Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/src/dashboard/probe.rs

//! **What the header band says about the machine**, read the cheap way.
//!
//! Every number comes from a file under `/proc`, one `statfs`, or one
//! `RTM_GETADDR` dump over the same `NETLINK_ROUTE` socket family
//! edda's `appliance_pid1::net` configures the interface with. No `sysinfo`, no `libc`
//! walking, nothing that needs `/sys` (which on the appliance may be a bare
//! mount point) — and every reader returns `Option`/an empty list on any
//! failure, because the screen is decoration and a missing number is shown
//! as a dash rather than becoming a reason PID 1 is unhappy.
//!
//! The parsers are pure over `&str` so the tests feed them recorded `/proc`
//! text (measured: the assertion is on the parsed value, not on "it did not
//! error").

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

// The netlink framing (lane G6): edda's own encoders, public since appliance-pid1
// 0.1.5, so this read side keeps no copy of them.
use appliance_pid1::net::{align4, netlink_message, netlink_socket};

/// A jiffies snapshot of the first `cpu` line of `/proc/stat`, for the busy
/// percentage between two reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CpuTicks {
    pub busy: u64,
    pub total: u64,
}

impl CpuTicks {
    /// Busy share of the interval between `earlier` and `self`, 0–100.
    /// `None` when the interval is empty (two identical reads, or the first
    /// tick).
    pub fn busy_percent_since(self, earlier: CpuTicks) -> Option<u8> {
        let total = self.total.checked_sub(earlier.total)?;
        if total == 0 {
            return None;
        }
        let busy = self.busy.saturating_sub(earlier.busy);
        Some((busy * 100 / total).min(100) as u8)
    }
}

/// `/proc/stat`'s aggregate `cpu` line → ticks, and how many `cpuN` lines
/// follow it (= online CPUs).
pub fn parse_stat(text: &str) -> (Option<CpuTicks>, usize) {
    let mut ticks = None;
    let mut cpus = 0;
    for line in text.lines() {
        let mut parts = line.split_ascii_whitespace();
        let Some(name) = parts.next() else { continue };
        if name == "cpu" {
            let v: Vec<u64> = parts.filter_map(|p| p.parse().ok()).collect();
            // user nice system idle iowait irq softirq steal …
            if v.len() >= 4 {
                let total: u64 = v.iter().sum();
                let idle = v[3] + v.get(4).copied().unwrap_or(0);
                ticks = Some(CpuTicks {
                    busy: total.saturating_sub(idle),
                    total,
                });
            }
        } else if name.starts_with("cpu") && name[3..].chars().all(|c| c.is_ascii_digit()) {
            cpus += 1;
        }
    }
    (ticks, cpus)
}

/// `(MemTotal, MemAvailable)` in bytes out of `/proc/meminfo`.
pub fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
    let mut total = None;
    let mut avail = None;
    for line in text.lines() {
        let (key, rest) = line.split_once(':')?;
        let kib: u64 = rest.split_ascii_whitespace().next()?.parse().ok()?;
        match key {
            "MemTotal" => total = Some(kib * 1024),
            "MemAvailable" => avail = Some(kib * 1024),
            _ => {}
        }
        if total.is_some() && avail.is_some() {
            break;
        }
    }
    Some((total?, avail?))
}

/// Whole seconds out of `/proc/uptime`.
pub fn parse_uptime(text: &str) -> Option<u64> {
    let first = text.split_ascii_whitespace().next()?;
    let secs: f64 = first.parse().ok()?;
    Some(secs as u64)
}

/// The 1-minute load out of `/proc/loadavg`, as printed.
pub fn parse_loadavg(text: &str) -> Option<String> {
    text.split_ascii_whitespace().next().map(str::to_owned)
}

