From b60248b5e8e7c8d3c75da0a561f696828c2d36cd Mon Sep 17 00:00:00 2001 From: Justin Scott Date: Sun, 30 Aug 2026 13:05:33 -0400 Subject: [PATCH] feat(server): receive unconfirmed audit notifications --- crates/bacnet-objects/src/device/mod.rs | 1 + crates/bacnet-objects/src/device/tests.rs | 1 + .../bacnet-server/src/audit_notification.rs | 29 +- .../src/handlers/audit_notification.rs | 14 +- .../src/handlers/tests/read_rpm.rs | 5 +- .../src/server/audit_notification_tests.rs | 45 ++- crates/bacnet-server/src/server/mod.rs | 33 ++ .../src/server/requests/audit_notification.rs | 132 +++++--- .../src/server/requests/unconfirmed.rs | 17 +- crates/bacnet-server/src/server/sc_builder.rs | 9 + .../unconfirmed_audit_notification_tests.rs | 302 ++++++++++++++++++ docs/conformance/bacnet-135-2020.json | 9 +- docs/rust-api.md | 31 +- 13 files changed, 551 insertions(+), 77 deletions(-) create mode 100644 crates/bacnet-server/src/server/unconfirmed_audit_notification_tests.rs diff --git a/crates/bacnet-objects/src/device/mod.rs b/crates/bacnet-objects/src/device/mod.rs index dfb1036..d5cdd3a 100644 --- a/crates/bacnet-objects/src/device/mod.rs +++ b/crates/bacnet-objects/src/device/mod.rs @@ -59,6 +59,7 @@ pub const EXECUTED_SERVICES: &[ServiceSupported] = &[ ServiceSupported::GET_EVENT_INFORMATION, ServiceSupported::SUBSCRIBE_COV_PROPERTY_MULTIPLE, ServiceSupported::CONFIRMED_AUDIT_NOTIFICATION, + ServiceSupported::UNCONFIRMED_AUDIT_NOTIFICATION, ServiceSupported::AUDIT_LOG_QUERY, ]; diff --git a/crates/bacnet-objects/src/device/tests.rs b/crates/bacnet-objects/src/device/tests.rs index 88abf2e..e672b13 100644 --- a/crates/bacnet-objects/src/device/tests.rs +++ b/crates/bacnet-objects/src/device/tests.rs @@ -347,6 +347,7 @@ fn read_protocol_services_supported() { assert!(ss.contains(ServiceSupported::WHO_IS)); assert!(ss.contains(ServiceSupported::READ_RANGE)); assert!(ss.contains(ServiceSupported::SUBSCRIBE_COV_PROPERTY_MULTIPLE)); + assert!(ss.contains(ServiceSupported::UNCONFIRMED_AUDIT_NOTIFICATION)); assert!(!ss.contains(ServiceSupported::WRITE_GROUP)); // …and initiate-only services are not declared as executed. assert!(!ss.contains(ServiceSupported::I_AM)); diff --git a/crates/bacnet-server/src/audit_notification.rs b/crates/bacnet-server/src/audit_notification.rs index 26a8935..57a8d2b 100644 --- a/crates/bacnet-server/src/audit_notification.rs +++ b/crates/bacnet-server/src/audit_notification.rs @@ -1,4 +1,4 @@ -//! Receiver policy and bounded duplicate detection for ConfirmedAuditNotification. +//! Receiver policy for confirmed and unconfirmed AuditNotification services. use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -10,7 +10,7 @@ use bacnet_types::primitives::ObjectIdentifier; use bacnet_types::MacAddr; use bytes::Bytes; -/// Local maximum accepted ConfirmedAuditNotification service payload. +/// Local maximum accepted AuditNotification service payload. pub const MAX_AUDIT_NOTIFICATION_BYTES: usize = 64 * 1024; /// Local maximum number of notifications in one accepted request. pub const MAX_AUDIT_NOTIFICATIONS: usize = 256; @@ -42,6 +42,31 @@ pub struct AuditNotificationAuthorizationContext { pub type AuditNotificationAuthorizer = Arc bool + Send + Sync>; +/// Transport provenance and decoded content supplied to the unconfirmed Audit +/// authorizer. +/// +/// The source/target identities inside `request` are peer-reported audit data, +/// not authenticated transport provenance. Policy should use `source_network` +/// for a usable routed origin and `source_mac` for the immediate data-link peer. +#[derive(Debug, Clone, PartialEq)] +pub struct UnconfirmedAuditNotificationAuthorizationContext { + /// Immediate data-link peer (normally a router for routed traffic). + pub source_mac: MacAddr, + /// Originating NPDU source when one was present. + pub source_network: Option, + /// Explicitly configured local Audit Log sink. + pub audit_log_sink: ObjectIdentifier, + /// Decoded peer-reported notification list, preserved verbatim. + pub request: AuditNotificationRequest, +} + +/// Fast, nonblocking authorization callback for UnconfirmedAuditNotification. +/// +/// Absence, `false`, or a panic silently denies the request before storage +/// mutation. +pub type UnconfirmedAuditNotificationAuthorizer = + Arc bool + Send + Sync>; + #[derive(Clone, Debug, PartialEq, Eq)] enum CanonicalPeer { Direct(MacAddr), diff --git a/crates/bacnet-server/src/handlers/audit_notification.rs b/crates/bacnet-server/src/handlers/audit_notification.rs index 75b51b7..52b817a 100644 --- a/crates/bacnet-server/src/handlers/audit_notification.rs +++ b/crates/bacnet-server/src/handlers/audit_notification.rs @@ -9,7 +9,7 @@ use bacnet_types::primitives::{ObjectIdentifier, PropertyValue}; /// Persistence is synchronous under the database writer in this bounded /// receiver foundation. That limits availability to the configured backend's /// commit latency, but keeps the durable commit and memory apply atomic. -pub fn handle_confirmed_audit_notification( +pub fn handle_audit_notification( db: &mut ObjectDatabase, sink: ObjectIdentifier, request: &AuditNotificationRequest, @@ -36,6 +36,18 @@ pub fn handle_confirmed_audit_notification( storage.store_notifications(&request.notifications, apdu_timeout_ms) } +/// Store one decoded and authorized confirmed notification batch. +/// +/// Retained as a compatibility alias for the original confirmed-only receiver +/// API; both inbound AuditNotification forms use the same atomic storage owner. +pub fn handle_confirmed_audit_notification( + db: &mut ObjectDatabase, + sink: ObjectIdentifier, + request: &AuditNotificationRequest, +) -> Result<(), Error> { + handle_audit_notification(db, sink, request) +} + fn configured_apdu_timeout(db: &ObjectDatabase) -> Result { let devices: Vec<_> = db .list_objects() diff --git a/crates/bacnet-server/src/handlers/tests/read_rpm.rs b/crates/bacnet-server/src/handlers/tests/read_rpm.rs index b55cc20..c0a094a 100644 --- a/crates/bacnet-server/src/handlers/tests/read_rpm.rs +++ b/crates/bacnet-server/src/handlers/tests/read_rpm.rs @@ -582,7 +582,7 @@ fn read_property_serves_derived_services_supported() { // End-to-end pin for #192: the wire-level BitString a client receives for // Protocol_Services_Supported, through the real ReadProperty handler path. // Expected bytes derive from device::EXECUTED_SERVICES bits - // {0,3-12,14-17,19,20,31-39,41,42,44} packed MSB-first over the full + // {0,3-12,14-17,19,20,31-39,41,44-46} packed MSB-first over the full // production range (49 defined bits, 7 octets, 7 unused). let db = make_db_with_device_and_ai(); let oid = ObjectIdentifier::new(ObjectType::DEVICE, 1).unwrap(); @@ -605,7 +605,7 @@ fn read_property_serves_derived_services_supported() { val, bacnet_types::primitives::PropertyValue::BitString { unused_bits: 7, - data: vec![0x9F, 0xFB, 0xD8, 0x01, 0xFF, 0x4C, 0x00], + data: vec![0x9F, 0xFB, 0xD8, 0x01, 0xFF, 0x4E, 0x00], } ); @@ -616,6 +616,7 @@ fn read_property_serves_derived_services_supported() { let ss = bacnet_types::bitstring::ServicesSupported::from_bacnet(&data); assert!(ss.contains(bacnet_types::enums::ServiceSupported::WHO_IS)); assert!(ss.contains(bacnet_types::enums::ServiceSupported::CONFIRMED_AUDIT_NOTIFICATION)); + assert!(ss.contains(bacnet_types::enums::ServiceSupported::UNCONFIRMED_AUDIT_NOTIFICATION)); assert!(ss.contains(bacnet_types::enums::ServiceSupported::AUDIT_LOG_QUERY)); assert!(!ss.contains(bacnet_types::enums::ServiceSupported::I_AM)); } diff --git a/crates/bacnet-server/src/server/audit_notification_tests.rs b/crates/bacnet-server/src/server/audit_notification_tests.rs index b3823e2..bad91aa 100644 --- a/crates/bacnet-server/src/server/audit_notification_tests.rs +++ b/crates/bacnet-server/src/server/audit_notification_tests.rs @@ -14,9 +14,9 @@ use bacnet_types::primitives::{BACnetTimeStamp, Date, ObjectIdentifier, Property use super::*; #[derive(Default)] -struct MemoryPersistence { - snapshot: StdMutex>, - fail: AtomicBool, +pub(super) struct MemoryPersistence { + pub(super) snapshot: StdMutex>, + pub(super) fail: AtomicBool, } impl AuditLogPersistence for MemoryPersistence { @@ -58,11 +58,11 @@ impl ClockReader for FixedClock { } } -fn oid(object_type: ObjectType, instance: u32) -> ObjectIdentifier { +pub(super) fn oid(object_type: ObjectType, instance: u32) -> ObjectIdentifier { ObjectIdentifier::new(object_type, instance).unwrap() } -fn notification(operation: AuditOperation) -> BACnetAuditNotification { +pub(super) fn notification(operation: AuditOperation) -> BACnetAuditNotification { BACnetAuditNotification { source_timestamp: Some(BACnetTimeStamp::Time(Time { hour: 12, @@ -91,7 +91,7 @@ fn notification(operation: AuditOperation) -> BACnetAuditNotification { } } -fn request_bytes(notifications: Vec) -> Bytes { +pub(super) fn request_bytes(notifications: Vec) -> Bytes { let mut bytes = BytesMut::new(); AuditNotificationRequest { notifications } .try_encode(&mut bytes) @@ -99,14 +99,14 @@ fn request_bytes(notifications: Vec) -> Bytes { bytes.freeze() } -fn database( +pub(super) fn database( persistence: Arc, sink_instance: u32, ) -> Arc> { database_with_device(persistence, sink_instance, Some(DeviceConfig::default())) } -fn database_with_device( +pub(super) fn database_with_device( persistence: Arc, sink_instance: u32, device_config: Option, @@ -182,7 +182,7 @@ async fn dispatch( .map(|bytes| decode_apdu(decode_npdu(bytes).unwrap().payload).unwrap()) } -async fn count(db: &Arc>, sink: ObjectIdentifier) -> (u64, u64) { +pub(super) async fn count(db: &Arc>, sink: ObjectIdentifier) -> (u64, u64) { let db = db.read().await; let object = db.get(&sink).unwrap(); let PropertyValue::Unsigned(records) = object @@ -497,14 +497,14 @@ async fn missing_or_invalid_device_apdu_timeout_is_operational_problem_without_m } #[test] -fn executed_service_truth_is_confirmed_only() { +fn executed_service_truth_includes_confirmed_and_unconfirmed_receipt() { assert!(EXECUTED_CONFIRMED.contains(&ConfirmedServiceChoice::CONFIRMED_AUDIT_NOTIFICATION)); assert!( - !EXECUTED_UNCONFIRMED.contains(&UnconfirmedServiceChoice::UNCONFIRMED_AUDIT_NOTIFICATION) + EXECUTED_UNCONFIRMED.contains(&UnconfirmedServiceChoice::UNCONFIRMED_AUDIT_NOTIFICATION) ); assert!(bacnet_objects::device::EXECUTED_SERVICES .contains(&ServiceSupported::CONFIRMED_AUDIT_NOTIFICATION)); - assert!(!bacnet_objects::device::EXECUTED_SERVICES + assert!(bacnet_objects::device::EXECUTED_SERVICES .contains(&ServiceSupported::UNCONFIRMED_AUDIT_NOTIFICATION)); } @@ -513,15 +513,25 @@ fn generic_and_bip_builders_store_the_same_explicit_receiver_policy() { let sink = oid(ObjectType::AUDIT_LOG, 7); let generic = BACnetServer::::generic_builder() .audit_notification_sink(sink) - .audit_notification_authorizer(|_| true); + .audit_notification_authorizer(|_| true) + .unconfirmed_audit_notification_authorizer(|_| true); assert_eq!(generic.config.audit_notification_sink, Some(sink)); assert!(generic.config.audit_notification_authorizer.is_some()); + assert!(generic + .config + .unconfirmed_audit_notification_authorizer + .is_some()); let bip = BACnetServer::::bip_builder() .audit_notification_sink(sink) - .audit_notification_authorizer(|_| true); + .audit_notification_authorizer(|_| true) + .unconfirmed_audit_notification_authorizer(|_| true); assert_eq!(bip.config.audit_notification_sink, Some(sink)); assert!(bip.config.audit_notification_authorizer.is_some()); + assert!(bip + .config + .unconfirmed_audit_notification_authorizer + .is_some()); } #[cfg(feature = "sc-tls")] @@ -532,7 +542,12 @@ fn sc_builder_exposes_the_same_explicit_receiver_policy() { bacnet_transport::sc::ScTransport, >::sc_builder() .audit_notification_sink(sink) - .audit_notification_authorizer(|_| true); + .audit_notification_authorizer(|_| true) + .unconfirmed_audit_notification_authorizer(|_| true); assert_eq!(sc.config.audit_notification_sink, Some(sink)); assert!(sc.config.audit_notification_authorizer.is_some()); + assert!(sc + .config + .unconfirmed_audit_notification_authorizer + .is_some()); } diff --git a/crates/bacnet-server/src/server/mod.rs b/crates/bacnet-server/src/server/mod.rs index 65c9096..e411664 100644 --- a/crates/bacnet-server/src/server/mod.rs +++ b/crates/bacnet-server/src/server/mod.rs @@ -52,6 +52,7 @@ use bacnet_types::MacAddr; use crate::audit_notification::{ AuditNotificationAuthorizationContext, AuditNotificationAuthorizer, DuplicateAdmission, + UnconfirmedAuditNotificationAuthorizationContext, UnconfirmedAuditNotificationAuthorizer, MAX_AUDIT_NOTIFICATIONS, MAX_AUDIT_NOTIFICATION_BYTES, }; use crate::cov::{CovNotificationKind, CovSubscription, CovSubscriptionTable}; @@ -306,6 +307,10 @@ pub struct ServerConfig { /// /// Absence, `false`, or a panic denies the request before mutation. pub audit_notification_authorizer: Option, + /// Optional fast, nonblocking UnconfirmedAuditNotification authorizer. + /// + /// Absence, `false`, or a panic silently denies the request before mutation. + pub unconfirmed_audit_notification_authorizer: Option, /// Optional password required for DeviceCommunicationControl. pub dcc_password: Option, /// Optional password required for ReinitializeDevice. @@ -385,6 +390,13 @@ impl std::fmt::Debug for ServerConfig { .as_ref() .map(|_| ""), ) + .field( + "unconfirmed_audit_notification_authorizer", + &self + .unconfirmed_audit_notification_authorizer + .as_ref() + .map(|_| ""), + ) .field("dcc_password", &self.dcc_password.as_ref().map(|_| "***")) .field( "reinit_password", @@ -414,6 +426,7 @@ impl Default for ServerConfig { life_safety_operation_authorizer: None, audit_notification_sink: None, audit_notification_authorizer: None, + unconfirmed_audit_notification_authorizer: None, dcc_password: None, reinit_password: None, enable_fault_detection: false, @@ -486,6 +499,15 @@ impl ServerBuilder { self } + /// Set the fail-closed UnconfirmedAuditNotification authorization policy. + pub fn unconfirmed_audit_notification_authorizer(mut self, authorizer: F) -> Self + where + F: Fn(&UnconfirmedAuditNotificationAuthorizationContext) -> bool + Send + Sync + 'static, + { + self.config.unconfirmed_audit_notification_authorizer = Some(Arc::new(authorizer)); + self + } + /// Enable periodic fault detection / reliability evaluation. /// /// Reliability evaluation only; Event Enrollment evaluation is configured @@ -615,6 +637,15 @@ impl BipServerBuilder { self } + /// Set the fail-closed UnconfirmedAuditNotification authorization policy. + pub fn unconfirmed_audit_notification_authorizer(mut self, authorizer: F) -> Self + where + F: Fn(&UnconfirmedAuditNotificationAuthorizationContext) -> bool + Send + Sync + 'static, + { + self.config.unconfirmed_audit_notification_authorizer = Some(Arc::new(authorizer)); + self + } + /// Enable periodic fault detection / reliability evaluation. /// /// Reliability evaluation only; Event Enrollment evaluation is configured @@ -926,6 +957,8 @@ mod sc_builder; pub(crate) use requests::{EXECUTED_CONFIRMED, EXECUTED_UNCONFIRMED}; #[cfg(test)] mod audit_notification_tests; +#[cfg(test)] +mod unconfirmed_audit_notification_tests; #[cfg(feature = "sc-tls")] pub use sc_builder::ScServerBuilder; mod responses; diff --git a/crates/bacnet-server/src/server/requests/audit_notification.rs b/crates/bacnet-server/src/server/requests/audit_notification.rs index ae788a7..d3a25a0 100644 --- a/crates/bacnet-server/src/server/requests/audit_notification.rs +++ b/crates/bacnet-server/src/server/requests/audit_notification.rs @@ -13,10 +13,8 @@ pub(super) async fn receive_confirmed_audit_notification( invoke_id: u8, service_request: &Bytes, ) -> Option> { - if service_request.len() > MAX_AUDIT_NOTIFICATION_BYTES { - return Some(Err(Error::OutOfRange(format!( - "ConfirmedAuditNotification payload exceeds {MAX_AUDIT_NOTIFICATION_BYTES} bytes" - )))); + if let Err(error) = validate_payload_size("ConfirmedAuditNotification", service_request) { + return Some(Err(error)); } let pending = match notification_transactions @@ -30,47 +28,101 @@ pub(super) async fn receive_confirmed_audit_notification( DuplicateAdmission::Duplicate => return None, DuplicateAdmission::New(pending) => pending, }; - let decoded = bacnet_services::audit::AuditNotificationRequest::decode(service_request); - let execution = match decoded { - Err(error) => Err(error), - Ok(request) if request.notifications.len() > MAX_AUDIT_NOTIFICATIONS => { - Err(Error::OutOfRange(format!( - "ConfirmedAuditNotification list exceeds {MAX_AUDIT_NOTIFICATIONS} items" - ))) - } - Ok(request) => match config.audit_notification_sink { - None => Err(request_denied()), - Some(sink) => { - let context = AuditNotificationAuthorizationContext { - source_mac: MacAddr::from_slice(source_mac), - source_network: source_network.cloned(), - invoke_id, - audit_log_sink: sink, - request: request.clone(), - }; - let authorized = - config - .audit_notification_authorizer - .as_ref() - .is_some_and(|authorizer| { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - authorizer(&context) - })) - .unwrap_or(false) - }); - if !authorized { - Err(request_denied()) - } else { - let mut db = db.write().await; - handlers::handle_confirmed_audit_notification(&mut db, sink, &request) - } - } + let execution = decode_authorize_and_store( + db, + config, + "ConfirmedAuditNotification", + service_request, + |sink, request| { + let context = AuditNotificationAuthorizationContext { + source_mac: MacAddr::from_slice(source_mac), + source_network: source_network.cloned(), + invoke_id, + audit_log_sink: sink, + request: request.clone(), + }; + config + .audit_notification_authorizer + .as_ref() + .is_some_and(|authorizer| fail_closed_authorize(|| authorizer(&context))) }, - }; + ) + .await; pending.complete(); Some(execution) } +/// Decode, authorize, and durably store one unconfirmed Audit request. +/// +/// The caller intentionally discards the result because an unconfirmed service +/// never emits a response APDU. +pub(super) async fn receive_unconfirmed_audit_notification( + db: &Arc>, + config: &ServerConfig, + source_mac: &[u8], + source_network: Option<&NpduAddress>, + service_request: &Bytes, +) -> Result<(), Error> { + validate_payload_size("UnconfirmedAuditNotification", service_request)?; + decode_authorize_and_store( + db, + config, + "UnconfirmedAuditNotification", + service_request, + |sink, request| { + let context = UnconfirmedAuditNotificationAuthorizationContext { + source_mac: MacAddr::from_slice(source_mac), + source_network: source_network.cloned(), + audit_log_sink: sink, + request: request.clone(), + }; + config + .unconfirmed_audit_notification_authorizer + .as_ref() + .is_some_and(|authorizer| fail_closed_authorize(|| authorizer(&context))) + }, + ) + .await +} + +fn validate_payload_size(service: &str, service_request: &Bytes) -> Result<(), Error> { + if service_request.len() > MAX_AUDIT_NOTIFICATION_BYTES { + return Err(Error::OutOfRange(format!( + "{service} payload exceeds {MAX_AUDIT_NOTIFICATION_BYTES} bytes" + ))); + } + Ok(()) +} + +async fn decode_authorize_and_store( + db: &Arc>, + config: &ServerConfig, + service: &str, + service_request: &Bytes, + authorize: F, +) -> Result<(), Error> +where + F: FnOnce(ObjectIdentifier, &bacnet_services::audit::AuditNotificationRequest) -> bool, +{ + let request = bacnet_services::audit::AuditNotificationRequest::decode(service_request)?; + if request.notifications.len() > MAX_AUDIT_NOTIFICATIONS { + return Err(Error::OutOfRange(format!( + "{service} list exceeds {MAX_AUDIT_NOTIFICATIONS} items" + ))); + } + let sink = config.audit_notification_sink.ok_or_else(request_denied)?; + if !authorize(sink, &request) { + return Err(request_denied()); + } + + let mut db = db.write().await; + handlers::handle_audit_notification(&mut db, sink, &request) +} + +fn fail_closed_authorize(authorize: impl FnOnce() -> bool) -> bool { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(authorize)).unwrap_or(false) +} + fn request_denied() -> Error { Error::Protocol { class: ErrorClass::SERVICES.to_raw() as u32, diff --git a/crates/bacnet-server/src/server/requests/unconfirmed.rs b/crates/bacnet-server/src/server/requests/unconfirmed.rs index c50f6bd..b72c6c7 100644 --- a/crates/bacnet-server/src/server/requests/unconfirmed.rs +++ b/crates/bacnet-server/src/server/requests/unconfirmed.rs @@ -1,5 +1,5 @@ -//! Unconfirmed-service dispatch (Who-Is, Who-Has, time sync, and -//! UnconfirmedTextMessage) — see `EXECUTED_UNCONFIRMED`. +//! Unconfirmed-service dispatch (Who-Is, Who-Has, time sync, text, and Audit +//! notification receipt) — see `EXECUTED_UNCONFIRMED`. //! //! Split out of `requests.rs` to keep every file under the 700-LOC cap. @@ -17,6 +17,7 @@ pub(crate) const EXECUTED_UNCONFIRMED: &[UnconfirmedServiceChoice] = &[ UnconfirmedServiceChoice::TIME_SYNCHRONIZATION, UnconfirmedServiceChoice::UTC_TIME_SYNCHRONIZATION, UnconfirmedServiceChoice::UNCONFIRMED_TEXT_MESSAGE, + UnconfirmedServiceChoice::UNCONFIRMED_AUDIT_NOTIFICATION, ]; impl BACnetServer { @@ -190,6 +191,18 @@ impl BACnetServer { debug!(error = %e, "UnconfirmedTextMessage decode failed"); } } + } else if req.service_choice == UnconfirmedServiceChoice::UNCONFIRMED_AUDIT_NOTIFICATION { + if let Err(error) = super::audit_notification::receive_unconfirmed_audit_notification( + db, + config, + &received.source_mac, + received.source_network.as_ref(), + &req.service_request, + ) + .await + { + debug!(%error, "Ignoring UnconfirmedAuditNotification request"); + } } else { debug!( service = req.service_choice.to_raw(), diff --git a/crates/bacnet-server/src/server/sc_builder.rs b/crates/bacnet-server/src/server/sc_builder.rs index 85a0c91..af01e94 100644 --- a/crates/bacnet-server/src/server/sc_builder.rs +++ b/crates/bacnet-server/src/server/sc_builder.rs @@ -126,6 +126,15 @@ impl ScServerBuilder { self } + /// Set the fail-closed UnconfirmedAuditNotification authorization policy. + pub fn unconfirmed_audit_notification_authorizer(mut self, authorizer: F) -> Self + where + F: Fn(&UnconfirmedAuditNotificationAuthorizationContext) -> bool + Send + Sync + 'static, + { + self.config.unconfirmed_audit_notification_authorizer = Some(Arc::new(authorizer)); + self + } + /// Enable periodic fault detection / reliability evaluation. pub fn enable_fault_detection(mut self, enabled: bool) -> Self { self.config.enable_fault_detection = enabled; diff --git a/crates/bacnet-server/src/server/unconfirmed_audit_notification_tests.rs b/crates/bacnet-server/src/server/unconfirmed_audit_notification_tests.rs new file mode 100644 index 0000000..fdac63c --- /dev/null +++ b/crates/bacnet-server/src/server/unconfirmed_audit_notification_tests.rs @@ -0,0 +1,302 @@ +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; + +use bacnet_objects::device::DeviceConfig; +use bacnet_transport::port::{ReceivedNpdu, TransportPort}; +use bacnet_types::enums::AuditOperation; +use tokio::sync::mpsc; + +use super::audit_notification_tests::{ + count, database, database_with_device, notification, oid, request_bytes, MemoryPersistence, +}; +use super::*; + +#[derive(Clone)] +struct CountingTransport { + sends: Arc, +} + +impl TransportPort for CountingTransport { + async fn start(&mut self) -> Result, Error> { + let (_tx, rx) = mpsc::channel(1); + Ok(rx) + } + + async fn stop(&mut self) -> Result<(), Error> { + Ok(()) + } + + async fn send_unicast(&self, _npdu: &[u8], _mac: &[u8]) -> Result<(), Error> { + self.sends.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn send_broadcast(&self, _npdu: &[u8]) -> Result<(), Error> { + self.sends.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn local_mac(&self) -> &[u8] { + &[0] + } +} + +fn received( + source_mac: &[u8], + source_network: Option, +) -> bacnet_network::layer::ReceivedApdu { + bacnet_network::layer::ReceivedApdu { + apdu: Bytes::new(), + source_mac: MacAddr::from_slice(source_mac), + source_network, + link_layer_group: false, + is_group: false, + data_attributes: Vec::new(), + reply_tx: None, + } +} + +async fn dispatch_unconfirmed( + db: &Arc>, + config: &ServerConfig, + comm_state: &Arc, + source_mac: &[u8], + source_network: Option, + service_request: Bytes, + sends: Arc, +) { + let network = Arc::new(NetworkLayer::new(CountingTransport { sends })); + let bindings = Arc::new(RwLock::new(DeviceBindingTable::new())); + BACnetServer::::handle_unconfirmed_request( + db, + &network, + config, + None, + comm_state, + &bindings, + UnconfirmedRequestPdu { + service_choice: UnconfirmedServiceChoice::UNCONFIRMED_AUDIT_NOTIFICATION, + service_request, + }, + &received(source_mac, source_network), + ) + .await; +} + +async fn assert_silent_drop( + db: &Arc>, + sink: ObjectIdentifier, + config: &ServerConfig, + service_request: Bytes, + comm_state: u8, +) { + let before = count(db, sink).await; + let sends = Arc::new(AtomicUsize::new(0)); + dispatch_unconfirmed( + db, + config, + &Arc::new(AtomicU8::new(comm_state)), + &[1], + None, + service_request, + Arc::clone(&sends), + ) + .await; + assert_eq!(count(db, sink).await, before); + assert_eq!(sends.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn accepted_direct_and_routed_requests_commit_atomically_without_output() { + let persistence = Arc::new(MemoryPersistence::default()); + let sink = oid(ObjectType::AUDIT_LOG, 7); + let db = database(Arc::clone(&persistence), 7); + let contexts = Arc::new(StdMutex::new(Vec::new())); + let observed = Arc::clone(&contexts); + let config = ServerConfig { + audit_notification_sink: Some(sink), + unconfirmed_audit_notification_authorizer: Some(Arc::new(move |context| { + observed.lock().unwrap().push(context.clone()); + true + })), + ..ServerConfig::default() + }; + let routed = NpduAddress { + network: 55, + mac_address: MacAddr::from_slice(&[0xaa]), + }; + let sends = Arc::new(AtomicUsize::new(0)); + let comm_state = Arc::new(AtomicU8::new(0)); + + let source = notification(AuditOperation::WRITE); + let mut target = source.clone(); + target.source_timestamp = None; + target.target_timestamp = source.source_timestamp.clone(); + dispatch_unconfirmed( + &db, + &config, + &comm_state, + &[0x10], + Some(routed.clone()), + request_bytes(vec![source, target]), + Arc::clone(&sends), + ) + .await; + assert_eq!(count(&db, sink).await, (1, 1)); + assert_eq!( + persistence + .snapshot + .lock() + .unwrap() + .as_ref() + .unwrap() + .records + .len(), + 1 + ); + + dispatch_unconfirmed( + &db, + &config, + &comm_state, + &[0x20], + None, + request_bytes(vec![notification(AuditOperation::READ)]), + Arc::clone(&sends), + ) + .await; + assert_eq!(count(&db, sink).await, (2, 2)); + assert_eq!(sends.load(Ordering::SeqCst), 0); + + let contexts = contexts.lock().unwrap(); + assert_eq!(contexts[0].source_mac, MacAddr::from_slice(&[0x10])); + assert_eq!(contexts[0].source_network, Some(routed)); + assert_eq!(contexts[0].audit_log_sink, sink); + assert_eq!(contexts[1].source_mac, MacAddr::from_slice(&[0x20])); + assert_eq!(contexts[1].source_network, None); +} + +#[tokio::test] +async fn every_precommit_failure_is_silent_and_nonmutating() { + let sink = oid(ObjectType::AUDIT_LOG, 7); + let valid = request_bytes(vec![notification(AuditOperation::WRITE)]); + + for authorizer in [ + None, + Some( + Arc::new(|_: &UnconfirmedAuditNotificationAuthorizationContext| false) + as UnconfirmedAuditNotificationAuthorizer, + ), + Some(Arc::new( + |_: &UnconfirmedAuditNotificationAuthorizationContext| -> bool { panic!("denied") }, + ) as UnconfirmedAuditNotificationAuthorizer), + ] { + let db = database(Arc::new(MemoryPersistence::default()), 7); + let config = ServerConfig { + audit_notification_sink: Some(sink), + unconfirmed_audit_notification_authorizer: authorizer, + ..ServerConfig::default() + }; + assert_silent_drop(&db, sink, &config, valid.clone(), 0).await; + } + + let authorized = ServerConfig { + audit_notification_sink: Some(sink), + unconfirmed_audit_notification_authorizer: Some(Arc::new(|_| true)), + ..ServerConfig::default() + }; + let db = database(Arc::new(MemoryPersistence::default()), 7); + let mut trailing = BytesMut::from(valid.as_ref()); + trailing.extend_from_slice(&[0xff]); + for malformed in [ + trailing.freeze(), + Bytes::from(vec![0; MAX_AUDIT_NOTIFICATION_BYTES + 1]), + ] { + assert_silent_drop(&db, sink, &authorized, malformed, 0).await; + } + let too_many = request_bytes( + (0..=MAX_AUDIT_NOTIFICATIONS) + .map(|_| notification(AuditOperation::WRITE)) + .collect(), + ); + assert!(too_many.len() <= MAX_AUDIT_NOTIFICATION_BYTES); + assert_silent_drop(&db, sink, &authorized, too_many, 0).await; + + for configured_sink in [ + None, + Some(oid(ObjectType::ANALOG_INPUT, 7)), + Some(oid(ObjectType::AUDIT_LOG, 99)), + ] { + let config = ServerConfig { + audit_notification_sink: configured_sink, + unconfirmed_audit_notification_authorizer: Some(Arc::new(|_| true)), + ..ServerConfig::default() + }; + assert_silent_drop(&db, sink, &config, valid.clone(), 0).await; + } + + let disabled = database(Arc::new(MemoryPersistence::default()), 7); + disabled + .write() + .await + .get_mut(&sink) + .unwrap() + .write_property( + PropertyIdentifier::LOG_ENABLE, + None, + PropertyValue::Boolean(false), + None, + ) + .unwrap(); + assert_silent_drop(&disabled, sink, &authorized, valid.clone(), 0).await; + + for device in [ + None, + Some(DeviceConfig { + apdu_timeout: 0, + ..DeviceConfig::default() + }), + ] { + let db = database_with_device(Arc::new(MemoryPersistence::default()), 7, device); + assert_silent_drop(&db, sink, &authorized, valid.clone(), 0).await; + } + + let persistence = Arc::new(MemoryPersistence::default()); + let db = database(Arc::clone(&persistence), 7); + persistence.fail.store(true, Ordering::Release); + assert_silent_drop(&db, sink, &authorized, valid.clone(), 0).await; + assert!(persistence + .snapshot + .lock() + .unwrap() + .as_ref() + .unwrap() + .records + .is_empty()); + + let persistence = Arc::new(MemoryPersistence::default()); + let db = database(Arc::clone(&persistence), 7); + db.write().await.set_clock_reader(None); + assert_silent_drop(&db, sink, &authorized, valid.clone(), 0).await; + assert!(persistence + .snapshot + .lock() + .unwrap() + .as_ref() + .unwrap() + .records + .is_empty()); + + let called = Arc::new(AtomicBool::new(false)); + let observed = Arc::clone(&called); + let config = ServerConfig { + audit_notification_sink: Some(sink), + unconfirmed_audit_notification_authorizer: Some(Arc::new(move |_| { + observed.store(true, Ordering::Release); + true + })), + ..ServerConfig::default() + }; + assert_silent_drop(&db, sink, &config, valid, 1).await; + assert!(!called.load(Ordering::Acquire)); +} diff --git a/docs/conformance/bacnet-135-2020.json b/docs/conformance/bacnet-135-2020.json index 0bc3725..9bca598 100644 --- a/docs/conformance/bacnet-135-2020.json +++ b/docs/conformance/bacnet-135-2020.json @@ -832,7 +832,7 @@ "id": "BACNET-13-AUDIT-WIRE-MODELS", "standard_anchor": "Clauses 13.19-13.21; Clause 21.2.1, Clause 21.2.3, Clause 21.3.1, BACnetAuditNotification, BACnetAuditLogQueryParameters, and BACnetAuditOperationFlags productions", "priority": "P1", - "requirement_summary": "AuditNotification request payloads carry one or more typed BACnetAuditNotification values under context tag 0, while AuditLogQuery requests carry a typed by-target or by-source query alternative. The bundled server executes queries over explicitly persisted retained records and receives ConfirmedAuditNotification only through one explicit sink plus fail-closed transport-provenance authorization, bounded duplicate detection, and atomic durable merge-or-create. Within the primitive layer's u64 Unsigned domain, the codecs preserve the Clause 21 field order, tags, numeric domains, Boolean encoding, recipient alternatives, standard operation-flag bits 0-15, and proprietary bits 32-63; reject reserved flag bits, trailing or malformed structure; bound allocation; and leave destination buffers unchanged when validation fails.", + "requirement_summary": "AuditNotification request payloads carry one or more typed BACnetAuditNotification values under context tag 0, while AuditLogQuery requests carry a typed by-target or by-source query alternative. The bundled server executes queries over explicitly persisted retained records and receives both ConfirmedAuditNotification and UnconfirmedAuditNotification through one explicit sink plus distinct fail-closed transport-provenance authorizers and one atomic durable merge-or-create path. Confirmed receipt retains bounded duplicate detection and ACK/Error replies; unconfirmed receipt has neither an invoke ID nor duplicate tracking and emits no response on any outcome. Executed bit 46 reflects only this receipt path, not notification generation, forwarding, or AR-L-A support. Within the primitive layer's u64 Unsigned domain, the codecs preserve the Clause 21 field order, tags, numeric domains, Boolean encoding, recipient alternatives, standard operation-flag bits 0-15, and proprietary bits 32-63; reject reserved flag bits, trailing or malformed structure; bound allocation; and leave destination buffers unchanged when validation fails.", "status": "implementation-present-needs-source-review", "code_anchors": [ "crates/bacnet-types/src/bitstring.rs", @@ -847,6 +847,7 @@ "crates/bacnet-server/src/handlers/audit_notification.rs", "crates/bacnet-server/src/audit_notification.rs", "crates/bacnet-server/src/server/requests/audit_notification.rs", + "crates/bacnet-server/src/server/requests/unconfirmed.rs", "crates/bacnet-server/src/server/requests/mod.rs", "crates/bacnet-objects/src/device/mod.rs", "crates/rusty-bacnet/src/client/client_methods/vt_audit_time_directed.rs" @@ -870,7 +871,8 @@ "crates/bacnet-objects/src/audit/notification_tests.rs::complementary_batch_merges_once_and_target_current_value_wins", "crates/bacnet-objects/src/audit/notification_tests.rs::completed_match_is_dropped_and_merge_preserves_record_identity", "crates/bacnet-server/src/server/audit_notification_tests.rs::authorized_routed_request_preserves_provenance_and_duplicate_is_silent", - "crates/bacnet-server/src/server/audit_notification_tests.rs::executed_service_truth_is_confirmed_only", + "crates/bacnet-server/src/server/unconfirmed_audit_notification_tests.rs::accepted_direct_and_routed_requests_commit_atomically_without_output", + "crates/bacnet-server/src/server/audit_notification_tests.rs::executed_service_truth_includes_confirmed_and_unconfirmed_receipt", "crates/bacnet-server/src/pics/tests.rs::executed_services_match_dispatch_table" ], "negative_tests": [ @@ -892,7 +894,8 @@ "crates/bacnet-server/src/handlers/tests/audit_log_query.rs::audit_typed_object_without_capability_maps_to_optional_functionality", "crates/bacnet-server/src/handlers/tests/audit_log_query.rs::malformed_payload_fails_before_audit_storage_is_inspected", "crates/bacnet-objects/src/audit/notification_tests.rs::commit_and_clock_failures_leave_memory_and_durable_snapshot_unchanged", - "crates/bacnet-server/src/server/audit_notification_tests.rs::policy_decode_bounds_sink_and_persistence_fail_before_success_ack" + "crates/bacnet-server/src/server/audit_notification_tests.rs::policy_decode_bounds_sink_and_persistence_fail_before_success_ack", + "crates/bacnet-server/src/server/unconfirmed_audit_notification_tests.rs::every_precommit_failure_is_silent_and_nonmutating" ], "benchmarks": [], "public_claims": [], diff --git a/docs/rust-api.md b/docs/rust-api.md index 6c23970..ae70d61 100644 --- a/docs/rust-api.md +++ b/docs/rust-api.md @@ -843,18 +843,24 @@ let query_ack = AuditLogQueryAck::decode(&raw_ack)?; These remain generic-client examples. The bundled server executes AuditLogQuery against the retained in-memory snapshot of an explicitly backed `AuditLogObject`, returning newest-first typed records through the existing -ComplexACK segmentation path. ConfirmedAuditNotification receipt is available -only when the server is configured with exactly one `audit_notification_sink` -and a fast `audit_notification_authorizer`; missing, false, or panicking policy -fails closed. Accepted lists merge or create records atomically through the -sink's durable backend. Transport provenance is passed to policy separately -from the preserved, peer-reported payload. Duplicate detection is bounded and -process-local (60 seconds / 256 exact requests) and silently discards detected -retransmissions rather than replaying responses. Synchronous persistence under -the database writer is an intentional availability limitation. Unconfirmed -receipt, query authorization, sustained rate limiting, durable idempotency, -failures-only filtering, and a wrap-safe 64-bit continuation are not provided. -No Audit BIBB claim is implied. +ComplexACK segmentation path. ConfirmedAuditNotification and +UnconfirmedAuditNotification receipt are available only when the server is +configured with exactly one `audit_notification_sink` and the corresponding +fast `audit_notification_authorizer` or +`unconfirmed_audit_notification_authorizer`; missing, false, or panicking +policy fails closed. Each policy receives the immediate MAC, optional routed +NPDU source, configured sink, and decoded request separately from the +peer-reported payload; only the confirmed context has an invoke ID. Accepted +lists merge or create records atomically through the sink's durable backend. +Confirmed duplicate detection is bounded and process-local (60 seconds / 256 +exact requests) and silently discards detected retransmissions rather than +replaying responses. Unconfirmed receipt never emits a response and does not +use duplicate tracking. Synchronous persistence under the database writer is +an intentional availability limitation. Query authorization, sustained rate +limiting, durable idempotency, producer/report generation, forwarding, +multi-log routing policy, failures-only filtering, and a wrap-safe 64-bit +continuation are not provided. Executed-service bit 46 represents receipt only; +no Audit Reporting BIBB, including AR-L-A, is claimed. --- @@ -948,6 +954,7 @@ The server automatically dispatches: - WhoHas / IHave - TimeSynchronization, UTCTimeSynchronization - UnconfirmedTextMessage +- UnconfirmedAuditNotification (explicit sink and distinct fail-closed authorizer; no response or duplicate tracking) **Outgoing (server-initiated):** - COV notifications (confirmed and unconfirmed, with ServerTsm retry for confirmed)