"use strict"; // Builds the chj.disks model from the output of remoteDisks.sh "inspect". 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. const text = (value, limit = 200) => String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit); const int = (value) => { const result = Number(value); return value === "" || value === null || value === undefined || !Number.isFinite(result) ? null : result; }; const lines = (value) => String(value || "").split(/\r?\n/).filter(Boolean); const MiB = 1048576; const GPT_TYPES = Object.freeze({ "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" }); const DOS_TYPES = Object.freeze({ "83": "linux", "82": "swap", "8e": "lvm", "fd": "raid", "ef": "esp", "7": "basic-data", "b": "fat32", "c": "fat32", "e": "fat16", "5": "extended", "f": "extended", "85": "extended", "ee": "gpt-protective" }); // Partition types CH-J writes (GPT GUID / MBR type) for new partitions and type flags. const PARTITION_TYPE_CODES = Object.freeze({ linux: { gpt: "0FC63DAF-8483-4772-8E79-3D69D8477DE4", dos: "83" }, swap: { gpt: "0657FD6D-A4AB-43C4-84E5-0933C84B4F4F", dos: "82" }, lvm: { gpt: "E6D6D379-F507-44C2-A23C-238F2A3DF928", dos: "8e" }, raid: { gpt: "A19D880F-05FC-4D3B-A006-743F0F84911E", dos: "fd" }, esp: { gpt: "C12A7328-F81F-11D2-BA4B-00A0C93EC93B", dos: "ef" }, "bios-boot": { gpt: "21686148-6449-6E6F-744E-656564454649", dos: null }, "basic-data": { gpt: "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7", dos: "7" }, fat32: { gpt: "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7", dos: "c" } }); const SYSTEM_MOUNTS = /^(\/|\/boot(\/.*)?|\/efi|\/usr(\/.*)?|\/var(\/.*)?|\/home|\/opt|\/srv|\/root|\/tmp|\/nix|\/snap(\/.*)?)$/; const VIRTUAL_MODEL = /QEMU|VBOX|VirtualBox|VMware|Virtual disk|Virtual HD|Msft Virtual|Xen|Hyper-V|BHYVE|Red Hat VirtIO/i; function splitSections(stdout) { const sections = {}; const pattern = /\x1eCHJ_SECTION=([a-z]+)\r?\n/g; let current = null; let start = 0; let match; while ((match = pattern.exec(stdout))) { if (current) sections[current] = stdout.slice(start, match.index); current = match[1]; start = pattern.lastIndex; } if (current) sections[current] = stdout.slice(start); return sections; } const keyValues = (value) => Object.fromEntries(lines(value).map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]).filter(([key]) => key)); const parseJson = (value) => { try { return JSON.parse(value); } catch { return null; } }; const unescapeMount = (value) => String(value).replace(/\\(\d{3})/g, (_m, octal) => String.fromCharCode(Number.parseInt(octal, 8))); function parseSysblock(section) { return lines(section).map((line) => { const f = line.split("|"); return { kname: text(f[0], 64), devno: text(f[1], 16), sectors512: int(f[2]), readOnly: f[3] === "1", removable: f[4] === "1", partition: f[5] === "-" ? null : int(f[5]), start512: f[6] === "-" ? null : int(f[6]), parent: f[7] === "-" ? null : text(f[7], 64), rotational: f[8] === "" ? null : f[8] === "1", logicalSectorSize: int(f[9]) || 512, physicalSectorSize: int(f[10]) || 512, model: text(f[11], 80), vendor: text(f[12], 40), serial: text(f[13], 80), wwid: text(f[14], 120), holders: String(f[15] || "").split(",").filter(Boolean).map((name) => text(name, 64)), slaves: String(f[16] || "").split(",").filter(Boolean).map((name) => text(name, 64)), dmName: text(f[17], 128), dmUuid: text(f[18], 160), loopBacking: text(f[19], 200), mdLevel: text(f[20], 16), sysPath: text(f[21], 200), discardMax: int(f[22]) }; }).filter((entry) => /^[A-Za-z0-9._:+-]{1,64}$/.test(entry.kname)); } function parseBlkid(section) { const result = new Map(); for (const block of String(section || "").split(/\n\s*\n/)) { const values = keyValues(block); if (values.DEVNAME) result.set(values.DEVNAME, values); } return result; } function parseMountinfo(section) { return lines(section).map((line) => { const [left, right = ""] = line.split(" - "); const fields = left.split(" "); const tail = right.split(" "); return { devno: fields[2], root: unescapeMount(fields[3] || ""), mountpoint: unescapeMount(fields[4] || ""), options: fields[5] || "", fstype: tail[0] || "", source: unescapeMount(tail[1] || "") }; }).filter((entry) => entry.mountpoint); } function parseDf(section) { const result = new Map(); for (const line of lines(section).slice(1)) { const match = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/); if (match) result.set(match[6], { size: Number(match[2]) * 1024, used: Number(match[3]) * 1024, available: Number(match[4]) * 1024 }); } return result; } function parseSwaps(section) { return lines(section).slice(1).map((line) => { const [name, type, size, used, priority] = line.trim().split(/\s+/); return { path: unescapeMount(name), type, size: Number(size) * 1024, used: Number(used) * 1024, priority: Number(priority) }; }); } function parsePartitionTables(section) { const tables = new Map(); const pattern = /@@disk (\S+) (\S+)\n([\s\S]*?)\n@@end/g; let match; while ((match = pattern.exec(String(section || "")))) { const json = parseJson(match[3]); if (json?.partitiontable) tables.set(match[1], { ...json.partitiontable, hash: /^[a-f0-9]{64}$/.test(match[2]) ? match[2] : null }); } return tables; } function parseLvm(section) { const parts = {}; for (const block of String(section || "").split(/^@@/m).filter(Boolean)) { const name = block.slice(0, block.indexOf("\n")).trim(); parts[name] = parseJson(block.slice(block.indexOf("\n") + 1))?.report?.[0] || {}; } const n = (value) => int(String(value ?? "").trim()); return { available: Boolean(section), pvs: (parts.pvs?.pv || []).map((pv) => ({ path: text(pv.pv_name), vg: text(pv.vg_name, 128), size: n(pv.pv_size), free: n(pv.pv_free), deviceSize: n(pv.dev_size), uuid: text(pv.pv_uuid, 64), attr: text(pv.pv_attr, 16) })), vgs: (parts.vgs?.vg || []).map((vg) => ({ name: text(vg.vg_name, 128), uuid: text(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: text(vg.vg_attr, 16) })), lvs: (parts.lvs?.lv || []).filter((lv) => !String(lv.lv_name).startsWith("[")).map((lv) => { const attr = text(lv.lv_attr, 16); return { name: text(lv.lv_name, 128), vg: text(lv.vg_name, 128), uuid: text(lv.lv_uuid, 64), size: n(lv.lv_size), attr, path: text(lv.lv_path), dmPath: text(lv.lv_dm_path), pool: text(lv.pool_lv, 128), dataPercent: n(lv.data_percent), metadataPercent: n(lv.metadata_percent), segtype: text(lv.segtype, 32), origin: text(lv.origin, 128), // lv_attr[0]: t = thin pool, V = thin volume, s/S = snapshot, o = origin; lv_attr[4]: a = active. thinPool: attr[0] === "t", thinVolume: attr[0] === "V", snapshot: /^[sS]/.test(attr), active: attr[4] === "a", pveGuest: (String(lv.lv_name).match(/^(?:vm|base)-(\d+)-(?:disk|cloudinit|state|tpmstate)/) || [])[1] || null }; }) }; } function parseCrypt(section) { const result = new Map(); for (const block of String(section || "").split(/^@@map /m).filter(Boolean)) { const name = block.slice(0, block.indexOf("\n")).trim(); const values = {}; for (const line of lines(block.slice(block.indexOf("\n") + 1))) { const index = line.indexOf(":"); if (index > 0) values[line.slice(0, index).trim().toLowerCase()] = text(line.slice(index + 1), 120); } result.set(name, { type: values.type || null, cipher: values.cipher || null, keySize: values.keysize || null, keyLocation: values["key location"] || null, device: values.device || null, mode: values.mode || null, sectorSize: int(values["sector size"]) }); } return result; } function parseFsinfo(section) { const result = new Map(); for (const block of String(section || "").split(/^@@fs /m).filter(Boolean)) { const [device, type] = block.slice(0, block.indexOf("\n")).trim().split(/\s+/); const body = block.slice(block.indexOf("\n") + 1); if (/^ext[234]$/.test(type)) { const value = (key) => int((body.match(new RegExp(`^${key}:\\s*(\\d+)`, "m")) || [])[1]); const blocks = value("Block count"); const blockSize = value("Block size"); const free = value("Free blocks"); if (blocks && blockSize) result.set(device, { fsSize: blocks * blockSize, fsUsed: free === null ? null : (blocks - free) * blockSize, blockSize, state: text((body.match(/^Filesystem state:\s*(.+)$/m) || [])[1], 40) }); } else if (type === "xfs") { const data = body.match(/^data\s*=.*?bsize=(\d+)\s+blocks=(\d+)/m); if (data) result.set(device, { fsSize: Number(data[1]) * Number(data[2]), blockSize: Number(data[1]) }); } } return result; } function parseBtrfs(section) { const filesystems = []; let current = null; for (const line of lines(section)) { const label = line.match(/^Label:\s*(?:'(.*)'|none)\s+uuid:\s*([0-9a-f-]+)/); if (label) { current = { label: label[1] || null, uuid: label[2], devices: [] }; filesystems.push(current); continue; } const device = line.match(/^\s*devid\s+(\d+)\s+size\s+(\d+)\s+used\s+(\d+)\s+path\s+(\S+)/); if (device && current) current.devices.push({ devid: Number(device[1]), size: Number(device[2]), used: Number(device[3]), path: device[4] }); } return filesystems; } function parseZpool(section) { const pools = []; let current = null; for (const line of lines(section)) { const fields = line.split("\t"); if (fields[0]) { current = { name: text(fields[0], 64), size: int(fields[1]), allocated: int(fields[2]), free: int(fields[3]), health: text(fields[9] || fields[fields.length - 1], 16), devices: [] }; pools.push(current); } else if (current && fields[1] && fields[1].startsWith("/dev/")) current.devices.push(text(fields[1], 200)); } return pools; } function parseLsblk(section) { const json = parseJson(section); const map = new Map(); for (const entry of json?.blockdevices || []) if (entry.kname || entry.name) map.set(entry.kname || entry.name, entry); return map; } function mediaType(entry) { if (/^loop/.test(entry.kname)) return "loop"; if (/^nvme/.test(entry.kname)) return VIRTUAL_MODEL.test(entry.model) ? "virtual" : "nvme"; if (/^(vd|xvd)/.test(entry.kname) || VIRTUAL_MODEL.test(entry.model) || VIRTUAL_MODEL.test(entry.vendor)) return "virtual"; if (entry.rotational === true) return "hdd"; if (entry.rotational === false) return "ssd"; return "unknown"; } function transport(entry, lsblk) { if (lsblk?.tran) return text(lsblk.tran, 16); const path = entry.sysPath; if (/^nvme/.test(entry.kname)) return "nvme"; if (/\/virtio\d+\/block\//.test(path)) return "virtio"; if (/\/virtio\d+\/host\d+\//.test(path)) return "virtio-scsi"; if (/\/usb\d+\//.test(path)) return "usb"; if (/\/ata\d+\//.test(path)) return "sata"; if (/\/host\d+\/target/.test(path)) return "scsi"; if (/^mmcblk/.test(entry.kname)) return "mmc"; return null; } function nodeKind(entry) { if (entry.partition !== null) return "part"; if (/^dm-/.test(entry.kname)) { if (/^CRYPT-/.test(entry.dmUuid)) return "crypt"; if (/^LVM-/.test(entry.dmUuid)) return "lvm"; return "dm"; } if (/^md\d/.test(entry.kname)) return "md"; if (/^loop/.test(entry.kname)) return "loop"; return "disk"; } // Unescape a device-mapper LVM name ("vg--data-lv" -> vg "vg-data", lv "lv"). function splitDmLvName(name) { const match = String(name).match(/^((?:[^-]|--)+)-((?:[^-]|--)+)$/); return match ? { vg: match[1].replace(/--/g, "-"), lv: match[2].replace(/--/g, "-") } : null; } function buildInventory(stdout) { const sections = splitSections(stdout); const meta = keyValues(sections.meta); const tools = keyValues(sections.tools); const versions = keyValues(sections.versions); const privileged = meta.uid === "0"; const sys = parseSysblock(sections.sysblock); const lsblk = parseLsblk(sections.lsblk); const blkid = parseBlkid(sections.blkid); const mounts = parseMountinfo(sections.mountinfo); const df = parseDf(sections.df); const swaps = parseSwaps(sections.swaps); const tables = parsePartitionTables(sections.pt); const lvm = parseLvm(sections.lvm); const crypt = parseCrypt(sections.crypt); const fsinfo = parseFsinfo(sections.fsinfo); const btrfs = parseBtrfs(sections.btrfs); const zpools = parseZpool(sections.zpool); const mdstat = String(sections.mdstat || ""); const nodes = new Map(); for (const entry of sys) { if (/^(ram|zram|fd)\d*$/.test(entry.kname)) continue; // Unused loop devices (size 0) and read-only snap images are not disks anyone manages here. if (/^loop\d+$/.test(entry.kname) && (!entry.sectors512 || entry.readOnly || !entry.loopBacking)) continue; if (/^sr\d+$/.test(entry.kname)) continue; const ls = lsblk.get(entry.kname) || {}; const path = entry.dmName && /^dm-/.test(entry.kname) ? `/dev/mapper/${entry.dmName}` : `/dev/${entry.kname}`; const probe = blkid.get(`/dev/${entry.kname}`) || blkid.get(path) || {}; const kind = nodeKind(entry); const fstype = text(probe.TYPE || ls.fstype || "", 32) || null; const node = { kname: entry.kname, path, kind, devno: entry.devno, size: entry.sectors512 === null ? null : entry.sectors512 * 512, readOnly: entry.readOnly, parent: entry.parent, partition: entry.partition, start512: entry.start512, holders: entry.holders, slaves: entry.slaves, fstype, fsVersion: text(probe.VERSION || ls.fsver || "", 16) || null, label: text(probe.LABEL ?? ls.label ?? "", 64) || null, uuid: text(probe.UUID ?? ls.uuid ?? "", 64) || null, partuuid: text(probe.PARTUUID ?? ls.partuuid ?? "", 64) || null, partlabel: text(probe.PARTLABEL ?? ls.partlabel ?? "", 72) || null, parttype: text(ls.parttype ?? "", 40) || null, dmName: entry.dmName || null, dmUuid: entry.dmUuid || null, loopBacking: entry.loopBacking || null, mdLevel: entry.mdLevel || null, mountpoints: [], usage: null, fsSize: null, fsUsed: null, swapActive: false, lvm: null, crypt: null, zfsPool: null, btrfs: null, _sys: entry, _lsblk: ls }; nodes.set(entry.kname, node); } const byDevno = new Map([...nodes.values()].map((node) => [node.devno, node])); const byPath = new Map(); for (const node of nodes.values()) { byPath.set(node.path, node); byPath.set(`/dev/${node.kname}`, node); if (node.dmName) byPath.set(`/dev/mapper/${node.dmName}`, node); } // Mounts: by device number first (exact), by source path otherwise (Btrfs uses anonymous device numbers). for (const mount of mounts) { const node = byDevno.get(mount.devno) || byPath.get(mount.source); if (!node) continue; if (!node.mountpoints.includes(mount.mountpoint)) node.mountpoints.push(mount.mountpoint); node.mountedFstype ||= mount.fstype; node.mountedReadOnly = /(^|,)ro(,|$)/.test(mount.options); } for (const swap of swaps) { const node = byPath.get(swap.path); if (node) { node.swapActive = true; node.swapUsed = swap.used; } } for (const node of nodes.values()) { const mountpoint = node.mountpoints.find((point) => df.has(point)); if (mountpoint) { node.usage = { ...df.get(mountpoint), source: "df", mountpoint }; } const info = fsinfo.get(node.path) || fsinfo.get(`/dev/${node.kname}`); if (info) { node.fsSize = info.fsSize; node.fsState = info.state || null; if (!node.usage && info.fsUsed !== null && info.fsUsed !== undefined) node.usage = { size: info.fsSize, used: info.fsUsed, available: info.fsSize - info.fsUsed, source: "superblock" }; } if (!node.fsSize && node.usage && node.fstype === "xfs") node.fsSize = null; } for (const fs of btrfs) for (const device of fs.devices) { const node = byPath.get(device.path); if (node) { node.btrfs = { uuid: fs.uuid, devid: device.devid, devices: fs.devices.length, allocated: device.used }; node.fsSize = device.size; } } for (const pool of zpools) for (const device of pool.devices) { const node = byPath.get(device); if (node) node.zfsPool = pool.name; } // LVM: privileged JSON when available, otherwise the device-mapper name. const pvByPath = new Map(lvm.pvs.map((pv) => [pv.path, pv])); for (const node of nodes.values()) { const pv = pvByPath.get(node.path) || pvByPath.get(`/dev/${node.kname}`); if (pv) node.pv = pv; if (node.kind === "lvm") { const lv = lvm.lvs.find((entry) => entry.dmPath === node.path || `/dev/mapper/${node.dmName}` === entry.dmPath); const names = splitDmLvName(node.dmName); node.lvm = lv ? { ...lv } : names ? { name: names.lv, vg: names.vg, size: node.size, attr: null, thinPool: /-tpool$|_tdata$|_tmeta$/.test(names.lv), thinVolume: false, partial: true } : null; if (node.lvm) node.displayPath = `/dev/${node.lvm.vg}/${node.lvm.name}`; } if (node.kind === "crypt") { const status = crypt.get(node.dmName); const type = (String(node.dmUuid).match(/^CRYPT-([A-Z0-9]+)-/) || [])[1] || null; node.crypt = { type: status?.type || type, cipher: status?.cipher || null, keyLocation: status?.keyLocation || null, mode: status?.mode || null, open: true }; } } // Graph edges. Partitions hang below their disk; dm/md below their slaves. for (const node of nodes.values()) { node.parents = node.kind === "part" ? [node.parent].filter((name) => nodes.has(name)) : node.slaves.filter((name) => nodes.has(name)); node.children = node.holders.filter((name) => nodes.has(name)); } const descendants = (node, seen = new Set()) => { for (const name of node.children) if (!seen.has(name)) { seen.add(name); descendants(nodes.get(name), seen); } return seen; }; for (const node of nodes.values()) { node.inUseReasons = []; if (node.mountpoints.length) node.inUseReasons.push({ reason: "mounted", detail: node.mountpoints.join(", ") }); if (node.swapActive) node.inUseReasons.push({ reason: "swap" }); if (node.children.length) node.inUseReasons.push({ reason: "holders", detail: node.children.map((name) => nodes.get(name).displayPath || nodes.get(name).path).join(", ") }); if (node.fstype === "zfs_member") node.inUseReasons.push({ reason: "zfs", detail: node.zfsPool || "" }); if (node.fstype === "linux_raid_member" && !node.children.length && /\b(md\d+)\b/.test(mdstat)) node.inUseReasons.push({ reason: "raid-member" }); node.inUse = node.inUseReasons.length > 0; const stack = [node, ...[...descendants(node)].map((name) => nodes.get(name))]; node.systemRoles = [...new Set(stack.flatMap((item) => [ ...item.mountpoints.filter((point) => SYSTEM_MOUNTS.test(point)).map((point) => `mount:${point}`), ...(item.swapActive ? ["swap"] : []) ]))]; } const disks = []; for (const node of nodes.values()) { if (!["disk", "loop"].includes(node.kind) || !node.size) continue; const entry = node._sys; const table = tables.get(node.kname) || null; const lbs = Number(table?.sectorsize) || entry.logicalSectorSize || 512; const sectors = Math.floor(node.size / lbs); const align = Math.max(1, Math.floor(MiB / lbs)); const partNodes = [...nodes.values()].filter((item) => item.kind === "part" && item.parent === node.kname); let label = table?.label || text(node._lsblk.pttype || "", 8) || null; if (!label && partNodes.length) label = "unknown"; const partitions = []; if (table) { for (const part of [...table.partitions || []].sort((a, b) => a.start - b.start)) { const number = Number((String(part.node).match(/(\d+)$/) || [])[1]); const kernelNode = partNodes.find((item) => item.partition === number) || null; partitions.push(describePartition(label, number, part.start, part.size, lbs, part, kernelNode)); } } else { for (const part of partNodes.sort((a, b) => a.start512 - b.start512)) { const factor = lbs / 512; partitions.push(describePartition(label, part.partition, Math.round(part.start512 / factor), Math.round(part.size / lbs), lbs, { type: part.parttype, uuid: part.partuuid, name: part.partlabel }, part)); } } // Usable area of the table; GPT keeps 33 sectors (header + 128 entries) at both ends. const gptTail = Math.ceil(16384 / lbs) + 1; let firstUsable = label === "gpt" ? Number(table?.firstlba) || gptTail + 1 : align; if (label === "gpt" && firstUsable < align) firstUsable = Math.min(align, firstUsable); const lastUsableNow = label === "gpt" ? Number(table?.lastlba) || sectors - gptTail - 1 : Math.min(sectors - 1, 0xffffffff); const lastUsable = label === "gpt" ? sectors - gptTail - 1 : lastUsableNow; const gptBackupMisplaced = label === "gpt" && Boolean(table?.lastlba) && lastUsableNow < lastUsable; const mbrLimited = label === "dos" && sectors - 1 > 0xffffffff; const segments = label ? buildSegments(partitions, firstUsable, lastUsable, align, lbs, lastUsableNow) : []; const whole = !partitions.length ? node : null; const allRoles = [...new Set([node, ...partitions.map((part) => part.kernel && nodes.get(part.kernel))].filter(Boolean).flatMap((item) => item.systemRoles))]; disks.push({ name: node.kname, path: node.path, kind: node.kind, model: entry.model || null, vendor: entry.vendor || null, serial: entry.serial || text(node._lsblk.serial || "", 80) || null, wwid: entry.wwid || text(node._lsblk.wwn || "", 120) || null, transport: transport(entry, node._lsblk), mediaType: mediaType(entry), rotational: entry.rotational, discard: entry.discardMax > 0, readOnly: node.readOnly, removable: entry.removable, loopBacking: node.loopBacking, size: node.size, sectors, logicalSectorSize: lbs, physicalSectorSize: entry.physicalSectorSize, alignSectors: align, table: label ? { type: label, id: text(table?.id || node._lsblk.ptuuid || "", 64) || null, firstUsable, lastUsable, lastUsableNow, hash: table?.hash || null, source: table ? "sfdisk" : "sysfs", gptBackupMisplaced, mbrLimited, maxPrimary: label === "dos" ? 4 : 128 } : null, partitions, segments, wholeDevice: whole ? { kname: whole.kname, fstype: whole.fstype } : null, systemRoles: allRoles, system: allRoles.length > 0, inUse: node.inUse || partitions.some((part) => part.inUse) }); } const pve = meta.pve === "1"; for (const disk of disks) { const stackKnames = new Set([disk.name, ...disk.partitions.map((part) => part.kernel).filter(Boolean)]); for (const kname of [...stackKnames]) for (const child of descendantsOf(nodes, kname)) stackKnames.add(child); const stackNodes = [...stackKnames].map((name) => nodes.get(name)).filter(Boolean); disk.layers = { luks: stackNodes.some((item) => item.fstype === "crypto_LUKS"), lvm: stackNodes.some((item) => item.fstype === "LVM2_member" || item.kind === "lvm"), raid: stackNodes.some((item) => item.fstype === "linux_raid_member" || item.kind === "md"), zfs: stackNodes.some((item) => item.fstype === "zfs_member") }; // Proxmox host storage (LVM/ZFS behind guests or the host) is never a target of destructive changes. disk.protected = pve && (disk.layers.lvm || disk.layers.zfs || disk.system) ? "pve-host-storage" : null; } const publicNodes = [...nodes.values()].map(({ _sys, _lsblk, holders, slaves, ...rest }) => rest); return { platform: "linux", supported: true, privileged, os: text(meta.os, 120), osId: text(meta.osId, 40), osLike: text(meta.osLike, 80), kernel: text(meta.kernel, 80), systemd: meta.systemd === "1", pve, selinux: meta.selinux ? text(meta.selinux, 20) : null, tools: Object.fromEntries(Object.entries(tools).map(([key, value]) => [text(key, 32), text(value, 120)])), versions: Object.fromEntries(Object.entries(versions).map(([key, value]) => [text(key, 32), text(value, 120)])), sources: { lsblk: lsblk.size > 0, blkid: blkid.size > 0, sfdisk: tables.size > 0, lvm: lvm.available, crypt: crypt.size > 0 }, partial: !privileged, disks: disks.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true })), nodes: publicNodes, lvm: { available: lvm.available, pvs: lvm.pvs, vgs: lvm.vgs, lvs: lvm.lvs }, btrfs, zpools, remoteTime: int(meta.time) }; function describePartition(tableType, number, start, size, lbs, part, kernelNode) { const typeCode = text(part.type || "", 40).toUpperCase(); const typeName = tableType === "gpt" ? GPT_TYPES[typeCode] || null : DOS_TYPES[typeCode.toLowerCase().replace(/^0+(?=.)/, "")] || null; const extended = typeName === "extended"; return { number, start, size, end: start + size - 1, bytes: size * lbs, type: typeCode || null, typeName, uuid: text(part.uuid || kernelNode?.partuuid || "", 64) || null, name: text(part.name || kernelNode?.partlabel || "", 72) || null, attrs: text(part.attrs || "", 160) || null, bootable: part.bootable === true, extended, logical: tableType === "dos" && number > 4, kernel: kernelNode?.kname || null, path: kernelNode?.path || null, kernelMatches: kernelNode ? Math.round(kernelNode.size / lbs) === size || extended : false, fstype: kernelNode?.fstype || null, label: kernelNode?.label || null, fsUuid: kernelNode?.uuid || null, mountpoints: kernelNode?.mountpoints || [], usage: kernelNode?.usage || null, fsSize: kernelNode?.fsSize || null, inUse: Boolean(kernelNode?.inUse), inUseReasons: kernelNode?.inUseReasons || [], systemRoles: kernelNode?.systemRoles || [], children: kernelNode?.children || [] }; } } function descendantsOf(nodes, kname, seen = new Set()) { for (const child of nodes.get(kname)?.children || []) if (!seen.has(child)) { seen.add(child); descendantsOf(nodes, child, seen); } return seen; } // Ordered partitions and unallocated gaps (>= one alignment unit) between firstUsable and lastUsable. function buildSegments(partitions, firstUsable, lastUsable, align, lbs, lastUsableNow = lastUsable) { const top = partitions.filter((part) => !part.logical).sort((a, b) => a.start - b.start); const segments = []; let cursor = firstUsable; const pushFree = (from, to) => { if (to - from + 1 < align) return; segments.push({ kind: "free", start: from, size: to - from + 1, end: to, bytes: (to - from + 1) * lbs, needsGptRelocation: to > lastUsableNow }); }; for (const part of top) { if (part.start > cursor) pushFree(cursor, part.start - 1); segments.push({ kind: "partition", number: part.number, start: part.start, size: part.size, end: part.end, bytes: part.bytes }); cursor = Math.max(cursor, part.end + 1); } if (cursor <= lastUsable) pushFree(cursor, lastUsable); return segments; } module.exports = { DOS_TYPES, GPT_TYPES, MiB, PARTITION_TYPE_CODES, SYSTEM_MOUNTS, buildInventory, buildSegments, parseBtrfs, parseLvm, parseMountinfo, splitDmLvName, splitSections };