Skip to main content

clawft_kernel/node_facts/
linux.rs

1//! Linux probes: CPU, distro/kernel, memory, storage, board class and
2//! binfmt emulation.
3//!
4//! Sources: `/proc/cpuinfo`, `/proc/device-tree/model`, `/etc/os-release`,
5//! `uname -r`, `/proc/meminfo`, `/proc/mounts`, `df`, and
6//! `/proc/sys/fs/binfmt_misc`.
7
8use clawft_types::placement::Provenance;
9
10use super::host::ProbeHost;
11use super::probe::{Collected, bytes_attr, cap, normalize_arch, note_external_withheld, parse_df, public_mount};
12
13const BINFMT: &str = "/proc/sys/fs/binfmt_misc";
14/// Mount roots treated as external (removable) drives.
15const EXTERNAL_ROOTS: &[&str] = &["/media/", "/mnt/", "/run/media/"];
16
17/// Arch a binfmt handler emulates, or `None` for handlers that are not
18/// CPU emulators (`register`, `status`, Mach-O shims, ...).
19pub fn binfmt_arch(name: &str) -> Option<String> {
20    if name.starts_with("rosetta") {
21        return Some("x86_64".into());
22    }
23    let arch = name.strip_prefix("qemu-")?;
24    Some(match arch {
25        "arm" => "armv7".into(),
26        other => normalize_arch(other),
27    })
28    .filter(|a: &String| !a.is_empty())
29}
30
31/// Parse `name enabled|disabled` lines into emulated arches, excluding the
32/// native one. Sorted and deduplicated.
33pub fn emulated_from_listing(lines: &str, native: &str) -> Vec<String> {
34    let mut out: Vec<String> = lines
35        .lines()
36        .filter_map(|l| {
37            let mut parts = l.split_whitespace();
38            let name = parts.next()?;
39            match parts.next() {
40                Some("enabled") | None => binfmt_arch(name),
41                _ => None,
42            }
43        })
44        .filter(|a| a != native)
45        .collect();
46    out.sort();
47    out.dedup();
48    out
49}
50
51/// Emulated arches registered with this kernel's binfmt_misc.
52pub fn host_binfmt_emulated(host: &dyn ProbeHost, native: &str) -> Vec<String> {
53    let listing: String = host
54        .list_dir(BINFMT)
55        .unwrap_or_default()
56        .into_iter()
57        .filter(|n| n != "register" && n != "status")
58        .map(|n| {
59            let state = host
60                .read_file(&format!("{BINFMT}/{n}"))
61                .and_then(|s| s.lines().next().map(str::to_string))
62                .unwrap_or_default();
63            format!("{n} {state}\n")
64        })
65        .collect();
66    emulated_from_listing(&listing, native)
67}
68
69fn kv<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
70    text.lines()
71        .filter_map(|l| l.split_once(sep))
72        .find(|(k, _)| k.trim() == key)
73        .map(|(_, v)| v.trim().trim_matches('"'))
74}
75
76/// Current available memory, from `/proc/meminfo` alone (cheap live probe).
77pub fn live_free(host: &dyn ProbeHost) -> Option<u64> {
78    host.read_file("/proc/meminfo")
79        .as_deref()
80        .and_then(|t| meminfo_bytes(t, "MemAvailable"))
81}
82
83fn meminfo_bytes(text: &str, key: &str) -> Option<u64> {
84    kv(text, key, ':')?
85        .split_whitespace()
86        .next()?
87        .parse::<u64>()
88        .ok()
89        .map(|kb| kb.saturating_mul(1024))
90}
91
92/// Run the Linux probes.
93pub fn probe(host: &dyn ProbeHost, arch: &str, c: &mut Collected) {
94    let cpuinfo = host.read_file("/proc/cpuinfo").unwrap_or_default();
95    let board = host
96        .read_file("/proc/device-tree/model")
97        .map(|s| s.trim_end_matches('\0').trim().to_string())
98        .filter(|s| !s.is_empty());
99
100    if let Some(mut cp) = cap(&format!("cpu.arch.{arch}"), Provenance::Probed) {
101        let cores = cpuinfo
102            .lines()
103            .filter(|l| l.starts_with("processor"))
104            .count();
105        if cores > 0 {
106            cp = cp.with_attr("cores", cores as i64);
107        }
108        let model = board
109            .clone()
110            .or_else(|| kv(&cpuinfo, "model name", ':').map(str::to_string));
111        if let Some(m) = model {
112            cp = cp.with_attr("model", m.as_str());
113        }
114        c.push(cp);
115    }
116
117    if let Some(mut os) = cap("os.linux", Provenance::Probed) {
118        if let Some(d) = host
119            .read_file("/etc/os-release")
120            .as_deref()
121            .and_then(|t| kv(t, "PRETTY_NAME", '=').map(str::to_string))
122        {
123            os = os.with_attr("distro", d.as_str());
124        }
125        if let Some(k) = host.run("uname", &["-r"]) {
126            os = os.with_attr("kernel", k.trim());
127        }
128        c.push(os);
129    }
130
131    if let Some(mi) = host.read_file("/proc/meminfo")
132        && let (Some(total), Some(m)) = (
133            meminfo_bytes(&mi, "MemTotal"),
134            cap("mem.system", Provenance::Probed),
135        )
136    {
137        let mut m = m.with_attr("total", bytes_attr(total));
138        if let Some(avail) = meminfo_bytes(&mi, "MemAvailable") {
139            m = m.with_attr("free", bytes_attr(avail));
140        }
141        c.push(m);
142    }
143
144    storage(host, c);
145
146    if let Some(b) = &board {
147        let class = if b.contains("Raspberry Pi 5") {
148            "pi5"
149        } else {
150            "other"
151        };
152        if let Some(nc) = cap(&format!("node.class.{class}"), Provenance::Probed) {
153            c.push(nc.with_attr("board", b.as_str()));
154        }
155    }
156}
157
158fn storage(host: &dyn ProbeHost, c: &mut Collected) {
159    if let Some((free, mount)) = host.run("df", &["-kP", "/"]).as_deref().and_then(parse_df)
160        && let Some(s) = cap("store.tier.internal", Provenance::Probed)
161    {
162        let mut s = s.with_attr("free", bytes_attr(free));
163        if let Some(m) = public_mount(&mount) {
164            s = s.with_attr("mount", m);
165        }
166        c.push(s);
167    }
168    let mounts = host.read_file("/proc/mounts").unwrap_or_default();
169    let mut external = 0;
170    for line in mounts.lines() {
171        let Some(mp) = line.split_whitespace().nth(1) else {
172            continue;
173        };
174        let mp = mp.replace("\\040", " ");
175        if !EXTERNAL_ROOTS.iter().any(|r| mp.starts_with(r)) {
176            continue;
177        }
178        let free = host.run("df", &["-kP", &mp]).as_deref().and_then(parse_df);
179        // Mount points carry the user and the drive label: they stay local.
180        if let Some(mut s) = cap("store.tier.external", Provenance::Probed) {
181            s = s.with_attr("mounted", true);
182            if let Some((f, _)) = free {
183                s = s.with_attr("free", bytes_attr(f));
184            }
185            c.push(s);
186            external += 1;
187        }
188    }
189    note_external_withheld(c, external);
190}