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    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    /// A stable per-machine identity a client can compare against its own, so
19    /// `audit verify` can tell whether the daemon it talked to is this machine or
20    /// a forwarded/remote one (#146). It is a salted hash of `/etc/machine-id`,
21    /// never the raw id (systemd treats the raw machine-id as confidential and
22    /// warns against exposing it). `None` when `/etc/machine-id` is unreadable.
23    #[serde(default)]
24    pub machine_id_hash: Option<String>,
25}
26
27/// Domain-separated hash of the local `/etc/machine-id`, or `None` if it cannot
28/// be read. Both the daemon (here) and the client compute this the same way, so
29/// equal hashes mean the same machine without either side revealing the raw id.
30pub 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
39/// The exact hashing both sides must agree on. Split out so it can be tested
40/// deterministically without depending on the host's `/etc/machine-id`.
41pub 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
58/// Abstraction over command execution, making state collection testable
59/// without requiring system tools to be installed.
60pub trait CommandRunner: Send + Sync {
61    fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error>;
62}
63
64/// Production implementation that delegates to `std::process::Command`.
65pub 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
83/// Run a best-effort state probe, returning the parsed value or its default.
84///
85/// A missing binary (`NotFound`) is expected on distros that don't ship the
86/// tool (e.g. `rpm-ostree`/`toolbox` on Ubuntu), so it stays quiet. Any other
87/// error means the tool exists but the probe broke — log it (instead of
88/// silently swallowing) so an operator can tell "genuinely empty" from
89/// "collection failed".
90fn 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
106/// Collect a system state snapshot using the provided runner.
107///
108/// Only `host_name` is required. All other fields (`deployment`, `services`,
109/// `flatpaks`, `toolboxes`) are best-effort and default to empty on failure so
110/// the daemon works on non-Silverblue systems and in CI. The brain's safety
111/// fence will reject irrelevant actions based on the curated state it receives.
112pub 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    // Call rpm-ostree once and reuse for both deployment and layered_packages.
122    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
184/// Extract the first whitespace-delimited field from each non-empty line.
185/// Suitable for systemctl's columnar output where the first field is the unit name.
186fn 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
194/// Return non-empty trimmed lines from command output.
195fn 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
203/// Parse the booted deployment from `rpm-ostree status --booted --json` into a
204/// human-readable summary like `"fedora/41/x86_64/silverblue (booted, v41.20260401.0)"`.
205///
206/// Returns `""` if parsing fails or the input is not valid JSON.
207fn 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
239/// Extract layered package names from `rpm-ostree status --booted --json`.
240///
241/// The JSON contains `deployments[0].requested-packages` (user-requested layers)
242/// and optionally `requested-local-packages`. We merge both lists.
243fn 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
269/// Extract local human users from `getent passwd` output.
270///
271/// Filters to uid >= 1000, excludes `nobody` and `nfsnobody`, and excludes
272/// accounts whose login shell ends with `nologin` or `/false`.
273fn parse_local_users(output: &str) -> Vec<String> {
274    output
275        .lines()
276        .filter_map(|line| {
277            let fields: Vec<&str> = line.split(':').collect();
278            // passwd format: name:pwd:uid:gid:gecos:home:shell (7 fields)
279            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        // rpm-ostree is not available on all systems (e.g. Fedora Workstation,
446        // Ubuntu, CI). A missing binary must produce an empty deployment string,
447        // not an error, so the daemon stays functional on non-Silverblue hosts.
448        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        // Deterministic and dependent on the input.
520        assert_eq!(hash_machine_id("abc"), hash_machine_id("abc"));
521        assert_ne!(hash_machine_id("abc"), hash_machine_id("abd"));
522        // 64 hex chars (sha256) and never the raw id.
523        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}