Files
bare-operating-system/packages/bare-os-booter/lib/boot/kernel-extension-resolver.js
T
2026-08-18 18:11:28 -04:00

303 lines
9.9 KiB
JavaScript

/**
* 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<string, { extId: string, version: string }>} */
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<string, string>} */
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<string, string[]>} */
const adj = new Map()
/** @type {Map<string, number>} */
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<string>} 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<string, string | string[] | undefined>} 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<string, string>} 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<string, string | string[]>, hashPins?: Record<string, string>, 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 }
}