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'); });