dsh-switchman 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 结果输出:表格/JSON 格式化 + 类型规范化 + 截断保护
5
+ * JSON 写 stdout、状态信息写 stderr,保证 stdout 是合法 JSON
6
+ */
7
+
8
+ // ── 类型规范化 ──
9
+
10
+ /**
11
+ * 规范化单个值为安全输出格式
12
+ * - BigInt → 十进制字符串
13
+ * - Buffer/Uint8Array → UTF-8 可打印则字符串,否则 base64
14
+ * - Date → ISO 字符串
15
+ * - 其他对象 → 安全 JSON 化
16
+ *
17
+ * @param {*} val
18
+ * @param {object} opts - { maxValueBytes: number }
19
+ * @returns {{ value: *, truncated: boolean, byteLength?: number }}
20
+ */
21
+ function normalizeValue(val, opts) {
22
+ const maxSize = (opts && opts.maxValueBytes) || 4096;
23
+
24
+ // BigInt 保留字符串精度
25
+ if (typeof val === 'bigint') {
26
+ return { value: val.toString(10), truncated: false };
27
+ }
28
+
29
+ // Buffer / Uint8Array 按二进制处理
30
+ if (Buffer.isBuffer(val) || (val instanceof Uint8Array)) {
31
+ return normalizeBuffer(val, maxSize);
32
+ }
33
+
34
+ // Date → ISO 字符串(dateStrings 已在连接选项中处理,这里兜底)
35
+ if (val instanceof Date) {
36
+ return { value: val.toISOString(), truncated: false };
37
+ }
38
+
39
+ // 对象(含数组)→ JSON 安全化
40
+ if (typeof val === 'object' && val !== null) {
41
+ try {
42
+ const json = safeJsonStringify(val);
43
+ if (Buffer.byteLength(json, 'utf8') > maxSize) {
44
+ const truncated = truncateString(json, maxSize);
45
+ return { value: JSON.parse(truncated.value), truncated: truncated.truncated, byteLength: json.length };
46
+ }
47
+ return { value: val, truncated: false };
48
+ } catch (_) {
49
+ return { value: '[unserializable object]', truncated: false };
50
+ }
51
+ }
52
+
53
+ // 基本类型直接返回
54
+ return { value: val, truncated: false };
55
+ }
56
+
57
+ /**
58
+ * Buffer/Uint8Array → 可打印 UTF-8 字符串或 base64
59
+ * 超过 maxValueBytes 时截断并标注
60
+ */
61
+ function normalizeBuffer(buf, maxSize) {
62
+ // Uint8Array → Buffer(共享底层内存,零拷贝视图)
63
+ const buffer = Buffer.isBuffer(buf) ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
64
+
65
+ // 尝试 UTF-8 解码
66
+ let str;
67
+ let isPrintable = false;
68
+ try {
69
+ str = buffer.toString('utf8');
70
+ isPrintable = isPrintableUtf8(str);
71
+ } catch (_) {
72
+ str = null;
73
+ }
74
+
75
+ const byteLength = buffer.length;
76
+ let truncated = false;
77
+
78
+ if (byteLength > maxSize) {
79
+ truncated = true;
80
+ // 截断到 maxSize 字节再解码
81
+ const sliced = buffer.subarray(0, maxSize);
82
+ if (isPrintable) {
83
+ str = sliced.toString('utf8') + '…';
84
+ } else {
85
+ str = sliced.toString('base64') + '…';
86
+ }
87
+ } else if (isPrintable) {
88
+ str = str; // 已经解码
89
+ } else {
90
+ str = buffer.toString('base64');
91
+ }
92
+
93
+ return {
94
+ value: str,
95
+ truncated,
96
+ byteLength,
97
+ };
98
+ }
99
+
100
+ /** 检查字符串是否为可打印 UTF-8(无控制字符,允许常见空白) */
101
+ function isPrintableUtf8(str) {
102
+ for (let i = 0; i < str.length; i++) {
103
+ const code = str.charCodeAt(i);
104
+ if (code < 0x20 && code !== 0x09 && code !== 0x0A && code !== 0x0D) return false;
105
+ if (code === 0x7F) return false; // DEL
106
+ if (code >= 0xFFFE) return false; // 非字符
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * 安全 JSON 字符串化,处理循环引用等异常
113
+ */
114
+ function safeJsonStringify(val) {
115
+ const seen = new WeakSet();
116
+ return JSON.stringify(val, (key, value) => {
117
+ if (typeof value === 'object' && value !== null) {
118
+ if (seen.has(value)) return '[circular]';
119
+ seen.add(value);
120
+ }
121
+ return value;
122
+ });
123
+ }
124
+
125
+ /** 截断字符串到指定字节数(UTF-8 安全) */
126
+ function truncateString(str, maxBytes) {
127
+ const buf = Buffer.from(str, 'utf8');
128
+ if (buf.length <= maxBytes) return { value: str, truncated: false };
129
+ // 找到不超过 maxBytes 的最后完整 UTF-8 字符边界
130
+ let end = maxBytes;
131
+ while (end > 0 && (buf[end] & 0xC0) === 0x80) end--; // 跳过多字节续字节
132
+ return { value: buf.subarray(0, end).toString('utf8') + '…', truncated: true };
133
+ }
134
+
135
+ // ── MySQL 表格输出 ──
136
+
137
+ /**
138
+ * 格式化 MySQL 查询结果为表格(按 fields 顺序)
139
+ * @param {Array} rows
140
+ * @param {Array} fields - mysql2 返回的 field metadata
141
+ * @param {object} opts - { maxRows, maxValueBytes, truncated }
142
+ */
143
+ function formatMysqlTable(rows, fields, opts) {
144
+ const maxRows = (opts && opts.maxRows) || 200;
145
+ const maxValueBytes = (opts && opts.maxValueBytes) || 4096;
146
+ const resultTruncated = opts && opts.truncated;
147
+
148
+ // 规范化行数据
149
+ const normalizedRows = [];
150
+ const fieldNames = (fields || []).map(f => f.name);
151
+
152
+ for (let i = 0; i < rows.length && i < maxRows; i++) {
153
+ const row = rows[i];
154
+ const outRow = [];
155
+ for (const name of fieldNames) {
156
+ const raw = row[name];
157
+ const norm = normalizeValue(raw, { maxValueBytes });
158
+ outRow.push(formatCell(norm));
159
+ }
160
+ normalizedRows.push(outRow);
161
+ }
162
+
163
+ if (normalizedRows.length === 0) {
164
+ return '(空结果集)';
165
+ }
166
+
167
+ // 计算列宽
168
+ const colWidths = fieldNames.map((name, colIdx) => {
169
+ let w = name.length;
170
+ for (const row of normalizedRows) {
171
+ w = Math.max(w, String(row[colIdx]).length);
172
+ }
173
+ return Math.min(w, 60); // 单列最大 60 字符
174
+ });
175
+
176
+ // 构建表格
177
+ const sep = colWidths.map(w => '-'.repeat(w + 2)).join('+');
178
+ const lines = [sep];
179
+
180
+ // 表头
181
+ lines.push('|' + fieldNames.map((name, i) => ' ' + name.padEnd(colWidths[i]) + ' ').join('|') + '|');
182
+ lines.push(sep);
183
+
184
+ // 数据行
185
+ for (const row of normalizedRows) {
186
+ lines.push('|' + row.map((cell, i) => ' ' + String(cell).padEnd(colWidths[i]) + ' ').join('|') + '|');
187
+ }
188
+ lines.push(sep);
189
+
190
+ let output = lines.join('\n');
191
+ if (resultTruncated) {
192
+ output += `\n⚠ 结果已截断(仅显示前 ${maxRows} 行)`;
193
+ }
194
+ return output;
195
+ }
196
+
197
+ /** 格式化单个单元格(附加截断标记) */
198
+ function formatCell(norm) {
199
+ if (norm.truncated && norm.byteLength) {
200
+ return String(norm.value) + ` [${norm.byteLength}B]`;
201
+ }
202
+ if (norm.truncated) {
203
+ return String(norm.value) + ' [截断]';
204
+ }
205
+ return norm.value;
206
+ }
207
+
208
+ // ── Redis 表格输出 ──
209
+
210
+ /**
211
+ * 格式化 Redis 结果为可读表格
212
+ * @param {*} result - Redis 返回的原始数据
213
+ * @param {object} info - { shape, command, maxItems, maxValueBytes }
214
+ * @returns {string}
215
+ */
216
+ function formatRedisTable(result, info) {
217
+ const maxItems = (info && info.maxItems) || 200;
218
+ const shape = (info && info.shape) || 'scalar';
219
+
220
+ // scalar
221
+ if (shape === 'scalar') {
222
+ if (result === null) return '(nil)';
223
+ if (typeof result === 'number') return String(result);
224
+ const norm = normalizeValue(result, { maxValueBytes: info && info.maxValueBytes });
225
+ return String(norm.value) + (norm.truncated ? ' [截断]' : '');
226
+ }
227
+
228
+ // list
229
+ if (shape === 'list') {
230
+ const items = Array.isArray(result) ? result : [result];
231
+ if (items.length === 0) return '(空列表)';
232
+ const lines = [];
233
+ const count = Math.min(items.length, maxItems);
234
+ for (let i = 0; i < count; i++) {
235
+ const norm = normalizeValue(items[i], { maxValueBytes: info && info.maxValueBytes });
236
+ lines.push(`${i + 1}) ${String(norm.value)}${norm.truncated ? ' [截断]' : ''}`);
237
+ }
238
+ if (items.length > maxItems) lines.push(`⚠ 仅显示前 ${maxItems} 条(共 ${items.length} 条)`);
239
+ return lines.join('\n');
240
+ }
241
+
242
+ // pairs (HGETALL 等)
243
+ if (shape === 'pairs') {
244
+ const arr = Array.isArray(result) ? result : [];
245
+ if (arr.length === 0) return '(空映射)';
246
+ const lines = [];
247
+ const pairCount = Math.min(Math.floor(arr.length / 2), maxItems);
248
+ for (let i = 0; i < pairCount; i++) {
249
+ const field = String(arr[i * 2] || '');
250
+ const val = normalizeValue(arr[i * 2 + 1], { maxValueBytes: info && info.maxValueBytes });
251
+ lines.push(`${field}: ${String(val.value)}${val.truncated ? ' [截断]' : ''}`);
252
+ }
253
+ if (Math.floor(arr.length / 2) > maxItems) {
254
+ lines.push(`⚠ 仅显示前 ${maxItems} 个字段`);
255
+ }
256
+ return lines.join('\n');
257
+ }
258
+
259
+ // scan
260
+ if (shape === 'scan') {
261
+ if (!Array.isArray(result)) return String(result);
262
+ const cursor = result[0];
263
+ const items = result[1] || [];
264
+ const lines = [`cursor: ${cursor}`];
265
+ const count = Math.min(items.length, maxItems);
266
+ for (let i = 0; i < count; i++) {
267
+ lines.push(`${i + 1}) ${String(items[i])}`);
268
+ }
269
+ if (items.length > maxItems) lines.push(`⚠ 仅显示前 ${maxItems} 条`);
270
+ return lines.join('\n');
271
+ }
272
+
273
+ // info sections
274
+ if (shape === 'info') {
275
+ return typeof result === 'string' ? result : String(result);
276
+ }
277
+
278
+ // client_list
279
+ if (shape === 'client_list') {
280
+ if (typeof result === 'string') {
281
+ const clients = result.trim().split('\n');
282
+ const count = Math.min(clients.length, maxItems);
283
+ const lines = [];
284
+ for (let i = 0; i < count; i++) {
285
+ lines.push(`--- 客户端 ${i + 1} ---`);
286
+ lines.push(clients[i]);
287
+ }
288
+ if (clients.length > maxItems) lines.push(`⚠ 仅显示前 ${maxItems} 个客户端`);
289
+ return lines.join('\n');
290
+ }
291
+ return String(result);
292
+ }
293
+
294
+ return String(result);
295
+ }
296
+
297
+ // ── JSON envelope ──
298
+
299
+ /**
300
+ * 构建 JSON 输出 envelope
301
+ * @param {object} params - { source, target, elapsedMs, count, truncated, result }
302
+ * @returns {string}
303
+ */
304
+ function formatJson(params) {
305
+ const envelope = {
306
+ source: params.source, // 'mysql' | 'redis'
307
+ target: params.target, // 脱敏目标
308
+ elapsedMs: params.elapsedMs,
309
+ count: params.count, // 行/元素数
310
+ truncated: params.truncated,
311
+ result: params.result,
312
+ };
313
+ return JSON.stringify(envelope, null, 2);
314
+ }
315
+
316
+ module.exports = {
317
+ normalizeValue,
318
+ formatMysqlTable,
319
+ formatRedisTable,
320
+ formatJson,
321
+ truncateString,
322
+ };
@@ -0,0 +1,362 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Redis 只读命令白名单校验
5
+ * 未知命令一律拒绝(fail-closed);精确参数保护
6
+ *
7
+ * 设计要点:
8
+ * - Object.freeze 白名单,运行时不可篡改
9
+ * - policy.shape 指导输出格式
10
+ * - KEYS/HGETALL 等无界命令需显式 --allow-unbounded
11
+ * - SCAN 类命令只接受结构化参数(MATCH/COUNT/TYPE)
12
+ */
13
+
14
+ // ── 白名单策略 ──
15
+
16
+ /**
17
+ * 策略字段说明:
18
+ * - arity: 参数个数(不含命令本身),null 表示不固定
19
+ * - shape: 输出形状 'scalar' | 'list' | 'pairs' | 'scan' | 'info' | 'client_list'
20
+ * - validate: (args) => void,参数校验函数
21
+ * - unbounded: true 表示结果集可能很大,需要 --allow-unbounded
22
+ */
23
+ const _READ_ONLY_COMMANDS = {
24
+ // ── 通用/键 ──
25
+ PING: { arity: 0, shape: 'scalar', validate: () => {} },
26
+ GET: { arity: 1, shape: 'scalar', validate: () => {} },
27
+ MGET: { arity: null, shape: 'list', validate: (a) => { if (!a.length) throw 'MGET 至少需要一个 key'; } },
28
+ EXISTS: { arity: null, shape: 'scalar', validate: (a) => { if (!a.length) throw 'EXISTS 至少需要一个 key'; } },
29
+ TYPE: { arity: 1, shape: 'scalar', validate: () => {} },
30
+ TTL: { arity: 1, shape: 'scalar', validate: () => {} },
31
+ PTTL: { arity: 1, shape: 'scalar', validate: () => {} },
32
+ STRLEN: { arity: 1, shape: 'scalar', validate: () => {} },
33
+ SCAN: { arity: null, shape: 'scan', validate: validateScan },
34
+ KEYS: { arity: 1, shape: 'list', unbounded: true, validate: () => {} },
35
+ DBSIZE: { arity: 0, shape: 'scalar', validate: () => {} },
36
+ TIME: { arity: 0, shape: 'scalar', validate: () => {} },
37
+
38
+ // ── Hash ──
39
+ HGET: { arity: 2, shape: 'scalar', validate: () => {} },
40
+ HMGET: { arity: null, shape: 'list', validate: (a) => { if (a.length < 2) throw 'HMGET 至少需要 field 和 2 个参数'; } },
41
+ HEXISTS: { arity: 2, shape: 'scalar', validate: () => {} },
42
+ HLEN: { arity: 1, shape: 'scalar', validate: () => {} },
43
+ HGETALL: { arity: 1, shape: 'pairs', unbounded: true, validate: () => {} },
44
+ HKEYS: { arity: 1, shape: 'list', unbounded: true, validate: () => {} },
45
+ HVALS: { arity: 1, shape: 'list', unbounded: true, validate: () => {} },
46
+ HSCAN: { arity: null, shape: 'scan', validate: validateScan },
47
+
48
+ // ── List ──
49
+ LINDEX: { arity: 2, shape: 'scalar', validate: () => {} },
50
+ LLEN: { arity: 1, shape: 'scalar', validate: () => {} },
51
+ LRANGE: { arity: null, shape: 'list', validate: validateLrange },
52
+
53
+ // ── Set ──
54
+ SISMEMBER: { arity: 2, shape: 'scalar', validate: () => {} },
55
+ SCARD: { arity: 1, shape: 'scalar', validate: () => {} },
56
+ SMEMBERS: { arity: 1, shape: 'list', unbounded: true, validate: () => {} },
57
+ SSCAN: { arity: null, shape: 'scan', validate: validateScan },
58
+
59
+ // ── ZSet ──
60
+ ZCARD: { arity: 1, shape: 'scalar', validate: () => {} },
61
+ ZCOUNT: { arity: 3, shape: 'scalar', validate: () => {} },
62
+ ZSCORE: { arity: 2, shape: 'scalar', validate: () => {} },
63
+ ZRANK: { arity: 2, shape: 'scalar', validate: () => {} },
64
+ ZREVRANK: { arity: 2, shape: 'scalar', validate: () => {} },
65
+ ZRANGE: { arity: null, shape: 'list', validate: validateZrange },
66
+ ZRANGEBYSCORE: { arity: null, shape: 'list', validate: validateZrangeByScore },
67
+ ZSCAN: { arity: null, shape: 'scan', validate: validateScan },
68
+
69
+ // ── 服务信息 ──
70
+ INFO: { arity: null, shape: 'info', validate: () => {} },
71
+ CLIENT: { arity: null, shape: 'client_list', validate: validateClient },
72
+ CONFIG: { arity: null, shape: 'list', validate: validateConfig },
73
+ };
74
+
75
+ /** 冻结白名单防止运行时篡改 */
76
+ const READ_ONLY_COMMANDS = Object.freeze(_READ_ONLY_COMMANDS);
77
+
78
+ // ── 参数校验函数 ──
79
+
80
+ /**
81
+ * SCAN/HSCAN/SSCAN/ZSCAN 参数结构化校验
82
+ * 只接受 MATCH pattern COUNT n [TYPE type];不允许重复选项
83
+ *
84
+ * 修复:cursor 位置因命令而异——SCAN 无 key 前缀,cursor 在首位;
85
+ * HSCAN/SSCAN/ZSCAN 第一个参数是 key,cursor 在第二位。
86
+ * 旧实现统一按 SCAN 处理,导致 HSCAN/SSCAN/ZSCAN 合法的 cursor=0 被误拒。
87
+ */
88
+ function validateScan(args, command) {
89
+ // SCAN 的参数布局:cursor 在首位;其余三个命令首位是 key,第二位才是 cursor
90
+ const hasKey = command !== 'SCAN';
91
+ let i;
92
+ if (hasKey) {
93
+ if (args.length < 2) throw `${command} 至少需要 key 和 cursor`;
94
+ const cursor = Number(args[1]);
95
+ if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor 必须是非负整数';
96
+ i = 2; // 选项从第三位开始解析
97
+ } else {
98
+ if (args.length === 0) throw 'SCAN 至少需要 cursor 参数';
99
+ const cursor = Number(args[0]);
100
+ if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor 必须是非负整数';
101
+ i = 1; // 选项从第二位开始解析
102
+ }
103
+
104
+ const seen = new Set();
105
+ for (; i < args.length; i++) {
106
+ const opt = String(args[i]).toUpperCase();
107
+ if (seen.has(opt)) throw `重复的 SCAN 选项: ${opt}`;
108
+ seen.add(opt);
109
+
110
+ if (opt === 'MATCH') {
111
+ if (i + 1 >= args.length) throw 'MATCH 需要一个 pattern 参数';
112
+ i++; // 跳过 pattern 值
113
+ } else if (opt === 'COUNT') {
114
+ if (i + 1 >= args.length) throw 'COUNT 需要一个数值参数';
115
+ const count = Number(args[i + 1]);
116
+ if (!Number.isInteger(count) || count < 1) throw 'COUNT 必须是正整数';
117
+ if (count > 5000) throw 'COUNT 超过上限 5000';
118
+ i++;
119
+ } else if (opt === 'TYPE') {
120
+ // TYPE 只支持 SCAN,HSCAN/SSCAN/ZSCAN 不支持 TYPE 选项(交给服务端拒不如显式拒绝)
121
+ if (hasKey) throw `${command} 不支持 TYPE 选项(仅 SCAN 支持)`;
122
+ if (i + 1 >= args.length) throw 'TYPE 需要一个类型名参数';
123
+ i++;
124
+ } else {
125
+ throw `SCAN 不支持的选项: ${opt}`;
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * LRANGE 范围校验:防客户端内存 DoS
132
+ * stop < 0(负索引)→ 长度未知,要求 --allow-unbounded
133
+ * start < 0 或 stop ≥ 0 时,检查 (stop - start + 1) ≤ maxItems
134
+ */
135
+ function validateLrange(args, command, limits) {
136
+ if (args.length < 3) throw '至少需要 3 个参数(key start stop)';
137
+ const start = Number(args[1]);
138
+ const stop = Number(args[2]);
139
+ if (!Number.isInteger(start) || !Number.isInteger(stop)) {
140
+ throw 'start/stop 必须是整数';
141
+ }
142
+ const maxItems = (limits && limits.maxItems) || 200;
143
+
144
+ // 负索引(stop < 0)表示到末尾,实际长度未知 → 要求显式放行
145
+ if (stop < 0) {
146
+ if (!(limits && limits.allowUnbounded)) {
147
+ throw 'stop 为负索引时范围不确定,需 --allow-unbounded 或用 SCAN/LRANGE + 正索引';
148
+ }
149
+ return; // 已放行,不再算范围
150
+ }
151
+ // start 为负索引时,Redis 换算后实际起始位置不确定 → 同样要求放行
152
+ if (start < 0) {
153
+ if (!(limits && limits.allowUnbounded)) {
154
+ throw 'start 为负索引时范围不确定,需 --allow-unbounded 或用 LRANGE + 正索引';
155
+ }
156
+ return;
157
+ }
158
+ // 双方都是非负索引,可精确计算返回数量
159
+ const count = stop - start + 1;
160
+ if (count > maxItems) {
161
+ throw `范围 ${start}..${stop} 共 ${count} 项,超过 maxItems=${maxItems}`;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * ZRANGE Redis 6.2+ 完整语法校验
167
+ * 支持 BYSCORE / BYLEX / REV / LIMIT / WITHSCORES 选项
168
+ * BYLEX 模式无 LIMIT 时要求 --allow-unbounded(词法范围可能很长)
169
+ * 含 LIMIT 时 count 必须 ≤ maxItems
170
+ */
171
+ function validateZrange(args, command, limits) {
172
+ if (args.length < 3) throw 'ZRANGE 至少需要 key min max';
173
+ const maxItems = (limits && limits.maxItems) || 200;
174
+
175
+ // 收集选项关键字(跳过前 3 个必选参数 key min max)
176
+ const optsUpper = args.slice(3).map(a => String(a).toUpperCase());
177
+ let hasByscore = false;
178
+ let hasBylex = false;
179
+ let hasRev = false;
180
+ let hasLimit = false;
181
+ let limitCount = null;
182
+
183
+ for (let i = 0; i < optsUpper.length; i++) {
184
+ const opt = optsUpper[i];
185
+ if (opt === 'BYSCORE') { hasByscore = true; continue; }
186
+ if (opt === 'BYLEX') { hasBylex = true; continue; }
187
+ if (opt === 'REV') { hasRev = true; continue; }
188
+ if (opt === 'WITHSCORES') { continue; }
189
+ if (opt === 'LIMIT') {
190
+ hasLimit = true;
191
+ // LIMIT 后需要 offset 和 count
192
+ if (i + 2 >= optsUpper.length) throw 'LIMIT 需要 offset 和 count';
193
+ const offset = Number(args[3 + i + 1]); // 原始 args 中对应位置
194
+ limitCount = Number(args[3 + i + 2]);
195
+ if (!Number.isInteger(offset) || !Number.isInteger(limitCount) || offset < 0 || limitCount < 0) {
196
+ throw 'LIMIT offset/count 必须是非负整数';
197
+ }
198
+ if (limitCount > maxItems) throw `LIMIT count ${limitCount} 超过 maxItems=${maxItems}`;
199
+ i += 2; // 跳过 offset 和 count
200
+ continue;
201
+ }
202
+ throw `ZRANGE 不支持的选项: ${opt}`;
203
+ }
204
+
205
+ // BYLEX 模式无 LIMIT → 词法范围可能很大,要求放行
206
+ if (hasBylex && !hasLimit) {
207
+ if (!(limits && limits.allowUnbounded)) {
208
+ throw 'BYLEX 模式无 LIMIT 时范围不确定,需 --allow-unbounded';
209
+ }
210
+ }
211
+ // BYSCORE 默认索引范围(如 0 -1)无 LIMIT → 同样要求放行
212
+ if (hasByscore && !hasLimit) {
213
+ if (!(limits && limits.allowUnbounded)) {
214
+ throw 'BYSCORE 模式无 LIMIT 时范围不确定,需 --allow-unbounded';
215
+ }
216
+ }
217
+ // 默认索引模式(非 BYSCORE/BYLEX),检查 min/max 范围
218
+ if (!hasByscore && !hasBylex) {
219
+ // min/max 作为 rank 索引时,负索引表示到末尾,范围不确定
220
+ const minVal = Number(args[1]);
221
+ const maxVal = Number(args[2]);
222
+ if ((minVal < 0 || maxVal < 0) && !hasLimit) {
223
+ if (!(limits && limits.allowUnbounded)) {
224
+ throw 'ZRANGE 含负索引且无 LIMIT,范围不确定,需 --allow-unbounded';
225
+ }
226
+ }
227
+ // 非负索引且有 LIMIT 时,LIMIT 已校验 count,安全
228
+ // 非负索引且无 LIMIT 时,需计算范围
229
+ if (minVal >= 0 && maxVal >= 0 && !hasLimit) {
230
+ const rangeCount = maxVal - minVal + 1;
231
+ if (rangeCount > maxItems) {
232
+ throw `范围 ${minVal}..${maxVal} 共 ${rangeCount} 项,超过 maxItems=${maxItems}`;
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * ZRANGEBYSCORE 必须带 LIMIT offset count 且 count 不超上限
240
+ */
241
+ function validateZrangeByScore(args) {
242
+ if (args.length < 3) throw 'ZRANGEBYSCORE 至少需要 key min max';
243
+ // 检查是否含 LIMIT
244
+ const argsUpper = args.map(a => String(a).toUpperCase());
245
+ const limitIdx = argsUpper.indexOf('LIMIT');
246
+ if (limitIdx === -1) {
247
+ throw 'ZRANGEBYSCORE 必须带 LIMIT 子句(防止无界结果集)';
248
+ }
249
+ if (limitIdx + 2 >= args.length) {
250
+ throw 'LIMIT 需要 offset 和 count 两个参数';
251
+ }
252
+ const count = Number(args[limitIdx + 2]);
253
+ if (!Number.isInteger(count) || count < 0) throw 'LIMIT count 必须是非负整数';
254
+ if (count > 5000) throw 'LIMIT count 超过上限 5000';
255
+ }
256
+
257
+ /**
258
+ * CLIENT 只允许 LIST 子命令
259
+ * KILL/PAUSE/UNBLOCK 可中断其他连接,拒绝
260
+ */
261
+ function validateClient(args) {
262
+ if (args.length === 0) throw 'CLIENT 需要子命令';
263
+ const sub = String(args[0]).toUpperCase();
264
+ if (sub !== 'LIST') throw `不允许 CLIENT ${sub}(只允许 CLIENT LIST)`;
265
+ }
266
+
267
+ /**
268
+ * CONFIG 只允许 GET 子命令,且 config key 在安全白名单内
269
+ * 拒绝 *(转储全部配置)、requirepass、masterauth 等敏感项
270
+ */
271
+ function validateConfig(args) {
272
+ if (args.length === 0) throw 'CONFIG 需要子命令';
273
+ const sub = String(args[0]).toUpperCase();
274
+ if (sub !== 'GET') throw `不允许 CONFIG ${sub}(只允许 CONFIG GET)`;
275
+
276
+ if (args.length < 2) throw 'CONFIG GET 至少需要一个参数';
277
+
278
+ // 安全白名单:只允许查看非敏感的运行时配置
279
+ const SAFE_CONFIG_KEYS = new Set([
280
+ 'databases', 'maxmemory', 'maxmemory-policy', 'maxmemory-samples',
281
+ 'timeout', 'tcp-keepalive', 'appendonly', 'appendfsync',
282
+ 'hz', 'save', 'rdbcompression', 'rdbchecksum',
283
+ 'lazyfree-lazy-eviction', 'lazyfree-lazy-expire',
284
+ 'io-threads', 'io-threads-do-reads',
285
+ 'cluster-enabled', 'cluster-node-timeout',
286
+ ]);
287
+
288
+ for (let i = 1; i < args.length; i++) {
289
+ const key = String(args[i]).toLowerCase();
290
+ if (key === '*') throw '不允许 CONFIG GET *(会转储全部配置含密码)';
291
+ if (key === 'requirepass' || key === 'masterauth') {
292
+ throw `不允许查询 ${key}(可能暴露密码)`;
293
+ }
294
+ // 包含 tls/key/pass/secret/acl 等关键词的一律拒绝
295
+ if (/tls[_-]?key|pass|secret|acl[_-]file|unixsocket/i.test(key)) {
296
+ throw `不允许查询敏感配置: ${key}`;
297
+ }
298
+ if (!SAFE_CONFIG_KEYS.has(key)) {
299
+ throw `CONFIG GET ${key} 不在安全白名单中`;
300
+ }
301
+ }
302
+ }
303
+
304
+ // ── 主校验入口 ──
305
+
306
+ /**
307
+ * 校验 Redis 命令是否在只读白名单内,并校验参数
308
+ * 未知命令一律拒绝(fail-closed)
309
+ *
310
+ * @param {string[]} argv - 完整命令数组,[cmd, ...args]
311
+ * @param {object} limits - { maxItems: number, allowUnbounded: boolean }
312
+ * @returns {{ command: string, args: string[], shape: string, unbounded: boolean }}
313
+ */
314
+ function validateRedisCommand(argv, limits) {
315
+ if (!Array.isArray(argv) || argv.length === 0) {
316
+ throw new RedisReadonlyError('Redis 命令不能为空');
317
+ }
318
+
319
+ const command = String(argv[0]).toUpperCase();
320
+ const policy = READ_ONLY_COMMANDS[command];
321
+
322
+ if (!policy) {
323
+ throw new RedisReadonlyError(`不允许的命令: ${command}(不在只读白名单中)`);
324
+ }
325
+
326
+ // 检查 arity(参数个数约束)
327
+ if (policy.arity !== null && argv.length - 1 !== policy.arity) {
328
+ throw new RedisReadonlyError(`${command} 需要 ${policy.arity} 个参数,收到 ${argv.length - 1} 个`);
329
+ }
330
+
331
+ // 无界命令需要显式放行
332
+ if (policy.unbounded && !(limits && limits.allowUnbounded)) {
333
+ throw new RedisReadonlyError(
334
+ `${command} 可能返回大量数据,需添加 --allow-unbounded 参数确认(建议优先使用 SCAN 类命令)`
335
+ );
336
+ }
337
+
338
+ // 运行策略特定的参数校验;传入命令名和 limits 以便各函数做范围/无界校验
339
+ const args = argv.slice(1);
340
+ try {
341
+ policy.validate(args, command, limits);
342
+ } catch (msg) {
343
+ throw new RedisReadonlyError(`${command} 参数错误: ${msg}`);
344
+ }
345
+
346
+ return {
347
+ command,
348
+ args,
349
+ shape: policy.shape,
350
+ unbounded: !!policy.unbounded,
351
+ };
352
+ }
353
+
354
+ class RedisReadonlyError extends Error {
355
+ constructor(msg) { super(msg); this.name = 'RedisReadonlyError'; }
356
+ }
357
+
358
+ module.exports = {
359
+ validateRedisCommand,
360
+ READ_ONLY_COMMANDS,
361
+ RedisReadonlyError,
362
+ };