diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index a3d9b2c78c..8c973471da 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -124,8 +124,10 @@ const missingSelectedTable = (name: string): SqliteInspectionFailure => ); // SQLite folds only ASCII letters when matching identifiers. Fold stored // bytes so replacement decoding cannot make distinct names share an identity. +// Latin-1 maps every byte to one code unit without hex expansion. These opaque +// keys are never returned as text or charged against the JSON reply budget. const sqliteNameBytesKey = (name: Uint8Array): string => - Buffer.from(name).toString("hex"); + Buffer.from(name.buffer, name.byteOffset, name.byteLength).toString("latin1"); const sqliteIdentifierBytesKey = ( name: Uint8Array, encoding: DatabaseEncoding, @@ -147,7 +149,7 @@ const sqliteIdentifierBytesKey = ( bytes[index + asciiOffset] = letter + 0x20; } } - return bytes.toString("hex"); + return bytes.toString("latin1"); }; const sqliteNameBytes = ( db: DatabaseSync, @@ -333,14 +335,14 @@ const readSchema = ( const value = tableRow.parse(raw); const nameKey = sqliteNameBytesKey(value.name); if (selectedName === undefined) { - metadataBudget.retain(nameKey); + metadataBudget.retain(value.display_name); metadata.set(nameKey, value); } else if (nameKey === selectedKey) { metadata.set(nameKey, value); } if (value.type === "virtual" && value.ncol === 0) { const virtualName = value.display_name; - const prefix = `${sqliteIdentifierBytesKey(value.name, encoding)}${underscore.toString("hex")}`; + const prefix = `${sqliteIdentifierBytesKey(value.name, encoding)}${underscore.toString("latin1")}`; if ( selectedKey === undefined || (unresolvedVirtualTables.length === 0 && diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index f30e311ab1..3062462cb6 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -217,26 +217,44 @@ it.each(["UTF-8", "UTF-16le", "UTF-16be"])( }, ); -it("counts schema names once when enforcing the complete reply budget", async () => { - const names = Array.from( - { length: 48 }, - (_, index) => `${index}_${"x".repeat(128 * 1024)}`, - ); - const path = await createDatabase( - names.map((name) => `CREATE TABLE "${name}" (value TEXT);`).join("\n"), - ); - const result = inspectSqliteDatabaseSnapshot(path, { - path, - schema_scope: "database", - }); - expect(result.schema.tables).toHaveLength(names.length); - expect(result.schema.tables.map((table) => table.name)).toEqual( - expect.arrayContaining(names), - ); - expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan( - 16 * 1024 * 1024, - ); -}); +it.each(["UTF-8", "UTF-16le", "UTF-16be"])( + "counts returned %s schema names rather than internal lookup keys in the reply budget", + async (encoding) => { + const names = Array.from( + { length: 48 }, + (_, index) => `${index}_${"x".repeat(128 * 1024)}`, + ); + const path = await createDatabase( + `PRAGMA encoding='${encoding}'; BEGIN; ${names + .map((name) => `CREATE TABLE "${name}" (value TEXT);`) + .join("\n")} COMMIT;`, + ); + for (const schema_scope of ["inventory", "database"] as const) { + const result = inspectSqliteDatabaseSnapshot(path, { + path, + schema_scope, + }); + expect(result.database_encoding).toBe(encoding); + expect(result.schema.completeness).toBe("complete"); + expect(result.schema.tables.map((table) => table.name).sort()).toEqual( + [...names].sort(), + ); + for (const table of result.schema.tables) + expect(table).toMatchObject( + schema_scope === "inventory" + ? { columns_completeness: "not_requested", columns: [] } + : { + sql: `CREATE TABLE "${table.name}" (value TEXT)`, + columns_completeness: "complete", + columns: [{ name: "value", declared_type: "TEXT" }], + }, + ); + expect( + Buffer.byteLength(JSON.stringify({ ok: true, inspection: result })), + ).toBeLessThan(16 * 1024 * 1024); + } + }, +); it("keeps unrelated catalog names outside the selected-table reply budget", async () => { const path = await createDatabase(