225 lines
11 KiB
JavaScript
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); }
|