303 lines
9.9 KiB
JavaScript
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 }
|
|
}
|