From beb2d8b1bfd8e20d98708608d0fceb639cd7107b Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:27:07 -0400 Subject: [PATCH 01/10] feat(sqlite): inspect database snapshots and selected table rows --- README.md | 1 + docs/.vitepress/config.ts | 1 + docs/cli.md | 8 + docs/sqlite-databases.md | 84 +++ scripts/lib/catalog-builders.mjs | 5 + scripts/lib/catalog-core.mjs | 2 + scripts/lib/module-boundaries.mjs | 1 + skill-src/reverse-engineer-anything/SKILL.md | 7 + .../references/native-and-artifacts.md | 12 + src/application/CapabilityInventory.ts | 9 + src/application/sqlite/SqliteDatabasePort.ts | 15 + .../sqlite/SqliteDatabaseService.test.ts | 142 +++++ .../sqlite/SqliteDatabaseService.ts | 109 ++++ src/cli.ts | 2 + src/cli/sqliteCommands.ts | 55 ++ src/cliCommandNames.ts | 1 + src/composition/sqlite.ts | 8 + src/contracts/sqlite/sqliteToolContracts.ts | 34 ++ src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.ts | 5 + src/domain/sqlite/sqliteDatabase.ts | 161 ++++++ src/server/createServer.ts | 10 + src/server/registerSqliteTools.ts | 33 ++ src/sqlite/SqliteDatabaseFailures.ts | 142 +++++ src/sqlite/SqliteDatabaseInspection.ts | 421 +++++++++++++++ src/sqlite/SqliteDatabaseLimits.ts | 28 + src/sqlite/SqliteDatabaseProvider.ts | 188 +++++++ src/sqlite/SqliteDatabaseSnapshot.ts | 119 +++++ src/sqlite/SqliteDatabaseWorker.ts | 57 ++ src/sqlite/SqliteDatabaseWorkerExecution.ts | 154 ++++++ tests/acceptance/sqliteDatabaseCli.test.ts | 274 ++++++++++ .../sqlite/sqliteDatabaseInspection.test.ts | 97 ++++ .../sqlite/sqliteDatabaseProvider.test.ts | 493 ++++++++++++++++++ .../sqlite/sqliteDatabaseSnapshot.test.ts | 72 +++ tests/fixtures/sqlite/database.ts | 64 +++ 35 files changed, 2816 insertions(+) create mode 100644 docs/sqlite-databases.md create mode 100644 src/application/sqlite/SqliteDatabasePort.ts create mode 100644 src/application/sqlite/SqliteDatabaseService.test.ts create mode 100644 src/application/sqlite/SqliteDatabaseService.ts create mode 100644 src/cli/sqliteCommands.ts create mode 100644 src/composition/sqlite.ts create mode 100644 src/contracts/sqlite/sqliteToolContracts.ts create mode 100644 src/domain/sqlite/sqliteDatabase.ts create mode 100644 src/server/registerSqliteTools.ts create mode 100644 src/sqlite/SqliteDatabaseFailures.ts create mode 100644 src/sqlite/SqliteDatabaseInspection.ts create mode 100644 src/sqlite/SqliteDatabaseLimits.ts create mode 100644 src/sqlite/SqliteDatabaseProvider.ts create mode 100644 src/sqlite/SqliteDatabaseSnapshot.ts create mode 100644 src/sqlite/SqliteDatabaseWorker.ts create mode 100644 src/sqlite/SqliteDatabaseWorkerExecution.ts create mode 100644 tests/acceptance/sqliteDatabaseCli.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseInspection.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseProvider.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts create mode 100644 tests/fixtures/sqlite/database.ts diff --git a/README.md b/README.md index 8c25a3ff520..a7918676a08 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ Additional tools and host support depend on the target: | Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) | | Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier; [offline EVM guide](docs/evm-bytecode.md) | +| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits | | Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://rea.tools/guides/javascript/) | | Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 31436200959..99dd3fed0cc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -41,6 +41,7 @@ export default defineConfig({ { text: "Android applications", link: "/android-analysis" }, { text: "Apple applications", link: "/apple-application-analysis" }, { text: "Firmware", link: "/firmware-analysis" }, + { text: "SQLite databases", link: "/sqlite-databases" }, { text: "Reconstruction readiness", link: "/reconstruction-readiness", diff --git a/docs/cli.md b/docs/cli.md index 51a45c46282..20d49208332 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,6 +20,14 @@ files when neither `--provider` nor `--snapshot` is supplied. See [JavaScript artifact reconstruction](javascript-artifact-reconstruction.md) for results, integrity checks and coverage. +## SQLite snapshots + +For a local database snapshot, use `rea inspect-sqlite-database PATH --json`. +Add `--table NAME --row-limit 20` to read a bounded sample from an ordinary +table. This command does not require a native analysis provider. See +[SQLite database snapshots](sqlite-databases.md) for database/WAL identity, +source-file handling and row-value semantics. + ## Native analysis Configure [Hopper or Ghidra](installation.md#hopper), or the diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md new file mode 100644 index 00000000000..c9ae4b96897 --- /dev/null +++ b/docs/sqlite-databases.md @@ -0,0 +1,84 @@ +# SQLite database snapshots + +`inspect_sqlite_database` / `inspect-sqlite-database` inspects one explicitly +selected local SQLite database snapshot without an active native analysis +target. Use it to understand application storage, configuration or retained +database evidence. No separate SQLite installation is required. + +This operation requires Node.js 24.x >=24.15 or 26+ with the native +`DatabaseSync.limits` API. The worker verifies that capability before querying +the copied database. Other REA features retain their published Node.js 22.x, +24.x or 26+ requirements. + +```sh +rea inspect-sqlite-database ./application.sqlite --json +rea inspect-sqlite-database ./application.sqlite --table settings --row-limit 20 --json +``` + +```json +{ + "name": "inspect_sqlite_database", + "arguments": { + "path": "/artifacts/application.sqlite", + "table": "settings", + "row_limit": 20 + } +} +``` + +MCP paths must be absolute filesystem paths on the current host. CLI paths +resolve against the operator's working directory. Select a quiescent snapshot: +stop the writer or obtain a consistent database/WAL pair using the application's +backup workflow before inspection. A file copy of a live pair does not establish +an atomic SQLite snapshot, even if no change is detected during inspection. + +## Returned evidence + +The complete schema inventory includes tables and their columns, indexes, views +and triggers. SQL declarations retain the original schema text. Virtual and +shadow tables are identified as such; their supported metadata coverage is +reported. Schema inventory does not execute views, triggers or arbitrary SQL. + +Rows are optional. `table` selects one exact ordinary-table name. `row_limit` +requires `table`, defaults to 100 and accepts 1 through 1000. The result reports +the selected limit, returned count and whether more rows were observed. Row +order is unspecified; a truncated sample does not prove anything about omitted +records. View, virtual-table and shadow-table row reads are unsupported. + +SQLite integers are decimal strings so every signed 64-bit value survives JSON +serialization. Real values retain their numbers or explicit infinite values. +Text returns the decoded string together with its bytes as hex and the database +encoding, preserving malformed text that the SQLite binding may replace for +display. Blobs are hex, and SQL NULL is a distinct tagged value. + +Database and existing sibling `-wal` files have separate original SHA-256 and +byte counts. The Evidence subject identifies the original database file; +the WAL identity is also necessary to interpret pending committed data. The +SQLite engine version is reported with the observed result. Neither digest is +a claim that the database is healthy, complete or trusted. + +## Source files and failures + +REA copies the selected database and existing sibling WAL to an owned private +temporary directory before SQLite opens them. Source files are not opened by +SQLite, so its locks or shared-memory sidecars cannot modify the original +snapshot. Temporary copies and the owned worker are cleaned up on success, +failure and cancellation. Extension loading and schema trust are disabled. + +The database and WAL together are limited to 256 MiB. SQLite values/records, +SQL statements and the complete worker JSON reply each have a 16 MiB limit. +The owned worker has a 256 MiB JavaScript heap limit and a 30-second inspection +deadline. SQLite native allocations are separate from the JavaScript heap; +overall native heap and total resident memory are not capped. Row limits do not +override these byte and resource limits. Resource-limit failures do not return +an incomplete schema as successful complete evidence. + +MCP also applies its configured response budget to the complete Evidence +envelope. If delivery exceeds that budget, it reports a transport constraint +with guidance for exporting retained Evidence, requesting a matching larger +client/server response budget or using complete CLI JSON output. + +Changed input identities, special files, malformed databases, unsupported +formats and resource failures are reported as errors with the selected path and +meaningful reason. Encrypted databases, database repair, arbitrary SQL, +cross-database queries and live capture are outside this inspection's coverage. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 7aefdf73484..a7526a38d74 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -27,6 +27,7 @@ export const toolFamilyCatalog = (sources) => { contracts: [ ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, ...sources.evmContracts.EVM_TOOL_CONTRACTS, + ...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, ], }, { @@ -159,6 +160,10 @@ export const providerCatalog = (sources) => { identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY, contracts: sources.evmContracts.EVM_TOOL_CONTRACTS, }, + { + identity: sources.sqliteProvider.SQLITE_PROVIDER_IDENTITY, + contracts: sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, + }, { identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER, contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 7792c193203..1519cb1d80a 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -46,6 +46,8 @@ const SOURCE_PATHS = { pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js", nativeContracts: "dist/contracts/native/nativeToolContracts.js", evmContracts: "dist/contracts/evm/evmToolContracts.js", + sqliteContracts: "dist/contracts/sqlite/sqliteToolContracts.js", + sqliteProvider: "dist/sqlite/SqliteDatabaseLimits.js", evmProvider: "dist/evm/EvmoleRelease.js", artifactContracts: "dist/contracts/artifactToolContracts.js", managedContracts: "dist/contracts/managed/managedToolContracts.js", diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index 2948cae3035..d47f5477fae 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const PURE_LAYERS = new Set(["domain", "contracts"]); const MIGRATED_PROVIDER_ROOTS = new Set([ + "sqlite", "evm", "android", "firmware", diff --git a/skill-src/reverse-engineer-anything/SKILL.md b/skill-src/reverse-engineer-anything/SKILL.md index d1650f26e87..19e38af5a3c 100644 --- a/skill-src/reverse-engineer-anything/SKILL.md +++ b/skill-src/reverse-engineer-anything/SKILL.md @@ -104,6 +104,13 @@ explicit path or endpoint and do not need it. argument strings and mutability as inferred candidates; this performs no chain lookup or target execution. See the [EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/evm-bytecode.md). +- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect + with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema + first or select an exact ordinary table and `row_limit` when rows are + needed. Preserve both database and existing sibling WAL identity; row samples + report truncation and have unspecified order. Use a quiescent snapshot rather + than assuming a live database/WAL pair was copied atomically. See the + [SQLite guide](https://github.com/morluto/rea/blob/main/docs/sqlite-databases.md). - Explicit Linux ELF file for offline layout, symbols, relocations or static mitigation evidence: `inspect_binary_layout`. This target-free operation uses caller-supplied pwntools without opening a disassembler database. Preserve its diff --git a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md index f47017171d9..d491599c1ce 100644 --- a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md +++ b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md @@ -1,5 +1,17 @@ # Native, managed, and packaged artifacts +## SQLite snapshots + +Use `inspect_sqlite_database` with an explicit local database snapshot for its +schema and optional rows from one exact ordinary table. It requires Node.js +24.x >=24.15 or 26+ with the native `DatabaseSync.limits` API. It also consumes the +existing sibling `-wal`; retain both original digests when citing its result. +Choose `row_limit` when a finite sample is useful and keep truncation and +unspecified row order explicit. Virtual tables, shadow tables and views are +schema facts, not row-read targets. REA inspects private copies and leaves +the source files untouched; obtain a coherent quiescent database/WAL snapshot +before inspection. See [SQLite snapshots](https://github.com/morluto/rea/blob/main/docs/sqlite-databases.md). + ## Recorded Linux crashes Use `inspect_recorded_crash` with an explicit Linux x86-64 ELF core path to diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index a710b34af59..2a9b7c51dbf 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { satisfies } from "semver"; import type { OptionalProviderLoadFailures, OptionalProviderLoadFailure, @@ -269,6 +270,14 @@ const workflowAvailabilityFor = ({ kind, policy, }: AvailabilityContext): Availability | null => { + if (name === "inspect_sqlite_database") + return satisfies(process.versions.node, "^24.15.0 || >=26.0.0") + ? { reason: "available", remediation: null } + : { + reason: "provider_unavailable", + remediation: + "SQLite inspection requires Node.js 24.x >=24.15 or 26+ with the native DatabaseSync.limits API. Other REA features retain their published runtime requirements. The inspection worker verifies the API before querying the snapshot.", + }; if (name === "trace_web_module_imports") return { reason: "available", diff --git a/src/application/sqlite/SqliteDatabasePort.ts b/src/application/sqlite/SqliteDatabasePort.ts new file mode 100644 index 00000000000..455b73bb1fa --- /dev/null +++ b/src/application/sqlite/SqliteDatabasePort.ts @@ -0,0 +1,15 @@ +import type { + AnalysisExecution, + ExecutionOptions, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { InspectSqliteDatabaseInput } from "../../domain/sqlite/sqliteDatabase.js"; +import type { Result } from "../../domain/result.js"; + +/** Offline database inspection boundary shared by CLI and MCP. */ +export interface SqliteDatabasePort { + inspect( + input: InspectSqliteDatabaseInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/sqlite/SqliteDatabaseService.test.ts b/src/application/sqlite/SqliteDatabaseService.test.ts new file mode 100644 index 00000000000..0d2dfd50b1d --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseService.test.ts @@ -0,0 +1,142 @@ +import { expect, it } from "vitest"; +import { SqliteDatabaseService } from "./SqliteDatabaseService.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { err } from "../../domain/result.js"; +import { ok } from "../../domain/result.js"; +import { createAnalysisExecution } from "../AnalysisProvider.js"; + +const fixture = () => ({ + artifact: { path: "/selected.db", sha256: "a".repeat(64), bytes: 4096 }, + wal: null, + engine: { name: "SQLite", version: "3.50.2" }, + database_encoding: "UTF-8", + schema: { + completeness: "complete", + tables: [], + indexes: [], + views: [], + triggers: [], + }, + rows: null, + limitations: [], +}); + +const execution = (value: unknown) => + createAnalysisExecution( + value, + { id: "sqlite", name: "SQLite", version: "3.50.2" }, + { + subject: { path: "/selected.db", sha256: "a".repeat(64), format: "file" }, + locations: [{ kind: "artifact-path", path: "/selected.db" }], + }, + ); + +it.each([ + { path: "relative.db" }, + { path: "/selected.db", approval: true }, + { path: "/selected.db", row_limit: 1 }, + { path: "/selected.db", table: 1 }, + { path: "/selected.db", table: "data", row_limit: 0 }, + { path: "/selected.db", table: "data", row_limit: 1001 }, +])( + "rejects invalid SQLite selection before provider effects: %j", + async (input) => { + const service = new SqliteDatabaseService({ + inspect: () => { + throw new Error("Invalid selection must not read a database"); + }, + }); + expect(await service.inspect(input)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }, +); + +it("preserves producer failures and rejects pre-cancelled requests before effects", async () => { + const failure = new AnalysisCapabilityUnavailableError( + "rea-sqlite", + "inspect_sqlite_database", + "Selected worker could not start", + ); + const service = new SqliteDatabaseService({ + inspect: () => Promise.resolve(err(failure)), + }); + expect(await service.inspect({ path: "/selected.db" })).toEqual(err(failure)); + const signal = AbortSignal.abort(); + const untouched = new SqliteDatabaseService({ + inspect: () => { + throw new Error("Cancelled selection must not read a database"); + }, + }); + expect( + await untouched.inspect({ path: "/selected.db" }, { signal }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it.each(["path", "digest", "subject", "wal", "table"])( + "rejects producer results with changed caller binding: %s", + async (problem) => { + const value = fixture(); + if (problem === "path") value.artifact.path = "/other.db"; + if (problem === "digest") value.artifact.sha256 = "b".repeat(64); + const observed = execution({ + ...value, + ...(problem === "wal" + ? { wal: { path: "/other.db-wal", sha256: "b".repeat(64), bytes: 32 } } + : {}), + }); + const service = new SqliteDatabaseService({ + inspect: () => + Promise.resolve( + ok({ + ...observed, + ...(problem === "subject" ? { subject: null } : {}), + }), + ), + }); + expect( + await service.inspect({ + path: "/selected.db", + ...(problem === "table" ? { table: "selected" } : {}), + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } }); + }, +); + +it("returns observed inline evidence only while the request remains active", async () => { + const value = fixture(); + const service = new SqliteDatabaseService({ + inspect: () => Promise.resolve(ok(execution(value))), + }); + expect(await service.inspect({ path: "/selected.db" })).toMatchObject({ + ok: true, + value: { + confidence: "observed", + subject: { + local_path: "/selected.db", + digest: { sha256: "a".repeat(64) }, + }, + normalized_result: value, + }, + }); + const controller = new AbortController(); + const cancelled = new SqliteDatabaseService({ + inspect: () => { + controller.abort(); + return Promise.resolve(ok(execution(value))); + }, + }); + expect( + await cancelled.inspect( + { path: "/selected.db" }, + { signal: controller.signal }, + ), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); diff --git a/src/application/sqlite/SqliteDatabaseService.ts b/src/application/sqlite/SqliteDatabaseService.ts new file mode 100644 index 00000000000..6b90a76ea17 --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseService.ts @@ -0,0 +1,109 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { SqliteDatabasePort } from "./SqliteDatabasePort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { + createEvidence, + type Evidence, + type EvidenceSubjectTarget, +} from "../../domain/evidence.js"; +import { + inspectSqliteDatabaseInputSchema, + sqliteDatabaseSchema, + type InspectSqliteDatabaseInput, + type SqliteDatabase, + SQLITE_ROW_LIMIT_DEFAULT, +} from "../../domain/sqlite/sqliteDatabase.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const OPERATION = "inspect_sqlite_database"; + +const matchesSelectedDatabase = ( + report: SqliteDatabase, + input: InspectSqliteDatabaseInput, + subject: EvidenceSubjectTarget, +): boolean => + report.artifact.path === input.path && + subject.path === input.path && + subject.sha256 === report.artifact.sha256 && + subject.format === "file" && + (report.wal === null || report.wal.path === `${input.path}-wal`) && + (input.table === undefined + ? report.rows === null + : report.rows?.table === input.table && + report.rows.row_limit === (input.row_limit ?? SQLITE_ROW_LIMIT_DEFAULT)); + +/** Shared snapshot inspection and caller-selection validation for CLI and MCP. */ +export class SqliteDatabaseService { + constructor(readonly provider: SqliteDatabasePort) {} + + /** Inspect an explicit database snapshot and preserve its original database/WAL identity. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const inspected = await this.provider.inspect(input.data, options); + if (!inspected.ok) return inspected; + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const execution = inspected.value; + const report = sqliteDatabaseSchema.safeParse(execution.result); + if ( + !report.success || + execution.subject === null || + !matchesSelectedDatabase(report.data, input.data, execution.subject) + ) + return err( + new AnalysisOutputError( + OPERATION, + "SQLite adapter returned malformed data or changed the selected database identity/table/row limit.", + ), + ); + return ok( + createEvidence(execution.subject, execution.provider, { + operation: OPERATION, + parameters: { + path: input.data.path, + ...(input.data.table === undefined + ? {} + : { table: input.data.table }), + ...(input.data.row_limit === undefined + ? {} + : { row_limit: input.data.row_limit }), + }, + result: report.data, + ...(execution.rawResult === null + ? {} + : { rawResult: execution.rawResult }), + confidence: "observed", + locations: execution.locations, + limitations: execution.limitations, + }), + ); + } +} diff --git a/src/cli.ts b/src/cli.ts index 590887c2a01..d99cb2f5da4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; +import { registerSqliteCommands } from "./cli/sqliteCommands.js"; import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; import { Cli } from "incur"; @@ -72,6 +73,7 @@ export const createCli = ( ); registerUtilityCommands(cli, logger, environment, analysis); registerEvmCommands(cli, logger, environment); + registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); registerManagedCommands(cli, logger, analysis.runProviderAnalysis); registerAndroidCommands(cli, logger, environment); diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts new file mode 100644 index 00000000000..ea53c2d068c --- /dev/null +++ b/src/cli/sqliteCommands.ts @@ -0,0 +1,55 @@ +import { z } from "incur"; +import { resolve } from "node:path"; +import { createSqliteDatabaseService } from "../composition/sqlite.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { SQLITE_ROW_LIMIT_MAX } from "../domain/sqlite/sqliteDatabase.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Resolve an operator-selected SQLite snapshot through the shared inspection workflow. */ +export const registerSqliteCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + const service = createSqliteDatabaseService(environment); + cli.command(CLI_COMMANDS.inspectSqliteDatabase, { + description: + "Inspect SQLite schema and optional ordinary-table rows from a local snapshot", + args: z.object({ + path: z.string().describe("Local SQLite database snapshot"), + }), + options: z.object({ + table: z + .string() + .optional() + .describe("Exact ordinary table name to read"), + rowLimit: z + .number() + .int() + .min(1) + .max(SQLITE_ROW_LIMIT_MAX) + .optional() + .describe("Selected row count, 1–1000; default 100; requires table"), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectSqliteDatabase, async () => { + const result = await service.inspect( + { + path: resolve(args.path), + ...(options.table === undefined ? {} : { table: options.table }), + ...(options.rowLimit === undefined + ? {} + : { row_limit: options.rowLimit }), + }, + { signal }, + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index dd0329eb0b7..6e20a3d3f43 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -1,5 +1,6 @@ /** Canonical names used by every one-shot CLI command registration. */ export const CLI_COMMANDS = Object.freeze({ + inspectSqliteDatabase: "inspect-sqlite-database", inspectEvmInterface: "inspect-evm-interface", inspectWebNetworkCapture: "inspect-web-network-capture", observeWebExecution: "observe-web-execution", diff --git a/src/composition/sqlite.ts b/src/composition/sqlite.ts new file mode 100644 index 00000000000..786684a2a0f --- /dev/null +++ b/src/composition/sqlite.ts @@ -0,0 +1,8 @@ +import { SqliteDatabaseService } from "../application/sqlite/SqliteDatabaseService.js"; +import { SqliteDatabaseProvider } from "../sqlite/SqliteDatabaseProvider.js"; + +/** Compose local snapshot inspection without opening a native analysis provider. */ +export const createSqliteDatabaseService = ( + environment: Readonly = process.env, +): SqliteDatabaseService => + new SqliteDatabaseService(new SqliteDatabaseProvider(environment)); diff --git a/src/contracts/sqlite/sqliteToolContracts.ts b/src/contracts/sqlite/sqliteToolContracts.ts new file mode 100644 index 00000000000..cc547da3edc --- /dev/null +++ b/src/contracts/sqlite/sqliteToolContracts.ts @@ -0,0 +1,34 @@ +import { + inspectSqliteDatabaseInputSchema, + sqliteDatabaseSchema, +} from "../../domain/sqlite/sqliteDatabase.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; + +/** Snapshot inspection is independent of any active native-analysis target. */ +export const SQLITE_TOOL_CONTRACTS = [ + { + name: "inspect_sqlite_database", + ...toolContractMetadata("inspect_sqlite_database"), + kind: "artifact-provider", + description: + "Inspect an explicit local SQLite database snapshot and its existing sibling WAL without modifying source files or opening a native analysis provider. Requires Node.js 24.x >=24.15 or 26+ with native DatabaseSync.limits support, checked by the worker. Returns database/WAL SHA-256, tables and columns, indexes, views and triggers inline as observed Evidence. Optionally select an exact ordinary table and a row limit (default 100, maximum 1000); returned rows retain 64-bit integers, text bytes and blobs with explicit truncation. No arbitrary SQL, view or virtual-table execution, extension loading, database repair, encryption support or live-snapshot atomicity claim. An isolated temporary copy and owned worker enforce input, value/record, SQL-statement, JSON-reply, JavaScript-heap and deadline limits; overall native heap is not capped. Supply a quiescent snapshot for coherent database/WAL evidence.", + inputSchema: inspectSqliteDatabaseInputSchema, + outputSchema: evidenceResultOf(sqliteDatabaseSchema), + examples: [ + { + title: "Inspect the complete schema of a local SQLite snapshot", + input: { path: "/artifacts/application.sqlite" }, + }, + { + title: "Read selected rows from an ordinary table", + input: { + path: "/artifacts/application.sqlite", + table: "settings", + row_limit: 20, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 212a748bd7c..9083f0c2457 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,4 +1,5 @@ import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; +import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js"; import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js"; @@ -28,6 +29,7 @@ export const TOOL_CONTRACTS = [ ...ANALYSIS_VIEW_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, + ...SQLITE_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...FIRMWARE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 35a51010bac..0853740c92f 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true }); /** Explicit effect audit for every public tool. */ export const TOOL_EFFECTS: Readonly> = { + inspect_sqlite_database: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + }), inspect_evm_interface: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/domain/sqlite/sqliteDatabase.ts b/src/domain/sqlite/sqliteDatabase.ts new file mode 100644 index 00000000000..e6597325055 --- /dev/null +++ b/src/domain/sqlite/sqliteDatabase.ts @@ -0,0 +1,161 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; + +/** Default ordinary-table sample size when the caller selects a table. */ +export const SQLITE_ROW_LIMIT_DEFAULT = 100; +/** Maximum selected sample size before row/value representation expansion. */ +export const SQLITE_ROW_LIMIT_MAX = 1000; + +const nonnegative = z.number().int().nonnegative(); +const hex = z.string().regex(/^(?:[0-9a-f]{2})*$/); +const encoding = z.enum(["UTF-8", "UTF-16le", "UTF-16be"]); +const artifactSchema = z.strictObject({ + path: z.string().min(1), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + bytes: nonnegative, +}); + +/** Inspect one frozen database and its sibling WAL, with optional ordinary-table rows. */ +export const inspectSqliteDatabaseInputSchema = z + .strictObject({ + path: localPathStringSchema.describe( + "Absolute path to an offline SQLite database snapshot", + ), + table: z + .string() + .optional() + .describe( + "Exact ordinary table name; views and virtual/shadow tables are not executed", + ), + row_limit: z + .number() + .int() + .min(1) + .max(SQLITE_ROW_LIMIT_MAX) + .optional() + .describe( + "Selected-table sample size, default 100; bounded to 1000 rows for output safety", + ), + }) + .superRefine((input, context) => { + if (input.row_limit !== undefined && input.table === undefined) + context.addIssue({ + code: "custom", + path: ["row_limit"], + message: "row_limit requires an explicit table selection", + }); + }) + .meta({ dependentRequired: { row_limit: ["table"] } }); + +/** JSON-safe SQLite cells retain integers, binary content and nonfinite REAL values. */ +export const sqliteValueSchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("null") }), + z.strictObject({ + type: z.literal("integer"), + value: z.string().regex(/^-?(?:0|[1-9][0-9]*)$/), + }), + z.strictObject({ + type: z.literal("real"), + value: z.union([z.number(), z.enum(["Infinity", "-Infinity"])]), + }), + z.strictObject({ + type: z.literal("text"), + value: z.string(), + bytes_hex: hex, + encoding, + }), + z.strictObject({ type: z.literal("blob"), hex }), +]); + +/** Frozen schema facts and explicitly bounded selected records, without arbitrary SQL. */ +export const sqliteDatabaseSchema = z + .strictObject({ + artifact: artifactSchema, + wal: artifactSchema.nullable(), + engine: z.strictObject({ + name: z.literal("SQLite"), + version: z.string().min(1), + }), + database_encoding: encoding, + schema: z.strictObject({ + completeness: z.literal("complete"), + tables: z.array( + z.strictObject({ + name: z.string(), + sql: z.string().nullable(), + root_page: nonnegative, + kind: z.enum(["table", "virtual", "shadow"]), + without_rowid: z.boolean(), + strict: z.boolean(), + columns_completeness: z.enum(["complete", "unsupported"]), + columns: z.array( + z.strictObject({ + cid: nonnegative, + name: z.string(), + declared_type: z.string(), + not_null: z.boolean(), + default_sql: z.string().nullable(), + primary_key_position: nonnegative, + hidden: z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + ]), + }), + ), + }), + ), + indexes: z.array( + z.strictObject({ + name: z.string(), + table: z.string(), + sql: z.string().nullable(), + root_page: nonnegative, + }), + ), + views: z.array( + z.strictObject({ name: z.string(), sql: z.string().nullable() }), + ), + triggers: z.array( + z.strictObject({ + name: z.string(), + table: z.string(), + sql: z.string().nullable(), + }), + ), + }), + rows: z + .strictObject({ + table: z.string(), + columns: z.array(z.string()), + values: z.array(z.array(sqliteValueSchema)), + row_limit: z.number().int().min(1).max(SQLITE_ROW_LIMIT_MAX), + returned_rows: nonnegative, + truncated: z.boolean(), + order: z.literal("unspecified"), + }) + .nullable(), + limitations: z.array(z.string()), + }) + .superRefine((report, context) => { + const rows = report.rows; + if ( + rows !== null && + (rows.returned_rows !== rows.values.length || + rows.returned_rows > rows.row_limit || + rows.values.some((row) => row.length !== rows.columns.length)) + ) + context.addIssue({ + code: "custom", + path: ["rows"], + message: + "Selected row counts and column dimensions must match the retained values", + }); + }); + +export type InspectSqliteDatabaseInput = z.infer< + typeof inspectSqliteDatabaseInputSchema +>; +export type SqliteDatabase = z.infer; +export type SqliteValue = z.infer; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index b578e26c50e..4788e70fe39 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -2,6 +2,9 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { createEvmInterfaceService } from "../composition/evm.js"; import { registerEvmTools } from "./registerEvmTools.js"; +import type { SqliteDatabaseService } from "../application/sqlite/SqliteDatabaseService.js"; +import { createSqliteDatabaseService } from "../composition/sqlite.js"; +import { registerSqliteTools } from "./registerSqliteTools.js"; import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js"; import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"; import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; @@ -84,6 +87,7 @@ export interface CreateServerOptions { readonly environment?: Readonly; readonly delivery?: ToolResultDelivery; readonly evmInterface?: EvmInterfaceService; + readonly sqliteDatabase?: SqliteDatabaseService; readonly logger?: Logger; readonly binaryLayout?: BinaryLayoutService; readonly recordedCrash?: RecordedCrashService; @@ -286,6 +290,12 @@ const registerConfiguredAnalysisTools = ( toolLogger, recordEvidence, ); + registerSqliteTools( + server, + options.sqliteDatabase ?? createSqliteDatabaseService(environment), + toolLogger, + recordEvidence, + ); registerRecordedCrashTools( server, options.recordedCrash ?? createRecordedCrashService(environment), diff --git a/src/server/registerSqliteTools.ts b/src/server/registerSqliteTools.ts new file mode 100644 index 00000000000..0f8c72745c0 --- /dev/null +++ b/src/server/registerSqliteTools.ts @@ -0,0 +1,33 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { SqliteDatabaseService } from "../application/sqlite/SqliteDatabaseService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind snapshot inspection to its named contract and additive session Evidence. */ +export const registerSqliteTools = ( + server: EvidenceMcpServer, + service: SqliteDatabaseService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], +): void => { + const contract = toolContract("inspect_sqlite_database"); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => { + const result = await logToolExecution(logger, contract.name, () => + service.inspect(input, { signal: context.mcpReq.signal }), + ); + if (!result.ok) return server.delivery.toCallToolResult(result, contract); + const recorded = recordEvidence?.(result.value); + return server.delivery.toEvidenceToolResult( + result.value, + contract, + recorded, + ); + }, + ); +}; diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts new file mode 100644 index 00000000000..89393aa9df8 --- /dev/null +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -0,0 +1,142 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; +import { + SQLITE_DATABASE_LIMITS, + SQLITE_PROVIDER_IDENTITY, +} from "./SqliteDatabaseLimits.js"; + +/** Preserve diagnostics independently of worker exit and private-root cleanup. */ +export const capturedSqliteOutput = ( + snapshot: ProviderProcessSnapshot, +): AnalysisCapturedOutput => ({ + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + truncated: snapshot.diagnosticTruncated === true, +}); + +/** Preserve artifact, caller, process, resource and output failure meanings. */ +export const sqliteDatabaseFailure = ( + cause: unknown, + path: string, + phase: string, +): AnalysisError => { + const operation = "inspect_sqlite_database"; + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError(operation, path, cause.message, { + cause, + }); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { cause }); + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", + message: cause.message, + }, + ]); + } + if (cause instanceof OwnedCommandFailure) { + const projected = ownedCommandFailure(cause); + if (projected !== undefined) return projected; + } + if (phase === "artifact-read" && cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(operation, path, cause.code, { + cause, + }); + if (cause.code === "ENOENT" || cause.code === "ENOTDIR") + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected database file set could not be read (${String(cause.code)}): ${path}`, + }, + ]); + } + return new ProviderAdapterError(SQLITE_PROVIDER_IDENTITY.id, operation, { + cause, + diagnostics: { + phase, + path, + reason: cause instanceof Error ? cause.message : String(cause), + ...(cause instanceof OwnedCommandFailure && cause.snapshot !== null + ? { + failure_kind: cause.reason, + exit_code: cause.snapshot.exitCode ?? null, + signal: cause.snapshot.signal ?? null, + captured_output: { ...capturedSqliteOutput(cause.snapshot) }, + } + : {}), + }, + }); +}; + +const ownedCommandFailure = ( + cause: OwnedCommandFailure, +): AnalysisError | undefined => { + const operation = "inspect_sqlite_database"; + const capturedOutput = + cause.snapshot === null ? undefined : capturedSqliteOutput(cause.snapshot); + const options = { + cause, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }; + if (cause.cleanupFailure !== null) + return new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + cause.resources, + { + reason: cause.cleanupFailure, + previous_error: { + failure_kind: cause.reason, + message: cause.message, + }, + ...(capturedOutput === undefined + ? {} + : { captured_output: { ...capturedOutput } }), + }, + { operation, cause }, + ); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, options); + if (cause.reason === "timeout") + return new AnalysisTimeoutError( + operation, + SQLITE_DATABASE_LIMITS.timeoutMs, + options, + ); + if (cause.reason === "output-limit") + return new AnalysisOutputError(operation, cause.message, options); + if ( + cause.snapshot?.stderr.text.includes("JavaScript heap out of memory") === + true + ) + return new AnalysisResourceConstraintError( + operation, + "memory", + "Owned SQLite worker reported JavaScript heap exhaustion.", + { + javascript_heap_bytes: 256 * 1024 * 1024, + }, + options, + ); + return undefined; +}; diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts new file mode 100644 index 00000000000..f08514e3b29 --- /dev/null +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -0,0 +1,421 @@ +import { DatabaseSync } from "node:sqlite"; +import { z } from "zod"; +import { + SQLITE_ROW_LIMIT_DEFAULT, + type InspectSqliteDatabaseInput, + type SqliteDatabase, + type SqliteValue, +} from "../domain/sqlite/sqliteDatabase.js"; +import { + SQLITE_DATABASE_LIMITS, + SqliteInspectionFailure, +} from "./SqliteDatabaseLimits.js"; + +const natural = z + .bigint() + .nonnegative() + .max(BigInt(Number.MAX_SAFE_INTEGER)) + .transform(Number); +const schemaRow = z.object({ + type: z.enum(["table", "index", "view", "trigger"]), + name: z.string(), + tbl_name: z.string(), + rootpage: natural, + sql: z.string().nullable(), + name_hex: z.string(), + table_hex: z.string(), + sql_hex: z.string().nullable(), +}); +const tableRow = z.object({ + schema: z.string(), + name: z.string(), + type: z.enum(["table", "view", "virtual", "shadow"]), + wr: z.bigint(), + strict: z.bigint(), +}); +const columnRow = z.object({ + cid: natural, + name: z.string(), + type: z.string(), + notnull: z.bigint(), + dflt_value: z.string().nullable(), + pk: natural, + hidden: z + .bigint() + .transform(Number) + .pipe(z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)])), +}); +const arrayRow = z.array( + z.union([ + z.null(), + z.bigint(), + z.custom( + (value) => typeof value === "number" && !Number.isNaN(value), + ), + z.string(), + ]), +); +const encodingSchema = z.enum(["UTF-8", "UTF-16le", "UTF-16be"]); +type DatabaseEncoding = z.output; +type Inspection = Pick< + SqliteDatabase, + "engine" | "database_encoding" | "schema" | "rows" | "limitations" +>; + +class OutputBudget { + #bytes = 1024; + retain(value: unknown): void { + this.#bytes += Buffer.byteLength(JSON.stringify(value)) + 1; + if (this.#bytes > SQLITE_DATABASE_LIMITS.outputBytes) + throw new SqliteInspectionFailure( + "output-limit", + "Complete SQLite evidence exceeds the 16 MiB JSON reply budget; no partial success was returned.", + ); + } + beforeDecode(row: z.output): void { + let minimumBytes = this.#bytes; + for (const value of row) + if (typeof value === "string") minimumBytes += Buffer.byteLength(value); + if (minimumBytes > SQLITE_DATABASE_LIMITS.outputBytes) + throw new SqliteInspectionFailure( + "output-limit", + "Selected cells exceed the 16 MiB reply budget before text/hex expansion.", + ); + } + beforeProjection(lengths: readonly bigint[]): void { + const encodedBytes = lengths.reduce( + (total, length) => total + length * 2n, + BigInt(lengths.length * 64), + ); + if ( + encodedBytes + BigInt(this.#bytes) > + BigInt(SQLITE_DATABASE_LIMITS.outputBytes) + ) + throw new SqliteInspectionFailure( + "output-limit", + "Selected row byte lengths exceed the 16 MiB reply budget before hex projection.", + ); + } +} +const quoteIdentifier = (name: string): string => + `"${name.replaceAll('"', '""')}"`; +const textEncoding = (encoding: DatabaseEncoding): string => + encoding === "UTF-8" + ? "utf-8" + : encoding === "UTF-16le" + ? "utf-16le" + : "utf-16be"; + +const validateSchemaText = ( + value: z.output, + encoding: DatabaseEncoding, +): void => { + const decoder = new TextDecoder(textEncoding(encoding), { + fatal: true, + ignoreBOM: true, + }); + for (const [label, text, hex] of [ + ["name", value.name, value.name_hex], + ["table", value.tbl_name, value.table_hex], + ["sql", value.sql, value.sql_hex], + ] as const) { + if (text === null && hex === null) continue; + try { + if ( + text === null || + hex === null || + decoder.decode(Buffer.from(hex, "hex")) !== text + ) + throw new Error("text/bytes mismatch"); + } catch (cause: unknown) { + throw new SqliteInspectionFailure( + "format", + `SQLite schema ${label} contains malformed or lossy decoded ${encoding} text: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } +}; + +const cell = ( + value: z.output[number], + encoding: DatabaseEncoding, +): SqliteValue => { + if (value === null) return { type: "null" }; + if (typeof value === "bigint") + return { type: "integer", value: String(value) }; + if (typeof value === "number") + return { + type: "real", + value: + value === Infinity + ? "Infinity" + : value === -Infinity + ? "-Infinity" + : value, + }; + const hex = value.slice(1).toLowerCase(); + if (!/^(?:[0-9a-f]{2})*$/.test(hex)) + throw new SqliteInspectionFailure( + "format", + "SQLite returned an invalid encoded cell.", + ); + if (value[0] === "b") return { type: "blob", hex }; + if (value[0] !== "t") + throw new SqliteInspectionFailure( + "format", + "SQLite returned an unknown encoded cell type.", + ); + return { + type: "text", + value: new TextDecoder(textEncoding(encoding), { ignoreBOM: true }).decode( + Buffer.from(hex, "hex"), + ), + bytes_hex: hex, + encoding, + }; +}; + +const readColumns = ( + db: DatabaseSync, + name: string, + budget: OutputBudget, +): SqliteDatabase["schema"]["tables"][number]["columns"] => { + const columns: SqliteDatabase["schema"]["tables"][number]["columns"] = []; + for (const raw of db + .prepare("SELECT * FROM pragma_table_xinfo(?, 'main')") + .iterate(name)) { + const value = columnRow.parse(raw); + const column = { + cid: value.cid, + name: value.name, + declared_type: value.type, + not_null: value.notnull !== 0n, + default_sql: value.dflt_value, + primary_key_position: value.pk, + hidden: value.hidden, + }; + budget.retain(column); + columns.push(column); + } + return columns; +}; + +const readSchema = ( + db: DatabaseSync, + context: { + readonly budget: OutputBudget; + readonly limitations: string[]; + readonly encoding: DatabaseEncoding; + }, +): SqliteDatabase["schema"] => { + const { budget, limitations, encoding } = context; + const metadata = new Map>(); + for (const raw of db.prepare("PRAGMA main.table_list").iterate()) { + const value = tableRow.parse(raw); + if (value.schema === "main") { + budget.retain(value.name); + metadata.set(value.name, value); + } + } + const schema: SqliteDatabase["schema"] = { + completeness: "complete", + tables: [], + indexes: [], + views: [], + triggers: [], + }; + for (const raw of db + .prepare( + "SELECT type, name, tbl_name, rootpage, sql, hex(CAST(name AS BLOB)) AS name_hex, hex(CAST(tbl_name AS BLOB)) AS table_hex, CASE WHEN sql IS NULL THEN NULL ELSE hex(CAST(sql AS BLOB)) END AS sql_hex FROM main.sqlite_schema ORDER BY type, name", + ) + .iterate()) { + const value = schemaRow.parse(raw); + validateSchemaText(value, encoding); + if (value.type === "table") { + const listed = metadata.get(value.name); + if (listed === undefined || listed.type === "view") + throw new SqliteInspectionFailure( + "format", + `SQLite schema/table inventory mismatch: ${value.name}`, + ); + const table: SqliteDatabase["schema"]["tables"][number] = { + name: value.name, + sql: value.sql, + root_page: value.rootpage, + kind: listed.type, + without_rowid: listed.wr !== 0n, + strict: listed.strict !== 0n, + columns_completeness: "complete", + columns: [], + }; + budget.retain(table); + if (listed.type === "virtual") { + table.columns_completeness = "unsupported"; + limitations.push( + `Virtual table column expansion is not performed: ${value.name}. Its complete stored CREATE statement is retained.`, + ); + } else table.columns = readColumns(db, value.name, budget); + schema.tables.push(table); + } else if (value.type === "index") { + const index = { + name: value.name, + table: value.tbl_name, + sql: value.sql, + root_page: value.rootpage, + }; + budget.retain(index); + schema.indexes.push(index); + } else if (value.type === "view") { + const view = { name: value.name, sql: value.sql }; + budget.retain(view); + schema.views.push(view); + } else { + const trigger = { + name: value.name, + table: value.tbl_name, + sql: value.sql, + }; + budget.retain(trigger); + schema.triggers.push(trigger); + } + } + return schema; +}; + +const readRows = ( + db: DatabaseSync, + input: InspectSqliteDatabaseInput, + schema: SqliteDatabase["schema"], + context: { + readonly encoding: DatabaseEncoding; + readonly budget: OutputBudget; + }, +): SqliteDatabase["rows"] => { + const { encoding, budget } = context; + if (input.table === undefined) return null; + const table = schema.tables.find((value) => value.name === input.table); + if (table === undefined) + throw new SqliteInspectionFailure( + "selection", + `Selected ordinary table does not exist with that exact name: ${input.table}`, + ); + if (table.kind !== "table") + throw new SqliteInspectionFailure( + "selection", + `Rows from ${table.kind} table ${input.table} are outside the ordinary-table inspection profile.`, + ); + const limit = input.row_limit ?? SQLITE_ROW_LIMIT_DEFAULT; + const columns = table.columns + .filter((column) => column.hidden !== 1) + .map((column) => column.name); + const projection = columns + .map((name) => { + const column = quoteIdentifier(name); + return `CASE typeof(${column}) WHEN 'text' THEN 't' || hex(CAST(${column} AS BLOB)) WHEN 'blob' THEN 'b' || hex(${column}) ELSE ${column} END`; + }) + .join(", "); + const source = `main.${quoteIdentifier(input.table)} NOT INDEXED`; + const lengths = db.prepare( + `SELECT ${columns + .map((name) => { + const column = quoteIdentifier(name); + return `CASE typeof(${column}) WHEN 'text' THEN length(CAST(${column} AS BLOB)) WHEN 'blob' THEN length(${column}) ELSE 0 END`; + }) + .join(", ")} FROM ${source} LIMIT ?`, + ); + lengths.setReturnArrays(true); + for (const raw of lengths.iterate(limit)) + budget.beforeProjection(z.array(z.bigint().nonnegative()).parse(raw)); + const statement = db.prepare(`SELECT ${projection} FROM ${source} LIMIT ?`); + statement.setReturnArrays(true); + const rows: NonNullable = { + table: input.table, + columns, + values: [], + row_limit: limit, + returned_rows: 0, + truncated: false, + order: "unspecified", + }; + for (const raw of statement.iterate(limit)) { + const rawRow = arrayRow.parse(raw); + // Hex expansion already belongs to the retained-output budget, before decoding/copying cells. + budget.beforeDecode(rawRow); + const values = rawRow.map((value) => cell(value, encoding)); + budget.retain(values); + rows.values.push(values); + } + rows.returned_rows = rows.values.length; + rows.truncated = + db.prepare(`SELECT 1 FROM ${source} LIMIT 1 OFFSET ?`).get(limit) !== + undefined; + return rows; +}; + +/** Query only a provider-owned snapshot; no caller SQL, extensions, views or virtual rows. */ +export const inspectSqliteDatabaseSnapshot = ( + snapshotPath: string, + input: InspectSqliteDatabaseInput, +): Inspection => { + const db = new DatabaseSync(snapshotPath, { + readOnly: true, + allowExtension: false, + readBigInts: true, + enableDoubleQuotedStringLiterals: false, + timeout: 0, + limits: { + length: SQLITE_DATABASE_LIMITS.outputBytes, + sqlLength: SQLITE_DATABASE_LIMITS.outputBytes, + }, + }); + try { + const limits: unknown = db.limits; + const supported = z + .object({ + length: z + .number() + .int() + .positive() + .max(SQLITE_DATABASE_LIMITS.outputBytes), + sqlLength: z + .number() + .int() + .positive() + .max(SQLITE_DATABASE_LIMITS.outputBytes), + }) + .safeParse(limits); + if (!supported.success) + throw new SqliteInspectionFailure( + "unavailable", + "SQLite inspection requires the native DatabaseSync.limits API to bound untrusted values and SQL. Use Node.js 24.15 or newer in the supported 24.x line, or a compatible Node.js 26+ runtime.", + ); + db.exec( + "PRAGMA trusted_schema=OFF; PRAGMA query_only=ON; PRAGMA mmap_size=0; PRAGMA cell_size_check=ON; BEGIN;", + ); + const version = z + .object({ version: z.string() }) + .parse(db.prepare("SELECT sqlite_version() AS version").get()).version; + const encoding = z + .object({ encoding: encodingSchema }) + .parse(db.prepare("PRAGMA encoding").get()).encoding; + const limitations = [ + "Inspects an offline stable file set. Filesystem identity checks detect observed acquisition changes; they do not establish an atomic snapshot of an actively changing database. Supply an application-created backup or quiescent database/WAL pair.", + "The source database and WAL are never opened by SQLite or modified. A private copy may acquire its own WAL-index sidecar and is removed after inspection.", + "SQLite reports current schema and selected committed rows from its valid database/WAL state. Deleted records, uncommitted or invalid WAL tails, encrypted databases, forensic recovery, and full integrity verification are outside this profile.", + "Stored CREATE statements preserve index/view/trigger definitions. Views, triggers, and virtual/shadow table rows are not executed; virtual-table column expansion is unsupported.", + "Selected rows have unspecified order and are a bounded sample. Text display may replace invalid sequences; bytes_hex and encoding preserve SQLite's text byte representation, not original on-disk page offsets.", + "Resource boundaries: combined input 256 MiB, complete worker JSON 16 MiB, native SQLite value/record and SQL statement lengths 16 MiB, owned-worker JavaScript heap 256 MiB and supervised deadline 30 seconds. These are distinct from total resident memory; an overall native SQLite heap cap is not claimed. Failures return no partial success.", + ]; + const budget = new OutputBudget(); + const schema = readSchema(db, { budget, limitations, encoding }); + return { + engine: { name: "SQLite", version }, + database_encoding: encoding, + schema, + rows: readRows(db, input, schema, { encoding, budget }), + limitations, + }; + } finally { + db.close(); + } +}; diff --git a/src/sqlite/SqliteDatabaseLimits.ts b/src/sqlite/SqliteDatabaseLimits.ts new file mode 100644 index 00000000000..db5ef88623d --- /dev/null +++ b/src/sqlite/SqliteDatabaseLimits.ts @@ -0,0 +1,28 @@ +/** The engine version is observed inside each successful owned worker. */ +export const SQLITE_PROVIDER_IDENTITY = { + id: "sqlite", + name: "REA SQLite database inspector", + version: null, +} as const; +/** Bound frozen input, retained JSON and worker allocation before parsing untrusted files. */ +export const SQLITE_DATABASE_LIMITS = { + inputBytes: 256 * 1024 * 1024, + outputBytes: 16 * 1024 * 1024, + timeoutMs: 30_000, + diagnosticBytes: 1024 * 1024, +} as const; +/** Internal rejection retains the failed boundary without returning partial success. */ +export class SqliteInspectionFailure extends Error { + constructor( + readonly reason: + | "format" + | "selection" + | "unsupported" + | "unavailable" + | "output-limit" + | "memory", + message: string, + ) { + super(message); + } +} diff --git a/src/sqlite/SqliteDatabaseProvider.ts b/src/sqlite/SqliteDatabaseProvider.ts new file mode 100644 index 00000000000..935d3b3dcf6 --- /dev/null +++ b/src/sqlite/SqliteDatabaseProvider.ts @@ -0,0 +1,188 @@ +import { + createAnalysisExecution, + type AnalysisExecution, + type ExecutionOptions, +} from "../application/AnalysisProvider.js"; +import type { SqliteDatabasePort } from "../application/sqlite/SqliteDatabasePort.js"; +import type { + AnalysisCapturedOutput, + AnalysisError, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + inspectSqliteDatabaseInputSchema, + sqliteDatabaseSchema, + type InspectSqliteDatabaseInput, +} from "../domain/sqlite/sqliteDatabase.js"; +import { isAbsolute } from "node:path"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { sqliteDatabaseFailure } from "./SqliteDatabaseFailures.js"; +import { SQLITE_PROVIDER_IDENTITY } from "./SqliteDatabaseLimits.js"; +import { captureSqliteDatabaseSnapshot } from "./SqliteDatabaseSnapshot.js"; + +import { + executeSqliteDatabaseWorker, + type SqliteWorkerLauncher, +} from "./SqliteDatabaseWorkerExecution.js"; + +const operation = "inspect_sqlite_database"; +/** Inspect a provider-owned copy through a cancellable SQLite child, without source writes. */ +export class SqliteDatabaseProvider implements SqliteDatabasePort { + constructor( + readonly environment: Readonly = process.env, + readonly launcher?: SqliteWorkerLauncher, + readonly createRuntime: () => Promise< + Pick + > = () => PrivateRuntimeRoot.create({ prefix: "rea-sqlite-database-" }), + ) {} + + /** Return exact original DB/WAL identities and current schema/selected committed rows. */ + async inspect( + input: InspectSqliteDatabaseInput, + options?: ExecutionOptions, + ): Promise> { + let root: Pick | undefined; + let retainedOutput: AnalysisCapturedOutput | undefined; + let phase = "configuration"; + let result: Result; + try { + if (options?.signal?.aborted) throw new AnalysisCancelledError(operation); + validateSqliteInput(input); + root = await this.createRuntime(); + phase = "artifact-read"; + const snapshot = await captureSqliteDatabaseSnapshot( + input.path, + root.path, + options?.signal, + ); + phase = "worker"; + const inspection = await executeSqliteDatabaseWorker({ + input, + root: root.path, + snapshotPath: snapshot.snapshotPath, + environment: this.environment, + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + ...(options?.signal === undefined ? {} : { signal: options.signal }), + captured: (output) => { + retainedOutput = output; + }, + }); + const validated = sqliteDatabaseSchema.safeParse({ + artifact: snapshot.artifact, + wal: snapshot.wal, + ...inspection, + }); + if (!validated.success) + throw new AnalysisOutputError( + operation, + "SQLite worker report dimensions or identity fields are invalid", + retainedOutput === undefined + ? undefined + : { capturedOutput: retainedOutput }, + ); + result = ok(createSqliteObservation(validated.data, input.path)); + } catch (cause: unknown) { + const failure = sqliteDatabaseFailure(cause, input.path, phase); + retainedOutput ??= failure.capturedOutput; + result = err(selectedFailure(failure, options?.signal, retainedOutput)); + } + if (root !== undefined) { + const cleaned = await cleanupSqliteRoot(root, result, retainedOutput); + if (!cleaned.ok) return cleaned; + } + return completedResult(result, options?.signal, retainedOutput); + } +} + +const selectedFailure = ( + failure: AnalysisError, + signal?: AbortSignal, + output?: AnalysisCapturedOutput, +): AnalysisError => + signal?.aborted && + failure._tag !== "AnalysisCancelledError" && + !failure.cleanupIncomplete + ? new AnalysisCancelledError( + operation, + output === undefined ? undefined : { capturedOutput: output }, + ) + : failure; +const completedResult = ( + result: Result, + signal?: AbortSignal, + output?: AnalysisCapturedOutput, +): Result => + result.ok && signal?.aborted + ? err( + new AnalysisCancelledError( + operation, + output === undefined ? undefined : { capturedOutput: output }, + ), + ) + : result; + +const createSqliteObservation = ( + report: ReturnType, + path: string, +): AnalysisExecution => { + const locations: AnalysisExecution["locations"][number][] = [ + { kind: "artifact-path", path }, + ]; + if (report.wal !== null) + locations.push({ kind: "artifact-path", path: report.wal.path }); + return createAnalysisExecution( + report, + { ...SQLITE_PROVIDER_IDENTITY, version: `SQLite ${report.engine.version}` }, + { + rawResult: null, + subject: { path, format: "file", sha256: report.artifact.sha256 }, + locations, + limitations: report.limitations, + }, + ); +}; +const cleanupSqliteRoot = async ( + root: Pick, + result: Result, + output?: AnalysisCapturedOutput, +): Promise> => { + try { + await root.close(); + return ok(null); + } catch (cause: unknown) { + return err( + new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + [root.path], + { + reason: cause instanceof Error ? cause.message : String(cause), + previous_error: result.ok ? null : projectAnalysisError(result.error), + previous_result: result.ok ? result.value.result : null, + ...(output === undefined ? {} : { captured_output: { ...output } }), + }, + { operation, cause }, + ), + ); + } +}; + +const validateSqliteInput = (input: InspectSqliteDatabaseInput): void => { + const parsed = inspectSqliteDatabaseInputSchema.safeParse(input); + if (!parsed.success || !isAbsolute(input.path)) + throw new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: parsed.success + ? "path must be an absolute local filesystem path" + : parsed.error.message, + }, + ]); +}; diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts new file mode 100644 index 00000000000..e4b9b1565c1 --- /dev/null +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -0,0 +1,119 @@ +import { lstat, open, writeFile } from "node:fs/promises"; +import type { BigIntStats } from "node:fs"; +import { join } from "node:path"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + readStableArtifact, + type StableArtifactFileSystem, +} from "../artifacts/readStableArtifact.js"; +import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; + +const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open, +}; +const optionalStat = async ( + path: string, + selectedFileSystem: StableArtifactFileSystem, +): Promise => { + try { + return await selectedFileSystem.lstat(path); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return null; + throw cause; + } +}; +const sameState = ( + before: BigIntStats | null, + after: BigIntStats | null, +): boolean => + before === null || after === null + ? before === after + : before.dev === after.dev && + before.ino === after.ino && + before.mode === after.mode && + before.size === after.size && + before.mtimeNs === after.mtimeNs && + before.ctimeNs === after.ctimeNs; + +/** Freeze a stable offline file set without ever opening the source through SQLite. */ +export const captureSqliteDatabaseSnapshot = async ( + path: string, + root: string, + signal?: AbortSignal, + selectedFileSystem: StableArtifactFileSystem = fileSystem, +) => { + signal?.throwIfAborted(); + const paths = [path, `${path}-wal`, `${path}-journal`]; + const before = await Promise.all( + paths.map((path) => optionalStat(path, selectedFileSystem)), + ); + const database = before[0]; + const wal = before[1]; + if (database === undefined || wal === undefined || before[2] === undefined) + throw new Error("Incomplete database file-set observation"); + if (database === null) + throw new ArtifactReaderFailure( + "path", + `Selected SQLite database does not exist: ${path}`, + ); + if (before[2] !== null) + throw new ArtifactReaderFailure( + "format", + `Rollback journal present: ${path}-journal. Supply a clean offline snapshot; rollback recovery is outside this inspection profile.`, + ); + const selectedSize = database.size + (wal?.size ?? 0n); + if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes)) + throw new ArtifactReaderFailure( + "limit", + `Selected SQLite database and WAL exceed the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte snapshot budget: ${path}`, + ); + const snapshotPath = join(root, "database.snapshot"); + const captured = await readStableArtifact( + path, + SQLITE_DATABASE_LIMITS.inputBytes, + signal, + selectedFileSystem, + ); + await writeFile(snapshotPath, captured.bytes, { + flag: "wx", + mode: 0o600, + ...(signal === undefined ? {} : { signal }), + }); + const artifact = { + path, + sha256: captured.sha256, + bytes: captured.bytes.length, + }; + let walArtifact: typeof artifact | null = null; + if (wal !== null) { + const capturedWal = await readStableArtifact( + `${path}-wal`, + SQLITE_DATABASE_LIMITS.inputBytes - artifact.bytes, + signal, + selectedFileSystem, + ); + await writeFile(`${snapshotPath}-wal`, capturedWal.bytes, { + flag: "wx", + mode: 0o600, + ...(signal === undefined ? {} : { signal }), + }); + walArtifact = { + path: `${path}-wal`, + sha256: capturedWal.sha256, + bytes: capturedWal.bytes.length, + }; + } + const after = await Promise.all( + paths.map((path) => optionalStat(path, selectedFileSystem)), + ); + for (const [index, expected] of before.entries()) + if (!sameState(expected, after[index] ?? null)) + throw new ArtifactReaderFailure( + "integrity", + `SQLite snapshot file set changed during acquisition: ${paths[index] ?? path}`, + ); + signal?.throwIfAborted(); + return { snapshotPath, artifact, wal: walArtifact }; +}; diff --git a/src/sqlite/SqliteDatabaseWorker.ts b/src/sqlite/SqliteDatabaseWorker.ts new file mode 100644 index 00000000000..ebd2d5bac12 --- /dev/null +++ b/src/sqlite/SqliteDatabaseWorker.ts @@ -0,0 +1,57 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { z } from "zod"; +import { inspectSqliteDatabaseInputSchema } from "../domain/sqlite/sqliteDatabase.js"; +import { inspectSqliteDatabaseSnapshot } from "./SqliteDatabaseInspection.js"; +import { + SQLITE_DATABASE_LIMITS, + SqliteInspectionFailure, +} from "./SqliteDatabaseLimits.js"; + +const requestSchema = z.strictObject({ + snapshot_path: z.string(), + reply_path: z.string(), + input: inspectSqliteDatabaseInputSchema, +}); +const main = async (): Promise => { + const requestPath = process.argv[2]; + if (requestPath === undefined) + throw new Error("Owned SQLite worker request path missing."); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + let reply: unknown; + try { + reply = { + ok: true, + inspection: inspectSqliteDatabaseSnapshot( + request.snapshot_path, + request.input, + ), + }; + } catch (cause: unknown) { + const code = + cause instanceof Error && "errcode" in cause ? cause.errcode : undefined; + reply = { + ok: false, + reason: + cause instanceof SqliteInspectionFailure + ? cause.reason + : code === 7 + ? "memory" + : code === 18 + ? "output-limit" + : "format", + message: cause instanceof Error ? cause.message : String(cause), + }; + } + let encoded = JSON.stringify(reply); + if (Buffer.byteLength(encoded) > SQLITE_DATABASE_LIMITS.outputBytes) + encoded = JSON.stringify({ + ok: false, + reason: "output-limit", + message: + "Complete SQLite evidence exceeds the 16 MiB JSON reply budget; no partial success was returned.", + }); + await writeFile(request.reply_path, encoded, { flag: "wx", mode: 0o600 }); +}; +await main(); diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts new file mode 100644 index 00000000000..8042fbcda67 --- /dev/null +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -0,0 +1,154 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; +import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { + sqliteDatabaseSchema, + type InspectSqliteDatabaseInput, +} from "../domain/sqlite/sqliteDatabase.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { runOwnedCommand } from "../process/OwnedCommand.js"; +import { capturedSqliteOutput } from "./SqliteDatabaseFailures.js"; +import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; + +const operation = "inspect_sqlite_database"; +const { + artifact: _artifactSchema, + wal: _walSchema, + ...inspectionShape +} = sqliteDatabaseSchema.shape; +const inspectionSchema = z.strictObject(inspectionShape); +const replySchema = z.discriminatedUnion("ok", [ + z.strictObject({ ok: z.literal(true), inspection: inspectionSchema }), + z.strictObject({ + ok: z.literal(false), + reason: z.enum([ + "format", + "selection", + "unsupported", + "unavailable", + "output-limit", + "memory", + ]), + message: z.string(), + }), +]); +/** Replaceable launcher retains the shared owned-process supervision boundary. */ +export type SqliteWorkerLauncher = NonNullable< + Parameters[2] +>["launcher"]; + +const rejectedReply = ( + reply: z.output & { ok: false }, + capturedOutput: AnalysisCapturedOutput, +) => { + if (reply.reason === "unavailable") + return new AnalysisCapabilityUnavailableError( + "sqlite", + operation, + reply.message, + { capturedOutput, userMessage: reply.message }, + ); + if (reply.reason === "memory") + return new AnalysisResourceConstraintError( + operation, + "memory", + reply.message, + { + javascript_heap_bytes: 256 * 1024 * 1024, + }, + { capturedOutput }, + ); + if (reply.reason === "output-limit") + return new AnalysisResourceConstraintError( + operation, + "transport", + reply.message, + { + sqlite_value_or_record_bytes: SQLITE_DATABASE_LIMITS.outputBytes, + worker_reply_bytes: SQLITE_DATABASE_LIMITS.outputBytes, + }, + { capturedOutput }, + ); + return new AnalysisInputError(operation, { capturedOutput }, [ + { + path: [reply.reason === "selection" ? "table" : "path"], + reason: "invalid_format", + message: reply.message, + }, + ]); +}; + +/** Execute only the private snapshot and validate the bounded worker protocol. */ +export const executeSqliteDatabaseWorker = async (request: { + readonly input: InspectSqliteDatabaseInput; + readonly root: string; + readonly snapshotPath: string; + readonly environment: Readonly; + readonly launcher?: SqliteWorkerLauncher; + readonly signal?: AbortSignal; + readonly captured: (output: AnalysisCapturedOutput) => void; +}): Promise> => { + const requestPath = join(request.root, "request.json"); + const replyPath = join(request.root, "reply.json"); + await writeFile( + requestPath, + JSON.stringify({ + snapshot_path: request.snapshotPath, + reply_path: replyPath, + input: request.input, + }), + { flag: "wx", mode: 0o600 }, + ); + const { NODE_OPTIONS: _ambientNodeOptions, ...environment } = + request.environment; + const execution = await runOwnedCommand( + { + command: process.execPath, + arguments: [ + "--max-old-space-size=256", + fileURLToPath(new URL("./SqliteDatabaseWorker.js", import.meta.url)), + requestPath, + ], + cwd: request.root, + expectedCommand: null, + runId: `rea-sqlite-database-${randomUUID()}`, + hostEnvironment: environment, + }, + { + timeoutMs: SQLITE_DATABASE_LIMITS.timeoutMs, + diagnosticBytes: SQLITE_DATABASE_LIMITS.diagnosticBytes, + }, + { + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.launcher === undefined ? {} : { launcher: request.launcher }), + }, + ); + const capturedOutput = capturedSqliteOutput(execution); + request.captured(capturedOutput); + let reply: z.output; + try { + const file = await readStableArtifact( + replyPath, + SQLITE_DATABASE_LIMITS.outputBytes, + request.signal, + ); + reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); + } catch (cause: unknown) { + throw new AnalysisOutputError( + operation, + `Owned SQLite worker reply is missing or malformed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause, capturedOutput }, + ); + } + if (!reply.ok) throw rejectedReply(reply, capturedOutput); + return reply.inspection; +}; diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts new file mode 100644 index 00000000000..7f0e73cb2b7 --- /dev/null +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -0,0 +1,274 @@ +import { createHash } from "node:crypto"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { expect, onTestFinished } from "vitest"; + +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const digest = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +const sourceState = async (path: string) => { + const bytes = await readFile(path); + const metadata = await stat(path, { bigint: true }); + return { + bytes, + device: metadata.dev, + inode: metadata.ino, + size: metadata.size, + modified: metadata.mtimeNs, + changed: metadata.ctimeNs, + }; +}; + +cliTest( + "inspects a real SQLite schema and lossless selected records identically through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-public-"); + const path = join(root, "selected.db"); + const database = new DatabaseSync(path); + try { + database.exec(` + CREATE TABLE "odd "" table" ( + id INTEGER PRIMARY KEY, + payload BLOB, + amount REAL, + missing TEXT, + text_value TEXT, + generated TEXT GENERATED ALWAYS AS (hex(payload)) VIRTUAL + ); + CREATE UNIQUE INDEX "quoted "" index" ON "odd "" table"(payload, id); + CREATE VIEW readable AS SELECT id FROM "odd "" table"; + CREATE TRIGGER observed AFTER INSERT ON "odd "" table" BEGIN SELECT 1; END; + CREATE TABLE keyed(k TEXT PRIMARY KEY) WITHOUT ROWID, STRICT; + `); + const insert = database.prepare( + 'INSERT INTO "odd "" table"(id,payload,amount,missing,text_value) VALUES(?,?,?,NULL,CAST(X\'80ff\' AS TEXT))', + ); + insert.run(9007199254740993n, Buffer.from("deadbeef", "hex"), Infinity); + insert.run(9007199254740994n, Buffer.from("deadbeef", "hex"), Infinity); + } finally { + database.close(); + } + const original = await readFile(path); + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + "selected.db", + "--table", + 'odd " table', + "--row-limit", + "1", + "--json", + ], + cwd: root, + environment: { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_GHIDRA_HOME: "/unconfigured/ghidra", + }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toMatchObject({ + operation: "inspect_sqlite_database", + confidence: "observed", + subject: { local_path: path, digest: { sha256: digest(original) } }, + normalized_result: { + artifact: { path, sha256: digest(original), bytes: original.length }, + wal: null, + schema: { + completeness: "complete", + tables: expect.arrayContaining([ + expect.objectContaining({ + name: 'odd " table', + columns: expect.arrayContaining([ + expect.objectContaining({ + name: "id", + primary_key_position: 1, + }), + expect.objectContaining({ name: "generated", hidden: 2 }), + ]), + }), + expect.objectContaining({ + name: "keyed", + without_rowid: true, + strict: true, + }), + ]), + indexes: expect.arrayContaining([ + expect.objectContaining({ + name: 'quoted " index', + table: 'odd " table', + }), + ]), + views: [ + { name: "readable", sql: expect.stringContaining("SELECT id") }, + ], + triggers: [ + { + name: "observed", + table: 'odd " table', + sql: expect.stringContaining("AFTER INSERT"), + }, + ], + }, + rows: { + table: 'odd " table', + columns: [ + "id", + "payload", + "amount", + "missing", + "text_value", + "generated", + ], + returned_rows: 1, + row_limit: 1, + truncated: true, + values: [ + [ + { + type: "integer", + value: expect.stringMatching(/^900719925474099[34]$/), + }, + { type: "blob", hex: "deadbeef" }, + { type: "real", value: "Infinity" }, + { type: "null" }, + { type: "text", value: "\ufffd\ufffd", bytes_hex: "80ff" }, + { + type: "text", + value: "DEADBEEF", + bytes_hex: "4445414442454546", + }, + ], + ], + }, + }, + }); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_sqlite_database", { + path, + table: 'odd " table', + row_limit: 1, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + expect(await readFile(path)).toEqual(original); + }, +); + +cliTest( + "includes uncheckpointed committed WAL data without modifying the source database or sidecars", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-wal-public-"); + const path = join(root, "wal.db"); + const database = new DatabaseSync(path); + onTestFinished(() => database.close()); + database.exec( + "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; CREATE TABLE committed(value TEXT); INSERT INTO committed VALUES('only in WAL');", + ); + const files = [path, `${path}-wal`, `${path}-shm`]; + const before = await Promise.all(files.map(sourceState)); + const db = before[0]; + const wal = before[1]; + expect(db).toBeDefined(); + expect(wal).toBeDefined(); + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "committed", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + artifact: { sha256: db === undefined ? "missing" : digest(db.bytes) }, + wal: { + path: `${path}-wal`, + sha256: wal === undefined ? "missing" : digest(wal.bytes), + }, + rows: { + values: [ + [ + { + type: "text", + value: "only in WAL", + bytes_hex: Buffer.from("only in WAL").toString("hex"), + }, + ], + ], + returned_rows: 1, + truncated: false, + }, + }, + }); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_sqlite_database", { + path, + table: "committed", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + const after = await Promise.all(files.map(sourceState)); + expect(after).toEqual(before); + }, +); + +cliTest( + "returns actionable public errors for corrupt input, unknown tables and invalid record choices", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-errors-public-"); + const path = join(root, "invalid.db"); + await writeFile(path, "not a SQLite database"); + const { call } = await connectLocalToolsMcp(); + const corrupt = await cli.run({ + arguments: ["inspect-sqlite-database", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(corrupt.exitCode).toBe(1); + expect(corrupt.json).toMatchObject({ code: "invalid_request" }); + expect((await call("inspect_sqlite_database", { path })).isError).toBe( + true, + ); + const valid = join(root, "valid.db"); + const database = new DatabaseSync(valid); + try { + database.exec("CREATE TABLE present(value TEXT);"); + } finally { + database.close(); + } + const missing = await cli.run({ + arguments: [ + "inspect-sqlite-database", + valid, + "--table", + "absent", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(missing.exitCode).toBe(1); + expect(missing.stdout).toContain("absent"); + expect( + (await call("inspect_sqlite_database", { path: valid, table: "absent" })) + .isError, + ).toBe(true); + for (const input of [ + { path: valid, row_limit: 1 }, + { path: valid, table: "present", row_limit: 0 }, + { path: valid, query: "DELETE FROM present" }, + ]) { + expect( + (await call("inspect_sqlite_database", input)).isError, + JSON.stringify(input), + ).toBe(true); + } + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts new file mode 100644 index 00000000000..eec61b803e2 --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -0,0 +1,97 @@ +import { DatabaseSync } from "node:sqlite"; +import { expect, it, onTestFinished } from "vitest"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; +import { SqliteInspectionFailure } from "../../../src/sqlite/SqliteDatabaseLimits.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const createDatabase = async (sql: string): Promise => { + const workspace = await createTestWorkspace("rea-sqlite-expansion-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("private.db"); + const producer = new DatabaseSync(path); + try { + producer.exec(sql); + } finally { + producer.close(); + } + return path; +}; + +it("rejects an oversized generated cell through SQLite's native value limit", async () => { + const path = await createDatabase(` + CREATE TABLE records ( + id INTEGER, + payload BLOB GENERATED ALWAYS AS (zeroblob(72 * 1024 * 1024)) VIRTUAL + ); + INSERT INTO records(id) VALUES (1); + `); + try { + inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + row_limit: 1, + }); + throw new Error("An oversized generated cell must not be projected"); + } catch (cause: unknown) { + expect(cause).toMatchObject({ + code: "ERR_SQLITE_ERROR", + errcode: 18, + }); + } +}); + +it("reports additional rows without expanding an unselected generated cell", async () => { + const path = await createDatabase(` + CREATE TABLE records ( + id INTEGER, + payload BLOB GENERATED ALWAYS AS ( + CASE WHEN id = 2 THEN zeroblob(72 * 1024 * 1024) ELSE X'01' END + ) VIRTUAL + ); + INSERT INTO records(id) VALUES (1), (2); + `); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + row_limit: 1, + }); + expect(result.rows).toMatchObject({ + columns: ["id", "payload"], + values: [ + [ + { type: "integer", value: "1" }, + { type: "blob", hex: "01" }, + ], + ], + returned_rows: 1, + truncated: true, + }); +}); + +it("rejects the combined representation of individually small generated cells", async () => { + const columns = Array.from( + { length: 6 }, + (_, index) => + `payload_${String(index)} BLOB GENERATED ALWAYS AS (zeroblob(2 * 1024 * 1024)) VIRTUAL`, + ); + const path = await createDatabase(` + CREATE TABLE records (id INTEGER, ${columns.join(", ")}); + INSERT INTO records(id) VALUES (1); + `); + try { + inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + row_limit: 1, + }); + throw new Error( + "Aggregate row expansion must be rejected before projection", + ); + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(SqliteInspectionFailure); + expect(cause).toMatchObject({ reason: "output-limit" }); + } +}); diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts new file mode 100644 index 00000000000..6860e876a43 --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -0,0 +1,493 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { access, lstat, readFile, symlink, writeFile } from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { expect, it, onTestFinished } from "vitest"; +import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; +import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const workerUrl = new URL( + "../../../dist/sqlite/SqliteDatabaseWorker.js", + import.meta.url, +); +const createProvider = () => + new SqliteDatabaseProvider(process.env, (spawn) => + spawnOwnedProviderProcess({ + ...spawn, + arguments: spawn.arguments.map((argument, index) => + index === 1 ? fileURLToPath(workerUrl) : argument, + ), + }), + ); + +const createFixture = async (wal = false) => { + const workspace = await createTestWorkspace("rea-sqlite-boundary-"); + const database = createSqliteDatabaseFixture(workspace.root, wal); + if (!wal) database.close(); + onTestFinished(async () => { + if (wal) database.close(); + await removeTestWorkspace(workspace.root); + }); + return { workspace, database }; +}; + +it("inspects committed WAL schema and rows while preserving every source artifact", async () => { + const { database } = await createFixture(true); + const paths = [database.path, database.walPath, `${database.path}-shm`]; + const original = await Promise.all( + paths.map(async (path) => ({ + path, + bytes: await readFile(path), + stat: await lstat(path), + })), + ); + const result = await createProvider().inspect({ + path: database.path, + table: "records", + row_limit: 1, + }); + if (!result.ok) throw result.error; + const main = original[0]; + const wal = original[1]; + if (main === undefined || wal === undefined) + throw new Error("Missing fixture identity"); + expect(result.value.result).toMatchObject({ + artifact: { + path: database.path, + bytes: main.bytes.length, + sha256: createHash("sha256").update(main.bytes).digest("hex"), + }, + wal: { + path: database.walPath, + bytes: wal.bytes.length, + sha256: createHash("sha256").update(wal.bytes).digest("hex"), + }, + schema: { + completeness: "complete", + tables: expect.arrayContaining([ + { + name: "records", + sql: expect.stringContaining("CREATE TABLE"), + root_page: expect.any(Number), + kind: "table", + without_rowid: false, + strict: false, + columns_completeness: "complete", + columns: expect.arrayContaining([ + expect.objectContaining({ name: "generated_value", hidden: 2 }), + ]), + }, + ]), + indexes: [ + expect.objectContaining({ name: "records_text", table: "records" }), + ], + views: [ + { + name: "record_names", + sql: expect.stringContaining("SELECT text_value"), + }, + ], + triggers: [ + { + name: "records_insert", + table: "records", + sql: expect.stringContaining("AFTER INSERT"), + }, + ], + }, + rows: { + table: "records", + returned_rows: 1, + truncated: true, + row_limit: 1, + order: "unspecified", + }, + }); + for (const artifact of original) { + expect(await readFile(artifact.path)).toEqual(artifact.bytes); + const after = await lstat(artifact.path); + expect({ + ino: after.ino, + size: after.size, + mtimeMs: after.mtimeMs, + ctimeMs: after.ctimeMs, + }).toEqual({ + ino: artifact.stat.ino, + size: artifact.stat.size, + mtimeMs: artifact.stat.mtimeMs, + ctimeMs: artifact.stat.ctimeMs, + }); + } +}); + +it("preserves SQLite integer, binary, nonfinite, null, and text byte values", async () => { + const { database } = await createFixture(); + const result = await createProvider().inspect({ + path: database.path, + table: "records", + row_limit: 10, + }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + wal: null, + rows: { + columns: [ + "id", + "integer_value", + "blob_value", + "real_value", + "nullable_value", + "text_value", + "generated_value", + ], + returned_rows: 3, + truncated: false, + values: [ + [ + { type: "integer", value: "1" }, + { type: "integer", value: "9223372036854775807" }, + { type: "blob", hex: "001fefff" }, + { type: "real", value: "Infinity" }, + { type: "null" }, + { + type: "text", + value: "first é\0value", + bytes_hex: Buffer.from("first é\0value").toString("hex"), + }, + { + type: "text", + value: "integer", + bytes_hex: Buffer.from("integer").toString("hex"), + }, + ], + [ + { type: "integer", value: "2" }, + { type: "integer", value: "-9223372036854775808" }, + { type: "blob", hex: "" }, + { type: "real", value: "-Infinity" }, + { + type: "text", + value: "present", + bytes_hex: Buffer.from("present").toString("hex"), + }, + { + type: "text", + value: "second", + bytes_hex: Buffer.from("second").toString("hex"), + }, + { + type: "text", + value: "integer", + bytes_hex: Buffer.from("integer").toString("hex"), + }, + ], + [ + { type: "integer", value: "3" }, + { type: "integer", value: "0" }, + { type: "blob", hex: "ff" }, + { type: "real", value: 1.25 }, + { type: "null" }, + { type: "text", bytes_hex: "ff00c0af" }, + { + type: "text", + value: "integer", + bytes_hex: Buffer.from("integer").toString("hex"), + }, + ], + ], + }, + }); + expect(() => JSON.stringify(result.value.result)).not.toThrow(); +}); + +it("selects an exact quoted table and refuses to execute table input as SQL", async () => { + const { database } = await createFixture(); + const provider = createProvider(); + const selected = await provider.inspect({ + path: database.path, + table: database.quotedTable, + row_limit: 1, + }); + if (!selected.ok) throw selected.error; + expect(selected.value.result).toMatchObject({ + rows: { + table: database.quotedTable, + columns: ["__proto__"], + values: [ + [ + { + type: "text", + value: "exact selected table", + bytes_hex: Buffer.from("exact selected table").toString("hex"), + }, + ], + ], + returned_rows: 1, + truncated: false, + }, + }); + const unknown = await provider.inspect({ + path: database.path, + table: 'records"; DROP TABLE records;--', + row_limit: 1, + }); + expect(unknown).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + const after = await provider.inspect({ + path: database.path, + table: "records", + row_limit: 10, + }); + if (!after.ok) throw after.error; + expect(after.value.result).toMatchObject({ rows: { returned_rows: 3 } }); +}); + +it("does not execute views when selecting rows", async () => { + const { database } = await createFixture(); + const result = await createProvider().inspect({ + path: database.path, + table: "record_names", + row_limit: 1, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); + +it("rejects malformed databases without altering the selected file", async () => { + const workspace = await createTestWorkspace("rea-sqlite-invalid-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = await workspace.write( + "not-a-database.db", + "selected non-SQLite bytes", + ); + const bytes = await readFile(path); + const result = await createProvider().inspect({ path }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(await readFile(path)).toEqual(bytes); +}); + +it.runIf(process.platform !== "win32")( + "rejects a symlink database", + async () => { + const { database, workspace } = await createFixture(); + const alias = workspace.path("alias.db"); + await symlink(database.path, alias); + const provider = createProvider(); + expect(await provider.inspect({ path: alias })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }, +); + +it("rejects an actual open rollback transaction without changing its journal", async () => { + const { database } = await createFixture(); + const writer = new DatabaseSync(database.path); + writer.exec( + "BEGIN IMMEDIATE; INSERT INTO records(id, text_value) VALUES (4, 'uncommitted')", + ); + try { + const main = await readFile(database.path); + const journalPath = `${database.path}-journal`; + const journal = await readFile(journalPath); + expect(journal.length).toBeGreaterThan(0); + expect( + await createProvider().inspect({ path: database.path }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(await readFile(database.path)).toEqual(main); + expect(await readFile(journalPath)).toEqual(journal); + } finally { + writer.exec("ROLLBACK"); + writer.close(); + } +}); + +it.runIf(process.platform !== "win32")( + "rejects FIFO input without waiting for a writer", + async () => { + const workspace = await createTestWorkspace("rea-sqlite-fifo-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("database.fifo"); + await promisify(execFile)("mkfifo", [path]); + const result = await createProvider().inspect({ path }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }, +); + +it("honors a cancelled caller before opening the database", async () => { + const { database } = await createFixture(); + const controller = new AbortController(); + controller.abort(); + expect( + await createProvider().inspect( + { path: database.path }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); +}); + +it("removes an acquired private root when cancelled before snapshot creation", async () => { + const { database } = await createFixture(); + const controller = new AbortController(); + let ownedRoot = ""; + const provider = new SqliteDatabaseProvider( + {}, + () => { + throw new Error("Cancelled request must not launch SQLite"); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-sqlite-cancel-root-", + }); + ownedRoot = root.path; + controller.abort(); + return root; + }, + ); + expect( + await provider.inspect( + { path: database.path }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it.each(["UTF-8", "UTF-16le", "UTF-16be"] as const)( + "preserves %s text, a leading BOM and empty table selection", + async (encoding) => { + const workspace = await createTestWorkspace("rea-sqlite-encoding-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("encoding.db"); + const writer = new DatabaseSync(path); + writer.exec( + `PRAGMA encoding='${encoding}'; CREATE TABLE "" ("1" TEXT, "0" TEXT)`, + ); + const value = "\uFEFFé𝄞\0selected"; + writer + .prepare('INSERT INTO "" VALUES (?, ?)') + .run(value, "second numeric column"); + writer.close(); + const bytes = Buffer.from(value, encoding === "UTF-8" ? "utf8" : "utf16le"); + if (encoding === "UTF-16be") bytes.swap16(); + const result = await createProvider().inspect({ + path, + table: "", + }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + database_encoding: encoding, + rows: { + table: "", + columns: ["1", "0"], + returned_rows: 1, + values: [ + [ + { type: "text", value, bytes_hex: bytes.toString("hex"), encoding }, + { type: "text", value: "second numeric column" }, + ], + ], + }, + }); + }, +); + +it("retains an unavailable virtual module definition without executing it", async () => { + const workspace = await createTestWorkspace("rea-sqlite-virtual-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("virtual.db"); + const writer = new DatabaseSync(path); + writer.exec( + "CREATE VIRTUAL TABLE optional_search USING fts5(body); INSERT INTO optional_search VALUES ('selected source')", + ); + writer.close(); + const bytes = await readFile(path); + const declaration = bytes.indexOf(Buffer.from("USING fts5")); + if (declaration < 0) + throw new Error("SQLite virtual module declaration not found"); + bytes.write("zzzz", declaration + Buffer.byteLength("USING "), "utf8"); + await writeFile(path, bytes); + const provider = createProvider(); + const result = await provider.inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + rows: null, + schema: { + tables: expect.arrayContaining([ + expect.objectContaining({ + name: "optional_search", + kind: "virtual", + sql: expect.stringContaining("USING zzzz"), + columns: [], + columns_completeness: "unsupported", + }), + ]), + }, + }); + expect( + await provider.inspect({ path, table: "optional_search" }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + expect(await readFile(path)).toEqual(bytes); +}); + +it("cancels an acquired SQLite worker and removes its private snapshot", async () => { + const { database } = await createFixture(); + const controller = new AbortController(); + let ownedRoot = ""; + let pid: number | undefined; + const provider = new SqliteDatabaseProvider({}, async (spawn) => { + ownedRoot = spawn.cwd ?? ""; + const requestPath = spawn.arguments[2]; + if (requestPath === undefined) + throw new Error("SQLite worker arguments missing"); + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: [ + "--input-type=module", + "-e", + `await import(${JSON.stringify(workerUrl.href)}); process.stdout.write('ready\\n'); setInterval(() => {}, 1000);`, + "sqlite-worker-test", + requestPath, + ], + }); + pid = launched.process.pid; + await waitForProviderProcessReady(launched.process); + await access(`${ownedRoot}/reply.json`); + setImmediate(() => controller.abort()); + return launched; + }); + const result = await provider.inspect( + { path: database.path, table: "records" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + if (pid === undefined) throw new Error("SQLite worker not acquired"); + const acquiredPid = pid; + expect(() => process.kill(acquiredPid, 0)).toThrow(); + await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts new file mode 100644 index 00000000000..71eae57d9bd --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts @@ -0,0 +1,72 @@ +import { lstat, open, readFile, utimes, writeFile } from "node:fs/promises"; +import { expect, it, onTestFinished } from "vitest"; +import type { StableArtifactFileSystem } from "../../../src/artifacts/readStableArtifact.js"; +import { captureSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseSnapshot.js"; +import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const fixture = async (wal = false) => { + const workspace = await createTestWorkspace("rea-sqlite-snapshot-race-"); + const database = createSqliteDatabaseFixture(workspace.root, wal); + if (!wal) database.close(); + onTestFinished(async () => { + if (wal) database.close(); + await removeTestWorkspace(workspace.root); + }); + const root = await workspace.mkdir("private"); + return { workspace, database, root }; +}; + +it("rejects a database changed after its copy but before WAL acquisition", async () => { + const { database, root } = await fixture(true); + const initial = await readFile(database.path); + const initialStat = await lstat(database.path); + let changed = false; + const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open: async (path, flags) => { + if (path === database.walPath) { + const bytes = Buffer.from(initial); + const offset = bytes.length - 1; + bytes[offset] = (bytes[offset] ?? 0) ^ 1; + await writeFile(database.path, bytes); + await utimes(database.path, initialStat.atime, initialStat.mtime); + changed = true; + } + return open(path, flags); + }, + }; + await expect( + captureSqliteDatabaseSnapshot(database.path, root, undefined, fileSystem), + ).rejects.toMatchObject({ reason: "integrity" }); + expect(changed).toBe(true); + expect(await readFile(`${root}/database.snapshot`)).toEqual(initial); +}); + +it.each(["-wal", "-journal"] as const)( + "rejects a previously absent %s created during database acquisition", + async (suffix) => { + const { database, root } = await fixture(); + let created = false; + const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open: async (path, flags) => { + if (path === database.path) { + await writeFile( + `${database.path}${suffix}`, + "newly selected sidecar", + ); + created = true; + } + return open(path, flags); + }, + }; + await expect( + captureSqliteDatabaseSnapshot(database.path, root, undefined, fileSystem), + ).rejects.toMatchObject({ reason: "integrity" }); + expect(created).toBe(true); + }, +); diff --git a/tests/fixtures/sqlite/database.ts b/tests/fixtures/sqlite/database.ts new file mode 100644 index 00000000000..882b2b334de --- /dev/null +++ b/tests/fixtures/sqlite/database.ts @@ -0,0 +1,64 @@ +import { DatabaseSync } from "node:sqlite"; +import { join } from "node:path"; + +/** Exact selected SQLite fixture with committed rows and optional live WAL. */ +export interface SqliteDatabaseFixture { + readonly path: string; + readonly walPath: string; + readonly quotedTable: string; + close(): void; +} + +/** Produce schema and lossless SQLite values through the actual SQLite engine. */ +export const createSqliteDatabaseFixture = ( + root: string, + wal = false, +): SqliteDatabaseFixture => { + const path = join(root, "selected.db"); + const db = new DatabaseSync(path); + if (wal) db.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0"); + db.exec(` + CREATE TABLE records ( + id INTEGER PRIMARY KEY, + integer_value INTEGER, + blob_value BLOB, + real_value REAL, + nullable_value TEXT, + text_value TEXT, + generated_value TEXT GENERATED ALWAYS AS (typeof(integer_value)) VIRTUAL + ); + CREATE INDEX records_text ON records(text_value); + CREATE VIEW record_names AS SELECT text_value FROM records; + CREATE TRIGGER records_insert AFTER INSERT ON records BEGIN SELECT 1; END; + CREATE TABLE "strange""'; DROP TABLE records;--" ("__proto__" TEXT); + INSERT INTO "strange""'; DROP TABLE records;--" VALUES ('exact selected table'); + `); + const insert = db.prepare( + "INSERT INTO records(id, integer_value, blob_value, real_value, nullable_value, text_value) VALUES (?, ?, ?, ?, ?, ?)", + ); + insert.run( + 1, + 9223372036854775807n, + Buffer.from([0, 31, 239, 255]), + Infinity, + null, + "first é\0value", + ); + insert.run( + 2, + -9223372036854775808n, + Buffer.alloc(0), + -Infinity, + "present", + "second", + ); + db.exec( + "INSERT INTO records(id, integer_value, blob_value, real_value, nullable_value, text_value) VALUES (3, 0, X'FF', 1.25, NULL, CAST(X'FF00C0AF' AS TEXT))", + ); + return { + path, + walPath: `${path}-wal`, + quotedTable: "strange\"'; DROP TABLE records;--", + close: () => db.close(), + }; +}; From 443ecd224c5e7f45698e553e3a9228360b07b05c Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:40:53 -0400 Subject: [PATCH 02/10] fix: avoid SQLite schema collisions and stabilize cleanup fixture Use direct, identifier-quoted table_xinfo PRAGMA statements so stored tables or views named pragma_table_xinfo cannot break database inspection. Cover both shadowing cases with real SQLite fixtures and preserved selected rows. Keep the synthetic JADX cleanup-failure child alive after stdin EOF until verified teardown kills it, avoiding natural-exit races in ownership scans. Include the stop result in teardown assertion failures. Validation: 21 focused SQLite tests, all eight check:ci tasks, runtime build, and real fixture EOF/lifetime probes passed. Local owned-worker and public SQLite tests remain blocked by this host's procps/PID namespace mismatch; those existing tests passed in the original GitHub CI run. --- src/sqlite/SqliteDatabaseInspection.ts | 5 +-- .../sqlite/sqliteDatabaseInspection.test.ts | 35 +++++++++++++++++++ tests/fixtures/android/jadx-mcp.mjs | 3 ++ tests/fixtures/android/jadx.ts | 5 ++- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index f08514e3b29..6ab52c62065 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -181,9 +181,10 @@ const readColumns = ( budget: OutputBudget, ): SqliteDatabase["schema"]["tables"][number]["columns"] => { const columns: SqliteDatabase["schema"]["tables"][number]["columns"] = []; + // Direct PRAGMA syntax cannot be shadowed by a stored table or view. for (const raw of db - .prepare("SELECT * FROM pragma_table_xinfo(?, 'main')") - .iterate(name)) { + .prepare(`PRAGMA main.table_xinfo(${quoteIdentifier(name)})`) + .iterate()) { const value = columnRow.parse(raw); const column = { cid: value.cid, diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index eec61b803e2..2404dde9b2d 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -20,6 +20,41 @@ const createDatabase = async (sql: string): Promise => { return path; }; +it.each(["table", "view"] as const)( + "inspects columns when a %s shadows the table-valued PRAGMA name", + async (kind) => { + const path = await createDatabase(` + CREATE TABLE "selected "" table" ("0" TEXT, "" INTEGER); + INSERT INTO "selected "" table" VALUES ('retained', 7); + ${kind === "table" ? "CREATE TABLE pragma_table_xinfo(value TEXT)" : "CREATE VIEW pragma_table_xinfo AS SELECT 1"}; + `); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: 'selected " table', + }); + expect(result.schema.tables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'selected " table', + columns: [ + expect.objectContaining({ name: "0", declared_type: "TEXT" }), + expect.objectContaining({ name: "", declared_type: "INTEGER" }), + ], + }), + ]), + ); + expect(result.rows).toMatchObject({ + columns: ["0", ""], + values: [ + [ + { type: "text", value: "retained" }, + { type: "integer", value: "7" }, + ], + ], + }); + }, +); + it("rejects an oversized generated cell through SQLite's native value limit", async () => { const path = await createDatabase(` CREATE TABLE records ( diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index e95029d531a..d67e6a68c3a 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -148,3 +148,6 @@ for (const name of ["get_xrefs_to_class", "get_xrefs_to_method"]) }), ); await server.connect(new StdioServerTransport()); +// A refused cleanup must leave a live process for verified fixture teardown. +// Otherwise stdin EOF races the ownership scan with the server's natural exit. +if (mode === "cleanup-failure") setInterval(() => {}, 1000); diff --git a/tests/fixtures/android/jadx.ts b/tests/fixtures/android/jadx.ts index 3488647a075..af112278b06 100644 --- a/tests/fixtures/android/jadx.ts +++ b/tests/fixtures/android/jadx.ts @@ -74,7 +74,10 @@ export const createJadxProtocolFixture = async ( ownsProcessLifetime: true, cleanup: () => cleanupOwnedProcessGroup(spawned.ownership), }); - expect((await supervisor.stop()).status).not.toBe("incomplete"); + const stopped = await supervisor.stop(); + expect(stopped.status, JSON.stringify(stopped)).not.toBe( + "incomplete", + ); if (options.cwd !== undefined) await rm(options.cwd, { recursive: true, force: true }); }); From 76d7036d51a78ed2b7a5d632b5b40a5cb634185b Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:59:11 -0400 Subject: [PATCH 03/10] fix(sqlite): preserve schema bytes and refuse ambiguous shadow rows Keep possible shadow tables unknown when their virtual module cannot be resolved, and reject row selection through CLI and MCP. Read schema text directly as bytes without hex expansion or native sort records, preserving exact encodings and valid large definitions. Add real SQLite regressions for unknown modules, malformed text, UTF-16, large SQL, and long identifiers. --- docs/sqlite-databases.md | 5 +- src/domain/sqlite/sqliteDatabase.ts | 6 +- src/sqlite/SqliteDatabaseInspection.ts | 93 +++++++++------- tests/acceptance/sqliteDatabaseCli.test.ts | 42 +++++++- .../sqlite/sqliteDatabaseInspection.test.ts | 100 ++++++++++++++++++ 5 files changed, 206 insertions(+), 40 deletions(-) diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index c9ae4b96897..fbc7cbdb5dc 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -37,7 +37,10 @@ an atomic SQLite snapshot, even if no change is detected during inspection. The complete schema inventory includes tables and their columns, indexes, views and triggers. SQL declarations retain the original schema text. Virtual and shadow tables are identified as such; their supported metadata coverage is -reported. Schema inventory does not execute views, triggers or arbitrary SQL. +reported. If a virtual table cannot be resolved, possible backing tables have +`kind: "unknown"` and an explicit limitation; their rows are refused. Unrelated +ordinary tables remain selectable. Schema inventory does not execute views, +triggers or arbitrary SQL. Rows are optional. `table` selects one exact ordinary-table name. `row_limit` requires `table`, defaults to 100 and accepts 1 through 1000. The result reports diff --git a/src/domain/sqlite/sqliteDatabase.ts b/src/domain/sqlite/sqliteDatabase.ts index e6597325055..39edf947f92 100644 --- a/src/domain/sqlite/sqliteDatabase.ts +++ b/src/domain/sqlite/sqliteDatabase.ts @@ -84,7 +84,11 @@ export const sqliteDatabaseSchema = z name: z.string(), sql: z.string().nullable(), root_page: nonnegative, - kind: z.enum(["table", "virtual", "shadow"]), + kind: z + .enum(["table", "virtual", "shadow", "unknown"]) + .describe( + "Unknown means a possible shadow table of an unresolved virtual table; its rows cannot be inspected", + ), without_rowid: z.boolean(), strict: z.boolean(), columns_completeness: z.enum(["complete", "unsupported"]), diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index 6ab52c62065..9095bffd33e 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -18,18 +18,16 @@ const natural = z .transform(Number); const schemaRow = z.object({ type: z.enum(["table", "index", "view", "trigger"]), - name: z.string(), - tbl_name: z.string(), + name: z.instanceof(Uint8Array), + tbl_name: z.instanceof(Uint8Array), rootpage: natural, - sql: z.string().nullable(), - name_hex: z.string(), - table_hex: z.string(), - sql_hex: z.string().nullable(), + sql: z.instanceof(Uint8Array).nullable(), }); const tableRow = z.object({ schema: z.string(), name: z.string(), type: z.enum(["table", "view", "virtual", "shadow"]), + ncol: natural, wr: z.bigint(), strict: z.bigint(), }); @@ -99,6 +97,9 @@ class OutputBudget { } const quoteIdentifier = (name: string): string => `"${name.replaceAll('"', '""')}"`; +// SQLite folds only ASCII letters when matching virtual/shadow table names. +const sqliteIdentifierKey = (name: string): string => + name.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); const textEncoding = (encoding: DatabaseEncoding): string => encoding === "UTF-8" ? "utf-8" @@ -106,33 +107,21 @@ const textEncoding = (encoding: DatabaseEncoding): string => ? "utf-16le" : "utf-16be"; -const validateSchemaText = ( - value: z.output, +const decodeSchemaText = ( + value: Uint8Array, encoding: DatabaseEncoding, -): void => { - const decoder = new TextDecoder(textEncoding(encoding), { - fatal: true, - ignoreBOM: true, - }); - for (const [label, text, hex] of [ - ["name", value.name, value.name_hex], - ["table", value.tbl_name, value.table_hex], - ["sql", value.sql, value.sql_hex], - ] as const) { - if (text === null && hex === null) continue; - try { - if ( - text === null || - hex === null || - decoder.decode(Buffer.from(hex, "hex")) !== text - ) - throw new Error("text/bytes mismatch"); - } catch (cause: unknown) { - throw new SqliteInspectionFailure( - "format", - `SQLite schema ${label} contains malformed or lossy decoded ${encoding} text: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } + label: string, +): string => { + try { + return new TextDecoder(textEncoding(encoding), { + fatal: true, + ignoreBOM: true, + }).decode(value); + } catch (cause: unknown) { + throw new SqliteInspectionFailure( + "format", + `SQLite schema ${label} contains malformed ${encoding} text: ${cause instanceof Error ? cause.message : String(cause)}`, + ); } }; @@ -218,6 +207,12 @@ const readSchema = ( metadata.set(value.name, value); } } + const unresolvedVirtualTables = [...metadata.values()] + .filter((value) => value.type === "virtual" && value.ncol === 0) + .map((value) => ({ + name: value.name, + prefix: sqliteIdentifierKey(`${value.name}_`), + })); const schema: SqliteDatabase["schema"] = { completeness: "complete", tables: [], @@ -225,13 +220,23 @@ const readSchema = ( views: [], triggers: [], }; + // Raw bytes preserve text without hex expansion; avoid sorter records that + // duplicate large names or definitions beyond SQLite's native record limit. for (const raw of db .prepare( - "SELECT type, name, tbl_name, rootpage, sql, hex(CAST(name AS BLOB)) AS name_hex, hex(CAST(tbl_name AS BLOB)) AS table_hex, CASE WHEN sql IS NULL THEN NULL ELSE hex(CAST(sql AS BLOB)) END AS sql_hex FROM main.sqlite_schema ORDER BY type, name", + "SELECT type, CAST(name AS BLOB) AS name, CAST(tbl_name AS BLOB) AS tbl_name, rootpage, CAST(sql AS BLOB) AS sql FROM main.sqlite_schema", ) .iterate()) { - const value = schemaRow.parse(raw); - validateSchemaText(value, encoding); + const parsed = schemaRow.parse(raw); + const value = { + ...parsed, + name: decodeSchemaText(parsed.name, encoding, "name"), + tbl_name: decodeSchemaText(parsed.tbl_name, encoding, "table"), + sql: + parsed.sql === null + ? null + : decodeSchemaText(parsed.sql, encoding, "sql"), + }; if (value.type === "table") { const listed = metadata.get(value.name); if (listed === undefined || listed.type === "view") @@ -239,16 +244,27 @@ const readSchema = ( "format", `SQLite schema/table inventory mismatch: ${value.name}`, ); + const identifier = sqliteIdentifierKey(value.name); + const possibleShadowParent = + listed.type === "table" + ? unresolvedVirtualTables.find((parent) => + identifier.startsWith(parent.prefix), + ) + : undefined; const table: SqliteDatabase["schema"]["tables"][number] = { name: value.name, sql: value.sql, root_page: value.rootpage, - kind: listed.type, + kind: possibleShadowParent === undefined ? listed.type : "unknown", without_rowid: listed.wr !== 0n, strict: listed.strict !== 0n, columns_completeness: "complete", columns: [], }; + if (possibleShadowParent !== undefined) + limitations.push( + `SQLite could not resolve virtual table ${possibleShadowParent.name}. Table ${value.name} may be its shadow table; its kind is unknown and row inspection is unavailable.`, + ); budget.retain(table); if (listed.type === "virtual") { table.columns_completeness = "unsupported"; @@ -300,6 +316,11 @@ const readRows = ( "selection", `Selected ordinary table does not exist with that exact name: ${input.table}`, ); + if (table.kind === "unknown") + throw new SqliteInspectionFailure( + "selection", + `SQLite cannot determine whether selected table ${input.table} is a shadow table of an unresolved virtual table. Row inspection requires a confirmed ordinary table.`, + ); if (table.kind !== "table") throw new SqliteInspectionFailure( "selection", diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index 7f0e73cb2b7..2a1b4a24906 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -222,7 +222,7 @@ cliTest( ); cliTest( - "returns actionable public errors for corrupt input, unknown tables and invalid record choices", + "returns actionable public errors for corrupt input, unknown or ambiguous tables and invalid record choices", async ({ cli }) => { const root = await createTestTempDirectory("rea-sqlite-errors-public-"); const path = join(root, "invalid.db"); @@ -240,10 +240,48 @@ cliTest( const valid = join(root, "valid.db"); const database = new DatabaseSync(valid); try { - database.exec("CREATE TABLE present(value TEXT);"); + database.exec( + "CREATE TABLE present(value TEXT); CREATE VIRTUAL TABLE search USING fts5(body);", + ); } finally { database.close(); } + const bytes = await readFile(valid); + const declaration = bytes.indexOf(Buffer.from("USING fts5")); + if (declaration < 0) throw new Error("Virtual declaration missing"); + bytes.write("zzzz", declaration + Buffer.byteLength("USING "), "utf8"); + await writeFile(valid, bytes); + const schema = await call("inspect_sqlite_database", { path: valid }); + expect(schema.isError).not.toBe(true); + expect(schema.structuredContent).toMatchObject({ + normalized_result: { + schema: { + tables: expect.arrayContaining([ + expect.objectContaining({ name: "search_data", kind: "unknown" }), + ]), + }, + }, + }); + const ambiguous = await cli.run({ + arguments: [ + "inspect-sqlite-database", + valid, + "--table", + "search_data", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(ambiguous.exitCode).toBe(1); + expect(ambiguous.stdout).toContain("shadow table"); + expect( + ( + await call("inspect_sqlite_database", { + path: valid, + table: "search_data", + }) + ).isError, + ).toBe(true); const missing = await cli.run({ arguments: [ "inspect-sqlite-database", diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index 2404dde9b2d..a5411908e04 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -1,4 +1,5 @@ import { DatabaseSync } from "node:sqlite"; +import { readFile, writeFile } from "node:fs/promises"; import { expect, it, onTestFinished } from "vitest"; import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; import { SqliteInspectionFailure } from "../../../src/sqlite/SqliteDatabaseLimits.js"; @@ -55,6 +56,105 @@ it.each(["table", "view"] as const)( }, ); +it.each([6, 9])( + "preserves a %i MiB schema statement without expanding its native record", + async (mebibytes) => { + const trigger = `CREATE TRIGGER large_definition AFTER INSERT ON records BEGIN /*${"x".repeat(mebibytes * 1024 * 1024)}*/ SELECT 1; END`; + const path = await createDatabase(` + CREATE TABLE records(value TEXT); + ${trigger}; + `); + const result = inspectSqliteDatabaseSnapshot(path, { path }); + expect(result.schema.triggers).toEqual([ + { name: "large_definition", table: "records", sql: trigger }, + ]); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan( + 16 * 1024 * 1024, + ); + }, +); + +it("inspects a large schema identifier without expanding a native sort record", async () => { + const name = "x".repeat(4 * 1024 * 1024); + const sql = `CREATE TABLE "${name}" (value TEXT)`; + const path = await createDatabase(sql); + const result = inspectSqliteDatabaseSnapshot(path, { path }); + expect(result.schema.tables).toMatchObject([ + { name, sql, columns: [{ name: "value", declared_type: "TEXT" }] }, + ]); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan( + 16 * 1024 * 1024, + ); +}); + +it.each(["UTF-8", "UTF-16le", "UTF-16be"])( + "preserves exact %s schema text when decoding native bytes", + async (encoding) => { + const sql = 'CREATE TABLE "\uFEFFé𝄞" ("列" TEXT DEFAULT \'\uFEFFvalue\')'; + const path = await createDatabase(`PRAGMA encoding='${encoding}'; ${sql}`); + const result = inspectSqliteDatabaseSnapshot(path, { path }); + expect(result.schema.tables).toMatchObject([ + { + name: "\uFEFFé𝄞", + sql, + columns: [{ name: "列", default_sql: "'\uFEFFvalue'" }], + }, + ]); + }, +); + +it("rejects malformed schema bytes instead of returning replacement text", async () => { + const path = await createDatabase( + "CREATE TABLE records(value TEXT /*schema-marker*/)", + ); + const bytes = await readFile(path); + const marker = bytes.indexOf(Buffer.from("schema-marker")); + if (marker < 0) throw new Error("Schema marker missing"); + bytes[marker] = 0xff; + await writeFile(path, bytes); + expect(() => inspectSqliteDatabaseSnapshot(path, { path })).toThrow( + /schema sql contains malformed UTF-8 text/, + ); +}); + +it("refuses possible shadow tables when their virtual module is unavailable", async () => { + const path = await createDatabase(` + CREATE VIRTUAL TABLE "SeArCh_É" USING fts5(body); + INSERT INTO "SeArCh_É" VALUES ('retained'); + CREATE TABLE "search_É_manual" (value TEXT); + CREATE TABLE "search_é_manual" (value TEXT); + INSERT INTO "search_é_manual" VALUES ('distinct Unicode name'); + CREATE TABLE "SeArCh_Élite" (value TEXT); + `); + const bytes = await readFile(path); + const declaration = bytes.indexOf(Buffer.from("USING fts5")); + if (declaration < 0) throw new Error("Virtual declaration missing"); + bytes.write("zzzz", declaration + Buffer.byteLength("USING "), "utf8"); + await writeFile(path, bytes); + + for (const table of ["SeArCh_É_data", "search_É_manual"]) + expect(() => inspectSqliteDatabaseSnapshot(path, { path, table })).toThrow( + /cannot determine.*shadow table/i, + ); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "search_é_manual", + }); + expect(result.schema.tables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "SeArCh_É", kind: "virtual" }), + expect.objectContaining({ name: "SeArCh_É_data", kind: "unknown" }), + expect.objectContaining({ name: "search_É_manual", kind: "unknown" }), + expect.objectContaining({ name: "search_é_manual", kind: "table" }), + expect.objectContaining({ name: "SeArCh_Élite", kind: "table" }), + ]), + ); + expect(result.rows).toMatchObject({ + values: [[{ type: "text", value: "distinct Unicode name" }]], + }); + expect(result.limitations.join("\n")).toMatch(/shadow table/i); +}); + it("rejects an oversized generated cell through SQLite's native value limit", async () => { const path = await createDatabase(` CREATE TABLE records ( From f3212fbf23b242ec8681d31004fab8d8292cc8fb Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:04:28 -0400 Subject: [PATCH 04/10] fix(ci): complete the packaged managed-native fixture Declare unavailable function-body ranges and absent native API/value-flow evidence explicitly. The package fixture must satisfy the stricter function dossier contract now on main. Reproduced the CI rejection against the PR merge commit, then verified the same managed CLI workflow passes with the completed fixture. --- scripts/lib/verify-package-core.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/lib/verify-package-core.mjs b/scripts/lib/verify-package-core.mjs index 417cc14a1c7..780904ce24d 100644 --- a/scripts/lib/verify-package-core.mjs +++ b/scripts/lib/verify-package-core.mjs @@ -82,6 +82,10 @@ export const functionDossier = (name) => { thunk_target: null, provenance: "synthetic-provider", }, + body: { + available: false, + reason: "Synthetic provider does not supply function body ranges.", + }, signature: null, locals: [], }, @@ -95,6 +99,8 @@ export const functionDossier = (name) => { referenced_strings: [], referenced_names: [], basic_blocks: [], + native_api: null, + native_value_flow: null, limitations: [], }; }; From a62af04001cdeab4c70706e4cef03becef730a2e Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:32:30 -0400 Subject: [PATCH 05/10] fix(sqlite): accept inactive journals and preserve acquisition failures Accept stable empty or fully zeroed rollback journal headers left by clean TRUNCATE/PERSIST commits, while refusing active, malformed, symlinked or changing journals. Bind each member read to the original file-set observation and report disappearance or replacement as changed evidence. Separate schema metadata preflight accounting from the final reply budget so names are not charged twice. Add real SQLite, filesystem-race and CLI/MCP regressions. Verified 49 focused tests, all static checks, runtime build and generated docs. --- docs/sqlite-databases.md | 4 + src/sqlite/SqliteDatabaseInspection.ts | 5 +- src/sqlite/SqliteDatabaseSnapshot.ts | 126 ++++++++++++- tests/acceptance/sqliteDatabaseCli.test.ts | 7 +- .../sqlite/sqliteDatabaseInspection.test.ts | 18 ++ .../sqlite/sqliteDatabaseSnapshot.test.ts | 167 +++++++++++++++++- 6 files changed, 313 insertions(+), 14 deletions(-) diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index fbc7cbdb5dc..08322cd50bc 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -67,6 +67,10 @@ temporary directory before SQLite opens them. Source files are not opened by SQLite, so its locks or shared-memory sidecars cannot modify the original snapshot. Temporary copies and the owned worker are cleaned up on success, failure and cancellation. Extension loading and schema trust are disabled. +Empty rollback journals and fully zeroed journal headers left by successful +`TRUNCATE` and `PERSIST` commits are accepted after stable identity checks. +Other retained rollback journals are refused because recovery is outside this +profile. Journal files are never modified or copied into the worker snapshot. The database and WAL together are limited to 256 MiB. SQLite values/records, SQL statements and the complete worker JSON reply each have a 16 MiB limit. diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index 9095bffd33e..cc0207eac5c 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -200,10 +200,13 @@ const readSchema = ( ): SqliteDatabase["schema"] => { const { budget, limitations, encoding } = context; const metadata = new Map>(); + // Names bound the minimum eventual schema output while metadata is collected. + // The full schema objects below account for them in the actual reply budget. + const metadataBudget = new OutputBudget(); for (const raw of db.prepare("PRAGMA main.table_list").iterate()) { const value = tableRow.parse(raw); if (value.schema === "main") { - budget.retain(value.name); + metadataBudget.retain(value.name); metadata.set(value.name, value); } } diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts index e4b9b1565c1..251d6dc73b7 100644 --- a/src/sqlite/SqliteDatabaseSnapshot.ts +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -1,5 +1,5 @@ import { lstat, open, writeFile } from "node:fs/promises"; -import type { BigIntStats } from "node:fs"; +import { constants, type BigIntStats } from "node:fs"; import { join } from "node:path"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { @@ -37,6 +37,108 @@ const sameState = ( before.mtimeNs === after.mtimeNs && before.ctimeNs === after.ctimeNs; +const duringAcquisition = async ( + path: string, + read: () => Promise, +): Promise => { + try { + return await read(); + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code)) + ) + throw new ArtifactReaderFailure( + "integrity", + `SQLite snapshot file set changed during acquisition: ${path}`, + { cause }, + ); + throw cause; + } +}; + +const readObservedArtifact = ( + path: string, + observed: BigIntStats, + maximumBytes: number, + selectedFileSystem: StableArtifactFileSystem, + signal?: AbortSignal, +) => + duringAcquisition(path, () => + readStableArtifact(path, maximumBytes, signal, { + open: (selectedPath, flags) => + selectedFileSystem.open(selectedPath, flags), + lstat: async (selectedPath) => { + const current = await selectedFileSystem.lstat(selectedPath); + if (!sameState(observed, current)) + throw new ArtifactReaderFailure( + "integrity", + `SQLite snapshot file set changed during acquisition: ${selectedPath}`, + ); + return current; + }, + }), + ); + +const verifyInactiveJournal = async ( + path: string, + before: BigIntStats, + selectedFileSystem: StableArtifactFileSystem, + signal?: AbortSignal, +): Promise => { + if (!before.isFile() || before.isSymbolicLink()) + throw new ArtifactReaderFailure( + "path", + `Expected a regular journal without a symlink: ${path}`, + ); + const file = await duringAcquisition(path, () => + selectedFileSystem.open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ), + ); + try { + if (!sameState(before, await file.stat({ bigint: true }))) + throw new ArtifactReaderFailure( + "integrity", + `SQLite journal changed before open: ${path}`, + ); + // TRUNCATE commits leave an empty file; PERSIST commits zero the complete + // 28-byte fixed header. A new active journal may zero only its magic bytes. + const header = Buffer.alloc(28); + let bytesRead = 0; + if (before.size > 0n) { + while (bytesRead < header.length) { + signal?.throwIfAborted(); + const read = await file.read( + header, + bytesRead, + header.length - bytesRead, + bytesRead, + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + } + if (!sameState(before, await file.stat({ bigint: true }))) + throw new ArtifactReaderFailure( + "integrity", + `SQLite journal changed during inspection: ${path}`, + ); + if ( + before.size !== 0n && + (bytesRead !== header.length || header.some((byte) => byte !== 0)) + ) + throw new ArtifactReaderFailure( + "format", + `Rollback journal may require recovery: ${path}. Supply a clean offline snapshot; only empty journals or a fully zeroed header are supported.`, + ); + } finally { + await file.close(); + } +}; + /** Freeze a stable offline file set without ever opening the source through SQLite. */ export const captureSqliteDatabaseSnapshot = async ( path: string, @@ -59,9 +161,11 @@ export const captureSqliteDatabaseSnapshot = async ( `Selected SQLite database does not exist: ${path}`, ); if (before[2] !== null) - throw new ArtifactReaderFailure( - "format", - `Rollback journal present: ${path}-journal. Supply a clean offline snapshot; rollback recovery is outside this inspection profile.`, + await verifyInactiveJournal( + `${path}-journal`, + before[2], + selectedFileSystem, + signal, ); const selectedSize = database.size + (wal?.size ?? 0n); if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes)) @@ -70,11 +174,12 @@ export const captureSqliteDatabaseSnapshot = async ( `Selected SQLite database and WAL exceed the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte snapshot budget: ${path}`, ); const snapshotPath = join(root, "database.snapshot"); - const captured = await readStableArtifact( + const captured = await readObservedArtifact( path, + database, SQLITE_DATABASE_LIMITS.inputBytes, - signal, selectedFileSystem, + signal, ); await writeFile(snapshotPath, captured.bytes, { flag: "wx", @@ -88,11 +193,12 @@ export const captureSqliteDatabaseSnapshot = async ( }; let walArtifact: typeof artifact | null = null; if (wal !== null) { - const capturedWal = await readStableArtifact( + const capturedWal = await readObservedArtifact( `${path}-wal`, + wal, SQLITE_DATABASE_LIMITS.inputBytes - artifact.bytes, - signal, selectedFileSystem, + signal, ); await writeFile(`${snapshotPath}-wal`, capturedWal.bytes, { flag: "wx", @@ -106,7 +212,9 @@ export const captureSqliteDatabaseSnapshot = async ( }; } const after = await Promise.all( - paths.map((path) => optionalStat(path, selectedFileSystem)), + paths.map((path) => + duringAcquisition(path, () => optionalStat(path, selectedFileSystem)), + ), ); for (const [index, expected] of before.entries()) if (!sameState(expected, after[index] ?? null)) diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index 2a1b4a24906..1380cd6d7da 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -24,13 +24,14 @@ const sourceState = async (path: string) => { }; }; -cliTest( - "inspects a real SQLite schema and lossless selected records identically through CLI and MCP", - async ({ cli }) => { +cliTest.for(["DELETE", "PERSIST", "TRUNCATE"])( + "inspects a real %s SQLite schema and lossless selected records identically through CLI and MCP", + async (journalMode, { cli }) => { const root = await createTestTempDirectory("rea-sqlite-public-"); const path = join(root, "selected.db"); const database = new DatabaseSync(path); try { + database.exec(`PRAGMA journal_mode=${journalMode}`); database.exec(` CREATE TABLE "odd "" table" ( id INTEGER PRIMARY KEY, diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index a5411908e04..7153377d7a2 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -103,6 +103,24 @@ 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 }); + 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("rejects malformed schema bytes instead of returning replacement text", async () => { const path = await createDatabase( "CREATE TABLE records(value TEXT /*schema-marker*/)", diff --git a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts index 71eae57d9bd..6b65e497ad5 100644 --- a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts @@ -1,7 +1,19 @@ -import { lstat, open, readFile, utimes, writeFile } from "node:fs/promises"; +import { + lstat, + open, + readFile, + rename, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; import { expect, it, onTestFinished } from "vitest"; import type { StableArtifactFileSystem } from "../../../src/artifacts/readStableArtifact.js"; import { captureSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseSnapshot.js"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; +import { sqliteDatabaseFailure } from "../../../src/sqlite/SqliteDatabaseFailures.js"; import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js"; import { createTestWorkspace, @@ -20,6 +32,159 @@ const fixture = async (wal = false) => { return { workspace, database, root }; }; +it.each(["PERSIST", "TRUNCATE"])( + "inspects a committed %s journal without modifying the source file set", + async (mode) => { + const { database, root } = await fixture(); + const writer = new DatabaseSync(database.path); + try { + writer.exec( + `PRAGMA journal_mode=${mode}; INSERT INTO records(id, text_value) VALUES (4, 'committed');`, + ); + } finally { + writer.close(); + } + const journalPath = `${database.path}-journal`; + const before = await readFile(journalPath); + const beforeStat = await lstat(journalPath, { bigint: true }); + const snapshot = await captureSqliteDatabaseSnapshot(database.path, root); + const result = inspectSqliteDatabaseSnapshot(snapshot.snapshotPath, { + path: database.path, + table: "records", + }); + expect(result.rows).toMatchObject({ returned_rows: 4, truncated: false }); + expect(await readFile(journalPath)).toEqual(before); + const afterStat = await lstat(journalPath, { bigint: true }); + expect([ + afterStat.ino, + afterStat.size, + afterStat.mtimeNs, + afterStat.ctimeNs, + ]).toEqual([ + beforeStat.ino, + beforeStat.size, + beforeStat.mtimeNs, + beforeStat.ctimeNs, + ]); + }, +); + +it.runIf(process.platform !== "win32").each([ + ["database", "remove"], + ["WAL", "remove"], + ["database", "replace with symlink"], + ["WAL", "replace with symlink"], +])( + "reports an observed %s change during acquisition as changed evidence: %s", + async (member, change) => { + const { database, workspace, root } = await fixture(true); + const replacement = await workspace.write("replacement", ""); + const selectedPath = + member === "database" ? database.path : database.walPath; + let observations = 0; + const fileSystem: StableArtifactFileSystem = { + lstat: async (path) => { + if (path === selectedPath && ++observations === 2) { + await unlink(path); + if (change === "replace with symlink") + await symlink(replacement, path); + } + return lstat(path, { bigint: true }); + }, + open, + }; + try { + await captureSqliteDatabaseSnapshot( + database.path, + root, + undefined, + fileSystem, + ); + throw new Error("A disappearing source must be rejected"); + } catch (cause) { + expect( + sqliteDatabaseFailure(cause, database.path, "artifact-read"), + ).toMatchObject({ _tag: "AnalysisArtifactChangedError" }); + expect(cause).toMatchObject({ + message: expect.stringContaining(selectedPath), + }); + } + }, +); + +it("refuses an active journal even before SQLite writes its magic header", async () => { + const { database, root } = await fixture(); + const writer = new DatabaseSync(database.path); + try { + writer.exec( + "PRAGMA journal_mode=PERSIST; BEGIN IMMEDIATE; INSERT INTO records(id, text_value) VALUES (4, 'uncommitted');", + ); + const journalPath = `${database.path}-journal`; + const journal = await readFile(journalPath); + expect(journal.subarray(0, 8).every((byte) => byte === 0)).toBe(true); + expect(journal.subarray(8, 28).some((byte) => byte !== 0)).toBe(true); + const original = await readFile(database.path); + await expect( + captureSqliteDatabaseSnapshot(database.path, root), + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining("recovery"), + }); + expect(await readFile(database.path)).toEqual(original); + expect(await readFile(journalPath)).toEqual(journal); + } finally { + writer.exec("ROLLBACK"); + writer.close(); + } +}); + +it("refuses an incomplete zeroed journal header", async () => { + const { database, root } = await fixture(); + await writeFile(`${database.path}-journal`, Buffer.alloc(27)); + await expect( + captureSqliteDatabaseSnapshot(database.path, root), + ).rejects.toMatchObject({ reason: "format" }); +}); + +it.runIf(process.platform !== "win32")( + "refuses a journal symlink even when its target is empty", + async () => { + const { database, workspace, root } = await fixture(); + const empty = await workspace.write("empty-journal", ""); + await symlink(empty, `${database.path}-journal`); + await expect( + captureSqliteDatabaseSnapshot(database.path, root), + ).rejects.toMatchObject({ reason: "path" }); + }, +); + +it.each(["activate", "replace", "remove"])( + "rejects a cold journal changed during database acquisition: %s", + async (change) => { + const { database, root } = await fixture(); + const journalPath = `${database.path}-journal`; + await writeFile(journalPath, Buffer.alloc(28)); + const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open: async (path, flags) => { + if (path === database.path) { + if (change === "activate") + await writeFile(journalPath, Buffer.alloc(28, 1)); + else if (change === "remove") await unlink(journalPath); + else { + await rename(journalPath, `${journalPath}.previous`); + await writeFile(journalPath, Buffer.alloc(28)); + } + } + return open(path, flags); + }, + }; + await expect( + captureSqliteDatabaseSnapshot(database.path, root, undefined, fileSystem), + ).rejects.toMatchObject({ reason: "integrity" }); + }, +); + it("rejects a database changed after its copy but before WAL acquisition", async () => { const { database, root } = await fixture(true); const initial = await readFile(database.path); From 878b873799d0b0d4a541b77cc85488823d0e89dd Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:34:32 -0400 Subject: [PATCH 06/10] fix(sqlite): preserve negative zero in JSON evidence Encode REAL negative zero as the explicit string -0 alongside the existing infinity representations, preventing JSON serialization from silently changing the observed value to positive zero. Update the output contract and documentation, add a real SQLite regression that distinguishes REAL -0, REAL 0 and INTEGER 0, and extend CLI/MCP acceptance fixtures across rollback journal modes. --- docs/sqlite-databases.md | 3 ++- src/domain/sqlite/sqliteDatabase.ts | 4 ++-- src/sqlite/SqliteDatabaseInspection.ts | 4 +++- tests/acceptance/sqliteDatabaseCli.test.ts | 19 +++++++++++++++--- .../sqlite/sqliteDatabaseInspection.test.ts | 20 +++++++++++++++++++ 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index 08322cd50bc..25966d97002 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -49,7 +49,8 @@ order is unspecified; a truncated sample does not prove anything about omitted records. View, virtual-table and shadow-table row reads are unsupported. SQLite integers are decimal strings so every signed 64-bit value survives JSON -serialization. Real values retain their numbers or explicit infinite values. +serialization. Real values retain their numbers; `"Infinity"`, `"-Infinity"` +and `"-0"` explicitly preserve infinities and negative zero through JSON. Text returns the decoded string together with its bytes as hex and the database encoding, preserving malformed text that the SQLite binding may replace for display. Blobs are hex, and SQL NULL is a distinct tagged value. diff --git a/src/domain/sqlite/sqliteDatabase.ts b/src/domain/sqlite/sqliteDatabase.ts index 39edf947f92..ce261b899c4 100644 --- a/src/domain/sqlite/sqliteDatabase.ts +++ b/src/domain/sqlite/sqliteDatabase.ts @@ -47,7 +47,7 @@ export const inspectSqliteDatabaseInputSchema = z }) .meta({ dependentRequired: { row_limit: ["table"] } }); -/** JSON-safe SQLite cells retain integers, binary content and nonfinite REAL values. */ +/** JSON-safe SQLite cells retain integers, binary content and special REAL values. */ export const sqliteValueSchema = z.discriminatedUnion("type", [ z.strictObject({ type: z.literal("null") }), z.strictObject({ @@ -56,7 +56,7 @@ export const sqliteValueSchema = z.discriminatedUnion("type", [ }), z.strictObject({ type: z.literal("real"), - value: z.union([z.number(), z.enum(["Infinity", "-Infinity"])]), + value: z.union([z.number(), z.enum(["Infinity", "-Infinity", "-0"])]), }), z.strictObject({ type: z.literal("text"), diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index cc0207eac5c..5dad30b3f86 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -140,7 +140,9 @@ const cell = ( ? "Infinity" : value === -Infinity ? "-Infinity" - : value, + : Object.is(value, -0) + ? "-0" + : value, }; const hex = value.slice(1).toLowerCase(); if (!/^(?:[0-9a-f]{2})*$/.test(hex)) diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index 1380cd6d7da..d0a1e65379d 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -39,6 +39,7 @@ cliTest.for(["DELETE", "PERSIST", "TRUNCATE"])( amount REAL, missing TEXT, text_value TEXT, + negative_zero, generated TEXT GENERATED ALWAYS AS (hex(payload)) VIRTUAL ); CREATE UNIQUE INDEX "quoted "" index" ON "odd "" table"(payload, id); @@ -47,10 +48,20 @@ cliTest.for(["DELETE", "PERSIST", "TRUNCATE"])( CREATE TABLE keyed(k TEXT PRIMARY KEY) WITHOUT ROWID, STRICT; `); const insert = database.prepare( - 'INSERT INTO "odd "" table"(id,payload,amount,missing,text_value) VALUES(?,?,?,NULL,CAST(X\'80ff\' AS TEXT))', + 'INSERT INTO "odd "" table"(id,payload,amount,missing,text_value,negative_zero) VALUES(?,?,?,NULL,CAST(X\'80ff\' AS TEXT),?)', + ); + insert.run( + 9007199254740993n, + Buffer.from("deadbeef", "hex"), + Infinity, + -0, + ); + insert.run( + 9007199254740994n, + Buffer.from("deadbeef", "hex"), + Infinity, + -0, ); - insert.run(9007199254740993n, Buffer.from("deadbeef", "hex"), Infinity); - insert.run(9007199254740994n, Buffer.from("deadbeef", "hex"), Infinity); } finally { database.close(); } @@ -124,6 +135,7 @@ cliTest.for(["DELETE", "PERSIST", "TRUNCATE"])( "amount", "missing", "text_value", + "negative_zero", "generated", ], returned_rows: 1, @@ -139,6 +151,7 @@ cliTest.for(["DELETE", "PERSIST", "TRUNCATE"])( { type: "real", value: "Infinity" }, { type: "null" }, { type: "text", value: "\ufffd\ufffd", bytes_hex: "80ff" }, + { type: "real", value: "-0" }, { type: "text", value: "DEADBEEF", diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index 7153377d7a2..d165ef48edf 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -21,6 +21,26 @@ const createDatabase = async (sql: string): Promise => { return path; }; +it("preserves negative-zero REAL values through JSON separately from positive and integer zero", async () => { + const path = await createDatabase(` + CREATE TABLE numbers(value); + INSERT INTO numbers VALUES (-0.0), (0.0), (0), (5e-324), (-5e-324); + `); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "numbers", + }); + expect(JSON.parse(JSON.stringify(result.rows))).toMatchObject({ + values: [ + [{ type: "real", value: "-0" }], + [{ type: "real", value: 0 }], + [{ type: "integer", value: "0" }], + [{ type: "real", value: Number.MIN_VALUE }], + [{ type: "real", value: -Number.MIN_VALUE }], + ], + }); +}); + it.each(["table", "view"] as const)( "inspects columns when a %s shadows the table-valued PRAGMA name", async (kind) => { From a9ce5887dba17fbdee7701d3ac067dc916f6de4d Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:40:04 -0400 Subject: [PATCH 07/10] fix(sqlite): retain worker and transport cleanup owners Retain failed SQLite worker supervisors before attempting snapshot removal. Retry process cleanup before releasing the database/WAL runtime root, and keep failed MCP server instances reachable after SDK shutdown or stdin EOF. Add regressions with a real owned child and the actual SDK stdio transport, including failure followed by successful cleanup and completed-owner release. Validation: 62 focused tests passed on Node 24.18.0 (one unsupported-runtime test skipped), 18 cleanup/shutdown tests passed on Node 22.23.1; build, typecheck, lint, formatting and diff checks passed. --- src/main/transport.ts | 52 +++++++-- src/sqlite/SqliteDatabaseProvider.ts | 47 +++++++- tests/boundary/mcp/serverLifecycleMcp.test.ts | 104 ++++++++++++++++++ .../sqlite/sqliteDatabaseCleanup.test.ts | 99 +++++++++++++++++ 4 files changed, 294 insertions(+), 8 deletions(-) diff --git a/src/main/transport.ts b/src/main/transport.ts index f8d6014f3cb..93754c90de5 100644 --- a/src/main/transport.ts +++ b/src/main/transport.ts @@ -55,6 +55,17 @@ export const startMcpTransport = async ( ); } const androidProviders: AndroidAnalysisPort[] = []; + const servers = new Set>(); + const closeServers = async (): Promise => { + const results = await Promise.allSettled( + [...servers].map((server) => server.close()), + ); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) + throw new AggregateError(failures, "REA server cleanup failed"); + }; const closeAndroid = async (): Promise => { const results = await Promise.allSettled( androidProviders.map((provider) => provider.close()), @@ -71,7 +82,7 @@ export const startMcpTransport = async ( serverContext.environment, ); androidProviders.push(android); - return (dependencies.createServer ?? createServer)( + const server = (dependencies.createServer ?? createServer)( { kind: "session", session }, { logger: serverContext.logger, @@ -81,6 +92,14 @@ export const startMcpTransport = async ( androidAnalysis: android, }, ); + // The SDK can discard a server even when its provider cleanup failed. + servers.add(server); + const closeServer = server.close.bind(server); + server.close = async () => { + await closeServer(); + servers.delete(server); + }; + return server; }, { onerror: () => { @@ -90,10 +109,12 @@ export const startMcpTransport = async ( }, ); } catch (cause: unknown) { - const [binaryCleanup, androidCleanup] = await Promise.allSettled([ - session.close(), - closeAndroid(), - ]); + const [binaryCleanup, androidCleanup, serverCleanup] = + await Promise.allSettled([ + session.close(), + closeAndroid(), + closeServers(), + ]); serverLogger.error( { error: cause instanceof Error ? cause.message : String(cause), @@ -109,7 +130,7 @@ export const startMcpTransport = async ( ); dependencies.writeStderr(`${cleanupError.message}\n`); } - for (const cleanup of [binaryCleanup, androidCleanup]) { + for (const cleanup of [binaryCleanup, androidCleanup, serverCleanup]) { if (cleanup.status === "rejected") { const reason = cleanup.reason instanceof Error @@ -124,5 +145,22 @@ export const startMcpTransport = async ( } return { ok: false }; } - return { ok: true, handle, closeAndroid }; + return { + ok: true, + handle: { + close: async () => { + const results = await Promise.allSettled([ + handle.close(), + closeServers(), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, "REA transport cleanup failed"); + }, + }, + closeAndroid, + }; }; diff --git a/src/sqlite/SqliteDatabaseProvider.ts b/src/sqlite/SqliteDatabaseProvider.ts index 137c8f0edf4..3f5b96ef7d6 100644 --- a/src/sqlite/SqliteDatabaseProvider.ts +++ b/src/sqlite/SqliteDatabaseProvider.ts @@ -23,6 +23,8 @@ import { } from "../domain/sqlite/sqliteDatabase.js"; import { isAbsolute } from "node:path"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { sqliteDatabaseFailure } from "./SqliteDatabaseFailures.js"; import { SQLITE_PROVIDER_IDENTITY } from "./SqliteDatabaseLimits.js"; import { captureSqliteDatabaseSnapshot } from "./SqliteDatabaseSnapshot.js"; @@ -38,6 +40,7 @@ type SqliteRuntimeRoot = Pick; interface PendingSnapshotCleanup { readonly result: SqliteOutcome; readonly output: AnalysisCapturedOutput | undefined; + process: ProviderProcessSupervisor | undefined; } /** Inspect a provider-owned copy through a cancellable SQLite child, without source writes. */ export class SqliteDatabaseProvider implements SqliteDatabasePort { @@ -97,6 +100,33 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { async #cleanupPending(): Promise> { const failures: AnalysisError[] = []; for (const [root, pending] of [...this.#pendingCleanup]) { + if (pending.process !== undefined) { + const stopped = await pending.process.stop(); + if (stopped.status === "incomplete") { + failures.push( + new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + [ + pending.process.launch.ownership?.runId ?? + "owned-sqlite-worker", + root.path, + ], + { + reason: stopped.reason, + previous_error: pending.result.ok + ? null + : projectAnalysisError(pending.result.error), + ...(pending.output === undefined + ? {} + : { captured_output: { ...pending.output } }), + }, + { operation }, + ), + ); + continue; + } + pending.process = undefined; + } const cleaned = await cleanupSqliteRoot( root, pending.result, @@ -124,6 +154,7 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { options?: ExecutionOptions, ): Promise { let root: Pick | undefined; + let processOwner: ProviderProcessSupervisor | undefined; let retainedOutput: AnalysisCapturedOutput | undefined; let phase = "configuration"; let result: Result; @@ -168,14 +199,28 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { ); result = ok(createSqliteObservation(validated.data, input.path)); } catch (cause: unknown) { + if (cause instanceof OwnedCommandFailure) + processOwner = cause.cleanupOwner; const failure = sqliteDatabaseFailure(cause, input.path, phase); retainedOutput ??= failure.capturedOutput; result = err(selectedFailure(failure, options?.signal, retainedOutput)); } if (root !== undefined) { + if (processOwner !== undefined) { + this.#pendingCleanup.set(root, { + result, + output: retainedOutput, + process: processOwner, + }); + return result; + } const cleaned = await cleanupSqliteRoot(root, result, retainedOutput); if (!cleaned.ok) { - this.#pendingCleanup.set(root, { result, output: retainedOutput }); + this.#pendingCleanup.set(root, { + result, + output: retainedOutput, + process: undefined, + }); return cleaned; } } diff --git a/tests/boundary/mcp/serverLifecycleMcp.test.ts b/tests/boundary/mcp/serverLifecycleMcp.test.ts index 05fbba4ff84..3decf0361a0 100644 --- a/tests/boundary/mcp/serverLifecycleMcp.test.ts +++ b/tests/boundary/mcp/serverLifecycleMcp.test.ts @@ -9,9 +9,113 @@ import { err } from "../../../src/domain/result.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import { createServer } from "../../../src/server/createServer.js"; import { SqliteDatabaseService } from "../../../src/application/sqlite/SqliteDatabaseService.js"; +import { PassThrough } from "node:stream"; +import { once } from "node:events"; +import { access } from "node:fs/promises"; +import { + serveStdio, + StdioServerTransport, +} from "@modelcontextprotocol/server/stdio"; +import pino from "pino"; +import { createManagedBinarySession } from "../../../src/composition/binary.js"; +import { startMcpTransport } from "../../../src/main/transport.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; const resources: Array<{ close(): Promise }> = []; +it.each(["handle", "stdin"] as const)( + "retains failed SQLite cleanup through production stdio shutdown: %s", + async (shutdown) => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-sqlite-stdio-cleanup-", + }); + let removalAllowed = false; + let attempts = 0; + const sqliteDatabase = new SqliteDatabaseService({ + inspect: async () => { + throw new Error("No inspection requested"); + }, + close: async () => { + attempts += 1; + if (!removalAllowed) throw new Error("SQLite snapshot removal denied"); + await root.close(); + }, + }); + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const logger = pino({ enabled: false }); + const session = createManagedBinarySession(); + const started = await startMcpTransport( + { + env: {}, + serve: (factory, options) => + serveStdio(factory, { + ...options, + transport: new StdioServerTransport(stdin, stdout), + }), + createServer: (source, options) => + createServer(source, { ...options, sqliteDatabase }), + writeStderr: () => undefined, + setExitCode: () => undefined, + registerShutdown: () => () => undefined, + }, + session, + { + environment: {}, + delivery: createToolResultDelivery(undefined), + logger, + serverLogger: logger, + loadOptionalProviders: async () => ({}), + }, + ); + if (!started.ok) throw new Error("Transport startup failed"); + try { + const response = once(stdout, "data"); + stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "sqlite-cleanup-test", version: "1.0.0" }, + }, + })}\n`, + ); + const [reply] = await response; + expect(JSON.parse(String(reply))).toMatchObject({ + id: 1, + result: { serverInfo: { name: "rea" } }, + }); + if (shutdown === "stdin") { + stdin.end(); + await new Promise((resolve) => setImmediate(resolve)); + } + await expect(started.handle.close()).rejects.toBeInstanceOf( + AggregateError, + ); + expect(attempts).toBeGreaterThan(0); + await expect(access(root.path)).resolves.toBeUndefined(); + removalAllowed = true; + await started.handle.close(); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + const completedAttempts = attempts; + await started.handle.close(); + expect(attempts).toBe(completedAttempts); + } finally { + removalAllowed = true; + await started.handle.close(); + await started.closeAndroid(); + await session.close(); + await root.close(); + stdin.destroy(); + stdout.destroy(); + } + }, +); + it("awaits SQLite cleanup and retries its retained owner after failed server shutdown", async () => { let removalAllowed = false; let attempts = 0; diff --git a/tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts b/tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts index a483ff9932a..67e158ab8ec 100644 --- a/tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts @@ -2,6 +2,13 @@ import { access } from "node:fs/promises"; import { expect, it, onTestFinished } from "vitest"; import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; +import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js"; +import { + spawnProviderProcessFixture, + stopProviderProcessFixture, + waitForProviderProcessReady, +} from "../../fixtures/providerProcess.js"; import { createDeferred } from "../../fixtures/binarySession.js"; import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js"; import { @@ -160,3 +167,95 @@ it("waits for concurrent acquisitions and releases only owners whose cleanup suc await provider.close(); await expect(access(second.path)).rejects.toMatchObject({ code: "ENOENT" }); }); + +it("retains the snapshot until a failed worker cleanup owner can be stopped", async () => { + const workspace = await createTestWorkspace("rea-sqlite-worker-cleanup-"); + const database = createSqliteDatabaseFixture(workspace.root); + database.close(); + const child = spawnProviderProcessFixture("graceful"); + let workerRemovalAllowed = false; + let rootRemovalAllowed = false; + let workerAttempts = 0; + let rootAttempts = 0; + const roots: PrivateRuntimeRoot[] = []; + const supervisor = new ProviderProcessSupervisor({ + process: child, + ownsProcessLifetime: true, + cleanup: async () => { + workerAttempts += 1; + if (!workerRemovalAllowed) + return { cleaned: false, reason: "SQLite worker cleanup denied" }; + await stopProviderProcessFixture(child); + return { cleaned: true, signaled: true }; + }, + }); + const provider = new SqliteDatabaseProvider( + {}, + async (spawn) => { + const stopped = await supervisor.stop(); + if (stopped.status !== "incomplete") + throw new Error("Expected retained worker"); + throw new OwnedCommandFailure( + "process", + "SQLite worker cleanup failed", + supervisor.snapshot(), + stopped.reason, + undefined, + [spawn.runId, spawn.cwd ?? ""], + supervisor, + ); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ parent: workspace.root }); + roots.push(root); + return { + path: root.path, + close: async () => { + rootAttempts += 1; + if (!rootRemovalAllowed) + throw new Error("SQLite snapshot removal denied"); + await root.close(); + }, + }; + }, + ); + onTestFinished(async () => { + workerRemovalAllowed = true; + rootRemovalAllowed = true; + await supervisor.stop(); + await provider.close(); + await Promise.all(roots.map((root) => root.close())); + await removeTestWorkspace(workspace.root); + }); + await waitForProviderProcessReady(child); + expect(await provider.inspect({ path: database.path })).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + // The snapshot cannot be removed while its worker may still use it. + expect(rootAttempts).toBe(0); + const root = roots[0]; + if (root === undefined) throw new Error("Missing retained snapshot"); + await expect(access(root.path)).resolves.toBeUndefined(); + expect(await provider.inspect({ path: database.path })).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(roots).toHaveLength(1); + expect(workerAttempts).toBe(2); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(rootAttempts).toBe(0); + workerRemovalAllowed = true; + await expect(provider.close()).rejects.toMatchObject({ + cleanupResources: [root.path], + }); + expect(child.signalCode).toBe("SIGKILL"); + expect(workerAttempts).toBe(4); + expect(rootAttempts).toBe(1); + rootRemovalAllowed = true; + await provider.close(); + expect(workerAttempts).toBe(4); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); From e59713d90fea8186f717e3595ee6448860101bc6 Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:24:55 -0400 Subject: [PATCH 08/10] fix(sqlite): preserve inspection and cancellation failures during cleanup Retain primary CLI inspection errors alongside shutdown failures, and keep reply-read cancellation typed when descriptor cleanup is incomplete. Add four real-filesystem CLI and worker boundary regressions. --- src/cli/sqliteCommands.ts | 42 ++++- src/sqlite/SqliteDatabaseWorkerExecution.ts | 19 +- .../sqliteDatabaseDescriptorCleanup.test.ts | 170 +++++++++++++++++- 3 files changed, 212 insertions(+), 19 deletions(-) diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts index a4f62038c0b..c53a5a9cdc9 100644 --- a/src/cli/sqliteCommands.ts +++ b/src/cli/sqliteCommands.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import { createSqliteDatabaseService } from "../composition/sqlite.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; @@ -38,8 +40,8 @@ export const registerSqliteCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectSqliteDatabase, async () => { - try { - const result = await service.inspect( + const inspection = await service + .inspect( { path: resolve(args.path), ...(options.table === undefined @@ -50,13 +52,41 @@ export const registerSqliteCommands = ( : { row_limit: options.rowLimit }), }, { signal }, + ) + .then( + (result) => ({ result }), + (cause: unknown) => ({ cause }), ); - return result.ok - ? result.value - : projectAnalysisError(result.error); - } finally { + try { await service.close(); + } catch (cause: unknown) { + const primary = + "cause" in inspection + ? inspection.cause + : inspection.result.ok + ? undefined + : inspection.result.error; + if ( + primary instanceof AnalysisError && + cause instanceof AnalysisError + ) + throw analysisErrorWithCleanupFailure( + primary, + cause, + "inspect_sqlite_database", + ); + if ("cause" in inspection) + throw new AggregateError( + [inspection.cause, cause], + "SQLite inspection and provider cleanup both failed", + { cause: inspection.cause }, + ); + throw cause; } + if ("cause" in inspection) throw inspection.cause; + return inspection.result.ok + ? inspection.result.value + : projectAnalysisError(inspection.result.error); }), ), }); diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index 2c9ad5a18de..9474a6d27cb 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import { + AnalysisCancelledError, AnalysisInputError, AnalysisCapabilityUnavailableError, AnalysisOutputError, @@ -148,17 +149,19 @@ export const executeSqliteDatabaseWorker = async (request: { ); reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); } catch (cause: unknown) { + const options = { + cause, + capturedOutput, + ...(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}), + }; + if (request.signal?.aborted) + throw new AnalysisCancelledError(operation, options); throw new AnalysisOutputError( operation, `Owned SQLite worker reply is missing or malformed: ${cause instanceof Error ? cause.message : String(cause)}`, - { - cause, - capturedOutput, - ...(cause instanceof ArtifactReaderFailure && - cause.cleanup !== undefined - ? { cleanup: cause.cleanup } - : {}), - }, + options, ); } if (!reply.ok) throw rejectedReply(reply, capturedOutput); diff --git a/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts index 601cb8d0dd4..f656c35f480 100644 --- a/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts @@ -1,9 +1,18 @@ +import { Cli } from "incur"; import { access, writeFile } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { expect, it, onTestFinished, vi } from "vitest"; +import { SqliteDatabaseService } from "../../../src/application/sqlite/SqliteDatabaseService.js"; +import { registerSqliteCommands } from "../../../src/cli/sqliteCommands.js"; +import { createSqliteDatabaseService } from "../../../src/composition/sqlite.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { silentLogger } from "../../../src/logger.js"; import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js"; import { createTestWorkspace, @@ -20,6 +29,10 @@ const descriptorCleanup = vi.hoisted(() => ({ onOpen: undefined as (() => void) | undefined, })); +vi.mock("../../../src/composition/sqlite.js", () => ({ + createSqliteDatabaseService: vi.fn(), +})); + vi.mock("node:fs/promises", async (importOriginal) => { const filesystem = await importOriginal(); return { @@ -54,7 +67,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { }); const fixture = async ( - member: "database" | "WAL" | "journal", + member: "database" | "WAL" | "journal" | "reply", invalidate = false, ) => { const workspace = await createTestWorkspace("rea-sqlite-descriptor-cleanup-"); @@ -64,16 +77,34 @@ const fixture = async ( ? database.path : member === "WAL" ? database.walPath - : `${database.path}-journal`; + : member === "journal" + ? `${database.path}-journal` + : ""; if (member === "journal") await writeFile(path, Buffer.alloc(28)); const roots: PrivateRuntimeRoot[] = []; const provider = new SqliteDatabaseProvider( - {}, - () => { - throw new Error("SQLite worker deliberately unavailable"); + process.env, + (spawn) => { + if (member !== "reply") + throw new Error("SQLite worker deliberately unavailable"); + return spawnOwnedProviderProcess({ + ...spawn, + arguments: spawn.arguments.map((argument, index) => + index === 1 + ? fileURLToPath( + new URL( + "../../../dist/sqlite/SqliteDatabaseWorker.js", + import.meta.url, + ), + ) + : argument, + ), + }); }, async () => { const root = await PrivateRuntimeRoot.create({ parent: workspace.root }); + if (member === "reply") + descriptorCleanup.path = join(root.path, "reply.json"); roots.push(root); return root; }, @@ -100,6 +131,30 @@ const fixture = async ( return { provider, roots, path, database }; }; +const serveInspection = async ( + provider: SqliteDatabaseProvider, + path: string, +) => { + const previousExitCode = process.exitCode; + onTestFinished(() => { + process.exitCode = previousExitCode; + }); + vi.mocked(createSqliteDatabaseService).mockReturnValue( + new SqliteDatabaseService(provider), + ); + const cli = Cli.create("rea-sqlite-cleanup-test", { sync: false }); + registerSqliteCommands(cli, silentLogger, {}); + let stdout = ""; + await cli.serve(["inspect-sqlite-database", path, "--json"], { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }); + return { output: JSON.parse(stdout) as unknown, exitCode: process.exitCode }; +}; + it.each(["database", "WAL", "journal"] as const)( "retains a failed %s descriptor and retries it at shutdown", async (member) => { @@ -139,6 +194,111 @@ it.each(["database", "WAL", "journal"] as const)( }, ); +it.each(["EACCES", "EPERM"])( + "retains the original %s inspection diagnostics through CLI shutdown", + async (code) => { + const selected = await fixture("database"); + descriptorCleanup.readFailure = Object.assign( + new Error("injected source read denial"), + { code }, + ); + const result = await serveInspection( + selected.provider, + selected.database.path, + ); + expect(result).toMatchObject({ + exitCode: 1, + output: { + code: "cleanup_incomplete", + details: { + resources: [expect.stringContaining(selected.path)], + diagnostics: { + primary_error: { + details: { + diagnostics: { + previous_error: { + code: "access_denied", + details: { path: selected.path, system_code: code }, + }, + }, + }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }, + }, + }); + expect(descriptorCleanup.attempts).toBe(2); + }, +); + +it("retains cancellation diagnostics and the interrupt exit code through CLI shutdown", async () => { + const selected = await fixture("database"); + descriptorCleanup.onOpen = () => { + const interrupt = process.listeners("SIGINT").at(-1); + if (interrupt === undefined) + throw new Error("Missing CLI interrupt handler"); + interrupt("SIGINT"); + }; + const result = await serveInspection( + selected.provider, + selected.database.path, + ); + expect(result).toMatchObject({ + exitCode: 130, + output: { + code: "cleanup_incomplete", + details: { + resources: [expect.stringContaining(selected.path)], + diagnostics: { + primary_error: { + details: { + diagnostics: { previous_error: { code: "cancelled" } }, + }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }, + }, + }); + expect(descriptorCleanup.attempts).toBeGreaterThanOrEqual(2); +}); + +it("retains reply-read cancellation and retries the failed reply descriptor close", async () => { + const selected = await fixture("reply"); + const controller = new AbortController(); + descriptorCleanup.onOpen = () => + controller.abort(new Error("requested cancellation during reply read")); + const result = await selected.provider.inspect( + { path: selected.database.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancelled reply read"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + execution_failure: "cancelled", + resources: [expect.stringContaining(descriptorCleanup.path)], + captured_output: { + stdout: "", + stderr: expect.any(String), + truncated: false, + }, + }, + }); + const handle = descriptorCleanup.handles[0]; + if (handle === undefined) throw new Error("Expected owned reply descriptor"); + expect(handle.fd).toBeGreaterThanOrEqual(0); + await expect(selected.provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + descriptorCleanup.denied = false; + await selected.provider.close(); + expect(handle.fd).toBe(-1); + for (const root of selected.roots) + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); + it("retries source cleanup before admitting another snapshot", async () => { const selected = await fixture("database"); await selected.provider.inspect({ path: selected.database.path }); From 7caf4db8ec5e4636acbe61af29048cdca61e037b Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:43:57 -0400 Subject: [PATCH 09/10] fix(sqlite): expose runtime readiness in CLI capabilities --- .github/workflows/ci.yml | 1 + docs/sqlite-databases.md | 6 +++ src/application/CapabilityInventory.ts | 11 ++-- src/application/DirectAnalysisStatus.ts | 9 +++- .../sqlite/SqliteDatabaseCapability.test.ts | 30 +++++++++++ .../sqlite/SqliteDatabaseCapability.ts | 34 ++++++++++++ tests/acceptance/sqliteDatabaseCli.test.ts | 52 +++++++++++++++++++ 7 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 src/application/sqlite/SqliteDatabaseCapability.test.ts create mode 100644 src/application/sqlite/SqliteDatabaseCapability.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31db55339f1..da370dc2202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,7 @@ jobs: - name: Verify SQLite inspection and cleanup on supported runtimes run: >- node node_modules/vitest/vitest.mjs run + src/application/sqlite/SqliteDatabaseCapability.test.ts src/application/sqlite/SqliteDatabaseService.test.ts tests/acceptance/sqliteDatabaseCli.test.ts tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index 662c0ffb5c9..f0ed9ca77df 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -10,7 +10,13 @@ This operation requires Node.js 24.x >=24.15 or 26+ with the native the copied database. Other REA features retain their published Node.js 22.x, 24.x or 26+ requirements. +`rea capabilities` reports `inspect_sqlite_database` availability and the +runtime requirement without opening a database. Unsupported runtimes show +`available: false`, `availability_code: "unsupported_version"`, and an upgrade +note while the inventory command still succeeds. + ```sh +rea capabilities --json rea inspect-sqlite-database ./application.sqlite --json rea inspect-sqlite-database ./application.sqlite --table settings --row-limit 20 --json ``` diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 03d773449a4..3ddd1a02a5f 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { satisfies } from "semver"; +import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; import type { OptionalProviderLoadFailures, OptionalProviderLoadFailure, @@ -271,14 +271,15 @@ const workflowAvailabilityFor = ({ kind, policy, }: AvailabilityContext): Availability | null => { - if (name === "inspect_sqlite_database") - return satisfies(process.versions.node, "^24.15.0 || >=26.0.0") + if (name === "inspect_sqlite_database") { + const capability = sqliteDatabaseCapability(process.versions.node); + return capability.available ? { reason: "available", remediation: null } : { reason: "provider_unavailable", - remediation: - "SQLite inspection requires Node.js 24.x >=24.15 or 26+ with the native DatabaseSync.limits API. Other REA features retain their published runtime requirements. The inspection worker verifies the API before querying the snapshot.", + remediation: capability.reason, }; + } if (name === "trace_web_module_imports") return { reason: "available", diff --git a/src/application/DirectAnalysisStatus.ts b/src/application/DirectAnalysisStatus.ts index cc6df790115..db0a220926e 100644 --- a/src/application/DirectAnalysisStatus.ts +++ b/src/application/DirectAnalysisStatus.ts @@ -4,6 +4,8 @@ import { createServerIdentity } from "../serverIdentity.js"; import { silentLogger } from "../logger.js"; import type { Logger } from "pino"; import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js"; +import { providerCapability } from "../contracts/toolOutputSchemaPrimitives.js"; +import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; /** Read target-free provider status and operation availability. */ export const runSessionStatus = async ( @@ -17,8 +19,13 @@ export const runSessionStatus = async ( if (!config.ok) return { error: projectAnalysisError(config.error) }; const session = dependencies.createBinarySession(config.value, logger); try { + const status = jsonObjectSchema.parse(session.status()); return { - ...jsonObjectSchema.parse(session.status()), + ...status, + capabilities: [ + ...providerCapability.array().parse(status.capabilities), + sqliteDatabaseCapability(process.versions.node), + ].sort((left, right) => left.operation.localeCompare(right.operation)), server_identity: createServerIdentity({ startedAt: new Date().toISOString(), }), diff --git a/src/application/sqlite/SqliteDatabaseCapability.test.ts b/src/application/sqlite/SqliteDatabaseCapability.test.ts new file mode 100644 index 00000000000..1946cf70407 --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseCapability.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { providerCapability } from "../../contracts/toolOutputSchemaPrimitives.js"; +import { sqliteDatabaseCapability } from "./SqliteDatabaseCapability.js"; + +it.each([ + ["22.23.1", false], + ["24.11.0", false], + ["24.14.9", false], + ["24.15.0", true], + ["25.1.0", false], + ["26.0.0", true], + ["24.15.0-rc.1", false], + ["invalid", false], +])( + "reports SQLite inspection availability on Node %s as %s", + (version, available) => { + const capability = providerCapability.parse( + sqliteDatabaseCapability(version), + ); + expect(capability).toMatchObject({ + operation: "inspect_sqlite_database", + available, + reason: available + ? null + : expect.stringContaining("Node.js 24.x >=24.15 or 26+"), + availability_code: available ? null : "unsupported_version", + limitations: [expect.stringContaining("DatabaseSync.limits")], + }); + }, +); diff --git a/src/application/sqlite/SqliteDatabaseCapability.ts b/src/application/sqlite/SqliteDatabaseCapability.ts new file mode 100644 index 00000000000..62e6af7eeab --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseCapability.ts @@ -0,0 +1,34 @@ +import { satisfies } from "semver"; +import { toolContract } from "../../contracts/toolContracts.js"; +import type { ProviderCapability } from "../../contracts/toolOutputSchemaPrimitives.js"; + +const SQLITE_RUNTIME_REQUIREMENT = + "SQLite inspection requires Node.js 24.x >=24.15 or 26+ with the native DatabaseSync.limits API. Other REA features retain their published runtime requirements. The inspection worker verifies the API before querying the snapshot."; + +/** Discover inspection readiness without opening a database or starting a worker. */ +export const sqliteDatabaseCapability = ( + nodeVersion: string, +): ProviderCapability => { + const contract = toolContract("inspect_sqlite_database"); + const facts = { + operation: contract.name, + effects: { + mutates_artifact: contract.effects.mutatesTarget, + launches_process: contract.effects.launchesProcess, + may_show_ui: contract.effects.changesUiState, + may_access_network: contract.effects.accessesNetwork, + may_write_filesystem: contract.effects.writesFilesystem, + changes_permissions: false, + requires_root: false, + }, + limitations: [SQLITE_RUNTIME_REQUIREMENT], + }; + return satisfies(nodeVersion, "^24.15.0 || >=26.0.0") + ? { ...facts, available: true, reason: null, availability_code: null } + : { + ...facts, + available: false, + reason: SQLITE_RUNTIME_REQUIREMENT, + availability_code: "unsupported_version", + }; +}; diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index 634e7586050..2f0bd59c16d 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -13,6 +13,58 @@ import { cliTest } from "../support/cli/cliFixture.js"; const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); +cliTest( + "discovers SQLite runtime availability before inspecting a database through CLI and MCP", + async ({ cli }) => { + const result = await cli.run({ + arguments: ["capabilities", "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toMatchObject({ + open: false, + capabilities: expect.arrayContaining([ + expect.objectContaining({ + operation: "inspect_sqlite_database", + available: SQLITE_NATIVE_LIMITS_AVAILABLE, + reason: SQLITE_NATIVE_LIMITS_AVAILABLE + ? null + : expect.stringContaining("Node.js 24.x >=24.15 or 26+"), + availability_code: SQLITE_NATIVE_LIMITS_AVAILABLE + ? null + : "unsupported_version", + limitations: [expect.stringContaining("DatabaseSync.limits")], + effects: { + mutates_artifact: false, + launches_process: true, + may_show_ui: false, + may_access_network: false, + may_write_filesystem: true, + changes_permissions: false, + requires_root: false, + }, + }), + ]), + }); + const { call } = await connectLocalToolsMcp(); + const response = await call("binary_session", {}); + expect(response.isError).not.toBe(true); + expect(response.structuredContent).toMatchObject({ + result: { + tool_availability: expect.arrayContaining([ + expect.objectContaining({ + name: "inspect_sqlite_database", + available: SQLITE_NATIVE_LIMITS_AVAILABLE, + remediation: SQLITE_NATIVE_LIMITS_AVAILABLE + ? null + : expect.stringContaining("Node.js 24.x >=24.15 or 26+"), + }), + ]), + }, + }); + }, +); + cliTest.skipIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( "reports the required native SQLite limits capability through CLI and MCP", async ({ cli }) => { From d63d1b15ca475548f408333be0268146c5ee475b Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:02:09 -0400 Subject: [PATCH 10/10] test(applications): repair Electron fixtures and catalog budget Declare inert Electron imports in preload fixtures so current main can prove API provenance. Preserve option and source-coordinate assertions. Use the configured catalog startup budget instead of the narrower per-test override under coverage contention. --- .../JavaScriptArtifactPathResolution.test.ts | 1 + .../applications/electronPreloadOverridesCli.test.ts | 11 +++++++---- tests/acceptance/applications/runtime.test.ts | 2 +- .../electronOptionKeys.test.ts | 5 ++++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 6eef8ced78b..0ff630ae48e 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -1015,6 +1015,7 @@ describe("contextual JavaScript module identity", () => { it("retains CommonJS and ESM file-base identity during inert extraction", () => { const analysis = analyzeJavaScriptStaticSource( ` + const { BrowserWindow } = require("electron"); new BrowserWindow({ webPreferences: { preload: path.resolve("ignored", __dirname, "preload.cjs") }}); diff --git a/tests/acceptance/applications/electronPreloadOverridesCli.test.ts b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts index 7997a7d8cb2..af78999d743 100644 --- a/tests/acceptance/applications/electronPreloadOverridesCli.test.ts +++ b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts @@ -67,7 +67,10 @@ cliTest.for(["CLI", "stdio MCP"] as const)( `new BrowserWindow({webPreferences:{${preferences}}});`, ); await Promise.all([ - writeFile(join(root, "main.js"), `${sourceLines.join("\n")}\n`), + writeFile( + join(root, "main.js"), + `const { BrowserWindow } = require("electron");\n${sourceLines.join("\n")}\n`, + ), writeFile(join(root, "first.js"), 'throw new Error("inert preload");\n'), writeFile(join(root, "last.js"), 'throw new Error("inert preload");\n'), ]); @@ -100,7 +103,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( return ( location?.available && location.value.kind === "source-range" && - location.value.start.line === index + 1 + location.value.start.line === index + 2 ); }); const observation = window?.observations[0]; @@ -128,9 +131,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( value: { kind: "source-range", source: "main.js", - start: { line: index + 1, column: 0 }, + start: { line: index + 2, column: 0 }, end: { - line: index + 1, + line: index + 2, column: (sourceLines[index]?.length ?? 0) - 1, }, }, diff --git a/tests/acceptance/applications/runtime.test.ts b/tests/acceptance/applications/runtime.test.ts index bfa75b72192..677aa4e5604 100644 --- a/tests/acceptance/applications/runtime.test.ts +++ b/tests/acceptance/applications/runtime.test.ts @@ -218,7 +218,7 @@ describe("production stdio runtime", () => { record.level >= 50, ); expect(fatal).toEqual([]); - }, 15_000); + }); it.runIf(process.platform === "darwin")( "serves a database-kind initial target without a fatal record on macOS", diff --git a/tests/composition/javascript-applications/electronOptionKeys.test.ts b/tests/composition/javascript-applications/electronOptionKeys.test.ts index 16c1ce2cd4f..a47bd890a73 100644 --- a/tests/composition/javascript-applications/electronOptionKeys.test.ts +++ b/tests/composition/javascript-applications/electronOptionKeys.test.ts @@ -165,7 +165,10 @@ const reconstructSource = async (source: string): Promise => { const root = await createTestTempDirectory("rea-electron-option-keys-"); await Promise.all([ writeFile(join(root, "package.json"), JSON.stringify({ main: "main.js" })), - writeFile(join(root, "main.js"), source), + writeFile( + join(root, "main.js"), + `const { BrowserWindow, utilityProcess } = require("electron");\n${source}`, + ), writeFile(join(root, "preload.js"), "void 0;"), writeFile(join(root, "worker.js"), "void 0;"), ]);