Files

225 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* /sync-verify:校验快照完整性 —— hash 是否对得上、密钥束能否解密、占位符是否还在。
* 用法: node verify.mjs [快照名] 缺省校验 latest.json 指向的那个
*
* 会真的解密密钥束(需 ZCODE_SYNC_PASSPHRASE),但只比哈希、不落地、不打印任何密钥值。
* 用途:导入前确认快照没坏;或怀疑"status 常说版本不同"时定位是内容问题还是 git 换行符问题。
* 环境变量: ZCODE_SYNC_VERIFY_STRICT=1 时,任何 warning 也算失败(退出码 1)。
*/
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import {
REPO_DIR, readJson, ensureRepo, latestSnapshotDir, listSnapshots, latestSnapshotName,
hashDir, decryptSecrets, requirePassphrase, humanSize, dirSize,
parseModelRel, isModelEntry, scanForSecrets,
} from "./sync-core.mjs";
/** Buffer 的 sha256(manifest 里的密钥 hash 就是这么算的) */
const sha256Buf = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
/** 体积容错读取 */
const dirSizeSafe = (d) => { try { return dirSize(d); } catch { return 0; } };
/**
* --all:扫全部快照,回答"哪个我能用"。
* 团队仓里可能混着不同口令导出的快照(GCM 认证失败无法与损坏区分),
* 这个模式把它们逐个用当前口令试一遍,比一个个人肉试快得多。
*/
function sweepAll() {
ensureRepo();
const all = listSnapshots();
if (!all.length) { console.log("# 远端尚无快照\n- 先 /sync-export 推第一版"); return; }
let pass = null;
try { pass = requirePassphrase(); } catch { /* 无口令:只报结构 */ }
const latestName = latestSnapshotName();
const out = [`# 全部快照扫描(${all.length} 个)`, `- 口令:${pass ? "已提供" : "未提供(只校验结构,不解密)"}`];
const usable = [];
for (const s of all) {
const mark = s.name === latestName ? "*" : " ";
const tag = [`${s.valid ? "结构OK" : "结构损坏"}`];
if (pass && s.valid) {
const enc = path.join(s.dir, "secrets.enc");
if (!fs.existsSync(enc)) { tag.push("无密钥束"); }
else {
try { decryptSecrets(fs.readFileSync(enc, "utf8"), pass); tag.push("可解密"); }
catch { tag.push("口令不符/损坏"); }
}
}
const good = s.valid && (!pass || tag.includes("可解密") || tag.includes("无密钥束"));
if (good) usable.push(s.name);
out.push(`${mark} ${s.name} ${humanSize(s.bytes).padStart(8)} ${s.host} ${tag.join(" / ")}`);
}
out.push(`- 标 * 的是 latest.json 指向的快照`);
out.push(`- 当前口令可用:${usable.length} 个${usable.length ? `(最新的:${usable[0]})` : ""}`);
if (pass && !usable.includes(latestName) && latestName) {
out.push(`- ⚠ latest.json 指向的 ${latestName} 用当前口令不可用 —— /sync-import 会失败。`);
if (usable.length) out.push(` 改用可用的那版:/sync-import ${usable[0]}`);
}
console.log(out.join("\n"));
}
function fail(e) { console.error(`校验失败:${e?.message ?? e}`); process.exitCode = 1; }
try {
if (process.argv.includes("--all")) { sweepAll(); process.exit(0); }
ensureRepo();
// 只看用户参数:argv[0]=node, argv[1]=脚本路径,不能把 node.exe 当成快照名
const FLAGS = new Set(["--all", "--strict", "--quiet"]);
const userRaw = process.argv.slice(2);
const badFlag = userRaw.find((a) => a.startsWith("--") && !FLAGS.has(a));
if (badFlag) throw new Error(`未知参数:${badFlag}(支持 [快照名] / --all / --strict / --quiet)`);
const want = userRaw.find((a) => !a.startsWith("--"));
const snapDir = want ? path.join(REPO_DIR, "snapshots", want) : latestSnapshotDir();
if (!snapDir || !fs.existsSync(path.join(snapDir, "manifest.json"))) {
throw new Error(want ? `快照不存在或无 manifest:snapshots/${want}` : "远端尚无快照(先 /sync-export)");
}
const snapName = path.basename(snapDir);
const m = readJson(path.join(snapDir, "manifest.json"), null);
if (!m) throw new Error(`manifest.json 读不出来(JSON 损坏?)`);
const errs = [], warns = [], oks = [];
const rel = (p) => path.relative(snapDir, p);
/* 1) 结构:必备文件在不在 */
for (const f of ["mcp.servers.json", "plugins-dirs.json", "secrets.enc", "secrets.manifest.json"]) {
const p = path.join(snapDir, f);
if (!fs.existsSync(p)) errs.push(`缺少必备文件 ${f}`);
else oks.push(`${f} 存在(${humanSize(fs.statSync(p).size)})`);
}
/* 2) 自研插件:逐目录重算 hash,与 manifest 对照 */
const cpRoot = path.join(snapDir, "custom-plugins");
for (const c of m.custom ?? []) {
const dir = path.join(cpRoot, c.name);
if (!fs.existsSync(dir)) { errs.push(`自研插件目录缺失:${c.name}`); continue; }
const got = hashDir(dir);
if (got !== c.hash) errs.push(`自研插件 ${c.name} 内容 hash 不符(manifest=${c.hash} 实际=${got})`);
else oks.push(`自研插件 ${c.name}@${c.version} hash 一致`);
}
// 目录里有 manifest 没记录的 = 快照不一致
if (fs.existsSync(cpRoot)) {
const onDisk = fs.readdirSync(cpRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
const inManifest = new Set((m.custom ?? []).map((c) => c.name));
for (const n of onDisk) if (!inManifest.has(n)) warns.push(`快照里有 manifest 未记录的插件目录:${n}`);
for (const n of inManifest) if (!onDisk.includes(n)) warns.push(`manifest 记录了但快照里没有:${n}`);
}
/* 3) 密钥束:能否解密 + 清单 hash 是否对得上 */
let bundle = null;
const encPath = path.join(snapDir, "secrets.enc");
if (fs.existsSync(encPath)) {
let pass = null;
try { pass = requirePassphrase(); }
catch (e) { warns.push(`未提供口令,跳过密钥束解密(${e.message})`); }
if (pass) {
try {
bundle = decryptSecrets(fs.readFileSync(encPath, "utf8"), pass);
oks.push(`密钥束可解密(${bundle.files?.length ?? 0} 个条目)`);
} catch (e) {
// GCM 认证失败无法区分"口令错"和"密文被改" —— 但两种情况里口令错远比损坏常见,
// 尤其是团队仓里另一台机器/换过口令的快照,所以把提示往那边引。
errs.push(`密钥束解不开:口令不对(该快照可能用了别的口令,比如换口令前导出的,或来自别的工作区)`
+ `,或 secrets.enc 被改过。加密层报错:${e.message}`);
}
}
}
// 密钥清单 hash 对照(解不开时就只能靠清单自证)
const manPath = path.join(snapDir, "secrets.manifest.json");
if (bundle) {
const listed = new Map((m.secretFiles ?? []).map((f) => [f.rel, f.sha256]));
const got = new Map();
for (const f of bundle.files ?? []) {
const buf = Buffer.from(f.contentB64 ?? "", "base64");
got.set(f.rel, sha256Buf(buf));
}
for (const [r, h] of listed) {
if (isModelEntry(r)) continue; // 模型条目在 modelKeys 里单独记
if (!got.has(r)) errs.push(`清单列了密钥 ${r},加密束里没有`);
else if (got.get(r) !== h) errs.push(`密钥 ${r} 内容与清单 hash 不符`);
else oks.push(`密钥 ${r} hash 一致`);
}
for (const r of got.keys()) {
if (!isModelEntry(r) && !listed.has(r)) warns.push(`加密束里有清单未记录的条目:${r}`);
}
// 模型条目:形状是否正确
for (const f of bundle.files ?? []) {
if (!isModelEntry(f.rel)) continue;
try { parseModelRel(f.rel); oks.push(`模型条目 ${f.rel} 形状正确`); }
catch (e) { errs.push(`模型条目非法:${f.rel}(${e.message})`); }
}
}
if (fs.existsSync(manPath)) {
const sm = readJson(manPath, null);
if (!Array.isArray(sm)) errs.push("secrets.manifest.json 不是数组");
else oks.push(`密钥清单 ${sm.length} 条`);
}
/* 4) 占位符:配置区不该再有本机路径(说明导出时占位符化生效了) */
const USER_PATH_RE = /[\\/]Users[\\/][^\\/"\s]+/;
const stringsOf = (node, out = []) => {
if (typeof node === "string") { out.push(node); return out; }
if (Array.isArray(node)) { node.forEach((v) => stringsOf(v, out)); return out; }
if (node && typeof node === "object") for (const [k, v] of Object.entries(node)) { stringsOf(k, out); stringsOf(v, out); }
return out;
};
let phCount = 0, leakCount = 0;
for (const f of ["mcp.servers.json", "plugins-dirs.json", "v2.providers.json"]) {
const p = path.join(snapDir, f);
if (!fs.existsSync(p)) continue;
let strs;
try { strs = stringsOf(JSON.parse(fs.readFileSync(p, "utf8"))); }
catch { errs.push(`${f} 不是合法 JSON`); continue; }
for (const s of strs) {
if (/\$\{[A-Z_]+\}/.test(s)) phCount++;
if (USER_PATH_RE.test(s)) { leakCount++; errs.push(`${f} 里残留未占位符化的绝对路径:${s.slice(0, 60)}`); }
}
}
oks.push(`占位符 ${phCount} 处,未占位符化路径 ${leakCount} 处`);
/* 5) 明文区密钥泄漏复检(含 manifest 自身) */
const hit = scanForSecrets({
mcp: readJson(path.join(snapDir, "mcp.servers.json"), {}),
dirs: readJson(path.join(snapDir, "plugins-dirs.json"), []),
manifest: { modelProviders: m.modelProviders ?? [] },
});
for (const h of hit) {
// 市场清单里可能带 baseURL 之类,只对"疑似真值"报警
warns.push(`明文区扫描命中疑似密钥字段:${h}`);
}
/* 6) manifest 自洽性 */
if (!m.snapshot || path.basename(m.snapshot) !== snapName) {
warns.push(`manifest.snapshot 与实际目录名不一致(${m.snapshot} vs ${snapName})`);
}
if (m.bytes && Math.abs(dirSizeSafe(snapDir) - m.bytes) > 4096) {
warns.push(`manifest.bytes=${m.bytes} 与实际体积差得较多(可能被手工改过)`);
}
if ((m.enabled ?? []).length && !(m.enabledDetailed ?? []).length) {
warns.push(`manifest 无 enabledDetailed(inline 插件版本缺失,建议重新导出)`);
}
/* ---- 汇总 ---- */
const strict = process.env.ZCODE_SYNC_VERIFY_STRICT === "1" || process.argv.includes("--strict");
const out = [`# 快照校验:${snapName}`, `- 来源:${m.host} @ ${m.createdAt}`, `- 体积:${humanSize(m.bytes ?? dirSizeSafe(snapDir))}`];
// 通过项也列出来:只报"通过 10 项"用户不知道到底验了什么,而警告/失败却有明细
const verbose = !process.argv.includes("--quiet");
out.push(`- 通过 ${oks.length} 项${verbose ? ":" : ""}`);
if (verbose) for (const o of oks) out.push(` ✓ ${o}`);
if (warns.length) out.push(`- 警告 ${warns.length} 项:`, ...warns.map((w) => ` ⚠ ${w}`));
if (errs.length) out.push(`- 失败 ${errs.length} 项:`, ...errs.map((e) => ` ✗ ${e}`));
console.log(out.join("\n"));
if (errs.length) {
process.exitCode = 1;
console.log(`\n结论:快照不可用(${errs.length} 项错误)。重新导出该机器的环境,或改校验别的快照。`);
} else if (warns.length && strict) {
process.exitCode = 1;
console.log(`\n结论:无致命错误,但有 ${warns.length} 项警告(严格模式视为失败)。`);
} else {
console.log(warns.length
? `\n结论:快照可用,${warns.length} 项警告不影响导入。`
: `\n结论:快照完好。`);
}
} catch (e) { fail(e); }