From 748ac7f0df340f0d0569eca2e803a59cd51ffdbd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:33:04 +0800 Subject: [PATCH] fix(sqlite): preserve column identity before row selection Read raw column metadata through the built-in PRAGMA in the private empty temp schema. Validate bytes before names become SQL identifiers, and select inventory subsets by exact encoded identity so unrelated malformed names cannot alias valid columns. Validation: 5 focused identity controls; 15 selected producer/provider controls (33 skipped); public CLI baseline collision, fixed format refusal and valid-subset success; check:fast and docs:check. Generated inert SQLite fixtures on macOS arm64 with Node 24.18.0; no Windows qualification or large-file/stress workload. Refs #4710 --- docs/sqlite-databases.md | 5 +- src/sqlite/SqliteDatabaseInspection.ts | 51 +++++--- .../sqlite/sqliteColumnIdentity.test.ts | 114 ++++++++++++++++++ 3 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 tests/boundary/sqlite/sqliteColumnIdentity.test.ts 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" })); + }, +);