Skip to main content

bacnet_server/server/
dcc_outcomes.rs

1use super::*;
2use std::fmt;
3use std::sync::atomic::AtomicU64;
4#[cfg(test)]
5#[path = "dcc_trace_tests.rs"]
6pub(crate) mod trace_tests;
7
8/// Lifetime totals for completed, admitted DCC handlers, saturating at `u64::MAX`.
9/// New servers start at zero. Fields are independently sampled, not an atomic
10/// aggregate. These are local operational telemetry, not a durable audit log.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub struct DccOutcomeCounters {
13    /// State/timer replacement committed; does not imply response delivery.
14    pub accepted_total: u64,
15    /// A valid mode was refused by local policy.
16    pub policy_denied_total: u64,
17    /// The configured password check failed.
18    pub password_failure_total: u64,
19    /// Deprecated DISABLE was refused after the password check.
20    pub deprecated_denied_total: u64,
21    /// Decode failed or the decoded mode was unknown after the password check.
22    pub malformed_total: u64,
23}
24
25#[derive(Clone, Copy)]
26pub(crate) enum DccOutcome {
27    Accepted,
28    PolicyDenied,
29    PasswordFailure,
30    DeprecatedDenied,
31    Malformed,
32}
33
34impl DccOutcome {
35    fn label(self) -> &'static str {
36        match self {
37            Self::Accepted => "accepted",
38            Self::PolicyDenied => "policy_denied",
39            Self::PasswordFailure => "password_failure",
40            Self::DeprecatedDenied => "deprecated_denied",
41            Self::Malformed => "malformed",
42        }
43    }
44}
45
46/// Only decoded, non-secret metadata can cross the validation boundary.
47#[derive(Clone, Copy, Default)]
48pub(crate) struct DccMetadata {
49    pub mode: Option<u32>,
50    pub duration: Option<u16>,
51}
52
53#[derive(Default)]
54pub(super) struct DccOutcomes([AtomicU64; 5]);
55
56impl DccOutcomes {
57    pub(super) fn snapshot(&self) -> DccOutcomeCounters {
58        let [accepted_total, policy_denied_total, password_failure_total, deprecated_denied_total, malformed_total] =
59            self.0.each_ref().map(|v| v.load(Ordering::Relaxed));
60        DccOutcomeCounters {
61            accepted_total,
62            policy_denied_total,
63            password_failure_total,
64            deprecated_denied_total,
65            malformed_total,
66        }
67    }
68
69    pub(super) fn record(
70        &self,
71        outcome: DccOutcome,
72        metadata: DccMetadata,
73        invoke_id: u8,
74        source_mac: &[u8],
75        source: Option<&NpduAddress>,
76    ) {
77        // Counters publish no other data. Saturation and relaxed independent
78        // samples intentionally provide no cross-handler ordering guarantee.
79        let index = match outcome {
80            DccOutcome::Accepted => 0,
81            DccOutcome::PolicyDenied => 1,
82            DccOutcome::PasswordFailure => 2,
83            DccOutcome::DeprecatedDenied => 3,
84            DccOutcome::Malformed => 4,
85        };
86        #[allow(deprecated, reason = "try_update needs Rust 1.95; the MSRV is 1.93")]
87        let _ = self.0[index].fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
88            Some(n.saturating_add(1))
89        });
90        // Lazy bounded Display performs no address formatting/allocation when
91        // the event's own callsite is disabled; no second filter callsite needed.
92        let routed = source.map(|s| s.mac_address.as_slice());
93        tracing::debug!(target: "bacnet_server::dcc_outcome",
94            outcome = outcome.label(), invoke_id, service = 17u8,
95            decoded_mode = metadata.mode, duration_minutes = metadata.duration,
96            source_kind = if source.is_some() { "claimed_routed" } else { "claimed_direct" },
97            claimed_source_mac = %BoundedHex(source_mac),
98            source_mac_truncated = source_mac.len() > 32,
99            claimed_snet = source.map(|s| s.network),
100            claimed_sadr = %BoundedHex(routed.unwrap_or_default()),
101            sadr_truncated = routed.is_some_and(|s| s.len() > 32),
102        );
103    }
104}
105
106struct BoundedHex<'a>(&'a [u8]);
107#[test]
108fn dcc_outcomes_saturation_without_subscriber() {
109    let counters = DccOutcomes::default();
110    for counter in &counters.0 {
111        counter.store(u64::MAX - 1, Ordering::Relaxed);
112    }
113    tracing::subscriber::with_default(tracing::subscriber::NoSubscriber::default(), || {
114        for _ in 0..3 {
115            for outcome in [
116                DccOutcome::Accepted,
117                DccOutcome::PolicyDenied,
118                DccOutcome::PasswordFailure,
119                DccOutcome::DeprecatedDenied,
120                DccOutcome::Malformed,
121            ] {
122                counters.record(outcome, DccMetadata::default(), 0, &[], None);
123            }
124        }
125    });
126    assert!(counters
127        .0
128        .iter()
129        .all(|counter| counter.load(Ordering::Relaxed) == u64::MAX));
130}
131impl fmt::Display for BoundedHex<'_> {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        for byte in self.0.iter().take(32) {
134            write!(f, "{byte:02x}")?;
135        }
136        Ok(())
137    }
138}
139
140impl<T: TransportPort + 'static> BACnetServer<T> {
141    /// Sample completed admitted DCC outcomes, including after `stop()`.
142    /// Excludes pre-handler rejections, duplicates, incomplete cancelled handlers,
143    /// timer expiry and response delivery outcomes. No history is retained.
144    pub fn dcc_outcome_counters(&self) -> DccOutcomeCounters {
145        self.dcc_outcomes.snapshot()
146    }
147}