Offentliggjort kilde
Download kilde ZIP
CH-J Tools
Gennemse mapper og filer til en specifik applikationsudgivelse.
CH-J / Server Manager
1704 filer
0.1.2-beta
Tilføjet: · Bygget:
1
//! chj.disks model from the output of `remoteDisks.sh inspect` (port of `disks/inventory.js`).2
//!3
//! The device graph comes from sysfs (holders/slaves), so layered stacks such as4
//! disk → partition → LUKS → LVM PV → VG → LV → ext4 are represented without assuming that a5
//! partition directly contains a file system. lsblk/blkid/sfdisk/LVM JSON only enrich nodes;6
//! without them (BusyBox, no root) the model is marked partial instead of guessing.8
use super::js::{self, int_opt, int_str, lines, num, number, number_str, text, text_or_null, truthy};9
use once_cell::sync::Lazy;10
use regex::Regex;11
use serde_json::{json, Map, Value};12
use std::collections::{BTreeMap, HashMap};14
pub const MIB: f64 = 1_048_576.0;16
pub fn gpt_type(code: &str) -> Option<&'static str> {17
Some(match code {18
"0FC63DAF-8483-4772-8E79-3D69D8477DE4" => "linux",19
"4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709" => "linux-root-x86-64",20
"B921B045-1DF0-41C3-AF44-4C6F280D3FAE" => "linux-root-arm64",21
"BC13C2FF-59E6-4262-A352-B275FD6F7172" => "linux-extended-boot",22
"933AC7E1-2EB4-4F13-B844-0E14E2AEF915" => "linux-home",23
"3B8F8425-20E0-4F3B-907F-1A25A76F98E8" => "linux-server-data",24
"4D21B016-B534-45C2-A9FB-5C16E091FD2D" => "linux-var",25
"0657FD6D-A4AB-43C4-84E5-0933C84B4F4F" => "swap",26
"E6D6D379-F507-44C2-A23C-238F2A3DF928" => "lvm",27
"A19D880F-05FC-4D3B-A006-743F0F84911E" => "raid",28
"CA7D7CCB-63ED-4C53-861C-1742536059CC" => "luks",29
"C12A7328-F81F-11D2-BA4B-00A0C93EC93B" => "esp",30
"21686148-6449-6E6F-744E-656564454649" => "bios-boot",31
"EBD0A0A2-B9E5-4433-87C0-68B6B72699C7" => "basic-data",32
"E3C9E316-0B5C-4DB8-817D-F92DF00215AE" => "microsoft-reserved",33
"DE94BBA4-06D1-4D40-A16A-BFD50179D6AC" => "windows-recovery",34
"6A898CC3-1DD2-11B2-99A6-080020736631" => "zfs",35
"516E7CBA-6ECF-11D6-8FF8-00022D09712B" => "freebsd-zfs",36
_ => return None,37
})38
}40
pub fn dos_type(code: &str) -> Option<&'static str> {41
Some(match code {42
"83" => "linux",43
"82" => "swap",44
"8e" => "lvm",45
"fd" => "raid",46
"ef" => "esp",47
"7" => "basic-data",48
"b" | "c" => "fat32",49
"e" => "fat16",50
"5" | "f" | "85" => "extended",51
"ee" => "gpt-protective",52
_ => return None,53
})54
}56
/// Partition types CH-J writes (GPT GUID / MBR type) for new partitions and type flags.57
pub fn partition_type_code(flag: &str, table: &str) -> Option<&'static str> {58
let (gpt, dos) = match flag {59
"linux" => ("0FC63DAF-8483-4772-8E79-3D69D8477DE4", Some("83")),60
"swap" => ("0657FD6D-A4AB-43C4-84E5-0933C84B4F4F", Some("82")),61
"lvm" => ("E6D6D379-F507-44C2-A23C-238F2A3DF928", Some("8e")),62
"raid" => ("A19D880F-05FC-4D3B-A006-743F0F84911E", Some("fd")),63
"esp" => ("C12A7328-F81F-11D2-BA4B-00A0C93EC93B", Some("ef")),64
"bios-boot" => ("21686148-6449-6E6F-744E-656564454649", None),65
"basic-data" => ("EBD0A0A2-B9E5-4433-87C0-68B6B72699C7", Some("7")),66
"fat32" => ("EBD0A0A2-B9E5-4433-87C0-68B6B72699C7", Some("c")),67
_ => return None,68
};69
match table {70
"gpt" => Some(gpt),71
"dos" => dos,72
_ => None,73
}74
}76
pub static SYSTEM_MOUNTS: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(/|/boot(/.*)?|/efi|/usr(/.*)?|/var(/.*)?|/home|/opt|/srv|/root|/tmp|/nix|/snap(/.*)?)$").unwrap());77
static VIRTUAL_MODEL: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)QEMU|VBOX|VirtualBox|VMware|Virtual disk|Virtual HD|Msft Virtual|Xen|Hyper-V|BHYVE|Red Hat VirtIO").unwrap());78
static SECTION: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_SECTION=([a-z]+)\r?\n").unwrap());80
pub fn split_sections(stdout: &str) -> HashMap<String, String> {81
let mut sections = HashMap::new();82
let mut current: Option<String> = None;83
let mut start = 0;84
for caps in SECTION.captures_iter(stdout) {85
let whole = caps.get(0).unwrap();86
if let Some(name) = current.take() {87
sections.insert(name, stdout[start..whole.start()].to_string());88
}89
current = Some(caps[1].to_string());90
start = whole.end();91
}92
if let Some(name) = current {93
sections.insert(name, stdout[start..].to_string());94
}95
sections96
}98
/// 0.1.0 inventory `keyValues` (a line without "=" yields key = line minus its last character).99
pub fn key_values(value: &str) -> BTreeMap<String, String> {100
let mut out = BTreeMap::new();101
for line in lines(value) {102
let units: Vec<u16> = line.encode_utf16().collect();103
let (key, val) = match line.find('=') {104
Some(i) => (line[..i].to_string(), line[i + 1..].to_string()),105
None => (String::from_utf16_lossy(&units[..units.len().saturating_sub(1)]), line.to_string()),106
};107
if !key.is_empty() {108
out.insert(key, val);109
}110
}111
out112
}114
fn parse_json(value: &str) -> Value {115
serde_json::from_str(value).unwrap_or(Value::Null)116
}118
static OCTAL: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\(\d{3})").unwrap());120
pub fn unescape_mount(value: &str) -> String {121
OCTAL122
.replace_all(value, |c: ®ex::Captures| {123
// parseInt("089", 8) stops at the first invalid digit.124
let digits: String = c[1].chars().take_while(|d| ('0'..='7').contains(d)).collect();125
match u32::from_str_radix(&digits, 8).ok().and_then(char::from_u32) {126
Some(ch) => ch.to_string(),127
None => "\u{0}".to_string(),128
}129
})130
.to_string()131
}133
#[derive(Debug, Clone)]134
pub struct SysEntry {135
pub kname: String,136
pub devno: String,137
pub sectors512: Value,138
pub read_only: bool,139
pub removable: bool,140
pub partition: Value,141
pub start512: Value,142
pub parent: Value,143
pub rotational: Value,144
pub logical_sector_size: f64,145
pub physical_sector_size: f64,146
pub model: String,147
pub vendor: String,148
pub serial: String,149
pub wwid: String,150
pub holders: Vec<String>,151
pub slaves: Vec<String>,152
pub dm_name: String,153
pub dm_uuid: String,154
pub loop_backing: String,155
pub md_level: String,156
pub sys_path: String,157
pub discard_max: Value,158
}160
static KNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._:+-]{1,64}$").unwrap());162
fn parse_sysblock(section: &str) -> Vec<SysEntry> {163
lines(section)164
.into_iter()165
.map(|line| {166
let f: Vec<&str> = line.split('|').collect();167
let field = |i: usize| f.get(i).copied();168
let get = |i: usize| field(i).unwrap_or("");169
let list = |i: usize| get(i).split(',').filter(|s| !s.is_empty()).map(|n| text(n, 64)).collect::<Vec<_>>();170
let or_default = |v: Value, d: f64| if truthy(&v) { number(&v) } else { d };171
SysEntry {172
kname: text(get(0), 64),173
devno: text(get(1), 16),174
sectors512: int_str(field(2)),175
read_only: field(3) == Some("1"),176
removable: field(4) == Some("1"),177
partition: if field(5) == Some("-") { Value::Null } else { int_str(field(5)) },178
start512: if field(6) == Some("-") { Value::Null } else { int_str(field(6)) },179
parent: if field(7) == Some("-") { Value::Null } else { json!(text(get(7), 64)) },180
rotational: if field(8) == Some("") { Value::Null } else { json!(field(8) == Some("1")) },181
logical_sector_size: or_default(int_str(field(9)), 512.0),182
physical_sector_size: or_default(int_str(field(10)), 512.0),183
model: text(get(11), 80),184
vendor: text(get(12), 40),185
serial: text(get(13), 80),186
wwid: text(get(14), 120),187
holders: list(15),188
slaves: list(16),189
dm_name: text(get(17), 128),190
dm_uuid: text(get(18), 160),191
loop_backing: text(get(19), 200),192
md_level: text(get(20), 16),193
sys_path: text(get(21), 200),194
discard_max: int_str(field(22)),195
}196
})197
.filter(|e| KNAME.is_match(&e.kname))198
.collect()199
}201
static BLANK_LINE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\n\s*\n").unwrap());203
fn parse_blkid(section: &str) -> HashMap<String, BTreeMap<String, String>> {204
let mut result = HashMap::new();205
for block in BLANK_LINE.split(section) {206
let values = key_values(block);207
if let Some(dev) = values.get("DEVNAME").filter(|d| !d.is_empty()).cloned() {208
result.insert(dev, values);209
}210
}211
result212
}214
struct Mount {215
devno: String,216
mountpoint: String,217
options: String,218
fstype: String,219
source: String,220
}222
fn parse_mountinfo(section: &str) -> Vec<Mount> {223
lines(section)224
.into_iter()225
.map(|line| {226
let mut halves = line.split(" - ");227
let left = halves.next().unwrap_or("");228
let right = halves.next().unwrap_or("");229
let fields: Vec<&str> = left.split(' ').collect();230
let tail: Vec<&str> = right.split(' ').collect();231
Mount {232
devno: fields.get(2).copied().unwrap_or("").to_string(),233
mountpoint: unescape_mount(fields.get(4).copied().unwrap_or("")),234
options: fields.get(5).copied().unwrap_or("").to_string(),235
fstype: tail.first().copied().unwrap_or("").to_string(),236
source: unescape_mount(tail.get(1).copied().unwrap_or("")),237
}238
})239
.filter(|m| !m.mountpoint.is_empty())240
.collect()241
}243
static DF: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$").unwrap());245
fn parse_df(section: &str) -> HashMap<String, Value> {246
let mut result = HashMap::new();247
for line in lines(section).into_iter().skip(1) {248
if let Some(c) = DF.captures(line) {249
let k = |i: usize| num(number_str(&c[i]) * 1024.0);250
result.insert(c[6].to_string(), json!({ "size": k(2), "used": k(3), "available": k(4) }));251
}252
}253
result254
}256
struct Swap {257
path: String,258
used: Value,259
}261
fn parse_swaps(section: &str) -> Vec<Swap> {262
lines(section)263
.into_iter()264
.skip(1)265
.map(|line| {266
let f: Vec<&str> = line.split_whitespace().collect();267
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) }268
})269
.collect()270
}272
static PT: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)@@disk (\S+) (\S+)\n(.*?)\n@@end").unwrap());273
static HASH64: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-f0-9]{64}$").unwrap());275
fn parse_partition_tables(section: &str) -> HashMap<String, Value> {276
let mut tables = HashMap::new();277
for c in PT.captures_iter(section) {278
let json = parse_json(&c[3]);279
if let Some(Value::Object(pt)) = json.get("partitiontable") {280
let mut table = pt.clone();281
table.insert("hash".into(), if HASH64.is_match(&c[2]) { json!(&c[2]) } else { Value::Null });282
tables.insert(c[1].to_string(), Value::Object(table));283
}284
}285
tables286
}288
/// `split(/^@@<prefix>/m).filter(Boolean)`.289
fn split_marker<'a>(section: &'a str, marker: &str) -> Vec<&'a str> {290
let mut parts = Vec::new();291
let mut start = 0;292
let mut search = 0;293
while let Some(found) = section[search..].find(marker) {294
let at = search + found;295
if at == 0 || section.as_bytes()[at - 1] == b'\n' {296
parts.push(§ion[start..at]);297
start = at + marker.len();298
}299
search = at + marker.len();300
}301
parts.push(§ion[start..]);302
parts.into_iter().filter(|p| !p.is_empty()).collect()303
}305
/// `block.slice(0, block.indexOf("\n"))` / `block.slice(block.indexOf("\n") + 1)` (no newline → drop last char / whole block).306
fn head_body(block: &str) -> (String, &str) {307
match block.find('\n') {308
Some(i) => (block[..i].to_string(), &block[i + 1..]),309
None => {310
let units: Vec<u16> = block.encode_utf16().collect();311
(String::from_utf16_lossy(&units[..units.len().saturating_sub(1)]), block)312
}313
}314
}316
static PVE_GUEST: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?:vm|base)-(\d+)-(?:disk|cloudinit|state|tpmstate)").unwrap());318
fn parse_lvm(section: Option<&str>) -> Value {319
let section = section.unwrap_or("");320
let mut parts: HashMap<String, Value> = HashMap::new();321
for block in split_marker(section, "@@") {322
let (name, body) = head_body(block);323
let report = parse_json(body).get("report").and_then(|r| r.get(0)).cloned().filter(truthy).unwrap_or_else(|| json!({}));324
parts.insert(name.trim().to_string(), report);325
}326
let n = |value: &Value| {327
let s = js::string(Some(value)).trim().to_string();328
int_opt(Some(&json!(s)))329
};330
let t = |value: &Value, limit: usize| json!(text(&js::string(Some(value)), limit));331
let list = |name: &str, key: &str| parts.get(name).and_then(|p| p.get(key)).and_then(Value::as_array).cloned().unwrap_or_default();332
let pvs: Vec<Value> = list("pvs", "pv")333
.iter()334
.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) }))335
.collect();336
let vgs: Vec<Value> = list("vgs", "vg")337
.iter()338
.map(|vg| {339
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) })340
})341
.collect();342
let lvs: Vec<Value> = list("lvs", "lv")343
.iter()344
.filter(|lv| !js::string(Some(&lv["lv_name"])).starts_with('['))345
.map(|lv| {346
let attr = text(&js::string(Some(&lv["lv_attr"])), 16);347
let a: Vec<char> = attr.chars().collect();348
let name = js::string(Some(&lv["lv_name"]));349
json!({350
"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,351
"path": t(&lv["lv_path"], 200), "dmPath": t(&lv["lv_dm_path"], 200), "pool": t(&lv["pool_lv"], 128), "dataPercent": n(&lv["data_percent"]),352
"metadataPercent": n(&lv["metadata_percent"]), "segtype": t(&lv["segtype"], 32), "origin": t(&lv["origin"], 128),353
"thinPool": a.first() == Some(&'t'), "thinVolume": a.first() == Some(&'V'), "snapshot": matches!(a.first(), Some('s') | Some('S')), "active": a.get(4) == Some(&'a'),354
"pveGuest": PVE_GUEST.captures(&name).map(|c| json!(&c[1])).unwrap_or(Value::Null)355
})356
})357
.collect();358
json!({ "available": !section.is_empty(), "pvs": pvs, "vgs": vgs, "lvs": lvs })359
}361
fn parse_crypt(section: &str) -> HashMap<String, Value> {362
let mut result = HashMap::new();363
for block in split_marker(section, "@@map ") {364
let (name, body) = head_body(block);365
let mut values: HashMap<String, String> = HashMap::new();366
for line in lines(body) {367
if let Some(i) = line.find(':').filter(|i| *i > 0) {368
values.insert(line[..i].trim().to_lowercase(), text(&line[i + 1..], 120));369
}370
}371
let get = |k: &str| values.get(k).filter(|v| !v.is_empty()).map(|v| json!(v)).unwrap_or(Value::Null);372
result.insert(373
name.trim().to_string(),374
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)) }),375
);376
}377
result378
}380
static EXT_STATE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^Filesystem state:\s*(.+)$").unwrap());381
static XFS_DATA: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^data\s*=.*?bsize=(\d+)\s+blocks=(\d+)").unwrap());382
static EXT_TYPE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^ext[234]$").unwrap());384
fn parse_fsinfo(section: &str) -> HashMap<String, Value> {385
let mut result = HashMap::new();386
for block in split_marker(section, "@@fs ") {387
let (head, body) = head_body(block);388
let mut words = head.split_whitespace();389
let device = words.next().unwrap_or("undefined").to_string();390
let kind = words.next().unwrap_or("undefined");391
if EXT_TYPE.is_match(kind) {392
let value = |key: &str| {393
let re = Regex::new(&format!(r"(?m)^{}:\s*(\d+)", regex::escape(key))).unwrap();394
int_str(re.captures(body).map(|c| c.get(1).unwrap().as_str()))395
};396
let (blocks, block_size, free) = (value("Block count"), value("Block size"), value("Free blocks"));397
if truthy(&blocks) && truthy(&block_size) {398
let (b, s) = (number(&blocks), number(&block_size));399
let state = EXT_STATE.captures(body).map(|c| c[1].to_string()).unwrap_or_default();400
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) }));401
}402
} else if kind == "xfs" {403
if let Some(c) = XFS_DATA.captures(body) {404
result.insert(device, json!({ "fsSize": num(number_str(&c[1]) * number_str(&c[2])), "blockSize": num(number_str(&c[1])) }));405
}406
}407
}408
result409
}411
static BTRFS_LABEL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^Label:\s*(?:'(.*)'|none)\s+uuid:\s*([0-9a-f-]+)").unwrap());412
static BTRFS_DEVICE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*devid\s+(\d+)\s+size\s+(\d+)\s+used\s+(\d+)\s+path\s+(\S+)").unwrap());414
fn parse_btrfs(section: &str) -> Vec<Value> {415
let mut filesystems: Vec<Value> = Vec::new();416
for line in lines(section) {417
if let Some(c) = BTRFS_LABEL.captures(line) {418
let label = c.get(1).map(|m| m.as_str()).filter(|s| !s.is_empty());419
filesystems.push(json!({ "label": label, "uuid": &c[2], "devices": [] }));420
continue;421
}422
if let (Some(c), Some(current)) = (BTRFS_DEVICE.captures(line), filesystems.last_mut()) {423
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] }));424
}425
}426
filesystems427
}429
fn parse_zpool(section: &str) -> Vec<Value> {430
let mut pools: Vec<Value> = Vec::new();431
for line in lines(section) {432
let f: Vec<&str> = line.split('\t').collect();433
let get = |i: usize| f.get(i).copied();434
if get(0).is_some_and(|s| !s.is_empty()) {435
let health = get(9).filter(|s| !s.is_empty()).or_else(|| f.last().copied()).unwrap_or("");436
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": [] }));437
} else if let (Some(current), Some(dev)) = (pools.last_mut(), get(1).filter(|d| d.starts_with("/dev/"))) {438
current["devices"].as_array_mut().unwrap().push(json!(text(dev, 200)));439
}440
}441
pools442
}444
fn parse_lsblk(section: Option<&str>) -> HashMap<String, Value> {445
let json = parse_json(section.unwrap_or(""));446
let mut map = HashMap::new();447
for entry in json.get("blockdevices").and_then(Value::as_array).cloned().unwrap_or_default() {448
let key = [&entry["kname"], &entry["name"]].into_iter().find(|v| truthy(v)).map(|v| js::string(Some(v)));449
if let Some(key) = key {450
map.insert(key, entry);451
}452
}453
map454
}456
fn media_type(entry: &SysEntry) -> &'static str {457
if entry.kname.starts_with("loop") {458
return "loop";459
}460
if entry.kname.starts_with("nvme") {461
return if VIRTUAL_MODEL.is_match(&entry.model) { "virtual" } else { "nvme" };462
}463
if entry.kname.starts_with("vd") || entry.kname.starts_with("xvd") || VIRTUAL_MODEL.is_match(&entry.model) || VIRTUAL_MODEL.is_match(&entry.vendor) {464
return "virtual";465
}466
match entry.rotational {467
Value::Bool(true) => "hdd",468
Value::Bool(false) => "ssd",469
_ => "unknown",470
}471
}473
static T_VIRTIO_BLOCK: Lazy<Regex> = Lazy::new(|| Regex::new(r"/virtio\d+/block/").unwrap());474
static T_VIRTIO_SCSI: Lazy<Regex> = Lazy::new(|| Regex::new(r"/virtio\d+/host\d+/").unwrap());475
static T_USB: Lazy<Regex> = Lazy::new(|| Regex::new(r"/usb\d+/").unwrap());476
static T_ATA: Lazy<Regex> = Lazy::new(|| Regex::new(r"/ata\d+/").unwrap());477
static T_SCSI: Lazy<Regex> = Lazy::new(|| Regex::new(r"/host\d+/target").unwrap());479
fn transport(entry: &SysEntry, lsblk: &Value) -> Value {480
if truthy(&lsblk["tran"]) {481
return json!(text(&js::string(Some(&lsblk["tran"])), 16));482
}483
let path = &entry.sys_path;484
let t = if entry.kname.starts_with("nvme") {485
"nvme"486
} else if T_VIRTIO_BLOCK.is_match(path) {487
"virtio"488
} else if T_VIRTIO_SCSI.is_match(path) {489
"virtio-scsi"490
} else if T_USB.is_match(path) {491
"usb"492
} else if T_ATA.is_match(path) {493
"sata"494
} else if T_SCSI.is_match(path) {495
"scsi"496
} else if entry.kname.starts_with("mmcblk") {497
"mmc"498
} else {499
return Value::Null;500
};501
json!(t)502
}504
static MD: Lazy<Regex> = Lazy::new(|| Regex::new(r"^md\d").unwrap());506
fn node_kind(entry: &SysEntry) -> &'static str {507
if !entry.partition.is_null() {508
return "part";509
}510
if entry.kname.starts_with("dm-") {511
if entry.dm_uuid.starts_with("CRYPT-") {512
return "crypt";513
}514
if entry.dm_uuid.starts_with("LVM-") {515
return "lvm";516
}517
return "dm";518
}519
if MD.is_match(&entry.kname) {520
return "md";521
}522
if entry.kname.starts_with("loop") {523
return "loop";524
}525
"disk"526
}528
static DM_LV: Lazy<Regex> = Lazy::new(|| Regex::new(r"^((?:[^-]|--)+)-((?:[^-]|--)+)$").unwrap());530
/// Unescape a device-mapper LVM name ("vg--data-lv" → vg "vg-data", lv "lv").531
pub fn split_dm_lv_name(name: &str) -> Option<(String, String)> {532
// The JavaScript regex backtracks to the last single dash between two valid halves.533
let chars: Vec<char> = name.chars().collect();534
for i in (0..chars.len()).rev() {535
if chars[i] != '-' {536
continue;537
}538
let (left, right): (String, String) = (chars[..i].iter().collect(), chars[i + 1..].iter().collect());539
let valid = |s: &str| !s.is_empty() && DM_LV.is_match(&format!("{s}-x")) && !s.starts_with('-') && dashes_paired(s);540
if valid(&left) && valid(&right) {541
return Some((left.replace("--", "-"), right.replace("--", "-")));542
}543
}544
None545
}547
/// `(?:[^-]|--)+` matches the whole string.548
fn dashes_paired(s: &str) -> bool {549
let mut run = 0;550
for c in s.chars() {551
if c == '-' {552
run += 1;553
} else {554
if run % 2 == 1 {555
return false;556
}557
run = 0;558
}559
}560
run % 2 == 0561
}563
static TPOOL: Lazy<Regex> = Lazy::new(|| Regex::new(r"-tpool$|_tdata$|_tmeta$").unwrap());564
static CRYPT_TYPE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^CRYPT-([A-Z0-9]+)-").unwrap());565
static MD_STAT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b(md\d+)\b").unwrap());566
static SKIP_KNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(ram|zram|fd)\d*$").unwrap());567
static LOOP_N: Lazy<Regex> = Lazy::new(|| Regex::new(r"^loop\d+$").unwrap());568
static SR_N: Lazy<Regex> = Lazy::new(|| Regex::new(r"^sr\d+$").unwrap());569
static PART_DIGITS: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\d+)$").unwrap());570
static RO: Lazy<Regex> = Lazy::new(|| Regex::new(r"(^|,)ro(,|$)").unwrap());571
static LEADING_ZEROS: Lazy<Regex> = Lazy::new(|| Regex::new(r"^0+(.)").unwrap());573
fn get_str<'a>(map: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {574
map.get(key).map(String::as_str)575
}577
pub fn build_inventory(stdout: &str) -> Value {578
let sections = split_sections(stdout);579
let section = |name: &str| sections.get(name).map(String::as_str);580
let s = |name: &str| section(name).unwrap_or("");581
let meta = key_values(s("meta"));582
let tools = key_values(s("tools"));583
let versions = key_values(s("versions"));584
let privileged = get_str(&meta, "uid") == Some("0");585
let sys = parse_sysblock(s("sysblock"));586
let lsblk = parse_lsblk(section("lsblk"));587
let blkid = parse_blkid(s("blkid"));588
let mounts = parse_mountinfo(s("mountinfo"));589
let df = parse_df(s("df"));590
let swaps = parse_swaps(s("swaps"));591
let tables = parse_partition_tables(s("pt"));592
let lvm = parse_lvm(section("lvm"));593
let crypt = parse_crypt(s("crypt"));594
let fsinfo = parse_fsinfo(s("fsinfo"));595
let btrfs = parse_btrfs(s("btrfs"));596
let zpools = parse_zpool(s("zpool"));597
let mdstat = s("mdstat").to_string();599
// Nodes in insertion order; `_sys`/`_lsblk` kept beside them.600
let mut order: Vec<String> = Vec::new();601
let mut nodes: HashMap<String, Map<String, Value>> = HashMap::new();602
let mut internal: HashMap<String, (SysEntry, Value)> = HashMap::new();603
for entry in &sys {604
if SKIP_KNAME.is_match(&entry.kname) {605
continue;606
}607
// Unused loop devices (size 0) and read-only snap images are not disks anyone manages here.608
if LOOP_N.is_match(&entry.kname) && (!truthy(&entry.sectors512) || entry.read_only || entry.loop_backing.is_empty()) {609
continue;610
}611
if SR_N.is_match(&entry.kname) {612
continue;613
}614
let ls = lsblk.get(&entry.kname).cloned().unwrap_or_else(|| json!({}));615
let path = if !entry.dm_name.is_empty() && entry.kname.starts_with("dm-") { format!("/dev/mapper/{}", entry.dm_name) } else { format!("/dev/{}", entry.kname) };616
let empty = BTreeMap::new();617
let probe = blkid.get(&format!("/dev/{}", entry.kname)).or_else(|| blkid.get(&path)).unwrap_or(&empty);618
let kind = node_kind(entry);619
let probe_or = |key: &str, ls_key: &str| -> String {620
// `probe.X ?? ls.x ?? ""`621
match probe.get(key) {622
Some(v) => v.clone(),623
None => match &ls[ls_key] {624
Value::Null => String::new(),625
v => js::string(Some(v)),626
},627
}628
};629
let probe_truthy = |key: &str, ls_key: &str| -> String {630
// `probe.X || ls.x || ""`631
match probe.get(key).filter(|v| !v.is_empty()) {632
Some(v) => v.clone(),633
None if truthy(&ls[ls_key]) => js::string(Some(&ls[ls_key])),634
None => String::new(),635
}636
};637
let fstype = text_or_null(&probe_truthy("TYPE", "fstype"), 32);638
let mut node = Map::new();639
node.insert("kname".into(), json!(entry.kname));640
node.insert("path".into(), json!(path));641
node.insert("kind".into(), json!(kind));642
node.insert("devno".into(), json!(entry.devno));643
node.insert("size".into(), if entry.sectors512.is_null() { Value::Null } else { num(number(&entry.sectors512) * 512.0) });644
node.insert("readOnly".into(), json!(entry.read_only));645
node.insert("parent".into(), entry.parent.clone());646
node.insert("partition".into(), entry.partition.clone());647
node.insert("start512".into(), entry.start512.clone());648
node.insert("fstype".into(), fstype);649
node.insert("fsVersion".into(), text_or_null(&probe_truthy("VERSION", "fsver"), 16));650
node.insert("label".into(), text_or_null(&probe_or("LABEL", "label"), 64));651
node.insert("uuid".into(), text_or_null(&probe_or("UUID", "uuid"), 64));652
node.insert("partuuid".into(), text_or_null(&probe_or("PARTUUID", "partuuid"), 64));653
node.insert("partlabel".into(), text_or_null(&probe_or("PARTLABEL", "partlabel"), 72));654
node.insert("parttype".into(), text_or_null(&if ls["parttype"].is_null() { String::new() } else { js::string(Some(&ls["parttype"])) }, 40));655
node.insert("dmName".into(), if entry.dm_name.is_empty() { Value::Null } else { json!(entry.dm_name) });656
node.insert("dmUuid".into(), if entry.dm_uuid.is_empty() { Value::Null } else { json!(entry.dm_uuid) });657
node.insert("loopBacking".into(), if entry.loop_backing.is_empty() { Value::Null } else { json!(entry.loop_backing) });658
node.insert("mdLevel".into(), if entry.md_level.is_empty() { Value::Null } else { json!(entry.md_level) });659
node.insert("mountpoints".into(), json!([]));660
node.insert("usage".into(), Value::Null);661
node.insert("fsSize".into(), Value::Null);662
node.insert("fsUsed".into(), Value::Null);663
node.insert("swapActive".into(), json!(false));664
node.insert("lvm".into(), Value::Null);665
node.insert("crypt".into(), Value::Null);666
node.insert("zfsPool".into(), Value::Null);667
node.insert("btrfs".into(), Value::Null);668
if !nodes.contains_key(&entry.kname) {669
order.push(entry.kname.clone());670
}671
nodes.insert(entry.kname.clone(), node);672
internal.insert(entry.kname.clone(), (entry.clone(), ls));673
}674
let lookup_path = |nodes: &HashMap<String, Map<String, Value>>, order: &[String]| {675
let mut by_path: HashMap<String, String> = HashMap::new();676
for kname in order {677
let node = &nodes[kname];678
by_path.insert(js::string(node.get("path")), kname.clone());679
by_path.insert(format!("/dev/{kname}"), kname.clone());680
if truthy(&node["dmName"]) {681
by_path.insert(format!("/dev/mapper/{}", js::string(node.get("dmName"))), kname.clone());682
}683
}684
by_path685
};686
let mut by_devno: HashMap<String, String> = HashMap::new();687
for kname in &order {688
by_devno.insert(js::string(nodes[kname].get("devno")), kname.clone());689
}690
let by_path = lookup_path(&nodes, &order);692
// Mounts: by device number first (exact), by source path otherwise (Btrfs uses anonymous device numbers).693
for mount in &mounts {694
let Some(kname) = by_devno.get(&mount.devno).or_else(|| by_path.get(&mount.source)).cloned() else { continue };695
let node = nodes.get_mut(&kname).unwrap();696
let points = node["mountpoints"].as_array_mut().unwrap();697
if !points.iter().any(|p| p == mount.mountpoint.as_str()) {698
points.push(json!(mount.mountpoint));699
}700
if !node.get("mountedFstype").is_some_and(truthy) {701
node.insert("mountedFstype".into(), json!(mount.fstype));702
}703
node.insert("mountedReadOnly".into(), json!(RO.is_match(&mount.options)));704
}705
for swap in &swaps {706
if let Some(kname) = by_path.get(&swap.path) {707
let node = nodes.get_mut(kname).unwrap();708
node.insert("swapActive".into(), json!(true));709
node.insert("swapUsed".into(), swap.used.clone());710
}711
}712
for kname in &order {713
let node = nodes.get_mut(kname).unwrap();714
let mountpoint = node["mountpoints"].as_array().unwrap().iter().filter_map(Value::as_str).find(|p| df.contains_key(*p)).map(str::to_string);715
if let Some(mountpoint) = mountpoint {716
let mut usage = df[&mountpoint].as_object().cloned().unwrap();717
usage.insert("source".into(), json!("df"));718
usage.insert("mountpoint".into(), json!(mountpoint));719
node.insert("usage".into(), Value::Object(usage));720
}721
let info = fsinfo.get(&js::string(node.get("path"))).or_else(|| fsinfo.get(&format!("/dev/{kname}")));722
if let Some(info) = info {723
node.insert("fsSize".into(), info["fsSize"].clone());724
node.insert("fsState".into(), if truthy(&info["state"]) { info["state"].clone() } else { Value::Null });725
let fs_used = info.get("fsUsed");726
if !truthy(&node["usage"]) && fs_used.is_some_and(|u| !u.is_null()) {727
let (size, used) = (number(&info["fsSize"]), number(fs_used.unwrap()));728
node.insert("usage".into(), json!({ "size": info["fsSize"], "used": fs_used.unwrap(), "available": num(size - used), "source": "superblock" }));729
}730
}731
if !truthy(&node["fsSize"]) && truthy(&node["usage"]) && node["fstype"] == "xfs" {732
node.insert("fsSize".into(), Value::Null);733
}734
}735
for fs in &btrfs {736
let devices = fs["devices"].as_array().cloned().unwrap_or_default();737
for device in &devices {738
if let Some(kname) = by_path.get(&js::string(device.get("path"))) {739
let node = nodes.get_mut(kname).unwrap();740
node.insert("btrfs".into(), json!({ "uuid": fs["uuid"], "devid": device["devid"], "devices": devices.len(), "allocated": device["used"] }));741
node.insert("fsSize".into(), device["size"].clone());742
}743
}744
}745
for pool in &zpools {746
for device in pool["devices"].as_array().cloned().unwrap_or_default() {747
if let Some(kname) = by_path.get(&js::string(Some(&device))) {748
nodes.get_mut(kname).unwrap().insert("zfsPool".into(), pool["name"].clone());749
}750
}751
}753
// LVM: privileged JSON when available, otherwise the device-mapper name.754
let mut pv_by_path: HashMap<String, Value> = HashMap::new();755
for pv in lvm["pvs"].as_array().cloned().unwrap_or_default() {756
pv_by_path.insert(js::string(pv.get("path")), pv);757
}758
let lvs = lvm["lvs"].as_array().cloned().unwrap_or_default();759
for kname in &order {760
let node = nodes.get_mut(kname).unwrap();761
let path = js::string(node.get("path"));762
if let Some(pv) = pv_by_path.get(&path).or_else(|| pv_by_path.get(&format!("/dev/{kname}"))) {763
node.insert("pv".into(), pv.clone());764
}765
let dm_name = js::string(Some(&node["dmName"]));766
let dm_name_js = if node["dmName"].is_null() { "null".to_string() } else { dm_name.clone() };767
if node["kind"] == "lvm" {768
let lv = lvs.iter().find(|e| e["dmPath"] == path.as_str() || e["dmPath"] == format!("/dev/mapper/{dm_name_js}").as_str());769
let names = split_dm_lv_name(&dm_name_js);770
let value = match (lv, names) {771
(Some(lv), _) => lv.clone(),772
(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 }),773
_ => Value::Null,774
};775
if !value.is_null() {776
node.insert("displayPath".into(), json!(format!("/dev/{}/{}", js::string(value.get("vg")), js::string(value.get("name")))));777
}778
node.insert("lvm".into(), value);779
}780
if node["kind"] == "crypt" {781
let status = crypt.get(&dm_name_js);782
let dm_uuid = if node["dmUuid"].is_null() { "null".to_string() } else { js::string(Some(&node["dmUuid"])) };783
let kind = CRYPT_TYPE.captures(&dm_uuid).map(|c| json!(&c[1])).unwrap_or(Value::Null);784
let pick = |k: &str| status.map(|s| s[k].clone()).filter(truthy).unwrap_or(Value::Null);785
let crypt_type = status.map(|s| s["type"].clone()).filter(truthy).unwrap_or(kind);786
node.insert("crypt".into(), json!({ "type": crypt_type, "cipher": pick("cipher"), "keyLocation": pick("keyLocation"), "mode": pick("mode"), "open": true }));787
}788
}790
// Graph edges. Partitions hang below their disk; dm/md below their slaves.791
for kname in &order {792
let (entry, _) = &internal[kname];793
let parents: Vec<String> = if nodes[kname]["kind"] == "part" {794
entry.parent.as_str().filter(|p| nodes.contains_key(*p)).map(|p| vec![p.to_string()]).unwrap_or_default()795
} else {796
entry.slaves.iter().filter(|n| nodes.contains_key(*n)).cloned().collect()797
};798
let children: Vec<String> = entry.holders.iter().filter(|n| nodes.contains_key(*n)).cloned().collect();799
let node = nodes.get_mut(kname).unwrap();800
node.insert("parents".into(), json!(parents));801
node.insert("children".into(), json!(children));802
}803
fn descendants(nodes: &HashMap<String, Map<String, Value>>, kname: &str, seen: &mut Vec<String>) {804
for child in nodes[kname]["children"].as_array().unwrap().iter().filter_map(Value::as_str) {805
if !seen.iter().any(|s| s == child) {806
seen.push(child.to_string());807
descendants(nodes, child, seen);808
}809
}810
}811
let raid_running = MD_STAT.is_match(&mdstat);812
for kname in order.clone() {813
let mut reasons: Vec<Value> = Vec::new();814
let node = &nodes[&kname];815
let points: Vec<String> = node["mountpoints"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();816
if !points.is_empty() {817
reasons.push(json!({ "reason": "mounted", "detail": points.join(", ") }));818
}819
if node["swapActive"] == true {820
reasons.push(json!({ "reason": "swap" }));821
}822
let children: Vec<String> = node["children"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();823
if !children.is_empty() {824
let detail = children.iter().map(|c| { let n = &nodes[c]; js::string(n.get("displayPath").filter(|v| truthy(v)).or(n.get("path"))) }).collect::<Vec<_>>().join(", ");825
reasons.push(json!({ "reason": "holders", "detail": detail }));826
}827
if node["fstype"] == "zfs_member" {828
reasons.push(json!({ "reason": "zfs", "detail": if truthy(&node["zfsPool"]) { js::string(Some(&node["zfsPool"])) } else { String::new() } }));829
}830
if node["fstype"] == "linux_raid_member" && children.is_empty() && raid_running {831
reasons.push(json!({ "reason": "raid-member" }));832
}833
let mut stack = vec![kname.clone()];834
let mut seen = Vec::new();835
descendants(&nodes, &kname, &mut seen);836
stack.extend(seen);837
let mut roles: Vec<String> = Vec::new();838
for item in &stack {839
let n = &nodes[item];840
for point in n["mountpoints"].as_array().unwrap().iter().filter_map(Value::as_str) {841
if SYSTEM_MOUNTS.is_match(point) {842
let role = format!("mount:{point}");843
if !roles.contains(&role) {844
roles.push(role);845
}846
}847
}848
if n["swapActive"] == true && !roles.iter().any(|r| r == "swap") {849
roles.push("swap".into());850
}851
}852
let node = nodes.get_mut(&kname).unwrap();853
node.insert("inUse".into(), json!(!reasons.is_empty()));854
node.insert("inUseReasons".into(), json!(reasons));855
node.insert("systemRoles".into(), json!(roles));856
}858
let mut disks: Vec<Value> = Vec::new();859
for kname in &order {860
let node = &nodes[kname];861
if !(node["kind"] == "disk" || node["kind"] == "loop") || !truthy(&node["size"]) {862
continue;863
}864
let (entry, ls) = &internal[kname];865
let table = tables.get(kname);866
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 });867
let size = number(&node["size"]);868
let sectors = (size / lbs).floor();869
let align = (MIB / lbs).floor().max(1.0);870
let mut part_nodes: Vec<&Map<String, Value>> = order.iter().map(|k| &nodes[k]).filter(|n| n["kind"] == "part" && n["parent"] == kname.as_str()).collect();871
let mut label: Option<String> = 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()));872
if label.is_none() && !part_nodes.is_empty() {873
label = Some("unknown".into());874
}875
let label_str = label.clone().unwrap_or_default();876
let mut partitions: Vec<Value> = Vec::new();877
if let Some(table) = table {878
let mut parts = table["partitions"].as_array().cloned().unwrap_or_default();879
parts.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal));880
for part in &parts {881
let number_value = PART_DIGITS.captures(&js::string(part.get("node"))).map(|c| number_str(&c[1])).unwrap_or(f64::NAN);882
let kernel = part_nodes.iter().find(|n| number(&n["partition"]) == number_value && !n["partition"].is_null()).copied();883
partitions.push(describe_partition(&label_str, number_value, number(&part["start"]), number(&part["size"]), lbs, part, kernel));884
}885
} else {886
part_nodes.sort_by(|a, b| number(&a["start512"]).partial_cmp(&number(&b["start512"])).unwrap_or(std::cmp::Ordering::Equal));887
let factor = lbs / 512.0;888
for part in &part_nodes {889
let synthetic = json!({ "type": part["parttype"], "uuid": part["partuuid"], "name": part["partlabel"] });890
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)));891
}892
}893
// Usable area of the table; GPT keeps 33 sectors (header + 128 entries) at both ends.894
let gpt = label_str == "gpt";895
let gpt_tail = (16384.0 / lbs).ceil() + 1.0;896
let first_lba = table.map(|t| js::number_opt(t.get("firstlba"))).filter(|n| *n != 0.0 && !n.is_nan());897
let last_lba = table.map(|t| js::number_opt(t.get("lastlba"))).filter(|n| *n != 0.0 && !n.is_nan());898
let first_usable = if gpt { first_lba.unwrap_or(gpt_tail + 1.0) } else { align };899
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) };900
let last_usable = if gpt { sectors - gpt_tail - 1.0 } else { last_usable_now };901
let gpt_backup_misplaced = gpt && table.is_some_and(|t| truthy_opt_value(t.get("lastlba"))) && last_usable_now < last_usable;902
let mbr_limited = label_str == "dos" && sectors - 1.0 > 4_294_967_295.0;903
let segments = if label.is_some() { build_segments(&partitions, first_usable, last_usable, align, lbs, last_usable_now) } else { Vec::new() };904
let mut all_roles: Vec<Value> = Vec::new();905
let mut sources: Vec<&Map<String, Value>> = vec![node];906
for part in &partitions {907
if let Some(kernel) = part["kernel"].as_str() {908
if let Some(n) = nodes.get(kernel) {909
sources.push(n);910
}911
}912
}913
for source in sources {914
for role in source["systemRoles"].as_array().unwrap() {915
if !all_roles.contains(role) {916
all_roles.push(role.clone());917
}918
}919
}920
let table_value = label.as_ref().map(|l| {921
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();922
json!({923
"type": l, "id": text_or_null(&id_source, 64), "firstUsable": num(first_usable), "lastUsable": num(last_usable), "lastUsableNow": num(last_usable_now),924
"hash": table.map(|t| t["hash"].clone()).filter(truthy).unwrap_or(Value::Null), "source": if table.is_some() { "sfdisk" } else { "sysfs" },925
"gptBackupMisplaced": gpt_backup_misplaced, "mbrLimited": mbr_limited, "maxPrimary": if l == "dos" { 4 } else { 128 }926
})927
});928
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) };929
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) };930
let in_use = node["inUse"] == true || partitions.iter().any(|p| p["inUse"] == true);931
disks.push(json!({932
"name": kname, "path": node["path"], "kind": node["kind"],933
"model": if entry.model.is_empty() { Value::Null } else { json!(entry.model) },934
"vendor": if entry.vendor.is_empty() { Value::Null } else { json!(entry.vendor) },935
"serial": serial, "wwid": wwid,936
"transport": transport(entry, ls), "mediaType": media_type(entry), "rotational": entry.rotational,937
"discard": number(&entry.discard_max) > 0.0, "readOnly": node["readOnly"], "removable": entry.removable, "loopBacking": node["loopBacking"],938
"size": node["size"], "sectors": num(sectors), "logicalSectorSize": num(lbs), "physicalSectorSize": num(entry.physical_sector_size), "alignSectors": num(align),939
"table": table_value.unwrap_or(Value::Null),940
"partitions": partitions, "segments": segments,941
"wholeDevice": Value::Null,942
"systemRoles": all_roles.clone(), "system": !all_roles.is_empty(),943
"inUse": in_use944
}));945
let last = disks.last_mut().unwrap();946
if last["partitions"].as_array().unwrap().is_empty() {947
last["wholeDevice"] = json!({ "kname": kname, "fstype": node["fstype"] });948
}949
}951
let pve = get_str(&meta, "pve") == Some("1");952
for disk in disks.iter_mut() {953
let mut stack: Vec<String> = vec![js::string(disk.get("name"))];954
for part in disk["partitions"].as_array().unwrap() {955
if let Some(k) = part["kernel"].as_str() {956
if !stack.iter().any(|s| s == k) {957
stack.push(k.to_string());958
}959
}960
}961
for kname in stack.clone() {962
let mut seen = Vec::new();963
if nodes.contains_key(&kname) {964
descendants(&nodes, &kname, &mut seen);965
}966
for child in seen {967
if !stack.contains(&child) {968
stack.push(child);969
}970
}971
}972
let stack_nodes: Vec<&Map<String, Value>> = stack.iter().filter_map(|k| nodes.get(k)).collect();973
let any = |f: &dyn Fn(&Map<String, Value>) -> bool| stack_nodes.iter().any(|n| f(n));974
let layers = json!({975
"luks": any(&|n| n["fstype"] == "crypto_LUKS"),976
"lvm": any(&|n| n["fstype"] == "LVM2_member" || n["kind"] == "lvm"),977
"raid": any(&|n| n["fstype"] == "linux_raid_member" || n["kind"] == "md"),978
"zfs": any(&|n| n["fstype"] == "zfs_member")979
});980
// Proxmox host storage (LVM/ZFS behind guests or the host) is never a target of destructive changes.981
let protected = pve && (layers["lvm"] == true || layers["zfs"] == true || disk["system"] == true);982
disk["layers"] = layers;983
disk["protected"] = if protected { json!("pve-host-storage") } else { Value::Null };984
}986
let public_nodes: Vec<Value> = order.iter().map(|k| Value::Object(nodes[k].clone())).collect();987
disks.sort_by(|a, b| js::natural_cmp(a["name"].as_str().unwrap_or(""), b["name"].as_str().unwrap_or("")));988
let clean_map = |m: &BTreeMap<String, String>| Value::Object(m.iter().map(|(k, v)| (text(k, 32), json!(text(v, 120)))).collect());989
let lsblk_present = !lsblk.is_empty();990
json!({991
"platform": "linux", "supported": true, "privileged": privileged,992
"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),993
"systemd": get_str(&meta, "systemd") == Some("1"), "pve": pve,994
"selinux": get_str(&meta, "selinux").filter(|s| !s.is_empty()).map(|s| json!(text(s, 20))).unwrap_or(Value::Null),995
"tools": clean_map(&tools),996
"versions": clean_map(&versions),997
"sources": { "lsblk": lsblk_present, "blkid": !blkid.is_empty(), "sfdisk": !tables.is_empty(), "lvm": lvm["available"], "crypt": !crypt.is_empty() },998
"partial": !privileged,999
"disks": disks,1000
"nodes": public_nodes,1001
"lvm": lvm,1002
"btrfs": btrfs, "zpools": zpools,1003
"remoteTime": int_str(get_str(&meta, "time"))1004
})1005
}1007
fn truthy_opt_value(value: Option<&Value>) -> bool {1008
value.is_some_and(truthy)1009
}1011
/// JavaScript `Math.round` (halves round up).1012
pub fn js_round(value: f64) -> f64 {1013
(value + 0.5).floor()1014
}1016
fn describe_partition(table_type: &str, number_value: f64, start: f64, size: f64, lbs: f64, part: &Value, kernel: Option<&Map<String, Value>>) -> Value {1017
let raw_type = if truthy(&part["type"]) { js::string(Some(&part["type"])) } else { String::new() };1018
let type_code = text(&raw_type, 40).to_uppercase();1019
let type_name = if table_type == "gpt" {1020
gpt_type(&type_code)1021
} else {1022
let lower = type_code.to_lowercase();1023
dos_type(&LEADING_ZEROS.replace(&lower, "$1"))1024
};1025
let extended = type_name == Some("extended");1026
let k = |key: &str| kernel.map(|n| n[key].clone()).unwrap_or(Value::Null);1027
let pick = |own: &str, kernel_key: &str, limit: usize| {1028
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() };1029
text_or_null(&source, limit)1030
};1031
let attrs = if truthy(&part["attrs"]) { js::string(Some(&part["attrs"])) } else { String::new() };1032
json!({1033
"number": num(number_value), "start": num(start), "size": num(size), "end": num(start + size - 1.0), "bytes": num(size * lbs),1034
"type": if type_code.is_empty() { Value::Null } else { json!(type_code) }, "typeName": type_name,1035
"uuid": pick("uuid", "partuuid", 64), "name": pick("name", "partlabel", 72),1036
"attrs": text_or_null(&attrs, 160), "bootable": part["bootable"] == true,1037
"extended": extended, "logical": table_type == "dos" && number_value > 4.0,1038
"kernel": k("kname"), "path": k("path"),1039
"kernelMatches": kernel.is_some_and(|n| js_round(number(&n["size"]) / lbs) == size || extended),1040
"fstype": k("fstype"), "label": k("label"), "fsUuid": k("uuid"),1041
"mountpoints": kernel.map(|n| n["mountpoints"].clone()).filter(truthy).unwrap_or_else(|| json!([])),1042
"usage": k("usage"), "fsSize": if truthy(&k("fsSize")) { k("fsSize") } else { Value::Null },1043
"inUse": kernel.is_some_and(|n| n["inUse"] == true),1044
"inUseReasons": kernel.map(|n| n["inUseReasons"].clone()).filter(truthy).unwrap_or_else(|| json!([])),1045
"systemRoles": kernel.map(|n| n["systemRoles"].clone()).filter(truthy).unwrap_or_else(|| json!([])),1046
"children": kernel.map(|n| n["children"].clone()).filter(truthy).unwrap_or_else(|| json!([]))1047
})1048
}1050
/// Ordered partitions and unallocated gaps (≥ one alignment unit) between firstUsable and lastUsable.1051
pub fn build_segments(partitions: &[Value], first_usable: f64, last_usable: f64, align: f64, lbs: f64, last_usable_now: f64) -> Vec<Value> {1052
let mut top: Vec<&Value> = partitions.iter().filter(|p| p["logical"] != true).collect();1053
top.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal));1054
let mut segments = Vec::new();1055
let mut cursor = first_usable;1056
let push_free = |from: f64, to: f64, segments: &mut Vec<Value>| {1057
if to - from + 1.0 < align {1058
return;1059
}1060
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 }));1061
};1062
for part in top {1063
let (start, end) = (number(&part["start"]), number(&part["end"]));1064
if start > cursor {1065
push_free(cursor, start - 1.0, &mut segments);1066
}1067
segments.push(json!({ "kind": "partition", "number": part["number"], "start": part["start"], "size": part["size"], "end": part["end"], "bytes": part["bytes"] }));1068
cursor = cursor.max(end + 1.0);1069
}1070
if cursor <= last_usable {1071
push_free(cursor, last_usable, &mut segments);1072
}1073
segments1074
}SHA-256
fbf9e93eda5576e95cfae3d3fd9de3e399185b1133a6b7d3a63fdebe36de51ecCH-J Proprietary Software License 1.14 · Kilden leveres under CH-J Proprietary Software License 1.14. Dens tilgængelighed ændrer ikke licensbetingelserne eller giver yderligere rettigheder.
Arkiv SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f