(staged as /lib/init/init-main.js); point bundle-kernel-init and verify scripts at the new path. Wire curl, wget, openssl, ssh-keygen, and tar through coreutils and booter host delegates with booter-side CLI helpers; refresh related bins, bare manifest, shell completion, and man DB (kernel + seeder). Add booter support modules for ACL evaluation, audit chain, secret handles, peer admission, replication priority, process table, swarm lifecycle, boot-graph proc, metrics, monotonic time, protomux alias registry, and swarm peer policy; extend extension resolver, VFS, swarm connection managers, IPC, identity-account, and initd. Harden bare-os-bare-libs build on esbuild failure; add verify scripts for extension manifest schema and runtime incomplete markers; extend ctx API typings, gen-ctx-client-stub, and verify-ctx-dts. Update boot hook fragment, bundled init.js, handbook and reference docs (incl. kernel security and VFS path classes).
257 lines
8.0 KiB
JavaScript
257 lines
8.0 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.
|
|
*/
|
|
|
|
/**
|
|
* @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[] }}
|
|
*/
|
|
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[] }[]} 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 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 }
|
|
}
|