#!/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); }