fix(agent): 限速桶 key 改能力名 + describeArgs 截断(A-P0-2 / A-P0-4)

架构审查两个 P0:

A-P0-2 限速桶 key 用错值(server.ts:364):
原实现 `allowByRate(clientId, limit, String(limit))` 把**限速值**
当桶名——ACL 支持按能力限速(精确 > glob > 全局),配
「screen.capture ≤5/min、app.install ≤5/min」两条同值规则时
两者 key 都是 `clientId|5` 共用一个桶:截图打满后装软件被误伤,
且两者总量仍按 5/min(漏过)。同时 rateBuckets 只增不删 +
psk 模式 client_id 自报 → 外部可枚举任意 client_id 造键 →
长期运行内存无界增长。
- 抽到 util/rate-limit.ts:key = `${认证身份 ?? 远端地址}:${能力名}`
  (未认证时退化按远端地址,不用空串把所有人混为一桶)
- 过期键清扫(滑动窗口边界与旧实现逐字节一致:now - t < 60_000
  才算窗口内)+ 容量上限淘汰最旧(Map 插入序 = 时间序)
- e2e limited 客户端改配两个同限速值能力(各 2/min),
  打满 system.info 后 system.audit.list 必须仍有完整配额

A-P0-4 describeArgs 大参数同步序列化阻塞事件循环(audit.ts:172):
原实现每次 invoke 全量 JSON.stringify + sha256。fs.write 的 data
可达数 MB——同步序列化 + 哈希 ≈ 数十毫秒级 CPU 且阻塞事件循环,
期间 GUI 注入、事件推送、保活 ping 全部延迟。
- 超过 64KB 只哈希前 64KB(按**字节**切:多字节字符下按字符切
  会得到非确定性边界),总长记 args_bytes、截断记 args_truncated
- 口径从「全量哈希」变为「前 64KB 哈希」只影响审计摘要,
  不影响任何安全属性(摘要用途是比对与防泄密,不是完整性证明)

测试(均故障注入实测会红):
- rate-limit 6 例:桶名是能力名 / 身份进 key / 不限速不建桶 /
  过期键清扫(59.999s 窗口内、恰好 60s 出窗)/ 容量上限 /
  同值多能力各占一桶
- audit-args-digest 6 例:小参数口径不回退 / 大参数截断口径 /
  按字节切(字符切口径必须不同)/ 尾部不进摘要 / 端到端入链
  校验通过 / logArgs 默认关不产预览
- e2e 注入验证:桶名退回 String(rateLimit) → v3 场景红;恢复 → 绿

单测 445/445、e2e 33/33、typecheck 全绿
This commit is contained in:
jkooe committed 2026-10-26 00:14:17 +08:00
1 parent 68ea3cd20e
commit 560b3c44e4
6 files changed
+368 -24

No files matched your search

