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 +1 -1
- package/bin/jsql +1 -1
- package/index.js +1 -1
- package/lib/migrate.js +89 -13
- package/lib/mod.js +28 -16
- package/lib/mongo_server.js +18 -2
- package/lib/multiserver.js +87 -2
- package/lib/mysql_server.js +10 -6
- package/lib/native_client.js +18 -4
- package/lib/native_target.js +53 -0
- package/lib/pg_server.js +35 -18
- package/lib/redis_server.js +87 -20
- package/lib/sql.js +15 -0
- package/lib/sqlite_worker.js +40 -4
- package/lib/star-nag.js +6 -4
- package/package.json +1 -1
- package/postinstall.js +205 -13
- package/test/star-nag.test.js +4 -2
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
|
+
> **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
|

|
|
9
9
|

|
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
|
|
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
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
//
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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) };
|
package/lib/mongo_server.js
CHANGED
|
@@ -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.
|
|
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 >
|
|
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
|
});
|
package/lib/multiserver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/lib/mysql_server.js
CHANGED
|
@@ -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
|
-
//
|
|
439
|
-
|
|
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 (
|
|
1300
|
-
else
|
|
1301
|
-
|
|
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;
|
package/lib/native_client.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
373
|
-
|
|
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
|
|
376
|
-
|
|
377
|
-
params[name] =
|
|
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
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/lib/redis_server.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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':
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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 = '';
|
package/lib/sqlite_worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
const AUTH_RESULT = AUTH_BASE + '/result
|
|
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
|
|
39
|
+
// result 的合法终态:done / denied / error。
|
|
39
40
|
// 只有终态才写配置——"started" 这一类中间态写进去只会污染 ~/.jsqlmr。
|
|
40
41
|
const TERMINAL_STATES = new Set(['done', 'denied', 'error']);
|
|
41
42
|
|
|
42
|
-
// 把 result
|
|
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
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/test/star-nag.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
|