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
This commit is contained in:
1 parent
77c8a1ba32
commit
748ac7f0df
3 files changed
+153
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }));
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user