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