bacnet_endpoint/device_writes.rs
1//! Pre-start execution profile and authority for the endpoint Device responder.
2
3use super::*;
4use bacnet_server::mutation::DeviceWriteAuthorizer;
5use bacnet_server::server::ReinitializeContext;
6use bacnet_types::enums::ServiceSupported;
7
8/// The services the session's server role, its bounded Device responder,
9/// executes on every session, as `BACnetServicesSupported` bits (Clause 21).
10///
11/// The responder is not the full `bacnet-server` service set: everything not
12/// here or in [`RESPONDER_OPT_IN_SERVICES`] is refused.
13pub const RESPONDER_SERVICES: &[ServiceSupported] = &[ServiceSupported::READ_PROPERTY];
14
15/// A service the session's server role executes only once an
16/// [`EndpointSession`] method enables it.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct ResponderOptIn {
19 /// The service, as its `BACnetServicesSupported` bit.
20 pub service: ServiceSupported,
21 /// The name of the [`EndpointSession`] method that enables it.
22 pub method: &'static str,
23}
24
25/// The services the server role executes only once enabled, with the method
26/// that enables each. The local Device and the session identity then
27/// advertise them too.
28pub const RESPONDER_OPT_IN_SERVICES: &[ResponderOptIn] = &[
29 ResponderOptIn {
30 service: ServiceSupported::WRITE_PROPERTY,
31 method: "with_device_writes",
32 },
33 ResponderOptIn {
34 service: ServiceSupported::REINITIALIZE_DEVICE,
35 method: "with_reinitialize",
36 },
37];
38
39impl<T: TransportPort + 'static> EndpointSession<T> {
40 /// Enables authorized writes to the local Device's `Description` and installed Audit recipient.
41 ///
42 /// The callback is mandatory and receives the existing redacted mutation
43 /// context. A refusal or panic denies the request before mutation. It must
44 /// be fast, nonblocking and side-effect-free; see [`DeviceWriteAuthorizer`].
45 /// Other objects/properties and WritePropertyMultiple remain unsupported.
46 /// An authorized NULL relinquishment succeeds without changing Description.
47 /// Recipient writes require the complete source runtime installed by source selection.
48 ///
49 /// Startup requires a server role and a concrete built-in local Device in
50 /// the attached database ([`ObjectDatabase::local_device`]: the lowest
51 /// instance when it holds several). Writes reach only that Device; any
52 /// other Device is out of scope. A composed identity must match it and
53 /// contain only the services the responder executes. Validation errors
54 /// precede configuration mutation and transport startup, allowing correction
55 /// and retry. The Device and identity then advertise exactly those services;
56 /// the default session remains ReadProperty-only.
57 ///
58 /// # Panics
59 /// Panics if startup has already consumed the session configuration.
60 pub fn with_device_writes(mut self, authorizer: DeviceWriteAuthorizer) -> Self {
61 self.assert_configurable();
62 self.device_write_authorizer = Some(authorizer);
63 self
64 }
65
66 /// Enables ReinitializeDevice, carried out by `handler` with the
67 /// requester's [`ReinitializeContext`] and the database write-locked.
68 ///
69 /// Startup requirements and the advertised services are those of
70 /// [`with_device_writes`](Self::with_device_writes). The handler follows
71 /// the rules on
72 /// [`ReinitializeHandler`](bacnet_server::server::ReinitializeHandler).
73 /// The SimpleACK goes out only after it returns, so register any restart
74 /// with [`ReinitializeContext::after_reply`] instead of restarting
75 /// inline: the hook runs once the egress has sent the SimpleACK, on the
76 /// same dispatch task, and never for a refusal. It runs synchronously
77 /// on the session's single dispatch task, so a slow or blocking handler
78 /// stalls the whole session: hand slow work such as backup files to a
79 /// task. Without a password set by
80 /// [`with_reinit_password`](Self::with_reinit_password) any peer reaches
81 /// it, and the Device-write authorizer doesn't cover this service, so
82 /// restrict sources through the context. Refuse with [`Error::protocol`];
83 /// a panic or an [`Error::reject`] is answered SERVICES / OTHER and the
84 /// session keeps serving.
85 ///
86 /// # Panics
87 /// Panics if startup has already consumed the session configuration.
88 pub fn with_reinitialize<F>(mut self, handler: F) -> Self
89 where
90 F: Fn(&ReinitializeContext, &mut ObjectDatabase) -> Result<(), Error>
91 + Send
92 + Sync
93 + 'static,
94 {
95 self.assert_configurable();
96 self.reinitialize = Some(Arc::new(handler));
97 self
98 }
99
100 /// Sets the password a ReinitializeDevice request must carry.
101 ///
102 /// It takes effect only with [`with_reinitialize`](Self::with_reinitialize):
103 /// startup refuses a password without a handler.
104 ///
105 /// # Panics
106 /// Panics if startup has already consumed the session configuration.
107 pub fn with_reinit_password(mut self, password: impl Into<String>) -> Self {
108 self.assert_configurable();
109 self.reinit_password = Some(password.into());
110 self
111 }
112
113 /// The services the responder executes with the capabilities enabled on
114 /// this session: [`RESPONDER_SERVICES`], then each enabled entry of
115 /// [`RESPONDER_OPT_IN_SERVICES`].
116 pub(crate) fn executed_services(&self) -> Vec<ServiceSupported> {
117 let enabled = |service: ServiceSupported| {
118 (service == ServiceSupported::WRITE_PROPERTY && self.device_write_authorizer.is_some())
119 || (service == ServiceSupported::REINITIALIZE_DEVICE && self.reinitialize.is_some())
120 };
121 let opt_in = RESPONDER_OPT_IN_SERVICES
122 .iter()
123 .map(|opt_in| opt_in.service)
124 .filter(|service| enabled(*service));
125 RESPONDER_SERVICES.iter().copied().chain(opt_in).collect()
126 }
127
128 pub(super) async fn validate_segmentation(&self) -> Result<(), Error> {
129 if self.identity.as_ref().is_some_and(|identity| {
130 identity.segmentation() != bacnet_types::enums::Segmentation::NONE
131 }) {
132 return Err(Error::Encoding(
133 "EndpointSession roles require segmentation NONE".into(),
134 ));
135 }
136 if let Some(database) = self.database.as_ref() {
137 let database = database.read().await;
138 if let Some(oid) = database.selected_device() {
139 let device = database
140 .get(&oid)
141 .expect("selected under same database guard");
142 let mode =
143 device.read_property(PropertyIdentifier::SEGMENTATION_SUPPORTED, None)?;
144 if mode
145 != PropertyValue::Enumerated(u32::from(
146 bacnet_types::enums::Segmentation::NONE.to_raw(),
147 ))
148 {
149 return Err(Error::Encoding(
150 "EndpointSession selected Device requires segmentation NONE".into(),
151 ));
152 }
153 }
154 }
155 Ok(())
156 }
157
158 pub(super) fn validate_device_execution(&mut self) -> Result<Option<ObjectIdentifier>, Error> {
159 let writes = self.device_write_authorizer.is_some();
160 let reinitialize = self.reinitialize.is_some();
161 if self.reinit_password.is_some() && !reinitialize {
162 return Err(Error::Encoding(
163 "a ReinitializeDevice password requires a ReinitializeDevice handler".into(),
164 ));
165 }
166 // An empty password could never match: a request's empty password
167 // decodes as none (#2094).
168 if self.reinit_password.as_deref() == Some("") {
169 return Err(Error::Encoding(
170 "reinit_password is empty; leave it unset for no password".into(),
171 ));
172 }
173 // Past the 20-character limit no request could carry it (#2060).
174 if self
175 .reinit_password
176 .as_deref()
177 .is_some_and(|password| !bacnet_services::device_mgmt::password_fits(password))
178 {
179 return Err(Error::Encoding(format!(
180 "reinit_password exceeds the {}-character limit",
181 bacnet_services::device_mgmt::MAX_PASSWORD_CHARS
182 )));
183 }
184 // Names the enabled capabilities that need the Device.
185 let (capability, subject) = match (writes, reinitialize) {
186 (true, true) => (
187 "Device writes and ReinitializeDevice",
188 "Device writes and ReinitializeDevice require",
189 ),
190 (true, false) => ("Device writes", "Device writes require"),
191 (false, _) => ("ReinitializeDevice", "ReinitializeDevice requires"),
192 };
193 if self.role == SessionRole::ClientOnly {
194 return if writes || reinitialize {
195 Err(Error::Encoding(format!("{subject} a server role")))
196 } else {
197 Ok(None)
198 };
199 }
200 let allowed = self.executed_services();
201 if self.identity.as_ref().is_some_and(|identity| {
202 !identity
203 .services()
204 .contains(&ServiceSupported::READ_PROPERTY)
205 || identity
206 .services()
207 .iter()
208 .any(|service| !allowed.contains(service))
209 }) {
210 return Err(Error::Encoding(
211 "Endpoint identity services do not match the responder's services".into(),
212 ));
213 }
214 if !writes && !reinitialize {
215 return Ok(None);
216 }
217 let db = self
218 .database
219 .as_mut()
220 .ok_or_else(|| Error::Encoding(format!("{subject} an attached local database")))?;
221 let db = Arc::get_mut(db)
222 .expect("database is unshared before startup")
223 .get_mut();
224 // The target is the selected Device, the one wildcard reads resolve
225 // to, and it must be concrete.
226 let oid = db
227 .local_device()
228 .identifier()
229 .ok_or_else(|| Error::Encoding(format!("{subject} a concrete local Device")))?;
230 if self
231 .identity
232 .as_ref()
233 .is_some_and(|identity| identity.device_oid() != oid)
234 {
235 return Err(Error::Encoding(format!(
236 "{capability} local Device does not match session identity"
237 )));
238 }
239 if !db
240 .get_mut(&oid)
241 .expect("Device exists")
242 .device_authority_internal()
243 .is_some_and(|device| device.object_identifier() == oid)
244 {
245 return Err(Error::Encoding(format!(
246 "{subject} the built-in Device authority"
247 )));
248 }
249 Ok(Some(oid))
250 }
251
252 // All fallible configuration validation (including source ownership) precedes
253 // this commit. No await/callback or second copy of Device state is involved.
254 pub(super) fn commit_device_profile(&mut self, target: Option<ObjectIdentifier>) {
255 let Some(oid) = target else { return };
256 let services = self.executed_services();
257 let db = Arc::get_mut(self.database.as_mut().expect("validated database"))
258 .expect("database is unshared before startup")
259 .get_mut();
260 db.get_mut(&oid)
261 .expect("validated Device")
262 .device_authority_internal()
263 .expect("validated Device authority")
264 .set_services_supported(&services);
265 if let Some(identity) = self.identity.take() {
266 self.identity = Some(identity.with_services(&services));
267 }
268 }
269}