Skip to main content

bacnet_objects/audit/
notification.rs

1use bacnet_types::primitives::{BACnetTimeStamp, Date, Time};
2
3use super::*;
4
5/// Mutable, object-owned receiver for one authorized Audit notification batch.
6///
7/// Implementations must commit the complete prospective state before exposing
8/// any in-memory change. The server uses this type-erased channel instead of
9/// downcasting an Audit Log object or taking ownership of its persistence.
10pub trait AuditLogNotificationSink: Send + Sync {
11    /// Whether this sink currently accepts Audit notifications.
12    fn notification_logging_enabled(&self) -> bool;
13
14    /// Merge or create every notification in wire order as one durable batch.
15    ///
16    /// `apdu_timeout_ms` is the local Device object's configured APDU_Timeout.
17    /// It supplies the Clause 12.64 complementary timestamp window.
18    fn store_notifications(
19        &mut self,
20        notifications: &[BACnetAuditNotification],
21        apdu_timeout_ms: u32,
22    ) -> Result<(), Error>;
23
24    /// Check the durable completed-confirmed ledger without mutating it.
25    fn has_completed_confirmed_receipt(
26        &self,
27        _key: &[u8],
28        _now_unix_millis: u64,
29    ) -> Result<bool, Error> {
30        Ok(false)
31    }
32
33    /// Atomically merge one confirmed batch and retain its completed receipt.
34    fn store_confirmed_notifications(
35        &mut self,
36        notifications: &[BACnetAuditNotification],
37        apdu_timeout_ms: u32,
38        _receipt: CompletedAuditReceipt,
39    ) -> Result<ConfirmedAuditNotificationOutcome, Error> {
40        self.store_notifications(notifications, apdu_timeout_ms)?;
41        Ok(ConfirmedAuditNotificationOutcome::Stored)
42    }
43}
44
45impl AuditLogObject {
46    fn store_notification_batch(
47        &mut self,
48        notifications: &[BACnetAuditNotification],
49        apdu_timeout_ms: u32,
50    ) -> Result<(), Error> {
51        if !self.log_enable {
52            return Err(Error::Protocol {
53                class: ErrorClass::SERVICES.to_raw() as u32,
54                code: ErrorCode::SERVICE_REQUEST_DENIED.to_raw() as u32,
55            });
56        }
57        if notifications.is_empty() {
58            return Err(Error::OutOfRange(
59                "Audit notification batch must not be empty".into(),
60            ));
61        }
62
63        let mut prospective = self.snapshot_for_next_generation()?;
64        let changed =
65            self.apply_notification_batch(&mut prospective, notifications, apdu_timeout_ms)?;
66        if changed {
67            self.commit_and_apply(prospective)?;
68        }
69        Ok(())
70    }
71
72    fn store_confirmed_notification_batch(
73        &mut self,
74        notifications: &[BACnetAuditNotification],
75        apdu_timeout_ms: u32,
76        receipt: CompletedAuditReceipt,
77    ) -> Result<ConfirmedAuditNotificationOutcome, Error> {
78        if !self.log_enable {
79            return Err(Error::Protocol {
80                class: ErrorClass::SERVICES.to_raw() as u32,
81                code: ErrorCode::SERVICE_REQUEST_DENIED.to_raw() as u32,
82            });
83        }
84        if notifications.is_empty() {
85            return Err(Error::OutOfRange(
86                "Audit notification batch must not be empty".into(),
87            ));
88        }
89        if receipt::contains(
90            &self.completed_receipts,
91            receipt.key(),
92            receipt.completed_at_unix_millis(),
93        )? {
94            return Ok(ConfirmedAuditNotificationOutcome::Duplicate);
95        }
96
97        let mut prospective = self.snapshot_for_next_generation()?;
98        self.apply_notification_batch(&mut prospective, notifications, apdu_timeout_ms)?;
99        let outcome = receipt::insert(&mut prospective.completed_receipts, receipt)?;
100        if outcome == ConfirmedAuditNotificationOutcome::Duplicate {
101            return Ok(outcome);
102        }
103        self.commit_and_apply(prospective)?;
104        Ok(outcome)
105    }
106
107    fn apply_notification_batch(
108        &self,
109        prospective: &mut AuditLogSnapshot,
110        notifications: &[BACnetAuditNotification],
111        apdu_timeout_ms: u32,
112    ) -> Result<bool, Error> {
113        let mut changed = false;
114        for notification in notifications {
115            if let Some(index) = matching_record_index(
116                &prospective.records,
117                notification,
118                u64::from(apdu_timeout_ms) * 2,
119            ) {
120                let BACnetAuditLogDatum::AuditNotification(stored) =
121                    &mut prospective.records[index].record.datum
122                else {
123                    unreachable!("matching_record_index only returns Audit notifications");
124                };
125                if stored.source_timestamp.is_some() && stored.target_timestamp.is_some() {
126                    continue;
127                }
128                merge_notification(stored, notification);
129                validate_record(&prospective.records[index].record)?;
130                changed = true;
131            } else {
132                let timestamp = self.valid_timestamp()?;
133                let record = BACnetAuditLogRecord {
134                    timestamp,
135                    datum: BACnetAuditLogDatum::AuditNotification(notification.clone()),
136                };
137                validate_record(&record)?;
138                append_record(prospective, record);
139                changed = true;
140            }
141        }
142        Ok(changed)
143    }
144}
145
146impl AuditLogNotificationSink for AuditLogObject {
147    fn notification_logging_enabled(&self) -> bool {
148        self.log_enable
149    }
150
151    fn store_notifications(
152        &mut self,
153        notifications: &[BACnetAuditNotification],
154        apdu_timeout_ms: u32,
155    ) -> Result<(), Error> {
156        self.store_notification_batch(notifications, apdu_timeout_ms)
157    }
158
159    fn has_completed_confirmed_receipt(
160        &self,
161        key: &[u8],
162        now_unix_millis: u64,
163    ) -> Result<bool, Error> {
164        receipt::contains(&self.completed_receipts, key, now_unix_millis)
165    }
166
167    fn store_confirmed_notifications(
168        &mut self,
169        notifications: &[BACnetAuditNotification],
170        apdu_timeout_ms: u32,
171        receipt: CompletedAuditReceipt,
172    ) -> Result<ConfirmedAuditNotificationOutcome, Error> {
173        self.store_confirmed_notification_batch(notifications, apdu_timeout_ms, receipt)
174    }
175}
176
177fn matching_record_index(
178    records: &[BACnetAuditLogRecordResult],
179    incoming: &BACnetAuditNotification,
180    window_ms: u64,
181) -> Option<usize> {
182    records.iter().rposition(|result| {
183        let BACnetAuditLogDatum::AuditNotification(stored) = &result.record.datum else {
184            return false;
185        };
186        notification_identity_matches(stored, incoming)
187            && complementary_timestamps_match(stored, incoming, window_ms)
188    })
189}
190
191fn notification_identity_matches(
192    stored: &BACnetAuditNotification,
193    incoming: &BACnetAuditNotification,
194) -> bool {
195    // Clause 12.64 names the exact equality coordinates. Its
196    // "operation-source" is the Clause 19.6 client actor represented by
197    // Source Device; Source Object is optional extra information. Likewise,
198    // Target Property is the BACnetPropertyReference parameter, distinct from
199    // the separately defined Target Object parameter. Source Object and Target
200    // Object are therefore merged when absent but are not match coordinates.
201    stored.source_device == incoming.source_device
202        && stored.operation == incoming.operation
203        && stored.invoke_id == incoming.invoke_id
204        && stored.target_device == incoming.target_device
205        && stored.target_property == incoming.target_property
206        && optional_agrees(&stored.source_user_id, &incoming.source_user_id)
207        && optional_agrees(&stored.source_user_role, &incoming.source_user_role)
208        && optional_agrees(&stored.target_value, &incoming.target_value)
209}
210
211fn optional_agrees<T: PartialEq>(left: &Option<T>, right: &Option<T>) -> bool {
212    match (left, right) {
213        (Some(left), Some(right)) => left == right,
214        _ => true,
215    }
216}
217
218fn complementary_timestamps_match(
219    stored: &BACnetAuditNotification,
220    incoming: &BACnetAuditNotification,
221    window_ms: u64,
222) -> bool {
223    match (
224        stored.source_timestamp.as_ref(),
225        incoming.target_timestamp.as_ref(),
226    ) {
227        (Some(left), Some(right)) if timestamps_within(left, right, window_ms) => true,
228        _ => matches!(
229            (
230                stored.target_timestamp.as_ref(),
231                incoming.source_timestamp.as_ref()
232            ),
233            (Some(left), Some(right)) if timestamps_within(left, right, window_ms)
234        ),
235    }
236}
237
238/// Compare like timestamp variants only. DateTime uses an absolute civil-time
239/// distance, Time uses the shortest circular time-of-day distance (so reports
240/// can straddle midnight), and SequenceNumber has no duration scale and must
241/// therefore be equal. Mixed variants do not match.
242fn timestamps_within(left: &BACnetTimeStamp, right: &BACnetTimeStamp, window_ms: u64) -> bool {
243    let window_centiseconds = window_ms / 10;
244    match (left, right) {
245        (BACnetTimeStamp::Time(left), BACnetTimeStamp::Time(right)) => {
246            let (Some(left), Some(right)) = (time_centiseconds(*left), time_centiseconds(*right))
247            else {
248                return false;
249            };
250            let distance = left.abs_diff(right);
251            distance.min(8_640_000 - distance) <= window_centiseconds
252        }
253        (
254            BACnetTimeStamp::DateTime {
255                date: left_date,
256                time: left_time,
257            },
258            BACnetTimeStamp::DateTime {
259                date: right_date,
260                time: right_time,
261            },
262        ) => {
263            let (Some(left), Some(right)) = (
264                datetime_centiseconds(*left_date, *left_time),
265                datetime_centiseconds(*right_date, *right_time),
266            ) else {
267                return false;
268            };
269            left.abs_diff(right) <= window_centiseconds
270        }
271        (BACnetTimeStamp::SequenceNumber(left), BACnetTimeStamp::SequenceNumber(right)) => {
272            left == right
273        }
274        _ => false,
275    }
276}
277
278fn time_centiseconds(time: Time) -> Option<u64> {
279    (time.hour <= 23 && time.minute <= 59 && time.second <= 59 && time.hundredths <= 99).then(
280        || {
281            (((u64::from(time.hour) * 60 + u64::from(time.minute)) * 60 + u64::from(time.second))
282                * 100)
283                + u64::from(time.hundredths)
284        },
285    )
286}
287
288fn datetime_centiseconds(date: Date, time: Time) -> Option<u64> {
289    let year = date.actual_year()?;
290    if !(1..=12).contains(&date.month) || !(1..=7).contains(&date.day_of_week) {
291        return None;
292    }
293    let max_day = days_in_month(year, date.month);
294    if date.day == 0 || date.day > max_day {
295        return None;
296    }
297    let days = days_from_civil(i64::from(year), i64::from(date.month), i64::from(date.day));
298    let expected_day_of_week = (days + 3).rem_euclid(7) as u8 + 1;
299    if date.day_of_week != expected_day_of_week {
300        return None;
301    }
302    let time = time_centiseconds(time)?;
303    let shifted_days = days.checked_add(719_468)?;
304    u64::try_from(shifted_days)
305        .ok()?
306        .checked_mul(8_640_000)?
307        .checked_add(time)
308}
309
310fn days_in_month(year: u16, month: u8) -> u8 {
311    match month {
312        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
313        4 | 6 | 9 | 11 => 30,
314        2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
315            29
316        }
317        2 => 28,
318        _ => 0,
319    }
320}
321
322fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
323    let year = year - i64::from(month <= 2);
324    let era = year.div_euclid(400);
325    let year_of_era = year - era * 400;
326    let month_prime = month + if month > 2 { -3 } else { 9 };
327    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
328    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
329    era * 146_097 + day_of_era - 719_468
330}
331
332fn merge_notification(stored: &mut BACnetAuditNotification, incoming: &BACnetAuditNotification) {
333    let incoming_is_target_report = incoming.target_timestamp.is_some();
334    fill_missing(&mut stored.source_timestamp, &incoming.source_timestamp);
335    fill_missing(&mut stored.target_timestamp, &incoming.target_timestamp);
336    fill_missing(&mut stored.source_object, &incoming.source_object);
337    fill_missing(&mut stored.source_comment, &incoming.source_comment);
338    fill_missing(&mut stored.target_comment, &incoming.target_comment);
339    fill_missing(&mut stored.source_user_id, &incoming.source_user_id);
340    fill_missing(&mut stored.source_user_role, &incoming.source_user_role);
341    fill_missing(&mut stored.target_object, &incoming.target_object);
342    fill_missing(&mut stored.target_property, &incoming.target_property);
343    fill_missing(&mut stored.target_priority, &incoming.target_priority);
344    fill_missing(&mut stored.target_value, &incoming.target_value);
345    fill_missing(&mut stored.result, &incoming.result);
346    if incoming_is_target_report && incoming.current_value.is_some() {
347        stored.current_value.clone_from(&incoming.current_value);
348    } else {
349        fill_missing(&mut stored.current_value, &incoming.current_value);
350    }
351}
352
353fn fill_missing<T: Clone>(stored: &mut Option<T>, incoming: &Option<T>) {
354    if stored.is_none() {
355        stored.clone_from(incoming);
356    }
357}