/// The first `model name` out of `/proc/cpuinfo`, shortened: vendor noise
/// (`(R)`, `(TM)`, `CPU @ 2.60GHz`, `Processor`) stripped, so it fits a cell.
///
/// `N-Core` / `N-Cores` goes too: it is the HOST chip's core count, and on a
/// 4-vCPU cloud plan `4x AMD EPYC 7542 32-Core` read as two contradicting
/// numbers (measured on UpCloud 2026-08-29). The vCPU count the band prints
/// in front of the name is the one that is true of this box.
pub fn parse_cpu_model(text: &str) -> Option<String> {
    let line = text.lines().find(|l| l.starts_with("model name"))?;
    let (_, value) = line.split_once(':')?;
    let mut out = String::new();
    for word in value.split_ascii_whitespace() {
        let w = word
            .replace("(R)", "")
            .replace("(TM)", "")
            .replace("(tm)", "");
        if w.is_empty()
            || w == "CPU"
            || w == "Processor"
            || w == "@"
            || w.ends_with("GHz")
            || is_core_count(&w)
        {
            continue;
        }
        if !out.is_empty() {
            out.push(' ');
        }
        out.push_str(&w);
    }
    if out.is_empty() {
        None
    } else {
        Some(out)
    }
}

/// `32-Core` / `64-Cores`: the host chip's core count in a model name.
fn is_core_count(word: &str) -> bool {
    let Some(digits) = word
        .strip_suffix("-Cores")
        .or_else(|| word.strip_suffix("-Core"))
    else {
        return false;
    };
    !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
}

// ── what the data root IS: /proc/mounts + the md sysfs ──────────────────────

/// The filesystem line of `/proc/mounts` for one mount point.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountFacts {
    /// `/dev/md127`, `/dev/md/korp-data`, `/dev/vda3`, …
    pub source: String,
    /// `xfs`, `vfat`, …
    pub fstype: String,
}

/// The LAST `/proc/mounts` line whose mount point is `mountpoint` (a later
/// mount shadows an earlier one at the same path). Octal escapes in the path
/// (`\040` for a space) are decoded so `/data` matches only `/data`.
pub fn parse_mounts(text: &str, mountpoint: &str) -> Option<MountFacts> {
    let mut found = None;
    for line in text.lines() {
        let mut f = line.split_ascii_whitespace();
        let (Some(source), Some(target), Some(fstype)) = (f.next(), f.next(), f.next()) else {
            continue;
        };
        if unescape_mount(target) == mountpoint {
            found = Some(MountFacts {
                source: unescape_mount(source),
                fstype: fstype.to_owned(),
            });
        }
    }
    found
}

fn unescape_mount(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'\\'
            && i + 3 < b.len()
            && b[i + 1..i + 4].iter().all(|c| (b'0'..=b'7').contains(c))
        {
            let v = (b[i + 1] - b'0') * 64 + (b[i + 2] - b'0') * 8 + (b[i + 3] - b'0');
            out.push(v);
            i += 4;
        } else {
            out.push(b[i]);
            i += 1;
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// An md array's shape as its sysfs `md/` directory says it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetFacts {
    /// `raid0`, `raid1`, …
    pub level: String,
    /// `md/raid_disks`.
    pub members: usize,
    /// The smallest member's share of the array, bytes (`md/dev-*/size` is
    /// KiB); members are equal by korp-installer's rule, so this is the size
    /// a human recognises as "the disk".
    pub member_bytes: u64,
    /// `md/chunk_size`, bytes.
    pub chunk_bytes: u64,
}

/// Read one array off its sysfs block directory (`/sys/block/md127`,
/// `/sys/dev/block/9:127`): `md/level`, `md/raid_disks`, `md/chunk_size`,
/// and every `md/dev-*/size`. `None` unless all of level, count and at least
/// one member size are there — a half-known set is printed as nothing, never
/// as `0×0G` (the identity-value trap: measure, never assert).
pub fn read_set_facts(block_dir: &std::path::Path) -> Option<SetFacts> {
    let md = block_dir.join("md");
    let text = |name: &str| {
        std::fs::read_to_string(md.join(name))
            .ok()
            .map(|t| t.trim().to_owned())
    };
    let level = text("level").filter(|l| !l.is_empty())?;
    let members: usize = text("raid_disks")?.parse().ok().filter(|&n| n > 0)?;
    let chunk_bytes: u64 = text("chunk_size").and_then(|c| c.parse().ok()).unwrap_or(0);
    let mut smallest: Option<u64> = None;
    for entry in std::fs::read_dir(&md).ok()? {
        let Ok(entry) = entry else { continue };
        let name = entry.file_name();
        let Some(dev) = name.to_str().and_then(|n| n.strip_prefix("dev-")) else {
            continue;
        };
        let kib = std::fs::read_to_string(entry.path().join("size"))
            .ok()
            .and_then(|t| t.trim().parse::<u64>().ok())
            .map(|k| k * 1024)
            .or_else(|| {
                // Fallback: the member's own block size, in 512-byte sectors.
                std::fs::read_to_string(block_dir.join("../../class/block").join(dev).join("size"))
                    .ok()
                    .and_then(|t| t.trim().parse::<u64>().ok())
                    .map(|s| s * 512)
            });
        if let Some(b) = kib.filter(|&b| b > 0) {
            smallest = Some(smallest.map_or(b, |s| s.min(b)));
        }
    }
    Some(SetFacts {
        level,
        members,
        member_bytes: smallest?,
        chunk_bytes,
    })
}

/// What the data root IS, for the `disk` cell: the filesystem, and the array
/// under it when the source is an md device.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiskKind {
    /// `xfs`, `fat` (the screen's word for `vfat`/`msdos`), else as mounted.
    pub fs: String,
    pub set: Option<SetFacts>,
}

