Skip to main content

monitord/
varlink_verify.rs

1//! # varlink_verify module
2//!
3//! Unit enumeration for `systemd-analyze verify` from the `io.systemd.Metrics`
4//! stream rather than D-Bus `ListUnits`. This reads only the per-unit
5//! `UnitLoadState` family, which the metrics endpoint has carried since the
6//! report framework appeared in v260 — so unlike the timestamp families, no
7//! v261 minimum applies here.
8//!
9//! Per the `varlink_unit` module docs, collectors that want every unit are
10//! served from the metrics stream: one streamed `List` instead of hundreds of
11//! per-unit round trips (over either transport). That stream is the one the
12//! units collector reads in the same cycle (see
13//! [`crate::varlink_units::SharedMetrics`]).
14
15use tracing::debug;
16
17use crate::varlink::metrics::ListOutput;
18use crate::varlink_units::SharedMetrics;
19
20/// Collect unit names from per-unit load-state metrics, sorted and deduplicated.
21///
22/// Every object is kept, whatever its load state: the metric objects are the
23/// same set D-Bus `ListUnits` returns (verified live: 199 == 199, including
24/// `not-found` units such as `syslog.service` that exist only because something
25/// references them).
26///
27/// One pass doubles as the trust check, since this enumeration must match the
28/// D-Bus set exactly for the two verify paths to check the same units: a
29/// missing family, any skipped family member (no object or a non-string
30/// state), or an empty result each bail so the caller falls back to D-Bus
31/// instead of verifying a silently smaller set and reporting success. A
32/// running systemd always has units, so none of these mean "idle system".
33fn collect_unit_names(metrics: &[ListOutput]) -> anyhow::Result<Vec<String>> {
34    let mut names: Vec<String> = Vec::new();
35    let mut saw_family = false;
36    let mut skipped = 0u64;
37    for metric in metrics {
38        if metric.name_suffix() != "UnitLoadState" {
39            continue;
40        }
41        saw_family = true;
42        match (metric.object(), metric.value().is_string()) {
43            (Some(unit_name), true) => names.push(unit_name.to_string()),
44            (object, is_string) => {
45                skipped += 1;
46                debug!(
47                    "Skipping {} for {object:?}: non-string load state or missing object (is_string={is_string}, value={:?})",
48                    metric.name(),
49                    metric.value()
50                );
51            }
52        }
53    }
54    if !saw_family {
55        anyhow::bail!("metrics carry no UnitLoadState family, cannot enumerate units");
56    }
57    if skipped > 0 {
58        anyhow::bail!(
59            "{skipped} UnitLoadState metrics skipped (missing object or non-string state), cannot trust enumeration"
60        );
61    }
62    names.sort();
63    names.dedup();
64    if names.is_empty() {
65        // Unreachable unless the loop above changes: a seen family with no
66        // skips always yields names. Kept so a future refactor can't turn
67        // this into a silent verify-nothing-and-report-success.
68        anyhow::bail!("UnitLoadState family present but no unit names enumerated");
69    }
70    Ok(names)
71}
72
73/// Enumerate all units over varlink.
74pub async fn list_unit_names(metrics: SharedMetrics) -> anyhow::Result<Vec<String>> {
75    let metrics = crate::varlink_units::await_metrics(metrics).await?;
76    collect_unit_names(&metrics)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn metric(unit: Option<&str>, load_state: serde_json::Value) -> ListOutput {
84        ListOutput {
85            name: "io.systemd.Manager.UnitLoadState".to_string(),
86            value: load_state,
87            object: unit.map(|unit| unit.to_string()),
88            fields: None,
89        }
90    }
91
92    fn loaded(unit: &str) -> ListOutput {
93        metric(Some(unit), serde_json::json!("loaded"))
94    }
95
96    #[test]
97    fn test_collects_every_object_whatever_its_load_state() {
98        // The metric objects are the same set ListUnits returns — including
99        // not-found units, which analyze then reports on exactly as the D-Bus
100        // path sees. Verified live: identical 199-unit sets on both sides.
101        let mut metrics = vec![
102            loaded("b.service"),
103            loaded("a.service"),
104            metric(Some("masked.service"), serde_json::json!("masked")),
105            metric(Some("ghost.service"), serde_json::json!("not-found")),
106            // Duplicates collapse: ListUnits never repeats a name either.
107            loaded("a.service"),
108        ];
109
110        // A different family sharing the stream must not leak names in.
111        metrics.push(ListOutput {
112            name: "io.systemd.Manager.UnitActiveState".to_string(),
113            value: serde_json::json!("active"),
114            object: Some("active-only.service".to_string()),
115            fields: None,
116        });
117
118        assert_eq!(
119            collect_unit_names(&metrics).expect("valid family collects"),
120            vec![
121                "a.service".to_string(),
122                "b.service".to_string(),
123                "ghost.service".to_string(),
124                "masked.service".to_string(),
125            ]
126        );
127    }
128
129    #[test]
130    fn test_skipped_family_members_reject_enumeration() {
131        // Any skipped family member makes the set untrustworthy: bail so the
132        // caller falls back to D-Bus rather than verifying a silent subset.
133        for metrics in [
134            vec![
135                loaded("good.service"),
136                metric(None, serde_json::json!("loaded")),
137            ],
138            vec![
139                loaded("good.service"),
140                metric(Some("odd.service"), serde_json::json!(3)),
141            ],
142            vec![
143                loaded("good.service"),
144                metric(Some("other.service"), serde_json::json!(null)),
145            ],
146        ] {
147            let err = collect_unit_names(&metrics).expect_err("skips must bail");
148            assert!(
149                err.to_string().contains("skipped"),
150                "unexpected error: {err}"
151            );
152        }
153    }
154
155    #[test]
156    fn test_missing_family_is_rejected() {
157        // No UnitLoadState metrics at all: the guard must trip so the caller
158        // falls back to D-Bus instead of verifying nothing and reporting success.
159        let without_family = vec![ListOutput {
160            name: "io.systemd.Manager.UnitsTotal".to_string(),
161            value: serde_json::json!(295),
162            object: None,
163            fields: None,
164        }];
165        let err = collect_unit_names(&without_family).expect_err("missing family must bail");
166        assert!(
167            err.to_string().contains("no UnitLoadState family"),
168            "unexpected error: {err}"
169        );
170
171        assert!(collect_unit_names(&[loaded("foo.service")]).is_ok());
172    }
173}