/** * Turns a finding's evidence into something a reader can read. * * Checks record evidence in whatever shape suits what they examined: * some serialise a list of objects, some an object of lists, some write * a few lines of text. Printing that verbatim shows the reader a JSON * dump and asks them to parse it — which defeats the purpose of evidence, * which is to let someone verify a conclusion without trusting it. * * The parser recognises those shapes and returns blocks: a table for a * list of records, labelled pairs for a single record, plain lines for * the rest. Field names are humanised and values are formatted according * to what the name says they are — a `_bytes` suffix is a size, `_hours` * a duration, an `_at` an instant — so the reader sees "1.2 TiB" where * the check wrote 1319413953331. * * Nothing is discarded: text the parser does not recognise is passed * through as lines, because evidence that has been silently dropped is * worse than evidence that is ugly. */ export type EvidenceBlock = | { kind: "table"; title?: string; columns: string[]; rows: string[][] } | { kind: "pairs"; title?: string; entries: Array<[string, string]> } | { kind: "text"; title?: string; lines: string[] } /** `max_age_hours` reads as "Max age hours"; `vmid` stays "VMID". */ const ACRONYMS: Record = { vmid: "VMID", id: "ID", cpu: "CPU", pci: "PCI", iommu: "IOMMU", smart: "SMART", zfs: "ZFS", arc: "ARC", ssh: "SSH", lxc: "LXC", pve: "PVE", pbs: "PBS", nfs: "NFS", url: "URL", os: "OS", ram: "RAM", } export function humanise(field: string): string { const parts = field.replace(/[_-]+/g, " ").trim().split(/\s+/) if (parts.length === 0) return field return parts .map((word, i) => { const known = ACRONYMS[word.toLowerCase()] if (known) return known return i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word }) .join(" ") } function sizeOf(value: number): string { const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] let n = Math.abs(value), i = 0 while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } const shown = n >= 100 || i < 2 ? Math.round(n) : Number(n.toFixed(1)) return `${value < 0 ? "-" : ""}${shown} ${units[i]}` } export function durationOf(hours: number, locale: string): string { if (!Number.isFinite(hours) || hours < 0) return "—" const total = Math.round(hours * 60) const days = Math.floor(total / 1440), h = Math.floor(total % 1440 / 60) const unit = (v: number, name: string) => new Intl.NumberFormat(locale, { style: "unit", unit: name, unitDisplay: "short", maximumFractionDigits: 0, }).format(v) return [days ? unit(days, "day") : "", h || days ? unit(h, "hour") : "", unit(total % 60, "minute")].filter(Boolean).join(" ") } /** * Formats one value using what its field name says it is. The name is * the only type information a check leaves behind, so it is what the * formatter reads. */ export function formatValue(field: string, value: unknown, locale: string, units?: string): string { if (value === null || value === undefined || value === "") return "—" // A recorded `false` is an answer, and it used to render as the same // dash as "nothing was recorded": a table of seven archives that are // definitely gone read as seven about which nothing was known. if (typeof value === "boolean") return value ? "✓" : "✗" const name = field.toLowerCase() if (typeof value === "number") { // The field name is read before the record's declared units: a // record of sizes still carries a timestamp and a percentage, and // those are not sizes. if (name.endsWith("_hours") || name === "hours") return durationOf(value, locale) if (name.endsWith("_days") || name === "days") return `${Number(value.toFixed(1))} d` if (name.endsWith("_percent") || name.endsWith("_pct")) { return `${Number(value.toFixed(1))} %` } // A check records instants as epoch seconds under names like // `last_backup` or `collected_at`, so both the name and the // magnitude have to agree before a number is shown as a date. const temporal = /(^|_)(at|time|date|seen|since|backup|run|checked|updated)$/ if (temporal.test(name) && Number.isFinite(value) && value > 1_000_000_000 && value < 4_000_000_000) { return new Date(value * 1000).toLocaleString(locale) } if (name.endsWith("_bytes") || name === "bytes" || name.endsWith("_size") || units === "bytes") { return sizeOf(value) } return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value) } if (Array.isArray(value)) { if (value.length === 0) return "—" const shown = value.slice(0, 3).map((v) => { if (v === null || typeof v !== "object") return String(v) // Inside a cell, name each record by whatever identifies it // rather than spelling out every field. const record = v as Record const key = ["vmid", "id", "name", "device", "storage", "volume", "job"] .find((k) => record[k] !== undefined) return key ? String(record[key]) : Object.entries(record).map(([k, x]) => `${humanise(k)} ${String(x)}`).join(" ") }) return shown.join(", ") + (value.length > 3 ? ` +${value.length - 3}` : "") } if (typeof value === "object") { return Object.entries(value as Record) .map(([k, v]) => `${humanise(k)}: ${formatValue(k, v, locale)}`) .join(" · ") } return String(value) } function isRecordList(value: unknown): value is Array> { return Array.isArray(value) && value.length > 0 && value.every((v) => v !== null && typeof v === "object" && !Array.isArray(v)) } function tableFrom(records: Array>, locale: string, title?: string): EvidenceBlock[] { // Union of the keys, in first-seen order: records from one check are // uniform in practice, but a missing key must not shift a column. const columns: string[] = [] for (const record of records) { for (const key of Object.keys(record)) { if (!columns.includes(key)) columns.push(key) } } const cells = records.map((r) => { const units = typeof r.units === "string" ? r.units : undefined return Object.fromEntries( columns.map((c) => [c, formatValue(c, r[c], locale, units)])) }) // A column holding the same value in every row is a property of the // whole set, not of any row. Stating it once keeps the table narrow // enough to read; it only pays off once the table is already wide. const constant: Array<[string, string]> = [] const varying = columns.filter((c) => { if (columns.length <= 6 || records.length < 2) return true const first = cells[0][c] if (!cells.every((row) => row[c] === first) || first === "—") return true constant.push([humanise(c), first]) return false }) const rows = cells.map((row) => varying.map((c) => row[c])) return constant.length ? [{ kind: "pairs" as const, title, entries: constant }, { kind: "table" as const, columns: varying.map(humanise), rows }] : [{ kind: "table" as const, title, columns: varying.map(humanise), rows }] } function blocksFromValue(value: unknown, locale: string, title?: string): EvidenceBlock[] { if (isRecordList(value)) return tableFrom(value, locale, title) if (Array.isArray(value)) { return value.length ? [{ kind: "text", title, lines: value.map((v) => formatValue("", v, locale)) }] : [] } if (value !== null && typeof value === "object") { const blocks: EvidenceBlock[] = [] const pairs: Array<[string, string]> = [] const record = value as Record const units = typeof record.units === "string" ? record.units : undefined for (const [key, inner] of Object.entries(record)) { // A nested list of records earns its own table under its own name; // everything else stays a labelled pair. if (isRecordList(inner)) { blocks.push(...tableFrom(inner, locale, humanise(key))) } else { pairs.push([humanise(key), formatValue(key, inner, locale, units)]) } } if (pairs.length) blocks.unshift({ kind: "pairs", title, entries: pairs }) return blocks } return [{ kind: "text", title, lines: [formatValue("", value, locale)] }] } /** * Text evidence: lines like `label:` introduce the indented lines under * them, which is the shape checks write by hand. */ function blocksFromText(text: string, locale: string): EvidenceBlock[] { const lines = text.split("\n") const blocks: EvidenceBlock[] = [] let title: string | undefined let buffer: string[] = [] const flush = () => { const kept = buffer.filter((l) => l.trim()) const joined = kept.join("\n").trim() // A section introduced by a heading gets the same treatment as // evidence that is JSON from the first character. let sectionTitle = title let source = joined if (joined && !/^[[{]/.test(joined)) { const at = joined.search(/:\s*[[{]/) // Only a short prefix is a label; a paragraph that happens to // mention a bracket is prose. if (at > 0 && at < 80) { sectionTitle = title || joined.slice(0, at).trim() source = joined.slice(joined.indexOf(joined[at] === ":" ? ":" : ":", at) + 1).trim() } } const split = source ? splitLeadingJson(source) : null if (split) { const [value, rest] = split blocks.push(...blocksFromValue(value, locale, sectionTitle)) if (rest) blocks.push({ kind: "text", lines: rest.split("\n") }) buffer = [] return } if (kept.length || title) blocks.push({ kind: "text", title, lines: kept }) buffer = [] } for (const line of lines) { const heading = /^(\S[^:]*):\s*$/.exec(line) if (heading) { flush() title = heading[1] continue } buffer.push(line.replace(/^\s{1,4}/, "")) } flush() return blocks.filter((b) => b.kind !== "text" || b.lines.length || b.title) } /** * Splits a leading JSON document from whatever text follows it, by * balancing brackets outside of strings. Checks routinely serialise * their records and then add a line qualifying them, and both halves * are evidence. */ export function splitLeadingJson(text: string): [unknown, string] | null { const open = text[0] if (open !== "{" && open !== "[") return null const close = open === "{" ? "}" : "]" let depth = 0, inString = false, escaped = false, end = -1 for (let i = 0; i < text.length; i++) { const c = text[i] if (escaped) { escaped = false; continue } if (c === "\\") { escaped = true; continue } if (c === '"') { inString = !inString; continue } if (inString) continue if (c === open) depth++ else if (c === close && --depth === 0) { end = i + 1; break } } if (end < 0) return null try { return [JSON.parse(text.slice(0, end)), text.slice(end).trim()] } catch { return null } } /** Parses one finding's evidence into blocks a reader can read. */ export function parseEvidence(evidence: string | null, locale = "en"): EvidenceBlock[] { if (!evidence) return [] const text = evidence.trim() if (!text) return [] const split = splitLeadingJson(text) if (split) { const [value, rest] = split const blocks = blocksFromValue(value, locale) return rest ? blocks.concat(blocksFromText(rest, locale)) : blocks } return blocksFromText(text, locale) }