diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index 274a93bbbb..087c5d27a7 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -57,7 +57,10 @@ triggers or arbitrary SQL. Rows are optional. `table` selects one exact ordinary-table name. `columns` (CLI repeated `--column`) selects exact visible column names in the requested order; omission selects all visible columns, including generated columns. -Unselected cells are not evaluated or projected. Column selection requires a +Unselected cells are not evaluated or projected. Malformed column-name bytes +cause a format refusal when all columns are requested. In inventory scope, an explicit valid subset can +still be sampled without decoding unrelated malformed column names. +Column selection requires a table and at least one name; empty-string identifiers are valid. `row_limit` requires `table`, defaults to 20 and accepts positive safe integers. Samples remain subject to the byte and worker resource limits below, regardless of row count. The result reports diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index 941634dd57..9153991504 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -35,10 +35,10 @@ const tableRow = z.object({ }); const columnRow = z.object({ cid: natural, - name: z.string(), - type: z.string(), + name: z.instanceof(Uint8Array), + type: z.instanceof(Uint8Array), notnull: z.bigint(), - dflt_value: z.string().nullable(), + dflt_value: z.instanceof(Uint8Array).nullable(), pk: natural, hidden: z .bigint() @@ -186,27 +186,46 @@ const cell = ( const readColumns = ( db: DatabaseSync, name: string, - budget: OutputBudget, + context: { + readonly budget: OutputBudget; + readonly encoding: DatabaseEncoding; + readonly selectedColumns?: readonly string[] | undefined; + }, ): SqliteDatabase["schema"]["tables"][number]["columns"] => { + const { budget, encoding, selectedColumns } = context; const columns: SqliteDatabase["schema"]["tables"][number]["columns"] = []; - // Direct PRAGMA syntax cannot be shadowed by a stored table or view. + // This private read-only connection has an empty temp schema. Qualifying the + // built-in there prevents stored main tables/views from shadowing it; the + // final argument selects main's metadata. Decode bytes before names become + // SQL identifiers. Inventory-only row subsets compare exact encoded names + // first, so an unrelated malformed identifier cannot alias a requested one. + const selection = + selectedColumns === undefined + ? "" + : ` WHERE CAST(name AS BLOB) IN (${selectedColumns.map(() => "CAST(? AS BLOB)").join(", ")})`; for (const raw of db - .prepare(`PRAGMA main.table_xinfo(${quoteIdentifier(name)})`) - .iterate()) { + .prepare( + `SELECT cid, CAST(name AS BLOB) AS name, CAST(type AS BLOB) AS type, "notnull", CAST(dflt_value AS BLOB) AS dflt_value, pk, hidden FROM temp.pragma_table_xinfo(?, 'main')${selection}`, + ) + .iterate(name, ...(selectedColumns ?? []))) { const value = columnRow.parse(raw); const column = { cid: value.cid, - name: value.name, - declared_type: value.type, + name: decodeSchemaText(value.name, encoding, "column name"), + declared_type: decodeSchemaText(value.type, encoding, "column type"), not_null: value.notnull !== 0n, - default_sql: value.dflt_value, + default_sql: + value.dflt_value === null + ? null + : decodeSchemaText(value.dflt_value, encoding, "column default"), primary_key_position: value.pk, hidden: value.hidden, }; budget.retain(column); columns.push(column); } - return columns; + // Sort only retained facts, without copying untrusted text into native sorter records. + return columns.sort((left, right) => left.cid - right.cid); }; const readSchema = ( @@ -355,11 +374,11 @@ const readSchema = ( listed.type !== "virtual" && (scope !== "inventory" || input.table === value.name) ) { - const columns = readColumns( - db, - value.name, - scope === "inventory" ? new OutputBudget() : budget, - ); + const columns = readColumns(db, value.name, { + budget: scope === "inventory" ? new OutputBudget() : budget, + encoding, + selectedColumns: scope === "inventory" ? input.columns : undefined, + }); if (input.table === value.name) selectedTable = { name: table.name, kind: table.kind, columns }; if (scope !== "inventory") table.columns = columns; diff --git a/tests/boundary/sqlite/sqliteColumnIdentity.test.ts b/tests/boundary/sqlite/sqliteColumnIdentity.test.ts new file mode 100644 index 0000000000..1f83454115 --- /dev/null +++ b/tests/boundary/sqlite/sqliteColumnIdentity.test.ts @@ -0,0 +1,114 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; +import { SQLITE_NATIVE_LIMITS_AVAILABLE } from "../../fixtures/sqlite/database.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const sample = async (encoding: string, malformed = false) => { + const root = await createTestTempDirectory("rea-sqlite-column-identity-"); + const path = join(root, "Columns.sqlite"); + const db = new DatabaseSync(path); + try { + db.exec(`PRAGMA encoding='${encoding}'; + CREATE TABLE t("col" TEXT, "�ol" TEXT); + INSERT INTO t VALUES ('first-column','replacement-column');`); + } finally { + db.close(); + } + if (malformed) { + const bytes = await readFile(path); + const marker = Buffer.from('"col"'); + const offset = bytes.indexOf(marker); + if (offset < 0 || bytes.indexOf(marker, offset + 1) !== -1) + throw new Error("Expected unique authored column marker"); + bytes[offset + 1] = 0xff; + await writeFile(path, bytes); + } + return path; +}; + +it + .runIf(SQLITE_NATIVE_LIMITS_AVAILABLE) + .each(["UTF-8", "UTF-16le", "UTF-16be"])( + "preserves exact column selection in %s with literal U+FFFD", + async (encoding) => { + const path = await sample(encoding); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + schema_scope: "inventory", + columns: ["�ol", "col", "�ol"], + }); + expect(result.schema.tables[0]).toMatchObject({ + columns: [], + columns_completeness: "not_requested", + }); + expect(result.rows?.columns).toEqual(["�ol", "col", "�ol"]); + expect( + result.rows?.values[0]?.map((cell) => + cell.type === "text" ? cell.value : null, + ), + ).toEqual(["replacement-column", "first-column", "replacement-column"]); + }, +); + +it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "refuses automatic sampling before malformed column bytes can alias a valid name", + async () => { + const path = await sample("UTF-8", true); + expect(() => + inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + schema_scope: "inventory", + }), + ).toThrow( + expect.objectContaining({ + reason: "format", + message: expect.stringContaining( + "column name contains malformed UTF-8", + ), + }), + ); + }, +); + +it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "selects the exact valid subset without decoding an unrelated malformed column", + async () => { + const path = await sample("UTF-8", true); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + schema_scope: "inventory", + columns: ["�ol"], + }); + expect(result.rows?.columns).toEqual(["�ol"]); + expect(result.rows?.values[0]?.[0]).toMatchObject({ + type: "text", + value: "replacement-column", + }); + expect(result.schema.tables[0]).toMatchObject({ + columns: [], + columns_completeness: "not_requested", + }); + expect(() => + inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + schema_scope: "inventory", + columns: ["col"], + }), + ).toThrow(expect.objectContaining({ reason: "column-selection" })); + expect(() => + inspectSqliteDatabaseSnapshot(path, { + path, + table: "t", + schema_scope: "selected_table", + columns: ["�ol"], + }), + ).toThrow(expect.objectContaining({ reason: "format" })); + }, +);