Skip to main content

bacnet_ops/safety/
mod.rs

1//! The write-safety core every front end shares (#964, #2068;
2//! `docs/design/tui.md` section 5, `docs/design/mcp.md` section 7):
3//! session levels, confirmation tiers, previews, the ledger, the audit log
4//! and the workspace lock, with the engine half of a write.
5//!
6//! - [`plan`]: a requested write turned into a preview: the value to send,
7//!   the pre-image, what Present_Value will do, and the tier.
8//! - [`ledger`]: every change still in effect, with its pre-image, saved to
9//!   the profile workspace after each change so it outlives a crash.
10//! - [`audit`]: the append-only `audit.jsonl`.
11//! - [`lock`]: the workspace's lock, so one process at a time changes the
12//!   ledger.
13//! - [`desk`]: the engine half: read the pre-images and plan, send a
14//!   confirmed preview through the ledger and the audit log, and revert.
15//!
16//! The TUI and the MCP server decide who may confirm what; [`desk`] checks
17//! the same rules again before it sends anything, and records every outcome
18//! even when the caller goes away under a write.
19
20pub mod audit;
21pub mod desk;
22pub mod file;
23pub mod ledger;
24pub mod lock;
25pub mod plan;
26mod send;
27
28use std::sync::OnceLock;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use bacnet_types::enums::{AbortReason, ErrorClass, ErrorCode, PropertyIdentifier};
32use bacnet_types::error::{Error, RemoteError};
33use bacnet_types::primitives::ObjectIdentifier;
34use serde::{Deserialize, Deserializer, Serialize, Serializer};
35
36use crate::class::Class;
37
38pub use send::{find, item_json};
39
40/// Default write session length, in minutes.
41pub const DEFAULT_MINUTES: u32 = 15;
42
43/// Longest write session, in minutes.
44pub const MAX_MINUTES: u32 = 480;
45
46/// Which front end made a change or a record: written into the ledger and
47/// the audit log, so one front end's view lists another's changes as
48/// theirs.
49///
50/// It is written as its name, a string; a name this release doesn't know
51/// (a later release's front end) loads as [`FrontEnd::Other`] and is
52/// written back as it was, so the entry survives a save unchanged.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub enum FrontEnd {
55    /// The terminal UI; what a ledger written before #2068 holds.
56    #[default]
57    Tui,
58    /// The MCP server.
59    Mcp,
60    /// A front end this release doesn't know, by the name it was written
61    /// with: its entries still load, and revert like any other.
62    Other(String),
63}
64
65impl FrontEnd {
66    /// Its name: `tui`, `mcp`, or the name another release wrote.
67    pub fn name(&self) -> &str {
68        match self {
69            Self::Tui => "tui",
70            Self::Mcp => "mcp",
71            Self::Other(name) => name,
72        }
73    }
74
75    /// True for the TUI, the default a ledger entry leaves out.
76    pub fn is_tui(&self) -> bool {
77        *self == Self::Tui
78    }
79}
80
81impl Serialize for FrontEnd {
82    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
83        serializer.serialize_str(self.name())
84    }
85}
86
87impl<'de> Deserialize<'de> for FrontEnd {
88    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
89        let name = String::deserialize(deserializer)?;
90        Ok(match name.as_str() {
91            "tui" => Self::Tui,
92            "mcp" => Self::Mcp,
93            _ => Self::Other(name),
94        })
95    }
96}
97
98/// This process's run, which marks the ledger entries it writes, so the
99/// next launch can tell which ones an earlier run left.
100pub fn run_id() -> &'static str {
101    static RUN: OnceLock<String> = OnceLock::new();
102    RUN.get_or_init(|| {
103        let millis = SystemTime::now()
104            .duration_since(UNIX_EPOCH)
105            .unwrap_or_default()
106            .as_millis();
107        format!("{}-{millis}", std::process::id())
108    })
109}
110
111/// What the session may do, always shown in the status bar.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum Level {
114    /// `--passive`: nothing is transmitted.
115    Passive,
116    /// The default: discovery, reads, finite COV and FD registrations.
117    ReadOnly,
118    /// A write session is armed; `dangerous` when the profile allows
119    /// dangerous operations in it.
120    Armed {
121        /// The profile allows dangerous operations.
122        dangerous: bool,
123    },
124    /// The worker's view of an armed session whose time has run out but
125    /// which nobody has disarmed yet: read-only, named apart in the audit
126    /// log. The app never shows it.
127    Expired,
128}
129
130impl Level {
131    /// Whether a command of `class` may run at this level. The classes are
132    /// the ones the one-shot guard uses (#972): reads from read-only up,
133    /// writes in an armed session, device management only in a dangerous
134    /// one, and nothing passive.
135    pub fn permits(self, class: Class) -> bool {
136        match (self, class) {
137            (Self::Passive, _) => false,
138            (_, Class::Read) => true,
139            (Self::Armed { .. }, Class::Write) => true,
140            (Self::Armed { dangerous }, Class::DeviceManagement) => dangerous,
141            (Self::ReadOnly | Self::Expired, _) => false,
142        }
143    }
144
145    /// The name in audit records.
146    pub fn audit_name(self) -> &'static str {
147        match self {
148            Self::Passive => "passive",
149            Self::ReadOnly => "read-only",
150            Self::Armed { dangerous: false } => "write-session",
151            Self::Armed { dangerous: true } => "write-session+dangerous",
152            Self::Expired => "write-session (expired)",
153        }
154    }
155}
156
157/// How much confirmation a change needs. Ordered: a batch takes its
158/// highest member's tier. T0 (reads and discovery) needs none and never
159/// reaches this type.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
161pub enum Tier {
162    /// A preview, then Enter.
163    T1,
164    /// A preview, then `y`.
165    T2,
166    /// A preview, then the device instance typed out.
167    T3,
168}
169
170/// Whether a change may go ahead, and with what confirmation.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum Gate {
173    /// Allowed after this tier's confirmation.
174    Allowed(Tier),
175    /// Refused, and why.
176    Blocked(String),
177}
178
179impl Gate {
180    /// `T1`, `T2`, `T3` or `blocked`, as the audit log names it.
181    pub fn name(&self) -> String {
182        match self {
183            Self::Allowed(tier) => format!("{tier:?}"),
184            Self::Blocked(_) => "blocked".into(),
185        }
186    }
187}
188
189/// Application-tagged bytes as sent or read, with their display text.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Stored {
192    /// The encoded value, as hex in the files.
193    #[serde(serialize_with = "to_hex", deserialize_with = "from_hex")]
194    pub bytes: Vec<u8>,
195    /// How it reads.
196    pub text: String,
197}
198
199/// The encoding of NULL, which relinquishes a slot (Clause 19.2.1).
200pub const NULL: [u8; 1] = [0x00];
201
202impl Stored {
203    /// A relinquish.
204    pub fn null() -> Self {
205        Self {
206            bytes: NULL.to_vec(),
207            text: "NULL".into(),
208        }
209    }
210}
211
212fn to_hex<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
213    serializer.serialize_str(&hex(bytes))
214}
215
216/// `bytes` as lower-case hex.
217pub fn hex(bytes: &[u8]) -> String {
218    bytes.iter().map(|b| format!("{b:02x}")).collect()
219}
220
221fn from_hex<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
222    let text = String::deserialize(deserializer)?;
223    if !text.len().is_multiple_of(2) || !text.is_ascii() {
224        return Err(serde::de::Error::custom("odd or non-ASCII hex"));
225    }
226    (0..text.len())
227        .step_by(2)
228        .map(|i| u8::from_str_radix(&text[i..i + 2], 16).map_err(serde::de::Error::custom))
229        .collect()
230}
231
232/// One write the user asked for, before anything is read.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WriteSpec {
235    /// Device instance, from the discovery table.
236    pub device: u32,
237    /// The object.
238    pub object: ObjectIdentifier,
239    /// The property.
240    pub property: PropertyIdentifier,
241    /// Array index, if any.
242    pub index: Option<u32>,
243    /// The value as typed, for `parse::parse_value_for`; `None` relinquishes.
244    pub value: Option<String>,
245    /// The priority asked for.
246    pub priority: Option<u8>,
247}
248
249/// Who holds the slot a commandable write goes to.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Occupancy {
252    /// The property is not commandable.
253    NotCommandable,
254    /// The slot is NULL.
255    Empty,
256    /// It holds a value this ledger wrote.
257    Ours,
258    /// It holds another commander's value, which the write replaces
259    /// without telling them (Clause 19.2.1.5).
260    Other,
261}
262
263/// What a write does to Present_Value.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum Effect {
266    /// It changes.
267    Changes {
268        /// Now.
269        from: String,
270        /// After the write.
271        to: String,
272    },
273    /// It stays, held by a numerically lower (stronger) priority when
274    /// there is one (Clause 19.2).
275    Unchanged {
276        /// The slot that keeps control.
277        held_by: Option<u8>,
278    },
279    /// The write is to some other property.
280    NotPresentValue,
281}
282
283/// One write, read and ready to confirm.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct Planned {
286    /// What was asked.
287    pub spec: WriteSpec,
288    /// The priority sent: the one asked, or 16 by default, for a
289    /// commandable property; none for another.
290    pub priority: Option<u8>,
291    /// The value sent.
292    pub value: Stored,
293    /// It goes to a priority slot.
294    pub commandable: bool,
295    /// The pre-image: the slot's value (`None` when NULL) or the
296    /// property's value (`None` when it couldn't be read).
297    pub before: Option<Stored>,
298    /// Who holds the slot.
299    pub occupancy: Occupancy,
300    /// What Present_Value does.
301    pub effect: Effect,
302    /// Out_Of_Service as read.
303    pub out_of_service: Option<bool>,
304    /// Warnings for the preview.
305    pub notes: Vec<String>,
306    /// Its own tier.
307    pub gate: Gate,
308}
309
310impl Planned {
311    /// `device 100 analog-output 1 present-value @8`.
312    pub fn label(&self) -> String {
313        label(
314            self.spec.device,
315            self.spec.object,
316            self.spec.property,
317            self.spec.index,
318            self.priority,
319        )
320    }
321
322    /// A relinquish: NULL to a slot, typed or from `n`.
323    pub fn relinquishes(&self) -> bool {
324        self.value.bytes == NULL
325    }
326}
327
328/// A target as one line.
329pub fn label(
330    device: u32,
331    object: ObjectIdentifier,
332    property: PropertyIdentifier,
333    index: Option<u32>,
334    priority: Option<u8>,
335) -> String {
336    let object = crate::label::object_label(object);
337    let property = crate::label::kebab(property);
338    let index = index.map(|i| format!("[{i}]")).unwrap_or_default();
339    let priority = priority.map(|p| format!(" @{p}")).unwrap_or_default();
340    format!("device {device} {object} {property}{index}{priority}")
341}
342
343/// A previewed change: one WriteProperty, or a WritePropertyMultiple batch.
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct Preview {
346    /// The device every write goes to.
347    pub device: u32,
348    /// The writes, in order.
349    pub items: Vec<Planned>,
350    /// Sent as one WritePropertyMultiple.
351    pub batch: bool,
352    /// The whole change's tier.
353    pub gate: Gate,
354}
355
356impl Preview {
357    /// True when `fresh`, read again just before sending, plans the same
358    /// writes as this confirmed preview: the same pre-images, slot holders
359    /// and priorities.
360    pub fn unchanged(&self, fresh: &Preview) -> bool {
361        self.items.len() == fresh.items.len()
362            && self.items.iter().zip(&fresh.items).all(|(a, b)| {
363                (a.before == b.before)
364                    && (a.occupancy == b.occupancy)
365                    && (a.priority == b.priority)
366                    && (a.commandable == b.commandable)
367            })
368    }
369
370    /// The service it goes out as.
371    pub fn service(&self) -> &'static str {
372        if self.batch {
373            "WritePropertyMultiple"
374        } else {
375            "WriteProperty"
376        }
377    }
378}
379
380/// How a confirmed request ended, for the audit log and the result view.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum Outcome {
383    /// Acknowledged.
384    Ack,
385    /// The device's mutation policy refused it: SERVICES /
386    /// SERVICE_REQUEST_DENIED, shown apart from other errors.
387    Denied,
388    /// Another Error PDU, or a Reject or Abort PDU, with its typed class and
389    /// code or reason (#1814).
390    Refused(RemoteError),
391    /// No answer: it may or may not have been applied.
392    Timeout,
393    /// Never sent, and why.
394    NotSent(String),
395    /// Failed some other way (the transport, say).
396    Failed(String),
397}
398
399impl Outcome {
400    /// From a client result.
401    pub fn of(result: &Result<(), Error>) -> Self {
402        let error = match result {
403            Ok(()) => return Self::Ack,
404            Err(Error::Timeout(_)) => return Self::Timeout,
405            Err(error) => error,
406        };
407        match error.remote() {
408            Some(RemoteError::Error {
409                class: ErrorClass::SERVICES,
410                code: ErrorCode::SERVICE_REQUEST_DENIED,
411            }) => Self::Denied,
412            Some(RemoteError::Abort {
413                reason: AbortReason::TSM_TIMEOUT,
414            }) => Self::Timeout,
415            Some(remote) => Self::Refused(remote),
416            None => Self::Failed(error.to_string()),
417        }
418    }
419
420    /// Whether the change took effect: `None` when that can't be known.
421    pub fn applied(&self) -> Option<bool> {
422        match self {
423            Self::Ack => Some(true),
424            Self::Denied | Self::Refused(_) | Self::NotSent(_) => Some(false),
425            Self::Timeout | Self::Failed(_) => None,
426        }
427    }
428
429    /// One line for the result view: names, or numbers for values without
430    /// one.
431    pub fn describe(&self) -> String {
432        match self {
433            Self::Ack => "acknowledged".into(),
434            Self::Denied => "DENIED by the device's policy (SERVICE_REQUEST_DENIED)".into(),
435            Self::Refused(RemoteError::Error { class, code }) => format!("error {class} / {code}"),
436            Self::Refused(RemoteError::Reject { reason }) => format!("rejected, reason {reason}"),
437            Self::Refused(RemoteError::Abort { reason }) => format!("aborted, reason {reason}"),
438            Self::Timeout => "no answer: it may or may not have been applied".into(),
439            Self::NotSent(why) => format!("not sent: {why}"),
440            Self::Failed(why) => format!("failed: {why}"),
441        }
442    }
443
444    /// The audit record's `outcome`. A class, code or reason is always a
445    /// string: its name, or its decimal number when it has none.
446    pub fn json(&self) -> serde_json::Value {
447        use serde_json::json;
448        match self {
449            Self::Ack => json!({ "result": "ack" }),
450            Self::Denied => json!({ "result": "error", "class": "SERVICES",
451                                    "code": "SERVICE_REQUEST_DENIED", "denied": true }),
452            Self::Refused(RemoteError::Error { class, code }) => json!({ "result": "error",
453                "class": class.to_string(), "code": code.to_string() }),
454            Self::Refused(RemoteError::Reject { reason }) => json!({ "result": "reject",
455                "reason": reason.to_string() }),
456            Self::Refused(RemoteError::Abort { reason }) => json!({ "result": "abort",
457                "reason": reason.to_string() }),
458            Self::Timeout => json!({ "result": "timeout" }),
459            Self::NotSent(why) => json!({ "result": "not-sent", "why": why }),
460            Self::Failed(why) => json!({ "result": "failed", "why": why }),
461        }
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    /// The TUI's levels and the one-shot guard share one classification.
470    #[test]
471    fn levels_permit_the_shared_classes() {
472        use Class::{DeviceManagement, Read, Write};
473        let table = [
474            (Level::Passive, [false, false, false]),
475            (Level::ReadOnly, [true, false, false]),
476            (Level::Expired, [true, false, false]),
477            (Level::Armed { dangerous: false }, [true, true, false]),
478            (Level::Armed { dangerous: true }, [true, true, true]),
479        ];
480        for (level, permits) in table {
481            let got = [Read, Write, DeviceManagement].map(|class| level.permits(class));
482            assert_eq!(got, permits, "{level:?}");
483        }
484    }
485}