resplite 1.4.20 → 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 +27 -1
- package/package.json +1 -1
- package/scripts/benchmark-redis-vs-resplite.js +37 -15
- package/src/commands/command.js +18 -15
- package/src/commands/hexpire.js +54 -0
- package/src/commands/hpersist.js +36 -0
- package/src/commands/httl.js +36 -0
- package/src/commands/registry.js +116 -2
- package/src/embed.js +5 -1
- package/src/engine/engine.js +40 -3
- package/src/engine/expiration.js +28 -0
- package/src/index.js +2 -1
- package/src/server/connection.js +5 -2
- package/src/server/tcp-server.js +5 -2
- package/src/storage/sqlite/hashes.js +154 -11
- package/src/storage/sqlite/schema.js +11 -0
- package/test/helpers/server.js +4 -1
- package/test/integration/command-security.test.js +90 -0
- package/test/integration/hashes.test.js +82 -0
- package/test/integration/migration-dirty-tracker.test.js +7 -1
- package/test/unit/engine-hashes.test.js +162 -0
- package/test/unit/expiration.test.js +43 -0
package/src/server/connection.js
CHANGED
|
@@ -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) {
|
package/src/server/tcp-server.js
CHANGED
|
@@ -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
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hash storage: redis_hashes + coordination with redis_keys.
|
|
3
3
|
* Empty hash removes the key (Section 8.6).
|
|
4
|
+
* Per-field TTL is tracked in redis_hash_field_ttl (epoch milliseconds);
|
|
5
|
+
* HSET/HINCRBY clear a field's TTL, lazy-expiration prunes stale fields.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { KEY_TYPES } from './schema.js';
|
|
@@ -9,28 +11,84 @@ import { runInTransaction } from './tx.js';
|
|
|
9
11
|
/**
|
|
10
12
|
* @param {import('better-sqlite3').Database} db
|
|
11
13
|
* @param {ReturnType<import('./keys.js').createKeysStorage>} keys
|
|
14
|
+
* @param {{ clock?: () => number }} [options]
|
|
12
15
|
*/
|
|
13
|
-
export function createHashesStorage(db, keys) {
|
|
16
|
+
export function createHashesStorage(db, keys, options = {}) {
|
|
17
|
+
const clock = options.clock ?? (() => Date.now());
|
|
18
|
+
|
|
14
19
|
const getStmt = db.prepare('SELECT value FROM redis_hashes WHERE key = ? AND field = ?');
|
|
15
20
|
const getAllStmt = db.prepare('SELECT field, value FROM redis_hashes WHERE key = ?').raw(true);
|
|
21
|
+
const getAllLiveStmt = db
|
|
22
|
+
.prepare(
|
|
23
|
+
`SELECT h.field, h.value
|
|
24
|
+
FROM redis_hashes h
|
|
25
|
+
LEFT JOIN redis_hash_field_ttl t ON t.key = h.key AND t.field = h.field
|
|
26
|
+
WHERE h.key = ? AND (t.expires_at IS NULL OR t.expires_at > ?)`
|
|
27
|
+
)
|
|
28
|
+
.raw(true);
|
|
16
29
|
const insertStmt = db.prepare('INSERT OR REPLACE INTO redis_hashes (key, field, value) VALUES (?, ?, ?)');
|
|
17
30
|
const deleteStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ? AND field = ?');
|
|
18
31
|
const deleteAllStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ?');
|
|
19
32
|
const countStmt = db.prepare('SELECT COUNT(*) AS n FROM redis_hashes WHERE key = ?');
|
|
33
|
+
const countLiveStmt = db.prepare(
|
|
34
|
+
`SELECT COUNT(*) AS n
|
|
35
|
+
FROM redis_hashes h
|
|
36
|
+
LEFT JOIN redis_hash_field_ttl t ON t.key = h.key AND t.field = h.field
|
|
37
|
+
WHERE h.key = ? AND (t.expires_at IS NULL OR t.expires_at > ?)`
|
|
38
|
+
);
|
|
39
|
+
const hasAnyTtlStmt = db.prepare('SELECT 1 FROM redis_hash_field_ttl WHERE key = ? LIMIT 1').pluck();
|
|
40
|
+
|
|
41
|
+
const getFieldTtlStmt = db.prepare(
|
|
42
|
+
'SELECT expires_at AS expiresAt FROM redis_hash_field_ttl WHERE key = ? AND field = ?'
|
|
43
|
+
);
|
|
44
|
+
const upsertFieldTtlStmt = db.prepare(
|
|
45
|
+
'INSERT OR REPLACE INTO redis_hash_field_ttl (key, field, expires_at) VALUES (?, ?, ?)'
|
|
46
|
+
);
|
|
47
|
+
const deleteFieldTtlStmt = db.prepare('DELETE FROM redis_hash_field_ttl WHERE key = ? AND field = ?');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* If the field has an expired TTL row, delete the field + TTL row, adjust count
|
|
51
|
+
* (and drop the whole key when empty). Returns true if the field was purged.
|
|
52
|
+
*/
|
|
53
|
+
function expireFieldIfDue(key, field, now) {
|
|
54
|
+
const ttl = getFieldTtlStmt.get(key, field);
|
|
55
|
+
if (!ttl || ttl.expiresAt > now) return false;
|
|
56
|
+
const existed = getStmt.get(key, field) != null;
|
|
57
|
+
deleteFieldTtlStmt.run(key, field);
|
|
58
|
+
if (!existed) return true;
|
|
59
|
+
deleteStmt.run(key, field);
|
|
60
|
+
const meta = keys.get(key);
|
|
61
|
+
if (!meta) return true;
|
|
62
|
+
const before = meta.hashCount != null ? meta.hashCount : (countStmt.get(key) || { n: 0 }).n + 1;
|
|
63
|
+
const remaining = Math.max(0, before - 1);
|
|
64
|
+
if (remaining === 0) {
|
|
65
|
+
deleteAllStmt.run(key);
|
|
66
|
+
keys.delete(key);
|
|
67
|
+
} else {
|
|
68
|
+
keys.setHashCount(key, remaining, { touchUpdatedAt: false });
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
20
72
|
|
|
21
73
|
return {
|
|
22
74
|
get(key, field) {
|
|
75
|
+
runInTransaction(db, () => {
|
|
76
|
+
expireFieldIfDue(key, field, clock());
|
|
77
|
+
});
|
|
23
78
|
const row = getStmt.get(key, field);
|
|
24
79
|
return row ? row.value : null;
|
|
25
80
|
},
|
|
26
81
|
|
|
27
82
|
getAll(key) {
|
|
28
|
-
|
|
83
|
+
const now = clock();
|
|
84
|
+
const hasTtl = hasAnyTtlStmt.get(key);
|
|
85
|
+
if (!hasTtl) return getAllStmt.all(key).flat();
|
|
86
|
+
return getAllLiveStmt.all(key, now).flat();
|
|
29
87
|
},
|
|
30
88
|
|
|
31
89
|
set(key, field, value, options = {}) {
|
|
32
90
|
runInTransaction(db, () => {
|
|
33
|
-
const now = options.updatedAt ??
|
|
91
|
+
const now = options.updatedAt ?? clock();
|
|
34
92
|
const meta = keys.get(key);
|
|
35
93
|
let knownCount = 0;
|
|
36
94
|
if (meta) {
|
|
@@ -50,6 +108,7 @@ export function createHashesStorage(db, keys) {
|
|
|
50
108
|
}
|
|
51
109
|
const existed = getStmt.get(key, field) != null;
|
|
52
110
|
insertStmt.run(key, field, value);
|
|
111
|
+
deleteFieldTtlStmt.run(key, field);
|
|
53
112
|
if (!existed) {
|
|
54
113
|
if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
|
|
55
114
|
else keys.setHashCount(key, 1, { touchUpdatedAt: false });
|
|
@@ -62,7 +121,7 @@ export function createHashesStorage(db, keys) {
|
|
|
62
121
|
|
|
63
122
|
setMultiple(key, pairs, options = {}) {
|
|
64
123
|
runInTransaction(db, () => {
|
|
65
|
-
const now = options.updatedAt ??
|
|
124
|
+
const now = options.updatedAt ?? clock();
|
|
66
125
|
const meta = keys.get(key);
|
|
67
126
|
let knownCount = 0;
|
|
68
127
|
if (meta) {
|
|
@@ -84,6 +143,7 @@ export function createHashesStorage(db, keys) {
|
|
|
84
143
|
for (let i = 0; i < pairs.length; i += 2) {
|
|
85
144
|
const existed = getStmt.get(key, pairs[i]) != null;
|
|
86
145
|
insertStmt.run(key, pairs[i], pairs[i + 1]);
|
|
146
|
+
deleteFieldTtlStmt.run(key, pairs[i]);
|
|
87
147
|
if (!existed) added++;
|
|
88
148
|
}
|
|
89
149
|
if (added > 0) {
|
|
@@ -103,6 +163,7 @@ export function createHashesStorage(db, keys) {
|
|
|
103
163
|
let n = 0;
|
|
104
164
|
for (const field of fields) {
|
|
105
165
|
const r = deleteStmt.run(key, field);
|
|
166
|
+
deleteFieldTtlStmt.run(key, field);
|
|
106
167
|
n += r.changes;
|
|
107
168
|
}
|
|
108
169
|
const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n ?? 0);
|
|
@@ -118,21 +179,26 @@ export function createHashesStorage(db, keys) {
|
|
|
118
179
|
|
|
119
180
|
count(key) {
|
|
120
181
|
const meta = keys.get(key);
|
|
121
|
-
if (meta
|
|
122
|
-
|
|
182
|
+
if (!meta || meta.type !== KEY_TYPES.HASH) {
|
|
183
|
+
const row = countStmt.get(key);
|
|
184
|
+
return row ? row.n : 0;
|
|
185
|
+
}
|
|
186
|
+
const hasTtl = hasAnyTtlStmt.get(key);
|
|
187
|
+
if (hasTtl) {
|
|
188
|
+
const row = countLiveStmt.get(key, clock());
|
|
189
|
+
return row ? row.n : 0;
|
|
123
190
|
}
|
|
191
|
+
if (meta.hashCount != null) return meta.hashCount;
|
|
124
192
|
const row = countStmt.get(key);
|
|
125
193
|
const n = row ? row.n : 0;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
keys.setHashCount(key, n, { touchUpdatedAt: false });
|
|
129
|
-
}
|
|
194
|
+
// One-time hydration for databases created before hash_count existed.
|
|
195
|
+
keys.setHashCount(key, n, { touchUpdatedAt: false });
|
|
130
196
|
return n;
|
|
131
197
|
},
|
|
132
198
|
|
|
133
199
|
incr(key, field, delta, options = {}) {
|
|
134
200
|
return runInTransaction(db, () => {
|
|
135
|
-
const now = options.updatedAt ??
|
|
201
|
+
const now = options.updatedAt ?? clock();
|
|
136
202
|
const meta = keys.get(key);
|
|
137
203
|
if (meta && meta.type !== KEY_TYPES.HASH) {
|
|
138
204
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
@@ -147,11 +213,13 @@ export function createHashesStorage(db, keys) {
|
|
|
147
213
|
keys.setHashCount(key, hydrated, { touchUpdatedAt: false });
|
|
148
214
|
}
|
|
149
215
|
}
|
|
216
|
+
expireFieldIfDue(key, field, now);
|
|
150
217
|
const cur = getStmt.get(key, field);
|
|
151
218
|
const num = cur == null ? 0 : parseInt(cur.value.toString('utf8'), 10);
|
|
152
219
|
if (Number.isNaN(num)) throw new Error('ERR hash value is not an integer');
|
|
153
220
|
const next = num + delta;
|
|
154
221
|
insertStmt.run(key, field, Buffer.from(String(next), 'utf8'));
|
|
222
|
+
deleteFieldTtlStmt.run(key, field);
|
|
155
223
|
if (cur == null) {
|
|
156
224
|
if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
|
|
157
225
|
else keys.setHashCount(key, 1, { touchUpdatedAt: false });
|
|
@@ -160,6 +228,81 @@ export function createHashesStorage(db, keys) {
|
|
|
160
228
|
});
|
|
161
229
|
},
|
|
162
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Per-field expiration set. Returns -2/0/1/2 per HEXPIRE spec.
|
|
233
|
+
* `condition` is null or one of 'NX','XX','GT','LT'.
|
|
234
|
+
*/
|
|
235
|
+
setFieldExpire(key, field, expiresAtMs, { condition = null } = {}) {
|
|
236
|
+
return runInTransaction(db, () => {
|
|
237
|
+
const now = clock();
|
|
238
|
+
expireFieldIfDue(key, field, now);
|
|
239
|
+
const exists = getStmt.get(key, field) != null;
|
|
240
|
+
if (!exists) return -2;
|
|
241
|
+
const current = getFieldTtlStmt.get(key, field);
|
|
242
|
+
const currentMs = current ? current.expiresAt : null;
|
|
243
|
+
if (condition === 'NX' && currentMs != null) return 0;
|
|
244
|
+
if (condition === 'XX' && currentMs == null) return 0;
|
|
245
|
+
if (condition === 'GT') {
|
|
246
|
+
if (currentMs == null) return 0;
|
|
247
|
+
if (!(expiresAtMs > currentMs)) return 0;
|
|
248
|
+
}
|
|
249
|
+
if (condition === 'LT') {
|
|
250
|
+
if (currentMs != null && !(expiresAtMs < currentMs)) return 0;
|
|
251
|
+
}
|
|
252
|
+
if (expiresAtMs <= now) {
|
|
253
|
+
deleteStmt.run(key, field);
|
|
254
|
+
deleteFieldTtlStmt.run(key, field);
|
|
255
|
+
const meta = keys.get(key);
|
|
256
|
+
if (meta) {
|
|
257
|
+
const before = meta.hashCount != null ? meta.hashCount : (countStmt.get(key) || { n: 0 }).n + 1;
|
|
258
|
+
const remaining = Math.max(0, before - 1);
|
|
259
|
+
if (remaining === 0) {
|
|
260
|
+
deleteAllStmt.run(key);
|
|
261
|
+
keys.delete(key);
|
|
262
|
+
} else {
|
|
263
|
+
keys.setHashCount(key, remaining, { touchUpdatedAt: false });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return 2;
|
|
267
|
+
}
|
|
268
|
+
upsertFieldTtlStmt.run(key, field, expiresAtMs);
|
|
269
|
+
return 1;
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Returns remaining ms (>= 0), -1 if field has no TTL, -2 if field missing.
|
|
275
|
+
*/
|
|
276
|
+
getFieldTtl(key, field) {
|
|
277
|
+
const now = clock();
|
|
278
|
+
let removed = false;
|
|
279
|
+
runInTransaction(db, () => {
|
|
280
|
+
removed = expireFieldIfDue(key, field, now);
|
|
281
|
+
});
|
|
282
|
+
if (removed) return -2;
|
|
283
|
+
const exists = getStmt.get(key, field) != null;
|
|
284
|
+
if (!exists) return -2;
|
|
285
|
+
const row = getFieldTtlStmt.get(key, field);
|
|
286
|
+
if (!row) return -1;
|
|
287
|
+
return Math.max(0, row.expiresAt - now);
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Clears a field's TTL. Returns 1 if cleared, -1 if no TTL, -2 if field missing.
|
|
292
|
+
*/
|
|
293
|
+
persistField(key, field) {
|
|
294
|
+
return runInTransaction(db, () => {
|
|
295
|
+
const now = clock();
|
|
296
|
+
expireFieldIfDue(key, field, now);
|
|
297
|
+
const exists = getStmt.get(key, field) != null;
|
|
298
|
+
if (!exists) return -2;
|
|
299
|
+
const row = getFieldTtlStmt.get(key, field);
|
|
300
|
+
if (!row) return -1;
|
|
301
|
+
deleteFieldTtlStmt.run(key, field);
|
|
302
|
+
return 1;
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
|
|
163
306
|
/** Copy all field/value rows from oldKey to newKey. Caller ensures newKey exists in redis_keys. */
|
|
164
307
|
copyKey(oldKey, newKey) {
|
|
165
308
|
const rows = getAllStmt.all(oldKey);
|
|
@@ -66,6 +66,17 @@ CREATE TABLE IF NOT EXISTS redis_zsets (
|
|
|
66
66
|
CREATE INDEX IF NOT EXISTS redis_zsets_key_score_member_idx
|
|
67
67
|
ON redis_zsets(key, score, member);
|
|
68
68
|
|
|
69
|
+
CREATE TABLE IF NOT EXISTS redis_hash_field_ttl (
|
|
70
|
+
key BLOB NOT NULL,
|
|
71
|
+
field BLOB NOT NULL,
|
|
72
|
+
expires_at INTEGER NOT NULL,
|
|
73
|
+
PRIMARY KEY (key, field),
|
|
74
|
+
FOREIGN KEY(key) REFERENCES redis_keys(key) ON DELETE CASCADE
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE INDEX IF NOT EXISTS redis_hash_field_ttl_expires_at_idx
|
|
78
|
+
ON redis_hash_field_ttl(expires_at);
|
|
79
|
+
|
|
69
80
|
CREATE TABLE IF NOT EXISTS search_indices (
|
|
70
81
|
name TEXT PRIMARY KEY,
|
|
71
82
|
schema_json TEXT NOT NULL,
|
package/test/helpers/server.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -65,6 +65,88 @@ describe('Hashes integration', () => {
|
|
|
65
65
|
assert.equal(tryParseValue(reply, 0).value, 3);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it('HEXPIRE + HTTL + HPERSIST round-trip (node-redis style argv)', async () => {
|
|
69
|
+
await sendCommand(port, argv('HSET', 'LobbyStream', '6GQZW:FBAX7', '1'));
|
|
70
|
+
// Mirror the exact argv seen in the node-redis client log.
|
|
71
|
+
const hexpireReply = await sendCommand(
|
|
72
|
+
port,
|
|
73
|
+
argv('HEXPIRE', 'LobbyStream', '90', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
74
|
+
);
|
|
75
|
+
const hexpireVal = tryParseValue(hexpireReply, 0).value;
|
|
76
|
+
assert.ok(Array.isArray(hexpireVal));
|
|
77
|
+
assert.equal(hexpireVal.length, 1);
|
|
78
|
+
assert.equal(Number(hexpireVal[0]), 1);
|
|
79
|
+
|
|
80
|
+
const httlReply = await sendCommand(
|
|
81
|
+
port,
|
|
82
|
+
argv('HTTL', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
83
|
+
);
|
|
84
|
+
const httlVal = tryParseValue(httlReply, 0).value;
|
|
85
|
+
assert.ok(Array.isArray(httlVal));
|
|
86
|
+
assert.equal(httlVal.length, 1);
|
|
87
|
+
const secs = Number(httlVal[0]);
|
|
88
|
+
assert.ok(secs > 0 && secs <= 90, `expected 0 < ttl <= 90, got ${secs}`);
|
|
89
|
+
|
|
90
|
+
const hpersistReply = await sendCommand(
|
|
91
|
+
port,
|
|
92
|
+
argv('HPERSIST', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
93
|
+
);
|
|
94
|
+
const hpersistVal = tryParseValue(hpersistReply, 0).value;
|
|
95
|
+
assert.equal(Number(hpersistVal[0]), 1);
|
|
96
|
+
|
|
97
|
+
const httlReply2 = await sendCommand(
|
|
98
|
+
port,
|
|
99
|
+
argv('HTTL', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
100
|
+
);
|
|
101
|
+
const ttl2 = Number(tryParseValue(httlReply2, 0).value[0]);
|
|
102
|
+
assert.equal(ttl2, -1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('HEXPIRE returns -2 for missing key/field', async () => {
|
|
106
|
+
const missingKey = await sendCommand(
|
|
107
|
+
port,
|
|
108
|
+
argv('HEXPIRE', 'hexp:nokey', '5', 'FIELDS', '1', 'f1')
|
|
109
|
+
);
|
|
110
|
+
assert.equal(Number(tryParseValue(missingKey, 0).value[0]), -2);
|
|
111
|
+
|
|
112
|
+
await sendCommand(port, argv('HSET', 'hexp:k', 'f1', 'v1'));
|
|
113
|
+
const missingField = await sendCommand(
|
|
114
|
+
port,
|
|
115
|
+
argv('HEXPIRE', 'hexp:k', '5', 'FIELDS', '1', 'nope')
|
|
116
|
+
);
|
|
117
|
+
assert.equal(Number(tryParseValue(missingField, 0).value[0]), -2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('HEXPIRE with 0 seconds deletes the field (returns 2)', async () => {
|
|
121
|
+
await sendCommand(port, argv('HSET', 'hexp:zero', 'f1', 'v1', 'f2', 'v2'));
|
|
122
|
+
const reply = await sendCommand(
|
|
123
|
+
port,
|
|
124
|
+
argv('HEXPIRE', 'hexp:zero', '0', 'FIELDS', '1', 'f1')
|
|
125
|
+
);
|
|
126
|
+
assert.equal(Number(tryParseValue(reply, 0).value[0]), 2);
|
|
127
|
+
const getReply = await sendCommand(port, argv('HGET', 'hexp:zero', 'f1'));
|
|
128
|
+
assert.ok(getReply.toString('utf8').startsWith('$-1'));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('HEXPIRE NX condition fails on existing TTL', async () => {
|
|
132
|
+
await sendCommand(port, argv('HSET', 'hexp:nx', 'f1', 'v1'));
|
|
133
|
+
await sendCommand(port, argv('HEXPIRE', 'hexp:nx', '10', 'FIELDS', '1', 'f1'));
|
|
134
|
+
const reply = await sendCommand(
|
|
135
|
+
port,
|
|
136
|
+
argv('HEXPIRE', 'hexp:nx', '20', 'NX', 'FIELDS', '1', 'f1')
|
|
137
|
+
);
|
|
138
|
+
assert.equal(Number(tryParseValue(reply, 0).value[0]), 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('HEXPIRE FIELDS count mismatch is a syntax error', async () => {
|
|
142
|
+
await sendCommand(port, argv('HSET', 'hexp:bad', 'f1', 'v1'));
|
|
143
|
+
const reply = await sendCommand(
|
|
144
|
+
port,
|
|
145
|
+
argv('HEXPIRE', 'hexp:bad', '10', 'FIELDS', '2', 'f1')
|
|
146
|
+
);
|
|
147
|
+
assert.ok(reply.toString('utf8').startsWith('-'), 'expected error reply');
|
|
148
|
+
});
|
|
149
|
+
|
|
68
150
|
it('legacy hash rows with null hash_count hydrate on first HLEN', async () => {
|
|
69
151
|
const s1 = await createTestServer();
|
|
70
152
|
await sendCommand(s1.port, argv('HSET', 'legacy:h', 'f1', 'v1', 'f2', 'v2'));
|
|
@@ -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({
|
|
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();
|