Files
jkooe 9baf3b0443 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 守卫强制耦合)。
2026-10-26 21:00:26 +08:00

368 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
backupNameRegex,
hashMatches,
hashMatchesEither,
hashRemoteFile,
pickBackupByHash,
planRollback,
remoteBasename,
remoteDirname,
runRollback,
} from '../../apps/cli/dist/cmd/rollback.js';
/**
* `nodeagent rollback` 守卫(ARCH T03 / PRD P0-2 验收②全覆盖)。
*
* 为什么需要守卫:rollback 是「发布即失联」场景的最后一道手动恢复通道。
* 它自己写坏入口 = 把最后一次恢复机会也毁掉。故钉死:
* 1. 无 LKG → 零调用零改动(不能病急乱投医);
* 2. 当前即 LKG → 零写入;
* 3. 写后校验失败 → 必须用回滚源还原(不能把半截文件留在入口上);
* 4. --dry-run → 只有读 invoke(fs.list/fs.read/system.info),零写零重启;
* 5. 指纹匹配:全量优先、老记录 12 位前缀回落、mtime 新→早优先。
*/
// ---------- 假被控端(内存文件系统 + 调用记录) ----------
const CHUNK = 512 * 1024;
class FakeAgent {
constructor(files, opts = {}) {
this.files = new Map(Object.entries(files));
this.calls = [];
this.mtimes = new Map();
/** 故障注入:对 target 的第一次写落到「被篡改内容」上(模拟杀软/磁盘把写好的文件改坏) */
this.corruptFirstWrite = opts.corruptFirstWrite ?? false;
this.writesToTarget = 0;
}
async invoke(cap, args) {
this.calls.push({ cap, args });
switch (cap) {
case 'system.info':
return { status: 'ok', data: this.files.get('__info__') ?? {} };
case 'fs.list': {
const entries = [...this.files.entries()]
.filter(([p]) => !p.startsWith('__'))
.map(([p, buf]) => ({
name: p.split(/[\\/]/).pop(),
path: p,
type: 'file',
size: buf.length,
mtime: this.mtimes.get(p) ?? 0,
}));
return { status: 'ok', data: { entries, total: entries.length, truncated: false } };
}
case 'fs.read': {
const buf = this.files.get(args.path);
if (!buf) return { status: 'failed', error: { name: 'E_EXECUTION_FAILED', message: `不存在: ${args.path}` } };
const off = args.offset ?? 0;
const chunk = buf.subarray(off, off + (args.max_bytes ?? CHUNK));
return {
status: 'ok',
data: { data: chunk.toString('base64'), bytes: chunk.length, eof: off + chunk.length >= buf.length },
};
}
case 'fs.write': {
const incoming = Buffer.from(args.data ?? '', 'base64');
if (args.append) {
const cur = this.files.get(args.path) ?? Buffer.alloc(0);
this.files.set(args.path, Buffer.concat([cur, incoming]));
} else if (this.corruptFirstWrite && this.writesToTarget === 0) {
// 模拟「写下去了但内容被改坏」:存一份被篡改的内容
this.writesToTarget += 1;
this.files.set(args.path, Buffer.from('CORRUPTED-BY-AV'));
} else {
this.writesToTarget += 1;
this.files.set(args.path, incoming);
}
return { status: 'ok', data: { bytes: incoming.length } };
}
default:
return { status: 'failed', error: { name: 'E_CAPABILITY_NOT_FOUND', message: cap } };
}
}
}
/** 常用夹具:LKG 内容(= 备份内容)与当前内容(不同指纹) */
const LKG_BYTES = Buffer.from('LKG-AGENT-CONTENT-v2.0.3');
const CUR_BYTES = Buffer.from('CURRENT-AGENT-CONTENT-v2.1.0-broken');
const lkgHashFull = createHash('sha256').update(LKG_BYTES).digest('hex');
const lkgHash12 = lkgHashFull.slice(0, 12);
function infoWithLkg(extra = {}) {
return {
agent_script: '/opt/nodeagent/agent.mjs',
build: { hash: createHash('sha256').update(CUR_BYTES).digest('hex').slice(0, 12) },
lkg: {
lkg: {
hash: lkgHash12,
hash_full: lkgHashFull,
bytes: LKG_BYTES.length,
script_path: '/opt/nodeagent/agent.mjs',
backup_glob: 'agent.mjs.bak*',
recorded_at: 1_700_000_000_000,
node_version: 'v22.20.0',
platform: 'linux',
},
current_hash: 'x',
matches: false,
byte_delta: 0,
},
...extra,
};
}
// ---------- 纯函数 ----------
test('planRollback:无 LKG 且无 --backup → no-lkg', () => {
const p = planRollback({ agent_script: '/a/agent.mjs', build: { hash: 'abc' }, lkg: { lkg: null } });
assert.equal(p.action, 'no-lkg');
assert.equal(p.targetHash, '');
});
test('planRollback:当前指纹 == LKG → already-lkg(零写入分支)', () => {
const p = planRollback(infoWithLkg({ build: { hash: lkgHash12 } }));
assert.equal(p.action, 'already-lkg');
assert.equal(p.targetHash, lkgHash12);
});
test('planRollback:给了 --backup → explicit(逃生门优先于一切判断)', () => {
const p = planRollback(infoWithLkg({ build: { hash: lkgHash12 } }), '/x/agent.mjs.bak-123');
assert.equal(p.action, 'explicit');
assert.equal(p.scriptPath, '/opt/nodeagent/agent.mjs');
});
test('planRollback:有 LKG 且当前不同 → match,targetHash 取 hash_full', () => {
const p = planRollback(infoWithLkg());
assert.equal(p.action, 'match');
assert.equal(p.targetHash, lkgHashFull);
});
test('planRollback:老记录无 hash_full → targetHash 回落 12 位', () => {
const info = infoWithLkg();
delete info.lkg.lkg.hash_full;
const p = planRollback(info);
assert.equal(p.action, 'match');
assert.equal(p.targetHash, lkgHash12);
});
test('pickBackupByHash:全量相等/12 位前缀都算命中;空候选返回 null', () => {
const other = createHash('sha256').update('other').digest('hex');
assert.equal(pickBackupByHash([], lkgHashFull), null);
assert.equal(pickBackupByHash([{ path: '/a', hash: other }], lkgHashFull), null);
assert.deepEqual(
pickBackupByHash([{ path: '/a', hash: lkgHashFull }], lkgHashFull),
{ path: '/a', hash: lkgHashFull },
);
assert.deepEqual(
pickBackupByHash([{ path: '/a', hash: lkgHashFull }], lkgHash12),
{ path: '/a', hash: lkgHashFull },
);
});
test('pickBackupByHash:mtime 新→早优先(顺序由调用方排好,本函数取第一个命中)', () => {
const stale = createHash('sha256').update('stale').digest('hex');
const cands = [
{ path: '/new.bak', hash: stale, mtime: 200 },
{ path: '/old.bak', hash: lkgHashFull, mtime: 100 },
];
assert.deepEqual(pickBackupByHash(cands, lkgHashFull), { path: '/old.bak', hash: lkgHashFull });
});
test('hashMatches:空值与仅前缀长于全长都不算命中', () => {
assert.equal(hashMatches('', lkgHash12), false);
assert.equal(hashMatches(lkgHashFull, ''), false);
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');
const re = backupNameRegex('index.js');
assert.ok(re.test('index.js.bak'));
assert.ok(re.test('index.js.bak-1729300000000'));
assert.ok(!re.test('index.js.bakX'), '后缀必须整段匹配');
assert.ok(!re.test('other.js.bak'));
// 文件名含正则元字符(+ . 等)时必须按字面匹配
const re2 = backupNameRegex('a+b.c');
assert.ok(re2.test('a+b.c.bak'));
assert.ok(!re2.test('aab.c.bak'), '元字符必须被转义');
});
test('hashRemoteFile:分块流式累加,指纹与整文件一次性 sha256 一致', async () => {
const big = Buffer.alloc(CHUNK * 2 + 123, 7);
const agent = new FakeAgent({ '/x/agent.mjs.bak': big });
const h = await hashRemoteFile(agent, '/x/agent.mjs.bak');
assert.equal(h.hash, createHash('sha256').update(big).digest('hex'));
assert.equal(h.bytes, big.length);
});
// ---------- 编排(runRollback,client 注入) ----------
test('无 LKG:planRollback 判 no-lkg,且不发起任何 fs 调用', async () => {
const agent = new FakeAgent({
__info__: { agent_script: '/opt/nodeagent/agent.mjs', build: { hash: 'abc' }, lkg: { lkg: null } },
});
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 2);
assert.equal(r.plan.action, 'no-lkg');
assert.deepEqual(
agent.calls.map((c) => c.cap),
['system.info'],
'无 LKG 时必须停在 system.info(零改动)',
);
});
test('指纹已匹配(already-lkg):零写入、退出 0', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg({ build: { hash: lkgHash12 } }),
'/opt/nodeagent/agent.mjs': LKG_BYTES,
});
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 0);
assert.equal(r.plan.action, 'already-lkg');
assert.deepEqual(
agent.calls.filter((c) => c.cap === 'fs.write' || c.cap === 'system.agent.restart'),
[],
'已是 LKG 时不允许任何写/重启',
);
});
test('正常回滚:指纹命中 → 覆盖写入 → 写后校验通过 → 待重启', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': LKG_BYTES,
});
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 0);
assert.equal(r.needsRestart, true);
assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak');
assert.equal(r.restored, false);
// 入口内容确实被替换成 LKG 内容
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES);
// 写后校验走过一次 fs.read 回读
const reads = agent.calls.filter((c) => c.cap === 'fs.read' && c.args.path === '/opt/nodeagent/agent.mjs');
assert.ok(reads.length >= 1, '必须有写后回读校验');
});
test('写后校验失败:自动用回滚源还原,退出码 1', async () => {
const agent = new FakeAgent(
{
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': LKG_BYTES,
},
{ corruptFirstWrite: true },
);
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 1, '写后校验失败必须报执行失败');
assert.equal(r.restored, true, '必须执行还原写');
assert.equal(r.needsRestart, false, '校验失败不得进入重启');
// 最终入口内容 == 回滚源内容(还原生效,没把坏内容留在入口上)
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES);
const writes = agent.calls.filter((c) => c.cap === 'fs.write' && c.args.path === '/opt/nodeagent/agent.mjs');
assert.ok(writes.length >= 2, '应有「回滚写 + 还原写」两次写入');
});
test('无匹配备份:列候选指纹、零写退出(exit 2)', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': Buffer.from('UNRELATED-CONTENT'),
});
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 2);
assert.equal(r.source, null);
assert.equal(r.candidates.length, 1, '候选指纹必须带回供排查');
assert.ok(!r.error.includes('undefined'));
assert.deepEqual(
agent.calls.filter((c) => c.cap === 'fs.write'),
[],
'无匹配时一个字节都不许写',
);
});
test('--dry-run:只读 invoke(system.info/fs.list/fs.read),零写零重启', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': LKG_BYTES,
});
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true, dryRun: true });
assert.equal(r.code, 0);
assert.equal(r.needsRestart, false);
assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak');
const caps = new Set(agent.calls.map((c) => c.cap));
assert.deepEqual([...caps].sort(), ['fs.list', 'fs.read', 'system.info']);
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), CUR_BYTES, 'dry-run 不许动入口');
});
test('--backup 逃生门:跳过 LKG/指纹匹配,以指定备份为源与期望指纹', async () => {
const agent = new FakeAgent({
__info__: { agent_script: '/opt/nodeagent/agent.mjs', build: { hash: 'zzz' }, lkg: { lkg: null } },
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/backups/agent.mjs.bak-123': LKG_BYTES,
});
const r = await runRollback(agent, { json: true, backup: '/opt/backups/agent.mjs.bak-123' });
assert.equal(r.code, 0);
assert.equal(r.plan.action, 'explicit');
assert.equal(r.needsRestart, true);
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES);
});
test('--backup 指向不存在的文件:前提不满足退出 2,不写入口', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
});
const r = await runRollback(agent, { json: true, backup: '/opt/backups/none.bak' });
assert.equal(r.code, 2);
assert.deepEqual(
agent.calls.filter((c) => c.cap === 'fs.write'),
[],
);
});
test('多候选按 mtime 新→早:旧备份才是 LKG 时也能命中(不被新备份抢先)', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': Buffer.from('NEWER-BUT-UNRELATED'),
'/opt/nodeagent/agent.mjs.bak-1000': LKG_BYTES,
});
agent.mtimes = new Map([
['/opt/nodeagent/agent.mjs.bak', 500],
['/opt/nodeagent/agent.mjs.bak-1000', 100],
]);
const r = await runRollback(agent, { json: true, dryRun: true });
assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak-1000');
});