import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { moduleStateDir } from '@celilo/capabilities'; import { moduleIntegrity, modules } from '../../db/schema'; import { cleanupTestDatabase, setupTestDatabase } from '../../test-utils/database'; import { auditModule } from './audit'; import { classifyModulePath } from './package-rules'; /** * The recurrence gate for celilo#1000: a hook has a sanctioned place to write, * and what it writes there never becomes a `module audit` finding. * * **The names below are generated, deliberately.** The failure this gate exists * for is not "we forgot to allow `state/cursor.json`". It is that the allow-list * was a list of literals (`screenshots/`, `cookies.json`) patched in one at a * time after each one bit someone, so it could only ever cover filenames * somebody had already been surprised by. A test asserting a literal filename * reproduces exactly that weakness. A hook writes what it needs to write, and * the framework does not get to know the name in advance. */ /** A name nothing in the codebase anticipates, and that no allow-list can hold. */ function unanticipatedName(seed: number): string { return `${seed.toString(36)}-${(seed * 7919).toString(36)}.dat`; } describe('celilo#1000: state/ is the hook-writable directory', () => { test('any name a hook invents under state/ is derived, at any depth', () => { for (let seed = 1; seed <= 25; seed++) { const name = unanticipatedName(seed); for (const path of [`state/${name}`, `state/nested/${name}`, `state/a/b/c/${name}`]) { expect(`${path} => ${classifyModulePath(path)}`).toBe(`${path} => derived`); } } }); /** * The contrast is the point, and `package` rather than `unknown` is what the * contrast actually is. `classifyModulePath` defaults to `package`, meaning * "this belongs to the module and must match `checksums.json`", so the same * name one directory up is scanned, found absent from the checksums, and * reported. That is the reporting this change exempts `state/` from, and * exempts nothing else from. If this half ever goes green alongside the half * above, the fix widened rather than named. */ test('the same names outside state/ are still checksum-bearing', () => { for (let seed = 1; seed <= 25; seed++) { const name = unanticipatedName(seed); expect(`${name} => ${classifyModulePath(name)}`).toBe(`${name} => package`); expect(`lib/${name} => ${classifyModulePath(`lib/${name}`)}`).toBe(`lib/${name} => package`); } }); test('a hook writing into state/ leaves module audit clean', async () => { const db = await setupTestDatabase(); const root = mkdtempSync(join(tmpdir(), 'celilo-state-gate-')); try { // A minimal installed tree: one packaged file, recorded in checksums. writeFileSync(join(root, 'manifest.yml'), 'id: state-gate\nversion: 1.0.0\n'); db.insert(modules) .values({ id: 'state-gate', name: 'state-gate', version: '1.0.0', sourcePath: root, manifestData: { id: 'state-gate', version: '1.0.0' }, }) .run(); db.insert(moduleIntegrity) .values({ moduleId: 'state-gate', checksums: { 'manifest.yml': await xxhashOf(join(root, 'manifest.yml')) }, version: '1.0.0', }) .run(); const before = await auditModule('state-gate', db); expect(before.violations).toEqual([]); // Now a hook runs and writes something nobody declared. The path comes // from the framework, NOT from this test: a gate that hand-rolls // `join(root, 'state')` proves the audit tolerates a directory while // saying nothing about whether a module can find it, which is exactly // how the surface half of celilo#1000 shipped missing. const stateDir = moduleStateDir(root); mkdirSync(stateDir, { recursive: true }); writeFileSync(join(stateDir, unanticipatedName(42)), 'whatever the hook needed'); const after = await auditModule('state-gate', db); expect(after.violations).toEqual([]); expect(after.success).toBe(true); } finally { rmSync(root, { recursive: true, force: true }); await cleanupTestDatabase(db); } }); }); /** The audit's own hash, so the fixture's checksum is right by construction. */ async function xxhashOf(path: string): Promise { const { readFileSync } = await import('node:fs'); return Bun.hash.xxHash64(readFileSync(path)).toString(16); }