dsh-switchman 1.5.0 → 1.6.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.
@@ -1,31 +1,31 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Redis 只读命令白名单校验
5
- * 未知命令一律拒绝(fail-closed);精确参数保护
4
+ * Redis read-only command allowlist validation
5
+ * Unknown commands are always rejected (fail-closed); precise argument protection
6
6
  *
7
- * 设计要点:
8
- * - Object.freeze 白名单,运行时不可篡改
9
- * - policy.shape 指导输出格式
10
- * - KEYS/HGETALL 等无界命令需显式 --allow-unbounded
11
- * - SCAN 类命令只接受结构化参数(MATCH/COUNT/TYPE)
7
+ * Design notes:
8
+ * - Object.freeze'd allowlist, tamper-proof at runtime
9
+ * - policy.shape drives the output format
10
+ * - Unbounded commands like KEYS/HGETALL require an explicit --allow-unbounded
11
+ * - SCAN-family commands accept only structured arguments (MATCH/COUNT/TYPE)
12
12
  */
13
13
 
14
- // ── 白名单策略 ──
14
+ // ── Allowlist policy ──
15
15
 
16
16
  /**
17
- * 策略字段说明:
18
- * - arity: 参数个数(不含命令本身),null 表示不固定
19
- * - shape: 输出形状 'scalar' | 'list' | 'pairs' | 'scan' | 'info' | 'client_list'
20
- * - validate: (args) => void,参数校验函数
21
- * - unbounded: true 表示结果集可能很大,需要 --allow-unbounded
17
+ * Policy fields:
18
+ * - arity: argument count (excluding the command itself), null for variable
19
+ * - shape: output shape 'scalar' | 'list' | 'pairs' | 'scan' | 'info' | 'client_list'
20
+ * - validate: (args) => void, argument validation function
21
+ * - unbounded: true means the result set can be large and needs --allow-unbounded
22
22
  */
