diff --git a/AGENTS.md b/AGENTS.md index d8e25a3..74e0458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **a-share-analysis** (2358 symbols, 3190 relationships, 65 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **a-share-analysis** (2373 symbols, 3222 relationships, 63 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/tools/database/mysql2-schema-exporter.mjs b/tools/database/mysql2-schema-exporter.mjs index 6643bd2..f84535b 100644 --- a/tools/database/mysql2-schema-exporter.mjs +++ b/tools/database/mysql2-schema-exporter.mjs @@ -136,9 +136,9 @@ async function queryCatalogRows(connection, sql, database, limits) { values: [database], timeout: limits.queryTimeoutMs, }); - } catch { + } catch (error) { // mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。 - throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败'); + sanitizeDatabaseQueryError(error); } if (!Array.isArray(result) || !Array.isArray(result[0])) { throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效'); @@ -302,6 +302,19 @@ function maskStringLiterals(sql) { return String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, "''"); } +function removeTableAutoIncrementOutsideLiterals(sql) { + const literals = []; + const masked = String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, (literal) => { + const index = literals.push(literal) - 1; + return `\u0001${index}\u0002`; + }); + const cleaned = masked.replace( + /\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i, + '', + ); + return cleaned.replace(/\u0001(\d+)\u0002/g, (_match, index) => literals[Number(index)]); +} + /** * SHOW CREATE 的格式由服务端决定,这里只移除与结构无关的账号绑定、当前表自增值、 * 行尾分号和换行差异,不重排对象体,也不删除字符串字面量中的业务文本。 @@ -313,20 +326,26 @@ export function normalizeCreateDefinition(definition) { /^(CREATE\s+(?:ALGORITHM=\S+\s+)?)DEFINER=`(?:``|[^`])+`@`(?:`(?:``|[^`])+`|'(?:''|[^'])+'|\S+)\s+/i, '$1', ) - .replace(/\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i, '') .trim() .replace(/;+\s*$/, ''); - const inspectable = maskStringLiterals(normalized); + const withoutCurrentAutoIncrement = removeTableAutoIncrementOutsideLiterals(normalized); + const inspectable = maskStringLiterals(withoutCurrentAutoIncrement); if ( /\bDEFINER\s*=/i.test(inspectable) || /\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i.test( inspectable, ) || - /\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable) + /\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable) || + /(?:--|\/\*)\s*(?:host|hostname|server)\s*[:=]\s*[^\s*]+/i.test(inspectable) ) { throw new SchemaExportError('UNSAFE_DEFINITION', 'Schema 对象定义包含禁止内容'); } - return normalized; + return withoutCurrentAutoIncrement; +} + +function sanitizeDatabaseQueryError(error) { + if (error instanceof SchemaExportError) throw error; + throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败'); } export async function readMysql57ServerMetadata( @@ -334,11 +353,16 @@ export async function readMysql57ServerMetadata( database, limits = DEFAULT_EXPORT_LIMITS, ) { - const [rows] = await connection.query({ - sql: SERVER_METADATA_QUERY, - values: [database], - timeout: limits.queryTimeoutMs, - }); + let rows; + try { + [rows] = await connection.query({ + sql: SERVER_METADATA_QUERY, + values: [database], + timeout: limits.queryTimeoutMs, + }); + } catch (error) { + sanitizeDatabaseQueryError(error); + } if (!Array.isArray(rows) || rows.length !== 1) { throw new SchemaExportError('DATABASE_METADATA_INVALID', '开发库元数据查询结果无效'); } @@ -365,7 +389,12 @@ function getCreateDescriptors(catalog) { async function readCreateDefinition(connection, descriptor, limits) { const sql = `SHOW CREATE ${descriptor.type} ${quoteIdentifier(DEVELOPMENT_DATABASE)}.${quoteIdentifier(descriptor.name)}`; - const [rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs }); + let rows; + try { + [rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs }); + } catch (error) { + sanitizeDatabaseQueryError(error); + } if (!Array.isArray(rows) || rows.length === 0) { throw new SchemaExportError('SHOW_CREATE_EMPTY', 'SHOW CREATE 未返回对象定义'); } diff --git a/tools/database/mysql2-schema-exporter.test.mjs b/tools/database/mysql2-schema-exporter.test.mjs index 472525e..d848fdb 100644 --- a/tools/database/mysql2-schema-exporter.test.mjs +++ b/tools/database/mysql2-schema-exporter.test.mjs @@ -494,6 +494,20 @@ test('定义规范化只移除账号绑定和当前自增值', () => { ); }); +test('规范化保留例程中的自增文本并拒绝主机信息', () => { + const routine = normalizeCreateDefinition( + "CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'", + ); + assert.equal( + routine, + "CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'", + ); + assert.throws( + () => normalizeCreateDefinition('CREATE TABLE `sample` (`id` int) /* Host: prod-db.internal */'), + (error) => error.code === 'UNSAFE_DEFINITION', + ); +}); + test('完整导出双重读取一致并始终关闭连接', async () => { const connection = createExportConnection(); let receivedOptions; @@ -592,6 +606,29 @@ test('SHOW CREATE 拒绝空结果、错误形状和过大定义', async () => { ); }); +test('直接元数据和 SHOW CREATE 查询错误统一脱敏且保留既有业务错误', async () => { + const rawFailure = new Error('host=prod-db.internal password=not-for-logs'); + await assert.rejects( + () => readMysql57ServerMetadata({ query: async () => { throw rawFailure; } }, 'a_share_analysis_dev'), + (error) => + error.code === 'DATABASE_QUERY_FAILED' && + !error.message.includes('prod-db.internal') && + !error.message.includes('not-for-logs'), + ); + await assert.rejects( + () => readDefinitions({ query: async () => { throw rawFailure; } }, completeCatalog), + (error) => + error.code === 'DATABASE_QUERY_FAILED' && + !error.message.includes('prod-db.internal') && + !error.message.includes('not-for-logs'), + ); + const timeout = new SchemaExportError('EXPORT_TIMEOUT', 'Schema 导出超过总时长限制'); + await assert.rejects( + () => readDefinitions({ query: async () => { throw timeout; } }, completeCatalog), + (error) => error === timeout, + ); +}); + test('查询失败和总超时只返回脱敏错误并关闭连接', async () => { const failed = createExportConnection({ queryError: new Error('connect 127.0.0.1 password=not-for-logs'),