impl DiskKind {
    pub fn from_mount(mount: &MountFacts, set: Option<SetFacts>) -> Self {
        let fs = match mount.fstype.as_str() {
            "vfat" | "msdos" | "fat" => "fat".to_owned(),
            other => other.to_owned(),
        };
        DiskKind { fs, set }
    }
}

impl std::fmt::Display for DiskKind {
    /// `4×75G raid0 xfs` / `xfs` / `fat`.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(set) = &self.set {
            write!(
                f,
                "{}\u{d7}{} {} ",
                set.members,
                crate::dashboard::fmt_bytes(set.member_bytes),
                set.level
            )?;
        }
        f.write_str(&self.fs)
    }
}

/// The sysfs block directory of the device node at `source`, through
/// `/sys/dev/block/<major>:<minor>` — the one door that works whatever the
/// node is called (`/dev/md127`, `/dev/md/korp-data` mknod'ed by korp-start).
fn sysfs_block_dir_of(source: &str) -> Option<std::path::PathBuf> {
    use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};
    let meta = std::fs::metadata(source).ok()?;
    if !meta.file_type().is_block_device() {
        return None;
    }
    let rdev = meta.rdev();
    let dir = std::path::PathBuf::from(format!(
        "/sys/dev/block/{}:{}",
        rustix::fs::major(rdev),
        rustix::fs::minor(rdev)
    ));
    dir.is_dir().then_some(dir)
}

/// Live: `/proc/mounts` for `mountpoint`, then the md sysfs when the source
/// is an array. `None` when the path is not a mount point of its own (a
/// developer's `--data-root /tmp/x`), so the cell appends nothing.
pub fn disk_kind(mountpoint: &std::path::Path) -> Option<DiskKind> {
    let mount = parse_mounts(&read("/proc/mounts")?, mountpoint.to_str()?)?;
    let set = sysfs_block_dir_of(&mount.source).and_then(|d| read_set_facts(&d));
    Some(DiskKind::from_mount(&mount, set))
}

/// One address on one interface, as the screen prints it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IfAddr {
    pub interface: String,
    pub address: IpAddr,
    pub prefix: u8,
}

impl std::fmt::Display for IfAddr {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{} {}/{}", self.interface, self.address, self.prefix)
    }
}

// ── netlink RTM_GETADDR, the dump ───────────────────────────────────────────

const RTM_GETADDR: u16 = 22;
const RTM_NEWADDR: u16 = 20;
const NLMSG_DONE: u16 = 3;
const NLMSG_ERROR: u16 = 2;
const NLM_F_REQUEST: u16 = 0x0001;
const NLM_F_DUMP: u16 = 0x0300;
const NLM_F_MULTI: u16 = 0x0002;
const AF_INET: u8 = 2;
const AF_INET6: u8 = 10;
const IFA_ADDRESS: u16 = 1;
const IFA_LOCAL: u16 = 2;
const IFA_LABEL: u16 = 3;
/// `RT_SCOPE_HOST`: loopback and its kin — not something a client dials.
const RT_SCOPE_HOST: u8 = 254;
/// `RT_SCOPE_LINK`: `fe80::/10` — real, but not the address anyone types.
const RT_SCOPE_LINK: u8 = 253;