+33 -4
View File
@@ -72,6 +72,13 @@ export interface AuditEntry {
error?: string | null;
/** 参数摘要(sha256 前缀),用于比对而不泄露内容 */
args_digest?: string;
/**
* v2.1.0(A-P0-4):参数序列化后的**总字节数**。
* 与 `args_digest_truncated` 同时出现即表示「摘要只覆盖前 64KB」。
*/
args_bytes?: number;
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB)。 */
args_truncated?: boolean;
/** 参数预览(默认不记录;开启后自动脱敏) */
args_preview?: string;
reason?: string;
@@ -168,12 +175,34 @@ function sanitize(value: unknown, depth = 0): unknown {
return value;
}
/**
* 参数摘要阈值(字节):超过即只哈希前 64KB。
*
* ⚠️ A-P0-4(2026-10-25):原实现每次 invoke 都全量 `JSON.stringify` + sha256。
* `fs.write` 的 data 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)——
* 同步序列化 + 哈希一个 8MB 字符串 ≈ 数十毫秒级 CPU,且**阻塞事件循环**:
* 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。
* 截断后:摘要口径从「全量哈希」变为「前 64KB 哈希」,**只影响审计摘要,
* 不影响任何安全属性**(摘要用途是比对与去泄密,从来不是完整性证明)。
* 总长记入 `args_bytes`,截断记入 `args_truncated`,语义不断代。
*/
const ARGS_DIGEST_MAX_BYTES = 64 * 1024;
/** 生成参数摘要与(可选)脱敏预览 —— 字段名与 AuditEntry 对齐,便于展开。 */
export function describeArgs(args: unknown): { args_digest: string; args_preview?: string } {
export function describeArgs(args: unknown): {
args_digest: string;
args_bytes?: number;
args_truncated?: boolean;
args_preview?: string;
} {
const json = JSON.stringify(args ?? {});
const digest = `sha256:${createHash('sha256').update(json).digest('hex').slice(0, 16)}`;
if (!opts.logArgs) return { args_digest: digest };
return { args_digest: digest, args_preview: JSON.stringify(sanitize(args)) };
const bytes = Buffer.byteLength(json, 'utf8');
// 截断按**字节**切(多字节字符下按字符切会得到非确定性的 64KB 边界)
const payload = bytes > ARGS_DIGEST_MAX_BYTES ? Buffer.from(json, 'utf8').subarray(0, ARGS_DIGEST_MAX_BYTES) : json;
const digest = `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
const base = bytes > ARGS_DIGEST_MAX_BYTES ? { args_bytes: bytes, args_truncated: true } : {};
if (!opts.logArgs) return { args_digest: digest, ...base };
return { args_digest: digest, ...base, args_preview: JSON.stringify(sanitize(args)) };
}
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
+14 -16
View File
@@ -32,6 +32,7 @@ import { createCapabilityRegistry } from './capabilities/index.js';
import { audit, describeArgs } from './audit.js';
import { disposeWatchesByOwner, watchCount } from './events.js';
import { runningTaskCount } from './capabilities/task.js';
import { createRateLimiter } from './util/rate-limit.js';
import type { AgentConfig } from './config.js';
import type { TlsMaterial } from './certs.js';
@@ -96,21 +97,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
*/
const hasAcl = Boolean(cfg.acl);
/** 按调用方的滑动窗口限速(每分钟)。 */
const rateBuckets = new Map<string, number[]>();
function allowByRate(clientId: string, limitPerMin?: number, bucket?: string): boolean {
if (!limitPerMin || limitPerMin <= 0) return true;
const key = bucket ? `${clientId}|${bucket}` : clientId;
const now = Date.now();
const recent = (rateBuckets.get(key) ?? []).filter((t) => now - t < 60_000);
if (recent.length >= limitPerMin) {
rateBuckets.set(key, recent);
return false;
}
recent.push(now);
rateBuckets.set(key, recent);
return true;
}
/**
* 按调用方的滑动窗口限速(每分钟)。
*
* ⚠️ A-P0-2(2026-10-25):原实现 `allowByRate(clientId, limit, String(limit))`
* 把**限速值**当桶名 —— 同限速值的不同能力共用一个桶(互相挤占/漏过),
* 且 `rateBuckets` 只增不删(psk 模式 client_id 自报 → 可枚举造键 → 内存无界增长)。
* 现抽到 `util/rate-limit.ts`:桶名 = 能力名;过期键顺手删 + 容量上限淘汰最旧。
*/
const rateLimiter = createRateLimiter();
const levelOrder: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2 };
const log = (level: LogLevel, msg: string): void => {
if (levelOrder[level] >= levelOrder[cfg.log_level]) {
@@ -360,8 +355,11 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
}
// 限速键 = `${认证身份 ?? 远端地址}:${能力名}`:
// ① 不同能力各占一桶(同限速值不再互挤/漏过);
// ② 未认证身份(psk 自报缺失)时退化按远端地址,不用空串把所有人混为一桶。
const rateLimit = clientRule ? rateLimitFor(clientRule, name) : undefined;
if (!allowByRate(state.clientId ?? '', rateLimit, String(rateLimit ?? ''))) {
if (!rateLimiter.allow(state.clientId ?? state.remote, rateLimit, name)) {
log('warn', `限速拒绝 client_id=${state.clientId} capability=${name}`);
audit({
type: 'rate.limited',
+83
View File
@@ -0,0 +1,83 @@
/**
* 按调用方的滑动窗口限速器(每分钟)。
*
* ## 为什么从 server.ts 抽出来(A-P0-2,2026-10-25)
* 原实现有两处缺陷,且逻辑埋在 `createAgentCore` 闭包里无法单测:
*
* 1. **桶 key 用错值**:原 `allowByRate(clientId, limit, String(limit))` 第三参
* (bucket)传的是**限速值本身**。于是「screen.capture ≤ 5/min」与
* 「app.install ≤ 5/min」两条同值规则共用 `clientId|5` 一个桶:
* 截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
* 正确口径:key = `${身份}:${能力名}`。
* 2. **桶只增不删**:`rateBuckets` Map 无任何清扫,key 永久留存。而 psk 模式下
* `client_id` 由客户端**自报**(配置级约束)→ 外部可枚举任意 client_id 造键
* → 长期运行内存无界增长。现在:每次允许判定顺手删过期键 + 容量上限淘汰最旧。
*
* ## 语义
* - 窗口 60s 滑动:只数「最近 60 秒内」的调用;
* - `limitPerMin` 为空/≤0 表示不限速(直接放行,**不建桶** —— 与旧行为一致);
* - 身份用「认证身份 ?? 远端地址」:psk 自报 client_id 不可信,但限速键仍以它为纲
* (配了限速就说明该 client_id 已在 ACL 里登记),无身份的调用方退化按远端地址。
*/
/** 桶容量上限:超出淘汰最旧(Map 保持插入顺序,首键即最旧)。 */
const DEFAULT_MAX_KEYS = 10_000;
const WINDOW_MS = 60_000;
export interface RateLimiter {
/**
* 请求一次调用配额。
*
* @param identity 调用方身份(认证身份 ?? 远端地址)
* @param limitPerMin 每分钟上限;空/≤0 = 不限速
* @param bucket 桶名(能力名)—— 不同能力各占一桶,互不挤占
*/
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean;
/** 当前桶数(供测试与观测)。 */
size(): number;
}
export interface RateLimiterOptions {
/** 桶容量上限(超出淘汰最旧)。默认 10_000。 */
maxKeys?: number;
/** 时钟注入(单测用假时钟推进 60s 窗口,不必真等)。 */
now?: () => number;
}
export function createRateLimiter(opts: RateLimiterOptions = {}): RateLimiter {
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS;
const now = opts.now ?? Date.now;
const buckets = new Map<string, number[]>();
return {
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean {
if (!limitPerMin || limitPerMin <= 0) return true;
const key = `${identity}|${bucket}`;
const t = now();
// 清扫①:过期键直接删(只增不删 = 内存无界增长;自报 client_id 可枚举造键)
for (const [k, times] of buckets) {
if (times.length === 0 || t - times[times.length - 1]! >= WINDOW_MS) buckets.delete(k);
}
// 清扫②:容量上限,超出淘汰最旧(插入序 = 时间序,首键最旧)
while (buckets.size >= maxKeys) {
const oldest = buckets.keys().next();
if (oldest.done) break;
buckets.delete(oldest.value);
}
const recent = (buckets.get(key) ?? []).filter((ts) => t - ts < WINDOW_MS);
if (recent.length >= limitPerMin) {
buckets.set(key, recent);
return false;
}
recent.push(t);
buckets.set(key, recent);
return true;
},
size(): number {
return buckets.size;
},
};
}
+19 -4
View File
@@ -335,11 +335,13 @@ async function testEd25519Acl() {
deny: [],
},
{
// 用于验证限速:每分钟仅 2 次
// 用于验证限速:每分钟仅 2 次。
// A-P0-2:两个能力配**相同**限速值 2 —— 旧实现把限速值当桶名
// (key=`limited|2`)会共桶互挤;修正后各能力独占一桶。
client_id: 'limited',
pubkey: kp.publicKey,
allow: ['system.info'],
max_calls_per_min: 2,
allow: ['system.info', 'system.audit.list'],
rate_limits: { 'system.info': 2, 'system.audit.list': 2 },
},
],
},
@@ -407,7 +409,7 @@ async function testEd25519Acl() {
assert.ok(withPreview, '开启 log_args 后应有参数预览');
c.close();
// 4. 限速:limited 配额 2/分钟,第 3 次应被拒
// 4. 限速:limited 每个能力 2/分钟,第 3 次应被拒
const lim = mk('limited', kp.privateKey);
await lim.connect();
const r1 = await lim.invoke('system.info');
@@ -421,6 +423,19 @@ async function testEd25519Acl() {
return true;
},
);
// A-P0-2 关键断言:system.info 已打满 2/分钟,但同限速值的 system.audit.list
// 必须仍有完整配额(旧实现共桶 → 此处第 1 次即被 -32407 误伤)。
const a1 = await lim.invoke('system.audit.list', { limit: 5 });
assert.equal(a1.status, 'ok', '同限速值的另一能力不得被挤占(A-P0-2)');
const a2 = await lim.invoke('system.audit.list', { limit: 5 });
assert.equal(a2.status, 'ok');
await assert.rejects(
() => lim.invoke('system.audit.list', { limit: 5 }),
(e) => {
assert.equal(e.code, -32407, `期望 -32407(限速),实际 ${e.code}`);
return true;
},
);
lim.close();
// 5. 未注册的调用方 → 握手失败
+130
View File
@@ -0,0 +1,130 @@
/**
* 参数摘要守卫(`apps/agent/src/audit.ts` describeArgs,A-P0-4,2026-10-25)。
*
* ⚠️ 背景:原实现每次 invoke 都全量 `JSON.stringify` + sha256。`fs.write` 的 data
* 可达数 MB——同步序列化 + 哈希 ≈ 数十毫秒级 CPU 且阻塞事件循环,期间 GUI
* 注入、事件推送、保活 ping 全部延迟。现改为:超过 64KB 只哈希**前 64KB**
* (按字节切),总长记 `args_bytes`、截断记 `args_truncated`。
* 摘要口径从「全量」变为「前 64KB」,只影响审计摘要,不影响任何安全属性
* (摘要用途是比对与防泄密,不是完整性证明)。
*
* 本文件钉死五条(均可用故障注入打红):
* ① 小参数口径不回退:digest 与独立重算的 sha256(json).slice(0,16) 一致;
* ② 大参数截断口径:digest = sha256(前 64KB 字节).slice(0,16),args_bytes/args_truncated 正确;
* ③ 按**字节**切而非按字符切(多字节边界,字符切口径必须不同);
* ④ 尾部不进摘要:前 64KB 相同、尾部不同的两个大参数 digest 相同;
* ⑤ 端到端:audit() 写入的记录带两字段,链校验仍通过;logArgs 预览行为不变。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initAudit, audit, describeArgs, verifyAudit } from '../../apps/agent/dist/audit.js';
const MAX_BYTES = 64 * 1024;
/** 独立重算摘要(不 import 实现内的私有常量路径,口径在此显式重写一遍)。 */
function digestOf(payload) {
return `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
}
function bigArgs(totalBytes, tailFill = '') {
// 构造 {capability, data},使 JSON 序列化后的总字节数 ≥ totalBytes。
// data 主体为 'x'(1 字节/字符,字节数可精确预估),tailFill 可接一段不同内容的尾部,
// 用于「前 64KB 相同、尾部不同」的对照。
const skeleton = { capability: 'fs.write', data: '' };
const baseBytes = Buffer.byteLength(JSON.stringify(skeleton), 'utf8');
const need = Math.max(1, totalBytes - baseBytes - tailFill.length);
return { capability: 'fs.write', data: 'x'.repeat(need) + tailFill };
}
test('A-P0-4①:小参数口径不回退 —— digest 与旧实现逐字节一致,无截断字段', () => {
const args = { path: '/tmp/a.txt', data: 'hello' };
const r = describeArgs(args);
assert.equal(r.args_digest, digestOf(JSON.stringify(args)), '小参数必须仍走全量哈希');
assert.equal('args_bytes' in r, false, '小参数不得出现 args_bytes');
assert.equal('args_truncated' in r, false, '小参数不得出现 args_truncated');
assert.match(r.args_digest, /^sha256:[0-9a-f]{16}$/, 'digest 形状不变');
});
test('A-P0-4②:大参数截断口径 —— 只哈希前 64KB,总长与截断标记正确', () => {
const total = 8 * 1024 * 1024; // 8MB,模拟 fs.write 大 data
const args = bigArgs(total);
const json = JSON.stringify(args);
const bytes = Buffer.byteLength(json, 'utf8');
assert.ok(bytes > MAX_BYTES, '夹具必须真的超过 64KB');
const r = describeArgs(args);
assert.equal(r.args_bytes, bytes, 'args_bytes 必须是序列化总字节数');
assert.equal(r.args_truncated, true);
// 口径:sha256(前 64KB 字节) 的前 16 位十六进制
const expected = digestOf(Buffer.from(json, 'utf8').subarray(0, MAX_BYTES));
assert.equal(r.args_digest, expected, '大参数 digest 必须等于前 64KB 的哈希');
// 且必须与全量哈希不同(证明真的截断了)
assert.notEqual(r.args_digest, digestOf(json), '不得退化成全量哈希');
});
test('A-P0-4③:按字节切而非按字符切 —— 多字节边界下字符切口径必须不同', () => {
// 用中文填充:'中' = 3 字节,64KB(65536) 不是 3 的整数倍 → 边界必然落字符中间
const args = { capability: 'fs.write', data: '中'.repeat(40000) }; // ≈120KB
const json = JSON.stringify(args);
assert.ok(Buffer.byteLength(json, 'utf8') > MAX_BYTES);
const r = describeArgs(args);
const byBytes = digestOf(Buffer.from(json, 'utf8').subarray(0, MAX_BYTES));
assert.equal(r.args_digest, byBytes, '必须按字节切');
// 若有人改成按字符切(json.slice(0, 65536)),边界落在 65535 字节处 → 口径不同 → 本断言打红
const byChars = digestOf(json.slice(0, MAX_BYTES));
assert.notEqual(r.args_digest, byChars, '按字符切是错误口径,必须被此断言挡住');
});
test('A-P0-4④:尾部不进摘要 —— 前 64KB 相同、尾部不同 → digest 相同但 args_bytes 不同', () => {
const a = bigArgs(2 * 1024 * 1024, '');
const b = bigArgs(3 * 1024 * 1024, 'y'.repeat(1024 * 1024)); // 更长且尾部内容不同
const ra = describeArgs(a);
const rb = describeArgs(b);
assert.equal(ra.args_digest, rb.args_digest, '前 64KB 一致则摘要必须一致');
assert.notEqual(ra.args_bytes, rb.args_bytes, '总长仍如实记录');
assert.equal(ra.args_truncated, true);
assert.equal(rb.args_truncated, true);
});
test('A-P0-4⑤:端到端 —— audit() 写入带两字段且链校验通过;logArgs 预览行为不变', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-audit-args-'));
try {
initAudit({ enabled: true, logArgs: true }, dir);
const big = bigArgs(1024 * 1024);
audit({ type: 'invoke', capability: 'fs.write', status: 'ok', ...describeArgs(big) });
audit({ type: 'invoke', capability: 'fs.write', status: 'ok', ...describeArgs({ path: '/tmp/x', data: 'y' }) });
const lines = readFileSync(join(dir, 'audit.log'), 'utf8').trim().split('\n');
const rec1 = JSON.parse(lines[0]);
const rec2 = JSON.parse(lines[1]);
assert.equal(rec1.args_truncated, true, '大参数记录必须带截断标记');
assert.equal(rec1.args_bytes, Buffer.byteLength(JSON.stringify(big), 'utf8'));
assert.equal(rec1.args_truncated, true);
assert.equal('args_bytes' in rec2, false, '小参数记录不得带截断字段');
assert.equal(typeof rec1.args_preview, 'string', 'logArgs 开启时预览仍产出(大参数也一样)');
const v = verifyAudit();
assert.equal(v.ok, true, `新字段不得破坏审计链,实际: ${JSON.stringify(v.broken_at)}`);
assert.equal(v.checked, 2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('A-P0-4⑤b:logArgs 默认关闭时不产出预览(行为不回退)', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-audit-args2-'));
try {
initAudit({ enabled: true }, dir); // logArgs 默认 false
const r = describeArgs({ path: '/tmp/x', data: 'hello' });
assert.equal('args_preview' in r, false, '默认不得记录预览');
assert.equal(r.args_digest, digestOf(JSON.stringify({ path: '/tmp/x', data: 'hello' })));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+89
View File
@@ -0,0 +1,89 @@
/**
* 限速器守卫(`apps/agent/src/util/rate-limit.ts`,A-P0-2,2026-10-25)。
*
* ⚠️ 背景:原实现 `allowByRate(clientId, limit, String(limit))` 把**限速值**当桶名。
* ACL 支持按能力限速(`rateLimitFor`,精确 > glob > 全局),当某 client 配了
* 「screen.capture ≤ 5/min、app.install ≤ 5/min」两条同值规则时,两者 key 都是
* `clientId|5` → 共用一个桶:截图 5 次后装软件也被限速(误伤),且两者总量
* 仍按 5/min(漏过)。
* 同时 `rateBuckets` 只增不删 + psk 模式 client_id 自报 → 外部可枚举任意
* client_id 造键 → 长期运行内存无界增长。
*
* 本文件钉死四条(均可用故障注入打红):
* ① 桶名是**能力名**:同限速值的不同能力互不挤占;
* ② 身份进 key:不同调用方互不挤占;
* ③ 过期键被清扫(假时钟推进 61s,桶数归零、重新放行);
* ④ 容量上限:自报 client_id 枚举造键 → 桶数有界。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRateLimiter } from '../../apps/agent/dist/util/rate-limit.js';
test('A-P0-2①:桶名是能力名 —— 同限速值的不同能力互不挤占', () => {
const rl = createRateLimiter();
// 同一 client、同一限速值、两个能力
assert.equal(rl.allow('c1', 2, 'screen.capture'), true);
assert.equal(rl.allow('c1', 2, 'screen.capture'), true);
assert.equal(rl.allow('c1', 2, 'screen.capture'), false, '第 3 次截图应被限');
// 关键:app.install 不受截图影响(旧实现此处共桶 → 误伤)
assert.equal(rl.allow('c1', 2, 'app.install'), true, '同限速值的另一能力不得被挤占');
assert.equal(rl.allow('c1', 2, 'app.install'), true);
assert.equal(rl.allow('c1', 2, 'app.install'), false, 'app.install 自己也按 2/min 限');
// 两个能力各占一桶
assert.equal(rl.size(), 2);
});
test('A-P0-2②:身份进 key —— 不同调用方互不挤占', () => {
const rl = createRateLimiter();
assert.equal(rl.allow('c1', 1, 'screen.capture'), true);
assert.equal(rl.allow('c1', 1, 'screen.capture'), false);
assert.equal(rl.allow('c2', 1, 'screen.capture'), true, '另一调用方有独立配额');
assert.equal(rl.size(), 2);
});
test('A-P0-2③:不限速时不建桶(与旧行为一致:直接放行)', () => {
const rl = createRateLimiter();
for (let i = 0; i < 100; i += 1) assert.equal(rl.allow('c1', undefined, 'screen.capture'), true);
assert.equal(rl.allow('c1', 0, 'screen.capture'), true, 'limit=0 同样视为不限速');
assert.equal(rl.allow('c1', -1, 'screen.capture'), true);
assert.equal(rl.size(), 0, '不限速的调用不得留下桶');
});
test('A-P0-2③:过期键被清扫(假时钟推进 61s → 桶清空且重新放行)', () => {
let t = 1_000_000;
const rl = createRateLimiter({ now: () => t });
assert.equal(rl.allow('c1', 1, 'screen.capture'), true);
assert.equal(rl.allow('c1', 1, 'screen.capture'), false, '窗口内第 2 次被限');
assert.equal(rl.size(), 1);
// 滑动窗口边界,与旧实现逐字节同语义:`now - t < 60_000` 才算窗口内。
t += 60_000 - 1; // 59.999s:仍在窗口内,第 2 次被限
assert.equal(rl.allow('c1', 1, 'screen.capture'), false, '59.999s 仍算窗口内');
t += 1; // 恰好 60s:`60_000 < 60_000` 为 false → 旧时间戳出窗 → 重新放行
assert.equal(rl.allow('c1', 1, 'screen.capture'), true, '恰好 60s 出窗(沿用旧实现边界)');
assert.equal(rl.size(), 1, '自己的桶重建');
// 再来一个不相关的旧桶,它应在下次 allow 时被顺手清掉
rl.allow('c2', 1, 'other');
t += 61_000;
rl.allow('c3', 1, 'x');
assert.equal(rl.size(), 1, `过期键必须被清扫(只增不删 = 内存无界增长),实际 ${rl.size()}`);
});
test('A-P0-2④:容量上限 —— 自报 client_id 枚举造键,桶数有界', () => {
let t = 0;
const rl = createRateLimiter({ maxKeys: 100, now: () => (t += 1) });
for (let i = 0; i < 1000; i += 1) {
rl.allow(`attacker-${i}`, 5, 'screen.capture');
}
assert.ok(rl.size() <= 100, `桶数必须被容量上限约束,实际 ${rl.size()}`);
// 最早的键已被淘汰:它若再来,配额从零开始(可接受:枚举者本就拿不到稳定配额)
assert.equal(rl.allow('attacker-0', 5, 'screen.capture'), true);
});
test('A-P0-2:限速值与桶名解耦的结构证据(同值多能力各占一桶)', () => {
const rl = createRateLimiter();
const caps = ['a.x', 'b.x', 'c.x', 'd.x'];
for (const c of caps) rl.allow('c1', 3, c);
assert.equal(rl.size(), caps.length, '每个能力一个桶(key 里没有限速值)');
});