Files
openclaw/src/infra/sqlite-integrity.ts
Vincent Koc f33ab243cf fix(sqlite): reject runtimes vulnerable to WAL corruption (#106065)
* fix(sqlite): require WAL-reset-safe Node runtime

* docs(sqlite): document safe Node runtime floor

* fix(sqlite): defer runtime library validation until use

* fix(ci): align startup memory with Node 24.15
2026-07-13 13:59:00 +08:00

126 lines
4.3 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
export type SqliteIntegrityChecks = {
integrityCheck: "ok";
quickCheck: "ok";
};
type SqliteCheckPragma = "integrity_check" | "quick_check";
type SqliteForeignKeyViolation = {
fkid: bigint;
parent: string;
rowid: bigint | null;
table: string;
};
const MAX_REPORTED_FOREIGN_KEY_VIOLATIONS = 5;
/** Require structural, table/index, and referential consistency before trusting a database. */
export function assertSqliteIntegrity(
database: DatabaseSync,
databaseLabel: string,
): SqliteIntegrityChecks {
const quickCheck = runSqliteCheck(database, databaseLabel, "quick_check");
const integrityCheck = runSqliteCheck(database, databaseLabel, "integrity_check");
runSqliteForeignKeyCheck(database, databaseLabel);
return { integrityCheck, quickCheck };
}
/** Require table and associated index consistency before trusting indexed reads. */
export function assertSqliteTableIntegrity(
database: DatabaseSync,
databaseLabel: string,
tableName: string,
): void {
runSqliteCheck(database, `${databaseLabel} table ${tableName}`, "integrity_check", tableName);
}
function runSqliteCheck(
database: DatabaseSync,
databaseLabel: string,
pragma: SqliteCheckPragma,
tableName?: string,
): "ok" {
const argument = tableName ? `('${tableName.replaceAll("'", "''")}')` : "";
const rows = database.prepare(`PRAGMA ${pragma}${argument};`).all() as Array<
Record<string, unknown>
>;
const results = rows.map((row) => row[pragma] ?? Object.values(row)[0]);
if (results.length === 1 && results[0] === "ok") {
return "ok";
}
const details = results.map((result) => String(result)).join("; ") || "no result";
throw new Error(`SQLite ${pragma} failed for ${databaseLabel}: ${details}`);
}
function runSqliteForeignKeyCheck(database: DatabaseSync, databaseLabel: string): void {
let violationCount = 0;
const violations: SqliteForeignKeyViolation[] = [];
try {
// Use direct PRAGMA syntax because a real schema object can shadow the
// table-valued pragma name and make a corrupt database appear clean.
const statement = database.prepare("PRAGMA foreign_key_check;");
statement.setReadBigInts(true);
// OpenClaw's Node >=22.22.3 floor includes iterate(), added in Node 22.13.
for (const violation of statement.iterate() as Iterable<SqliteForeignKeyViolation>) {
violationCount += 1;
retainSortedForeignKeyViolation(violations, violation);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`SQLite foreign_key_check failed for ${databaseLabel}: ${message}`, {
cause: error,
});
}
if (violations.length === 0) {
return;
}
const details = violations.map(formatSqliteForeignKeyViolation);
if (violationCount > MAX_REPORTED_FOREIGN_KEY_VIOLATIONS) {
details.push("additional violations omitted");
}
throw new Error(`SQLite foreign_key_check failed for ${databaseLabel}: ${details.join("; ")}`);
}
function retainSortedForeignKeyViolation(
retained: SqliteForeignKeyViolation[],
violation: SqliteForeignKeyViolation,
): void {
retained.push(violation);
retained.sort(compareSqliteForeignKeyViolations);
if (retained.length > MAX_REPORTED_FOREIGN_KEY_VIOLATIONS) {
retained.pop();
}
}
function compareSqliteForeignKeyViolations(
left: SqliteForeignKeyViolation,
right: SqliteForeignKeyViolation,
): number {
const tableOrder = Buffer.compare(Buffer.from(left.table), Buffer.from(right.table));
if (tableOrder !== 0) {
return tableOrder;
}
if (left.rowid === null || right.rowid === null) {
if (left.rowid !== right.rowid) {
return left.rowid === null ? -1 : 1;
}
} else if (left.rowid !== right.rowid) {
return left.rowid < right.rowid ? -1 : 1;
}
const parentOrder = Buffer.compare(Buffer.from(left.parent), Buffer.from(right.parent));
if (parentOrder !== 0) {
return parentOrder;
}
if (left.fkid === right.fkid) {
return 0;
}
return left.fkid < right.fkid ? -1 : 1;
}
function formatSqliteForeignKeyViolation(violation: SqliteForeignKeyViolation): string {
const row = violation.rowid === null ? "row without rowid" : `row ${violation.rowid.toString()}`;
return `${violation.table} ${row} references ${violation.parent} (foreign key ${violation.fkid.toString()})`;
}