/// The `RTM_GETADDR` dump request for `family` (`AF_UNSPEC` = 0 for all).
pub fn getaddr_dump_message(seq: u32) -> Vec<u8> {
    // ifaddrmsg: family, prefixlen, flags, scope, index — 8 bytes, all zero
    // for a dump.
    netlink_message(RTM_GETADDR, NLM_F_REQUEST | NLM_F_DUMP, seq, &[0u8; 8])
}

/// Every `RTM_NEWADDR` in one or more dump answers → addresses worth showing:
/// host scope (loopback) and link scope (`fe80::`) are left out. Interface
/// names come from `IFA_LABEL` (IPv4 carries it) or from `index_to_name`
/// through `resolve`, so this parser is pure over bytes.
///
/// Returns `(addresses, done)`; `done` is true once `NLMSG_DONE` was seen.
pub fn parse_getaddr_reply(
    buf: &[u8],
    resolve: &mut dyn FnMut(u32) -> Option<String>,
) -> (Vec<IfAddr>, bool) {
    let mut out = Vec::new();
    let mut off = 0usize;
    let mut done = false;
    while off + 16 <= buf.len() {
        let len = u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) as usize;
        let kind = u16::from_le_bytes([buf[off + 4], buf[off + 5]]);
        let flags = u16::from_le_bytes([buf[off + 6], buf[off + 7]]);
        if len < 16 || off + len > buf.len() {
            break;
        }
        match kind {
            NLMSG_DONE => {
                done = true;
                break;
            }
            NLMSG_ERROR => {
                done = true;
                break;
            }
            RTM_NEWADDR if len >= 24 => {
                let body = &buf[off + 16..off + len];
                let family = body[0];
                let prefix = body[1];
                let scope = body[3];
                let index = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
                if scope != RT_SCOPE_HOST && scope != RT_SCOPE_LINK {
                    let mut address = None;
                    let mut local = None;
                    let mut label = None;
                    let mut a = 8usize;
                    while a + 4 <= body.len() {
                        let alen = u16::from_le_bytes([body[a], body[a + 1]]) as usize;
                        let atype = u16::from_le_bytes([body[a + 2], body[a + 3]]);
                        if alen < 4 || a + alen > body.len() {
                            break;
                        }
                        let payload = &body[a + 4..a + alen];
                        match atype {
                            IFA_ADDRESS => address = ip_from(family, payload),
                            IFA_LOCAL => local = ip_from(family, payload),
                            IFA_LABEL => {
                                let end = payload
                                    .iter()
                                    .position(|&b| b == 0)
                                    .unwrap_or(payload.len());
                                label = String::from_utf8(payload[..end].to_vec()).ok();
                            }
                            _ => {}
                        }
                        a += align4(alen);
                    }
                    // IFA_LOCAL is the interface's own address on a
                    // point-to-point link; IFA_ADDRESS is the peer there and
                    // the same address everywhere else.
                    if let Some(ip) = local.or(address) {
                        let interface = label
                            .or_else(|| resolve(index))
                            .unwrap_or_else(|| format!("if{index}"));
                        out.push(IfAddr {
                            interface,
                            address: ip,
                            prefix,
                        });
                    }
                }
            }
            _ => {}
        }
        if flags & NLM_F_MULTI == 0 {
            // A single (non-multipart) answer ends here.
            done = true;
        }
        off += align4(len);
    }
    (out, done)
}

fn ip_from(family: u8, payload: &[u8]) -> Option<IpAddr> {
    match (family, payload.len()) {
        (AF_INET, 4) => Some(IpAddr::V4(Ipv4Addr::new(
            payload[0], payload[1], payload[2], payload[3],
        ))),
        (AF_INET6, 16) => {
            let mut b = [0u8; 16];
            b.copy_from_slice(payload);
            Some(IpAddr::V6(Ipv6Addr::from(b)))
        }
        _ => None,
    }
}