23
23
  const _READ_ONLY_COMMANDS = {
24
- // ── 通用/键 ──
24
+ // ── Generic/keys ──
25
25
  PING: { arity: 0, shape: 'scalar', validate: () => {} },
26
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'; } },
27
+ MGET: { arity: null, shape: 'list', validate: (a) => { if (!a.length) throw 'MGET requires at least one key'; } },
28
+ EXISTS: { arity: null, shape: 'scalar', validate: (a) => { if (!a.length) throw 'EXISTS requires at least one key'; } },
29
29
  TYPE: { arity: 1, shape: 'scalar', validate: () => {} },
30
30
  TTL: { arity: 1, shape: 'scalar', validate: () => {} },
31
31
  PTTL: { arity: 1, shape: 'scalar', validate: () => {} },
@@ -37,7 +37,7 @@ const _READ_ONLY_COMMANDS = {
37
37
 
38
38
  // ── Hash ──
39
39
  HGET: { arity: 2, shape: 'scalar', validate: () => {} },
40
- HMGET: { arity: null, shape: 'list', validate: (a) => { if (a.length < 2) throw 'HMGET 至少需要 field 和 2 个参数'; } },
40
+ HMGET: { arity: null, shape: 'list', validate: (a) => { if (a.length < 2) throw 'HMGET requires at least a key and one field'; } },
41
41
  HEXISTS: { arity: 2, shape: 'scalar', validate: () => {} },
42
42
  HLEN: { arity: 1, shape: 'scalar', validate: () => {} },
43
43
  HGETALL: { arity: 1, shape: 'pairs', unbounded: true, validate: () => {} },
@@ -66,113 +66,116 @@ const _READ_ONLY_COMMANDS = {
66
66
  ZRANGEBYSCORE: { arity: null, shape: 'list', validate: validateZrangeByScore },
67
67
  ZSCAN: { arity: null, shape: 'scan', validate: validateScan },
68
68
 
69
- // ── 服务信息 ──
69
+ // ── Server info ──
70
70
  INFO: { arity: null, shape: 'info', validate: () => {} },
71
71
  CLIENT: { arity: null, shape: 'client_list', validate: validateClient },
72
72
  CONFIG: { arity: null, shape: 'list', validate: validateConfig },
73
73
  };
74
74
 
75
- /** 冻结白名单防止运行时篡改 */
75
+ /** Freeze the allowlist to prevent runtime tampering */
76
76
  const READ_ONLY_COMMANDS = Object.freeze(_READ_ONLY_COMMANDS);
77
77
 
78
- // ── 参数校验函数 ──
78
+ // ── Argument validators ──
79
79
 
80
80
  /**
81
- * SCAN/HSCAN/SSCAN/ZSCAN 参数结构化校验
82
- * 只接受 MATCH pattern COUNT n [TYPE type];不允许重复选项
81
+ * Structured argument validation for SCAN/HSCAN/SSCAN/ZSCAN
82
+ * Only MATCH pattern COUNT n [TYPE type] are accepted; duplicate options are rejected
83
83
  *
84
- * 修复:cursor 位置因命令而异——SCAN 无 key 前缀,cursor 在首位;
85
- * HSCAN/SSCAN/ZSCAN 第一个参数是 key,cursor 在第二位。
86
- * 旧实现统一按 SCAN 处理,导致 HSCAN/SSCAN/ZSCAN 合法的 cursor=0 被误拒。
84
+ * Fix: the cursor position differs per command — SCAN has no key prefix and
85
+ * the cursor comes first; HSCAN/SSCAN/ZSCAN take a key first, so the cursor
86
+ * is second. The old implementation treated everything like SCAN and wrongly
87
+ * rejected a legal cursor=0 for HSCAN/SSCAN/ZSCAN.
87
88
  */
88
89
  function validateScan(args, command) {
89
- // SCAN 的参数布局:cursor 在首位;其余三个命令首位是 key,第二位才是 cursor
90
+ // SCAN argument layout: the cursor comes first; the other three commands
91
+ // take a key first and the cursor second
90
92
  const hasKey = command !== 'SCAN';
91
93
  let i;
92
94
  if (hasKey) {
93
- if (args.length < 2) throw `${command} 至少需要 key 和 cursor`;
95
+ if (args.length < 2) throw `${command} requires at least a key and a cursor`;
94
96
  const cursor = Number(args[1]);
95
- if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor 必须是非负整数';
96
- i = 2; // 选项从第三位开始解析
97
+ if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor must be a non-negative integer';
98
+ i = 2; // options start at the third position
97
99
  } else {
98
- if (args.length === 0) throw 'SCAN 至少需要 cursor 参数';
100
+ if (args.length === 0) throw 'SCAN requires at least a cursor argument';
99
101
  const cursor = Number(args[0]);
100
- if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor 必须是非负整数';
101
- i = 1; // 选项从第二位开始解析
102
+ if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor must be a non-negative integer';
103
+ i = 1; // options start at the second position
102
104
  }
103
105
 
104
106
  const seen = new Set();
105
107
  for (; i < args.length; i++) {
106
108
  const opt = String(args[i]).toUpperCase();
107
- if (seen.has(opt)) throw `重复的 SCAN 选项: ${opt}`;
109
+ if (seen.has(opt)) throw `Duplicate SCAN option: ${opt}`;
108
110
  seen.add(opt);
109
111
 
110
112
  if (opt === 'MATCH') {
111
- if (i + 1 >= args.length) throw 'MATCH 需要一个 pattern 参数';
112
- i++; // 跳过 pattern 值
113
+ if (i + 1 >= args.length) throw 'MATCH requires a pattern argument';
114
+ i++; // skip the pattern value
113
115
  } else if (opt === 'COUNT') {
114
- if (i + 1 >= args.length) throw 'COUNT 需要一个数值参数';
116
+ if (i + 1 >= args.length) throw 'COUNT requires a numeric argument';
115
117
  const count = Number(args[i + 1]);
116
- if (!Number.isInteger(count) || count < 1) throw 'COUNT 必须是正整数';
117
- if (count > 5000) throw 'COUNT 超过上限 5000';
118
+ if (!Number.isInteger(count) || count < 1) throw 'COUNT must be a positive integer';
119
+ if (count > 5000) throw 'COUNT exceeds the cap of 5000';
118
120
  i++;
119
121
  } 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 需要一个类型名参数';
122
+ // TYPE is SCAN-only; HSCAN/SSCAN/ZSCAN do not support it (reject
123
+ // explicitly rather than deferring to the server)
124
+ if (hasKey) throw `${command} does not support the TYPE option (SCAN only)`;
125
+ if (i + 1 >= args.length) throw 'TYPE requires a type-name argument';
123
126
  i++;
124
127
  } else {
125
- throw `SCAN 不支持的选项: ${opt}`;
128
+ throw `Unsupported SCAN option: ${opt}`;
126
129
  }
127
130
  }
128
131
  }
129
132
 
130
133
  /**
131
- * LRANGE 范围校验:防客户端内存 DoS
132
- * stop < 0(负索引)→ 长度未知,要求 --allow-unbounded
133
- * start < 0 或 stop ≥ 0 时,检查 (stop - start + 1) ≤ maxItems
134
+ * LRANGE range validation: prevents client-side memory DoS
135
+ * stop < 0 (negative index) → length unknown, require --allow-unbounded
136
+ * when start < 0 or stop ≥ 0, check (stop - start + 1) ≤ maxItems
134
137
  */
135
138
  function validateLrange(args, command, limits) {
136
- if (args.length < 3) throw '至少需要 3 个参数(key start stop)';
139
+ if (args.length < 3) throw 'At least 3 arguments required (key start stop)';
137
140
  const start = Number(args[1]);
138
141
  const stop = Number(args[2]);
139
142
  if (!Number.isInteger(start) || !Number.isInteger(stop)) {
140
- throw 'start/stop 必须是整数';
143
+ throw 'start/stop must be integers';
141
144
  }
142
145
  const maxItems = (limits && limits.maxItems) || 200;
143
146
 
144
- // 负索引(stop < 0)表示到末尾,实际长度未知 → 要求显式放行
147
+ // A negative stop index means "to the end"; the actual length is unknown → require explicit opt-in
145
148
  if (stop < 0) {
146
149
  if (!(limits && limits.allowUnbounded)) {
147
- throw 'stop 为负索引时范围不确定,需 --allow-unbounded 或用 SCAN/LRANGE + 正索引';
150
+ throw 'A negative stop index makes the range unbounded; use --allow-unbounded or SCAN/LRANGE with non-negative indexes';
148
151
  }
149
- return; // 已放行,不再算范围
152
+ return; // opted in; skip the range check
150
153
  }
151
- // start 为负索引时,Redis 换算后实际起始位置不确定 → 同样要求放行
154
+ // A negative start index resolves to an unpredictable position in Redis → also require opt-in
152
155
  if (start < 0) {
153
156
  if (!(limits && limits.allowUnbounded)) {
154
- throw 'start 为负索引时范围不确定,需 --allow-unbounded 或用 LRANGE + 正索引';
157
+ throw 'A negative start index makes the range unbounded; use --allow-unbounded or LRANGE with non-negative indexes';
155
158
  }
156
159
  return;
157
160
  }
158
- // 双方都是非负索引,可精确计算返回数量
161
+ // Both indexes non-negative: the returned count can be computed exactly
159
162
  const count = stop - start + 1;
160
163
  if (count > maxItems) {
161
- throw `范围 ${start}..${stop} 共 ${count} 项,超过 maxItems=${maxItems}`;
164
+ throw `Range ${start}..${stop} yields ${count} items, exceeding maxItems=${maxItems}`;
162
165
  }
163
166
  }
164
167
 
165
168
  /**
166
- * ZRANGE Redis 6.2+ 完整语法校验
167
- * 支持 BYSCORE / BYLEX / REV / LIMIT / WITHSCORES 选项
168
- * BYLEX 模式无 LIMIT 时要求 --allow-unbounded(词法范围可能很长)
169
- * 含 LIMIT 时 count 必须 ≤ maxItems
169
+ * Full ZRANGE syntax validation for Redis 6.2+
170
+ * Supports BYSCORE / BYLEX / REV / LIMIT / WITHSCORES options
171
+ * BYLEX without LIMIT requires --allow-unbounded (lexicographic ranges can be long)
172
+ * With LIMIT, count must be ≤ maxItems
170
173
  */
171
174
  function validateZrange(args, command, limits) {
172
- if (args.length < 3) throw 'ZRANGE 至少需要 key min max';
175
+ if (args.length < 3) throw 'ZRANGE requires at least key min max';
173
176
  const maxItems = (limits && limits.maxItems) || 200;
174
177
 
175
- // 收集选项关键字(跳过前 3 个必选参数 key min max)
178
+ // Collect option keywords (skip the 3 required arguments key min max)
176
179
  const optsUpper = args.slice(3).map(a => String(a).toUpperCase());
177
180
  let hasByscore = false;
178
181
  let hasBylex = false;
@@ -188,94 +191,95 @@ function validateZrange(args, command, limits) {
188
191
  if (opt === 'WITHSCORES') { continue; }
189
192
  if (opt === 'LIMIT') {
190
193
  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
+ // LIMIT takes offset and count
195
+ if (i + 2 >= optsUpper.length) throw 'LIMIT requires offset and count';
196
+ const offset = Number(args[3 + i + 1]); // corresponding position in the original args
194
197
  limitCount = Number(args[3 + i + 2]);
195
198
  if (!Number.isInteger(offset) || !Number.isInteger(limitCount) || offset < 0 || limitCount < 0) {
196
- throw 'LIMIT offset/count 必须是非负整数';
199
+ throw 'LIMIT offset/count must be non-negative integers';
197
200
  }
198
- if (limitCount > maxItems) throw `LIMIT count ${limitCount} 超过 maxItems=${maxItems}`;
199
- i += 2; // 跳过 offset 和 count
201
+ if (limitCount > maxItems) throw `LIMIT count ${limitCount} exceeds maxItems=${maxItems}`;
202
+ i += 2; // skip offset and count
200
203
  continue;
201
204
  }
202
- throw `ZRANGE 不支持的选项: ${opt}`;
205
+ throw `Unsupported ZRANGE option: ${opt}`;
203
206
  }
204
207
 
205
- // BYLEX 模式无 LIMIT → 词法范围可能很大,要求放行
208
+ // BYLEX without LIMIT → the lexicographic range can be huge; require opt-in
206
209
  if (hasBylex && !hasLimit) {
207
210
  if (!(limits && limits.allowUnbounded)) {
208
- throw 'BYLEX 模式无 LIMIT 时范围不确定,需 --allow-unbounded';
211
+ throw 'BYLEX without LIMIT has an unbounded range; use --allow-unbounded';
209
212
  }
210
213
  }
211
- // BYSCORE 默认索引范围(如 0 -1)无 LIMIT → 同样要求放行
214
+ // BYSCORE with the default index range (e.g. 0 -1) and no LIMIT → also require opt-in
212
215
  if (hasByscore && !hasLimit) {
213
216
  if (!(limits && limits.allowUnbounded)) {
214
- throw 'BYSCORE 模式无 LIMIT 时范围不确定,需 --allow-unbounded';
217
+ throw 'BYSCORE without LIMIT has an unbounded range; use --allow-unbounded';
215
218
  }
216
219
  }
217
- // 默认索引模式(非 BYSCORE/BYLEX),检查 min/max 范围
220
+ // Default index mode (not BYSCORE/BYLEX): check the min/max range
218
221
  if (!hasByscore && !hasBylex) {
219
- // min/max 作为 rank 索引时,负索引表示到末尾,范围不确定
222
+ // With min/max as rank indexes, a negative index means "to the end"; the
223
+ // range is unbounded
220
224
  const minVal = Number(args[1]);
221
225
  const maxVal = Number(args[2]);
222
226
  if ((minVal < 0 || maxVal < 0) && !hasLimit) {
223
227
  if (!(limits && limits.allowUnbounded)) {
224
- throw 'ZRANGE 含负索引且无 LIMIT,范围不确定,需 --allow-unbounded';
228
+ throw 'ZRANGE with a negative index and no LIMIT has an unbounded range; use --allow-unbounded';
225
229
  }
226
230
  }
227
- // 非负索引且有 LIMIT 时,LIMIT 已校验 count,安全
228
- // 非负索引且无 LIMIT 时,需计算范围
231
+ // Non-negative indexes with LIMIT: LIMIT already validated the count; safe
232
+ // Non-negative indexes without LIMIT: compute the range
229
233
  if (minVal >= 0 && maxVal >= 0 && !hasLimit) {
230
234
  const rangeCount = maxVal - minVal + 1;
231
235
  if (rangeCount > maxItems) {
232
- throw `范围 ${minVal}..${maxVal} 共 ${rangeCount} 项,超过 maxItems=${maxItems}`;
236
+ throw `Range ${minVal}..${maxVal} yields ${rangeCount} items, exceeding maxItems=${maxItems}`;
233
237
  }
234
238
  }
235
239
  }
236
240
  }
237
241
 
238
242
  /**
239
- * ZRANGEBYSCORE 必须带 LIMIT offset count 且 count 不超上限
243
+ * ZRANGEBYSCORE must carry LIMIT offset count, and count must not exceed the cap
240
244
  */
241
245
  function validateZrangeByScore(args) {
242
- if (args.length < 3) throw 'ZRANGEBYSCORE 至少需要 key min max';
243
- // 检查是否含 LIMIT
246
+ if (args.length < 3) throw 'ZRANGEBYSCORE requires at least key min max';
247
+ // Check for LIMIT
244
248
  const argsUpper = args.map(a => String(a).toUpperCase());
245
249
  const limitIdx = argsUpper.indexOf('LIMIT');
246
250
  if (limitIdx === -1) {
247
- throw 'ZRANGEBYSCORE 必须带 LIMIT 子句(防止无界结果集)';
251
+ throw 'ZRANGEBYSCORE requires a LIMIT clause (prevents unbounded result sets)';
248
252
  }
249
253
  if (limitIdx + 2 >= args.length) {
250
- throw 'LIMIT 需要 offset 和 count 两个参数';
254
+ throw 'LIMIT requires both offset and count arguments';
251
255
  }
252
256
  const count = Number(args[limitIdx + 2]);
253
- if (!Number.isInteger(count) || count < 0) throw 'LIMIT count 必须是非负整数';
254
- if (count > 5000) throw 'LIMIT count 超过上限 5000';
257
+ if (!Number.isInteger(count) || count < 0) throw 'LIMIT count must be a non-negative integer';
258
+ if (count > 5000) throw 'LIMIT count exceeds the cap of 5000';
255
259
  }
256
260
 
257
261
  /**
258
- * CLIENT 只允许 LIST 子命令
259
- * KILL/PAUSE/UNBLOCK 可中断其他连接,拒绝
262
+ * CLIENT allows only the LIST subcommand
263
+ * KILL/PAUSE/UNBLOCK can disrupt other connections; reject them
260
264
  */
261
265
  function validateClient(args) {
262
- if (args.length === 0) throw 'CLIENT 需要子命令';
266
+ if (args.length === 0) throw 'CLIENT requires a subcommand';
263
267
  const sub = String(args[0]).toUpperCase();
264
- if (sub !== 'LIST') throw `不允许 CLIENT ${sub}(只允许 CLIENT LIST)`;
268
+ if (sub !== 'LIST') throw `CLIENT ${sub} is not allowed (only CLIENT LIST is allowed)`;
265
269
  }
266
270
 
267
271
  /**
268
- * CONFIG 只允许 GET 子命令,且 config key 在安全白名单内
269
- * 拒绝 *(转储全部配置)、requirepass、masterauth 等敏感项
272
+ * CONFIG allows only the GET subcommand, and the config key must be on the safe list
273
+ * Reject * (dumps the whole config) and sensitive keys like requirepass and masterauth
270
274
  */
271
275
  function validateConfig(args) {
272
- if (args.length === 0) throw 'CONFIG 需要子命令';
276
+ if (args.length === 0) throw 'CONFIG requires a subcommand';
273
277
  const sub = String(args[0]).toUpperCase();
274
- if (sub !== 'GET') throw `不允许 CONFIG ${sub}(只允许 CONFIG GET)`;
278
+ if (sub !== 'GET') throw `CONFIG ${sub} is not allowed (only CONFIG GET is allowed)`;
275
279
 
276
- if (args.length < 2) throw 'CONFIG GET 至少需要一个参数';
280
+ if (args.length < 2) throw 'CONFIG GET requires at least one argument';
277
281
 
278
- // 安全白名单:只允许查看非敏感的运行时配置
282
+ // Safe list: only non-sensitive runtime configs may be viewed
279
283
  const SAFE_CONFIG_KEYS = new Set([
280
284
  'databases', 'maxmemory', 'maxmemory-policy', 'maxmemory-samples',
281
285
  'timeout', 'tcp-keepalive', 'appendonly', 'appendfsync',
@@ -287,60 +291,61 @@ function validateConfig(args) {
287
291
 
288
292
  for (let i = 1; i < args.length; i++) {
289
293
  const key = String(args[i]).toLowerCase();
290
- if (key === '*') throw '不允许 CONFIG GET *(会转储全部配置含密码)';
294
+ if (key === '*') throw 'CONFIG GET * is not allowed (dumps the entire config including passwords)';
291
295
  if (key === 'requirepass' || key === 'masterauth') {
292
- throw `不允许查询 ${key}(可能暴露密码)`;
296
+ throw `Querying ${key} is not allowed (may expose passwords)`;
293
297
  }
294
- // 包含 tls/key/pass/secret/acl 等关键词的一律拒绝
298
+ // Reject any key containing markers like tls/key/pass/secret/acl
295
299
  if (/tls[_-]?key|pass|secret|acl[_-]file|unixsocket/i.test(key)) {
296
- throw `不允许查询敏感配置: ${key}`;
300
+ throw `Querying sensitive config is not allowed: ${key}`;
297
301
  }
298
302
  if (!SAFE_CONFIG_KEYS.has(key)) {
299
- throw `CONFIG GET ${key} 不在安全白名单中`;
303
+ throw `CONFIG GET ${key} is not on the safe list`;
300
304
  }
301
305
  }
302
306
  }
303
307
 
304
- // ── 主校验入口 ──
308
+ // ── Main validation entry ──
305
309
 
306
310
  /**
307
- * 校验 Redis 命令是否在只读白名单内,并校验参数
308
- * 未知命令一律拒绝(fail-closed)
311
+ * Validate that a Redis command is on the read-only allowlist and validate its arguments
312
+ * Unknown commands are always rejected (fail-closed)
309
313
  *
310
- * @param {string[]} argv - 完整命令数组,[cmd, ...args]
314
+ * @param {string[]} argv - full command array, [cmd, ...args]
311
315
  * @param {object} limits - { maxItems: number, allowUnbounded: boolean }
312
316
  * @returns {{ command: string, args: string[], shape: string, unbounded: boolean }}
313
317
  */
314
318
  function validateRedisCommand(argv, limits) {
315
319
  if (!Array.isArray(argv) || argv.length === 0) {
316
- throw new RedisReadonlyError('Redis 命令不能为空');
320
+ throw new RedisReadonlyError('Redis command must not be empty');
317
321
  }
318
322
 
319
323
  const command = String(argv[0]).toUpperCase();
320
324
  const policy = READ_ONLY_COMMANDS[command];
321
325
 
322
326
  if (!policy) {
323
- throw new RedisReadonlyError(`不允许的命令: ${command}(不在只读白名单中)`);
327
+ throw new RedisReadonlyError(`Command not allowed: ${command} (not on the read-only allowlist)`);
324
328
  }
325
329
 
326
- // 检查 arity(参数个数约束)
330
+ // Check arity (argument count constraint)
327
331
  if (policy.arity !== null && argv.length - 1 !== policy.arity) {
328
- throw new RedisReadonlyError(`${command} 需要 ${policy.arity} 个参数,收到 ${argv.length - 1} 个`);
332
+ throw new RedisReadonlyError(`${command} requires ${policy.arity} argument(s), got ${argv.length - 1}`);
329
333
  }
330
334
 
331
- // 无界命令需要显式放行
335
+ // Unbounded commands require explicit opt-in
332
336
  if (policy.unbounded && !(limits && limits.allowUnbounded)) {
333
337
  throw new RedisReadonlyError(
334
- `${command} 可能返回大量数据,需添加 --allow-unbounded 参数确认(建议优先使用 SCAN 类命令)`
338
+ `${command} may return a large amount of data; add --allow-unbounded to confirm (prefer SCAN-family commands)`
335
339
  );
336
340
  }
337
341
 
338
- // 运行策略特定的参数校验;传入命令名和 limits 以便各函数做范围/无界校验
342
+ // Run the policy-specific argument validator; pass the command name and
343
+ // limits so each function can do range/unbounded checks
339
344
  const args = argv.slice(1);
340
345
  try {
341
346
  policy.validate(args, command, limits);
342
347
  } catch (msg) {
343
- throw new RedisReadonlyError(`${command} 参数错误: ${msg}`);
348
+ throw new RedisReadonlyError(`${command} argument error: ${msg}`);
344
349
  }
345
350
 
346
351
  return {
@@ -2,21 +2,21 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * MySQL 只读查询入口
6
- * 编排流程:CLI 解析 → SQL 只读校验 → 建连(只读事务+超时)→ 查询 → 输出 → 回滚关闭
7
- * 安全要点:客户端 timer + 服务端 MAX_EXECUTION_TIME 双保险;永远 ROLLBACK
5
+ * MySQL read-only query entry point
6
+ * Pipeline: CLI parsing → SQL read-only validation → connect (read-only txn + timeout) → query → output → rollback & close
7
+ * Security: client-side timer + server-side MAX_EXECUTION_TIME as dual safeguards; always ROLLBACK
8
8
  */
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
 
13
- // ── 内部模块 ──
13
+ // ── Internal modules ──
14
14
  const { parseCli, CliError } = require('./lib/cli');
15
15
  const { loadEnvFile, resolveEnv, resolveEnvNumber, ConfigError, sanitizeTarget, safeMessage, buildTlsOptions } = require('./lib/config');
16
16
  const { validateMysqlSql, ReadonlyError } = require('./lib/mysql-readonly');
17
17
  const { formatMysqlTable, formatJson } = require('./lib/output');
18
18
 
19
- // ── CLI 参数规格 ──
19
+ // ── CLI option spec ──
20
20
  const MYSQL_SPEC = {
21
21
  host: { type: 'string', default: '127.0.0.1' },
22
22
  port: { type: 'integer', default: 3306, min: 1, max: 65535 },
@@ -37,28 +37,28 @@ const MYSQL_SPEC = {
37
37
 
38
38
  const ENV_PREFIX = 'DB_QUERY_MYSQL_';
39
39
 
40
- /** 从文件读取 SQL(限 64KiB 普通文本) */
40
+ /** Read SQL from a file (plain text, max 64KiB) */
41
41
  function readSqlFile(filePath) {
42
42
  const resolved = path.resolve(filePath);
43
- if (!fs.existsSync(resolved)) throw new Error(`SQL 文件不存在: ${resolved}`);
43
+ if (!fs.existsSync(resolved)) throw new Error(`SQL file not found: ${resolved}`);
44
44
  const stat = fs.statSync(resolved);
45
- if (stat.size > 64 * 1024) throw new Error(`SQL 文件超过 64KiB: ${resolved}`);
45
+ if (stat.size > 64 * 1024) throw new Error(`SQL file exceeds 64KiB: ${resolved}`);
46
46
  return fs.readFileSync(resolved, 'utf8');
47
47
  }
48
48
 
49
- /** 打印校验结果(--validate-only 模式) */
49
+ /** Print validation result (--validate-only mode) */
50
50
  function printValidation(checked) {
51
- process.stderr.write(`✓ 校验通过: ${checked.kind}${checked.limitInjected ? ' (已注入 LIMIT 截断)' : ''}\n`);
52
- process.stderr.write(`处理后的 SQL:\n${checked.sql}\n`);
51
+ process.stderr.write(`✓ Validation passed: ${checked.kind}${checked.limitInjected ? ' (LIMIT injected for truncation)' : ''}\n`);
52
+ process.stderr.write(`Processed SQL:\n${checked.sql}\n`);
53
53
  }
54
54
 
55
- /** 带超时的查询执行(客户端 timer + 服务端 MAX_EXECUTION_TIME 双保险) */
55
+ /** Execute the query with a timeout (client-side timer + server-side MAX_EXECUTION_TIME as dual safeguards) */
56
56
  function queryWithTimeout(conn, sql, timeoutMs) {
57
57
  return new Promise((resolve, reject) => {
58
58
  const timer = setTimeout(() => {
59
- // 客户端超时:销毁连接,避免服务端继续传输
59
+ // Client-side timeout: destroy the connection to stop server transmission
60
60
  conn.destroy();
61
- reject(new Error(`查询超时 (${timeoutMs}ms)`));
61
+ reject(new Error(`Query timed out (${timeoutMs}ms)`));
62
62
  }, timeoutMs);
63
63
 
64
64
  conn.query(sql)
@@ -67,56 +67,59 @@ function queryWithTimeout(conn, sql, timeoutMs) {
67
67
  });
68
68
  }
69
69
 
70
- /** 安全回滚(忽略错误,确保连接一定关闭) */
70
+ /** Roll back safely (ignore errors so the connection always closes) */
71
71
  async function safeRollback(conn) {
72
- try { await conn.query('ROLLBACK'); } catch (_) { /* 事务不存在等错误忽略 */ }
72
+ try { await conn.query('ROLLBACK'); } catch (_) { /* ignore errors such as no active transaction */ }
73
73
  }
74
74
 
75
- /** 安全关闭连接 */
75
+ /** Close the connection safely */
76
76
  async function safeClose(conn) {
77
- try { await conn.end(); } catch (_) { /* 连接已断开等错误忽略 */ }
77
+ try { await conn.end(); } catch (_) { /* ignore errors such as connection already closed */ }
78
78
  }
79
79
 
80
- // ── 主函数 ──
80
+ // ── Main ──
81
81
  async function main() {
82
82
  const { options, rest } = parseCli(process.argv.slice(2), MYSQL_SPEC);
83
83
 
84
- // SQL 来源校验:--file 和位置参数二选一
84
+ // SQL source check: --file and a positional argument are mutually exclusive
85
85
  if (options.file && rest.length > 0) {
86
- throw new CliError('--file 和位置参数 SQL 不能同时指定');
86
+ throw new CliError('--file and a positional SQL argument cannot both be given');
87
87
  }
88
88
  if (!options.file && rest.length === 0) {
89
- throw new CliError('必须指定 SQL(位置参数或 --file)');
89
+ throw new CliError('SQL must be provided (positional argument or --file)');
90
90
  }
91
91
 
92
- // 读取 SQL(在任何连接操作之前)
92
+ // Read the SQL (before any connection setup)
93
93
  const rawSql = options.file ? readSqlFile(options.file) : rest[0];
94
94
 
95
- // 只读校验(纯离线,不依赖任何连接参数)
95
+ // Read-only validation (fully offline, independent of connection parameters)
96
96
  const maxRows = options.maxRows;
97
97
  const checked = validateMysqlSql(rawSql, { maxRows });
98
98
 
99
- // --validate-only:纯离线校验,不解析凭据、不建连
99
+ // --validate-only: pure offline validation, no credential resolution, no connection
100
100
  if (options['validate-only']) {
101
101
  return printValidation(checked);
102
102
  }
103
103
 
104
- // ── 以下为连库路径,需要连接参数 ──
105
- // 修复:env 文件加载必须在 user/database 必填检查之前,否则 env 文件提供的凭据读不到
104
+ // ── Connection path below: requires connection parameters ──
105
+ // Fix: env file loading must happen before the required user/database checks,
106
+ // otherwise credentials provided by the env file are not visible
106
107
  const envPairs = loadEnvFile(options['env-file']);
107
108
 
108
- // 合并环境变量覆盖(统一用 resolveEnvNumber 做边界校验,防 env 文件绕过硬上限)
109
+ // Merge environment overrides (resolveEnvNumber uniformly enforces bounds,
110
+ // so env files cannot bypass hard limits)
109
111
  const host = resolveEnv(envPairs, ENV_PREFIX + 'HOST', options.host);
110
112
  const port = resolveEnvNumber(envPairs, ENV_PREFIX + 'PORT', options.port, { min: 1, max: 65535 });
111
113
  const user = resolveEnv(envPairs, ENV_PREFIX + 'USER', options.user);
112
114
  const password = resolveEnv(envPairs, ENV_PREFIX + 'PASSWORD', options.password);
113
115
  const database = resolveEnv(envPairs, ENV_PREFIX + 'DATABASE', options.database);
114
116
 
115
- // user 必填;database 放宽为可选——排查第一步常是 SHOW DATABASES / SELECT 1 /
116
- // 带库名前缀的表查询,不强制默认库;查表未指定库时 MySQL 会自报 ER_NO_DB_ERROR
117
- if (!user) throw new CliError('缺少 user(用 --user、DB_QUERY_MYSQL_USER、环境变量或 env 文件提供)');
117
+ // user is required; database is optional — the first triage step is often
118
+ // SHOW DATABASES / SELECT 1 / db-prefixed table queries with no default db;
119
+ // MySQL itself reports ER_NO_DB_ERROR when a table query omits the database
120
+ if (!user) throw new CliError('Missing user (provide via --user, DB_QUERY_MYSQL_USER, environment variable, or env file)');
118
121
  if (!database) {
119
- process.stderr.write('(未指定 database:仅能执行无默认库的查询,如 SHOW DATABASES / SELECT 1 / 带库名前缀的表)\n');
122
+ process.stderr.write('(no database specified: only queries that need no default db will work, e.g. SHOW DATABASES / SELECT 1 / db-prefixed tables)\n');
120
123
  }
121
124
 
122
125
  const maxRowsResolved = resolveEnvNumber(envPairs, ENV_PREFIX + 'MAX_ROWS', options.maxRows, { min: 1, max: 5000 });
@@ -124,11 +127,11 @@ async function main() {
124
127
  const timeoutMs = resolveEnvNumber(envPairs, ENV_PREFIX + 'TIMEOUT_MS', options['timeout-ms'], { min: 1000, max: 60000 });
125
128
  const connectTimeoutMs = resolveEnvNumber(envPairs, ENV_PREFIX + 'CONNECT_TIMEOUT_MS', options['connect-timeout-ms'], { min: 1000, max: 30000 });
126
129
 
127
- // 打印脱敏目标(连库前确认去向,绝不暴露密码)
130
+ // Print sanitized target (confirm the destination before connecting; never expose the password)
128
131
  const target = sanitizeTarget({ host, port, user, database });
129
132
  process.stderr.write(`→ MySQL ${target}\n`);
130
133
 
131
- // 动态加载 mysql2(只在需要连库时)
134
+ // Load mysql2 lazily (only when a connection is needed)
132
135
  const mysql = require('mysql2/promise');
133
136
 
134
137
  const conn = await mysql.createConnection({
@@ -138,11 +141,11 @@ async function main() {
138
141
  password: password || undefined,
139
142
  database,
140
143
  connectTimeout: connectTimeoutMs,
141
- multipleStatements: false, // 显式关闭,不依赖驱动默认值
144
+ multipleStatements: false, // explicitly disabled; do not rely on driver defaults
142
145
  supportBigNumbers: true,
143
- bigNumberStrings: true, // BIGINT/DECIMAL 保留字符串精度
146
+ bigNumberStrings: true, // keep BIGINT/DECIMAL as strings for precision
144
147
  decimalNumbers: false,
145
- dateStrings: true, // 日期保留服务端文本
148
+ dateStrings: true, // keep dates as server-side text
146
149
  ...(options.tls || options['ca-file']
147
150
  ? { ssl: buildTlsOptions(options['ca-file']) || { rejectUnauthorized: true } }
148
151
  : {}),
@@ -150,7 +153,8 @@ async function main() {
150
153
 
151
154
  const startMs = Date.now();
152
155
  try {
153
- // 服务端只读事务(不支持的版本会报错,fail-closed 不静默降级)
156
+ // Server-side read-only transaction (unsupported versions raise an error;
157
+ // fail-closed, no silent downgrade)
154
158
  await conn.query('SET SESSION TRANSACTION READ ONLY');
155
159
  await conn.query('SET SESSION MAX_EXECUTION_TIME = ?', [timeoutMs]);
156
160
  await conn.query('START TRANSACTION READ ONLY');
@@ -158,12 +162,12 @@ async function main() {
158
162
  const [rows, fields] = await queryWithTimeout(conn, checked.sql, timeoutMs);
159
163
  const elapsedMs = Date.now() - startMs;
160
164
 
161
- // 判断是否有截断(LIMIT 注入了 maxRows+1 哨兵行)
165
+ // Detect truncation (LIMIT injected a maxRows+1 sentinel row)
162
166
  const truncated = checked.limitInjected && rows.length > maxRowsResolved;
163
167
  const displayRows = truncated ? rows.slice(0, maxRowsResolved) : rows;
164
168
 
165
169
  if (options.format === 'json') {
166
- // JSON 写 stdout,状态写 stderr,保证 stdout 是合法 JSON
170
+ // JSON to stdout, status to stderr, so stdout stays valid JSON
167
171
  process.stdout.write(JSON.stringify({
168
172
  source: 'mysql',
169
173
  target,
@@ -180,9 +184,9 @@ async function main() {
180
184
  }) + '\n');
181
185
  }
182
186
 
183
- process.stderr.write(`✓ ${rows.length} 行${truncated ? '(已截断)' : ''},${elapsedMs}ms\n`);
187
+ process.stderr.write(`✓ ${rows.length} row(s)${truncated ? ' (truncated)' : ''} in ${elapsedMs}ms\n`);
184
188
  } finally {
185
- // 永远 ROLLBACK,不发 COMMIT(防御性,确保不留事务)
189
+ // Always ROLLBACK, never COMMIT (defensive: guarantees no transaction is left open)
186
190
  await safeRollback(conn);
187
191
  await safeClose(conn);
188
192
  }