#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; const VERIFY_SCRIPT = 'scripts/ops/pingora-cutover-evidence-verify.mjs'; const failures = []; const tmpRoot = mkdtempSync( path.join(tmpdir(), 'genarrative-pingora-cutover-evidence-verify-'), ); try { main(); } finally { rmSync(tmpRoot, { recursive: true, force: true }); } if (failures.length > 0) { console.error('[check:pingora-cutover-evidence-verify] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('[check:pingora-cutover-evidence-verify] OK'); function main() { assertScriptShape(); assertValidEvidenceBundleManifest(); assertValidCommandEvidenceManifest(); assertRequireSummaryOkPassesForOkManifest(); assertRequireSummaryOkFailsForCriticalManifest(); assertRequireSummaryOkFailsWhenSummaryMissing(); assertMissingSchemaVersionFails(); assertUnsupportedSchemaVersionFails(); assertMissingFileFails(); assertSizeMismatchFails(); assertSha256MismatchFails(); assertUnsafePathFails(); assertStringMetadataFailsExceptManifest(); assertExtraFileFailsByDefault(); assertExtraDirectoryFailsByDefault(); assertExtraSymlinkFailsByDefault(); assertAllowExtraFilesOverrideSucceeds(); assertRejectsRootPath(); assertRejectsEntryPathControlCharacters(); assertRejectsSymlinkBundleDir(); assertRejectsNonDirectoryBundleDir(); assertRejectsInvalidJson(); assertRejectsBothEntryModes(); assertRejectsRelativeEntryPath(); assertRejectsManifestFilePathControlCharacters(); } function assertScriptShape() { const content = readFileSync(VERIFY_SCRIPT, 'utf8'); assertIncludes( content, 'createHash', '证据校验脚本必须计算 sha256。', ); assertIncludes( content, 'sizeBytes', '证据校验脚本必须校验 sizeBytes。', ); assertIncludes( content, '证据目录不能是符号链接', '证据校验脚本必须拒绝符号链接证据目录。', ); assertIncludes( content, '不会修改证据目录', '证据校验脚本 usage 必须说明只读边界。', ); assertIncludes( content, '未登记', '证据校验脚本必须说明默认拒绝未登记证据条目。', ); assertIncludes( content, '--allow-extra-files', '证据校验脚本必须把未登记条目的例外开关显式化。', ); assertIncludes( content, '--require-summary-ok', '证据校验脚本必须支持正式 runbook 要求 summary OK。', ); assertIncludes( content, 'manifest.summary.status is not OK', '证据校验脚本必须在 summary 非 OK 时给出结构化失败原因。', ); assertIncludes( content, 'manifest.schemaVersion 必须是 1', '证据校验脚本必须拒绝未知 manifest schemaVersion。', ); assertIncludes( content, 'manifest.files.${key}.path', '证据校验脚本必须校验 manifest.files 路径安全。', ); if ( content.includes('writeFile') || content.includes('chmod(') || content.includes("spawn(") || content.includes('execFile') ) { failures.push('证据校验脚本必须保持只读,不能写文件或执行外部命令。'); } } function assertValidEvidenceBundleManifest() { const fixture = prepareEvidenceBundleFixture('valid-bundle'); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 0, '合法证据包 manifest 应校验通过。'); if (result.status !== 0) { return; } const output = parseJson(result.stdout, '合法证据包校验 stdout'); assertEqual(output.ok, true, '合法证据包 stdout 必须 ok=true。'); assertEqual(output.checkedCount, 4, '合法证据包必须校验四个元数据文件。'); assertEqual(output.failedCount, 0, '合法证据包不能有失败项。'); assertEqual( output.files?.every((item) => item.status === 'OK'), true, '合法证据包每个文件都应 OK。', ); } function assertValidCommandEvidenceManifest() { const fixture = prepareCommandEvidenceFixture('valid-command'); const result = runVerify(['--manifest', path.join(fixture.dir, 'manifest.json')]); assertStatus(result, 0, '合法命令证据 manifest 应校验通过。'); if (result.status !== 0) { return; } const output = parseJson(result.stdout, '合法命令证据校验 stdout'); assertEqual(output.ok, true, '合法命令证据 stdout 必须 ok=true。'); assertEqual(output.checkedCount, 3, '合法命令证据必须校验三个元数据文件。'); } function assertRequireSummaryOkPassesForOkManifest() { const fixture = prepareEvidenceBundleFixture('summary-ok'); const result = runVerify(['--bundle-dir', fixture.dir, '--require-summary-ok']); assertStatus(result, 0, '要求 summary OK 且 manifest.summary.status=OK 时应通过。'); if (result.status !== 0) { return; } const output = parseJson(result.stdout, 'summary OK 校验 stdout'); assertEqual(output.ok, true, 'summary OK 校验 stdout 必须 ok=true。'); assertEqual( output.requireSummaryOk, true, 'summary OK 校验 stdout 必须记录 requireSummaryOk=true。', ); assertEqual( output.summary?.status, 'OK', 'summary OK 校验 stdout 必须输出 summary.status=OK。', ); } function assertRequireSummaryOkFailsForCriticalManifest() { const fixture = prepareEvidenceBundleFixture('summary-critical'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); manifest.summary.status = 'CRITICAL'; writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir, '--require-summary-ok']); assertStatus(result, 1, '要求 summary OK 但 manifest.summary.status=CRITICAL 时必须失败。'); assertIncludes( result.stdout, 'manifest.summary.status is not OK', 'summary 非 OK 失败必须写入 JSON reason。', ); } function assertRequireSummaryOkFailsWhenSummaryMissing() { const fixture = prepareCommandEvidenceFixture('summary-missing'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); delete manifest.summary; writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir, '--require-summary-ok']); assertStatus(result, 1, '要求 summary OK 但 manifest 缺少 summary 时必须失败。'); assertIncludes( result.stdout, 'manifest.summary.status is not OK', '缺少 summary 失败必须写入 JSON reason。', ); } function assertMissingSchemaVersionFails() { const fixture = prepareEvidenceBundleFixture('schema-version-missing'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); delete manifest.schemaVersion; writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, 'manifest 缺少 schemaVersion 时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, 'manifest.schemaVersion 必须是 1', '缺少 manifest schemaVersion 必须给出明确错误。', ); } function assertUnsupportedSchemaVersionFails() { const fixture = prepareCommandEvidenceFixture('schema-version-unsupported'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); manifest.schemaVersion = 2; writeJson(manifestPath, manifest); const result = runVerify(['--manifest', manifestPath]); assertStatus(result, 1, 'manifest schemaVersion 非 1 时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, 'manifest.schemaVersion 必须是 1', '未知 manifest schemaVersion 必须给出明确错误。', ); } function assertMissingFileFails() { const fixture = prepareEvidenceBundleFixture('missing-file'); rmSync(path.join(fixture.dir, 'snapshot.stderr.txt'), { force: true }); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, 'manifest 指向缺失文件时必须失败。'); assertIncludes( result.stdout, 'missing file', '缺失文件失败必须写入 JSON reason。', ); } function assertSizeMismatchFails() { const fixture = prepareEvidenceBundleFixture('size-mismatch'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); manifest.files.snapshot.sizeBytes += 1; writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, 'sizeBytes 不一致时必须失败。'); assertIncludes( result.stdout, 'size mismatch', 'sizeBytes 不一致必须写入 JSON reason。', ); } function assertSha256MismatchFails() { const fixture = prepareEvidenceBundleFixture('sha256-mismatch'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); manifest.files.snapshot.sha256 = '0'.repeat(64); writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, 'sha256 不一致时必须失败。'); assertIncludes( result.stdout, 'sha256 mismatch', 'sha256 不一致必须写入 JSON reason。', ); } function assertUnsafePathFails() { const fixture = prepareEvidenceBundleFixture('unsafe-path'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); manifest.files.snapshot.path = '../snapshot.json'; writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, 'manifest 文件 path 逃逸证据目录时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '只能是证据目录内的安全文件名', 'unsafe path 失败必须给出明确错误。', ); } function assertStringMetadataFailsExceptManifest() { const fixture = prepareEvidenceBundleFixture('string-metadata'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); manifest.files.snapshot = 'snapshot.json'; writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, '非 manifest 文件仍用字符串登记时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '必须使用 { path, sizeBytes, sha256 } 元数据对象', '字符串 metadata 失败必须给出明确错误。', ); } function assertExtraFileFailsByDefault() { const fixture = prepareEvidenceBundleFixture('extra-file'); writeFileSync(path.join(fixture.dir, 'operator-note.txt'), 'manual note\n', 'utf8'); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, '证据目录混入未登记普通文件时默认必须失败。'); assertIncludes( result.stdout, 'extra file not registered in manifest.files', '未登记普通文件失败必须写入 JSON reason。', ); assertIncludes( result.stdout, 'operator-note.txt', '未登记普通文件失败必须输出文件名。', ); } function assertExtraDirectoryFailsByDefault() { const fixture = prepareEvidenceBundleFixture('extra-directory'); mkdirSync(path.join(fixture.dir, 'notes'), { recursive: true }); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, '证据目录混入未登记目录时默认必须失败。'); assertIncludes( result.stdout, 'extra directory not registered in manifest.files', '未登记目录失败必须写入 JSON reason。', ); } function assertExtraSymlinkFailsByDefault() { const fixture = prepareEvidenceBundleFixture('extra-symlink'); writeFileSync(path.join(fixture.dir, 'target.txt'), 'target\n', 'utf8'); const manifest = readJson(path.join(fixture.dir, 'manifest.json')); manifest.files.target = metadataFor(path.join(fixture.dir, 'target.txt')); writeJson(path.join(fixture.dir, 'manifest.json'), manifest); symlinkSync(path.join(fixture.dir, 'target.txt'), path.join(fixture.dir, 'target-link.txt')); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, '证据目录混入未登记符号链接时默认必须失败。'); assertIncludes( result.stdout, 'extra symlink not registered in manifest.files', '未登记符号链接失败必须写入 JSON reason。', ); } function assertAllowExtraFilesOverrideSucceeds() { const fixture = prepareEvidenceBundleFixture('allow-extra-file'); writeFileSync(path.join(fixture.dir, 'operator-note.txt'), 'manual note\n', 'utf8'); const result = runVerify(['--bundle-dir', fixture.dir, '--allow-extra-files']); assertStatus(result, 0, '显式允许未登记文件时 verifier 应通过。'); if (result.status !== 0) { return; } const output = parseJson(result.stdout, '允许未登记文件校验 stdout'); assertEqual(output.ok, true, '允许未登记文件时 stdout 必须 ok=true。'); assertEqual( output.allowExtraFiles, true, '允许未登记文件时 stdout 必须记录 allowExtraFiles=true。', ); assertEqual( output.extraFiles?.length, 0, '允许未登记文件时 stdout 不应输出失败 extraFiles。', ); } function assertRejectsRootPath() { const result = runVerify(['--bundle-dir', path.parse(process.cwd()).root]); assertStatus(result, 1, '证据目录为文件系统根目录时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '不能是文件系统根目录', '根目录失败必须给出明确错误。', ); } function assertRejectsEntryPathControlCharacters() { const fixture = prepareEvidenceBundleFixture('entry-control-character'); const result = runVerify(['--bundle-dir', `${fixture.dir}\n--allow-extra-files`]); assertStatus(result, 1, '入口路径带换行控制字符时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '--bundle-dir 不能包含换行或 NUL 字符', '入口路径控制字符失败必须给出明确错误。', ); } function assertRejectsSymlinkBundleDir() { const fixture = prepareEvidenceBundleFixture('symlink-target'); const linkPath = path.join(tmpRoot, 'symlink-bundle'); symlinkSync(fixture.dir, linkPath); const result = runVerify(['--bundle-dir', linkPath]); assertStatus(result, 1, '证据目录是符号链接时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '证据目录不能是符号链接', '符号链接证据目录失败必须给出明确错误。', ); } function assertRejectsNonDirectoryBundleDir() { const filePath = path.join(tmpRoot, 'not-a-dir'); writeFileSync(filePath, 'not a dir\n', 'utf8'); const result = runVerify(['--bundle-dir', filePath]); assertStatus(result, 1, '证据目录不是目录时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '证据目录必须是目录', '非目录证据路径失败必须给出明确错误。', ); } function assertRejectsInvalidJson() { const dir = path.join(tmpRoot, 'invalid-json'); mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, 'manifest.json'), '{ not json\n', 'utf8'); const result = runVerify(['--bundle-dir', dir]); assertStatus(result, 1, 'manifest JSON 非法时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, 'manifest 不是合法 JSON', '非法 JSON 失败必须给出明确错误。', ); } function assertRejectsBothEntryModes() { const fixture = prepareEvidenceBundleFixture('both-entry-modes'); const result = runVerify([ '--bundle-dir', fixture.dir, '--manifest', path.join(fixture.dir, 'manifest.json'), ]); assertStatus(result, 1, '同时提供 manifest 和 bundle-dir 时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '只能二选一', '入口参数互斥失败必须给出明确错误。', ); } function assertRejectsRelativeEntryPath() { const result = runVerify(['--bundle-dir', 'relative-evidence']); assertStatus(result, 1, '入口路径是相对路径时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '必须是绝对路径', '相对路径失败必须给出明确错误。', ); } function assertRejectsManifestFilePathControlCharacters() { const fixture = prepareEvidenceBundleFixture('metadata-control-character'); const manifestPath = path.join(fixture.dir, 'manifest.json'); const manifest = readJson(manifestPath); manifest.files.snapshot.path = 'snapshot.json\noperator-note.txt'; writeJson(manifestPath, manifest); const result = runVerify(['--bundle-dir', fixture.dir]); assertStatus(result, 1, 'manifest 文件 path 带控制字符时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '只能是证据目录内的安全文件名', 'manifest 文件 path 控制字符失败必须给出明确错误。', ); } function prepareEvidenceBundleFixture(name) { const dir = path.join(tmpRoot, name); mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, 'snapshot.json'), '{"ok":true}\n', 'utf8'); writeFileSync(path.join(dir, 'snapshot.stdout.txt'), '{"ok":true}\n', 'utf8'); writeFileSync(path.join(dir, 'snapshot.stderr.txt'), '', 'utf8'); writeFileSync( path.join(dir, 'snapshot-command.json'), '{"executable":"node"}\n', 'utf8', ); writeJson(path.join(dir, 'manifest.json'), { schemaVersion: 1, summary: { status: 'OK', }, files: { manifest: 'manifest.json', snapshot: metadataFor(path.join(dir, 'snapshot.json')), snapshotParseError: null, snapshotStdout: metadataFor(path.join(dir, 'snapshot.stdout.txt')), snapshotStderr: metadataFor(path.join(dir, 'snapshot.stderr.txt')), snapshotCommand: metadataFor(path.join(dir, 'snapshot-command.json')), directLive: null, directLiveParseError: null, directLiveStdout: null, directLiveStderr: null, directLiveCommand: null, }, }); return { dir }; } function prepareCommandEvidenceFixture(name) { const dir = path.join(tmpRoot, name); mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, 'command.stdout.txt'), 'ok\n', 'utf8'); writeFileSync(path.join(dir, 'command.stderr.txt'), '', 'utf8'); writeFileSync( path.join(dir, 'command-record.json'), '{"exitCode":0}\n', 'utf8', ); writeJson(path.join(dir, 'manifest.json'), { schemaVersion: 1, summary: { status: 'OK', exitCode: 0, }, files: { manifest: 'manifest.json', stdout: metadataFor(path.join(dir, 'command.stdout.txt')), stderr: metadataFor(path.join(dir, 'command.stderr.txt')), commandRecord: metadataFor(path.join(dir, 'command-record.json')), }, }); return { dir }; } function metadataFor(filePath) { const content = readFileSync(filePath); return { path: path.basename(filePath), sizeBytes: content.length, sha256: createHash('sha256').update(content).digest('hex'), }; } function runVerify(args) { return spawnSync('node', [VERIFY_SCRIPT, ...args], { cwd: process.cwd(), encoding: 'utf8', }); } function readJson(filePath) { try { return JSON.parse(readFileSync(filePath, 'utf8')); } catch (error) { failures.push(`${filePath} 不是合法 JSON: ${error.message}`); return {}; } } function writeJson(filePath, value) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function parseJson(raw, label) { try { return JSON.parse(raw); } catch (error) { failures.push(`${label} 不是合法 JSON: ${error.message}\n${raw}`); return {}; } } function assertStatus(result, expected, reason) { const actual = result.status ?? 0; if (actual !== expected) { failures.push( `${reason} 预期退出码 ${expected},实际 ${actual}。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ); } } function assertIncludes(content, needle, reason) { if (!String(content).includes(needle)) { failures.push(`${reason} 缺少: ${needle}`); } } function assertEqual(actual, expected, reason) { if (actual !== expected) { failures.push(`${reason} 预期 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`); } }