/// The live dump: one request, answers until `NLMSG_DONE`. Empty on any
/// failure — the screen shows no addresses rather than a wrong one.
pub fn addresses() -> Vec<IfAddr> {
    use rustix::net::{
        netdevice, recv, send, socket, AddressFamily, RecvFlags, SendFlags, SocketType,
    };
    let Ok(sock) = netlink_socket() else {
        return Vec::new();
    };
    if send(&sock, &getaddr_dump_message(1), SendFlags::empty()).is_err() {
        return Vec::new();
    }
    let probe = socket(AddressFamily::INET, SocketType::DGRAM, None).ok();
    let mut resolve = |index: u32| -> Option<String> {
        let probe = probe.as_ref()?;
        netdevice::index_to_name(probe, index).ok()
    };
    let mut out = Vec::new();
    let mut buf = vec![0u8; 16384];
    // A dump is a bounded number of datagrams; 64 is far past any real box.
    for _ in 0..64 {
        let Ok((n, _)) = recv(&sock, &mut buf[..], RecvFlags::empty()) else {
            break;
        };
        if n == 0 {
            break;
        }
        let (mut got, done) = parse_getaddr_reply(&buf[..n], &mut resolve);
        out.append(&mut got);
        if done {
            break;
        }
    }
    out
}

// ── the readers ─────────────────────────────────────────────────────────────

fn read(path: &str) -> Option<String> {
    std::fs::read_to_string(path).ok()
}

pub fn hostname() -> Option<String> {
    let h = read("/proc/sys/kernel/hostname")?;
    let h = h.trim();
    if h.is_empty() || h == "(none)" {
        None
    } else {
        Some(h.to_owned())
    }
}

pub fn cpu_ticks() -> (Option<CpuTicks>, usize) {
    read("/proc/stat")
        .map(|t| parse_stat(&t))
        .unwrap_or((None, 0))
}

pub fn memory() -> Option<(u64, u64)> {
    meminfo().ok()
}

/// `(MemTotal, MemAvailable)` in bytes, or **why not**. The band shows a dash
/// for a missing number; `Admin.Capacity` must name the cause of an absence,
/// so this is the reader both go through — [`memory`] is its `.ok()`.
pub fn meminfo() -> Result<(u64, u64), String> {
    let text = read_why("/proc/meminfo")?;
    parse_meminfo(&text)
        .ok_or_else(|| "/proc/meminfo has no MemTotal and MemAvailable lines to parse".to_owned())
}

/// The 1-minute load out of `/proc/loadavg` as a number, or why not.
/// [`load1`] is the band's string of the same field.
pub fn load1_value() -> Result<f64, String> {
    let text = read_why("/proc/loadavg")?;
    parse_loadavg(&text)
        .and_then(|first| first.parse::<f64>().ok())
        .ok_or_else(|| "/proc/loadavg does not start with a number".to_owned())
}

/// [`read`] that keeps the OS's reason, for the readers whose caller reports
/// an absence rather than drawing a dash.
fn read_why(path: &str) -> Result<String, String> {
    std::fs::read_to_string(path).map_err(|err| format!("{path} could not be read ({err})"))
}

pub fn uptime_secs() -> Option<u64> {
    parse_uptime(&read("/proc/uptime")?)
}

pub fn load1() -> Option<String> {
    parse_loadavg(&read("/proc/loadavg")?)
}

pub fn cpu_model() -> Option<String> {
    parse_cpu_model(&read("/proc/cpuinfo")?)
}

/// One live index row's facts, engine-neutral: the repository's git dir and
/// the engine's snapshot. Rate is the screen's to derive across two ticks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexFacts {
    pub git_dir: std::path::PathBuf,
    pub snap: crate::storage::IndexSnapshot,
}

/// Every open repository's index work, active or not. A few atomics per open
/// store; no store lock, no `/proc`.
pub fn index_progress(engine: crate::storage::Engine) -> Vec<IndexFacts> {
    engine
        .index_progress()
        .into_iter()
        .map(|(git_dir, snap)| IndexFacts { git_dir, snap })
        .collect()
}

/// `(total, available)` bytes of the filesystem holding `path`.
pub fn disk(path: &std::path::Path) -> Option<(u64, u64)> {
    let st = rustix::fs::statfs(path).ok()?;
    let bsize = st.f_frsize as u64;
    Some((st.f_blocks * bsize, st.f_bavail * bsize))
}

#[cfg(test)]
mod tests {
    use super::*;
    use appliance_pid1::net::rtattr;

