fix(cli): rollback 指纹校验容忍 12/64 位两种口径

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 守卫强制耦合)。
This commit is contained in:
jkooe committed 2026-10-26 21:00:26 +08:00
1 parent dadd861fdd
commit 9baf3b0443
3 files changed
+42 -3

No files matched your search

+1 -1
View File
@@ -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 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
+21 -2
View File
@@ -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<boolean> {
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;
}
+20
View File
@@ -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');