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