monitord/
dbus_stats.rs

1//! # dbus_stats module
2//!
3//! Handle getting statistics of our Dbus daemon/broker
4
5use std::collections::HashMap;
6use std::fs;
7use std::io;
8use std::sync::Arc;
9
10use thiserror::Error;
11use tokio::sync::RwLock;
12use tracing::error;
13use uzers::get_user_by_uid;
14use zbus::fdo::{DBusProxy, StatsProxy};
15use zbus::names::BusName;
16use zvariant::{Dict, OwnedValue, Value};
17
18use crate::MachineStats;
19
20#[derive(Error, Debug)]
21pub enum MonitordDbusStatsError {
22    #[error("D-Bus error: {0}")]
23    ZbusError(#[from] zbus::Error),
24    #[error("D-Bus fdo error: {0}")]
25    FdoError(#[from] zbus::fdo::Error),
26}
27
28// Unfortunately, various DBus daemons (ex: dbus-broker and dbus-daemon)
29// represent stats differently. Moreover, the stats vary across versions of the same daemon.
30// Hence, the code uses flexible approach providing max available information.
31
32/// Per-peer resource accounting from dbus-broker's PeerAccounting stats.
33/// Each peer represents a single D-Bus connection identified by a unique bus name.
34#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
35pub struct DBusBrokerPeerAccounting {
36    /// Unique D-Bus connection name (e.g. ":1.42")
37    pub id: String,
38    /// Well-known bus name owned by this peer, if any (e.g. "org.freedesktop.NetworkManager")
39    pub well_known_name: Option<String>,
40
41    // credentials
42    /// Unix UID of the process owning this D-Bus connection
43    pub unix_user_id: Option<u32>,
44    /// PID of the process owning this D-Bus connection
45    pub process_id: Option<u32>,
46    /// Unix supplementary group IDs of the process owning this connection
47    pub unix_group_ids: Option<Vec<u32>>,
48    // ignoring LinuxSecurityLabel
49    // pub linux_security_label: Option<String>,
50
51    // stats
52    /// Number of bus name objects held by this peer
53    pub name_objects: Option<u32>,
54    /// Bytes consumed by match rules registered by this peer
55    pub match_bytes: Option<u32>,
56    /// Number of match rules registered by this peer for signal filtering
57    pub matches: Option<u32>,
58    /// Number of pending reply objects (outstanding method calls awaiting replies)
59    pub reply_objects: Option<u32>,
60    /// Total bytes received by this peer from the bus
61    pub incoming_bytes: Option<u32>,
62    /// Total file descriptors received by this peer via D-Bus fd-passing
63    pub incoming_fds: Option<u32>,
64    /// Total bytes sent by this peer to the bus
65    pub outgoing_bytes: Option<u32>,
66    /// Total file descriptors sent by this peer via D-Bus fd-passing
67    pub outgoing_fds: Option<u32>,
68    /// Bytes used for D-Bus activation requests by this peer
69    pub activation_request_bytes: Option<u32>,
70    /// File descriptors used for D-Bus activation requests by this peer
71    pub activation_request_fds: Option<u32>,
72}
73
74impl DBusBrokerPeerAccounting {
75    pub fn get_cgroup_name(&self) -> Result<String, io::Error> {
76        let pid = self
77            .process_id
78            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "missing process_id"))?;
79
80        let path = format!("/proc/{}/cgroup", pid);
81        let content = fs::read_to_string(&path)?;
82
83        // ex: 0::/system.slice/metalos.classic.metald.service
84        let cgroup = content.strip_prefix("0::").ok_or_else(|| {
85            io::Error::new(io::ErrorKind::InvalidData, "unexpected cgroup format")
86        })?;
87
88        Ok(cgroup.trim().trim_matches('/').replace('/', "-"))
89    }
90}
91
92/// Aggregated D-Bus resource accounting grouped by cgroup.
93/// Not directly present in dbus-broker stats; computed by summing peer stats that share a cgroup.
94/// Grouping by cgroup reduces metric cardinality while still identifying abusive clients.
95#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
96pub struct DBusBrokerCGroupAccounting {
97    /// Cgroup path with slashes replaced by dashes (e.g. "system.slice-sshd.service")
98    pub name: String,
99
100    // stats (aggregated sums across all peers in this cgroup)
101    /// Total bus name objects held by peers in this cgroup
102    pub name_objects: Option<u32>,
103    /// Total bytes consumed by match rules from peers in this cgroup
104    pub match_bytes: Option<u32>,
105    /// Total match rules registered by peers in this cgroup
106    pub matches: Option<u32>,
107    /// Total pending reply objects from peers in this cgroup
108    pub reply_objects: Option<u32>,
109    /// Total bytes received by peers in this cgroup
110    pub incoming_bytes: Option<u32>,
111    /// Total file descriptors received by peers in this cgroup
112    pub incoming_fds: Option<u32>,
113    /// Total bytes sent by peers in this cgroup
114    pub outgoing_bytes: Option<u32>,
115    /// Total file descriptors sent by peers in this cgroup
116    pub outgoing_fds: Option<u32>,
117    /// Total activation request bytes from peers in this cgroup
118    pub activation_request_bytes: Option<u32>,
119    /// Total activation request file descriptors from peers in this cgroup
120    pub activation_request_fds: Option<u32>,
121}
122
123impl DBusBrokerCGroupAccounting {
124    pub fn combine_with_peer(&mut self, peer: &DBusBrokerPeerAccounting) {
125        fn sum(a: &mut Option<u32>, b: &Option<u32>) {
126            *a = match (a.take(), b) {
127                (Some(x), Some(y)) => Some(x + y),
128                (Some(x), None) => Some(x),
129                (None, Some(y)) => Some(*y),
130                (None, None) => None,
131            };
132        }
133
134        sum(&mut self.name_objects, &peer.name_objects);
135        sum(&mut self.match_bytes, &peer.match_bytes);
136        sum(&mut self.matches, &peer.matches);
137        sum(&mut self.reply_objects, &peer.reply_objects);
138        sum(&mut self.incoming_bytes, &peer.incoming_bytes);
139        sum(&mut self.incoming_fds, &peer.incoming_fds);
140        sum(&mut self.outgoing_bytes, &peer.outgoing_bytes);
141        sum(&mut self.outgoing_fds, &peer.outgoing_fds);
142        sum(
143            &mut self.activation_request_bytes,
144            &peer.activation_request_bytes,
145        );
146        sum(
147            &mut self.activation_request_fds,
148            &peer.activation_request_fds,
149        );
150    }
151}
152
153/// Current/maximum resource pair as reported by dbus-broker's UserAccounting.
154/// Note: dbus-broker stores the current value in inverted form; actual usage = max - cur.
155#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
156pub struct CurMaxPair {
157    /// Remaining quota (inverted: actual usage = max - cur)
158    pub cur: u32,
159    /// Maximum allowed quota for this resource
160    pub max: u32,
161}
162
163impl CurMaxPair {
164    pub fn get_usage(&self) -> u32 {
165        // There is a theoretical possibility of max < cur due to various factors.
166        // I'll leave it for now to avoid premature optimizations.
167        self.max - self.cur
168    }
169}
170
171/// Per-user aggregated D-Bus resource limits and usage from dbus-broker's UserAccounting.
172/// Each entry tracks quota consumption across all connections belonging to a Unix user.
173#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
174pub struct DBusBrokerUserAccounting {
175    /// Unix user ID this accounting entry belongs to
176    pub uid: u32,
177
178    /// Message byte quota: remaining (cur) and maximum (max) allowed bytes across all connections
179    pub bytes: Option<CurMaxPair>,
180    /// File descriptor quota: remaining (cur) and maximum (max) allowed FDs across all connections
181    pub fds: Option<CurMaxPair>,
182    /// Match rule quota: remaining (cur) and maximum (max) allowed match rules across all connections
183    pub matches: Option<CurMaxPair>,
184    /// Object quota: remaining (cur) and maximum (max) allowed objects (names, replies) across all connections
185    pub objects: Option<CurMaxPair>,
186    // UserUsage provides detailed breakdown of the aggregated numbers.
187    // However, dbus-broker exposes usage as real values (not inverted, see CurMaxPair).
188}
189
190impl DBusBrokerUserAccounting {
191    fn new(uid: u32) -> Self {
192        Self {
193            uid,
194            ..Default::default()
195        }
196    }
197
198    pub fn get_name_for_metric(&self) -> String {
199        match get_user_by_uid(self.uid) {
200            Some(user) => user.name().to_string_lossy().into_owned(),
201            None => self.uid.to_string(),
202        }
203    }
204}
205
206/// D-Bus daemon/broker statistics from org.freedesktop.DBus.Debug.Stats.
207/// Works with both dbus-daemon and dbus-broker; broker-specific fields are in separate maps.
208#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
209pub struct DBusStats {
210    /// Current D-Bus message serial number (monotonically increasing message counter)
211    pub serial: Option<u32>,
212    /// Number of fully authenticated active D-Bus connections
213    pub active_connections: Option<u32>,
214    /// Number of D-Bus connections still in the authentication handshake phase
215    pub incomplete_connections: Option<u32>,
216    /// Current number of registered bus names (well-known + unique)
217    pub bus_names: Option<u32>,
218    /// Peak (high-water mark) number of bus names ever registered simultaneously
219    pub peak_bus_names: Option<u32>,
220    /// Peak number of bus names registered by a single connection
221    pub peak_bus_names_per_connection: Option<u32>,
222    /// Current number of active signal match rules across all connections
223    pub match_rules: Option<u32>,
224    /// Peak number of match rules ever registered simultaneously
225    pub peak_match_rules: Option<u32>,
226    /// Peak number of match rules registered by a single connection
227    pub peak_match_rules_per_connection: Option<u32>,
228
229    /// Per-peer resource accounting (dbus-broker only), keyed by unique connection name
230    pub dbus_broker_peer_accounting: Option<HashMap<String, DBusBrokerPeerAccounting>>,
231    /// Per-user resource quota accounting (dbus-broker only), keyed by Unix UID
232    pub dbus_broker_user_accounting: Option<HashMap<u32, DBusBrokerUserAccounting>>,
233
234    /// Whether per-peer stats collection is enabled in config
235    pub peer_stats: bool,
236    /// Whether per-cgroup aggregated stats are enabled in config (derived from peer stats)
237    pub cgroup_stats: bool,
238}
239
240impl DBusStats {
241    pub fn peer_accounting(&self) -> Option<&HashMap<String, DBusBrokerPeerAccounting>> {
242        match self.peer_stats {
243            true => self.dbus_broker_peer_accounting.as_ref(),
244            false => None,
245        }
246    }
247
248    pub fn cgroup_accounting(&self) -> Option<HashMap<String, DBusBrokerCGroupAccounting>> {
249        if !self.cgroup_stats {
250            return None;
251        }
252
253        let peer_accounting = self.dbus_broker_peer_accounting.as_ref()?;
254        let mut result: HashMap<String, DBusBrokerCGroupAccounting> = HashMap::new();
255
256        for peer in peer_accounting.values() {
257            let cgroup_name = match peer.get_cgroup_name() {
258                Ok(name) => name,
259                Err(err) => {
260                    error!("Failed to get cgroup name for peer {}: {}", peer.id, err);
261                    continue;
262                }
263            };
264
265            let entry = result.entry(cgroup_name).or_insert_with_key(|cgroup_name| {
266                DBusBrokerCGroupAccounting {
267                    name: cgroup_name.clone(),
268                    ..Default::default()
269                }
270            });
271
272            entry.combine_with_peer(peer);
273        }
274
275        Some(result)
276    }
277
278    pub fn user_accounting(&self) -> Option<&HashMap<u32, DBusBrokerUserAccounting>> {
279        self.dbus_broker_user_accounting.as_ref()
280    }
281}
282
283fn get_u32(dict: &Dict, key: &str) -> Option<u32> {
284    let value_key: Value = key.into();
285    dict.get(&value_key).ok().and_then(|v| match v.flatten() {
286        Some(Value::U32(val)) => Some(*val),
287        _ => None,
288    })
289}
290
291fn get_u32_vec(dict: &Dict, key: &str) -> Option<Vec<u32>> {
292    let value_key: Value = key.into();
293    dict.get(&value_key).ok().and_then(|v| match v.flatten() {
294        Some(Value::Array(array)) => {
295            let vec: Vec<u32> = array
296                .iter()
297                .filter_map(|item| {
298                    if let Value::U32(num) = item {
299                        Some(*num)
300                    } else {
301                        None
302                    }
303                })
304                .collect();
305
306            Some(vec)
307        }
308        _ => None,
309    })
310}
311
312/* Parse DBusBrokerPeerAccounting from OwnedValue.
313 * Expected structure:
314 * struct {
315 *     string ":1.2197907"
316 *     array [
317 *         dict entry(
318 *              string "UnixUserID"
319 *              variant uint32 0
320 *         )
321 *         ... other fields
322 *     ]
323 *     array [
324 *         dict entry(
325 *              string "NameObjects"
326 *              uint32 1
327 *         )
328 *         ... other fields
329 *     ]
330 * }
331 */
332
333fn parse_peer_struct(
334    peer_value: &Value,
335    well_known_to_peer_names: &HashMap<String, String>,
336) -> Option<DBusBrokerPeerAccounting> {
337    let peer_struct = match peer_value {
338        Value::Structure(peer_struct) => peer_struct,
339        _ => return None,
340    };
341
342    match peer_struct.fields() {
343        [Value::Str(id), Value::Dict(credentials), Value::Dict(stats), ..] => {
344            Some(DBusBrokerPeerAccounting {
345                id: id.to_string(),
346                well_known_name: well_known_to_peer_names.get(id.as_str()).cloned(),
347                unix_user_id: get_u32(credentials, "UnixUserID"),
348                process_id: get_u32(credentials, "ProcessID"),
349                unix_group_ids: get_u32_vec(credentials, "UnixGroupIDs"),
350                name_objects: get_u32(stats, "NameObjects"),
351                match_bytes: get_u32(stats, "MatchBytes"),
352                matches: get_u32(stats, "Matches"),
353                reply_objects: get_u32(stats, "ReplyObjects"),
354                incoming_bytes: get_u32(stats, "IncomingBytes"),
355                incoming_fds: get_u32(stats, "IncomingFds"),
356                outgoing_bytes: get_u32(stats, "OutgoingBytes"),
357                outgoing_fds: get_u32(stats, "OutgoingFds"),
358                activation_request_bytes: get_u32(stats, "ActivationRequestBytes"),
359                activation_request_fds: get_u32(stats, "ActivationRequestFds"),
360            })
361        }
362        _ => None,
363    }
364}
365
366fn parse_peer_accounting(
367    config: &crate::config::Config,
368    owned_value: &OwnedValue,
369    well_known_to_peer_names: &HashMap<String, String>,
370) -> Option<HashMap<String, DBusBrokerPeerAccounting>> {
371    // need to keep collecting peer stats when cgroup_stats=true
372    // since cgroup_stats is a derivative of peer stats
373    if !config.dbus_stats.peer_stats && !config.dbus_stats.cgroup_stats {
374        return None;
375    }
376
377    let value: &Value = owned_value;
378    let peers_value = match value {
379        Value::Array(peers_value) => peers_value,
380        _ => return None,
381    };
382
383    let result = peers_value
384        .iter()
385        .filter_map(|peer| parse_peer_struct(peer, well_known_to_peer_names))
386        .map(|peer| (peer.id.clone(), peer))
387        .collect();
388
389    Some(result)
390}
391
392/* Parse DBusBrokerUserAccounting from OwnedValue.
393 * Expected structure:
394 * struct {
395 *     uint32 0
396 *     array [
397 *         struct {
398 *             string "Bytes"
399 *             uint32 536843240
400 *             uint32 536870912
401 *         }
402 *         ... more fields
403 *     ]
404 *     # TODO parse usages, ignoring for now
405 *     # see src/bus/driver.c:2258
406 *     # the part below is not parsed
407 *     array [
408 *         dict entry(
409 *             uint32 0
410 *             array [
411 *             dict entry(
412 *                 string "Bytes"
413 *                 uint32 27672
414 *             )
415 *             ... more fields
416 *             ]
417 *         )
418 *     ]
419 * }
420 */
421
422fn parse_user_struct(user_value: &Value) -> Option<DBusBrokerUserAccounting> {
423    let user_struct = match user_value {
424        Value::Structure(user_struct) => user_struct,
425        _ => return None,
426    };
427
428    match user_struct.fields() {
429        [Value::U32(uid), Value::Array(user_stats), ..] => {
430            let mut user = DBusBrokerUserAccounting::new(*uid);
431            for user_stat in user_stats.iter() {
432                if let Value::Structure(user_stat) = user_stat {
433                    if let [Value::Str(name), Value::U32(cur), Value::U32(max), ..] =
434                        user_stat.fields()
435                    {
436                        let pair = CurMaxPair {
437                            cur: *cur,
438                            max: *max,
439                        };
440                        match name.as_str() {
441                            "Bytes" => user.bytes = Some(pair),
442                            "Fds" => user.fds = Some(pair),
443                            "Matches" => user.matches = Some(pair),
444                            "Objects" => user.objects = Some(pair),
445                            _ => {} // ignore other fields
446                        }
447                    }
448                }
449            }
450
451            Some(user)
452        }
453        _ => None,
454    }
455}
456
457fn parse_user_accounting(
458    config: &crate::config::Config,
459    owned_value: &OwnedValue,
460) -> Option<HashMap<u32, DBusBrokerUserAccounting>> {
461    // reject collecting user stats when told so
462    if !config.dbus_stats.user_stats {
463        return None;
464    }
465
466    let value: &Value = owned_value;
467    let users_value = match value {
468        Value::Array(users_value) => users_value,
469        _ => return None,
470    };
471
472    let result = users_value
473        .iter()
474        .filter_map(parse_user_struct)
475        .map(|user| (user.uid, user))
476        .collect();
477
478    Some(result)
479}
480
481async fn get_well_known_to_peer_names(
482    dbus_proxy: &DBusProxy<'_>,
483) -> Result<HashMap<String, String>, MonitordDbusStatsError> {
484    let dbus_names = dbus_proxy.list_names().await?;
485    let mut result = HashMap::new();
486
487    for owned_busname in dbus_names.iter() {
488        let name: &BusName = owned_busname;
489        if let BusName::WellKnown(_) = name {
490            // TODO parallelize
491            let owner = dbus_proxy.get_name_owner(name.clone()).await?;
492            result.insert(owner.to_string(), name.to_string());
493        }
494    }
495
496    Ok(result)
497}
498
499/// Pull all units from dbus and count how system is setup and behaving
500pub async fn parse_dbus_stats(
501    config: &crate::config::Config,
502    connection: &zbus::Connection,
503) -> Result<DBusStats, MonitordDbusStatsError> {
504    let dbus_proxy = DBusProxy::new(connection).await?;
505    let well_known_to_peer_names = get_well_known_to_peer_names(&dbus_proxy).await?;
506
507    let stats_proxy = StatsProxy::new(connection).await?;
508    let stats = stats_proxy.get_stats().await?;
509
510    let dbus_stats = DBusStats {
511        serial: stats.serial(),
512        active_connections: stats.active_connections(),
513        incomplete_connections: stats.incomplete_connections(),
514        bus_names: stats.bus_names(),
515        peak_bus_names: stats.peak_bus_names(),
516        peak_bus_names_per_connection: stats.peak_bus_names_per_connection(),
517        match_rules: stats.match_rules(),
518        peak_match_rules: stats.peak_match_rules(),
519        peak_match_rules_per_connection: stats.peak_match_rules_per_connection(),
520
521        // attempt to parse dbus-broker specific stats
522        dbus_broker_peer_accounting: stats
523            .rest()
524            .get("org.bus1.DBus.Debug.Stats.PeerAccounting")
525            .map(|peer| parse_peer_accounting(config, peer, &well_known_to_peer_names))
526            .unwrap_or_default(),
527        dbus_broker_user_accounting: stats
528            .rest()
529            .get("org.bus1.DBus.Debug.Stats.UserAccounting")
530            .map(|user| parse_user_accounting(config, user))
531            .unwrap_or_default(),
532
533        // have to keep settings since cgroup stats depends on peer stats
534        peer_stats: config.dbus_stats.peer_stats,
535        cgroup_stats: config.dbus_stats.cgroup_stats,
536    };
537
538    Ok(dbus_stats)
539}
540
541/// Async wrapper than can update dbus stats when passed a locked struct
542pub async fn update_dbus_stats(
543    config: Arc<crate::config::Config>,
544    connection: zbus::Connection,
545    locked_machine_stats: Arc<RwLock<MachineStats>>,
546) -> anyhow::Result<()> {
547    match parse_dbus_stats(&config, &connection).await {
548        Ok(dbus_stats) => {
549            let mut machine_stats = locked_machine_stats.write().await;
550            machine_stats.dbus_stats = Some(dbus_stats)
551        }
552        Err(err) => error!("dbus stats failed: {:?}", err),
553    }
554    Ok(())
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use zvariant::{Array, OwnedValue, Str, Structure, Value};
561
562    #[test]
563    fn test_cur_max_pair_usage() {
564        let p = CurMaxPair { cur: 10, max: 100 };
565        assert_eq!(p.get_usage(), 90);
566    }
567
568    #[test]
569    fn test_cgroup_accounting_gating_and_skip_errors() {
570        let disabled = DBusStats {
571            cgroup_stats: false,
572            peer_stats: true,
573            dbus_broker_peer_accounting: Some(HashMap::new()),
574            ..Default::default()
575        };
576        assert!(disabled.cgroup_accounting().is_none());
577
578        let mut peers: HashMap<String, DBusBrokerPeerAccounting> = HashMap::new();
579        peers.insert(
580            ":1.77".to_string(),
581            DBusBrokerPeerAccounting {
582                id: ":1.77".to_string(),
583                process_id: None,
584                ..Default::default()
585            },
586        );
587
588        let enabled = DBusStats {
589            cgroup_stats: true,
590            peer_stats: true,
591            dbus_broker_peer_accounting: Some(peers),
592            ..Default::default()
593        };
594
595        let cg_map = enabled.cgroup_accounting().expect("map should exist");
596        assert!(cg_map.is_empty());
597    }
598
599    #[test]
600    fn test_combine_with_peer_option_summing() {
601        let mut cg = DBusBrokerCGroupAccounting {
602            name: "cg1".to_string(),
603            name_objects: Some(5),
604            match_bytes: None,
605            matches: Some(3),
606            reply_objects: None,
607            incoming_bytes: Some(10),
608            incoming_fds: None,
609            outgoing_bytes: Some(7),
610            outgoing_fds: Some(2),
611            activation_request_bytes: None,
612            activation_request_fds: Some(1),
613        };
614
615        let peer = DBusBrokerPeerAccounting {
616            id: ":1.1".to_string(),
617            well_known_name: Some("com.example".to_string()),
618            unix_user_id: Some(1000),
619            process_id: Some(1234),
620            unix_group_ids: Some(vec![1000]),
621            name_objects: Some(2),
622            match_bytes: Some(4),
623            matches: None,
624            reply_objects: Some(1),
625            incoming_bytes: None,
626            incoming_fds: Some(5),
627            outgoing_bytes: Some(3),
628            outgoing_fds: None,
629            activation_request_bytes: Some(8),
630            activation_request_fds: None,
631        };
632
633        cg.combine_with_peer(&peer);
634
635        assert_eq!(cg.name_objects, Some(7));
636        assert_eq!(cg.match_bytes, Some(4));
637        assert_eq!(cg.matches, Some(3));
638        assert_eq!(cg.reply_objects, Some(1));
639        assert_eq!(cg.incoming_bytes, Some(10));
640        assert_eq!(cg.incoming_fds, Some(5));
641        assert_eq!(cg.outgoing_bytes, Some(10));
642        assert_eq!(cg.outgoing_fds, Some(2));
643        assert_eq!(cg.activation_request_bytes, Some(8));
644        assert_eq!(cg.activation_request_fds, Some(1));
645    }
646
647    #[test]
648    fn test_parse_user_accounting_gating_and_parse() {
649        // When user_stats=false, should return None
650        let mut cfg = crate::config::Config::default();
651        cfg.dbus_stats.user_stats = false;
652        let empty_val = Value::Array(Array::from(Vec::<Value>::new()));
653        let empty_owned = OwnedValue::try_from(empty_val).expect("owned value conversion");
654        assert!(parse_user_accounting(&cfg, &empty_owned).is_none());
655
656        // When user_stats=true, empty array should return Some(empty map)
657        cfg.dbus_stats.user_stats = true;
658        let empty_val = Value::Array(Array::from(Vec::<Value>::new()));
659        let owned = OwnedValue::try_from(empty_val).expect("should convert empty array");
660        let parsed = parse_user_accounting(&cfg, &owned).expect("should parse empty");
661        assert_eq!(parsed.len(), 0);
662
663        // Non-array input should return None
664        let non_array = OwnedValue::try_from(Value::U32(0)).expect("should convert u32 value");
665        assert!(parse_user_accounting(&cfg, &non_array).is_none());
666    }
667
668    #[test]
669    fn test_parse_user_struct_invalid_returns_none() {
670        // Build an invalid structure (wrong field types/order) to ensure None is returned
671        let invalid = Value::Structure(Structure::from((
672            Value::Str(Str::from_static("not_uid")),
673            Value::U32(10),
674            Value::U32(20),
675        )));
676        assert!(parse_user_struct(&invalid).is_none());
677    }
678
679    #[test]
680    fn test_user_metric_name_fallback() {
681        // Use a likely-nonexistent uid to force fallback to stringified uid
682        let user = DBusBrokerUserAccounting {
683            uid: 999_999,
684            bytes: Some(CurMaxPair { cur: 5, max: 10 }),
685            ..Default::default()
686        };
687        // If users crate can’t resolve uid, it should fallback to uid string
688        let name = user.get_name_for_metric();
689        assert_eq!(name, "999999");
690    }
691}