Files
nodeagent/tests/unit/ps1-encoding.test.mjs
T
jkooe 25f889c6d8 fix(test): ps1-encoding 守卫改用 fileURLToPath(Windows 上 URL.pathname 会多一根前导斜杠)
**这是 Windows CI 一上线就抓到的真 bug** —— 新加的 CI 单测步骤当场回本。

根因:测试用 new URL('../../', import.meta.url).pathname 取仓库根。macOS 上得到
/Users/... 可用;Windows 上得到 /D:/a/nodeagent/...(盘符前多一根斜杠),
readdirSync 直接 ENOENT → 整个文件 22 个用例全灭。
本地(macOS)永远测不出来,只有 Windows runner 会炸。

修法:改用 fileURLToPath(new URL(...)) —— Node 官方的跨平台转换。
全仓已无同类残留。本地 195 单测 / 29 e2e 复跑全绿。
2026-10-07 02:00:10 +08:00

222 lines
9.6 KiB
JavaScript
Raw 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.
/**
* 编码守卫:仓库里的手写 .ps1 **必须带 UTF-8 BOM**。
*
* ⚠️ 背景(真机踩过,2026-10-04):`install.ps1` 一度是无 BOM 的 UTF-8,
* 在被控端用 **Windows PowerShell 5.1** 执行时直接解析失败:
* 所在位置 D:\Nodeagent\install.ps1:31 字符: 31
* + [string]$ProjectRoot = "",
* “,”后面缺少表达式。
* 原因:5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK)解码,
* 脚本里的中文注释/字符串被解成乱码,其中某些字节序列被误认成引号,
* 于是整份脚本的字符串边界全乱 —— 报出的一串语法错误彼此无关,
* 真正的病灶在文件靠前的位置。
*
* **CI 之前全绿是因为冒烟步骤用 `shell: pwsh`(PowerShell 7,认无 BOM UTF-8)**,
* 只有真机的 5.1 会炸。故此用例 + CI 的 5.1 解析冒烟一起守。
*
* 对照:运行时**动态生成**的 .ps1 走 `apps/agent/src/capabilities/network.ts`
* 的 `withBom()`,已有同样约束。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('../../', import.meta.url));
const PS_DIR = join(ROOT, 'scripts');
/** 收集仓库里所有需要分发的 .ps1(含子目录) */
function collectPs1(dir) {
return collectByExt(dir, '.ps1');
}
/** 收集仓库里所有 .cmd(双击入口) */
function collectCmd(dir) {
return collectByExt(dir, '.cmd');
}
function collectByExt(dir, ext) {
const out = [];
for (const name of readdirSync(dir, { withFileTypes: true })) {
if (name.name === 'node_modules' || name.name.startsWith('.')) continue;
const full = join(dir, name.name);
if (name.isDirectory()) out.push(...collectByExt(full, ext));
else if (name.name.endsWith(ext)) out.push(full);
}
return out;
}
const PS1_FILES = collectPs1(PS_DIR);
test('仓库中至少存在 3 个待分发的 .ps1(防止 glob 失效导致守卫空转)', () => {
assert.ok(
PS1_FILES.length >= 3,
`只找到 ${PS1_FILES.length} 个 .ps1,守卫可能已失效(目录:${PS_DIR})`,
);
});
for (const file of PS1_FILES) {
const rel = file.slice(ROOT.length);
test(`${rel}:以 UTF-8 BOM 开头(PowerShell 5.1 兼容底线)`, () => {
const buf = readFileSync(file);
const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
assert.ok(
hasBom,
`${rel} 缺少 UTF-8 BOM。5.1 会按 ANSI/GBK 解析无 BOM 脚本,` +
`中文注释将破坏字符串边界并导致 ParseError。` +
`修法:把 U+FEFF 写进文件头(BOM 对 .ps1 是必需,对 JSON 则相反)。`,
);
});
test(`${rel}:内容是合法 UTF-8(无 GBK 混入的二次编码痕迹)`, () => {
const text = readFileSync(file, 'utf8');
// U+FFFD = 解码失败替换符;出现即说明混入过非 UTF-8 字节
assert.ok(!text.includes('\uFFFD'), `${rel} 含非法 UTF-8 字节`);
// 去掉 BOM 再校验,避免把 BOM 当成正文
assert.ok(text.replace(/^\uFEFF/, '').length > 0, `${rel} 去掉 BOM 后为空文件`);
});
}
test('install.ps1:关键参数与提示文案完好(防打包时被截断/改写)', () => {
const text = readFileSync(join(PS_DIR, 'install.ps1'), 'utf8');
for (const token of [
'param(',
'$AllowInput',
'New-ScheduledTask',
'New-NetFirewallRule',
'UTF8Encoding($false)', // 写 agent.json 必须无 BOM —— 与 .ps1 自身要 BOM 恰好相反
]) {
assert.ok(text.includes(token), `install.ps1 丢失关键片段:${token}`);
}
});
// ── .cmd 侧守卫 ───────────────────────────────────────────────────────────────
// 编码方向与 .ps1 **相反**:.cmd 必须纯 ASCII。
// CMD.exe 的代码页(中文 Windows = 936/GBK)无法可靠往返 UTF-8,
// .cmd 里的中文会变乱码,甚至把后续命令截断(真机 2026-10-04 的教训同源)。
// 中文提示一律交给带 BOM 的 .ps1 输出。
const CMD_FILES = collectCmd(PS_DIR);
test('仓库中至少存在 2 个 .cmd(防止 glob 失效导致守卫空转)', () => {
assert.ok(CMD_FILES.length >= 2, `只找到 ${CMD_FILES.length} 个 .cmd,守卫可能已失效`);
});
for (const file of CMD_FILES) {
const rel = file.slice(ROOT.length);
test(`${rel}:纯 ASCII(CMD 代码页安全)`, () => {
const buf = readFileSync(file);
const idx = [...buf].findIndex((b) => b > 0x7f);
assert.equal(
idx, -1,
`${rel} 第 ${idx} 字节起含非 ASCII —— CMD 代码页(936/GBK)无法可靠处理,` +
`中文提示请交给 .ps1(它带 BOM,5.1 能正确读)。`,
);
});
test(`${rel}:行尾是 CRLF(CMD 在部分环境下对 LF 行为不一致)`, () => {
const text = readFileSync(file, 'latin1');
// 允许文件内完全没有换行(极短脚本),但只要有换行就必须是 CRLF
const lfOnly = text.replace(/\r\n/g, '').includes('\n');
assert.equal(lfOnly, false, `${rel} 存在裸 LF 行尾,请统一为 CRLF`);
});
}
test('install.cmd:自提权 + 读 PSK.txt + 调 install.ps1 三段齐全', () => {
const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1');
for (const token of [
'net session', // 探测是否已管理员
'Start-Process', // 自提权(UAC)
'PSK.txt', // 读包内密钥
'install.ps1', // 调底层安装
'-ExecutionPolicy Bypass',
'-AllowInput',
]) {
assert.ok(text.includes(token), `install.cmd 丢失关键片段:${token}`);
}
// 提权后必须 exit,不能继续用非管理员权限往下跑
assert.ok(/Verb RunAs[\s\S]{0,200}exit \/b/.test(text), '提权后应立即退出本进程');
});
test('control.cmd:菜单项与 control.ps1 的 ValidateSet 一一对应', () => {
const cmd = readFileSync(join(PS_DIR, 'control.cmd'), 'latin1');
const ps1 = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8');
// 从 control.ps1 的 ValidateSet 里取出合法 action
const m = ps1.match(/ValidateSet\(([^)]*)\)/);
assert.ok(m, 'control.ps1 必须用 ValidateSet 约束 -Action');
const actions = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter(Boolean);
// control.cmd 里 call :RUN <action> 的每个 action 都必须在 ValidateSet 内
const called = [...cmd.matchAll(/call :RUN (\S+)/g)].map((x) => x[1]);
assert.ok(called.length >= 5, `control.cmd 只发现 ${called.length} 个菜单项,可能正则失配`);
for (const a of called) {
assert.ok(actions.includes(a), `control.cmd 调用了 control.ps1 不支持的 action: ${a}`);
}
// 反向:ps1 支持的 action 都应在菜单里可达
for (const a of actions) {
assert.ok(called.includes(a), `control.ps1 支持的 ${a} 在 control.cmd 菜单里没有入口`);
}
});
test('control.ps1:状态检查覆盖任务/进程/端口/配置四项', () => {
const text = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8');
for (const token of [
'Get-ScheduledTask',
'Get-Process',
'Get-NetTCPConnection',
'agent.json',
]) {
assert.ok(text.includes(token), `control.ps1 状态检查缺少:${token}`);
}
// 启动后必须轮询端口,而不是盲等固定秒数
assert.ok(
/for \(\$i = 0; \$i -lt 15/.test(text),
'启动后应轮询端口真正监听(最多 15s),而非盲等',
);
});
test('数据目录:install.ps1 与 control.ps1 都认 NODEAGENT_HOME(须与 agent 侧一致)', () => {
// apps/agent/src/config.ts: agentDir() = NODEAGENT_HOME ?? ~/.nodeagent
// 若 ps1 写死 USERPROFILE 而计划任务环境设了 NODEAGENT_HOME,
// 就会「配置写 A、读取 B」→ 装完连不上(E_AUTH_FAILED)。
for (const f of ['install.ps1', 'control.ps1']) {
const text = readFileSync(join(PS_DIR, f), 'utf8');
assert.ok(
text.includes('NODEAGENT_HOME'),
`${f} 未处理 NODEAGENT_HOME —— 必须与 apps/agent/src/config.ts 的 agentDir() 保持一致`,
);
}
});
test('控制台脚本:pause 均被 CI 守卫(否则 CI 会永久挂起)', () => {
for (const f of ['install.cmd', 'control.cmd']) {
const text = readFileSync(join(PS_DIR, f), 'latin1');
const bare = text.split('\r\n').filter((l) => /^\s*pause\s*$/i.test(l));
assert.equal(
bare.length, 0,
`${f} 有 ${bare.length} 处裸 pause,CI(CI=true)会卡死。统一用 "if not defined CI pause"。`,
);
}
});
test('install.cmd:管理员探测用 fltmc 而非 net session(不依赖 Server 服务)', () => {
const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1');
// net session 依赖 Server 服务,部分 Windows 版本该服务停用 → 已提权却被判为未提权 → 死循环 UAC
assert.ok(/^\s*fltmc\s*>nul/m.test(text), 'install.cmd 应用 fltmc 探测管理员权限');
assert.ok(
!/^\s*net\s+session\s*>nul/m.test(text),
'install.cmd 不应再用 net session 探测(依赖 Server 服务,可能误判)',
);
});
// ---------- 安全:公开的安装包不得含密钥 ----------
test('pack.mjs 打 zip 必须排除 PSK.txt(zip 作为公开 Release 资产发布)', () => {
const pack = readFileSync(join(ROOT, 'scripts', 'pack.mjs'), 'utf8');
const zipLine = pack.split('\n').find((l) => l.includes('zip -q -r'));
assert.ok(zipLine, '找不到打 zip 的命令行');
assert.match(zipLine, /-x\s+"?PSK\.txt"?/, '必须 -x 排除 PSK.txt,否则预共享密钥随公开资产泄露');
});