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