    const STAT: &str = "cpu  4705 0 3540 178540 320 0 87 0 0 0\ncpu0 2000 0 1700 89000 200 0 40 0 0 0\ncpu1 2705 0 1840 89540 120 0 47 0 0 0\nintr 12345\nctxt 1\n";

    #[test]
    fn stat_counts_cpus_and_turns_two_reads_into_a_busy_percent() {
        let (t, cpus) = parse_stat(STAT);
        assert_eq!(cpus, 2);
        let t = t.unwrap();
        assert_eq!(t.total, 4705 + 3540 + 178540 + 320 + 87);
        assert_eq!(t.busy, 4705 + 3540 + 87);
        let later = CpuTicks {
            busy: t.busy + 50,
            total: t.total + 200,
        };
        assert_eq!(later.busy_percent_since(t), Some(25));
        assert_eq!(
            t.busy_percent_since(t),
            None,
            "an empty interval is no percentage"
        );
        assert_eq!(
            t.busy_percent_since(later),
            None,
            "a wrapped/earlier read is refused, not negative"
        );
    }

    #[test]
    fn meminfo_gives_total_and_available_in_bytes() {
        let text = "MemTotal:       16303748 kB\nMemFree:         1234 kB\nMemAvailable:   15000000 kB\nBuffers: 1 kB\n";
        assert_eq!(
            parse_meminfo(text),
            Some((16303748 * 1024, 15000000 * 1024))
        );
        assert_eq!(
            parse_meminfo("MemTotal: 1 kB\n"),
            None,
            "a file without MemAvailable is not guessed at"
        );
    }

    #[test]
    fn uptime_load_and_model_parse_the_real_shapes() {
        assert_eq!(parse_uptime("350735.47 234388.90\n"), Some(350735));
        assert_eq!(
            parse_loadavg("0.08 0.03 0.01 1/412 12345\n"),
            Some("0.08".into())
        );
        assert_eq!(parse_cpu_model("processor: 0\n"), None);
    }

    /// The model cell: what `/proc/cpuinfo` says → what the band prints.
    /// The host chip's `N-Core` is not this box's CPU count (a 4-vCPU plan
    /// on an EPYC 7542 said `4x … 32-Core` on UpCloud, 2026-08-29).
    #[test]
    fn the_cpu_model_is_trimmed_of_suffixes_that_are_not_the_chip_name() {
        let table = [
            ("AMD EPYC 7542 32-Core Processor", "AMD EPYC 7542"),
            (
                "AMD Ryzen Threadripper PRO 3975WX 32-Cores",
                "AMD Ryzen Threadripper PRO 3975WX",
            ),
            (
                "Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz",
                "Intel Xeon E5-2680 v4",
            ),
            (
                "Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
                "Intel Core i7-8650U",
            ),
            (
                "Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz",
                "Intel Xeon Platinum 8375C",
            ),
            ("QEMU Virtual CPU version 2.5+", "QEMU Virtual version 2.5+"),
            ("ARM Cortex-A72", "ARM Cortex-A72"),
        ];
        for (raw, want) in table {
            let cpuinfo = format!("processor\t: 0\nvendor_id\t: x\nmodel name\t: {raw}\n");
            assert_eq!(parse_cpu_model(&cpuinfo).as_deref(), Some(want), "{raw}");
        }
        assert!(
            is_core_count("32-Core")
                && is_core_count("64-Cores")
                && !is_core_count("-Core")
                && !is_core_count("Cortex-A72")
        );
    }

    /// One RTM_NEWADDR with IFA_ADDRESS + IFA_LABEL, in a multipart answer
    /// followed by DONE.
    fn newaddr(
        family: u8,
        prefix: u8,
        scope: u8,
        index: u32,
        addr: &[u8],
        label: Option<&str>,
    ) -> Vec<u8> {
        let mut body = vec![family, prefix, 0, scope];
        body.extend_from_slice(&index.to_le_bytes());
        body.extend_from_slice(&rtattr(IFA_ADDRESS, addr));
        if let Some(l) = label {
            let mut z = l.as_bytes().to_vec();
            z.push(0);
            body.extend_from_slice(&rtattr(IFA_LABEL, &z));
        }
        netlink_message(RTM_NEWADDR, NLM_F_MULTI, 1, &body)
    }

