//! chj.disks model from the output of `remoteDisks.sh inspect` (port of `disks/inventory.js`). //! //! The device graph comes from sysfs (holders/slaves), so layered stacks such as //! disk → partition → LUKS → LVM PV → VG → LV → ext4 are represented without assuming that a //! partition directly contains a file system. lsblk/blkid/sfdisk/LVM JSON only enrich nodes; //! without them (BusyBox, no root) the model is marked partial instead of guessing. use super::js::{self, int_opt, int_str, lines, num, number, number_str, text, text_or_null, truthy}; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, HashMap}; pub const MIB: f64 = 1_048_576.0; pub fn gpt_type(code: &str) -> Option<&'static str> { Some(match code { "0FC63DAF-8483-4772-8E79-3D69D8477DE4" => "linux", "4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709" => "linux-root-x86-64", "B921B045-1DF0-41C3-AF44-4C6F280D3FAE" => "linux-root-arm64", "BC13C2FF-59E6-4262-A352-B275FD6F7172" => "linux-extended-boot", "933AC7E1-2EB4-4F13-B844-0E14E2AEF915" => "linux-home", "3B8F8425-20E0-4F3B-907F-1A25A76F98E8" => "linux-server-data", "4D21B016-B534-45C2-A9FB-5C16E091FD2D" => "linux-var", "0657FD6D-A4AB-43C4-84E5-0933C84B4F4F" => "swap", "E6D6D379-F507-44C2-A23C-238F2A3DF928" => "lvm", "A19D880F-05FC-4D3B-A006-743F0F84911E" => "raid", "CA7D7CCB-63ED-4C53-861C-1742536059CC" => "luks", "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" => "esp", "21686148-6449-6E6F-744E-656564454649" => "bios-boot", "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7" => "basic-data", "E3C9E316-0B5C-4DB8-817D-F92DF00215AE" => "microsoft-reserved", "DE94BBA4-06D1-4D40-A16A-BFD50179D6AC" => "windows-recovery", "6A898CC3-1DD2-11B2-99A6-080020736631" => "zfs", "516E7CBA-6ECF-11D6-8FF8-00022D09712B" => "freebsd-zfs", _ => return None, }) } pub fn dos_type(code: &str) -> Option<&'static str> { Some(match code { "83" => "linux", "82" => "swap", "8e" => "lvm", "fd" => "raid", "ef" => "esp", "7" => "basic-data", "b" | "c" => "fat32", "e" => "fat16", "5" | "f" | "85" => "extended", "ee" => "gpt-protective", _ => return None, }) } /// Partition types CH-J writes (GPT GUID / MBR type) for new partitions and type flags. pub fn partition_type_code(flag: &str, table: &str) -> Option<&'static str> { let (gpt, dos) = match flag { "linux" => ("0FC63DAF-8483-4772-8E79-3D69D8477DE4", Some("83")), "swap" => ("0657FD6D-A4AB-43C4-84E5-0933C84B4F4F", Some("82")), "lvm" => ("E6D6D379-F507-44C2-A23C-238F2A3DF928", Some("8e")), "raid" => ("A19D880F-05FC-4D3B-A006-743F0F84911E", Some("fd")), "esp" => ("C12A7328-F81F-11D2-BA4B-00A0C93EC93B", Some("ef")), "bios-boot" => ("21686148-6449-6E6F-744E-656564454649", None), "basic-data" => ("EBD0A0A2-B9E5-4433-87C0-68B6B72699C7", Some("7")), "fat32" => ("EBD0A0A2-B9E5-4433-87C0-68B6B72699C7", Some("c")), _ => return None, }; match table { "gpt" => Some(gpt), "dos" => dos, _ => None, } } pub static SYSTEM_MOUNTS: Lazy = Lazy::new(|| Regex::new(r"^(/|/boot(/.*)?|/efi|/usr(/.*)?|/var(/.*)?|/home|/opt|/srv|/root|/tmp|/nix|/snap(/.*)?)$").unwrap()); static VIRTUAL_MODEL: Lazy = Lazy::new(|| Regex::new(r"(?i)QEMU|VBOX|VirtualBox|VMware|Virtual disk|Virtual HD|Msft Virtual|Xen|Hyper-V|BHYVE|Red Hat VirtIO").unwrap()); static SECTION: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_SECTION=([a-z]+)\r?\n").unwrap()); pub fn split_sections(stdout: &str) -> HashMap { let mut sections = HashMap::new(); let mut current: Option = None; let mut start = 0; for caps in SECTION.captures_iter(stdout) { let whole = caps.get(0).unwrap(); if let Some(name) = current.take() { sections.insert(name, stdout[start..whole.start()].to_string()); } current = Some(caps[1].to_string()); start = whole.end(); } if let Some(name) = current { sections.insert(name, stdout[start..].to_string()); } sections } /// 0.1.0 inventory `keyValues` (a line without "=" yields key = line minus its last character). pub fn key_values(value: &str) -> BTreeMap { let mut out = BTreeMap::new(); for line in lines(value) { let units: Vec = line.encode_utf16().collect(); let (key, val) = match line.find('=') { Some(i) => (line[..i].to_string(), line[i + 1..].to_string()), None => (String::from_utf16_lossy(&units[..units.len().saturating_sub(1)]), line.to_string()), }; if !key.is_empty() { out.insert(key, val); } } out } fn parse_json(value: &str) -> Value { serde_json::from_str(value).unwrap_or(Value::Null) } static OCTAL: Lazy = Lazy::new(|| Regex::new(r"\\(\d{3})").unwrap()); pub fn unescape_mount(value: &str) -> String { OCTAL .replace_all(value, |c: ®ex::Captures| { // parseInt("089", 8) stops at the first invalid digit. let digits: String = c[1].chars().take_while(|d| ('0'..='7').contains(d)).collect(); match u32::from_str_radix(&digits, 8).ok().and_then(char::from_u32) { Some(ch) => ch.to_string(), None => "\u{0}".to_string(), } }) .to_string() } #[derive(Debug, Clone)] pub struct SysEntry { pub kname: String, pub devno: String, pub sectors512: Value, pub read_only: bool, pub removable: bool, pub partition: Value, pub start512: Value, pub parent: Value, pub rotational: Value, pub logical_sector_size: f64, pub physical_sector_size: f64, pub model: String, pub vendor: String, pub serial: String, pub wwid: String, pub holders: Vec, pub slaves: Vec, pub dm_name: String, pub dm_uuid: String, pub loop_backing: String, pub md_level: String, pub sys_path: String, pub discard_max: Value, } static KNAME: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._:+-]{1,64}$").unwrap()); fn parse_sysblock(section: &str) -> Vec { lines(section) .into_iter() .map(|line| { let f: Vec<&str> = line.split('|').collect(); let field = |i: usize| f.get(i).copied(); let get = |i: usize| field(i).unwrap_or(""); let list = |i: usize| get(i).split(',').filter(|s| !s.is_empty()).map(|n| text(n, 64)).collect::>(); let or_default = |v: Value, d: f64| if truthy(&v) { number(&v) } else { d }; SysEntry { kname: text(get(0), 64), devno: text(get(1), 16), sectors512: int_str(field(2)), read_only: field(3) == Some("1"), removable: field(4) == Some("1"), partition: if field(5) == Some("-") { Value::Null } else { int_str(field(5)) }, start512: if field(6) == Some("-") { Value::Null } else { int_str(field(6)) }, parent: if field(7) == Some("-") { Value::Null } else { json!(text(get(7), 64)) }, rotational: if field(8) == Some("") { Value::Null } else { json!(field(8) == Some("1")) }, logical_sector_size: or_default(int_str(field(9)), 512.0), physical_sector_size: or_default(int_str(field(10)), 512.0), model: text(get(11), 80), vendor: text(get(12), 40), serial: text(get(13), 80), wwid: text(get(14), 120), holders: list(15), slaves: list(16), dm_name: text(get(17), 128), dm_uuid: text(get(18), 160), loop_backing: text(get(19), 200), md_level: text(get(20), 16), sys_path: text(get(21), 200), discard_max: int_str(field(22)), } }) .filter(|e| KNAME.is_match(&e.kname)) .collect() } static BLANK_LINE: Lazy = Lazy::new(|| Regex::new(r"\n\s*\n").unwrap()); fn parse_blkid(section: &str) -> HashMap> { let mut result = HashMap::new(); for block in BLANK_LINE.split(section) { let values = key_values(block); if let Some(dev) = values.get("DEVNAME").filter(|d| !d.is_empty()).cloned() { result.insert(dev, values); } } result } struct Mount { devno: String, mountpoint: String, options: String, fstype: String, source: String, } fn parse_mountinfo(section: &str) -> Vec { lines(section) .into_iter() .map(|line| { let mut halves = line.split(" - "); let left = halves.next().unwrap_or(""); let right = halves.next().unwrap_or(""); let fields: Vec<&str> = left.split(' ').collect(); let tail: Vec<&str> = right.split(' ').collect(); Mount { devno: fields.get(2).copied().unwrap_or("").to_string(), mountpoint: unescape_mount(fields.get(4).copied().unwrap_or("")), options: fields.get(5).copied().unwrap_or("").to_string(), fstype: tail.first().copied().unwrap_or("").to_string(), source: unescape_mount(tail.get(1).copied().unwrap_or("")), } }) .filter(|m| !m.mountpoint.is_empty()) .collect() } static DF: Lazy = Lazy::new(|| Regex::new(r"^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$").unwrap()); fn parse_df(section: &str) -> HashMap { let mut result = HashMap::new(); for line in lines(section).into_iter().skip(1) { if let Some(c) = DF.captures(line) { let k = |i: usize| num(number_str(&c[i]) * 1024.0); result.insert(c[6].to_string(), json!({ "size": k(2), "used": k(3), "available": k(4) })); } } result } struct Swap { path: String, used: Value, } fn parse_swaps(section: &str) -> Vec { lines(section) .into_iter() .skip(1) .map(|line| { let f: Vec<&str> = line.split_whitespace().collect(); Swap { path: unescape_mount(f.first().copied().unwrap_or("undefined")), used: num(f.get(3).map(|v| number_str(v)).unwrap_or(f64::NAN) * 1024.0) } }) .collect() } static PT: Lazy = Lazy::new(|| Regex::new(r"(?s)@@disk (\S+) (\S+)\n(.*?)\n@@end").unwrap()); static HASH64: Lazy = Lazy::new(|| Regex::new(r"^[a-f0-9]{64}$").unwrap()); fn parse_partition_tables(section: &str) -> HashMap { let mut tables = HashMap::new(); for c in PT.captures_iter(section) { let json = parse_json(&c[3]); if let Some(Value::Object(pt)) = json.get("partitiontable") { let mut table = pt.clone(); table.insert("hash".into(), if HASH64.is_match(&c[2]) { json!(&c[2]) } else { Value::Null }); tables.insert(c[1].to_string(), Value::Object(table)); } } tables } /// `split(/^@@/m).filter(Boolean)`. fn split_marker<'a>(section: &'a str, marker: &str) -> Vec<&'a str> { let mut parts = Vec::new(); let mut start = 0; let mut search = 0; while let Some(found) = section[search..].find(marker) { let at = search + found; if at == 0 || section.as_bytes()[at - 1] == b'\n' { parts.push(§ion[start..at]); start = at + marker.len(); } search = at + marker.len(); } parts.push(§ion[start..]); parts.into_iter().filter(|p| !p.is_empty()).collect() } /// `block.slice(0, block.indexOf("\n"))` / `block.slice(block.indexOf("\n") + 1)` (no newline → drop last char / whole block). fn head_body(block: &str) -> (String, &str) { match block.find('\n') { Some(i) => (block[..i].to_string(), &block[i + 1..]), None => { let units: Vec = block.encode_utf16().collect(); (String::from_utf16_lossy(&units[..units.len().saturating_sub(1)]), block) } } } static PVE_GUEST: Lazy = Lazy::new(|| Regex::new(r"^(?:vm|base)-(\d+)-(?:disk|cloudinit|state|tpmstate)").unwrap()); fn parse_lvm(section: Option<&str>) -> Value { let section = section.unwrap_or(""); let mut parts: HashMap = HashMap::new(); for block in split_marker(section, "@@") { let (name, body) = head_body(block); let report = parse_json(body).get("report").and_then(|r| r.get(0)).cloned().filter(truthy).unwrap_or_else(|| json!({})); parts.insert(name.trim().to_string(), report); } let n = |value: &Value| { let s = js::string(Some(value)).trim().to_string(); int_opt(Some(&json!(s))) }; let t = |value: &Value, limit: usize| json!(text(&js::string(Some(value)), limit)); let list = |name: &str, key: &str| parts.get(name).and_then(|p| p.get(key)).and_then(Value::as_array).cloned().unwrap_or_default(); let pvs: Vec = list("pvs", "pv") .iter() .map(|pv| json!({ "path": t(&pv["pv_name"], 200), "vg": t(&pv["vg_name"], 128), "size": n(&pv["pv_size"]), "free": n(&pv["pv_free"]), "deviceSize": n(&pv["dev_size"]), "uuid": t(&pv["pv_uuid"], 64), "attr": t(&pv["pv_attr"], 16) })) .collect(); let vgs: Vec = list("vgs", "vg") .iter() .map(|vg| { json!({ "name": t(&vg["vg_name"], 128), "uuid": t(&vg["vg_uuid"], 64), "size": n(&vg["vg_size"]), "free": n(&vg["vg_free"]), "extentSize": n(&vg["vg_extent_size"]), "freeExtents": n(&vg["vg_free_count"]), "pvCount": n(&vg["pv_count"]), "lvCount": n(&vg["lv_count"]), "attr": t(&vg["vg_attr"], 16) }) }) .collect(); let lvs: Vec = list("lvs", "lv") .iter() .filter(|lv| !js::string(Some(&lv["lv_name"])).starts_with('[')) .map(|lv| { let attr = text(&js::string(Some(&lv["lv_attr"])), 16); let a: Vec = attr.chars().collect(); let name = js::string(Some(&lv["lv_name"])); json!({ "name": t(&lv["lv_name"], 128), "vg": t(&lv["vg_name"], 128), "uuid": t(&lv["lv_uuid"], 64), "size": n(&lv["lv_size"]), "attr": attr, "path": t(&lv["lv_path"], 200), "dmPath": t(&lv["lv_dm_path"], 200), "pool": t(&lv["pool_lv"], 128), "dataPercent": n(&lv["data_percent"]), "metadataPercent": n(&lv["metadata_percent"]), "segtype": t(&lv["segtype"], 32), "origin": t(&lv["origin"], 128), "thinPool": a.first() == Some(&'t'), "thinVolume": a.first() == Some(&'V'), "snapshot": matches!(a.first(), Some('s') | Some('S')), "active": a.get(4) == Some(&'a'), "pveGuest": PVE_GUEST.captures(&name).map(|c| json!(&c[1])).unwrap_or(Value::Null) }) }) .collect(); json!({ "available": !section.is_empty(), "pvs": pvs, "vgs": vgs, "lvs": lvs }) } fn parse_crypt(section: &str) -> HashMap { let mut result = HashMap::new(); for block in split_marker(section, "@@map ") { let (name, body) = head_body(block); let mut values: HashMap = HashMap::new(); for line in lines(body) { if let Some(i) = line.find(':').filter(|i| *i > 0) { values.insert(line[..i].trim().to_lowercase(), text(&line[i + 1..], 120)); } } let get = |k: &str| values.get(k).filter(|v| !v.is_empty()).map(|v| json!(v)).unwrap_or(Value::Null); result.insert( name.trim().to_string(), json!({ "type": get("type"), "cipher": get("cipher"), "keySize": get("keysize"), "keyLocation": get("key location"), "device": get("device"), "mode": get("mode"), "sectorSize": int_str(values.get("sector size").map(String::as_str)) }), ); } result } static EXT_STATE: Lazy = Lazy::new(|| Regex::new(r"(?m)^Filesystem state:\s*(.+)$").unwrap()); static XFS_DATA: Lazy = Lazy::new(|| Regex::new(r"(?m)^data\s*=.*?bsize=(\d+)\s+blocks=(\d+)").unwrap()); static EXT_TYPE: Lazy = Lazy::new(|| Regex::new(r"^ext[234]$").unwrap()); fn parse_fsinfo(section: &str) -> HashMap { let mut result = HashMap::new(); for block in split_marker(section, "@@fs ") { let (head, body) = head_body(block); let mut words = head.split_whitespace(); let device = words.next().unwrap_or("undefined").to_string(); let kind = words.next().unwrap_or("undefined"); if EXT_TYPE.is_match(kind) { let value = |key: &str| { let re = Regex::new(&format!(r"(?m)^{}:\s*(\d+)", regex::escape(key))).unwrap(); int_str(re.captures(body).map(|c| c.get(1).unwrap().as_str())) }; let (blocks, block_size, free) = (value("Block count"), value("Block size"), value("Free blocks")); if truthy(&blocks) && truthy(&block_size) { let (b, s) = (number(&blocks), number(&block_size)); let state = EXT_STATE.captures(body).map(|c| c[1].to_string()).unwrap_or_default(); result.insert(device, json!({ "fsSize": num(b * s), "fsUsed": if free.is_null() { Value::Null } else { num((b - number(&free)) * s) }, "blockSize": block_size, "state": text(&state, 40) })); } } else if kind == "xfs" { if let Some(c) = XFS_DATA.captures(body) { result.insert(device, json!({ "fsSize": num(number_str(&c[1]) * number_str(&c[2])), "blockSize": num(number_str(&c[1])) })); } } } result } static BTRFS_LABEL: Lazy = Lazy::new(|| Regex::new(r"^Label:\s*(?:'(.*)'|none)\s+uuid:\s*([0-9a-f-]+)").unwrap()); static BTRFS_DEVICE: Lazy = Lazy::new(|| Regex::new(r"^\s*devid\s+(\d+)\s+size\s+(\d+)\s+used\s+(\d+)\s+path\s+(\S+)").unwrap()); fn parse_btrfs(section: &str) -> Vec { let mut filesystems: Vec = Vec::new(); for line in lines(section) { if let Some(c) = BTRFS_LABEL.captures(line) { let label = c.get(1).map(|m| m.as_str()).filter(|s| !s.is_empty()); filesystems.push(json!({ "label": label, "uuid": &c[2], "devices": [] })); continue; } if let (Some(c), Some(current)) = (BTRFS_DEVICE.captures(line), filesystems.last_mut()) { current["devices"].as_array_mut().unwrap().push(json!({ "devid": num(number_str(&c[1])), "size": num(number_str(&c[2])), "used": num(number_str(&c[3])), "path": &c[4] })); } } filesystems } fn parse_zpool(section: &str) -> Vec { let mut pools: Vec = Vec::new(); for line in lines(section) { let f: Vec<&str> = line.split('\t').collect(); let get = |i: usize| f.get(i).copied(); if get(0).is_some_and(|s| !s.is_empty()) { let health = get(9).filter(|s| !s.is_empty()).or_else(|| f.last().copied()).unwrap_or(""); pools.push(json!({ "name": text(get(0).unwrap(), 64), "size": int_str(get(1)), "allocated": int_str(get(2)), "free": int_str(get(3)), "health": text(health, 16), "devices": [] })); } else if let (Some(current), Some(dev)) = (pools.last_mut(), get(1).filter(|d| d.starts_with("/dev/"))) { current["devices"].as_array_mut().unwrap().push(json!(text(dev, 200))); } } pools } fn parse_lsblk(section: Option<&str>) -> HashMap { let json = parse_json(section.unwrap_or("")); let mut map = HashMap::new(); for entry in json.get("blockdevices").and_then(Value::as_array).cloned().unwrap_or_default() { let key = [&entry["kname"], &entry["name"]].into_iter().find(|v| truthy(v)).map(|v| js::string(Some(v))); if let Some(key) = key { map.insert(key, entry); } } map } fn media_type(entry: &SysEntry) -> &'static str { if entry.kname.starts_with("loop") { return "loop"; } if entry.kname.starts_with("nvme") { return if VIRTUAL_MODEL.is_match(&entry.model) { "virtual" } else { "nvme" }; } if entry.kname.starts_with("vd") || entry.kname.starts_with("xvd") || VIRTUAL_MODEL.is_match(&entry.model) || VIRTUAL_MODEL.is_match(&entry.vendor) { return "virtual"; } match entry.rotational { Value::Bool(true) => "hdd", Value::Bool(false) => "ssd", _ => "unknown", } } static T_VIRTIO_BLOCK: Lazy = Lazy::new(|| Regex::new(r"/virtio\d+/block/").unwrap()); static T_VIRTIO_SCSI: Lazy = Lazy::new(|| Regex::new(r"/virtio\d+/host\d+/").unwrap()); static T_USB: Lazy = Lazy::new(|| Regex::new(r"/usb\d+/").unwrap()); static T_ATA: Lazy = Lazy::new(|| Regex::new(r"/ata\d+/").unwrap()); static T_SCSI: Lazy = Lazy::new(|| Regex::new(r"/host\d+/target").unwrap()); fn transport(entry: &SysEntry, lsblk: &Value) -> Value { if truthy(&lsblk["tran"]) { return json!(text(&js::string(Some(&lsblk["tran"])), 16)); } let path = &entry.sys_path; let t = if entry.kname.starts_with("nvme") { "nvme" } else if T_VIRTIO_BLOCK.is_match(path) { "virtio" } else if T_VIRTIO_SCSI.is_match(path) { "virtio-scsi" } else if T_USB.is_match(path) { "usb" } else if T_ATA.is_match(path) { "sata" } else if T_SCSI.is_match(path) { "scsi" } else if entry.kname.starts_with("mmcblk") { "mmc" } else { return Value::Null; }; json!(t) } static MD: Lazy = Lazy::new(|| Regex::new(r"^md\d").unwrap()); fn node_kind(entry: &SysEntry) -> &'static str { if !entry.partition.is_null() { return "part"; } if entry.kname.starts_with("dm-") { if entry.dm_uuid.starts_with("CRYPT-") { return "crypt"; } if entry.dm_uuid.starts_with("LVM-") { return "lvm"; } return "dm"; } if MD.is_match(&entry.kname) { return "md"; } if entry.kname.starts_with("loop") { return "loop"; } "disk" } static DM_LV: Lazy = Lazy::new(|| Regex::new(r"^((?:[^-]|--)+)-((?:[^-]|--)+)$").unwrap()); /// Unescape a device-mapper LVM name ("vg--data-lv" → vg "vg-data", lv "lv"). pub fn split_dm_lv_name(name: &str) -> Option<(String, String)> { // The JavaScript regex backtracks to the last single dash between two valid halves. let chars: Vec = name.chars().collect(); for i in (0..chars.len()).rev() { if chars[i] != '-' { continue; } let (left, right): (String, String) = (chars[..i].iter().collect(), chars[i + 1..].iter().collect()); let valid = |s: &str| !s.is_empty() && DM_LV.is_match(&format!("{s}-x")) && !s.starts_with('-') && dashes_paired(s); if valid(&left) && valid(&right) { return Some((left.replace("--", "-"), right.replace("--", "-"))); } } None } /// `(?:[^-]|--)+` matches the whole string. fn dashes_paired(s: &str) -> bool { let mut run = 0; for c in s.chars() { if c == '-' { run += 1; } else { if run % 2 == 1 { return false; } run = 0; } } run % 2 == 0 } static TPOOL: Lazy = Lazy::new(|| Regex::new(r"-tpool$|_tdata$|_tmeta$").unwrap()); static CRYPT_TYPE: Lazy = Lazy::new(|| Regex::new(r"^CRYPT-([A-Z0-9]+)-").unwrap()); static MD_STAT: Lazy = Lazy::new(|| Regex::new(r"\b(md\d+)\b").unwrap()); static SKIP_KNAME: Lazy = Lazy::new(|| Regex::new(r"^(ram|zram|fd)\d*$").unwrap()); static LOOP_N: Lazy = Lazy::new(|| Regex::new(r"^loop\d+$").unwrap()); static SR_N: Lazy = Lazy::new(|| Regex::new(r"^sr\d+$").unwrap()); static PART_DIGITS: Lazy = Lazy::new(|| Regex::new(r"(\d+)$").unwrap()); static RO: Lazy = Lazy::new(|| Regex::new(r"(^|,)ro(,|$)").unwrap()); static LEADING_ZEROS: Lazy = Lazy::new(|| Regex::new(r"^0+(.)").unwrap()); fn get_str<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a str> { map.get(key).map(String::as_str) } pub fn build_inventory(stdout: &str) -> Value { let sections = split_sections(stdout); let section = |name: &str| sections.get(name).map(String::as_str); let s = |name: &str| section(name).unwrap_or(""); let meta = key_values(s("meta")); let tools = key_values(s("tools")); let versions = key_values(s("versions")); let privileged = get_str(&meta, "uid") == Some("0"); let sys = parse_sysblock(s("sysblock")); let lsblk = parse_lsblk(section("lsblk")); let blkid = parse_blkid(s("blkid")); let mounts = parse_mountinfo(s("mountinfo")); let df = parse_df(s("df")); let swaps = parse_swaps(s("swaps")); let tables = parse_partition_tables(s("pt")); let lvm = parse_lvm(section("lvm")); let crypt = parse_crypt(s("crypt")); let fsinfo = parse_fsinfo(s("fsinfo")); let btrfs = parse_btrfs(s("btrfs")); let zpools = parse_zpool(s("zpool")); let mdstat = s("mdstat").to_string(); // Nodes in insertion order; `_sys`/`_lsblk` kept beside them. let mut order: Vec = Vec::new(); let mut nodes: HashMap> = HashMap::new(); let mut internal: HashMap = HashMap::new(); for entry in &sys { if SKIP_KNAME.is_match(&entry.kname) { continue; } // Unused loop devices (size 0) and read-only snap images are not disks anyone manages here. if LOOP_N.is_match(&entry.kname) && (!truthy(&entry.sectors512) || entry.read_only || entry.loop_backing.is_empty()) { continue; } if SR_N.is_match(&entry.kname) { continue; } let ls = lsblk.get(&entry.kname).cloned().unwrap_or_else(|| json!({})); let path = if !entry.dm_name.is_empty() && entry.kname.starts_with("dm-") { format!("/dev/mapper/{}", entry.dm_name) } else { format!("/dev/{}", entry.kname) }; let empty = BTreeMap::new(); let probe = blkid.get(&format!("/dev/{}", entry.kname)).or_else(|| blkid.get(&path)).unwrap_or(&empty); let kind = node_kind(entry); let probe_or = |key: &str, ls_key: &str| -> String { // `probe.X ?? ls.x ?? ""` match probe.get(key) { Some(v) => v.clone(), None => match &ls[ls_key] { Value::Null => String::new(), v => js::string(Some(v)), }, } }; let probe_truthy = |key: &str, ls_key: &str| -> String { // `probe.X || ls.x || ""` match probe.get(key).filter(|v| !v.is_empty()) { Some(v) => v.clone(), None if truthy(&ls[ls_key]) => js::string(Some(&ls[ls_key])), None => String::new(), } }; let fstype = text_or_null(&probe_truthy("TYPE", "fstype"), 32); let mut node = Map::new(); node.insert("kname".into(), json!(entry.kname)); node.insert("path".into(), json!(path)); node.insert("kind".into(), json!(kind)); node.insert("devno".into(), json!(entry.devno)); node.insert("size".into(), if entry.sectors512.is_null() { Value::Null } else { num(number(&entry.sectors512) * 512.0) }); node.insert("readOnly".into(), json!(entry.read_only)); node.insert("parent".into(), entry.parent.clone()); node.insert("partition".into(), entry.partition.clone()); node.insert("start512".into(), entry.start512.clone()); node.insert("fstype".into(), fstype); node.insert("fsVersion".into(), text_or_null(&probe_truthy("VERSION", "fsver"), 16)); node.insert("label".into(), text_or_null(&probe_or("LABEL", "label"), 64)); node.insert("uuid".into(), text_or_null(&probe_or("UUID", "uuid"), 64)); node.insert("partuuid".into(), text_or_null(&probe_or("PARTUUID", "partuuid"), 64)); node.insert("partlabel".into(), text_or_null(&probe_or("PARTLABEL", "partlabel"), 72)); node.insert("parttype".into(), text_or_null(&if ls["parttype"].is_null() { String::new() } else { js::string(Some(&ls["parttype"])) }, 40)); node.insert("dmName".into(), if entry.dm_name.is_empty() { Value::Null } else { json!(entry.dm_name) }); node.insert("dmUuid".into(), if entry.dm_uuid.is_empty() { Value::Null } else { json!(entry.dm_uuid) }); node.insert("loopBacking".into(), if entry.loop_backing.is_empty() { Value::Null } else { json!(entry.loop_backing) }); node.insert("mdLevel".into(), if entry.md_level.is_empty() { Value::Null } else { json!(entry.md_level) }); node.insert("mountpoints".into(), json!([])); node.insert("usage".into(), Value::Null); node.insert("fsSize".into(), Value::Null); node.insert("fsUsed".into(), Value::Null); node.insert("swapActive".into(), json!(false)); node.insert("lvm".into(), Value::Null); node.insert("crypt".into(), Value::Null); node.insert("zfsPool".into(), Value::Null); node.insert("btrfs".into(), Value::Null); if !nodes.contains_key(&entry.kname) { order.push(entry.kname.clone()); } nodes.insert(entry.kname.clone(), node); internal.insert(entry.kname.clone(), (entry.clone(), ls)); } let lookup_path = |nodes: &HashMap>, order: &[String]| { let mut by_path: HashMap = HashMap::new(); for kname in order { let node = &nodes[kname]; by_path.insert(js::string(node.get("path")), kname.clone()); by_path.insert(format!("/dev/{kname}"), kname.clone()); if truthy(&node["dmName"]) { by_path.insert(format!("/dev/mapper/{}", js::string(node.get("dmName"))), kname.clone()); } } by_path }; let mut by_devno: HashMap = HashMap::new(); for kname in &order { by_devno.insert(js::string(nodes[kname].get("devno")), kname.clone()); } let by_path = lookup_path(&nodes, &order); // Mounts: by device number first (exact), by source path otherwise (Btrfs uses anonymous device numbers). for mount in &mounts { let Some(kname) = by_devno.get(&mount.devno).or_else(|| by_path.get(&mount.source)).cloned() else { continue }; let node = nodes.get_mut(&kname).unwrap(); let points = node["mountpoints"].as_array_mut().unwrap(); if !points.iter().any(|p| p == mount.mountpoint.as_str()) { points.push(json!(mount.mountpoint)); } if !node.get("mountedFstype").is_some_and(truthy) { node.insert("mountedFstype".into(), json!(mount.fstype)); } node.insert("mountedReadOnly".into(), json!(RO.is_match(&mount.options))); } for swap in &swaps { if let Some(kname) = by_path.get(&swap.path) { let node = nodes.get_mut(kname).unwrap(); node.insert("swapActive".into(), json!(true)); node.insert("swapUsed".into(), swap.used.clone()); } } for kname in &order { let node = nodes.get_mut(kname).unwrap(); let mountpoint = node["mountpoints"].as_array().unwrap().iter().filter_map(Value::as_str).find(|p| df.contains_key(*p)).map(str::to_string); if let Some(mountpoint) = mountpoint { let mut usage = df[&mountpoint].as_object().cloned().unwrap(); usage.insert("source".into(), json!("df")); usage.insert("mountpoint".into(), json!(mountpoint)); node.insert("usage".into(), Value::Object(usage)); } let info = fsinfo.get(&js::string(node.get("path"))).or_else(|| fsinfo.get(&format!("/dev/{kname}"))); if let Some(info) = info { node.insert("fsSize".into(), info["fsSize"].clone()); node.insert("fsState".into(), if truthy(&info["state"]) { info["state"].clone() } else { Value::Null }); let fs_used = info.get("fsUsed"); if !truthy(&node["usage"]) && fs_used.is_some_and(|u| !u.is_null()) { let (size, used) = (number(&info["fsSize"]), number(fs_used.unwrap())); node.insert("usage".into(), json!({ "size": info["fsSize"], "used": fs_used.unwrap(), "available": num(size - used), "source": "superblock" })); } } if !truthy(&node["fsSize"]) && truthy(&node["usage"]) && node["fstype"] == "xfs" { node.insert("fsSize".into(), Value::Null); } } for fs in &btrfs { let devices = fs["devices"].as_array().cloned().unwrap_or_default(); for device in &devices { if let Some(kname) = by_path.get(&js::string(device.get("path"))) { let node = nodes.get_mut(kname).unwrap(); node.insert("btrfs".into(), json!({ "uuid": fs["uuid"], "devid": device["devid"], "devices": devices.len(), "allocated": device["used"] })); node.insert("fsSize".into(), device["size"].clone()); } } } for pool in &zpools { for device in pool["devices"].as_array().cloned().unwrap_or_default() { if let Some(kname) = by_path.get(&js::string(Some(&device))) { nodes.get_mut(kname).unwrap().insert("zfsPool".into(), pool["name"].clone()); } } } // LVM: privileged JSON when available, otherwise the device-mapper name. let mut pv_by_path: HashMap = HashMap::new(); for pv in lvm["pvs"].as_array().cloned().unwrap_or_default() { pv_by_path.insert(js::string(pv.get("path")), pv); } let lvs = lvm["lvs"].as_array().cloned().unwrap_or_default(); for kname in &order { let node = nodes.get_mut(kname).unwrap(); let path = js::string(node.get("path")); if let Some(pv) = pv_by_path.get(&path).or_else(|| pv_by_path.get(&format!("/dev/{kname}"))) { node.insert("pv".into(), pv.clone()); } let dm_name = js::string(Some(&node["dmName"])); let dm_name_js = if node["dmName"].is_null() { "null".to_string() } else { dm_name.clone() }; if node["kind"] == "lvm" { let lv = lvs.iter().find(|e| e["dmPath"] == path.as_str() || e["dmPath"] == format!("/dev/mapper/{dm_name_js}").as_str()); let names = split_dm_lv_name(&dm_name_js); let value = match (lv, names) { (Some(lv), _) => lv.clone(), (None, Some((vg, lv_name))) => json!({ "name": lv_name, "vg": vg, "size": node["size"], "attr": null, "thinPool": TPOOL.is_match(&lv_name), "thinVolume": false, "partial": true }), _ => Value::Null, }; if !value.is_null() { node.insert("displayPath".into(), json!(format!("/dev/{}/{}", js::string(value.get("vg")), js::string(value.get("name"))))); } node.insert("lvm".into(), value); } if node["kind"] == "crypt" { let status = crypt.get(&dm_name_js); let dm_uuid = if node["dmUuid"].is_null() { "null".to_string() } else { js::string(Some(&node["dmUuid"])) }; let kind = CRYPT_TYPE.captures(&dm_uuid).map(|c| json!(&c[1])).unwrap_or(Value::Null); let pick = |k: &str| status.map(|s| s[k].clone()).filter(truthy).unwrap_or(Value::Null); let crypt_type = status.map(|s| s["type"].clone()).filter(truthy).unwrap_or(kind); node.insert("crypt".into(), json!({ "type": crypt_type, "cipher": pick("cipher"), "keyLocation": pick("keyLocation"), "mode": pick("mode"), "open": true })); } } // Graph edges. Partitions hang below their disk; dm/md below their slaves. for kname in &order { let (entry, _) = &internal[kname]; let parents: Vec = if nodes[kname]["kind"] == "part" { entry.parent.as_str().filter(|p| nodes.contains_key(*p)).map(|p| vec![p.to_string()]).unwrap_or_default() } else { entry.slaves.iter().filter(|n| nodes.contains_key(*n)).cloned().collect() }; let children: Vec = entry.holders.iter().filter(|n| nodes.contains_key(*n)).cloned().collect(); let node = nodes.get_mut(kname).unwrap(); node.insert("parents".into(), json!(parents)); node.insert("children".into(), json!(children)); } fn descendants(nodes: &HashMap>, kname: &str, seen: &mut Vec) { for child in nodes[kname]["children"].as_array().unwrap().iter().filter_map(Value::as_str) { if !seen.iter().any(|s| s == child) { seen.push(child.to_string()); descendants(nodes, child, seen); } } } let raid_running = MD_STAT.is_match(&mdstat); for kname in order.clone() { let mut reasons: Vec = Vec::new(); let node = &nodes[&kname]; let points: Vec = node["mountpoints"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); if !points.is_empty() { reasons.push(json!({ "reason": "mounted", "detail": points.join(", ") })); } if node["swapActive"] == true { reasons.push(json!({ "reason": "swap" })); } let children: Vec = node["children"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); if !children.is_empty() { let detail = children.iter().map(|c| { let n = &nodes[c]; js::string(n.get("displayPath").filter(|v| truthy(v)).or(n.get("path"))) }).collect::>().join(", "); reasons.push(json!({ "reason": "holders", "detail": detail })); } if node["fstype"] == "zfs_member" { reasons.push(json!({ "reason": "zfs", "detail": if truthy(&node["zfsPool"]) { js::string(Some(&node["zfsPool"])) } else { String::new() } })); } if node["fstype"] == "linux_raid_member" && children.is_empty() && raid_running { reasons.push(json!({ "reason": "raid-member" })); } let mut stack = vec![kname.clone()]; let mut seen = Vec::new(); descendants(&nodes, &kname, &mut seen); stack.extend(seen); let mut roles: Vec = Vec::new(); for item in &stack { let n = &nodes[item]; for point in n["mountpoints"].as_array().unwrap().iter().filter_map(Value::as_str) { if SYSTEM_MOUNTS.is_match(point) { let role = format!("mount:{point}"); if !roles.contains(&role) { roles.push(role); } } } if n["swapActive"] == true && !roles.iter().any(|r| r == "swap") { roles.push("swap".into()); } } let node = nodes.get_mut(&kname).unwrap(); node.insert("inUse".into(), json!(!reasons.is_empty())); node.insert("inUseReasons".into(), json!(reasons)); node.insert("systemRoles".into(), json!(roles)); } let mut disks: Vec = Vec::new(); for kname in &order { let node = &nodes[kname]; if !(node["kind"] == "disk" || node["kind"] == "loop") || !truthy(&node["size"]) { continue; } let (entry, ls) = &internal[kname]; let table = tables.get(kname); let lbs = table.map(|t| number(&t["sectorsize"])).filter(|n| *n != 0.0 && !n.is_nan()).unwrap_or(if entry.logical_sector_size != 0.0 { entry.logical_sector_size } else { 512.0 }); let size = number(&node["size"]); let sectors = (size / lbs).floor(); let align = (MIB / lbs).floor().max(1.0); let mut part_nodes: Vec<&Map> = order.iter().map(|k| &nodes[k]).filter(|n| n["kind"] == "part" && n["parent"] == kname.as_str()).collect(); let mut label: Option = table.map(|t| js::string(t.get("label"))).filter(|l| !l.is_empty()).or_else(|| Some(text(&if truthy(&ls["pttype"]) { js::string(Some(&ls["pttype"])) } else { String::new() }, 8)).filter(|l| !l.is_empty())); if label.is_none() && !part_nodes.is_empty() { label = Some("unknown".into()); } let label_str = label.clone().unwrap_or_default(); let mut partitions: Vec = Vec::new(); if let Some(table) = table { let mut parts = table["partitions"].as_array().cloned().unwrap_or_default(); parts.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal)); for part in &parts { let number_value = PART_DIGITS.captures(&js::string(part.get("node"))).map(|c| number_str(&c[1])).unwrap_or(f64::NAN); let kernel = part_nodes.iter().find(|n| number(&n["partition"]) == number_value && !n["partition"].is_null()).copied(); partitions.push(describe_partition(&label_str, number_value, number(&part["start"]), number(&part["size"]), lbs, part, kernel)); } } else { part_nodes.sort_by(|a, b| number(&a["start512"]).partial_cmp(&number(&b["start512"])).unwrap_or(std::cmp::Ordering::Equal)); let factor = lbs / 512.0; for part in &part_nodes { let synthetic = json!({ "type": part["parttype"], "uuid": part["partuuid"], "name": part["partlabel"] }); partitions.push(describe_partition(&label_str, number(&part["partition"]), js_round(number(&part["start512"]) / factor), js_round(number(&part["size"]) / lbs), lbs, &synthetic, Some(part))); } } // Usable area of the table; GPT keeps 33 sectors (header + 128 entries) at both ends. let gpt = label_str == "gpt"; let gpt_tail = (16384.0 / lbs).ceil() + 1.0; let first_lba = table.map(|t| js::number_opt(t.get("firstlba"))).filter(|n| *n != 0.0 && !n.is_nan()); let last_lba = table.map(|t| js::number_opt(t.get("lastlba"))).filter(|n| *n != 0.0 && !n.is_nan()); let first_usable = if gpt { first_lba.unwrap_or(gpt_tail + 1.0) } else { align }; let last_usable_now = if gpt { last_lba.unwrap_or(sectors - gpt_tail - 1.0) } else { (sectors - 1.0).min(4_294_967_295.0) }; let last_usable = if gpt { sectors - gpt_tail - 1.0 } else { last_usable_now }; let gpt_backup_misplaced = gpt && table.is_some_and(|t| truthy_opt_value(t.get("lastlba"))) && last_usable_now < last_usable; let mbr_limited = label_str == "dos" && sectors - 1.0 > 4_294_967_295.0; let segments = if label.is_some() { build_segments(&partitions, first_usable, last_usable, align, lbs, last_usable_now) } else { Vec::new() }; let mut all_roles: Vec = Vec::new(); let mut sources: Vec<&Map> = vec![node]; for part in &partitions { if let Some(kernel) = part["kernel"].as_str() { if let Some(n) = nodes.get(kernel) { sources.push(n); } } } for source in sources { for role in source["systemRoles"].as_array().unwrap() { if !all_roles.contains(role) { all_roles.push(role.clone()); } } } let table_value = label.as_ref().map(|l| { let id_source = table.map(|t| t["id"].clone()).filter(truthy).or_else(|| Some(ls["ptuuid"].clone()).filter(truthy)).map(|v| js::string(Some(&v))).unwrap_or_default(); json!({ "type": l, "id": text_or_null(&id_source, 64), "firstUsable": num(first_usable), "lastUsable": num(last_usable), "lastUsableNow": num(last_usable_now), "hash": table.map(|t| t["hash"].clone()).filter(truthy).unwrap_or(Value::Null), "source": if table.is_some() { "sfdisk" } else { "sysfs" }, "gptBackupMisplaced": gpt_backup_misplaced, "mbrLimited": mbr_limited, "maxPrimary": if l == "dos" { 4 } else { 128 } }) }); let serial = if !entry.serial.is_empty() { json!(entry.serial) } else { text_or_null(&if truthy(&ls["serial"]) { js::string(Some(&ls["serial"])) } else { String::new() }, 80) }; let wwid = if !entry.wwid.is_empty() { json!(entry.wwid) } else { text_or_null(&if truthy(&ls["wwn"]) { js::string(Some(&ls["wwn"])) } else { String::new() }, 120) }; let in_use = node["inUse"] == true || partitions.iter().any(|p| p["inUse"] == true); disks.push(json!({ "name": kname, "path": node["path"], "kind": node["kind"], "model": if entry.model.is_empty() { Value::Null } else { json!(entry.model) }, "vendor": if entry.vendor.is_empty() { Value::Null } else { json!(entry.vendor) }, "serial": serial, "wwid": wwid, "transport": transport(entry, ls), "mediaType": media_type(entry), "rotational": entry.rotational, "discard": number(&entry.discard_max) > 0.0, "readOnly": node["readOnly"], "removable": entry.removable, "loopBacking": node["loopBacking"], "size": node["size"], "sectors": num(sectors), "logicalSectorSize": num(lbs), "physicalSectorSize": num(entry.physical_sector_size), "alignSectors": num(align), "table": table_value.unwrap_or(Value::Null), "partitions": partitions, "segments": segments, "wholeDevice": Value::Null, "systemRoles": all_roles.clone(), "system": !all_roles.is_empty(), "inUse": in_use })); let last = disks.last_mut().unwrap(); if last["partitions"].as_array().unwrap().is_empty() { last["wholeDevice"] = json!({ "kname": kname, "fstype": node["fstype"] }); } } let pve = get_str(&meta, "pve") == Some("1"); for disk in disks.iter_mut() { let mut stack: Vec = vec![js::string(disk.get("name"))]; for part in disk["partitions"].as_array().unwrap() { if let Some(k) = part["kernel"].as_str() { if !stack.iter().any(|s| s == k) { stack.push(k.to_string()); } } } for kname in stack.clone() { let mut seen = Vec::new(); if nodes.contains_key(&kname) { descendants(&nodes, &kname, &mut seen); } for child in seen { if !stack.contains(&child) { stack.push(child); } } } let stack_nodes: Vec<&Map> = stack.iter().filter_map(|k| nodes.get(k)).collect(); let any = |f: &dyn Fn(&Map) -> bool| stack_nodes.iter().any(|n| f(n)); let layers = json!({ "luks": any(&|n| n["fstype"] == "crypto_LUKS"), "lvm": any(&|n| n["fstype"] == "LVM2_member" || n["kind"] == "lvm"), "raid": any(&|n| n["fstype"] == "linux_raid_member" || n["kind"] == "md"), "zfs": any(&|n| n["fstype"] == "zfs_member") }); // Proxmox host storage (LVM/ZFS behind guests or the host) is never a target of destructive changes. let protected = pve && (layers["lvm"] == true || layers["zfs"] == true || disk["system"] == true); disk["layers"] = layers; disk["protected"] = if protected { json!("pve-host-storage") } else { Value::Null }; } let public_nodes: Vec = order.iter().map(|k| Value::Object(nodes[k].clone())).collect(); disks.sort_by(|a, b| js::natural_cmp(a["name"].as_str().unwrap_or(""), b["name"].as_str().unwrap_or(""))); let clean_map = |m: &BTreeMap| Value::Object(m.iter().map(|(k, v)| (text(k, 32), json!(text(v, 120)))).collect()); let lsblk_present = !lsblk.is_empty(); json!({ "platform": "linux", "supported": true, "privileged": privileged, "os": text(get_str(&meta, "os").unwrap_or(""), 120), "osId": text(get_str(&meta, "osId").unwrap_or(""), 40), "osLike": text(get_str(&meta, "osLike").unwrap_or(""), 80), "kernel": text(get_str(&meta, "kernel").unwrap_or(""), 80), "systemd": get_str(&meta, "systemd") == Some("1"), "pve": pve, "selinux": get_str(&meta, "selinux").filter(|s| !s.is_empty()).map(|s| json!(text(s, 20))).unwrap_or(Value::Null), "tools": clean_map(&tools), "versions": clean_map(&versions), "sources": { "lsblk": lsblk_present, "blkid": !blkid.is_empty(), "sfdisk": !tables.is_empty(), "lvm": lvm["available"], "crypt": !crypt.is_empty() }, "partial": !privileged, "disks": disks, "nodes": public_nodes, "lvm": lvm, "btrfs": btrfs, "zpools": zpools, "remoteTime": int_str(get_str(&meta, "time")) }) } fn truthy_opt_value(value: Option<&Value>) -> bool { value.is_some_and(truthy) } /// JavaScript `Math.round` (halves round up). pub fn js_round(value: f64) -> f64 { (value + 0.5).floor() } fn describe_partition(table_type: &str, number_value: f64, start: f64, size: f64, lbs: f64, part: &Value, kernel: Option<&Map>) -> Value { let raw_type = if truthy(&part["type"]) { js::string(Some(&part["type"])) } else { String::new() }; let type_code = text(&raw_type, 40).to_uppercase(); let type_name = if table_type == "gpt" { gpt_type(&type_code) } else { let lower = type_code.to_lowercase(); dos_type(&LEADING_ZEROS.replace(&lower, "$1")) }; let extended = type_name == Some("extended"); let k = |key: &str| kernel.map(|n| n[key].clone()).unwrap_or(Value::Null); let pick = |own: &str, kernel_key: &str, limit: usize| { let source = if truthy(&part[own]) { js::string(Some(&part[own])) } else if truthy(&k(kernel_key)) { js::string(Some(&k(kernel_key))) } else { String::new() }; text_or_null(&source, limit) }; let attrs = if truthy(&part["attrs"]) { js::string(Some(&part["attrs"])) } else { String::new() }; json!({ "number": num(number_value), "start": num(start), "size": num(size), "end": num(start + size - 1.0), "bytes": num(size * lbs), "type": if type_code.is_empty() { Value::Null } else { json!(type_code) }, "typeName": type_name, "uuid": pick("uuid", "partuuid", 64), "name": pick("name", "partlabel", 72), "attrs": text_or_null(&attrs, 160), "bootable": part["bootable"] == true, "extended": extended, "logical": table_type == "dos" && number_value > 4.0, "kernel": k("kname"), "path": k("path"), "kernelMatches": kernel.is_some_and(|n| js_round(number(&n["size"]) / lbs) == size || extended), "fstype": k("fstype"), "label": k("label"), "fsUuid": k("uuid"), "mountpoints": kernel.map(|n| n["mountpoints"].clone()).filter(truthy).unwrap_or_else(|| json!([])), "usage": k("usage"), "fsSize": if truthy(&k("fsSize")) { k("fsSize") } else { Value::Null }, "inUse": kernel.is_some_and(|n| n["inUse"] == true), "inUseReasons": kernel.map(|n| n["inUseReasons"].clone()).filter(truthy).unwrap_or_else(|| json!([])), "systemRoles": kernel.map(|n| n["systemRoles"].clone()).filter(truthy).unwrap_or_else(|| json!([])), "children": kernel.map(|n| n["children"].clone()).filter(truthy).unwrap_or_else(|| json!([])) }) } /// Ordered partitions and unallocated gaps (≥ one alignment unit) between firstUsable and lastUsable. pub fn build_segments(partitions: &[Value], first_usable: f64, last_usable: f64, align: f64, lbs: f64, last_usable_now: f64) -> Vec { let mut top: Vec<&Value> = partitions.iter().filter(|p| p["logical"] != true).collect(); top.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal)); let mut segments = Vec::new(); let mut cursor = first_usable; let push_free = |from: f64, to: f64, segments: &mut Vec| { if to - from + 1.0 < align { return; } segments.push(json!({ "kind": "free", "start": num(from), "size": num(to - from + 1.0), "end": num(to), "bytes": num((to - from + 1.0) * lbs), "needsGptRelocation": to > last_usable_now })); }; for part in top { let (start, end) = (number(&part["start"]), number(&part["end"])); if start > cursor { push_free(cursor, start - 1.0, &mut segments); } segments.push(json!({ "kind": "partition", "number": part["number"], "start": part["start"], "size": part["size"], "end": part["end"], "bytes": part["bytes"] })); cursor = cursor.max(end + 1.0); } if cursor <= last_usable { push_free(cursor, last_usable, &mut segments); } segments }