dsh-db-tool 1.7.2 → 1.8.1

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.
@@ -6,7 +6,7 @@
6
6
  * TRANSACTION READ ONLY)+ query 首词只读白名单双保险;服务层拦截 execute。
7
7
  */
8
8
  import mysql from 'mysql2/promise';
9
- import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from '../sql-shared/common.js';
9
+ import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent, validPort } from '../sql-shared/common.js';
10
10
  /** ro 模式下 query 允许的读语句首词白名单 */
11
11
  const RO_READ_PREFIX = /^(select|show|desc|describe|explain|use|help|table)\b/i;
12
12
  /**
@@ -43,7 +43,8 @@ function connOptions(conn) {
43
43
  if (conn.url) {
44
44
  const u = new URL(conn.url);
45
45
  base.host = u.hostname;
46
- base.port = Number(u.port) || 3306;
46
+ // 端口段校验:':0' 等非法值抛中文错误,不再被 Number()|| 静默改写 3306
47
+ base.port = u.port ? validPort(u.port, 3306, 'mysql') : 3306;
47
48
  try {
48
49
  if (u.username)
49
50
  base.user = decodeURIComponent(u.username);
@@ -63,7 +64,8 @@ function connOptions(conn) {
63
64
  else if (conn.fields) {
64
65
  const f = conn.fields;
65
66
  base.host = String(f.host ?? '127.0.0.1');
66
- base.port = Number(f.port ?? 3306);
67
+ // 前端原样发字符串端口:显式校验,NaN/0 等不再被驱动 `|| 3306` 静默改写后连错服务
68
+ base.port = validPort(f.port, 3306, 'mysql');
67
69
  base.user = String(f.user ?? f.username ?? 'root');
68
70
  if (f.password != null)
69
71
  base.password = String(f.password);
@@ -78,16 +80,46 @@ function connOptions(conn) {
78
80
  return base;
79
81
  }
80
82
  export async function createMysqlAdapter(conn, opts) {
81
- const pool = mysql.createPool(connOptions(conn));
83
+ const baseOpts = connOptions(conn);
84
+ const pool = mysql.createPool(baseOpts);
82
85
  const readOnly = opts?.mode === 'ro';
83
- if (readOnly) {
84
- // 服务器级 ro 强制:每个新底层连接自动设为只读会话(应用层另有 query 白名单双保险)。
85
- // 回调收到的是 callback 版 PoolConnection,query() 返回 Query 命令对象(EventEmitter):
86
- // 吞掉 SET 失败防 uncaughtException(见 swallowSetError)。
87
- pool.on('connection', (c) => {
86
+ // 跨库(Navicat 式):按库名缓存独立池。mysql2 官方多库方案即多池管理——池上
87
+ // changeUser/USE 只影响被调用的那条池内连接,池保持建池配置,切库不可靠。
88
+ const mainDb = typeof baseOpts.database === 'string' ? baseOpts.database : '';
89
+ const dbPools = new Map();
90
+ /**
91
+ * 关闭幂等标志(对齐 pg-like/sqlserver):关闭后一切访问必须被拦下——
92
+ * 否则跨库访问会新建池(关不掉的连接泄漏),主池请求则报英文驱动错。
93
+ */
94
+ let closed = false;
95
+ const assertOpen = () => {
96
+ if (closed)
97
+ throw new Error('mysql 连接已关闭:请重新连接后重试');
98
+ };
99
+ /** ro 模式:每个新底层连接自动设为只读会话(应用层另有 query 白名单双保险)。
100
+ * 回调收到的是 callback 版 PoolConnection,query() 返回 Query 命令对象(EventEmitter):
101
+ * 吞掉 SET 失败防 uncaughtException(见 swallowSetError)。主池与跨库池共用同一注册。 */
102
+ const attachReadOnly = (p) => {
103
+ if (!readOnly)
104
+ return;
105
+ p.on('connection', (c) => {
88
106
  swallowSetError(c.query('SET SESSION TRANSACTION READ ONLY'));
89
107
  });
90
- }
108
+ };
109
+ attachReadOnly(pool);
110
+ const poolFor = (db) => {
111
+ assertOpen();
112
+ if (db === mainDb)
113
+ return pool;
114
+ let p = dbPools.get(db);
115
+ if (!p) {
116
+ // connOptions 已把 URL/fields 拆成纯字段,覆盖 database 即建出目标库的池
117
+ p = mysql.createPool({ ...baseOpts, database: db });
118
+ dbPools.set(db, p);
119
+ attachReadOnly(p);
120
+ }
121
+ return p;
122
+ };
91
123
  /** 事务激活期间路由到同一连接,保证 BEGIN/COMMIT 生效 */
