Files
reverse-engineering-anything/src/sqlite/SqliteDatabaseWorkerExecution.ts
T
morluto d5e0747025 refactor(process): build infrastructure cancellations with a required reason (#10595)
* refactor(errors): require a reason for every analysis cancellation

Add cancelledBy, the one constructor for AnalysisCancelledError. Its
reason argument is required: an aborted signal, an earlier cancellation,
a reason carried from an inner failure, or an OwnerCancellation when REA
closed the work's owner itself.

Closed adb, apktool, firmware, sqlite, gdb, web-bundle analyzer and
binary-session record owners now report OwnerCancellation.ownerClosed
instead of an absent reason. Firmware's predecessor wait returns its
cancellation as a Result, removing an unreachable reason-less fallback.

An architecture guard rejects new direct constructions, with a counted
per-file baseline that can only shrink.

* refactor(errors): share one aborted-signal predicate with cancelledBy

* refactor(process): return a departing caller's reason from a shared startup wait

* refactor(providers): build decompiler-provider cancellations with a required reason (#10587)

Migrate every AnalysisCancelledError construction under src/ghidra,
src/ida, src/cutter, src/jeb and src/kuna to cancelledBy and lower the
guard baseline from 671 to 572. Closed Kuna target commands report
OwnerCancellation.ownerClosed instead of an absent reason, and Kuna and
JEB close abort their lifetimes with that typed reason.

* refactor(process): build infrastructure cancellations with a required reason

Migrate every AnalysisCancelledError construction in shared process,
provider, artifact, domain, server and CLI code and in the smaller
providers to cancelledBy, lowering the guard baseline from 671 to 566.
Option bags that carried a cause are split so the cancellation branch
cannot receive one.

* fix(errors): read the reason of any signal-shaped cancellation source

* test(ci): cover the counted-baseline comparison behind the architecture guard
2026-11-15 17:05:23 +08:00

359 lines
13 KiB
TypeScript

import { safeParseUtf8Json } from "../domain/safeJson.js";
import {
bindArtifactFileCleanup,
artifactFileCancellationCause,
} from "../artifacts/ArtifactFileOperation.js";
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,
AnalysisError,
} from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
AnalysisInputError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
AnalysisResourceConstraintError,
cancelledBy,
carriedReason,
} from "../domain/analysisErrorCore.js";
import {
sqliteDatabaseSchema,
type InspectSqliteDatabaseInput,
} from "../domain/sqlite/sqliteDatabase.js";
import {
completedStableArtifact,
readStableArtifact,
} from "../artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { runOwnedCommand } from "../process/OwnedCommand.js";
import type {
ProviderProcessSupervisor,
ProviderProcessStopResult,
} from "../process/ProviderProcess.js";
import { capturedSqliteOutput } from "./SqliteDatabaseFailures.js";
import {
SQLITE_DATABASE_LIMITS,
SQLITE_INSPECTION_REASONS,
SQLITE_STRUCTURED_INSPECTION_REASONS,
} from "./SqliteDatabaseLimits.js";
const operation = "inspect_sqlite_database";
const {
artifact: _artifactSchema,
wal: _walSchema,
worker_timeout_ms: _workerTimeoutSchema,
...inspectionShape
} = sqliteDatabaseSchema.shape;
const inspectionSchema = z.strictObject(inspectionShape);
const replySchema = z.union([
z.strictObject({ ok: z.literal(true), inspection: inspectionSchema }),
z.strictObject({
ok: z.literal(false),
reason: z
.enum(SQLITE_INSPECTION_REASONS)
.exclude(SQLITE_STRUCTURED_INSPECTION_REASONS),
message: z.string(),
}),
z.strictObject({
ok: z.literal(false),
reason: z.literal("column-evaluation"),
message: z.string(),
readable_columns: z.array(z.string()),
}),
z.strictObject({
ok: z.literal(false),
reason: z.literal("column-limit"),
message: z.string(),
selected_columns: z.int().positive(),
engine_column_limit: z.int().positive(),
}),
]);
/** Replaceable launcher retains the shared owned-process supervision boundary. */
export type SqliteWorkerLauncher = NonNullable<
Parameters<typeof runOwnedCommand>[2]
>["launcher"];
/** Validated worker facts and the exact reply descriptor's finalization outcome. */
interface SqliteWorkerInspection {
readonly inspection: z.output<typeof inspectionSchema>;
readonly finalization: AnalysisError | undefined;
}
// Each input rejection names the caller argument that requires correction.
const rejectedArgument = {
format: "path",
unsupported: "path",
selection: "table",
"column-selection": "columns",
"row-offset": "row_offset",
} as const;
const rejectedReply = (
reply: z.output<typeof replySchema> & { ok: false },
selectedPath: string,
capturedOutput: AnalysisCapturedOutput,
) => {
if (reply.reason === "unavailable")
return new AnalysisCapabilityUnavailableError(
"sqlite",
operation,
reply.message,
{ capturedOutput, userMessage: reply.message },
);
if (reply.reason === "column-evaluation") {
const readable = reply.readable_columns;
return new AnalysisCapabilityUnavailableError(
"sqlite",
operation,
reply.message,
{
capturedOutput,
userMessage: reply.message,
remediationAction: `The database is readable; only the listed column values failed. REA's SQLite connection loads no extensions or application functions and disables schema trust. ${
readable.length === 0
? "No other readable column was found"
: `Select columns whose values were read: columns: ${JSON.stringify(readable)} (CLI: one --column per name)`
}; omit table and use schema_scope: database to read the stored declarations without evaluating rows. Obtaining the failed values needs a SQLite connection able to evaluate them. Do not repeat the unchanged request.`,
},
);
}
if (reply.reason === "row-evaluation")
return new AnalysisCapabilityUnavailableError(
"sqlite",
operation,
reply.message,
{
capturedOutput,
userMessage: reply.message,
diagnostics: { selected_path: selectedPath },
remediationAction:
"SQLite read the selected schema but could not prepare or evaluate the requested rows. REA's private connection loads no extensions, application functions or application collations. The SQLite reason may identify a missing dependency; an unidentified query failure does not establish database corruption. Omit table and use schema_scope: database to inspect stored declarations. Obtaining the requested values may require the application's supported consistent export. Do not repeat the unchanged row request.",
},
);
if (reply.reason === "memory")
return new AnalysisResourceConstraintError(
operation,
"memory",
reply.message,
{
javascript_heap_bytes: 256 * 1024 * 1024,
},
{ capturedOutput, diagnostics: { selected_path: selectedPath } },
);
if (reply.reason === "output-limit")
return new AnalysisResourceConstraintError(
operation,
"transport",
reply.message,
{
sqlite_value_or_record_bytes: SQLITE_DATABASE_LIMITS.outputBytes,
sqlite_sql_statement_bytes: SQLITE_DATABASE_LIMITS.outputBytes,
worker_reply_bytes: SQLITE_DATABASE_LIMITS.outputBytes,
},
{
capturedOutput,
diagnostics: { selected_path: selectedPath },
retryable: false,
remediationAction:
"The SQLite value/record, SQL statement and worker JSON reply allowances are each fixed at 16 MiB; this refusal does not identify which was exceeded. worker_timeout_ms and client deadlines do not raise them. If it preserves the requested facts, select exact columns, reduce row_limit, or use schema_scope: inventory instead of database. This worker refusal leaves no completed Evidence to retrieve. Do not repeat the unchanged request or truncate required database bytes.",
},
);
if (reply.reason === "column-limit")
return new AnalysisResourceConstraintError(
operation,
"capacity",
reply.message,
{
selected_result_columns: reply.selected_columns,
engine_result_column_limit: reply.engine_column_limit,
},
{
capturedOutput,
diagnostics: { selected_path: selectedPath },
retryable: false,
remediationAction: `The selected database is readable; only the combined projection exceeded this connection's native result-column limit. Request at most ${String(reply.engine_column_limit)} columns per call, splitting wider facts across calls, or omit table to inspect stored declarations with schema_scope: database. The limit belongs to SQLite's connection, so worker_timeout_ms and client deadlines do not raise it. Do not repeat the unchanged request.`,
},
);
const argument = rejectedArgument[reply.reason];
return new AnalysisInputError(operation, { capturedOutput }, [
{
path: [argument],
reason: "invalid_format",
// The worker sees only the private snapshot, so name the caller's selection here.
message:
argument === "path"
? `Selected SQLite database is invalid (${reply.message}): ${selectedPath}`
: 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 timeoutMs: number;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly resourceScope: ArtifactResourceScope;
readonly launcher?: SqliteWorkerLauncher;
readonly signal?: AbortSignal;
readonly captured: (output: AnalysisCapturedOutput) => void;
readonly retainCleanup?: (
owner: ProviderProcessSupervisor,
result: Extract<ProviderProcessStopResult, { status: "incomplete" }>,
) => void;
}): Promise<SqliteWorkerInspection> => {
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: request.timeoutMs,
diagnosticBytes: SQLITE_DATABASE_LIMITS.diagnosticBytes,
runtime: "v8",
},
{
...(request.signal === undefined ? {} : { signal: request.signal }),
...(request.launcher === undefined ? {} : { launcher: request.launcher }),
...(request.retainCleanup === undefined
? {}
: { retainCleanup: request.retainCleanup }),
},
);
const capturedOutput = capturedSqliteOutput(execution);
request.captured(capturedOutput);
let reply: z.output<typeof replySchema>;
let finalization: AnalysisError | undefined;
try {
let file;
try {
file = await readStableArtifact(
replyPath,
SQLITE_DATABASE_LIMITS.outputBytes,
request.signal,
undefined,
request.resourceScope,
);
} catch (cause: unknown) {
const completed = completedStableArtifact(cause);
if (!(cause instanceof ArtifactReaderFailure) || completed === undefined)
throw cause;
finalization = sqliteReplyCompletionFailure(cause, capturedOutput);
file = completed;
}
const decoded = safeParseUtf8Json(file.bytes);
if (!decoded.ok) throw decoded.cause;
reply = replySchema.parse(decoded.value);
} catch (cause: unknown) {
throw sqliteReplyFinalizationFailure(
sqliteReplyReadFailure(cause, capturedOutput),
finalization,
);
}
if (!reply.ok)
throw sqliteReplyFinalizationFailure(
rejectedReply(reply, request.input.path, capturedOutput),
finalization,
);
return { inspection: reply.inspection, finalization };
};
const sqliteReplyReadFailure = (
cause: unknown,
capturedOutput: AnalysisCapturedOutput,
): AnalysisError => {
const optionsWithoutCause = {
capturedOutput,
...(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
? { cleanup: cause.cleanup }
: {}),
};
const options = { cause, ...optionsWithoutCause };
const error =
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
? cancelledBy(
operation,
carriedReason(artifactFileCancellationCause(cause)),
optionsWithoutCause,
)
: new AnalysisOutputError(
operation,
`Owned SQLite worker reply is missing or malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
options,
);
bindArtifactFileCleanup(cause, error);
return error;
};
/** Stable bytes remain usable when only their descriptor's final close failed. */
const sqliteReplyCompletionFailure = (
cause: ArtifactReaderFailure,
capturedOutput: AnalysisCapturedOutput,
): AnalysisError => {
if (cause.cleanup === undefined)
return sqliteReplyReadFailure(cause, capturedOutput);
const cleanup = new ProviderCleanupError(
"sqlite",
cause.cleanup.resources,
{ phase: "reply_descriptor_close", reason: cause.cleanup.reason },
{ operation, cause, capturedOutput },
);
bindArtifactFileCleanup(cause, cleanup);
if (cause.reason !== "cancelled") return cleanup;
const cancellation = cancelledBy(
operation,
carriedReason(artifactFileCancellationCause(cause)),
{ capturedOutput },
);
return analysisErrorWithCleanupFailure(
cancellation,
cleanup,
operation,
).bindCleanupDependencies([cancellation, cleanup]);
};
/** A malformed reply or producer refusal stays primary over its later close. */
export const sqliteReplyFinalizationFailure = (
primary: AnalysisError,
finalization: AnalysisError | undefined,
): AnalysisError =>
finalization === undefined
? primary
: finalization.cleanupIncomplete
? analysisErrorWithCleanupFailure(
primary,
finalization,
operation,
).bindCleanupDependencies([primary, finalization])
: primary.retainSecondaryFailure(finalization);