Skip to main content

sysknife_daemon/
state_collector.rs

1use serde::{Deserialize, Serialize};
2use std::io;
3
4/// Snapshot of live system state collected by the daemon.
5///
6/// Field names mirror `sysknife_brain::CuratedState` so the shell can deserialize
7/// the JSON representation without depending on this crate.
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub struct CollectedState {
10    /// Daemon host identity, filled from the same cached detection used by the
11    /// platform fence. Absent on older daemons or when detection failed.
12    #[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    /// A stable per-machine identity a client can compare against its own, so
23    /// `audit verify` can tell whether the daemon it talked to is this machine or
24    /// a forwarded/remote one (#146). It is a salted hash of `/etc/machine-id`,
25    /// never the raw id (systemd treats the raw machine-id as confidential and
26    /// warns against exposing it). `None` when `/etc/machine-id` is unreadable.
27    #[serde(default)]
28    pub machine_id_hash: Option<String>,
29}
30
31/// Domain-separated hash of the local `/etc/machine-id`, or `None` if it cannot
32/// be read. Both the daemon (here) and the client compute this the same way, so
33/// equal hashes mean the same machine without either side revealing the raw id.
34pub 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
43/// The exact hashing both sides must agree on. Split out so it can be tested
44/// deterministically without depending on the host's `/etc/machine-id`.
45pub 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
62/// Abstraction over command execution, making state collection testable
63/// without requiring system tools to be installed.
64pub trait CommandRunner: Send + Sync {
65    fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error>;
66}
67
68/// Production implementation that delegates to `std::process::Command`.
69pub 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
87/// Run a best-effort state probe, returning the parsed value or its default.
88///
89/// A missing binary (`NotFound`) is expected on distros that don't ship the
90/// tool (e.g. `rpm-ostree`/`toolbox` on Ubuntu), so it stays quiet. Any other
91/// error means the tool exists but the probe broke — log it (instead of
92/// silently swallowing) so an operator can tell "genuinely empty" from
93/// "collection failed".
94fn 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
110/// Collect a system state snapshot using the provided runner.
111///
112/// Only `host_name` is required. All other fields (`deployment`, `services`,
113/// `flatpaks`, `toolboxes`) are best-effort and default to empty on failure so
114/// the daemon works on non-Silverblue systems and in CI. The brain's safety
115/// fence will reject irrelevant actions based on the curated state it receives.
116pub 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    // Call rpm-ostree once and reuse for both deployment and layered_packages.
126    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
189/// Extract the first whitespace-delimited field from each non-empty line.
190/// Suitable for systemctl's columnar output where the first field is the unit name.
191fn 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
199/// Return non-empty trimmed lines from command output.
200fn 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
208/// Parse the booted deployment from `rpm-ostree status --booted --json` into a
209/// human-readable summary like `"fedora/41/x86_64/silverblue (booted, v41.20260401.0)"`.
210///
211/// Returns `""` if parsing fails or the input is not valid JSON.
212fn 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
244/// Extract layered package names from `rpm-ostree status --booted --json`.
245///
246/// The JSON contains `deployments[0].requested-packages` (user-requested layers)
247/// and optionally `requested-local-packages`. We merge both lists.
248fn 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
274/// Extract local human users from `getent passwd` output.
275///
276/// Filters to uid >= 1000, excludes `nobody` and `nfsnobody`, and excludes
277/// accounts whose login shell ends with `nologin` or `/false`.
278fn parse_local_users(output: &str) -> Vec<String> {
279    output
280        .lines()
281        .filter_map(|line| {
282            let fields: Vec<&str> = line.split(':').collect();
283            // passwd format: name:pwd:uid:gid:gecos:home:shell (7 fields)
284            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        // rpm-ostree is not available on all systems (e.g. Fedora Workstation,
451        // Ubuntu, CI). A missing binary must produce an empty deployment string,
452        // not an error, so the daemon stays functional on non-Silverblue hosts.
453        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        // Deterministic and dependent on the input.
533        assert_eq!(hash_machine_id("abc"), hash_machine_id("abc"));
534        assert_ne!(hash_machine_id("abc"), hash_machine_id("abd"));
535        // 64 hex chars (sha256) and never the raw id.
536        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}