#!/usr/bin/env node // xcheck.mjs —— 交付件交叉核验器 v1(仅 node 内置模块,无任何依赖) // 用法: node xcheck.mjs [--json ] // 清单: {"items":[{"id"?,"path","sha256"?,"bytes"?}, ...]} // 判定(名词冻结): PASS / MISMATCH / MISSING / UNCHECKED // PASS 文件可读,且所有已声明项(sha256、bytes)全部相符;无任何声明 → 永不 PASS // MISMATCH 任一已声明项与实况不符;报声明值/实际值 64 位全串 + 哈希首个不同字符序号(0 起) // MISSING 文件读不到;原因码: ENOENT / EACCES / NOT_A_FILE(其他 errno 原样透出,绝不折进 PASS) // UNCHECKED 文件可读,但条目未声明任何 sha256/bytes——它没有被核验,永不记为 PASS // 退出码: 全 PASS=0;任一非 PASS=1;清单缺失/JSON 非法/字段类型错=2 // 设计依据(第一棒实测): 字节数相同 != 内容相同(69B vs 69B 哈希不同), // 所以哈希与字节数各自独立比对,任一项不符即 MISMATCH。 import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; const HEX64 = /^[0-9a-fA-F]{64}$/; function fail2(msg) { console.error('error: ' + msg); process.exit(2); } const argv = process.argv.slice(2); const pos = []; let jsonOut = null; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--json') { jsonOut = argv[++i] ?? fail2('--json requires a file path'); } else pos.push(argv[i]); } if (pos.length !== 1) fail2('usage: node xcheck.mjs [--json ]'); let manifest; try { manifest = JSON.parse(readFileSync(pos[0], 'utf8')); } catch (e) { fail2('manifest unreadable or invalid JSON: ' + e.message); } if (manifest === null || typeof manifest !== 'object' || !Array.isArray(manifest.items)) fail2('manifest must be {"items":[...]}'); function mapFsErr(code) { if (code === 'ENOENT') return 'ENOENT'; if (code === 'EACCES' || code === 'EPERM') return 'EACCES'; return null; } function firstDiffIndex(a, b) { const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i; return n; // 双 64 位串不会走到这里,留作健壮性 } const results = []; for (const it of manifest.items) { const row = { id: it && it.id !== undefined ? it.id : null, path: null, verdict: null, reason: null, declared: {}, actual: {}, diffs: [] }; if (it === null || typeof it !== 'object' || typeof it.path !== 'string' || it.path === '') fail2('every item needs a non-empty string "path" (bad item id=' + JSON.stringify(it && it.id !== undefined ? it.id : null) + ')'); row.path = it.path; const dSha = it.sha256 !== undefined ? String(it.sha256) : null; const dBytes = it.bytes !== undefined ? it.bytes : null; if (dSha !== null && !HEX64.test(dSha)) fail2('item "' + it.path + '": sha256 must be a 64-hex string'); if (dBytes !== null && (!Number.isInteger(dBytes) || dBytes < 0)) fail2('item "' + it.path + '": bytes must be a non-negative integer'); let st = null; try { st = statSync(it.path); } catch (e) { row.verdict = 'MISSING'; row.reason = mapFsErr(e.code) ?? e.code; results.push(row); continue; } if (!st.isFile()) { row.verdict = 'MISSING'; row.reason = 'NOT_A_FILE'; results.push(row); continue; } let buf; try { buf = readFileSync(it.path); } catch (e) { row.verdict = 'MISSING'; row.reason = mapFsErr(e.code) ?? e.code; results.push(row); continue; } const aSha = createHash('sha256').update(buf).digest('hex'); const aBytes = buf.length; row.actual = { sha256: aSha, bytes: aBytes, statBytes: st.size }; if (dSha !== null) row.declared.sha256 = dSha.toLowerCase(); if (dBytes !== null) row.declared.bytes = dBytes; if (dSha !== null && dSha.toLowerCase() !== aSha) row.diffs.push({ field: 'sha256', declared: dSha.toLowerCase(), actual: aSha, firstDiffIndex0: firstDiffIndex(dSha.toLowerCase(), aSha) }); if (dBytes !== null && dBytes !== aBytes) row.diffs.push({ field: 'bytes', declared: dBytes, actual: aBytes }); row.verdict = row.diffs.length > 0 ? 'MISMATCH' : (dSha !== null || dBytes !== null) ? 'PASS' : 'UNCHECKED'; results.push(row); } for (const r of results) { if (r.verdict === 'PASS') console.log('PASS id=' + r.id + ' path=' + r.path + ' bytes=' + r.actual.bytes + ' sha256=' + r.actual.sha256); else if (r.verdict === 'UNCHECKED') console.log('UNCHECKED id=' + r.id + ' path=' + r.path + ' (read ' + r.actual.bytes + ' B, but no sha256/bytes declared - not a pass)'); else if (r.verdict === 'MISSING') console.log('MISSING id=' + r.id + ' path=' + r.path + ' reason=' + r.reason); else console.log('MISMATCH id=' + r.id + ' path=' + r.path + r.diffs.map(d => d.field === 'sha256' ? '\n [declared] sha256=' + d.declared + '\n [actual] sha256=' + d.actual + '\n first_diff_index(0-based)=' + d.firstDiffIndex0 : '\n [declared] bytes=' + d.declared + '\n [actual] bytes=' + d.actual).join('')); } const uniq = new Map(); for (const r of results) if (r.actual && r.actual.sha256) uniq.set(r.actual.sha256, (uniq.get(r.actual.sha256) ?? []).concat(r.path)); const shared = [...uniq.entries()].filter(([, ps]) => ps.length > 1); console.log('summary: unique_contents=' + uniq.size + (shared.length ? '; shared: ' + shared.map(([h, ps]) => h + ' <- ' + ps.join(' | ')).join('; ') : '')); const nonPass = results.filter(r => r.verdict !== 'PASS').length; const exitCode = nonPass > 0 ? 1 : 0; if (jsonOut) { try { writeFileSync(jsonOut, JSON.stringify({ results, summary: { uniqueContents: uniq.size }, exitCode }, null, 2)); } catch (e) { console.error('error: cannot write --json output: ' + e.message); process.exit(2); } } process.exit(exitCode);