/** * Kernel extension registry resolver: duplicate detection, capability conflicts, * dependency ordering (topological sort), optional signer/hash policy, and cycle reporting. * * Enforcement at boot remains in `kernel/init.js` (`kernel.ext.d`); this module provides * shared deterministic resolution for booter tooling, tests, and strict preflight. * * **Keep in sync:** `topologicalOrderKernelExtensions` / `kernelExtensionDependencyDepth` * must match `topologicalOrderKernelExtEntries` / `kernelExtDependencyDepth` in * `kernel/lib/init/init-main.js` (same Kahn sort and depth walk). CI: booter test * `kernel extension topological order matches resolver module`. */ /** * @param {{ extId: string, source: string }[]} entries * @returns {{ ok: true, ordered: typeof entries } | { ok: false, duplicateIds: string[] }} */ export function resolveKernelExtensionIds(entries) { const seen = new Map() /** @type {string[]} */ const dups = [] for (const e of entries) { const id = String(e.extId || '').trim() if (!id) continue if (seen.has(id)) dups.push(id) else seen.set(id, e.source) } if (dups.length) return { ok: false, duplicateIds: [...new Set(dups)] } return { ok: true, ordered: [...entries] } } /** * @param {{ extId: string, capabilities?: string[] }[]} entries * @returns {{ ok: boolean, conflicts: string[] }} */ /** * Optional `provides` rows declare a logical service name + version string per extension drop-in. * Two different extension ids claiming the same `name` with different `version` values is a strict conflict. * @param {{ extId: string, provides?: { name?: string, version?: string, semver?: string }[] }[]} entries * @returns {{ ok: boolean, conflicts: string[] }} */ export function detectKernelExtensionProvidesConflicts(entries) { /** @type {Map} */ const byName = new Map() /** @type {string[]} */ const conflicts = [] for (const e of entries) { const id = String(e.extId || '').trim() if (!id) continue const provides = Array.isArray(e.provides) ? e.provides : [] for (const p of provides) { if (!p || typeof p !== 'object') continue const name = String(p.name || '').trim() const ver = String(p.version ?? p.semver ?? '').trim() if (!name || !ver) continue const prev = byName.get(name) if (prev && prev.version !== ver) { conflicts.push(`${name}: ${prev.extId}@${prev.version} vs ${id}@${ver}`) } else if (!prev) { byName.set(name, { extId: id, version: ver }) } } } conflicts.sort() return { ok: conflicts.length === 0, conflicts } } export function detectKernelExtensionCapabilityConflicts(entries) { /** @type {Map} */ const capToId = new Map() /** @type {string[]} */ const conflicts = [] for (const e of entries) { const id = String(e.extId || '').trim() if (!id) continue const caps = Array.isArray(e.capabilities) ? e.capabilities.map((c) => String(c).trim()).filter(Boolean) : [] for (const c of caps) { const prev = capToId.get(c) if (prev && prev !== id) conflicts.push(`${c}(${prev} vs ${id})`) else capToId.set(c, id) } } return { ok: conflicts.length === 0, conflicts } } /** * @param {{ file: string, extId: string, scripts: string[], dependsOn: string[], beforeIds?: string[], signaturePointer?: string, signerPubHex?: string, contentSha256Hex?: string }[]} entries * @returns {{ ok: true, ordered: typeof entries } | { ok: false, cycleExtIds: string[] }} */ export function topologicalOrderKernelExtensions(entries) { const sorted = [...entries].sort((a, b) => a.file.localeCompare(b.file)) const idToFile = new Map() for (const e of sorted) { if (!idToFile.has(e.extId)) idToFile.set(e.extId, e.file) } /** @type {Map} */ const adj = new Map() /** @type {Map} */ const indeg = new Map() for (const e of sorted) indeg.set(e.file, 0) for (const e of sorted) { for (const dep of e.dependsOn) { const from = idToFile.get(dep) if (!from || from === e.file) continue if (!adj.has(from)) adj.set(from, []) adj.get(from).push(e.file) indeg.set(e.file, (indeg.get(e.file) || 0) + 1) } } /** @type {string[]} */ const q = sorted .map((e) => e.file) .filter((f) => (indeg.get(f) || 0) === 0) .sort() /** @type {string[]} */ const out = [] while (q.length) { const f = q.shift() if (!f) break out.push(f) for (const to of adj.get(f) || []) { indeg.set(to, (indeg.get(to) || 0) - 1) if (indeg.get(to) === 0) { q.push(to) q.sort() } } } if (out.length !== sorted.length) { const stuck = sorted.filter((e) => !out.includes(e.file)) return { ok: false, cycleExtIds: stuck.map((e) => e.extId) } } const byFile = new Map(sorted.map((e) => [e.file, e])) return { ok: true, ordered: out.map((f) => byFile.get(f)).filter(Boolean) } } /** * @param {{ extId: string, dependsOn: string[] }[]} entries * @returns {number} */ export function kernelExtensionDependencyDepth(entries) { const idToEntry = new Map(entries.map((e) => [e.extId, e])) const memo = new Map() /** * @param {string} id * @param {Set} stack */ function depth(id, stack) { if (memo.has(id)) return memo.get(id) if (stack.has(id)) return 99 const e = idToEntry.get(id) if (!e) return 0 stack.add(id) let d = 0 for (const dep of e.dependsOn) { d = Math.max(d, depth(dep, stack) + 1) } stack.delete(id) memo.set(id, d) return d } let max = 0 for (const e of entries) { max = Math.max(max, depth(e.extId, new Set())) } return max } /** * @param {Record} pins extId → expected ed25519 pubkey hex (64) or list for threshold * @param {{ extId: string, signerPubHex?: string }[]} entries * @returns {{ ok: boolean, violations: string[] }} */ export function validateExtensionSignerPins(entries, pins) { if (!pins || typeof pins !== 'object') return { ok: true, violations: [] } /** @type {string[]} */ const violations = [] for (const e of entries) { const id = String(e.extId || '').trim() if (!id) continue const need = pins[id] if (need == null) continue const needList = Array.isArray(need) ? need.map((x) => String(x).trim().toLowerCase()) : [String(need).trim().toLowerCase()] const got = String(e.signerPubHex || '').trim().toLowerCase() if (!got || got.length !== 64 || !/^[0-9a-f]+$/.test(got)) { violations.push(`${id}: missing or invalid signerPubHex`) continue } if (!needList.includes(got)) { violations.push(`${id}: signer pin mismatch`) } } return { ok: violations.length === 0, violations } } /** * @param {Record} hashPins extId → lowercase sha256 hex * @param {{ extId: string, contentSha256Hex?: string }[]} entries * @returns {{ ok: boolean, violations: string[] }} */ export function validateExtensionContentHashes(entries, hashPins) { if (!hashPins || typeof hashPins !== 'object') return { ok: true, violations: [] } /** @type {string[]} */ const violations = [] for (const e of entries) { const id = String(e.extId || '').trim() if (!id) continue const need = hashPins[id] if (need == null) continue const want = String(need).trim().toLowerCase() const got = String(e.contentSha256Hex || '').trim().toLowerCase() if (!got || got !== want) violations.push(`${id}: content hash pin mismatch`) } return { ok: violations.length === 0, violations } } /** * Full resolve pipeline for extension drop-in rows (same shape as kernel `kernel.ext.d` JSON + file meta). * @param {{ file: string, extId: string, scripts: string[], dependsOn: string[], beforeIds?: string[], signaturePointer?: string, signerPubHex?: string, contentSha256Hex?: string, capabilities?: string[], provides?: { name?: string, version?: string, semver?: string }[] }[]} entries * @param {{ signerPins?: Record, hashPins?: Record, maxDepth?: number } | undefined} policy */ export function resolveKernelExtensionsFull(entries, policy = {}) { const idRes = resolveKernelExtensionIds( entries.map((e) => ({ extId: e.extId, source: e.file })) ) if (!idRes.ok) { return { ok: false, reason: 'duplicate_ids', duplicateIds: idRes.duplicateIds, ordered: [] } } const capRes = detectKernelExtensionCapabilityConflicts(entries) if (!capRes.ok) { return { ok: false, reason: 'capability_conflict', conflicts: capRes.conflicts, ordered: [] } } const provRes = detectKernelExtensionProvidesConflicts(entries) if (!provRes.ok) { return { ok: false, reason: 'provides_conflict', conflicts: provRes.conflicts, ordered: [] } } const maxDepth = policy.maxDepth if (typeof maxDepth === 'number' && maxDepth >= 1) { const d = kernelExtensionDependencyDepth( entries.map((e) => ({ extId: e.extId, dependsOn: e.dependsOn || [] })) ) if (d > maxDepth) { return { ok: false, reason: 'max_depth', depth: d, maxDepth, ordered: [] } } } const pinRes = validateExtensionSignerPins(entries, policy.signerPins || {}) if (!pinRes.ok) { return { ok: false, reason: 'signer_pins', violations: pinRes.violations, ordered: [] } } const hashRes = validateExtensionContentHashes(entries, policy.hashPins || {}) if (!hashRes.ok) { return { ok: false, reason: 'hash_pins', violations: hashRes.violations, ordered: [] } } const topo = topologicalOrderKernelExtensions(entries) if (!topo.ok) { return { ok: false, reason: 'cycle', cycleExtIds: topo.cycleExtIds, ordered: [] } } return { ok: true, ordered: topo.ordered, reason: null } }