import { existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { eq } from 'drizzle-orm'; import { type DbClient, getDb } from '../../db/client'; import { moduleIntegrity, modules } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { type InstanceRecord, loadInstance, submoduleSourcePath, verifyInstanceLinks, } from '../../services/module-instances'; import { computeFileChecksum } from './checksum'; import type { IntegrityViolation } from './extract'; import { type HostPlaneResult, verifyModuleOnHosts } from './host-plane'; import { classifyModulePath } from './package-rules'; /** * Audit result for a module */ export interface AuditResult { success: boolean; moduleId: string; violations: IntegrityViolation[]; error?: string; /** What the module records, and what version the baseline describes. */ moduleVersion?: string; baselineVersion?: string | null; /** Present only under `deep`. See `host-plane.ts`. */ hostPlane?: HostPlaneResult; } export interface AuditOptions { /** * Also ask each of the module's systems whether what is running is what * celilo generated. One SSH per system, so it is off by default. */ deep?: boolean; } /** * Recursively scan the installed tree, keeping only paths whose content is a * stable integrity claim (`package`) or whose presence is a finding in itself * (`unknown`). `derived` paths — `generated/**`, the hook runtime closure, * `checksums.json` — are celilo's own and are dropped here, because a check * that reports them can only ever be wrong. */ async function scanDirectory(dir: string, baseDir: string): Promise { const files: string[] = []; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); const relativePath = relative(baseDir, fullPath); // A symlink is SKIPPED, deliberately and in writing. // // An instance's authored source is symlinked in from its parent's // `submodules/` tree (openspec/changes/submodules D4), so those bytes are // the PARENT's and are covered by the parent's baseline. This module has no // claim to make over them, and following the link would have it claim // integrity over bytes whose checksums belong to somebody else. // // This already happened, by accident: `Dirent.isDirectory()` and // `isFile()` are BOTH false for a symlink, so links fell through both // branches below and vanished. Right answer, no reason. Stating it here // means a later change that makes the walk follow links has to decide // about instances on purpose rather than reverse this silently. if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { // Prune whole derived subtrees rather than walking them. `generated/` // alone carries terraform provider binaries. if (classifyModulePath(relativePath) === 'derived') continue; const subFiles = await scanDirectory(fullPath, baseDir); files.push(...subFiles); } else if (entry.isFile()) { if (classifyModulePath(relativePath) === 'derived') continue; files.push(relativePath); } } return files; } /** * Audit module integrity by verifying checksums * * @param moduleId - Module ID to audit * @param db - Database client (optional, for testing) * @returns Audit result with any violations found */ export async function auditModule( moduleId: string, db = getDb(), options: AuditOptions = {}, ): Promise { const violations: IntegrityViolation[] = []; try { // Get module record const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, moduleId, violations: [], error: `Module '${moduleId}' not found`, }; } // An instance has no integrity row of its own and never will // (openspec/changes/submodules D4). Its authored bytes are its // submodule's, its submodule's are its parent's, and the parent already // carries a baseline covering `submodules/**`. So it resolves THROUGH to // that rather than being excluded: excluding it would report nothing for // forty rows, and erroring would report `No integrity data found` forty // times for a fleet that is entirely healthy. const instance = loadInstance(moduleId, db); if (instance) { return await auditInstance(instance, module.sourcePath, db, options); } // Get integrity data const integrity = db .select() .from(moduleIntegrity) .where(eq(moduleIntegrity.moduleId, moduleId)) .get(); if (!integrity) { return { success: false, moduleId, violations: [], error: `No integrity data found for module '${moduleId}'.`, }; } const expectedChecksums: Record = integrity.checksums; const moduleDir = module.sourcePath; // Which version do these checksums describe? Ahead of every file finding, // because when the answer is "not the installed one" the file findings are // a consequence of it and not independent evidence. Before D1 this was // unanswerable: the row was written once at first import and `module // update` never touched it, so verify reported the same violations whether // the files were old or the checksums were old. if (integrity.version === null) { violations.push({ type: 'stale-baseline', path: 'checksums.json', message: `Baseline records no version — it was written before celilo stamped them, so it cannot be compared to the installed ${module.version}. Re-run 'celilo module update' for this module to refresh it.`, }); } else if (integrity.version !== module.version) { violations.push({ type: 'stale-baseline', path: 'checksums.json', message: `Baseline describes ${integrity.version}, module records ${module.version}. The checksums are old, not the files. Re-run 'celilo module update' for this module to refresh it.`, }); } // Check if module directory exists if (!existsSync(moduleDir)) { return { success: false, moduleId, violations: [], error: `Module directory not found: ${moduleDir}`, }; } // Validate all expected files exist and have correct checksums. A baseline // entry for a derived path is not checkable: celilo rewrites those bytes // after install (`bun install` over the hook runtime closure), so comparing // them to what the package shipped can only ever produce a false positive. for (const [filePath, expectedChecksum] of Object.entries(expectedChecksums)) { if (classifyModulePath(filePath) === 'derived') continue; const fullPath = join(moduleDir, filePath); if (!existsSync(fullPath)) { violations.push({ type: 'missing', path: filePath, message: `Missing file: ${filePath}`, expectedDigest: expectedChecksum, actualDigest: null, }); continue; } const actualChecksum = await computeFileChecksum(fullPath); if (actualChecksum !== expectedChecksum) { violations.push({ type: 'modified', path: filePath, message: `Checksum mismatch: ${filePath}`, expectedDigest: expectedChecksum, actualDigest: actualChecksum, }); } } // The second plane (is what we would deploy built from what we installed?) // retired here: the generated tree is ephemeral now (D4 of // control-plane-stops-building-modules) — rendered for a deploy and // deleted on success — so a persistent generated copy that verbatim role // assets could be compared against no longer exists to compare. // Check for extra files (not in checksums) const actualFiles = await scanDirectory(moduleDir, moduleDir); const expectedFiles = new Set(Object.keys(expectedChecksums)); for (const file of actualFiles) { if (!expectedFiles.has(file)) { violations.push({ type: 'extra', path: file, message: `Unexpected file: ${file}`, }); } } // Plane three: is what is running what we generated? One SSH per system, // so it is asked only when the caller says so. With the generated tree // ephemeral (D4), a deep audit after a successful deploy finds no playbook // and says so — the remedy is generate, which is cheap and never builds. const generatedDir = join(moduleDir, 'generated'); let hostPlane: HostPlaneResult | undefined; if (options.deep) { hostPlane = await verifyModuleOnHosts({ moduleId, manifest: module.manifestData as unknown as ModuleManifest, generatedPath: generatedDir, }); } // An `unmeasured` host is not a pass. A check that could not reach its // subject says so, and does not count as green. const hostPlaneClean = (hostPlane?.findings ?? []).every((f) => f.state === 'converged'); return { success: violations.length === 0 && hostPlaneClean, moduleId, violations, moduleVersion: module.version, baselineVersion: integrity.version, hostPlane, }; } catch (error) { return { success: false, moduleId, violations, error: `Failed to audit module: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } } /** * Audit an instance. * * Two questions, and only the second is the instance's own. * * Its BYTES belong to its parent, so the integrity verdict is the parent's: * `submodules/**` classifies `package`, so the parent's baseline already covers * every file an instance runs. Re-checksumming them here would be asking the * same question a second time and inventing a second answer that can disagree. * * Its LINKS are its own, and nothing else in celilo checks them. A link * repointed at another module's source is an instance running somebody else's * code while every checksum in the fleet still reconciles, which is exactly the * kind of quiet wrong answer the integrity plane exists to prevent. */ async function auditInstance( instance: InstanceRecord, instancePath: string, db: DbClient, options: AuditOptions, ): Promise { const violations: IntegrityViolation[] = []; const parent = db.select().from(modules).where(eq(modules.id, instance.parentId)).get(); if (!parent) { // The FK makes this unreachable through SQL, so reaching it means the row // was written around the schema. Reported rather than assumed away. return { success: false, moduleId: instance.moduleId, violations, error: `Instance '${instance.moduleId}' names parent '${instance.parentId}', which does not exist.`, }; } const submodulePath = submoduleSourcePath(parent.sourcePath, instance.submodule); for (const link of await verifyInstanceLinks({ instancePath, submodulePath })) { violations.push({ type: 'modified', path: link.entry, message: link.actual === null ? `Link '${link.entry}' is missing or is not a link. It should point at ${link.expected}. Redeploy this instance to rebuild its links.` : `Link '${link.entry}' points at ${link.actual}, not at ${link.expected}. This instance is running source it does not own.`, }); } // The parent's verdict, carried rather than recomputed. A parent whose // baseline is stale or whose files are modified means every instance under it // is running unverified code, and saying so here is what stops an operator // reading a clean instance row as evidence. const parentResult = await auditModule(instance.parentId, db, options); if (!parentResult.success) { violations.push({ type: 'stale-baseline', path: `${instance.parentId}`, message: `The bytes this instance runs belong to '${instance.parentId}', whose own audit is not clean (${parentResult.violations.length} violation(s)${parentResult.error ? `: ${parentResult.error}` : ''}). Fix the parent; this instance cannot be verified independently.`, }); } return { success: violations.length === 0, moduleId: instance.moduleId, violations, moduleVersion: parent.version, baselineVersion: parentResult.baselineVersion, }; }