Skip to main content

monitord/
verify.rs

1//! # verify module
2//!
3//! Collects systemd unit verification errors by running `systemd-analyze verify`
4//! on all unit files and parsing the output. Tracks counts of failing units by type.
5
6use std::collections::{HashMap, HashSet};
7use std::process::Command;
8use std::sync::Arc;
9
10use thiserror::Error;
11use tokio::sync::RwLock;
12
13use crate::MachineStats;
14
15#[derive(Error, Debug)]
16pub enum MonitordVerifyError {
17    #[error("Failed to execute systemd-analyze: {0}")]
18    CommandError(String),
19    #[error("Unable to connect to D-Bus via zbus: {0:#}")]
20    ZbusError(#[from] zbus::Error),
21}
22
23/// Statistics about unit verification errors, aggregated by unit type (service, slice, timer, etc.)
24#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
25pub struct VerifyStats {
26    /// Total count of units with verification failures
27    pub total: u64,
28    /// Count of failing units per type (e.g., "service", "slice", "timer")
29    /// Only includes types that have at least one failure
30    #[serde(flatten)]
31    pub by_type: HashMap<String, u64>,
32}
33
34/// Extract unit type from a unit name (e.g., "foo.service" -> "service")
35fn get_unit_type(unit_name: &str) -> Option<String> {
36    // Filter out obviously invalid unit names
37    if unit_name.len() < 3 {
38        return None;
39    }
40
41    // Check if it starts with a valid character (alphanumeric, dash, underscore, backslash for escapes)
42    let first_char = unit_name.chars().next()?;
43    if !first_char.is_alphanumeric() && first_char != '-' && first_char != '\\' {
44        return None;
45    }
46
47    unit_name.rsplit('.').next().map(|s| s.to_string())
48}
49
50/// Parse systemd-analyze verify output to extract failing unit names
51/// Output format examples:
52/// - "Unit foo.service not found."
53/// - "/path/to/foo.service:5: Unknown key..."
54/// - "foo.service: Command ... failed..."
55fn parse_verify_output(stderr: &str) -> HashSet<String> {
56    let mut failing_units = HashSet::new();
57
58    for line in stderr.lines() {
59        let trimmed = line.trim();
60        if trimmed.is_empty() {
61            continue;
62        }
63
64        // Skip "Failed to prepare filename" lines - these are input errors, not unit errors
65        if trimmed.contains("Failed to prepare filename") {
66            continue;
67        }
68
69        let mut found_in_line = false;
70
71        // Format 1: "/path/file.service:line: message" - extract just the filename
72        if line.starts_with('/') {
73            if let Some(pos) = line.find(':') {
74                let path_part = &line[..pos];
75                if let Some(filename) = path_part.rsplit('/').next() {
76                    if filename.contains('.') && get_unit_type(filename).is_some() {
77                        failing_units.insert(filename.to_string());
78                        found_in_line = true;
79                    }
80                }
81            }
82        }
83
84        // Format 2: "Unit foo.service ..." or "foo.service: ..." - only if not already found from path
85        if !found_in_line {
86            for word in line.split_whitespace() {
87                let cleaned = word.trim_end_matches(':').trim_end_matches('.');
88                // Only consider it a unit name if it has a valid extension and looks reasonable
89                if cleaned.contains('.')
90                    && cleaned.len() > 2 // Minimum reasonable length
91                    && !cleaned.contains('(') // Skip things like "foo(8)"
92                    && get_unit_type(cleaned).is_some()
93                {
94                    failing_units.insert(cleaned.to_string());
95                    break; // Only take first unit name per line
96                }
97            }
98        }
99    }
100
101    failing_units
102}
103
104/// Log the pre-filter enumerated unit set at debug level.
105///
106/// Both enumeration paths (D-Bus `ListUnits` and the varlink metrics stream)
107/// call this with their full list so the integration test can diff the
108/// enumerated sets without paying for a full `systemd-analyze verify` run.
109/// Sorted so the line is deterministic regardless of enumeration order.
110fn log_enumerated_units(all_units: &[String]) {
111    let mut names: Vec<&str> = all_units.iter().map(String::as_str).collect();
112    names.sort_unstable();
113    tracing::debug!(
114        "verify enumerated {} units: {}",
115        names.len(),
116        names.join(",")
117    );
118}
119
120/// Filter unit names by the verify allowlist/blocklist.
121///
122/// Shared by the D-Bus and varlink enumeration paths so both check the same
123/// set for a given config.
124fn filter_unit_names(
125    all_units: Vec<String>,
126    allowlist: &HashSet<String>,
127    blocklist: &HashSet<String>,
128) -> Vec<String> {
129    all_units
130        .into_iter()
131        .filter(|unit_name| {
132            // Apply allowlist
133            if !allowlist.is_empty() && !allowlist.contains(unit_name) {
134                return false;
135            }
136            // Apply blocklist
137            if blocklist.contains(unit_name) {
138                return false;
139            }
140            true
141        })
142        .collect()
143}
144
145/// Build the `systemd-analyze verify` invocation for the given units.
146///
147/// The `--` separator is required: unit names like `-.mount` start with a
148/// dash and would otherwise be parsed as (bogus) options, making analyze exit
149/// before checking anything.
150fn build_verify_command(units_to_check: &[String]) -> Command {
151    let mut cmd = Command::new("systemd-analyze");
152    cmd.arg("verify");
153    cmd.arg("--");
154    for unit_name in units_to_check {
155        cmd.arg(unit_name);
156    }
157    cmd
158}
159
160/// Run `systemd-analyze verify` over the given units and count failures by type.
161///
162/// Shared by the D-Bus and varlink enumeration paths: only the source of the
163/// unit list differs. All units are checked in one invocation for performance.
164async fn verify_units(units_to_check: Vec<String>) -> Result<VerifyStats, MonitordVerifyError> {
165    let mut stats = VerifyStats::default();
166
167    if units_to_check.is_empty() {
168        return Ok(stats);
169    }
170
171    // Run systemd-analyze verify on all units at once for better performance
172    let output =
173        tokio::task::spawn_blocking(move || build_verify_command(&units_to_check).output())
174            .await
175            .map_err(|e| MonitordVerifyError::CommandError(e.to_string()))?
176            .map_err(|e| MonitordVerifyError::CommandError(e.to_string()))?;
177
178    // Parse stderr for failing units
179    let stderr = String::from_utf8_lossy(&output.stderr);
180    let failing_units = parse_verify_output(&stderr);
181
182    // Count failures by type
183    for unit_name in failing_units {
184        stats.total += 1;
185
186        if let Some(unit_type) = get_unit_type(&unit_name) {
187            *stats.by_type.entry(unit_type).or_insert(0) += 1;
188        }
189    }
190
191    Ok(stats)
192}
193
194/// Collect verification stats for all units in the system
195pub async fn get_verify_stats(
196    connection: &zbus::Connection,
197    allowlist: &HashSet<String>,
198    blocklist: &HashSet<String>,
199) -> Result<VerifyStats, MonitordVerifyError> {
200    // Get list of all units from systemd
201    let manager_proxy = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
202        .cache_properties(zbus::proxy::CacheProperties::No)
203        .build()
204        .await?;
205    let all_units = manager_proxy.list_units().await?;
206
207    let all_units: Vec<String> = all_units.into_iter().map(|unit| unit.0).collect();
208    log_enumerated_units(&all_units);
209    let units_to_check = filter_unit_names(all_units, allowlist, blocklist);
210    verify_units(units_to_check).await
211}
212
213/// Async wrapper that updates verify stats when passed a locked struct
214pub async fn update_verify_stats(
215    connection: zbus::Connection,
216    locked_machine_stats: Arc<RwLock<MachineStats>>,
217    allowlist: HashSet<String>,
218    blocklist: HashSet<String>,
219    varlink_enabled: bool,
220) -> anyhow::Result<()> {
221    let verify_stats = if varlink_enabled {
222        match crate::varlink_verify::list_unit_names(crate::varlink_verify::METRICS_SOCKET_PATH)
223            .await
224        {
225            Ok(all_units) => {
226                log_enumerated_units(&all_units);
227                locked_machine_stats.write().await.varlink_usage.verify =
228                    Some(crate::CollectorTransport::Varlink);
229                verify_units(filter_unit_names(all_units, &allowlist, &blocklist))
230                    .await
231                    .map_err(|e| anyhow::anyhow!("Error getting verify stats: {:?}", e))?
232            }
233            Err(err) => {
234                tracing::warn!(
235                    "Varlink verify enumeration failed, falling back to D-Bus: {:?}",
236                    err
237                );
238                locked_machine_stats.write().await.varlink_usage.verify =
239                    Some(crate::CollectorTransport::Dbus);
240                get_verify_stats(&connection, &allowlist, &blocklist)
241                    .await
242                    .map_err(|e| anyhow::anyhow!("Error getting verify stats: {:?}", e))?
243            }
244        }
245    } else {
246        locked_machine_stats.write().await.varlink_usage.verify =
247            Some(crate::CollectorTransport::Dbus);
248        get_verify_stats(&connection, &allowlist, &blocklist)
249            .await
250            .map_err(|e| anyhow::anyhow!("Error getting verify stats: {:?}", e))?
251    };
252
253    let mut machine_stats = locked_machine_stats.write().await;
254    machine_stats.verify_stats = Some(verify_stats);
255    Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_get_unit_type() {
264        assert_eq!(get_unit_type("foo.service"), Some("service".to_string()));
265        assert_eq!(get_unit_type("bar.slice"), Some("slice".to_string()));
266        assert_eq!(get_unit_type("baz.timer"), Some("timer".to_string()));
267        assert_eq!(get_unit_type("test"), Some("test".to_string()));
268    }
269
270    #[test]
271    fn test_filter_unit_names() {
272        let all = vec![
273            "wanted.service".to_string(),
274            "blocked.service".to_string(),
275            "other.timer".to_string(),
276        ];
277
278        // Empty allowlist: everything except blocklisted.
279        assert_eq!(
280            filter_unit_names(
281                all.clone(),
282                &HashSet::new(),
283                &HashSet::from(["blocked.service".to_string()]),
284            ),
285            vec!["wanted.service".to_string(), "other.timer".to_string()]
286        );
287
288        // Non-empty allowlist: only listed units, minus blocklisted.
289        assert_eq!(
290            filter_unit_names(
291                all.clone(),
292                &HashSet::from(["wanted.service".to_string(), "blocked.service".to_string()]),
293                &HashSet::from(["blocked.service".to_string()]),
294            ),
295            vec!["wanted.service".to_string()]
296        );
297    }
298
299    #[test]
300    fn test_build_verify_command_separates_flags_from_units() {
301        // Units like -.mount start with a dash: without `--`, analyze parses
302        // them as options and exits before checking anything.
303        let cmd = build_verify_command(&["-.mount".to_string(), "foo.service".to_string()]);
304        let args: Vec<_> = cmd.get_args().collect();
305        assert_eq!(args, vec!["verify", "--", "-.mount", "foo.service"]);
306    }
307
308    #[test]
309    fn test_verify_stats_default() {
310        let stats = VerifyStats::default();
311        assert_eq!(stats.total, 0);
312        assert_eq!(stats.by_type.len(), 0);
313    }
314
315    #[test]
316    fn test_parse_verify_output() {
317        let stderr = r#"
318/usr/lib/systemd/system/foo.service:4: Unknown section 'Service'. Ignoring.
319bar.slice: Command /bin/foo is not executable: No such file or directory
320Unit baz.timer not found.
321test-with-error.target: Some error message here
322"#;
323        let failing = parse_verify_output(stderr);
324        // Debug output
325        let mut sorted: Vec<_> = failing.iter().collect();
326        sorted.sort();
327        for unit in &sorted {
328            eprintln!("Found unit: {}", unit);
329        }
330
331        assert!(failing.contains("foo.service"));
332        assert!(failing.contains("bar.slice"));
333        assert!(failing.contains("baz.timer"));
334        assert!(failing.contains("test-with-error.target"));
335        assert_eq!(failing.len(), 4);
336    }
337}