jsql-neo 6.5.6 → 6.5.8

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
@@ -3,7 +3,7 @@
3
3
  > **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
4
4
  > MySQL, PostgreSQL, MongoDB, Redis, SQL, TypeScript, **and the browser**.
5
5
 
6
- > **v6.5.6** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
6
+ > **v6.5.8** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
7
7
 
8
8
  ![Engines](https://img.shields.io/badge/engines-Native%20%7C%20WASM%20%7C%20Pure%20JS-7ee787)
9
9
  ![MySQL](https://img.shields.io/badge/protocol-MySQL%20compatible-1f6feb)
package/bin/jsql CHANGED
@@ -66,7 +66,7 @@ const cli = yaggs({ pkg: require('../package.json') })
66
66
  if (!argv.address) return fail(new Error("'mod add' requires --address <path>"));
67
67
  const ext = path.extname(argv.address).toLowerCase();
68
68
  if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
69
- console.warn("Warning: JS modules run without sandbox isolation (trust model). Use .json files for data-only plugins.");
69
+ console.warn("Warning: JS modules run in a restricted vm sandbox (no require/process/Buffer). Set JSQL_ALLOW_UNSAFE_MODULES=1 only for trusted code. Use .json files for data-only plugins.");
70
70
  }
71
71
  const entry = manager.add(argv.address);
72
72
  out(useJson ? entry : `Module '${entry.name}' added (${entry.path}). Use 'jsql mod enable ${entry.name}' to enable.`);
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v6.5.6 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v6.5.8 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
package/lib/migrate.js CHANGED
@@ -140,15 +140,53 @@ function parseCSV(text, opts = {}) {
140
140
  }
141
141
 
142
142
  function toCSV(rows, columns) {
143
+ const lines = [csvLine(columns, columns)];
144
+ for (const r of rows) lines.push(csvLine(r, columns));
145
+ return lines.join('\n') + '\n';
146
+ }
147
+
148
+ function csvLine(row, columns) {
143
149
  const escape = (v) => {
144
150
  const s = serializeValue(v);
145
151
  return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
146
152
  };
147
- const lines = [columns.map(escape).join(',')];
148
- for (const r of rows) {
149
- lines.push(columns.map(c => escape(r[c])).join(','));
153
+ return columns.map((c, i) => escape(Array.isArray(row) ? row[i] : row[c])).join(',');
154
+ }
155
+
156
+ /**
157
+ * 分页遍历整表,避免 `find(table, {}, { limit: 1e9 })` 把整表读进内存。
158
+ * onRow 每批回调一次(rows 数组)。
159
+ */
160
+ async function iterateTable(engine, table, opts, onRows) {
161
+ const chunkSize = opts.chunkSize != null ? opts.chunkSize : DEFAULT_CHUNK_SIZE;
162
+ if (!Number.isInteger(chunkSize) || chunkSize < 1) throw new Error('chunkSize must be a positive integer');
163
+ const maxRows = opts.maxRows == null ? Infinity : opts.maxRows;
164
+ let offset = 0;
165
+ let total = 0;
166
+ for (;;) {
167
+ const rows = await engine.find(table, {}, { limit: chunkSize, offset });
168
+ if (!Array.isArray(rows) || rows.length === 0) break;
169
+ if (rows.length > chunkSize) throw new Error('engine.find() ignored the limit option');
170
+ total += rows.length;
171
+ if (total > maxRows) throw new Error(`export exceeded maxRows limit of ${maxRows}`);
172
+ await onRows(rows);
173
+ offset += rows.length;
174
+ if (rows.length < chunkSize) break;
150
175
  }
151
- return lines.join('\n') + '\n';
176
+ return total;
177
+ }
178
+
179
+ function writeChunk(stream, text) {
180
+ return new Promise((resolve, reject) => {
181
+ stream.write(text, (err) => (err ? reject(err) : resolve()));
182
+ });
183
+ }
184
+
185
+ function closeStream(stream) {
186
+ return new Promise((resolve, reject) => {
187
+ stream.once('error', reject);
188
+ stream.end(() => resolve());
189
+ });
152
190
  }
153
191
 
154
192
  /* ---------- JSON ---------- */
@@ -321,24 +359,62 @@ async function importDumpFile(engine, filePath, opts = {}) {
321
359
  return importDump(engine, text, opts);
322
360
  }
323
361
 
324
- async function exportToFile(engine, table, filePath) {
362
+ async function exportToFile(engine, table, filePath, opts = {}) {
325
363
  const ext = path.extname(filePath).toLowerCase();
326
- let content;
327
- if (ext === '.json') {
328
- content = JSON.stringify(await exportTableToJSON(engine, table), null, 2);
329
- } else if (ext === '.csv') {
330
- content = await exportTableToCSV(engine, table);
331
- } else {
364
+ if (ext !== '.json' && ext !== '.csv') {
332
365
  throw new Error('Unsupported export format (use .json or .csv): ' + filePath);
333
366
  }
334
- fs.writeFileSync(filePath, content);
335
- return content.length;
367
+ const schema = await engine.getTableSchema(table);
368
+ if (!schema) throw new Error(`Table '${table}' does not exist`);
369
+ const columns = Object.keys(schema);
370
+ const stream = fs.createWriteStream(filePath, { encoding: 'utf8' });
371
+ // 防止磁盘写满等异步错误在 write 回调之外变成未捕获异常
372
+ stream.on('error', () => {});
373
+ let bytes = 0;
374
+ const write = async (text) => {
375
+ bytes += Buffer.byteLength(text, 'utf8');
376
+ await writeChunk(stream, text);
377
+ };
378
+ try {
379
+ if (ext === '.json') {
380
+ await write(
381
+ '{\n "table": ' + JSON.stringify(table) +
382
+ ',\n "schema": ' + JSON.stringify(schema, null, 2) +
383
+ ',\n "rows": [\n'
384
+ );
385
+ let first = true;
386
+ await iterateTable(engine, table, opts, async (rows) => {
387
+ let buf = '';
388
+ for (const row of rows) {
389
+ buf += (first ? ' ' : ',\n ') + JSON.stringify(row);
390
+ first = false;
391
+ }
392
+ await write(buf);
393
+ });
394
+ await write('\n ]\n}\n');
395
+ } else {
396
+ await write(csvLine(columns, columns) + '\n');
397
+ await iterateTable(engine, table, opts, async (rows) => {
398
+ let buf = '';
399
+ for (const row of rows) buf += csvLine(row, columns) + '\n';
400
+ await write(buf);
401
+ });
402
+ }
403
+ await closeStream(stream);
404
+ return bytes;
405
+ } catch (e) {
406
+ stream.destroy();
407
+ try { fs.unlinkSync(filePath); } catch (_) { /* ignore */ }
408
+ throw e;
409
+ }
336
410
  }
337
411
 
338
412
  module.exports = {
339
413
  normalizeSchema,
340
414
  parseCSV,
341
415
  toCSV,
416
+ csvLine,
417
+ iterateTable,
342
418
  exportTableToJSON,
343
419
  exportAllToJSON,
344
420
  importFromJSON,
package/lib/mod.js CHANGED
@@ -2,6 +2,7 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const Module = require('module');
5
+ const vm = require('vm');
5
6
 
6
7
  const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.config', 'jsql', 'mod.config');
7
8
  const CONFIG_PATH = process.env.JSQL_MOD_CONFIG || DEFAULT_CONFIG_PATH;
@@ -279,28 +280,39 @@ class ModuleManager {
279
280
  };
280
281
  }
281
282
 
282
- // P1-45: 无沙箱——使用 vm 模块执行 JS 代码,限制全局访问
283
+ // JS 模块默认在受限 vm 沙箱中运行:不暴露 require/process/Buffer。
284
+ // 需要这些能力时必须显式设置 JSQL_ALLOW_UNSAFE_MODULES=1(信任该模块)。
283
285
  const resolved = path.isAbsolute(filePath) ? filePath : require.resolve(filePath);
284
286
  delete require.cache[resolved];
285
287
  let plugin;
286
288
  try {
287
- const vm = require('vm');
288
289
  const code = fs.readFileSync(resolved, 'utf8');
289
- const sandbox = {
290
- require: require,
291
- module: { exports: {} },
292
- exports: {},
293
- console: console,
294
- process: process,
295
- Buffer: Buffer,
296
- __filename: resolved,
297
- __dirname: path.dirname(resolved),
298
- };
299
- vm.createContext(sandbox);
300
- vm.runInContext(code, sandbox, { filename: resolved });
301
- plugin = sandbox.module.exports;
290
+ const unsafe = process.env.JSQL_ALLOW_UNSAFE_MODULES === '1';
291
+ // 空对象作为沙箱:module/exports 在沙箱内部创建,避免把宿主对象带进去
292
+ const context = vm.createContext(Object.create(null), {
293
+ codeGeneration: { strings: unsafe, wasm: unsafe },
294
+ });
295
+ context.__filename = resolved;
296
+ context.__dirname = path.dirname(resolved);
297
+ if (unsafe) {
298
+ context.require = require;
299
+ context.process = process;
300
+ context.Buffer = Buffer;
301
+ context.console = console;
302
+ }
303
+ vm.runInContext(
304
+ 'globalThis.module = { exports: {} }; globalThis.exports = module.exports;' +
305
+ (unsafe ? '' : 'globalThis.console = Object.freeze({ log(){}, info(){}, warn(){}, error(){} });'),
306
+ context,
307
+ { timeout: 1000 }
308
+ );
309
+ vm.runInContext(code, context, { filename: resolved, timeout: 5000 });
310
+ plugin = vm.runInContext('module.exports', context, { timeout: 1000 });
302
311
  } catch (e) {
303
- throw new Error(`Failed to load module ${filePath}: ${e.message}`);
312
+ const hint = process.env.JSQL_ALLOW_UNSAFE_MODULES !== '1' && /is not defined/.test(e.message || '')
313
+ ? '\nJS modules run in a restricted sandbox. Set JSQL_ALLOW_UNSAFE_MODULES=1 to allow require/process/Buffer (trusted code only).'
314
+ : '';
315
+ throw new Error(`Failed to load module ${filePath}: ${e.message}${hint}`);
304
316
  }
305
317
  if (typeof plugin === 'function') {
306
318
  return { install: plugin, name: path.basename(filePath, ext) };
@@ -151,6 +151,7 @@ class MongoServer {
151
151
  this.port = options.port ?? 27017;
152
152
  this.host = options.host || '127.0.0.1';
153
153
  this.dataDir = options.dataDir || null;
154
+ this.maxMessageSize = options.maxMessageSize || 48 * 1024 * 1024;
154
155
  this._engine = null;
155
156
  this._server = null;
156
157
  this._sockets = new Set();
@@ -189,7 +190,11 @@ class MongoServer {
189
190
 
190
191
  listen(cb) {
191
192
  this._server = net.createServer((socket) => this._handleSocket(socket));
192
- this._server.listen(this.port, this.host, cb || (() => {}));
193
+ this._server.on('error', (err) => {
194
+ if (typeof this.options.onError === 'function') this.options.onError(err);
195
+ else if (cb) cb(err);
196
+ });
197
+ this._server.listen(this.port, this.host, cb ? () => cb() : undefined);
193
198
  return this;
194
199
  }
195
200
 
@@ -197,6 +202,10 @@ class MongoServer {
197
202
  this._sockets.add(socket);
198
203
  socket.on('close', () => this._sockets.delete(socket));
199
204
  let buf = Buffer.isBuffer(existing) ? Buffer.from(existing) : Buffer.alloc(0);
205
+ if (buf.length > this.maxMessageSize) {
206
+ socket.destroy();
207
+ return;
208
+ }
200
209
  socket.on('error', () => {});
201
210
  let processing = false;
202
211
  let again = false;
@@ -207,7 +216,10 @@ class MongoServer {
207
216
  for (;;) {
208
217
  if (buf.length < 4) break;
209
218
  const len = buf.readInt32LE(0);
210
- if (len < 16 || len > 48 * 1024 * 1024) break;
219
+ if (len < 16 || len > this.maxMessageSize) {
220
+ socket.destroy();
221
+ return;
222
+ }
211
223
  if (buf.length < len) break;
212
224
  const msg = buf.slice(0, len);
213
225
  buf = buf.slice(len);
@@ -223,6 +235,10 @@ class MongoServer {
223
235
  }
224
236
  };
225
237
  socket.on('data', (chunk) => {
238
+ if (buf.length + chunk.length > this.maxMessageSize * 2) {
239
+ socket.destroy();
240
+ return;
241
+ }
226
242
  buf = Buffer.concat([buf, chunk]);
227
243
  processLoop();
228
244
  });
@@ -12,6 +12,7 @@
12
12
  * typeorm, redis-cli, psql, phpMyAdmin ...) connect to the same endpoint.
13
13
  */
14
14
  const net = require('net');
15
+ const fs = require('fs');
15
16
  const path = require('path');
16
17
  const Database = require('./database');
17
18
  const { MysqlServer, MysqlConnection } = require('./mysql_server');
@@ -51,9 +52,23 @@ class MultiServer {
51
52
  const getEngine = async (dbName) => {
52
53
  const key = dbName || 'default';
53
54
  if (this._engines.has(key)) return this._engines.get(key);
55
+ if (this.dataDir && this.dataDir !== ':memory:') {
56
+ // 防止 PG/MySQL 传来的库名做路径穿越
57
+ if (typeof key !== 'string' || !/^[A-Za-z0-9_$.\-]+$/.test(key) || key.includes('..') || key === '.') {
58
+ const err = new Error(`Invalid database name '${key}'`);
59
+ err.code = 1049;
60
+ throw err;
61
+ }
62
+ }
54
63
  let engine;
55
64
  if (this.dataDir && this.dataDir !== ':memory:') {
56
- engine = new Database(path.join(this.dataDir, key), { autoSave: true });
65
+ const dbPath = path.join(this.dataDir, key);
66
+ let stat = null;
67
+ try { stat = fs.statSync(dbPath); } catch (e) { /* not created yet */ }
68
+ // 兼容旧版单文件库:目录模式会 EISDIR,文件模式会覆盖目录
69
+ engine = stat && stat.isFile()
70
+ ? new Database(dbPath, { autoSave: true })
71
+ : new Database(dbPath, { mode: 'hybrid' });
57
72
  } else {
58
73
  engine = new Database(':memory:', { autoSave: false });
59
74
  }
@@ -66,9 +81,78 @@ class MultiServer {
66
81
  this.mysql._getEngine = getEngine;
67
82
  this.pg = new PgServer(baseOpts);
68
83
  this.pg._getEngine = getEngine;
84
+ this.pg._engines = this._engines;
69
85
  this.redis = new RedisServer({ ...baseOpts });
70
86
  this.mongo = new MongoServer(baseOpts);
71
87
  this.mongo._getEngine = getEngine;
88
+
89
+ // MySQL 的库级操作也必须走共享引擎,否则同一目录会被打开两次(split brain)
90
+ const dbError = (message, code) => {
91
+ const err = new Error(message);
92
+ err.code = code;
93
+ return err;
94
+ };
95
+ const dbDir = this.dataDir && this.dataDir !== ':memory:' ? path.resolve(this.dataDir) : null;
96
+ const safeDbName = (name) => this.mysql._safeDbName(name);
97
+ const dbPath = (name) => (dbDir && safeDbName(name) ? path.join(dbDir, name) : null);
98
+
99
+ const getDatabase = async (name, opts = {}) => {
100
+ if (!safeDbName(name)) throw dbError(`Unknown database '${name}'`, 1049);
101
+ if (this._engines.has(name)) return this._engines.get(name);
102
+ const dir = dbPath(name);
103
+ if (dir && !fs.existsSync(dir)) {
104
+ if (opts.autoCreate) fs.mkdirSync(dir, { recursive: true });
105
+ else throw dbError(`Unknown database '${name}'`, 1049);
106
+ }
107
+ return getEngine(name);
108
+ };
109
+
110
+ this.mysql._getDatabase = getDatabase;
111
+ this.mysql._dbExists = (name) => {
112
+ if (this._engines.has(name)) return true;
113
+ const dir = dbPath(name);
114
+ return !!(dir && fs.existsSync(dir));
115
+ };
116
+ this.mysql.createDatabase = async (name, opts = {}) => {
117
+ if (!safeDbName(name)) throw dbError(`Invalid database name '${name}'`, 1105);
118
+ if (this._engines.has(name) || (dbPath(name) && fs.existsSync(dbPath(name)))) {
119
+ if (opts.ifNotExists) return getDatabase(name);
120
+ throw dbError(`Can't create database '${name}'; database exists`, 1007);
121
+ }
122
+ const dir = dbPath(name);
123
+ if (dir) fs.mkdirSync(dir, { recursive: true });
124
+ return getEngine(name);
125
+ };
126
+ this.mysql.dropDatabase = async (name, opts = {}) => {
127
+ if (!safeDbName(name)) throw dbError(`Unknown database '${name}'`, 1049);
128
+ const engine = this._engines.get(name);
129
+ const dir = dbPath(name);
130
+ const hasDir = !!(dir && fs.existsSync(dir));
131
+ if (!engine && !hasDir) {
132
+ if (opts.ifExists) return;
133
+ throw dbError(`Can't drop database '${name}'; database doesn't exist`, 1008);
134
+ }
135
+ if (engine && typeof engine.stop === 'function') {
136
+ try { await engine.stop(); } catch (e) { /* ignore */ }
137
+ }
138
+ this._engines.delete(name);
139
+ this.mysql._databases.delete(name);
140
+ if (hasDir) fs.rmSync(dir, { recursive: true, force: true });
141
+ };
142
+ this.mysql.listDatabases = async () => {
143
+ const names = new Set(this._engines.keys());
144
+ if (dbDir && fs.existsSync(dbDir)) {
145
+ for (const n of fs.readdirSync(dbDir)) {
146
+ const p = path.join(dbDir, n);
147
+ try {
148
+ if (fs.statSync(p).isDirectory() && safeDbName(n)) names.add(n);
149
+ } catch (e) { /* ignore */ }
150
+ }
151
+ }
152
+ if (names.size === 0) return ['jsql'];
153
+ return Array.from(names).sort();
154
+ };
155
+
72
156
  this._server = null;
73
157
  this._sockets = new Set();
74
158
  }
@@ -106,7 +190,8 @@ class MultiServer {
106
190
  this._server.listen(this.port, this.host, cb || (() => {}));
107
191
  this._server.on('error', (err) => {
108
192
  if (this.options.onError) this.options.onError(err);
109
- else throw err;
193
+ else if (cb) cb(err);
194
+ else console.error('[jsql-neo] multi-protocol server error:', err.message);
110
195
  });
111
196
  return this;
112
197
  }
@@ -435,8 +435,9 @@ function binaryResultSetPacket(result, tableSchema, baseSeq) {
435
435
  for (let i = 0; i < cols.length; i++) {
436
436
  const v = Array.isArray(row) ? row[i] : row[cols[i]];
437
437
  if (v === null || v === undefined) {
438
- // null bitmap 按列顺序编码,bit=1 表示 NULL
439
- nb[Math.floor(i / 8)] |= (1 << (i % 8));
438
+ // binary protocol 的 NULL bitmap 前两位保留,列从 bit 2 开始
439
+ const bit = i + 2;
440
+ nb[Math.floor(bit / 8)] |= (1 << (bit % 8));
440
441
  encoded.push(null);
441
442
  } else {
442
443
  encoded.push(encodeValue(v, types[i]));
@@ -1296,11 +1297,14 @@ class MysqlServer {
1296
1297
  );
1297
1298
  friendly.code = 'EADDRINUSE';
1298
1299
  friendly.cause = err;
1299
- if (cb) cb(friendly);
1300
- else throw friendly;
1301
- } else if (cb) cb(err);
1300
+ if (typeof this.options.onError === 'function') this.options.onError(friendly);
1301
+ else if (cb) cb(friendly);
1302
+ else console.error('[jsql-neo] mysql server error:', friendly.message);
1303
+ } else if (typeof this.options.onError === 'function') this.options.onError(err);
1304
+ else if (cb) cb(err);
1305
+ else console.error('[jsql-neo] mysql server error:', err.message);
1302
1306
  });
1303
- this._server.listen(this.port, this.host, cb || (() => {}));
1307
+ this._server.listen(this.port, this.host, cb ? () => cb() : undefined);
1304
1308
  }).catch(err => {
1305
1309
  if (cb) cb(err);
1306
1310
  else throw err;
@@ -17,8 +17,9 @@ function _loadNativeOrStub() {
17
17
  console.warn(`[jsql-neo] native module unavailable (${e.code || e.message}), using stub.`);
18
18
  console.warn('[jsql-neo] stub mode: limited functionality, install Rust + cargo build for full support.');
19
19
  // 返回一个 stub 对象,所有函数都抛 NotSupported
20
- const stub = new Proxy({}, {
21
- get(_, prop) {
20
+ const stub = new Proxy({ __jsqlStub: true }, {
21
+ get(target, prop) {
22
+ if (prop === '__jsqlStub') return true;
22
23
  if (prop === 'then' || prop === 'catch') return undefined; // not a thenable
23
24
  return (...args) => {
24
25
  const err = new Error(`Native function '${String(prop)}' not available in stub mode`);
@@ -32,6 +33,7 @@ function _loadNativeOrStub() {
32
33
  }
33
34
 
34
35
  const native = _loadNativeOrStub();
36
+ const NATIVE_AVAILABLE = native && native.__jsqlStub !== true;
35
37
 
36
38
  // updateById / removeById 走 opBuffer + 定时批量 flush。进程直接退出(没调 stop())
37
39
  // 时定时器来不及跑,写操作会丢。这里用一个进程级 exit 钩子,在退出前同步 flush
@@ -371,7 +373,7 @@ class JSQL {
371
373
  // 6.3.4 起 native 层改为句柄注册表,同进程可并存多个独立数据库。
372
374
  // 若加载到的是旧版预编译二进制(无 jsqlInstanceNew),则回退到单实例行为。
373
375
  this._handle = null;
374
- this._multiInstance = typeof native.jsqlInstanceNew === 'function';
376
+ this._multiInstance = NATIVE_AVAILABLE && typeof native.jsqlInstanceNew === 'function';
375
377
  if (this._multiInstance) {
376
378
  this._handle = native.jsqlInstanceNew();
377
379
  }
@@ -1018,4 +1020,16 @@ class JSQL {
1018
1020
  }
1019
1021
  }
1020
1022
 
1021
- module.exports = { JSQL };
1023
+ // 没有原生模块时不要构造一个「所有属性都是函数」的假 native:
1024
+ // 直接回退到 WASM/JS 引擎,保持 API 可用(性能较低但功能完整)。
1025
+ let ExportedJSQL = JSQL;
1026
+ if (!NATIVE_AVAILABLE) {
1027
+ console.warn('[jsql-neo] NativeJSQL unavailable; falling back to the WASM/JS engine.');
1028
+ try {
1029
+ ExportedJSQL = require('./wasm_client').JSQL;
1030
+ } catch (e) {
1031
+ console.warn('[jsql-neo] WASM fallback also unavailable:', e.message);
1032
+ }
1033
+ }
1034
+
1035
+ module.exports = { JSQL: ExportedJSQL, _nativeAvailable: NATIVE_AVAILABLE };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { execFileSync } = require('child_process');
5
+
6
+ const MIN_NAPI_VERSION = 6;
7
+
8
+ function detectLibc(platform = process.platform) {
9
+ if (platform !== 'linux') return null;
10
+ try {
11
+ const report = process.report && typeof process.report.getReport === 'function'
12
+ ? process.report.getReport()
13
+ : null;
14
+ if (report && report.header && report.header.glibcVersionRuntime) return 'gnu';
15
+ } catch (_) { /* ignore */ }
16
+ try {
17
+ if (fs.existsSync('/etc/alpine-release')) return 'musl';
18
+ } catch (_) { /* ignore */ }
19
+ try {
20
+ const out = execFileSync('ldd', ['--version'], {
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ });
24
+ if (/musl/i.test(out)) return 'musl';
25
+ if (/glibc|GNU libc/i.test(out)) return 'gnu';
26
+ } catch (_) { /* ignore */ }
27
+ return 'gnu';
28
+ }
29
+
30
+ function nativeTargetSuffix(platform = process.platform, arch = process.arch, libc = detectLibc(platform)) {
31
+ const p = platform === 'win32' ? 'win32' : platform === 'darwin' ? 'darwin' : 'linux';
32
+ const a = arch === 'x64' ? 'x64' : arch === 'arm64' ? 'arm64' : arch;
33
+ if (p === 'linux') return `${p}-${a}-${libc === 'musl' ? 'musl' : 'gnu'}`;
34
+ if (p === 'darwin') return `${p}-${a}`;
35
+ return `${p}-${a}-msvc`;
36
+ }
37
+
38
+ function nativeNapiVersion() {
39
+ const v = Number(process.versions && process.versions.napi);
40
+ return Number.isFinite(v) ? v : 0;
41
+ }
42
+
43
+ function supportsNativeNapi() {
44
+ return nativeNapiVersion() >= MIN_NAPI_VERSION;
45
+ }
46
+
47
+ module.exports = {
48
+ MIN_NAPI_VERSION,
49
+ detectLibc,
50
+ nativeTargetSuffix,
51
+ nativeNapiVersion,
52
+ supportsNativeNapi,
53
+ };
package/lib/pg_server.js CHANGED
@@ -299,11 +299,20 @@ class PgConnection {
299
299
  }
300
300
 
301
301
  _onData(chunk) {
302
+ const max = this.server.maxMessageSize;
303
+ if (this.buffer.length + chunk.length > max * 2) {
304
+ this.socket.destroy();
305
+ return;
306
+ }
302
307
  this.buffer = Buffer.concat([this.buffer, chunk]);
303
308
  if (!this._startupDone || this._waitingSslRestart) {
304
309
  // 首消息: 长度 + 协议号(SSLRequest 拒绝后客户端重发的 startup 也是无类型消息)
305
310
  if (this.buffer.length < 4) return;
306
311
  const len = this.buffer.readInt32BE(0);
312
+ if (len < 8 || len > max) {
313
+ this.socket.destroy();
314
+ return;
315
+ }
307
316
  if (this.buffer.length < len) return;
308
317
  const body = this.buffer.slice(0, len);
309
318
  this.buffer = this.buffer.slice(len);
@@ -315,7 +324,12 @@ class PgConnection {
315
324
  if (this.buffer.length < 5) break;
316
325
  // PG 帧: type(1) + int32 length(含自身4字节, 不含type) + payload
317
326
  const len = this.buffer.readInt32BE(1);
318
- if (len < 4 || this.buffer.length < 1 + len) break;
327
+ if (len < 4 || len > max) {
328
+ this._send(buildErrorResponse('08P01', 'invalid or oversized protocol message length'));
329
+ this.socket.destroy();
330
+ return;
331
+ }
332
+ if (this.buffer.length < 1 + len) break;
319
333
  const type = this.buffer[0];
320
334
  const body = this.buffer.slice(5, 1 + len);
321
335
  this.buffer = this.buffer.slice(1 + len);
@@ -369,12 +383,15 @@ class PgConnection {
369
383
  let pos = 8;
370
384
  const params = {};
371
385
  while (pos < body.length) {
372
- const name = body.toString('utf8', pos, body.indexOf(0, pos));
373
- pos = body.indexOf(0, pos) + 1;
386
+ const nameEnd = body.indexOf(0, pos);
387
+ if (nameEnd < 0) break;
388
+ const name = body.toString('utf8', pos, nameEnd);
389
+ pos = nameEnd + 1;
374
390
  if (name === '') break;
375
- const val = body.toString('utf8', pos, body.indexOf(0, pos));
376
- pos = body.indexOf(0, pos) + 1;
377
- params[name] = val;
391
+ const valEnd = body.indexOf(0, pos);
392
+ if (valEnd < 0) break;
393
+ params[name] = body.toString('utf8', pos, valEnd);
394
+ pos = valEnd + 1;
378
395
  }
379
396
  this.user = params.user || 'postgres';
380
397
  this.database = params.database || this.user;
@@ -492,13 +509,16 @@ class PgConnection {
492
509
  if (!stmt) throw new Error(`prepared statement "${stmtName}" does not exist`);
493
510
  let off = n0 + this._cstringLen(body, n0);
494
511
  const nFormats = body.readInt16BE(off); off += 2;
512
+ if (nFormats < 0) throw new Error('invalid Bind format count');
495
513
  const formats = [];
496
514
  for (let i = 0; i < nFormats; i++) { formats.push(body.readInt16BE(off)); off += 2; }
497
515
  const nValues = body.readInt16BE(off); off += 2;
516
+ if (nValues < 0) throw new Error('invalid Bind parameter count');
498
517
  const values = [];
499
518
  for (let i = 0; i < nValues; i++) {
500
519
  const vlen = body.readInt32BE(off); off += 4;
501
520
  if (vlen === -1) { values.push(null); continue; }
521
+ if (vlen < 0 || off + vlen > body.length) throw new Error('invalid Bind parameter length');
502
522
  const fmt = formats.length > 0 ? formats[Math.min(i, formats.length - 1)] : 0;
503
523
  values.push(body.toString('utf8', off, off + vlen));
504
524
  off += vlen;
@@ -666,15 +686,9 @@ class PgConnection {
666
686
  };
667
687
  let results;
668
688
  if (values && values.length > 0) {
669
- // 参数化:将 $n 解析为 AST 后求值,或直接注入
670
- const prepared = sql.replace(/\$\d+/g, (m) => {
671
- const idx = parseInt(m.slice(1), 10) - 1;
672
- const v = values[idx];
673
- if (v === null || v === undefined) return 'NULL';
674
- if (typeof v === 'number') return String(v);
675
- return "'" + String(v).replace(/\\/g, '\\\\').replace(/'/g, "''") + "'";
676
- });
677
- results = await executeSQL(engine, prepared, { dialect: 'pg', safety: false, session: ctx.session });
689
+ // 交给 executeSQL/applyParams 处理 $n:会跳过字符串字面量和注释,
690
+ // 不会再像正则那样把 '$1' 替换进 SQL 文本里。
691
+ results = await executeSQL(engine, sql, values, { dialect: 'pg', safety: false, session: ctx.session });
678
692
  } else {
679
693
  results = await executeSQL(engine, sql, { dialect: 'pg', safety: false, session: ctx.session });
680
694
  }
@@ -758,6 +772,7 @@ class PgServer {
758
772
  this.auth = options.auth || null; // { user: { password, databases: [...] } }
759
773
  this.noAuth = options.noAuth === true;
760
774
  this.dataDir = options.dataDir || null;
775
+ this.maxMessageSize = options.maxMessageSize || 64 * 1024 * 1024;
761
776
  this.version = require('../package.json').version;
762
777
  this._server = null;
763
778
  this._sockets = new Set();
@@ -782,7 +797,8 @@ class PgServer {
782
797
 
783
798
  _canAccessDb(user, dbName) {
784
799
  if (this.noAuth) return true;
785
- if (!this.auth) return false;
800
+ // 未配置 auth 时与 MySQL 行为保持一致:不做库级限制
801
+ if (!this.auth) return true;
786
802
  const entry = this.auth[user];
787
803
  if (!entry) return false;
788
804
  if (entry && typeof entry === 'object' && Array.isArray(entry.databases)) {
@@ -829,11 +845,12 @@ class PgServer {
829
845
  this._sockets.add(socket);
830
846
  new PgConnection(socket, this);
831
847
  });
832
- this._server.listen(this.port, this.host, cb || (() => {}));
833
848
  this._server.on('error', (err) => {
834
849
  if (this.options.onError) this.options.onError(err);
835
- else throw err;
850
+ else if (cb) cb(err);
851
+ else console.error('[jsql-neo] pg server error:', err.message);
836
852
  });
853
+ this._server.listen(this.port, this.host, cb ? () => cb() : undefined);
837
854
  return this;
838
855
  }
839
856
 
@@ -39,7 +39,20 @@ class RedisServer {
39
39
  this.password = opts.password || null;
40
40
  this.dataDir = opts.dataDir || null;
41
41
  this.onQuery = opts.onQuery || null;
42
- this.db = new Map();
42
+ this.onError = opts.onError || null;
43
+ this.databaseCount = opts.databases != null ? Number(opts.databases) : 16;
44
+ if (!Number.isInteger(this.databaseCount) || this.databaseCount < 1 || this.databaseCount > 1024) {
45
+ throw new Error('Redis databases must be an integer between 1 and 1024');
46
+ }
47
+ this.maxBufferSize = opts.maxBufferSize || 64 * 1024 * 1024;
48
+ this.maxBulkLength = opts.maxBulkLength || 64 * 1024 * 1024;
49
+ this.maxMultiBulkLength = opts.maxMultiBulkLength || 1024 * 1024;
50
+ this.maxInlineLength = opts.maxInlineLength || 64 * 1024;
51
+ this._dbs = new Map();
52
+ for (let i = 0; i < this.databaseCount; i++) this._dbs.set(i, new Map());
53
+ this.selected = 0;
54
+ this.db = this._dbs.get(0);
55
+ this._selected = new WeakMap(); // socket → db index
43
56
  this.snapshotTimer = null;
44
57
  this._authedSockets = new WeakMap();
45
58
  this._load();
@@ -49,16 +62,29 @@ class RedisServer {
49
62
  return this.dataDir ? path.join(this.dataDir, 'data.rdb.json') : null;
50
63
  }
51
64
 
65
+ _restoreEntry(map, k, v) {
66
+ // Restore type info: { _t: 'set', _v: [...] } → Set, { _t: 'date', _v: ... } → Date
67
+ if (v && v._t === 'set' && Array.isArray(v._v)) map.set(k, new Set(v._v));
68
+ else if (v && v._t === 'date') map.set(k, new Date(v._v));
69
+ else map.set(k, v);
70
+ }
71
+
52
72
  _load() {
53
73
  const p = this.snapshotPath();
54
74
  if (!p || !fs.existsSync(p)) return;
55
75
  try {
56
76
  const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
57
- for (const [k, v] of Object.entries(raw)) {
58
- // Restore type info: { _t: 'set', _v: [...] } → Set, { _t: 'date', _v: ... } → Date
59
- if (v && v._t === 'set' && Array.isArray(v._v)) this.db.set(k, new Set(v._v));
60
- else if (v && v._t === 'date') this.db.set(k, new Date(v._v));
61
- else this.db.set(k, v);
77
+ if (raw && raw.__jsql_redis__ === 2 && raw.databases && typeof raw.databases === 'object') {
78
+ // v2:多 DB 快照 { __jsql_redis__: 2, databases: { "0": {...}, "1": {...} } }
79
+ for (const [idxStr, entries] of Object.entries(raw.databases)) {
80
+ const idx = Number(idxStr);
81
+ const map = this._dbs.get(idx);
82
+ if (!map || !entries || typeof entries !== 'object') continue;
83
+ for (const [k, v] of Object.entries(entries)) this._restoreEntry(map, k, v);
84
+ }
85
+ } else {
86
+ // v1:旧版只有单个 db,全部落到 0 号库
87
+ for (const [k, v] of Object.entries(raw || {})) this._restoreEntry(this._dbs.get(0), k, v);
62
88
  }
63
89
  } catch (_) {}
64
90
  }
@@ -74,14 +100,18 @@ class RedisServer {
74
100
  const p = this.snapshotPath();
75
101
  if (!p) return;
76
102
  fs.mkdirSync(path.dirname(p), { recursive: true });
77
- const out = {};
103
+ const out = { __jsql_redis__: 2, databases: {} };
78
104
  const now = Date.now();
79
- for (const [k, v] of this.db) {
80
- if (v && v.ttl && v.ttl <= now) continue;
81
- // Preserve type info: Set → { _t: 'set', _v: [...] }, Date → ISO string
82
- if (v instanceof Set) out[k] = { _t: 'set', _v: [...v] };
83
- else if (v instanceof Date) out[k] = { _t: 'date', _v: v.toISOString() };
84
- else out[k] = v;
105
+ for (const [idx, map] of this._dbs) {
106
+ const entries = {};
107
+ for (const [k, v] of map) {
108
+ if (v && v.ttl && v.ttl <= now) continue;
109
+ // Preserve type info: Set → { _t: 'set', _v: [...] }, Date → ISO string
110
+ if (v instanceof Set) entries[k] = { _t: 'set', _v: [...v] };
111
+ else if (v instanceof Date) entries[k] = { _t: 'date', _v: v.toISOString() };
112
+ else entries[k] = v;
113
+ }
114
+ if (Object.keys(entries).length > 0) out.databases[String(idx)] = entries;
85
115
  }
86
116
  const tmp = p + '.tmp';
87
117
  fs.writeFileSync(tmp, JSON.stringify(out));
@@ -118,10 +148,14 @@ class RedisServer {
118
148
  if (!this.password) throw new Error('ERR Client sent AUTH, but no password is set');
119
149
  if (args[0] !== this.password) throw new Error('ERR invalid password');
120
150
  return 'OK';
121
- case 'SELECT':
151
+ case 'SELECT': {
122
152
  if (!/^\d+$/.test(args[0])) throw new Error(`ERR invalid DB index`);
123
- this.selected = parseInt(args[0], 10);
153
+ const idx = parseInt(args[0], 10);
154
+ if (idx >= this.databaseCount) throw new Error('ERR DB index is out of range');
155
+ this.selected = idx;
156
+ this.db = this._dbs.get(idx);
124
157
  return 'OK';
158
+ }
125
159
  case 'SET': {
126
160
  const key = args[0];
127
161
  const val = args[1];
@@ -525,7 +559,11 @@ class RedisServer {
525
559
  }
526
560
  return count;
527
561
  }
528
- case 'FLUSHALL': case 'FLUSHDB':
562
+ case 'FLUSHALL':
563
+ for (const map of this._dbs.values()) map.clear();
564
+ this._scheduleSnapshot();
565
+ return 'OK';
566
+ case 'FLUSHDB':
529
567
  this.db.clear();
530
568
  this._scheduleSnapshot();
531
569
  return 'OK';
@@ -548,9 +586,13 @@ class RedisServer {
548
586
  }
549
587
 
550
588
  /* ---------- RESP protocol ---------- */
551
- listen() {
589
+ listen(cb) {
552
590
  this.server = net.createServer((socket) => this._handleSocket(socket));
553
- this.server.listen(this.port, this.host);
591
+ this.server.on('error', (err) => {
592
+ if (this.onError) this.onError(err);
593
+ else if (cb) cb(err);
594
+ });
595
+ this.server.listen(this.port, this.host, cb ? () => cb() : undefined);
554
596
  return this;
555
597
  }
556
598
 
@@ -580,7 +622,13 @@ class RedisServer {
580
622
  };
581
623
 
582
624
  socket.on('data', (chunk) => {
583
- buf = Buffer.concat([buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8')]);
625
+ const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
626
+ if (buf.length + next.length > this.maxBufferSize) {
627
+ try { this._send(socket, new Error('ERR Protocol error: request buffer limit exceeded')); } catch (_) {}
628
+ socket.destroy();
629
+ return;
630
+ }
631
+ buf = Buffer.concat([buf, next]);
584
632
  drain();
585
633
  });
586
634
  socket.on('error', () => {});
@@ -598,7 +646,11 @@ class RedisServer {
598
646
  // 非 '*' 开头:inline 命令(telnet / nc 手工输入)
599
647
  if (buf[0] !== 0x2a) {
600
648
  const i = crlf(0);
601
- if (i < 0) return null;
649
+ if (i < 0) {
650
+ if (buf.length > this.maxInlineLength) throw new Error('ERR Protocol error: too big inline request');
651
+ return null;
652
+ }
653
+ if (i > this.maxInlineLength) throw new Error('ERR Protocol error: too big inline request');
602
654
  const line = buf.slice(0, i).toString('utf8').trim();
603
655
  if (!line) return { consumed: i + 2, ignore: true };
604
656
  const parts = line.split(/\s+/);
@@ -612,6 +664,7 @@ class RedisServer {
612
664
  if (i < 0) return null;
613
665
  const n = parseInt(buf.slice(1, i).toString('utf8'), 10);
614
666
  if (isNaN(n)) throw new Error('ERR Protocol error: invalid multibulk length');
667
+ if (n > this.maxMultiBulkLength) throw new Error('ERR Protocol error: invalid multibulk length');
615
668
  // *0(空命令)与 *-1(null 数组)此前会走到 parts[0].toUpperCase() 崩溃
616
669
  if (n <= 0) return { consumed: i + 2, ignore: true };
617
670
 
@@ -624,8 +677,10 @@ class RedisServer {
624
677
  if (j < 0) return null;
625
678
  const len = parseInt(buf.slice(off + 1, j).toString('utf8'), 10);
626
679
  if (isNaN(len)) throw new Error('ERR Protocol error: invalid bulk length');
680
+ if (len > this.maxBulkLength) throw new Error('ERR Protocol error: invalid bulk length');
627
681
  const start = j + 2;
628
682
  if (len === -1) { parts.push(null); off = start; continue; } // $-1 null bulk
683
+ if (len < -1) throw new Error('ERR Protocol error: invalid bulk length');
629
684
  if (buf.length < start + len + 2) return null; // 按字节判断
630
685
  parts.push(buf.slice(start, start + len).toString('utf8'));
631
686
  off = start + len + 2;
@@ -635,6 +690,11 @@ class RedisServer {
635
690
  }
636
691
 
637
692
  _handle(socket, cmd, args) {
693
+ const idx = this._selected.get(socket);
694
+ const prevDb = this.db;
695
+ const prevSelected = this.selected;
696
+ // execute() 是同步的,这里临时切换 this.db 实现每连接独立的 SELECT
697
+ this.db = this._dbs.get(idx === undefined ? 0 : idx) || this._dbs.get(0);
638
698
  try {
639
699
  if (this.onQuery) this.onQuery(cmd, args);
640
700
  if (cmd === 'AUTH') {
@@ -661,9 +721,16 @@ class RedisServer {
661
721
  return;
662
722
  }
663
723
  const r = this.execute(cmd, args);
724
+ if (cmd === 'SELECT' && r === 'OK') this._selected.set(socket, this.selected);
664
725
  this._send(socket, r);
665
726
  } catch (e) {
666
727
  this._send(socket, e);
728
+ } finally {
729
+ // SELECT 需要把全局指针留在新库上,兼容直接调用 execute() 的旧用法
730
+ if (cmd !== 'SELECT') {
731
+ this.db = prevDb;
732
+ this.selected = prevSelected;
733
+ }
667
734
  }
668
735
  }
669
736
 
package/lib/sql.js CHANGED
@@ -7601,6 +7601,21 @@ function applyParams(sql, values) {
7601
7601
  i++;
7602
7602
  continue;
7603
7603
  }
7604
+ // PostgreSQL dollar-quoted string: $$...$$ / $tag$...$tag$
7605
+ // 必须先于 $N 参数处理,否则字符串里的 $1 会被错误替换。
7606
+ if (c === '$' && !(sql[i + 1] >= '0' && sql[i + 1] <= '9')) {
7607
+ let j = i + 1;
7608
+ while (j < n && /[A-Za-z0-9_]/.test(sql[j])) j++;
7609
+ if (sql[j] === '$') {
7610
+ const delim = sql.slice(i, j + 1);
7611
+ const end = sql.indexOf(delim, j + 1);
7612
+ if (end !== -1) {
7613
+ out += sql.slice(i, end + delim.length);
7614
+ i = end + delim.length;
7615
+ continue;
7616
+ }
7617
+ }
7618
+ }
7604
7619
  // $N 编号占位符(PG 风格):$1 / $2 ...
7605
7620
  if (c === '$' && i + 1 < sql.length && sql[i + 1] >= '0' && sql[i + 1] <= '9') {
7606
7621
  let num = '';
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  const { workerData } = require('worker_threads');
8
+ const vm = require('vm');
8
9
  const Database = require('./database');
9
10
  const { executeSQL, parseSQL, applyParams } = require('./sql');
10
11
 
@@ -15,6 +16,43 @@ let sab = null;
15
16
  let ctrl = null;
16
17
  const userFunctions = {};
17
18
  const userAggregates = {};
19
+ const MAX_UDF_SOURCE = 64 * 1024;
20
+
21
+ /**
22
+ * 编译用户注册的函数/聚合器。
23
+ * 默认在受限 vm 沙箱中执行(无 require/process/Buffer,禁用 eval/new Function);
24
+ * 需要闭包/宿主能力时显式设置 JSQL_ALLOW_UNSAFE_UDF=1(信任该函数源码)。
25
+ */
26
+ function compileUserFunction(fnStr, kind, allowObject) {
27
+ if (typeof fnStr !== 'string' || fnStr.length === 0 || fnStr.length > MAX_UDF_SOURCE) {
28
+ throw new Error(`${kind} source must be a non-empty string under ${MAX_UDF_SOURCE} bytes`);
29
+ }
30
+ if (process.env.JSQL_ALLOW_UNSAFE_UDF === '1') {
31
+ // 兼容旧行为:显式信任时允许完整宿主权限
32
+ // eslint-disable-next-line no-new-func
33
+ const fn = new Function('return (' + fnStr + ')')();
34
+ if (typeof fn !== 'function' && !(allowObject && fn && typeof fn === 'object')) {
35
+ throw new Error(`${kind} source did not evaluate to a function`);
36
+ }
37
+ return fn;
38
+ }
39
+ const context = vm.createContext(Object.create(null), {
40
+ codeGeneration: { strings: false, wasm: false },
41
+ });
42
+ vm.runInContext(
43
+ 'globalThis.console = Object.freeze({ log(){}, info(){}, warn(){}, error(){} });',
44
+ context,
45
+ { timeout: 1000 }
46
+ );
47
+ const fn = vm.runInContext('(' + fnStr + ')', context, {
48
+ timeout: 1000,
49
+ filename: `jsql-${kind}.js`,
50
+ });
51
+ if (typeof fn !== 'function' && !(allowObject && fn && typeof fn === 'object')) {
52
+ throw new Error(`${kind} source did not evaluate to a function`);
53
+ }
54
+ return fn;
55
+ }
18
56
 
19
57
  function startEngine(filename, options) {
20
58
  const opts = options || {};
@@ -182,8 +220,7 @@ async function handleOp(op, args) {
182
220
  const [name, fnStr, deterministic] = args;
183
221
  // 用函数源码在 worker 中重建(无闭包依赖的纯函数)
184
222
  try {
185
- // eslint-disable-next-line no-new-func
186
- const fn = new Function('return (' + fnStr + ')')();
223
+ const fn = compileUserFunction(fnStr, 'registerFunction');
187
224
  userFunctions[String(name).toUpperCase()] = fn;
188
225
  result = { ok: true };
189
226
  } catch (e) {
@@ -199,8 +236,7 @@ async function handleOp(op, args) {
199
236
  case 'registerAggregate': {
200
237
  const [name, specStr, deterministic] = args;
201
238
  try {
202
- // eslint-disable-next-line no-new-func
203
- const spec = new Function('return (' + specStr + ')')();
239
+ const spec = compileUserFunction(specStr, 'registerAggregate', true);
204
240
  userAggregates[String(name).toUpperCase()] = spec;
205
241
  result = { ok: true };
206
242
  } catch (e) {
package/lib/star-nag.js CHANGED
@@ -1,3 +1,4 @@
1
+ // === lib/star-nag.js ===
1
2
  'use strict';
2
3
  /**
3
4
  * CLI 启动时的 star 提示。
@@ -25,8 +26,8 @@ const URL = `https://github.com/${REPO}`;
25
26
  const NAG_INTERVAL_DAYS = 30;
26
27
 
27
28
  const AUTH_BASE = process.env.JSQL_AUTH_BASE || 'https://auth.vexify.top';
28
- const AUTH_START = AUTH_BASE + '/start.php';
29
- const AUTH_RESULT = AUTH_BASE + '/result.php';
29
+ const AUTH_START = AUTH_BASE + '/start';
30
+ const AUTH_RESULT = AUTH_BASE + '/result';
30
31
 
31
32
  /**
32
33
  * 发起 OAuth 浏览器流并在后台轮询结果。
@@ -35,11 +36,11 @@ const AUTH_RESULT = AUTH_BASE + '/result.php';
35
36
  * 拿到结果写进 ~/.jsqlmr(starred: true/false)。
36
37
  * 进程提前退出就放弃这一次,下次启动会再提示(30 天节流管频率)。
37
38
  */
38
- // result.php 的合法终态:done / denied / error。
39
+ // result 的合法终态:done / denied / error。
39
40
  // 只有终态才写配置——"started" 这一类中间态写进去只会污染 ~/.jsqlmr。
40
41
  const TERMINAL_STATES = new Set(['done', 'denied', 'error']);
41
42
 
42
- // 把 result.php 的响应收敛成规范状态。老服务端会把 started 也报成 done,
43
+ // 把 result 的响应收敛成规范状态。老服务端会把 started 也报成 done,
43
44
  // 缺少 starred 字段 —— 一律当未完成处理,让调用方继续等。
44
45
  function parseAuthResult(j) {
45
46
  if (!j || typeof j !== 'object') return null;
@@ -280,3 +281,4 @@ function waitForResult(startUrl, timeoutMs = 180000) {
280
281
  }
281
282
 
282
283
  module.exports = { nag, checkStarred, startOauthCheck, waitForResult, parseAuthResult, readConfig, setStarred, configPath, REPO, URL };
284
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.5.6",
3
+ "version": "6.5.8",
4
4
  "description": "JSQL-NEO \u2014 Rust-powered embedded database with WASM, REST API, B-Tree indexes, WAL, crash recovery",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/postinstall.js CHANGED
@@ -1,8 +1,10 @@
1
1
  const { execSync } = require('child_process');
2
+ const crypto = require('crypto');
2
3
  const fs = require('fs');
3
4
  const path = require('path');
4
5
  const http = require('http');
5
6
  const https = require('https');
7
+ const { detectLibc, nativeTargetSuffix, nativeNapiVersion, supportsNativeNapi, MIN_NAPI_VERSION } = require('./lib/native_target');
6
8
 
7
9
  const ROOT = __dirname;
8
10
  const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
@@ -34,6 +36,10 @@ const GHPROXY = process.env.JSQL_GHPROXY !== undefined
34
36
  const GHPROXY_TOKEN = process.env.JSQL_GHPROXY_TOKEN || DEFAULT_GHPROXY_TOKEN;
35
37
  const TAG = `v${pkg.version}`;
36
38
 
39
+ // 原生模块体积上限(防代理/镜像返回超大文件打爆磁盘/内存)
40
+ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
41
+ const CHECKSUM_ASSET = 'SHA256SUMS';
42
+
37
43
  /** 加速站鉴权头 */
38
44
  function proxyHeaders() {
39
45
  return GHPROXY_TOKEN ? { Authorization: 'Bearer ' + GHPROXY_TOKEN } : undefined;
@@ -64,22 +70,30 @@ const REPO = (pkg.repository && pkg.repository.url)
64
70
  ? pkg.repository.url.replace(/\.git$/, '').replace(/^.*github\.com\//i, '')
65
71
  : 'vexify-org/JSQL-neo';
66
72
 
67
- // 与 .github/workflows/build-native.yml 中发布的资产命名保持一致
68
- function nativeTargetSuffix(platform = process.platform, arch = process.arch) {
69
- const p = platform === 'win32' ? 'win32' : platform === 'darwin' ? 'darwin' : 'linux';
70
- const a = arch === 'x64' ? 'x64' : arch === 'arm64' ? 'arm64' : arch;
71
- if (p === 'linux') return `${p}-${a}-gnu`;
72
- if (p === 'darwin') return `${p}-${a}`;
73
- return `${p}-${a}-msvc`;
73
+ function sameOrigin(a, b) {
74
+ try { return new URL(a).origin === new URL(b).origin; } catch (_) { return false; }
75
+ }
76
+
77
+ function safeRedirect(next) {
78
+ return /^https?:/i.test(String(next || ''));
74
79
  }
75
80
 
76
- function download(url, dest, headers) {
81
+ function download(url, dest, headers, redirects = 0) {
77
82
  return new Promise((resolve, reject) => {
83
+ if (redirects > 5) {
84
+ reject(new Error(`too many redirects for ${url}`));
85
+ return;
86
+ }
78
87
  const mod = url.startsWith('https:') ? https : http;
79
88
  const req = mod.get(url, { headers: headers || {} }, (res) => {
80
89
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
81
90
  res.resume();
82
- download(res.headers.location, dest, headers).then(resolve, reject);
91
+ const next = new URL(res.headers.location, url).toString();
92
+ if (!safeRedirect(next)) {
93
+ reject(new Error(`invalid redirect target for ${url}`));
94
+ return;
95
+ }
96
+ download(next, dest, sameOrigin(next, url) ? headers : undefined, redirects + 1).then(resolve, reject);
83
97
  return;
84
98
  }
85
99
  if (res.statusCode !== 200) {
@@ -87,16 +101,137 @@ function download(url, dest, headers) {
87
101
  reject(new Error(`HTTP ${res.statusCode} for ${url}`));
88
102
  return;
89
103
  }
104
+ const declared = Number(res.headers['content-length']);
105
+ if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) {
106
+ res.resume();
107
+ reject(new Error(`download too large: ${declared} bytes (limit ${MAX_DOWNLOAD_BYTES})`));
108
+ return;
109
+ }
90
110
  fs.mkdirSync(path.dirname(dest), { recursive: true });
91
111
  const ws = fs.createWriteStream(dest);
92
- ws.on('error', reject);
112
+ let received = 0;
113
+ let settled = false;
114
+ const fail = (err) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ try { ws.destroy(); } catch (_) { /* ignore */ }
118
+ try { fs.unlinkSync(dest); } catch (_) { /* ignore */ }
119
+ reject(err);
120
+ };
121
+ ws.on('error', fail);
122
+ res.on('error', fail);
123
+ res.on('data', (chunk) => {
124
+ received += chunk.length;
125
+ if (received > MAX_DOWNLOAD_BYTES) {
126
+ fail(new Error(`download exceeded ${MAX_DOWNLOAD_BYTES} bytes`));
127
+ res.destroy();
128
+ }
129
+ });
93
130
  res.pipe(ws);
94
- res.on('end', () => ws.end(() => resolve()));
131
+ ws.on('finish', () => {
132
+ if (settled) return;
133
+ settled = true;
134
+ resolve();
135
+ });
95
136
  });
96
137
  req.on('error', reject);
97
138
  });
98
139
  }
99
140
 
141
+ function fetchText(url, headers, redirects = 0) {
142
+ return new Promise((resolve, reject) => {
143
+ if (redirects > 5) {
144
+ reject(new Error(`too many redirects for ${url}`));
145
+ return;
146
+ }
147
+ const mod = url.startsWith('https:') ? https : http;
148
+ const req = mod.get(url, { headers: headers || {} }, (res) => {
149
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
150
+ res.resume();
151
+ const next = new URL(res.headers.location, url).toString();
152
+ if (!safeRedirect(next)) {
153
+ reject(new Error(`invalid redirect target for ${url}`));
154
+ return;
155
+ }
156
+ fetchText(next, sameOrigin(next, url) ? headers : undefined, redirects + 1).then(resolve, reject);
157
+ return;
158
+ }
159
+ if (res.statusCode !== 200) {
160
+ res.resume();
161
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
162
+ return;
163
+ }
164
+ const chunks = [];
165
+ let received = 0;
166
+ res.on('data', (chunk) => {
167
+ received += chunk.length;
168
+ if (received > 1024 * 1024) {
169
+ res.destroy();
170
+ reject(new Error('checksum manifest too large'));
171
+ return;
172
+ }
173
+ chunks.push(chunk);
174
+ });
175
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
176
+ res.on('error', reject);
177
+ });
178
+ req.on('error', reject);
179
+ });
180
+ }
181
+
182
+ function sha256File(file) {
183
+ const hash = crypto.createHash('sha256');
184
+ const fd = fs.openSync(file, 'r');
185
+ try {
186
+ const buf = Buffer.allocUnsafe(1024 * 1024);
187
+ for (;;) {
188
+ const n = fs.readSync(fd, buf, 0, buf.length, null);
189
+ if (n <= 0) break;
190
+ hash.update(buf.subarray(0, n));
191
+ }
192
+ } finally {
193
+ fs.closeSync(fd);
194
+ }
195
+ return hash.digest('hex');
196
+ }
197
+
198
+ /** 从 SHA256SUMS 清单中取出指定资产的小写 sha256;找不到返回 null */
199
+ function expectedHashFromManifest(text, asset) {
200
+ for (const line of String(text || '').split(/\r?\n/)) {
201
+ const m = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
202
+ if (!m) continue;
203
+ if (path.basename(m[2].trim()) === asset) return m[1].toLowerCase();
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * 获取原生模块的期望哈希:
210
+ * 1) JSQL_NATIVE_SHA256 显式指定(最高优先级)
211
+ * 2) 从 GitHub Release 的 SHA256SUMS 资产下载并解析
212
+ * 拿不到就返回 null —— 调用方会拒绝加载未校验的二进制(fail closed)。
213
+ */
214
+ async function fetchExpectedHash(asset) {
215
+ const explicit = process.env.JSQL_NATIVE_SHA256;
216
+ if (explicit) {
217
+ if (!/^[a-fA-F0-9]{64}$/.test(explicit.trim())) {
218
+ throw new Error('JSQL_NATIVE_SHA256 must be a 64-character hex SHA-256 digest');
219
+ }
220
+ return explicit.trim().toLowerCase();
221
+ }
222
+ const raw = `https://github.com/${REPO}/releases/download/${TAG}/${CHECKSUM_ASSET}`;
223
+ for (const cand of proxyCandidates(raw)) {
224
+ try {
225
+ const text = await fetchText(cand.url, cand.viaProxy ? proxyHeaders() : undefined);
226
+ const hash = expectedHashFromManifest(text, asset);
227
+ if (hash) return hash;
228
+ } catch (e) {
229
+ // 清单不存在/代理失败时继续尝试下一个来源
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+
100
235
  function buildNativeFromSource() {
101
236
  const crate = path.join(ROOT, 'nativesrc', 'jsql-neo-native');
102
237
  if (!fs.existsSync(path.join(crate, 'Cargo.toml'))) return;
@@ -135,14 +270,62 @@ function tryLoadNative() {
135
270
  }
136
271
 
137
272
  async function downloadAndVerify() {
138
- const asset = `jsql-neo-native.${nativeTargetSuffix()}.node`;
273
+ if (!supportsNativeNapi()) {
274
+ console.warn(
275
+ `[jsql-neo] Node ${process.versions.node} provides N-API ${nativeNapiVersion() || 'unknown'}, ` +
276
+ `but the native module requires N-API >= ${MIN_NAPI_VERSION}; skipping native download.`
277
+ );
278
+ return false;
279
+ }
280
+ const libc = detectLibc();
281
+ const asset = process.env.JSQL_NATIVE_ASSET || `jsql-neo-native.${nativeTargetSuffix(process.platform, process.arch, libc)}.node`;
139
282
  const raw = `https://github.com/${REPO}/releases/download/${TAG}/${asset}`;
283
+
284
+ let expected = null;
285
+ try {
286
+ expected = await fetchExpectedHash(asset);
287
+ } catch (e) {
288
+ console.warn('[jsql-neo] failed to resolve native checksum:', e.message);
289
+ }
290
+
291
+ const allowUnverified = process.env.JSQL_ALLOW_UNVERIFIED_NATIVE === '1';
292
+ if (!expected && !allowUnverified) {
293
+ if (libc === 'musl') {
294
+ console.warn(
295
+ `[jsql-neo] musl libc detected but this release has no native asset for ${asset}; ` +
296
+ 'falling back to the WASM/JS engine. Set JSQL_NATIVE_ASSET to a compatible musl build if available.'
297
+ );
298
+ } else {
299
+ // fail closed:拿不到 SHA256SUMS 就不下载,绝不 require 未经校验的二进制
300
+ console.warn(
301
+ '[jsql-neo] no SHA256SUMS available for this release; refusing to download an unverified native module.\n' +
302
+ '[jsql-neo] set JSQL_NATIVE_SHA256=<64-hex> to pin a checksum, or JSQL_ALLOW_UNVERIFIED_NATIVE=1 to override (unsafe).'
303
+ );
304
+ }
305
+ return false;
306
+ }
307
+ if (!expected) {
308
+ console.warn('[jsql-neo] WARNING: JSQL_ALLOW_UNVERIFIED_NATIVE=1 — native module integrity is NOT verified.');
309
+ }
310
+
140
311
  // 依次尝试:环境变量指定的代理 → 内置加速站 → 直连
141
312
  for (const cand of proxyCandidates(raw)) {
142
313
  console.log(`[jsql-neo] downloading native module: ${cand.url}`);
314
+ const tmp = `${NATIVE_PATH}.${process.pid}.download`;
143
315
  try {
144
316
  // 加速站鉴权走 Authorization 头,直连时不带
145
- await download(cand.url, NATIVE_PATH, cand.viaProxy ? proxyHeaders() : undefined);
317
+ await download(cand.url, tmp, cand.viaProxy ? proxyHeaders() : undefined);
318
+ if (expected) {
319
+ const actual = sha256File(tmp);
320
+ if (actual !== expected) {
321
+ console.warn(`[jsql-neo] checksum mismatch (expected ${expected}, got ${actual}); trying next source.`);
322
+ continue;
323
+ }
324
+ console.log('[jsql-neo] checksum verified:', actual);
325
+ }
326
+ fs.mkdirSync(path.dirname(NATIVE_PATH), { recursive: true });
327
+ fs.renameSync(tmp, NATIVE_PATH);
328
+ fs.chmodSync(NATIVE_PATH, 0o755);
146
329
  if (tryLoadNative()) {
147
330
  console.log('[jsql-neo] native module downloaded: native/jsql-neo-native.node');
148
331
  return true;
@@ -150,6 +333,8 @@ async function downloadAndVerify() {
150
333
  console.warn('[jsql-neo] downloaded module cannot be loaded; trying next source.');
151
334
  } catch (e) {
152
335
  console.warn('[jsql-neo] download failed:', e.message);
336
+ } finally {
337
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch (_) { /* ignore */ }
153
338
  }
154
339
  }
155
340
  return false;
@@ -161,6 +346,13 @@ async function ensureNativeNode() {
161
346
  console.log('[jsql-neo] native module ready: native/jsql-neo-native.node');
162
347
  return;
163
348
  }
349
+ if (!supportsNativeNapi()) {
350
+ console.warn(
351
+ `[jsql-neo] Node ${process.versions.node} provides N-API ${nativeNapiVersion() || 'unknown'}, ` +
352
+ `but the native module requires N-API >= ${MIN_NAPI_VERSION}; using the WASM/JS engine.`
353
+ );
354
+ return;
355
+ }
164
356
  if (await downloadAndVerify()) return;
165
357
  buildNativeFromSource();
166
358
  }
@@ -1,3 +1,4 @@
1
+ // === test/star-nag.test.js ===
1
2
  /**
2
3
  * jsql star 授权流回归测试
3
4
  *
@@ -42,7 +43,7 @@ let after = null;
42
43
 
43
44
  const server = http.createServer((req, res) => {
44
45
  res.writeHead(200, { 'Content-Type': 'application/json' });
45
- if (new URL(req.url, 'http://127.0.0.1').pathname === '/result.php') {
46
+ if (new URL(req.url, 'http://127.0.0.1').pathname === '/result') {
46
47
  if (pollsLeft > 0 && --pollsLeft === 0 && after) scenario = after;
47
48
  res.end(JSON.stringify(scenario));
48
49
  return;
@@ -121,7 +122,7 @@ async function main() {
121
122
  {
122
123
  // waitForResult 用同步 curl,会阻塞本进程事件循环 —— server 必须留在父进程,
123
124
  // 轮询放到子进程里跑,否则 parent 的 server 被饿死,curl 全部 -m 超时。
124
- const url = `http://127.0.0.1:${PORT}/start.php?nonce=abcdefghijklmnopqrst`;
125
+ const url = `http://127.0.0.1:${PORT}/start?nonce=abcdefghijklmnopqrst`;
125
126
  const poll = (resp, timeoutMs) => new Promise((resolve) => {
126
127
  scenario = resp; pollsLeft = 0; after = null;
127
128
  const code = [
@@ -158,3 +159,4 @@ async function main() {
158
159
  }
159
160
 
160
161
  main().catch((e) => { console.error(e); process.exit(1); });
162
+