1use serde::{Deserialize, Serialize};
2use std::io;
3
4#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub struct CollectedState {
10 pub host_name: String,
11 pub deployment: String,
12 pub services: Vec<String>,
13 pub flatpaks: Vec<String>,
14 pub toolboxes: Vec<String>,
15 pub layered_packages: Vec<String>,
16 pub containers: Vec<String>,
17 pub users: Vec<String>,
18}
19
20#[derive(Debug, thiserror::Error)]
21pub enum CollectorError {
22 #[error("command failed for {command}: {reason}")]
23 CommandFailed {
24 command: &'static str,
25 reason: String,
26 },
27}
28
29pub trait CommandRunner: Send + Sync {
32 fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error>;
33}
34
35pub struct RealCommandRunner;
37
38impl CommandRunner for RealCommandRunner {
39 fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error> {
40 let output = std::process::Command::new(program).args(args).output()?;
41 if !output.status.success() {
42 let stderr = String::from_utf8_lossy(&output.stderr);
43 return Err(io::Error::other(format!(
44 "{} exited with status {}: {}",
45 program,
46 output.status,
47 stderr.trim()
48 )));
49 }
50 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
51 }
52}
53
54fn probe_or_warn<T: Default>(
62 label: &str,
63 result: Result<String, io::Error>,
64 parse: impl FnOnce(&str) -> T,
65) -> T {
66 match result {
67 Ok(s) => parse(&s),
68 Err(e) => {
69 if e.kind() != io::ErrorKind::NotFound {
70 eprintln!("[sysknife-daemon] state: {label} probe failed ({e}); reporting empty");
71 }
72 T::default()
73 }
74 }
75}
76
77pub fn collect_state(runner: &dyn CommandRunner) -> Result<CollectedState, CollectorError> {
84 let host_name = runner
85 .run("hostname", &[])
86 .map(|s| s.trim().to_string())
87 .map_err(|e| CollectorError::CommandFailed {
88 command: "hostname",
89 reason: e.to_string(),
90 })?;
91
92 let rpm_ostree_output = probe_or_warn(
94 "rpm-ostree",
95 runner.run("rpm-ostree", &["status", "--booted", "--json"]),
96 |s| s.trim().to_string(),
97 );
98
99 let deployment = parse_deployment_summary(&rpm_ostree_output);
100 let layered_packages = parse_layered_packages(&rpm_ostree_output);
101
102 let services = probe_or_warn(
103 "systemctl",
104 runner.run(
105 "systemctl",
106 &[
107 "list-units",
108 "--type=service",
109 "--state=running",
110 "--no-legend",
111 "--no-pager",
112 "--plain",
113 ],
114 ),
115 parse_service_lines,
116 );
117
118 let flatpaks = probe_or_warn(
119 "flatpak",
120 runner.run("flatpak", &["list", "--columns=application"]),
121 parse_lines,
122 );
123
124 let toolboxes = probe_or_warn(
125 "toolbox",
126 runner.run("toolbox", &["list", "--containers"]),
127 parse_lines,
128 );
129
130 let containers = probe_or_warn(
131 "podman",
132 runner.run("podman", &["ps", "--format", "{{.Names}}"]),
133 parse_lines,
134 );
135
136 let users = probe_or_warn(
137 "getent",
138 runner.run("getent", &["passwd", "--service", "files"]),
139 parse_local_users,
140 );
141
142 Ok(CollectedState {
143 host_name,
144 deployment,
145 services,
146 flatpaks,
147 toolboxes,
148 layered_packages,
149 containers,
150 users,
151 })
152}
153
154fn parse_service_lines(output: &str) -> Vec<String> {
157 output
158 .lines()
159 .filter_map(|l| l.split_whitespace().next())
160 .map(String::from)
161 .collect()
162}
163
164fn parse_lines(output: &str) -> Vec<String> {
166 output
167 .lines()
168 .map(|l| l.trim().to_string())
169 .filter(|l| !l.is_empty())
170 .collect()
171}
172
173fn parse_deployment_summary(json_output: &str) -> String {
178 let Ok(val) = serde_json::from_str::<serde_json::Value>(json_output) else {
179 return String::new();
180 };
181 let booted = val
182 .get("deployments")
183 .and_then(|v| v.as_array())
184 .and_then(|arr| arr.first());
185 let Some(dep) = booted else {
186 return String::new();
187 };
188 let origin = dep
189 .get("origin")
190 .and_then(|v| v.as_str())
191 .unwrap_or("unknown");
192 let is_booted = dep.get("booted").and_then(|v| v.as_bool()).unwrap_or(false);
193 let version = dep.get("version").and_then(|v| v.as_str());
194
195 let mut summary = origin.to_string();
196 let mut tags = Vec::new();
197 if is_booted {
198 tags.push("booted".to_string());
199 }
200 if let Some(v) = version {
201 tags.push(format!("v{v}"));
202 }
203 if !tags.is_empty() {
204 summary.push_str(&format!(" ({})", tags.join(", ")));
205 }
206 summary
207}
208
209fn parse_layered_packages(json_output: &str) -> Vec<String> {
214 let Ok(val) = serde_json::from_str::<serde_json::Value>(json_output) else {
215 return Vec::new();
216 };
217 let deployments = match val.get("deployments").and_then(|v| v.as_array()) {
218 Some(arr) => arr,
219 None => return Vec::new(),
220 };
221 let Some(booted) = deployments.first() else {
222 return Vec::new();
223 };
224 let mut pkgs = Vec::new();
225 for key in &["requested-packages", "requested-local-packages"] {
226 if let Some(arr) = booted.get(*key).and_then(|v| v.as_array()) {
227 for item in arr {
228 if let Some(s) = item.as_str() {
229 if !s.is_empty() {
230 pkgs.push(s.to_string());
231 }
232 }
233 }
234 }
235 }
236 pkgs
237}
238
239fn parse_local_users(output: &str) -> Vec<String> {
244 output
245 .lines()
246 .filter_map(|line| {
247 let fields: Vec<&str> = line.split(':').collect();
248 if fields.len() < 7 {
250 return None;
251 }
252 let username = fields[0];
253 let uid: u32 = fields[2].parse().ok()?;
254 let shell = fields[6];
255 if uid >= 1000
256 && username != "nobody"
257 && username != "nfsnobody"
258 && !shell.ends_with("nologin")
259 && !shell.ends_with("/false")
260 {
261 Some(username.to_string())
262 } else {
263 None
264 }
265 })
266 .collect()
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use std::collections::HashMap;
273
274 struct MockRunner {
275 responses: HashMap<String, String>,
276 }
277
278 impl MockRunner {
279 fn new(entries: &[(&str, &[&str], &str)]) -> Self {
280 let responses = entries
281 .iter()
282 .map(|(program, args, output)| {
283 let key = std::iter::once(*program)
284 .chain(args.iter().copied())
285 .collect::<Vec<_>>()
286 .join(" ");
287 (key, output.to_string())
288 })
289 .collect();
290 Self { responses }
291 }
292 }
293
294 impl CommandRunner for MockRunner {
295 fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error> {
296 let key = std::iter::once(program)
297 .chain(args.iter().copied())
298 .collect::<Vec<_>>()
299 .join(" ");
300 Ok(self.responses.get(&key).cloned().unwrap_or_default())
301 }
302 }
303
304 #[test]
305 fn collect_state_parses_hostname_and_deployment() {
306 let runner = MockRunner::new(&[
307 ("hostname", &[], "silverblue-lab\n"),
308 (
309 "rpm-ostree",
310 &["status", "--booted", "--json"],
311 r#"{"deployments":[{"origin":"fedora/41/x86_64/silverblue","booted":true,"version":"41.20260401.0","requested-packages":["vim","htop"]}]}"#,
312 ),
313 (
314 "systemctl",
315 &[
316 "list-units",
317 "--type=service",
318 "--state=running",
319 "--no-legend",
320 "--no-pager",
321 "--plain",
322 ],
323 "sshd.service loaded active running OpenSSH server\n",
324 ),
325 (
326 "flatpak",
327 &["list", "--columns=application"],
328 "org.gnome.Gedit\n",
329 ),
330 ("toolbox", &["list", "--containers"], "sysknife-dev\n"),
331 (
332 "podman",
333 &["ps", "--format", "{{.Names}}"],
334 "my-container\n",
335 ),
336 (
337 "getent",
338 &["passwd", "--service", "files"],
339 "root:x:0:0:root:/root:/bin/bash\njane:x:1000:1000:Jane:/home/jane:/bin/bash\n",
340 ),
341 ]);
342
343 let state = collect_state(&runner).unwrap();
344
345 assert_eq!(state.host_name, "silverblue-lab");
346 assert_eq!(
347 state.deployment,
348 "fedora/41/x86_64/silverblue (booted, v41.20260401.0)"
349 );
350 assert_eq!(state.services, vec!["sshd.service"]);
351 assert_eq!(state.flatpaks, vec!["org.gnome.Gedit"]);
352 assert_eq!(state.toolboxes, vec!["sysknife-dev"]);
353 assert_eq!(state.layered_packages, vec!["vim", "htop"]);
354 assert_eq!(state.containers, vec!["my-container"]);
355 assert_eq!(state.users, vec!["jane"]);
356 }
357
358 #[test]
359 fn collect_state_trims_hostname_whitespace() {
360 let runner = MockRunner::new(&[
361 ("hostname", &[], " my-host \n"),
362 ("rpm-ostree", &["status", "--booted", "--json"], "{}"),
363 ]);
364
365 let state = collect_state(&runner).unwrap();
366 assert_eq!(state.host_name, "my-host");
367 }
368
369 #[test]
370 fn collect_state_defaults_to_empty_lists_on_optional_command_failure() {
371 struct PartialRunner;
372 impl CommandRunner for PartialRunner {
373 fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
374 match program {
375 "hostname" => Ok("host\n".to_string()),
376 "rpm-ostree" => Ok("{}".to_string()),
377 _ => Err(io::Error::new(io::ErrorKind::NotFound, "not found")),
378 }
379 }
380 }
381
382 let state = collect_state(&PartialRunner).unwrap();
383 assert!(state.services.is_empty());
384 assert!(state.flatpaks.is_empty());
385 assert!(state.toolboxes.is_empty());
386 assert!(state.layered_packages.is_empty());
387 assert!(state.containers.is_empty());
388 assert!(state.users.is_empty());
389 }
390
391 #[test]
392 fn collect_state_returns_error_when_hostname_fails() {
393 struct FailingRunner;
394 impl CommandRunner for FailingRunner {
395 fn run(&self, _program: &str, _args: &[&str]) -> Result<String, io::Error> {
396 Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
397 }
398 }
399
400 let result = collect_state(&FailingRunner);
401 assert!(
402 matches!(
403 result,
404 Err(CollectorError::CommandFailed {
405 command: "hostname",
406 ..
407 })
408 ),
409 "expected CommandFailed(hostname), got: {result:?}"
410 );
411 }
412
413 #[test]
414 fn collect_state_returns_empty_deployment_when_rpm_ostree_missing() {
415 struct NoRpmOstreeRunner;
419 impl CommandRunner for NoRpmOstreeRunner {
420 fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
421 match program {
422 "hostname" => Ok("non-silverblue-host\n".to_string()),
423 _ => Err(io::Error::new(io::ErrorKind::NotFound, "not found")),
424 }
425 }
426 }
427
428 let state = collect_state(&NoRpmOstreeRunner).unwrap();
429 assert_eq!(state.host_name, "non-silverblue-host");
430 assert_eq!(
431 state.deployment, "",
432 "deployment must be empty, not an error"
433 );
434 assert!(state.services.is_empty());
435 assert!(state.flatpaks.is_empty());
436 assert!(state.toolboxes.is_empty());
437 assert!(state.layered_packages.is_empty());
438 assert!(state.containers.is_empty());
439 assert!(state.users.is_empty());
440 }
441
442 #[test]
443 fn collect_state_parses_multiple_services() {
444 let runner = MockRunner::new(&[
445 ("hostname", &[], "host\n"),
446 ("rpm-ostree", &["status", "--booted", "--json"], "{}"),
447 (
448 "systemctl",
449 &[
450 "list-units",
451 "--type=service",
452 "--state=running",
453 "--no-legend",
454 "--no-pager",
455 "--plain",
456 ],
457 "sshd.service loaded active running SSH\nNetworkManager.service loaded active running NM\n",
458 ),
459 ]);
460
461 let state = collect_state(&runner).unwrap();
462 assert_eq!(
463 state.services,
464 vec!["sshd.service", "NetworkManager.service"]
465 );
466 }
467
468 #[test]
469 fn collected_state_round_trips_through_json() {
470 let state = CollectedState {
471 host_name: "lab".to_string(),
472 deployment: "{}".to_string(),
473 services: vec!["sshd.service".to_string()],
474 flatpaks: vec!["org.mozilla.firefox".to_string()],
475 toolboxes: vec![],
476 layered_packages: vec!["vim".to_string()],
477 containers: vec!["dev-box".to_string()],
478 users: vec!["alice".to_string()],
479 };
480
481 let json = serde_json::to_string(&state).unwrap();
482 let restored: CollectedState = serde_json::from_str(&json).unwrap();
483 assert_eq!(state, restored);
484 }
485
486 #[test]
487 fn parse_layered_packages_extracts_requested_packages() {
488 let json = r#"{"deployments":[{"requested-packages":["vim","htop"],"requested-local-packages":["my-tool"]}]}"#;
489 let pkgs = super::parse_layered_packages(json);
490 assert_eq!(pkgs, vec!["vim", "htop", "my-tool"]);
491 }
492
493 #[test]
494 fn parse_layered_packages_handles_empty_deployments() {
495 let json = r#"{"deployments":[]}"#;
496 assert!(super::parse_layered_packages(json).is_empty());
497 }
498
499 #[test]
500 fn parse_layered_packages_handles_invalid_json() {
501 assert!(super::parse_layered_packages("not json").is_empty());
502 }
503
504 #[test]
505 fn parse_deployment_summary_extracts_origin_and_version() {
506 let json = r#"{"deployments":[{"origin":"fedora/41/x86_64/silverblue","booted":true,"version":"41.20260401.0","requested-packages":[]}]}"#;
507 assert_eq!(
508 super::parse_deployment_summary(json),
509 "fedora/41/x86_64/silverblue (booted, v41.20260401.0)"
510 );
511 }
512
513 #[test]
514 fn parse_deployment_summary_handles_missing_version() {
515 let json = r#"{"deployments":[{"origin":"fedora/41/x86_64/silverblue","booted":true}]}"#;
516 assert_eq!(
517 super::parse_deployment_summary(json),
518 "fedora/41/x86_64/silverblue (booted)"
519 );
520 }
521
522 #[test]
523 fn parse_deployment_summary_returns_empty_on_invalid_json() {
524 assert_eq!(super::parse_deployment_summary("not json"), "");
525 assert_eq!(super::parse_deployment_summary("{}"), "");
526 assert_eq!(super::parse_deployment_summary(r#"{"deployments":[]}"#), "");
527 }
528
529 #[test]
530 fn parse_local_users_filters_by_uid_and_excludes_system_accounts() {
531 let passwd = "root:x:0:0:root:/root:/bin/bash\n\
532 daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n\
533 jane:x:1000:1000:Jane:/home/jane:/bin/bash\n\
534 bob:x:1001:1001:Bob:/home/bob:/bin/bash\n\
535 svcacct:x:1002:1002:Service Account:/home/svcacct:/usr/sbin/nologin\n\
536 blocked:x:1003:1003:Blocked:/home/blocked:/bin/false\n\
537 nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n\
538 nfsnobody:x:65534:65534:nfsnobody:/nonexistent:/usr/sbin/nologin\n";
539 let users = super::parse_local_users(passwd);
540 assert_eq!(users, vec!["jane", "bob"]);
541 }
542
543 #[test]
544 fn parse_local_users_handles_empty_output() {
545 assert!(super::parse_local_users("").is_empty());
546 }
547}