1use 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#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
25pub struct VerifyStats {
26 pub total: u64,
28 #[serde(flatten)]
31 pub by_type: HashMap<String, u64>,
32}
33
34fn get_unit_type(unit_name: &str) -> Option<String> {
36 if unit_name.len() < 3 {
38 return None;
39 }
40
41 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
50fn 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 if trimmed.contains("Failed to prepare filename") {
66 continue;
67 }
68
69 let mut found_in_line = false;
70
71 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 if !found_in_line {
86 for word in line.split_whitespace() {
87 let cleaned = word.trim_end_matches(':').trim_end_matches('.');
88 if cleaned.contains('.')
90 && cleaned.len() > 2 && !cleaned.contains('(') && get_unit_type(cleaned).is_some()
93 {
94 failing_units.insert(cleaned.to_string());
95 break; }
97 }
98 }
99 }
100
101 failing_units
102}
103
104fn 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
120fn 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 if !allowlist.is_empty() && !allowlist.contains(unit_name) {
134 return false;
135 }
136 if blocklist.contains(unit_name) {
138 return false;
139 }
140 true
141 })
142 .collect()
143}
144
145fn 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
160async 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 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 let stderr = String::from_utf8_lossy(&output.stderr);
180 let failing_units = parse_verify_output(&stderr);
181
182 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
194pub async fn get_verify_stats(
196 connection: &zbus::Connection,
197 allowlist: &HashSet<String>,
198 blocklist: &HashSet<String>,
199) -> Result<VerifyStats, MonitordVerifyError> {
200 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
213pub 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 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 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 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 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}