1use bacnet_types::primitives::{BACnetTimeStamp, Date, Time};
2
3use super::*;
4
5pub trait AuditLogNotificationSink: Send + Sync {
11 fn notification_logging_enabled(&self) -> bool;
13
14 fn store_notifications(
19 &mut self,
20 notifications: &[BACnetAuditNotification],
21 apdu_timeout_ms: u32,
22 ) -> Result<(), Error>;
23
24 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 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 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
238fn 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}