#!/usr/bin/env node import { createHash } from 'node:crypto'; import { constants as fsConstants } from 'node:fs'; import { access, lstat, readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; const config = parseArgs(process.argv.slice(2)); const manifestPath = await resolveManifestPath(config); const bundleDir = path.dirname(manifestPath); await validateBundleDir(bundleDir); const manifest = await readManifest(manifestPath); validateManifestSchemaVersion(manifest); const { verifications, registeredFileNames } = await verifyManifestFiles(bundleDir, manifest); const extraFiles = config.allowExtraFiles ? [] : await findExtraEvidenceEntries(bundleDir, registeredFileNames); const summaryVerification = config.requireSummaryOk ? verifySummaryOk(manifest) : null; const failed = verifications.filter((item) => item.status !== 'OK'); const failedCount = failed.length + extraFiles.length + (summaryVerification && summaryVerification.status !== 'OK' ? 1 : 0); console.log( `${JSON.stringify( { ok: failedCount === 0, manifestPath, bundleDir, checkedCount: verifications.length, failedCount, allowExtraFiles: config.allowExtraFiles, requireSummaryOk: config.requireSummaryOk, summary: summaryVerification, files: verifications, extraFiles, }, null, 2, )}\n`, ); if (failedCount > 0) { process.exit(1); } function parseArgs(argv) { const result = { manifestPath: '', bundleDir: '', allowExtraFiles: false, requireSummaryOk: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; switch (arg) { case '-h': case '--help': printUsage(); process.exit(0); break; case '--manifest': result.manifestPath = requireValue(argv, ++index, arg); break; case '--bundle-dir': result.bundleDir = requireValue(argv, ++index, arg); break; case '--allow-extra-files': result.allowExtraFiles = true; break; case '--require-summary-ok': result.requireSummaryOk = true; break; default: throw new Error(`未知参数: ${arg}`); } } if (result.manifestPath && result.bundleDir) { throw new Error('--manifest 和 --bundle-dir 只能二选一。'); } if (!result.manifestPath && !result.bundleDir) { throw new Error('必须提供 --manifest 或 --bundle-dir。'); } for (const [label, value] of [ ['--manifest', result.manifestPath], ['--bundle-dir', result.bundleDir], ]) { if (!value) { continue; } if (!path.isAbsolute(value)) { throw new Error(`${label} 必须是绝对路径。`); } if (isFilesystemRootPath(value)) { throw new Error(`${label} 不能是文件系统根目录。`); } validateNoControlCharacters(value, label); } return result; } function printUsage() { console.log(`Usage: node scripts/ops/pingora-cutover-evidence-verify.mjs --manifest node scripts/ops/pingora-cutover-evidence-verify.mjs --bundle-dir Options: --allow-extra-files 允许证据目录中存在 manifest.files 未登记的额外条目;默认拒绝,正式切换归档不应使用。 --require-summary-ok 要求 manifest.summary.status 必须是 OK;正式切换 runbook 的即时验真步骤必须使用。 该脚本只读校验 Pingora 直连切换证据目录中的 schemaVersion=1 manifest.files 元数据,确认每个已登记文件的 path / sizeBytes / sha256 与实际文件一致,并默认拒绝证据目录中混入未登记文件、目录或符号链接;null 文件条目会跳过。正式切换 runbook 还会用 --require-summary-ok 把 manifest.summary.status 非 OK 的证据作为失败处理。脚本不会修改证据目录、不会 reload systemd、不会访问 Nginx 或 Pingora。 `); } function requireValue(argv, index, flag) { const value = argv[index]; if (value === undefined || value.startsWith('--')) { throw new Error(`${flag} 缺少参数值`); } return value; } async function resolveManifestPath(config) { if (config.manifestPath) { return path.resolve(config.manifestPath); } return path.join(path.resolve(config.bundleDir), 'manifest.json'); } async function validateBundleDir(bundleDir) { const resolved = path.resolve(bundleDir); if (isFilesystemRootPath(resolved)) { throw new Error('证据目录不能是文件系统根目录。'); } const stats = await lstat(resolved); if (stats.isSymbolicLink()) { throw new Error(`证据目录不能是符号链接: ${resolved}`); } if (!stats.isDirectory()) { throw new Error(`证据目录必须是目录: ${resolved}`); } await access(resolved, fsConstants.R_OK | fsConstants.X_OK); } async function readManifest(manifestPath) { const stats = await lstat(manifestPath); if (stats.isSymbolicLink()) { throw new Error(`manifest 不能是符号链接: ${manifestPath}`); } if (!stats.isFile()) { throw new Error(`manifest 必须是文件: ${manifestPath}`); } let content; try { content = await readFile(manifestPath, 'utf8'); } catch (error) { throw new Error(`读取 manifest 失败: ${error.message}`); } try { return JSON.parse(content); } catch (error) { throw new Error(`manifest 不是合法 JSON: ${error.message}`); } } function validateManifestSchemaVersion(manifest) { if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { throw new Error('manifest 顶层必须是 JSON object。'); } if (manifest.schemaVersion !== 1) { throw new Error('manifest.schemaVersion 必须是 1。'); } } async function verifyManifestFiles(bundleDir, manifest) { if (!manifest.files || typeof manifest.files !== 'object') { throw new Error('manifest 缺少 files object。'); } const verifications = []; const registeredFileNames = new Set(['manifest.json']); for (const [key, value] of Object.entries(manifest.files)) { if (value === null) { continue; } if (typeof value === 'string') { if (key === 'manifest' && value === 'manifest.json') { continue; } throw new Error( `manifest.files.${key} 必须使用 { path, sizeBytes, sha256 } 元数据对象。`, ); } validateMetadata(key, value); registeredFileNames.add(value.path); verifications.push(await verifyMetadata(bundleDir, key, value)); } return { verifications, registeredFileNames }; } function verifySummaryOk(manifest) { const status = manifest?.summary?.status; if (status === 'OK') { return { status: 'OK', expectedStatus: 'OK', actualStatus: status, reason: null, }; } return { status: 'MISMATCH', expectedStatus: 'OK', actualStatus: status ?? null, reason: 'manifest.summary.status is not OK', }; } async function findExtraEvidenceEntries(bundleDir, registeredFileNames) { const entries = await readdir(bundleDir, { withFileTypes: true }); const extras = []; for (const entry of entries) { if (registeredFileNames.has(entry.name)) { continue; } extras.push({ path: entry.name, status: 'MISMATCH', reason: entry.isSymbolicLink() ? 'extra symlink not registered in manifest.files' : entry.isDirectory() ? 'extra directory not registered in manifest.files' : 'extra file not registered in manifest.files', }); } return extras.sort((left, right) => left.path.localeCompare(right.path)); } function validateMetadata(key, value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`manifest.files.${key} 必须是元数据对象或 null。`); } if (typeof value.path !== 'string' || value.path.length === 0) { throw new Error(`manifest.files.${key}.path 必须是非空字符串。`); } if ( /[\0\r\n]/u.test(value.path) || path.isAbsolute(value.path) || value.path.includes('/') || value.path.includes('\\') || value.path === '.' || value.path === '..' || value.path.includes('..') ) { throw new Error( `manifest.files.${key}.path 只能是证据目录内的安全文件名: ${value.path}`, ); } if (!Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0) { throw new Error(`manifest.files.${key}.sizeBytes 必须是非负安全整数。`); } if (typeof value.sha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(value.sha256)) { throw new Error(`manifest.files.${key}.sha256 必须是 64 位小写十六进制。`); } } async function verifyMetadata(bundleDir, key, metadata) { const filePath = path.join(bundleDir, metadata.path); const expectedPath = path.resolve(bundleDir, metadata.path); if (path.dirname(expectedPath) !== path.resolve(bundleDir)) { throw new Error(`manifest.files.${key}.path 超出证据目录: ${metadata.path}`); } let stats; let content; try { const linkStats = await lstat(filePath); if (linkStats.isSymbolicLink()) { return { key, path: metadata.path, status: 'MISMATCH', reason: 'file is symlink', }; } if (!linkStats.isFile()) { return { key, path: metadata.path, status: 'MISMATCH', reason: 'not a regular file', }; } [stats, content] = await Promise.all([stat(filePath), readFile(filePath)]); } catch (error) { return { key, path: metadata.path, status: 'MISMATCH', reason: error?.code === 'ENOENT' ? 'missing file' : error.message, }; } const actualSha256 = createHash('sha256').update(content).digest('hex'); const sizeMatches = stats.size === metadata.sizeBytes; const sha256Matches = actualSha256 === metadata.sha256; return { key, path: metadata.path, status: sizeMatches && sha256Matches ? 'OK' : 'MISMATCH', expectedSizeBytes: metadata.sizeBytes, actualSizeBytes: stats.size, expectedSha256: metadata.sha256, actualSha256, reason: sizeMatches && sha256Matches ? null : [ ...(sizeMatches ? [] : ['size mismatch']), ...(sha256Matches ? [] : ['sha256 mismatch']), ].join(', '), }; } function isFilesystemRootPath(value) { const resolved = path.resolve(String(value)); return resolved === path.parse(resolved).root; } function validateNoControlCharacters(value, label) { if (/[\0\r\n]/u.test(String(value))) { throw new Error(`${label} 不能包含换行或 NUL 字符。`); } }