/** * celilo#524. `verifyCollectionIntegrity` is a supply-chain check that could * not report a failure: three independent paths returned "integrity verified" * for a tampered collection, and no constructible input reached the failure * return. The file had no tests at all. * * These build real broken collections on disk — the same three the issue's * break-and-watch constructed — rather than mocking the filesystem. A mock * would assert my belief about what `readFile` does; a real directory asserts * what the function does. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type InstalledCollection, reportIntegrity, verifyCollectionIntegrity, } from './dependencies'; const NAME = 'community.general'; const [NAMESPACE, COLLECTION] = NAME.split('.'); const sha256 = (content: string) => createHash('sha256').update(content).digest('hex'); describe('verifyCollectionIntegrity', () => { let root: string; let dir: string; let info: InstalledCollection; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'celilo-galaxy-')); dir = join(root, NAMESPACE, COLLECTION); mkdirSync(dir, { recursive: true }); info = { name: NAME, version: { major: 1, minor: 0, patch: 0 }, path: root }; }); afterEach(() => { try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } }); /** Write a coherent, genuinely intact collection. */ function writeGoodCollection(): void { const payload = '- name: do a thing\n'; writeFileSync(join(dir, 'plugins.yml'), payload); const filesJson = JSON.stringify({ files: [{ name: 'plugins.yml', ftype: 'file', chksum_sha256: sha256(payload) }], }); writeFileSync(join(dir, 'FILES.json'), filesJson); writeFileSync( join(dir, 'MANIFEST.json'), JSON.stringify({ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' }, file_manifest_file: { name: 'FILES.json', ftype: 'file', chksum_type: 'sha256', chksum_sha256: sha256(filesJson), }, }), ); } test('an intact collection verifies, and says how many files it checked', async () => { writeGoodCollection(); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('verified'); expect(outcome.status === 'verified' && outcome.filesChecked).toBeGreaterThan(0); }); // ── The three cases from the issue. Each returned `true` before. ────────── test('A: an unparseable MANIFEST.json is unverifiable, not verified', async () => { writeGoodCollection(); writeFileSync(join(dir, 'MANIFEST.json'), '{ this is not json'); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('unverifiable'); // Rule 6.2: the exception path used to claim it logged and did not. expect(outcome.status === 'unverifiable' && outcome.reason.length).toBeGreaterThan(0); }); test('B: a MANIFEST.json with no FILES.json checksum is unverifiable', async () => { writeGoodCollection(); writeFileSync( join(dir, 'MANIFEST.json'), JSON.stringify({ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' }, // `file_manifest_file` stripped — the one field an attacker shipping a // tampered collection fully controls. It used to switch the check off. }), ); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('unverifiable'); expect(outcome.status === 'unverifiable' && outcome.reason).toContain('no FILES.json checksum'); }); test('C: every listed file missing from disk is unverifiable, not a pass', async () => { writeGoodCollection(); rmSync(join(dir, 'plugins.yml')); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('unverifiable'); // Verifying zero files is not verifying. expect(outcome.status === 'unverifiable' && outcome.reason).toContain('present on disk'); }); // ── Refutation is distinct from ignorance ──────────────────────────────── test('a modified file is a MISMATCH, not merely unverifiable', async () => { writeGoodCollection(); writeFileSync(join(dir, 'plugins.yml'), '- name: do something ELSE\n'); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('mismatch'); expect(outcome.status === 'mismatch' && outcome.detail).toContain('plugins.yml'); }); test('a tampered FILES.json is a MISMATCH', async () => { writeGoodCollection(); // Rewrite FILES.json so it no longer matches the checksum in MANIFEST.json — // what re-pointing a file at different content looks like. writeFileSync( join(dir, 'FILES.json'), JSON.stringify({ files: [{ name: 'plugins.yml', ftype: 'file', chksum_sha256: sha256('something else') }], }), ); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('mismatch'); expect(outcome.status === 'mismatch' && outcome.detail).toContain('FILES.json'); }); test('a missing FILES.json is unverifiable, and is not confused with a mismatch', async () => { writeGoodCollection(); rmSync(join(dir, 'FILES.json')); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('unverifiable'); }); test('a collection with no checksummed files is unverifiable', async () => { writeGoodCollection(); const filesJson = JSON.stringify({ files: [{ name: 'roles', ftype: 'dir' }] }); writeFileSync(join(dir, 'FILES.json'), filesJson); writeFileSync( join(dir, 'MANIFEST.json'), JSON.stringify({ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' }, file_manifest_file: { name: 'FILES.json', ftype: 'file', chksum_type: 'sha256', chksum_sha256: sha256(filesJson), }, }), ); const outcome = await verifyCollectionIntegrity(info); expect(outcome.status).toBe('unverifiable'); }); /** * The recurrence gate proper. Every way this function can fail to establish * integrity must be distinguishable from success — a single `status` compared * against 'verified' is what a caller will actually write, and it must be * false for all of them. */ test('no broken collection is EVER reported as verified', async () => { const breakages: Array<[string, () => void]> = [ ['unparseable manifest', () => writeFileSync(join(dir, 'MANIFEST.json'), 'nope')], [ 'no files checksum', () => writeFileSync( join(dir, 'MANIFEST.json'), JSON.stringify({ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' }, }), ), ], ['files missing', () => rmSync(join(dir, 'plugins.yml'))], ['manifest missing', () => rmSync(join(dir, 'MANIFEST.json'))], ['files.json missing', () => rmSync(join(dir, 'FILES.json'))], ['content modified', () => writeFileSync(join(dir, 'plugins.yml'), 'tampered\n')], ]; for (const [label, breakIt] of breakages) { rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); writeGoodCollection(); breakIt(); const outcome = await verifyCollectionIntegrity(info); expect(`${label}: ${outcome.status}`).not.toBe(`${label}: verified`); } }); }); /** * celilo#524, policy half. A refuted collection now REFUSES the import; an * uncheckable one warns. * * Refusing is cheap exactly here: `installCollectionsForModule` is called only * from `module/import.ts`, and nothing installs collections in the deploy path, * so a false positive means "this module did not import" rather than "the fleet * stopped deploying". No module row exists yet either, so there is nothing left * half-created — which is why this is an import refusal and not an ERROR state. */ describe('reportIntegrity — what each outcome does to the import', () => { test('a MISMATCH refuses, and the message says what and why', () => { const refusal = reportIntegrity('community.general', { status: 'mismatch', detail: 'plugins.yml does not match its checksum', }); expect(refusal).toBeTruthy(); expect(refusal).toContain('community.general'); expect(refusal).toContain('plugins.yml does not match its checksum'); // The operator needs to know this is refutation, not a missing checksum. expect(refusal).toContain('corruption or tampering'); }); /** * The distinction that keeps this from becoming a false-positive machine. A * collection shipping no `file_manifest_file` is telling you about its * publisher, not about tampering — blocking on it would refuse ordinary * collections forever, which is how a check gets disabled wholesale. */ test('an UNVERIFIABLE outcome does NOT refuse', () => { expect( reportIntegrity('community.general', { status: 'unverifiable', reason: 'MANIFEST.json declares no FILES.json checksum', }), ).toBeNull(); }); test('a verified collection refuses nothing', () => { expect( reportIntegrity('community.general', { status: 'verified', filesChecked: 5 }), ).toBeNull(); }); });