resplite 1.5.0 → 1.5.2

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.
package/README.md CHANGED
@@ -62,6 +62,10 @@ const log = new LemonLog('RESPlite');
62
62
  const srv = await createRESPlite({
63
63
  port: 6380,
64
64
  db: './data.db',
65
+ commandPolicy: {
66
+ rename: { KEYS: 'SAFE_KEYS' }, // original KEYS is blocked
67
+ disabled: ['MONITOR'], // blocked as unsupported
68
+ },
65
69
  hooks: {
66
70
  onUnknownCommand({ command, argv, clientAddress }) {
67
71
  log.warn({ command, argv, clientAddress }, 'unsupported command');
@@ -84,6 +88,28 @@ Available hooks:
84
88
 
85
89
  If you want a tiny in-process smoke test that starts RESPLite and connects with the `redis` client in the same script, see [Minimal embedded example](#minimal-embedded-example) below.
86
90
 
91
+ ### Command hardening (rename/disable)
92
+
93
+ You can harden the command surface by renaming sensitive commands and/or disabling them:
94
+
95
+ ```javascript
96
+ const srv = await createRESPlite({
97
+ db: './secure.db',
98
+ commandPolicy: {
99
+ rename: {
100
+ KEYS: 'SAFE_KEYS',
101
+ DEL: 'RMDEL',
102
+ },
103
+ disabled: ['MONITOR', 'CLIENT'],
104
+ },
105
+ });
106
+ ```
107
+
108
+ Behavior:
109
+ - `rename`: only the new alias is accepted; the original command name is blocked.
110
+ - `disabled`: command is blocked and replies with `ERR command not supported yet`.
111
+ - `COMMAND`, `COMMAND COUNT`, and `COMMAND INFO` expose only visible commands (including aliases, excluding blocked names).
112
+
87
113
  ## Migration from Redis
88
114
 
89
115
  RESPLite is a good fit for migrating **non-replicated Redis** instances that have **grown large** (e.g. tens of GB) and where RESPLite's latency is acceptable. The recommended path is to drive the migration from a Node.js script via `resplite/migration`, keeping preflight, dirty tracking, bulk import, cutover, and verification in one place.
@@ -574,7 +600,7 @@ To reproduce the benchmark, run `npm run benchmark -- --template default`. Numbe
574
600
  | **Connection** | PING, ECHO, QUIT |
575
601
  | **Strings** | GET, SET, MGET, MSET, DEL, EXISTS, INCR, DECR, INCRBY, DECRBY, STRLEN |
576
602
  | **TTL** | EXPIRE, PEXPIRE, TTL, PTTL, PERSIST |
577
- | **Hashes** | HSET, HGET, HMGET, HGETALL, HKEYS, HVALS, HDEL, HEXISTS, HINCRBY |
603
+ | **Hashes** | HSET, HGET, HMGET, HGETALL, HKEYS, HVALS, HDEL, HEXISTS, HINCRBY, HEXPIRE, HTTL, HPERSIST |
578
604
  | **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD, SPOP, SRANDMEMBER |
579
605
  | **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, LSET, LTRIM, BLPOP, BRPOP |
580
606
  | **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZRANK, ZREVRANK, ZCOUNT, ZINCRBY, ZREMRANGEBYRANK, ZREMRANGEBYSCORE |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -98,9 +98,13 @@ function spawnResplite(templateName, port, dbPath) {
98
98
  }
99
99
 
100
100
  function formatNum(n) {
101
+ if (!Number.isFinite(n)) return '—';
101
102
  if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
102
103
  if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K';
103
- return String(n);
104
+ if (n >= 100) return n.toFixed(2);
105
+ if (n >= 10) return n.toFixed(2);
106
+ if (n >= 1) return n.toFixed(2);
107
+ return n.toFixed(3);
104
108
  }
105
109
 
106
110
  function formatMs(ms) {
@@ -603,23 +607,41 @@ async function main() {
603
607
  for (const { client } of respliteClients) await client.quit();
604
608
  for (const { child } of children) child.kill();
605
609
 
606
- const colWidth = 10;
607
610
  const headerCols = ['Suite', 'Redis', ...templateNames];
608
- const sep = headerCols.map((_, i) => (i === 0 ? '-'.repeat(18) : '-'.repeat(colWidth))).join('|');
609
- console.log('');
610
- console.log('--- Summary (ops/sec) ---');
611
- console.log(headerCols.map((h, i) => (i === 0 ? h.padEnd(18) : h.padStart(colWidth))).join(' | '));
612
- console.log(sep);
613
- for (const r of results) {
614
- if (r.error) {
615
- console.log(`${r.suite.padEnd(18)} | ERROR: ${r.error}`);
616
- continue;
617
- }
611
+ const summaryRows = results.map((r) => {
612
+ if (r.error) return { suite: r.suite, values: [`ERROR: ${r.error}`] };
618
613
  const redisVal = r.redis.error ? '—' : formatNum(r.redis.opsPerSec);
619
614
  const templateVals = templateNames.map((t) => (r.templates[t]?.error ? '—' : formatNum(r.templates[t]?.opsPerSec)));
620
- console.log(
621
- [r.suite.padEnd(18), redisVal.padStart(colWidth), ...templateVals.map((v) => v.padStart(colWidth))].join(' | ')
622
- );
615
+ return { suite: r.suite, values: [redisVal, ...templateVals] };
616
+ });
617
+
618
+ const suiteWidth = Math.max(
619
+ headerCols[0].length,
620
+ ...summaryRows.map((r) => r.suite.length)
621
+ );
622
+ const valueWidths = headerCols.slice(1).map((h, idx) =>
623
+ Math.max(
624
+ h.length,
625
+ ...summaryRows.map((r) => (r.values[idx] || '').length)
626
+ )
627
+ );
628
+
629
+ console.log('');
630
+ console.log('--- Summary (ops/sec) ---');
631
+ console.log([
632
+ headerCols[0].padEnd(suiteWidth),
633
+ ...headerCols.slice(1).map((h, idx) => h.padStart(valueWidths[idx])),
634
+ ].join(' | '));
635
+ console.log([
636
+ '-'.repeat(suiteWidth),
637
+ ...valueWidths.map((w) => '-'.repeat(w)),
638
+ ].join('-|-'));
639
+
640
+ for (const row of summaryRows) {
641
+ console.log([
642
+ row.suite.padEnd(suiteWidth),
643
+ ...row.values.map((v, idx) => v.padStart(valueWidths[idx])),
644
+ ].join(' | '));
623
645
  }
624
646
 
625
647
  console.log('');
@@ -15,42 +15,42 @@ const WRITE_COMMANDS = new Set([
15
15
  * @param {string} name - Command name (lowercase for reply).
16
16
  * @returns {Array<string|number|string[]>}
17
17
  */
18
- function docFor(name) {
18
+ function docFor(name, canonicalName = name) {
19
19
  const lower = name.toLowerCase();
20
- const flags = WRITE_COMMANDS.has(name) ? ['write', 'fast'] : ['readonly', 'fast'];
20
+ const flags = WRITE_COMMANDS.has(canonicalName) ? ['write', 'fast'] : ['readonly', 'fast'];
21
21
  let arity = 2;
22
22
  let firstKey = 1;
23
23
  let lastKey = 1;
24
24
  let step = 1;
25
- if (['MGET', 'MSET', 'DEL', 'UNLINK', 'EXISTS', 'KEYS', 'SCAN', 'PING', 'ECHO', 'QUIT', 'TYPE', 'OBJECT', 'SQLITE.INFO', 'CACHE.INFO', 'MEMORY.INFO', 'COMMAND', 'MONITOR', 'CLIENT'].includes(name)) {
26
- if (['PING', 'ECHO', 'QUIT', 'COMMAND', 'MONITOR'].includes(name)) {
25
+ if (['MGET', 'MSET', 'DEL', 'UNLINK', 'EXISTS', 'KEYS', 'SCAN', 'PING', 'ECHO', 'QUIT', 'TYPE', 'OBJECT', 'SQLITE.INFO', 'CACHE.INFO', 'MEMORY.INFO', 'COMMAND', 'MONITOR', 'CLIENT'].includes(canonicalName)) {
26
+ if (['PING', 'ECHO', 'QUIT', 'COMMAND', 'MONITOR'].includes(canonicalName)) {
27
27
  firstKey = 0;
28
28
  lastKey = 0;
29
29
  step = 0;
30
- arity = name === 'COMMAND' ? -1 : (name === 'ECHO' ? 2 : 1);
31
- } else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(name)) {
30
+ arity = canonicalName === 'COMMAND' ? -1 : (canonicalName === 'ECHO' ? 2 : 1);
31
+ } else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(canonicalName)) {
32
32
  arity = -2;
33
33
  lastKey = -1;
34
- } else if (name === 'MSET') {
34
+ } else if (canonicalName === 'MSET') {
35
35
  arity = -3;
36
36
  lastKey = -1;
37
37
  step = 2;
38
- } else if (['DEL', 'UNLINK'].includes(name)) {
38
+ } else if (['DEL', 'UNLINK'].includes(canonicalName)) {
39
39
  arity = -2;
40
40
  lastKey = -1;
41
41
  }
42
- } else if (name.startsWith('FT.') || name.startsWith('SQLITE.') || name.startsWith('CACHE.') || name.startsWith('MEMORY.')) {
42
+ } else if (canonicalName.startsWith('FT.') || canonicalName.startsWith('SQLITE.') || canonicalName.startsWith('CACHE.') || canonicalName.startsWith('MEMORY.')) {
43
43
  firstKey = 0;
44
44
  lastKey = 0;
45
45
  step = 0;
46
46
  arity = -2;
47
- } else if (['BLPOP', 'BRPOP'].includes(name)) {
47
+ } else if (['BLPOP', 'BRPOP'].includes(canonicalName)) {
48
48
  arity = -3;
49
49
  lastKey = -1;
50
50
  step = 1;
51
- } else if (['HMGET', 'HGETALL', 'HGET', 'HSET', 'HDEL', 'HEXISTS', 'HINCRBY', 'HLEN'].includes(name)) {
52
- arity = (name === 'HGET' || name === 'HLEN' || name === 'HEXISTS') ? 3 : -3;
53
- } else if (name === 'SETEX') {
51
+ } else if (['HMGET', 'HGETALL', 'HGET', 'HSET', 'HDEL', 'HEXISTS', 'HINCRBY', 'HLEN'].includes(canonicalName)) {
52
+ arity = (canonicalName === 'HGET' || canonicalName === 'HLEN' || canonicalName === 'HEXISTS') ? 3 : -3;
53
+ } else if (canonicalName === 'SETEX') {
54
54
  arity = 4;
55
55
  }
56
56
  return [lower, arity, flags, firstKey, lastKey, step, []];
@@ -63,10 +63,13 @@ function docFor(name) {
63
63
  */
64
64
  export function handleCommand(engine, args, context) {
65
65
  const allNames = context?.getCommandNames ? context.getCommandNames() : [];
66
+ const resolveCanonical = context?.resolveCommandForIntrospection
67
+ ? (name) => context.resolveCommandForIntrospection(name)
68
+ : (name) => name;
66
69
  const sub = (args && args.length > 0 && Buffer.isBuffer(args[0])) ? args[0].toString('utf8').toUpperCase() : '';
67
70
 
68
71
  if (!sub || sub === '') {
69
- const reply = allNames.map((n) => docFor(n));
72
+ const reply = allNames.map((n) => docFor(n, resolveCanonical(n)));
70
73
  return reply;
71
74
  }
72
75
  if (sub === 'COUNT') {
@@ -75,7 +78,7 @@ export function handleCommand(engine, args, context) {
75
78
  if (sub === 'INFO') {
76
79
  const names = (args.slice(1) || []).map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)).toUpperCase());
77
80
  const set = new Set(allNames);
78
- const reply = names.map((n) => set.has(n) ? docFor(n) : null).filter((x) => x != null);
81
+ const reply = names.map((n) => set.has(n) ? docFor(n, resolveCanonical(n)) : null).filter((x) => x != null);
79
82
  return reply;
80
83
  }
81
84
 
@@ -91,6 +91,8 @@ import * as monitor from './monitor.js';
91
91
  import * as client from './client.js';
92
92
  import * as command from './command.js';
93
93
 
94
+ const COMPILED_POLICY_TAG = Symbol('compiled-command-policy');
95
+
94
96
  const HANDLERS = new Map([
95
97
  ['PING', (e, a) => ping.handlePing()],
96
98
  ['ECHO', (e, a) => echo.handleEcho(a)],
@@ -181,6 +183,100 @@ const HANDLERS = new Map([
181
183
  ['COMMAND', (e, a, ctx) => command.handleCommand(e, a, ctx)],
182
184
  ]);
183
185
 
186
+ function assertCommandName(value, field) {
187
+ if (typeof value !== 'string' || value.trim() === '') {
188
+ throw new Error(`invalid command policy: ${field} must be a non-empty string`);
189
+ }
190
+ return value.trim().toUpperCase();
191
+ }
192
+
193
+ function isCompiledPolicy(value) {
194
+ return !!(value && typeof value === 'object' && value[COMPILED_POLICY_TAG] === true);
195
+ }
196
+
197
+ export function compileCommandPolicy(policy = {}) {
198
+ if (policy == null) return null;
199
+ if (isCompiledPolicy(policy)) return policy;
200
+ if (typeof policy !== 'object') {
201
+ throw new Error('invalid command policy: expected an object');
202
+ }
203
+
204
+ const rename = policy.rename ?? {};
205
+ const disabled = policy.disabled ?? [];
206
+
207
+ if (rename == null || typeof rename !== 'object' || Array.isArray(rename)) {
208
+ throw new Error('invalid command policy: rename must be an object');
209
+ }
210
+ if (!Array.isArray(disabled)) {
211
+ throw new Error('invalid command policy: disabled must be an array');
212
+ }
213
+
214
+ const knownCommands = new Set(HANDLERS.keys());
215
+ const renamedOriginals = new Set();
216
+ const aliasToOriginal = new Map();
217
+
218
+ for (const [rawOriginal, rawAlias] of Object.entries(rename)) {
219
+ const original = assertCommandName(rawOriginal, 'rename key');
220
+ const alias = assertCommandName(rawAlias, `rename.${rawOriginal}`);
221
+ if (!knownCommands.has(original)) {
222
+ throw new Error(`invalid command policy: cannot rename unknown command "${original}"`);
223
+ }
224
+ if (alias === original) {
225
+ throw new Error(`invalid command policy: alias for "${original}" must be different`);
226
+ }
227
+ if (knownCommands.has(alias)) {
228
+ throw new Error(`invalid command policy: alias "${alias}" conflicts with existing command`);
229
+ }
230
+ if (aliasToOriginal.has(alias)) {
231
+ throw new Error(`invalid command policy: alias "${alias}" is duplicated`);
232
+ }
233
+ renamedOriginals.add(original);
234
+ aliasToOriginal.set(alias, original);
235
+ }
236
+
237
+ const disabledSet = new Set();
238
+ for (const rawName of disabled) {
239
+ const name = assertCommandName(rawName, 'disabled[] item');
240
+ disabledSet.add(name);
241
+ }
242
+
243
+ return {
244
+ [COMPILED_POLICY_TAG]: true,
245
+ disabledSet,
246
+ aliasToOriginal,
247
+ renamedOriginals,
248
+ };
249
+ }
250
+
251
+ function listVisibleCommandNames(policy) {
252
+ const names = [];
253
+ for (const name of HANDLERS.keys()) {
254
+ if (policy?.renamedOriginals?.has(name)) continue;
255
+ if (policy?.disabledSet?.has(name)) continue;
256
+ names.push(name);
257
+ }
258
+ if (policy?.aliasToOriginal) {
259
+ for (const [alias] of policy.aliasToOriginal.entries()) {
260
+ if (policy.disabledSet.has(alias)) continue;
261
+ names.push(alias);
262
+ }
263
+ }
264
+ return names;
265
+ }
266
+
267
+ function resolveIncomingCommand(inputCommand, policy) {
268
+ if (!policy) return { blocked: false, resolvedCommand: inputCommand };
269
+ if (policy.disabledSet.has(inputCommand)) {
270
+ return { blocked: true, resolvedCommand: inputCommand };
271
+ }
272
+ const target = policy.aliasToOriginal.get(inputCommand);
273
+ if (target) return { blocked: false, resolvedCommand: target };
274
+ if (policy.renamedOriginals.has(inputCommand)) {
275
+ return { blocked: true, resolvedCommand: inputCommand };
276
+ }
277
+ return { blocked: false, resolvedCommand: inputCommand };
278
+ }
279
+
184
280
  /**
185
281
  * Dispatch command. Full argv: [commandNameBuf, ...argBuffers].
186
282
  * @param {object} engine
@@ -193,10 +289,22 @@ export function dispatch(engine, argv, context) {
193
289
  return { error: 'ERR wrong number of arguments' };
194
290
  }
195
291
  const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
292
+ const policy = compileCommandPolicy(context?.commandPolicy);
293
+ const commandResolution = resolveIncomingCommand(cmd, policy);
294
+ if (commandResolution.blocked) {
295
+ return { error: unsupported() };
296
+ }
297
+ const resolvedCommand = commandResolution.resolvedCommand;
196
298
  const args = argv.slice(1);
197
299
  const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
198
- if (context) context.getCommandNames = () => Array.from(HANDLERS.keys());
199
- const handler = HANDLERS.get(cmd);
300
+ if (context) {
301
+ context.getCommandNames = () => listVisibleCommandNames(policy);
302
+ context.resolveCommandForIntrospection = (name) => {
303
+ if (!policy) return name;
304
+ return policy.aliasToOriginal.get(String(name).toUpperCase()) ?? String(name).toUpperCase();
305
+ };
306
+ }
307
+ const handler = HANDLERS.get(resolvedCommand);
200
308
  if (!handler) {
201
309
  context?.onUnknownCommand?.({
202
310
  command: cmd,
package/src/embed.js CHANGED
@@ -13,6 +13,7 @@ import net from 'node:net';
13
13
  import { handleConnection } from './server/connection.js';
14
14
  import { createEngine } from './engine/engine.js';
15
15
  import { openDb } from './storage/sqlite/db.js';
16
+ import { compileCommandPolicy } from './commands/registry.js';
16
17
 
17
18
  export { handleConnection, createEngine, openDb };
18
19
 
@@ -37,6 +38,7 @@ export { handleConnection, createEngine, openDb };
37
38
  * @param {Record<string, string|number>} [options.pragma] Override specific pragmas only when needed (e.g. { synchronous: 'FULL' }). Applied after the template.
38
39
  * @param {RESPliteHooks} [options.hooks] Optional event hooks for observability (onUnknownCommand, onCommandError, onSocketError).
39
40
  * @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to call close(). Set false if you handle shutdown yourself to avoid double handlers.
41
+ * @param {{ rename?: Record<string, string>, disabled?: string[] } | null} [options.commandPolicy] Optional: rename/disable commands for hardening.
40
42
  * @returns {Promise<{ port: number, host: string, close: () => Promise<void> }>}
41
43
  */
42
44
  export async function createRESPlite({
@@ -47,7 +49,9 @@ export async function createRESPlite({
47
49
  pragma,
48
50
  hooks = {},
49
51
  gracefulShutdown = true,
52
+ commandPolicy = null,
50
53
  } = {}) {
54
+ const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
51
55
  const db = openDb(dbPath, { pragmaTemplate, pragma });
52
56
  const engine = createEngine({ db });
53
57
  const connections = new Set();
@@ -55,7 +59,7 @@ export async function createRESPlite({
55
59
  const server = net.createServer((socket) => {
56
60
  connections.add(socket);
57
61
  socket.once('close', () => connections.delete(socket));
58
- handleConnection(socket, engine, hooks);
62
+ handleConnection(socket, engine, hooks, compiledCommandPolicy);
59
63
  });
60
64
  await new Promise((resolve) => server.listen(port, host, resolve));
61
65
 
package/src/index.js CHANGED
@@ -26,6 +26,7 @@ const DEFAULT_PORT = 6379;
26
26
  * @param {string} [options.pragmaTemplate]
27
27
  * @param {Record<string, string|number>} [options.pragma] Override specific pragmas when needed (e.g. { synchronous: 'FULL' }). Convention: template is applied by default.
28
28
  * @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to close server and DB. Set false if you handle shutdown yourself.
29
+ * @param {{ rename?: Record<string, string>, disabled?: string[] } | null} [options.commandPolicy] Optional: rename/disable commands for hardening.
29
30
  */
30
31
  export function startServer(options = {}) {
31
32
  const dbPath = options.dbPath ?? process.env.RESPLITE_DB ?? DEFAULT_DB_PATH;
@@ -45,7 +46,7 @@ export function startServer(options = {}) {
45
46
  sweeper.start();
46
47
 
47
48
  const connections = new Set();
48
- const server = createServer({ engine, port, connections });
49
+ const server = createServer({ engine, port, connections, commandPolicy: options.commandPolicy ?? null });
49
50
 
50
51
  if (gracefulShutdown) {
51
52
  let shuttingDown = false;
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { RESPReader } from '../resp/parser.js';
6
- import { dispatch } from '../commands/registry.js';
6
+ import { compileCommandPolicy, dispatch } from '../commands/registry.js';
7
7
  import { encode, encodeSimpleString, encodeError } from '../resp/encoder.js';
8
8
  import { registerMonitorClient, unregisterMonitorClient, broadcastMonitorCommand } from './monitor.js';
9
9
 
@@ -13,11 +13,13 @@ let nextConnectionId = 0;
13
13
  * @param {import('net').Socket} socket
14
14
  * @param {object} engine
15
15
  * @param {object} [hooks] Optional: onUnknownCommand, onCommandError, onSocketError
16
+ * @param {object|null} [commandPolicy] Optional: command rename/disable policy.
16
17
  */
17
- export function handleConnection(socket, engine, hooks = {}) {
18
+ export function handleConnection(socket, engine, hooks = {}, commandPolicy = null) {
18
19
  const reader = new RESPReader();
19
20
  const connectionId = ++nextConnectionId;
20
21
  const clientAddress = `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`;
22
+ const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
21
23
  const context = {
22
24
  connectionId,
23
25
  clientAddress,
@@ -29,6 +31,7 @@ export function handleConnection(socket, engine, hooks = {}) {
29
31
  onUnknownCommand: hooks.onUnknownCommand,
30
32
  onCommandError: hooks.onCommandError,
31
33
  onSocketError: hooks.onSocketError,
34
+ commandPolicy: compiledCommandPolicy,
32
35
  };
33
36
 
34
37
  function writeResult(out) {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import net from 'node:net';
6
6
  import { handleConnection } from './connection.js';
7
+ import { compileCommandPolicy } from '../commands/registry.js';
7
8
 
8
9
  /**
9
10
  * @param {object} options
@@ -11,15 +12,17 @@ import { handleConnection } from './connection.js';
11
12
  * @param {number} [options.port=6379]
12
13
  * @param {string} [options.host='0.0.0.0']
13
14
  * @param {Set<import('node:net').Socket>} [options.connections] If provided, each accepted socket is added here (for graceful shutdown).
15
+ * @param {object|null} [options.commandPolicy] Optional: command rename/disable policy.
14
16
  * @returns {import('node:net').Server}
15
17
  */
16
- export function createServer({ engine, port = 6379, host = '0.0.0.0', connections = null }) {
18
+ export function createServer({ engine, port = 6379, host = '0.0.0.0', connections = null, commandPolicy = null }) {
19
+ const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
17
20
  const server = net.createServer((socket) => {
18
21
  if (connections) {
19
22
  connections.add(socket);
20
23
  socket.once('close', () => connections.delete(socket));
21
24
  }
22
- handleConnection(socket, engine);
25
+ handleConnection(socket, engine, {}, compiledCommandPolicy);
23
26
  });
24
27
  return server;
25
28
  }
@@ -7,17 +7,20 @@ import net from 'node:net';
7
7
  import { handleConnection } from '../../src/server/connection.js';
8
8
  import { createEngine } from '../../src/engine/engine.js';
9
9
  import { openDb } from '../../src/storage/sqlite/db.js';
10
+ import { compileCommandPolicy } from '../../src/commands/registry.js';
10
11
  import { tmpDbPath } from './tmp.js';
11
12
 
12
13
  export function createTestServer(options = {}) {
13
14
  const dbPath = options.dbPath || tmpDbPath();
14
15
  const db = openDb(dbPath);
15
16
  const engine = createEngine({ db });
17
+ const hooks = options.hooks || {};
18
+ const commandPolicy = compileCommandPolicy(options.commandPolicy ?? null);
16
19
  const connections = new Set();
17
20
  const server = net.createServer((socket) => {
18
21
  connections.add(socket);
19
22
  socket.once('close', () => connections.delete(socket));
20
- handleConnection(socket, engine);
23
+ handleConnection(socket, engine, hooks, commandPolicy);
21
24
  });
22
25
  return new Promise((resolve, reject) => {
23
26
  server.listen(0, '127.0.0.1', () => {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Integration tests for command hardening policy (rename/disable).
3
+ */
4
+
5
+ import { describe, it, before, after } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { createTestServer } from '../helpers/server.js';
8
+ import { sendCommand, argv } from '../helpers/client.js';
9
+ import { tryParseValue } from '../../src/resp/parser.js';
10
+
11
+ function parseResp(buffer) {
12
+ const parsed = tryParseValue(buffer, 0);
13
+ return parsed ? parsed.value : null;
14
+ }
15
+
16
+ function asUtf8(value) {
17
+ return Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
18
+ }
19
+
20
+ describe('command hardening policy', () => {
21
+ let s;
22
+ let port;
23
+
24
+ before(async () => {
25
+ s = await createTestServer({
26
+ commandPolicy: {
27
+ rename: {
28
+ KEYS: 'SAFE_KEYS',
29
+ DEL: 'RMDEL',
30
+ },
31
+ disabled: ['MONITOR'],
32
+ },
33
+ });
34
+ port = s.port;
35
+ });
36
+
37
+ after(async () => {
38
+ await s.closeAsync();
39
+ });
40
+
41
+ it('renamed command is available only through alias', async () => {
42
+ const setReply = parseResp(await sendCommand(port, argv('SET', 'policy:k1', 'v1')));
43
+ assert.equal(asUtf8(setReply), 'OK');
44
+
45
+ const aliasReply = parseResp(await sendCommand(port, argv('SAFE_KEYS', 'policy:*')));
46
+ assert.ok(Array.isArray(aliasReply));
47
+ assert.ok(aliasReply.map(asUtf8).includes('policy:k1'));
48
+
49
+ const oldNameReply = parseResp(await sendCommand(port, argv('KEYS', 'policy:*')));
50
+ assert.ok(oldNameReply && oldNameReply.error);
51
+ assert.equal(asUtf8(oldNameReply.error), 'ERR command not supported yet');
52
+ });
53
+
54
+ it('explicitly disabled command returns unsupported', async () => {
55
+ const reply = parseResp(await sendCommand(port, argv('MONITOR')));
56
+ assert.ok(reply && reply.error);
57
+ assert.equal(asUtf8(reply.error), 'ERR command not supported yet');
58
+ });
59
+
60
+ it('COMMAND only exposes visible names and keeps canonical metadata', async () => {
61
+ const listReply = parseResp(await sendCommand(port, argv('COMMAND')));
62
+ assert.ok(Array.isArray(listReply));
63
+ const names = listReply.map((doc) => asUtf8(doc[0]).toUpperCase());
64
+ assert.ok(names.includes('SAFE_KEYS'));
65
+ assert.ok(names.includes('RMDEL'));
66
+ assert.ok(!names.includes('KEYS'));
67
+ assert.ok(!names.includes('DEL'));
68
+ assert.ok(!names.includes('MONITOR'));
69
+
70
+ const infoReply = parseResp(await sendCommand(port, argv('COMMAND', 'INFO', 'RMDEL')));
71
+ assert.ok(Array.isArray(infoReply));
72
+ assert.equal(infoReply.length, 1);
73
+ const rmDelDoc = infoReply[0];
74
+ assert.equal(asUtf8(rmDelDoc[0]), 'rmdel');
75
+ assert.equal(rmDelDoc[1], -2);
76
+ const flags = rmDelDoc[2].map(asUtf8);
77
+ assert.ok(flags.includes('write'));
78
+ });
79
+
80
+ it('renamed write command executes through alias and blocks original', async () => {
81
+ await sendCommand(port, argv('SET', 'policy:del', 'to-delete'));
82
+
83
+ const aliasDeleteReply = parseResp(await sendCommand(port, argv('RMDEL', 'policy:del')));
84
+ assert.equal(aliasDeleteReply, 1);
85
+
86
+ const originalDeleteReply = parseResp(await sendCommand(port, argv('DEL', 'policy:del')));
87
+ assert.ok(originalDeleteReply && originalDeleteReply.error);
88
+ assert.equal(asUtf8(originalDeleteReply.error), 'ERR command not supported yet');
89
+ });
90
+ });
@@ -27,7 +27,13 @@ const PREFIX = `__resplite_tracker_test_${process.pid}__`;
27
27
 
28
28
  /** Connect to local Redis; return null if unavailable. */
29
29
  async function tryConnectRedis() {
30
- const client = createClient({ url: REDIS_URL });
30
+ const client = createClient({
31
+ url: REDIS_URL,
32
+ socket: {
33
+ connectTimeout: 1500,
34
+ reconnectStrategy: () => new Error('no reconnect in tests'),
35
+ },
36
+ });
31
37
  try {
32
38
  await client.connect();
33
39
  await client.ping();