92
124
  let txConn = null;
93
125
  // PoolConnection 与 Pool 的联合会让 mysql2 的 query/execute 重载解析失败,收窄为 Pool 视图
@@ -160,18 +192,23 @@ export async function createMysqlAdapter(conn, opts) {
160
192
  const [rows] = await pool.query('SELECT VERSION() AS v');
161
193
  return { ok: true, serverInfo: `MySQL ${String(rows[0]?.v ?? '')}` };
162
194
  }),
163
- query: (sql, params) => humanize('mysql 查询失败', async () => {
195
+ query: (sql, params, database) => humanize('mysql 查询失败', async () => {
164
196
  if (readOnly && !RO_READ_PREFIX.test(sql.trimStart())) {
165
197
  throw new Error('DRIVER_ERROR: 只读(ro)模式下 query 仅允许读语句(select/show/desc/describe/explain/use/help/table),写操作请使用可写连接');
166
198
  }
167
- const [rows, fields] = await runner().query(sql, params ?? []);
199
+ // 跨库请求路由到该库自己的池(白名单校验在前,拒绝发生在建池之前;
200
+ // 缺省判断用 != null:空串等非法值必须进 assertIdent 被拒,不能落回主池);
201
+ // 无 database 时维持事务粘主池语义(服务层已拒绝事务携带 database)
202
+ const target = database != null ? poolFor(assertIdent(database, '库名')) : runner();
203
+ const [rows, fields] = await target.query(sql, params ?? []);
168
204
  if (!Array.isArray(rows))
169
205
  return { columns: [], rows: [], rowCount: 0 };
170
206
  return toQueryResult(rows, fields);
171
207
  }),
172
- execute: (statement, params) => humanize('mysql 执行失败', async () => {
208
+ execute: (statement, params, database) => humanize('mysql 执行失败', async () => {
173
209
  // mysql2 的 ExecuteValues 不接受 unknown[],此处值均为绑定参数(string|number|null)
174
- const [result] = await runner().execute(statement, (params ?? []));
210
+ const target = database != null ? poolFor(assertIdent(database, '库名')) : runner();
211
+ const [result] = await target.execute(statement, (params ?? []));
175
212
  const header = (Array.isArray(result) ? result[0] : result);
176
213
  const n = header?.affectedRows;
177
214
  return {
@@ -231,7 +268,12 @@ export async function createMysqlAdapter(conn, opts) {
231
268
  return { columns: [], rows: [], rowCount: 0 };
232
269
  return toQueryResult(rows, fields);
233
270
  }),
234
- close: () => humanize('mysql 关闭连接', () => pool.end()),
271
+ close: () => humanize('mysql 关闭连接', async () => {
272
+ if (closed)
273
+ return; // 幂等:重复 close 为 no-op(pg-pool 二次 end() 会抛的教训)
274
+ closed = true;
275
+ await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
276
+ }),
235
277
  };
236
278
  }
237
279
  export const factory = (conn) => createMysqlAdapter(conn);
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import oracledb from 'oracledb';
20
20
  import { sqlHead, SQL_READ_HEADS } from '../../guard/index.js';
21
+ import { validPort } from '../sql-shared/common.js';
21
22
  // CLOB 统一按字符串返回;BLOB 统一按 Buffer 返回。均为官方全局按类型转换
22
23
  // (fetchInfo 的键是列名而非类型名,`{ BLOB: … }` 只会匹配恰好名为 "BLOB" 的列,
23
24
  // 按类型统一转换须用全局 fetchAsString/fetchAsBuffer——官方 connection.rst
@@ -111,12 +112,15 @@ export function resolveOracleConn(conn) {
111
112
  return {
112
113
  user,
113
114
  password: decodeURIComponent(u.password),
114
- connectString: `${u.hostname}:${u.port || 1521}/${service}`,
115
+ // 端口段校验:':0' 等非法值抛中文错误,不再 host:0 直传驱动
116
+ connectString: `${u.hostname}:${u.port ? validPort(u.port, 1521, 'oracle') : 1521}/${service}`,
115
117
  };
116
118
  }
117
119
  const f = conn.fields ?? {};
118
120
  const host = typeof f.host === 'string' ? f.host : '';
119
- const port = typeof f.port === 'number' ? f.port : 1521;
121
+ // 前端原样发字符串端口:显式校验;历史 typeof==='number' 门把一切字符串(含合法 '14330')
122
+ // 静默丢弃回退 1521
123
+ const port = validPort(f.port, 1521, 'oracle');
120
124
  const service = typeof f.service === 'string' ? f.service : typeof f.database === 'string' ? f.database : '';
121
125
  if (host === '' || service === '') {
122
126
  throw new Error('Oracle 连接需要 url(oracle://user:pass@host:1521/SERVICE)或 fields(host/port/service)');
@@ -10,6 +10,7 @@
10
10
  * - 只读账号建议由用户在服务端配置 ACL 只读用户(见 README/文档)。
11
11
  */
12
12
  import { createClient } from 'redis';
13
+ import { validPort } from '../sql-shared/common.js';
13
14
  /** 危险命令:服务层拦截器在用户确认前拒绝执行(KEYS 同时被 query 白名单禁用)。对齐官方 ACL dangerous 类。 */
14
15
  export const DANGEROUS_COMMANDS = new Set([
15
16
  'FLUSHALL', 'FLUSHDB', 'SWAPDB', 'CONFIG', 'DEBUG', 'SAVE', 'SHUTDOWN',
@@ -111,9 +112,11 @@ function parseInfo(raw) {
111
112
  return rows;
112
113
  }
113
114
  /** 从 fields 拼接连接 URL(url 缺省时) */
114
- function buildUrl(fields, ssl) {
115
+ export function buildUrl(fields, ssl) {
115
116
  const host = typeof fields?.host === 'string' && fields.host !== '' ? fields.host : '127.0.0.1';
116
- const port = typeof fields?.port === 'number' ? fields.port : 6379;
117
+ // 前端原样发字符串端口:显式校验;历史 typeof==='number' 门把一切字符串(含合法 '14330')
118
+ // 静默丢弃回退 6379,属静默错连
119
+ const port = validPort(fields?.port, 6379, 'redis');
117
120
  const user = typeof fields?.user === 'string' && fields.user !== '' ? encodeURIComponent(fields.user) : '';
118
121
  const pass = typeof fields?.password === 'string' ? `:${encodeURIComponent(fields.password)}@` : '';
119
122
  const db = typeof fields?.database === 'number' && fields.database !== 0 ? `/${fields.database}` : '';
@@ -27,6 +27,21 @@ export function clampOffset(offset) {
27
27
  const n = Math.floor(Number(offset ?? 0));
28
28
  return Number.isFinite(n) && n > 0 ? n : 0;
29
29
  }
30
+ /**
31
+ * 端口校验(fail-closed,sqlserver validPort 同口径,各适配器 fields/URL 两路径共用):
32
+ * 仅「未提供」(undefined/null/'',表单留空的官方语义)回退该库默认端口;提供了但
33
+ * Number() 后不是 1-65535 的整数则抛中文错误——绝不静默改写用户配置(各驱动普遍对
34
+ * falsy 端口 `|| 默认` 静默回退、对非整数静默截断,连错服务/端口后排障会被误导)。
35
+ */
36
+ export function validPort(v, dflt, label) {
37
+ if (v === undefined || v === null || v === '')
38
+ return dflt;
39
+ const n = Number(v);
40
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
41
+ throw new Error(`${label} 连接的端口取值非法:${JSON.stringify(v)}(须为 1-65535 的整数;留空才使用默认端口 ${dflt})`);
42
+ }
43
+ return n;
44
+ }
30
45
  /** 任意单元格 → NormalizedCell(string | number | null) */
31
46
  export function normalizeCell(v) {
32
47
  if (v === null || v === undefined)
@@ -44,8 +59,9 @@ export function normalizeCell(v) {
44
59
  ? Number(b)
45
60
  : b.toString();
46
61
  }
62
+ // Invalid Date(如驱动返回无法解析的日期)不能抛 RangeError:单格异常会放大成整查询失败
47
63
  if (v instanceof Date)
48
- return v.toISOString();
64
+ return Number.isNaN(v.getTime()) ? String(v) : v.toISOString();
49
65
  if (v instanceof Uint8Array)
50
66
  return `[BLOB ${v.byteLength} bytes]`;
51
67
  let s;
@@ -1,4 +1,4 @@
1
- import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from './common.js';
1
+ import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent, validPort } from './common.js';
2
2
  /** 多主机 authority 逐台重组段(host[:port]) */
3
3
  function joinHostSeg(h) {
4
4
  return `${h.host}${h.port != null ? ':' + h.port : ''}`;
@@ -20,7 +20,9 @@ function poolConfig(conn, overrideUrl, max = 10) {
20
20
  // devg-dist/gaussdb-12-0319)、默认管理用户 gaussdb;postgresql 维持社区默认 5432/postgres 不变。
21
21
  const gb = conn.meta.kind === 'gaussdb';
22
22
  base.host = String(f.host ?? '127.0.0.1');
23
- base.port = Number(f.port ?? (gb ? 8000 : 5432));
23
+ // 前端原样发字符串端口:显式校验,NaN/'' 经 Number() 变 0 后会被驱动 truthy 检查
24
+ // 静默回退 defaults 5432(连 gaussdb 想兜底的 8000 都落不到)、小数被 parseInt 截断
25
+ base.port = validPort(f.port, gb ? 8000 : 5432, conn.meta.kind);
24
26
  base.user = String(f.user ?? f.username ?? (gb ? 'gaussdb' : 'postgres'));
25
27
  if (f.password != null)
26
28
  base.password = String(f.password);
@@ -42,7 +44,7 @@ function poolConfig(conn, overrideUrl, max = 10) {
42
44
  * authority(host:port,host:port)端口段含逗号非数字 → new URL 直接抛 Invalid URL。
43
45
  * 故自行按文本解析 authority,不经 WHATWG 解析。
44
46
  */
45
- export function normalizeUrl(url) {
47
+ export function normalizeUrl(url, label = 'postgres') {
46
48
  const stripped = url.replace(/^jdbc:/i, '');
47
49
  const schemeEnd = stripped.indexOf('://');
48
50
  // 无 authority(非 URL 形态):整串原样保留,交由驱动按既有逻辑处理
@@ -60,8 +62,14 @@ export function normalizeUrl(url) {
60
62
  const ci = seg.lastIndexOf(':');
61
63
  if (ci < 0)
62
64
  return { host: seg };
63
- const port = Number.parseInt(seg.slice(ci + 1), 10);
64
- return Number.isFinite(port) ? { host: seg.slice(0, ci), port } : { host: seg };
65
+ const host = seg.slice(0, ci);
66
+ const rawPort = seg.slice(ci + 1);
67
+ // 空端口段视同未提供(驱动按默认端口探测);非空则必须为 1-65535 整数——历史 parseInt
68
+ // 会静默截断 '1433.5'→1433、NaN 时把整段当主机名,均属静默改写端口。dflt 传 0 仅占位:
69
+ // rawPort 非空不会走到回退分支。
70
+ if (rawPort === '')
71
+ return { host };
72
+ return { host, port: validPort(rawPort, 0, label) };
65
73
  });
66
74
  const prefix = `${stripped.slice(0, schemeEnd)}://${userinfo}`;
67
75
  // 单台不设 hosts(调用方只在多台时探测)
@@ -118,7 +126,7 @@ export async function pickHost(driver, base, hosts, masterOnly) {
118
126
  * 返回最终驱动池配置与最终连接 URL。
119
127
  */
120
128
  export async function pgLikePoolCfg(kind, driver, conn, max) {
121
- const norm = conn.url ? normalizeUrl(conn.url) : null;
129
+ const norm = conn.url ? normalizeUrl(conn.url, kind) : null;
122
130
  const cleanUrl = norm ? norm.prefix + (norm.hosts ?? []).map(joinHostSeg).join(',') + norm.suffix : undefined;
123
131
  let finalUrl = cleanUrl;
124
132
  if (norm?.hosts && norm.hosts.length > 1) {