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:
1 parent
dadd861fdd
commit
9baf3b0443
3 files changed
+42
-3
No files matched your search
@@ -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 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user