From 9baf3b044368d098dfb1a276a9ca3a0325fac289 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:00:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli):=20rollback=20=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=AE=B9=E5=BF=8D=2012/64=20=E4=BD=8D?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA 独立验证路由的 P0 缺陷(tests/unit/qa-v210-supplement.test.mjs C⑦ 红): - 写后校验(rollback.ts:462):back.hash 是 64 位流式 sha256,而老 LKG 记录的 expectHash 只有 12 位(无 hash_full)→ 严格 === 必不成立 → 老记录回滚永远 被判「写后校验失败」并用回滚源还原,向后兼容路径实际不可用。 - 重启复核(rollback.ts:519):新 LKG 记录带 hash_full(64 位)时,p.buildHash 是 12 位 → 严格 === 必不成立 → 回滚其实成功却被误报「90s 内未复核到回滚版本」 并以退出码 1 结束(--backup 逃生门同样命中此缺陷)。 修法:新增纯函数 hashMatchesEither(短的一方是长的一方前缀即命中),仅用于 「校验/复核」方向;挑回滚源仍用单向 hashMatches —— hash_full 在手时不许前缀 糊弄(C⑥ 钉死),两条语义各有其守卫。 README 单测数 476 → 477(新增 hashMatchesEither 用例,docs-numbers 守卫强制耦合)。 --- README.md | 2 +- apps/cli/src/cmd/rollback.ts | 23 +++++++++++++++++++++-- tests/unit/rollback.test.mjs | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee3be07..955c2ee 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点 > 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。 > 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。 -**52 项能力** · **42 个 MCP 工具** · **476 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证) +**52 项能力** · **42 个 MCP 工具** · **477 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证) > 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR) > 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。 diff --git a/apps/cli/src/cmd/rollback.ts b/apps/cli/src/cmd/rollback.ts index ba327df..0d2d144 100644 --- a/apps/cli/src/cmd/rollback.ts +++ b/apps/cli/src/cmd/rollback.ts @@ -163,6 +163,25 @@ export function hashMatches(got: string, want: string): boolean { return want.length < got.length && got.startsWith(want); } +/** + * 双向前缀匹配:短的一方是长的一方的前缀即算命中。 + * + * 为什么需要它(v2.1.0 QA 缺陷单):指纹有两个口径同时在场 —— + * · `build.hash` / 老 LKG 的 `hash` 是 **12 位**前缀; + * · `hash_full` / 流式 sha256 结果是 **64 位**全量。 + * 写后校验与重启复核是「拿 64 位实测值去比期望值」,而期望值可能是 12 位(老 + * 记录)或 64 位(新记录)。用严格 === 会让其中一种口径必然失败: + * · 12 位期望 ↔ 64 位实测 → 老记录回滚永远走「写后校验失败」; + * · 64 位期望 ↔ 12 位实测 → 新记录回滚成功后被误报复核失败(退出码 1)。 + * 因此校验方向必须容忍两种口径,而「挑回滚源」方向仍用单向 hashMatches + * (hash_full 在手时不许前缀糊弄,见 tests/unit/qa-v210-supplement.test.mjs C⑥)。 + */ +export function hashMatchesEither(a: string, b: string): boolean { + if (!a || !b) return false; + if (a === b) return true; + return a.length < b.length ? b.startsWith(a) : a.startsWith(b); +} + /** * 回滚计划(纯函数):决定 action 与期望指纹。 * @@ -459,7 +478,7 @@ export async function runRollback( try { const written = await copyRemoteFile(client, source.path, plan.scriptPath); const back = await hashRemoteFile(client, plan.scriptPath); - if (back.hash !== written.hash || back.hash !== expectHash) { + if (back.hash !== written.hash || !hashMatchesEither(back.hash, expectHash)) { say('✗ 写后校验失败,正在用回滚源还原…'); await copyRemoteFile(client, source.path, plan.scriptPath); return { @@ -516,7 +535,7 @@ export async function restartAndVerify(expectHash: string): Promise { const p = await probeOnce(); if (!p.ok) continue; lastSeen = p.buildHash ?? 'unknown'; - if (p.buildHash === expectHash) { + if (hashMatchesEither(p.buildHash ?? '', expectHash)) { console.log(`✓ 回滚完成并复核通过:指纹 ${p.buildHash},PID ${p.pid ?? '?'},能力 ${p.caps} 项`); return true; } diff --git a/tests/unit/rollback.test.mjs b/tests/unit/rollback.test.mjs index e339e27..d5a05ce 100644 --- a/tests/unit/rollback.test.mjs +++ b/tests/unit/rollback.test.mjs @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { backupNameRegex, hashMatches, + hashMatchesEither, hashRemoteFile, pickBackupByHash, planRollback, @@ -178,6 +179,25 @@ test('hashMatches:空值与仅前缀长于全长都不算命中', () => { assert.equal(hashMatches(lkgHash12, lkgHashFull), false, '12 位不得反向匹配 64 位'); }); +test('hashMatchesEither:校验方向必须容忍 12 位与 64 位两种口径', () => { + // 缺陷单背景(QA 路由 P0):写后校验与重启复核是「拿实测值比期望值」, + // 期望值可能是老记录的 12 位或新记录的 64 位。严格 === 会让其中一种口径必败: + // · 12 位期望 ↔ 64 位实测 → 老记录回滚永远报「写后校验失败」并还原(回滚失效) + // · 64 位期望 ↔ 12 位实测 → 新记录回滚成功后被误报「未复核到回滚版本」(退出码 1) + assert.equal(hashMatchesEither(lkgHashFull, lkgHash12), true, '64 位实测 vs 12 位期望 → 命中(老 LKG 路径)'); + assert.equal(hashMatchesEither(lkgHash12, lkgHashFull), true, '12 位实测 vs 64 位期望 → 命中(重启复核路径)'); + assert.equal(hashMatchesEither(lkgHashFull, lkgHashFull), true, '全量相等 → 命中'); + assert.equal( + hashMatchesEither(lkgHash12, 'deadbeef1234'), + false, + '前缀不同 → 不命中(不能因为长度不同就放行)', + ); + assert.equal(hashMatchesEither('', lkgHash12), false); + assert.equal(hashMatchesEither(lkgHashFull, ''), false); + // 与挑源方向的分工:pickBackupByHash 仍用单向 hashMatches,hash_full 在手不许前缀糊弄 + assert.equal(pickBackupByHash([{ path: '/x.bak', hash: lkgHash12, mtime: 1 }], lkgHashFull), null); +}); + test('路径纯函数:Windows 反斜杠与正则元字符都不破坏 basename/正则', () => { assert.equal(remoteBasename('D:\\Nodeagent\\apps\\agent\\dist\\index.js'), 'index.js'); assert.equal(remoteDirname('D:\\Nodeagent\\agent.mjs'), 'D:\\Nodeagent');