P0-1 被控端自更新境内必然失败(裸 fetch 不读代理): - 新增 apps/agent/src/net/proxy.ts + index.ts:sidecar 已验证出网方案的 TS 移植(NA_PROXY → 环境变量 → 系统代理;PAC/socks 明确报错不静默降级; 重定向链共享超时预算;CONNECT 自管 abort;TLS 默认校验证书)。 只依赖 node 内置模块,不 import 项目内任何模块(依赖严格单向) - selfupdate.download 改走代理;成功/失败均带回 via/proxy/proxy_source/ outbound_notes 诊断;错误分类同款移植 sidecar main.mjs:146-165 (ECONNRESET 不得报成「请求超时」——真机教训) - httpGet 增 maxBytes 双闸(content-length 预检 + 流式中断), 保住「64MB 上限」安全属性 - 回环目标恒直连(CI/dev 机 HTTP_PROXY 不得误送自建镜像进代理—— e2e 实踩:代理另一侧的 localhost 不是本机 → 404) A-P0-3 LKG 只有指纹没有内容指针: - LkgRecord 只增 hash_full(64 位全量)+ backup_glob(.bak*)两字段; 新增 lkgHashMatches(全量优先、老记录回落 12 位前缀,不猜) - selfupdate 备份保留最近 3 个(planBackupCleanup 纯函数,最旧先删) P1-4 system.audit.l2 returns_schema 空壳 → 四 action 属性并集补全; AgentUpdate returns 增 via/proxy/proxy_source/outbound_notes 四个只增字段 安全属性逐条不变:sha256 强制校验 / 先校验后落盘 / 写后校验失败回滚 / TLS 默认校验证书 / 只接受 http(s) 测试:新增 agent-proxy 17 例 + selfupdate-backup-cleanup 8 例 + lkg 新增 4 例;单测 403/403、e2e 33/33、typecheck 全绿
130 lines
5.7 KiB
JavaScript
130 lines
5.7 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { createHash } from 'node:crypto';
|
||
|
||
/**
|
||
* LKG(最后已知可用版本)守卫 —— 交付流水线 §6。
|
||
*
|
||
* 为什么需要守卫:LKG 是回滚的**目标**。若它记错了(记成尚未验证的版本),
|
||
* 回滚就会回到一个坏版本 —— 比没有 LKG 更糟,因为它给人虚假的安全感。
|
||
* 故这里钉死三条:
|
||
* 1. 记录内容必须与当前入口脚本真实一致;
|
||
* 2. 文件不存在/损坏时**返回 null,不做任何猜测**;
|
||
* 3. matches 的判定必须用同一套 hash 口径(与 system.info 的 build.hash 同)。
|
||
*/
|
||
|
||
// 通过 NODEAGENT_HOME 指向临时目录,避免污染真实数据目录
|
||
const dir = mkdtempSync(join(tmpdir(), 'na-lkg-'));
|
||
process.env['NODEAGENT_HOME'] = dir;
|
||
|
||
const { recordLKG, readLKG, lkgStatus, lkgFilePath, lkgHashMatches } = await import('../../apps/agent/dist/lkg.js');
|
||
|
||
test('LKG 落在 NODEAGENT_HOME 指定的数据目录', () => {
|
||
assert.ok(lkgFilePath().startsWith(dir), `LKG 路径应在临时目录内,实际: ${lkgFilePath()}`);
|
||
});
|
||
|
||
test('未记录时 readLKG 返回 null(不猜测)', () => {
|
||
assert.equal(readLKG(), null);
|
||
});
|
||
|
||
test('记录后可读回,且 hash 与当前入口脚本真实一致', () => {
|
||
const rec = recordLKG();
|
||
assert.ok(rec, 'recordLKG 应返回记录');
|
||
const back = readLKG();
|
||
assert.ok(back, '应能读回');
|
||
assert.equal(back.hash, rec.hash);
|
||
assert.equal(back.bytes, rec.bytes);
|
||
|
||
// 独立重算:与直接对 argv[1] 求 sha256 前 12 位比对
|
||
const p = process.argv[1];
|
||
const expect = createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 12);
|
||
assert.equal(back.hash, expect, 'LKG hash 必须等于入口脚本真实 sha256 前 12 位');
|
||
});
|
||
|
||
test('lkgStatus.matches 在记录后为 true,byte_delta 为 0', () => {
|
||
recordLKG();
|
||
const s = lkgStatus();
|
||
assert.equal(s.matches, true);
|
||
assert.equal(s.byte_delta, 0);
|
||
assert.equal(s.current_hash, s.lkg.hash);
|
||
});
|
||
|
||
test('LKG 文件损坏时 readLKG 返回 null 而非抛错', () => {
|
||
recordLKG();
|
||
writeFileSync(lkgFilePath(), '{ this is not json', 'utf8');
|
||
assert.equal(readLKG(), null, '损坏的 LKG 必须返回 null,不得抛错或猜值');
|
||
});
|
||
|
||
test('LKG 记录缺少 hash 字段时视为无效', () => {
|
||
writeFileSync(lkgFilePath(), JSON.stringify({ bytes: 1 }), 'utf8');
|
||
assert.equal(readLKG(), null);
|
||
});
|
||
|
||
test('故障注入:篡改 LKG 的 hash 会被 matches=false 检出', () => {
|
||
recordLKG();
|
||
const good = readLKG();
|
||
// 模拟「更新后还没起来」:LKG 仍是旧值,而当前脚本已被替换成别的 hash
|
||
writeFileSync(
|
||
lkgFilePath(),
|
||
JSON.stringify({ ...good, hash: 'deadbeefcafe' }, null, 2),
|
||
'utf8',
|
||
);
|
||
const s = lkgStatus();
|
||
assert.equal(s.matches, false, 'hash 不一致必须检出 matches=false(这是「暂缓放量」的判据)');
|
||
assert.ok(s.lkg, 'LKG 记录本身仍应可读,供控制端定位回滚目标');
|
||
});
|
||
|
||
test('LKG 记录含回滚所需的全部字段', () => {
|
||
recordLKG();
|
||
const r = readLKG();
|
||
for (const k of ['hash', 'bytes', 'script_path', 'recorded_at', 'node_version', 'platform']) {
|
||
assert.ok(k in r, `LKG 缺少回滚所需字段: ${k}`);
|
||
}
|
||
assert.ok(existsSync(lkgFilePath()), 'LKG 文件应真实落盘');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// v2.1.0 新增字段(只增):hash_full + backup_glob —— 回滚内容定位用
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('v2.1.0:recordLKG 写 hash_full(64 位)与 backup_glob', () => {
|
||
const rec = recordLKG();
|
||
assert.ok(rec, 'recordLKG 应返回记录');
|
||
assert.match(rec.hash_full, /^[0-9a-f]{64}$/, 'hash_full 必须是完整 sha256(64 位十六进制)');
|
||
assert.equal(rec.hash, rec.hash_full.slice(0, 12), '12 位 hash 必须是 hash_full 的前缀(同口径)');
|
||
// 独立重算:与直接对 argv[1] 求 sha256 全量比对
|
||
const p = process.argv[1];
|
||
const full = createHash('sha256').update(readFileSync(p)).digest('hex');
|
||
assert.equal(rec.hash_full, full, 'hash_full 必须等于入口脚本真实 sha256');
|
||
assert.match(rec.backup_glob, /\.bak\*$/, 'backup_glob 应为 <basename>.bak*(覆盖 deploy 固定名与 selfupdate 时间戳名)');
|
||
|
||
const back = readLKG();
|
||
assert.equal(back.hash_full, full, '读回的记录必须保留 hash_full');
|
||
assert.equal(back.backup_glob, rec.backup_glob);
|
||
});
|
||
|
||
test('v2.1.0:lkgHashMatches 有 hash_full 按全量比', () => {
|
||
const rec = recordLKG();
|
||
const full = rec.hash_full;
|
||
assert.equal(lkgHashMatches(rec, full), true);
|
||
const flipped = `${'0'.repeat(63)}${full.slice(-1) === '0' ? '1' : '0'}`;
|
||
assert.equal(lkgHashMatches(rec, flipped), false, '全量不一致必须不中');
|
||
});
|
||
|
||
test('v2.1.0:老记录(无 hash_full)回落 12 位前缀比对,不猜测', () => {
|
||
// 模拟 v2.1.0 之前写的 LKG 文件:只有 12 位 hash
|
||
const p = process.argv[1];
|
||
const full = createHash('sha256').update(readFileSync(p)).digest('hex');
|
||
writeFileSync(lkgFilePath(), JSON.stringify({ hash: full.slice(0, 12), bytes: 1, script_path: p }), 'utf8');
|
||
const old = readLKG();
|
||
assert.ok(old, '老记录必须仍可读');
|
||
assert.equal(old.hash_full, undefined, '老记录没有 hash_full');
|
||
assert.equal(lkgHashMatches(old, full), true, '回落:12 位前缀命中');
|
||
assert.equal(lkgHashMatches(old, 'f'.repeat(64)), false, '前缀不中必须不中');
|
||
assert.equal(lkgHashMatches(null, full), false, '无记录不猜');
|
||
assert.equal(lkgHashMatches(old, undefined), false, '无候选值不猜');
|
||
});
|