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}
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
29/// Abstraction over command execution, making state collection testable
30/// without requiring system tools to be installed.
31pub trait CommandRunner: Send + Sync {
32    fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error>;
33}
34
35/// Production implementation that delegates to `std::process::Command`.
36pub 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
54/// Run a best-effort state probe, returning the parsed value or its default.
55///
56/// A missing binary (`NotFound`) is expected on distros that don't ship the
57/// tool (e.g. `rpm-ostree`/`toolbox` on Ubuntu), so it stays quiet. Any other
58/// error means the tool exists but the probe broke — log it (instead of
59/// silently swallowing) so an operator can tell "genuinely empty" from
60/// "collection failed".
61fn 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
77/// Collect a system state snapshot using the provided runner.
78///
79/// Only `host_name` is required. All other fields (`deployment`, `services`,
80/// `flatpaks`, `toolboxes`) are best-effort and default to empty on failure so
81/// the daemon works on non-Silverblue systems and in CI. The brain's safety
82/// fence will reject irrelevant actions based on the curated state it receives.
83pub 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    // Call rpm-ostree once and reuse for both deployment and layered_packages.
93    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
154/// Extract the first whitespace-delimited field from each non-empty line.
155/// Suitable for systemctl's columnar output where the first field is the unit name.
156fn 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
164/// Return non-empty trimmed lines from command output.
165fn 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
173/// Parse the booted deployment from `rpm-ostree status --booted --json` into a
174/// human-readable summary like `"fedora/41/x86_64/silverblue (booted, v41.20260401.0)"`.
175///
176/// Returns `""` if parsing fails or the input is not valid JSON.
177fn 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
209/// Extract layered package names from `rpm-ostree status --booted --json`.
210///
211/// The JSON contains `deployments[0].requested-packages` (user-requested layers)
212/// and optionally `requested-local-packages`. We merge both lists.
213fn 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
239/// Extract local human users from `getent passwd` output.
240///
241/// Filters to uid >= 1000, excludes `nobody` and `nfsnobody`, and excludes
242/// accounts whose login shell ends with `nologin` or `/false`.
243fn parse_local_users(output: &str) -> Vec<String> {
244    output
245        .lines()
246        .filter_map(|line| {
247            let fields: Vec<&str> = line.split(':').collect();
248            // passwd format: name:pwd:uid:gid:gecos:home:shell (7 fields)
249            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        // rpm-ostree is not available on all systems (e.g. Fedora Workstation,
416        // Ubuntu, CI). A missing binary must produce an empty deployment string,
417        // not an error, so the daemon stays functional on non-Silverblue hosts.
418        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}