    fn done() -> Vec<u8> {
        netlink_message(NLMSG_DONE, NLM_F_MULTI, 1, &[0u8; 4])
    }

    #[test]
    fn the_dump_request_is_a_request_plus_dump_over_an_empty_ifaddrmsg() {
        let m = getaddr_dump_message(7);
        assert_eq!(m.len(), 24);
        assert_eq!(u16::from_le_bytes([m[4], m[5]]), RTM_GETADDR);
        assert_eq!(u16::from_le_bytes([m[6], m[7]]), NLM_F_REQUEST | NLM_F_DUMP);
        assert_eq!(&m[16..], &[0u8; 8]);
    }

    #[test]
    fn a_dump_reply_yields_the_dialable_addresses_and_skips_loopback_and_link_local() {
        let mut buf = Vec::new();
        buf.extend(newaddr(
            AF_INET,
            8,
            RT_SCOPE_HOST,
            1,
            &[127, 0, 0, 1],
            Some("lo"),
        ));
        buf.extend(newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0")));
        buf.extend(newaddr(
            AF_INET6,
            64,
            RT_SCOPE_LINK,
            2,
            &[0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            None,
        ));
        buf.extend(newaddr(
            AF_INET6,
            64,
            0,
            2,
            &[0x20, 0x01, 0xd, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            None,
        ));
        buf.extend(done());
        let mut resolved = Vec::new();
        let (addrs, finished) = parse_getaddr_reply(&buf, &mut |i| {
            resolved.push(i);
            Some(format!("if{i}-resolved"))
        });
        assert!(finished);
        assert_eq!(
            addrs.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
            ["eth0 10.0.2.15/24", "if2-resolved 2001:db8::1/64"]
        );
        assert_eq!(
            resolved,
            [2],
            "only the label-less address asked for a name"
        );
    }

    #[test]
    fn a_reply_without_done_says_so_and_a_truncated_one_stops_cleanly() {
        let full = newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0"));
        let (addrs, finished) = parse_getaddr_reply(&full, &mut |_| None);
        assert_eq!(addrs.len(), 1);
        assert!(!finished, "multipart without DONE: more datagrams follow");
        let (addrs, _) = parse_getaddr_reply(&full[..full.len() - 3], &mut |_| None);
        assert!(
            addrs.is_empty(),
            "a message whose declared length overruns the buffer is refused"
        );
    }

    #[test]
    fn the_live_readers_answer_on_this_box() {
        // Not a fixture: these read the running kernel. The assertion is on
        // shape — a Linux box has ≥1 CPU, RAM, and an uptime.
        let (ticks, cpus) = cpu_ticks();
        assert!(ticks.is_some() && cpus >= 1);
        let (total, avail) = memory().expect("meminfo");
        assert!(total >= avail && total > 0);
        assert!(uptime_secs().is_some());
        let (dt, da) = disk(std::path::Path::new("/")).expect("statfs /");
        assert!(dt >= da && dt > 0);
    }
}

// ── PCI hot-plug: can a member leave and come back while gunnar runs? ──────

/// **Does the RUNNING kernel have PCI hot-plug?** Measured, never declared.
///
/// Why it matters: UpCloud resizes only DETACHED volumes, so a runtime growth
/// is detach → resize → re-attach, and a kernel with `# CONFIG_HOTPLUG_PCI is
/// not set` never sees the member leave or come back (`511 HOTPLUG_FAILED`).
///
/// The rule, in order — both work on a no-OS PID 1 with no shell and a kernel
/// built with `CONFIG_MODULES=n`:
///
/// 1. `/proc/config.gz` (only when the kernel has `CONFIG_IKCONFIG_PROC`;
///    `tunnr-kernel`'s `nuked` preset does NOT): `CONFIG_HOTPLUG_PCI=y` AND at
///    least one controller (`_ACPI`, `_PCIE`, `_SHPC`) `=y`.
/// 2. Otherwise the built-in controllers' parameter directories:
///    `/sys/module/acpiphp` (`acpiphp.disable`), `/sys/module/pciehp`
///    (`pciehp.pciehp_poll_mode`), `/sys/module/shpchp` (`shpchp.shpchp_debug`).
///    `param_sysfs_builtin` creates these for built-in code with parameters
///    whether or not modules exist; nothing else in the tree creates them.
///
/// NOT used: `/sys/bus/pci/slots` — `drivers/pci/slot.o` builds on `SYSFS`
/// alone, so the directory exists on a kernel with no hot-plug at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PciHotplug {
    pub present: bool,
    /// What was read, for the reason line: `config.gz: …` or `sys/module: …`.
    pub evidence: String,
}

/// The built-in hot-plug controllers whose `/sys/module/<name>` directory is
/// the fallback evidence.
pub const HOTPLUG_CONTROLLERS: [&str; 3] = ["acpiphp", "pciehp", "shpchp"];

/// Pure over what was read: the decompressed `/proc/config.gz` (if any) and a
/// predicate for `/sys/module/<name>` existing.
pub fn decide_pci_hotplug(config: Option<&str>, has_module: &dyn Fn(&str) -> bool) -> PciHotplug {
    if let Some(text) = config {
        let y = |sym: &str| text.lines().any(|l| l.trim() == format!("{sym}=y"));
        let controllers: Vec<&str> = [
            "CONFIG_HOTPLUG_PCI_ACPI",
            "CONFIG_HOTPLUG_PCI_PCIE",
            "CONFIG_HOTPLUG_PCI_SHPC",
        ]
        .into_iter()
        .filter(|s| y(s))
        .collect();
        let present = y("CONFIG_HOTPLUG_PCI") && !controllers.is_empty();
        return PciHotplug {
            present,
            evidence: if present {
                format!("config.gz: CONFIG_HOTPLUG_PCI=y, {}", controllers.join(" "))
            } else {
                "config.gz: CONFIG_HOTPLUG_PCI or every controller is not set".to_owned()
            },
        };
    }
    let found: Vec<&str> = HOTPLUG_CONTROLLERS
        .into_iter()
        .filter(|m| has_module(m))
        .collect();
    PciHotplug {
        present: !found.is_empty(),
        evidence: if found.is_empty() {
            "sys/module: no acpiphp, pciehp or shpchp (no /proc/config.gz)".to_owned()
        } else {
            format!("sys/module: {} (no /proc/config.gz)", found.join(" "))
        },
    }
}

/// Read it off the running machine.
pub fn pci_hotplug() -> PciHotplug {
    use std::io::Read as _;
    let config = std::fs::File::open("/proc/config.gz").ok().and_then(|f| {
        let mut s = String::new();
        flate2::read::GzDecoder::new(f)
            .read_to_string(&mut s)
            .ok()
            .map(|_| s)
    });
    decide_pci_hotplug(config.as_deref(), &|m| {
        std::path::Path::new("/sys/module").join(m).is_dir()
    })
}

#[cfg(test)]
mod pci_hotplug_tests {
    use super::*;

    #[test]
    fn config_gz_decides_when_present_and_needs_a_controller() {
        let none = |_: &str| true; // the fallback must not be consulted
        let hot = "CONFIG_PCI=y\nCONFIG_HOTPLUG_PCI=y\nCONFIG_HOTPLUG_PCI_ACPI=y\n";
        let p = decide_pci_hotplug(Some(hot), &none);
        assert!(
            p.present && p.evidence.contains("HOTPLUG_PCI_ACPI"),
            "{p:?}"
        );
        assert!(
            !decide_pci_hotplug(Some("CONFIG_HOTPLUG_PCI=y\n"), &none).present,
            "the core without a controller acknowledges nothing"
        );
        assert!(
            !decide_pci_hotplug(
                Some("# CONFIG_HOTPLUG_PCI is not set\nCONFIG_HOTPLUG_PCI_ACPI=y\n"),
                &none
            )
            .present
        );
    }

    #[test]
    fn without_config_gz_the_builtin_controllers_decide() {
        let p = decide_pci_hotplug(None, &|m| m == "pciehp" || m == "acpiphp");
        assert!(
            p.present && p.evidence == "sys/module: acpiphp pciehp (no /proc/config.gz)",
            "{p:?}"
        );
        let p = decide_pci_hotplug(None, &|_| false);
        assert!(!p.present && p.evidence.contains("no acpiphp"), "{p:?}");
    }
}