Files
reverse-engineering-anything/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts
T
morluto c966d6f49a refactor(sqlite): classify database failures through the shared provider classifier (#10870)
* fix(pcap): report a refused tool launch as an unavailable tool

When a capture read's tool launch was refused with EPERM, pcap reported it
as unreadable tool output (AnalysisProtocolError) instead of an unavailable
tool. Pcap also never told "not executable" apart from "missing".

Add classifyLaunchFailure in src/process as the one owner of which spawn
errnos mean missing (ENOENT, ENOTDIR) and which mean present but not
executable (EACCES, EPERM, ENOEXEC, EISDIR). Pcap, apktool and gdb used three
different errno lists; all three now use it.

* refactor(process): classify owned command outcomes through one owner

Add ProviderOutcome, read once from the supervisor's facts, and classifyProviderOutcome, the one mapping from an outcome to a typed error. Migrate pcap and the shared artifact producer command failure (TShark, sigrok). Add the rea/provider-failure-classifier lint rule with a shrink-only baseline of per-site (cause: unknown) => AnalysisError functions.

* refactor(process): classify Java and adb launch refusals with the shared owner (#10598)

The Ghidra and JADX Java probes and adb's version probe now take
missing-versus-not-executable from classifyLaunchFailure instead of their
own errno checks. ELOOP and ENAMETOOLONG join the owner's missing set, so
adb keeps its existing classification and both Java probes now treat a
non-directory path component as a missing runtime.

* fix(pcap): preserve command launch attribution

* test(pcap): expect an unknown failing stage when a limited capture tool exits unsuccessfully

* refactor(process): classify owned command outcomes through one owner

Add ProviderOutcome, read once from the supervisor's facts, and classifyProviderOutcome, the one mapping from an outcome to a typed error. Migrate pcap and the shared artifact producer command failure (TShark, sigrok). Add the rea/provider-failure-classifier lint rule with a shrink-only baseline of per-site (cause: unknown) => AnalysisError functions.

* test(lint): cover baselined files in the provider-failure classifier rule

* fix(apktool): report the decoder JVM's heap exhaustion as memory, not a target refusal

The supervisor now watches for a managed runtime's out-of-memory report while output streams (JVM java.lang.OutOfMemoryError, V8 'JavaScript heap out of memory'), so a retention budget cannot hide it. ProviderOutcome gains a runtime-exhausted kind and classifyProviderOutcome maps it once. Apktool decode opts in and moves to the shared classifier; Ghidra's single-purpose output token becomes the JVM runtime option and its area matching reuses the shared table.

* fix(process): keep the runtime's out-of-memory report on the memory error it supports

* fix(pcap): attribute a limited capture's exit through the prlimit wrapper

* fix(process): keep a shortened out-of-memory line marked as shortened

* fix(process): identify the refused executable for every launcher shape

Name the executable on errnos Node throws synchronously from spawn
(ENOEXEC, ENOTDIR, ...) and read the Windows native launcher's
executable-launch constraint, so pcap attributes those refusals to the
selected tool or limiter instead of an unknown stage.

* test(pcap): prove synchronous launch naming with ENOTDIR on every POSIX host

* fix(apktool): advise the JVM limit that governs the reported memory area

* fix(ghidra): read the supervisor's parsed OOM area instead of reparsing lines

* fix(sqlite,capture): read V8 heap exhaustion from streamed output, not retained stderr (#10724)

The SQLite worker and the Node capture decoders now declare runtime: "v8", so the supervisor observes V8's heap report as output streams. Both classifiers read the runtime-exhausted outcome instead of matching retained stderr, so a report beyond the retention budget, or one whose process then overflowed its output budget, is still reported as memory exhaustion.

* refactor(evm): classify EVMole worker outcomes through the shared provider classifier (#10716)

* refactor(evm): classify EVMole worker outcomes through the shared provider classifier

The EVMole worker's cancellation, deadline, output budget and CPU/file-size signals now go through classifyProviderOutcome; only the prlimit launch refusal and the reserved EFBIG exit remain in its dialect. The worker is opted into V8 exhaustion scanning so a heap report names its fixed 256 MiB heap. providerOutcomeOf now reads a launch refusal only from a spawn errno, so a private-storage or carrier read errno is no longer reported as a limiter launch failure.

* fix(evm): keep a stream failure's exit status and update Linux worker expectations

* refactor(process): read launch refusals through the launcher identity owner

* refactor(wasm): classify WABT command outcomes through the shared provider classifier (#10727)

* refactor(wasm): classify WABT command outcomes through the shared provider classifier

WABT's cancellation, command deadline, output budget, and inherited-limit signals now go through classifyProviderOutcome; its dialect keeps only the configuration-probe and launch-refusal unavailability and the unclassified-exit output error. A tool ended by SIGXCPU/SIGXFSZ or the memory limit is now a resource constraint instead of an output error, and a refused launch in the producer phase is reported as unavailable. The shared timeout context gains the analysis setting that selected the deadline, and the output-limit context may name its exceeded budget.

* fix(wasm): keep setup guidance when a WABT version probe's output cannot be collected

* refactor(providers): classify raw provider failures through one owner

* refactor(process): build classified cancellations with cancelledBy and the carried caller reason

* chore(lint): count runOwned's close-failure mapper from main in the classifier baseline

* fix(process): carry the runtime's exhaustion report on every memory error that claims it

SQLite and historical-capture memory errors recognised a streamed V8 heap report but dropped it, so a report outside retained output reached callers without its source line or omission flags. One projection, runtimeExhaustionReport, now owns that shape for the shared classifier and both consumers. Also lowers the EVMole and WABT cancellation-constructor baselines.

* fix(process): keep the provider's recovery for a native-thread exhaustion report

The shared classifier replaced the provider-supplied recovery with generic capacity advice whenever the JVM reported it could not create a native thread, dropping Apktool's launcher guidance and flipping retryable to true. The provider's recovery now wins; the capacity advice remains the fallback for providers that supply none.

* refactor(pwntools): classify pwntools failures through the shared provider classifier (#10782)

* refactor(pwntools): classify pwntools failures through the shared provider classifier

pwntools' per-site classifier is replaced by pwntoolsFailureDialect for classifyProviderFailure. Command outcomes go through classifyProviderOutcome, launch refusals through launcherRefusalOf instead of a 'spawn ' syscall prefix check, the input limit through the file dialect's fileSize, and the cleanup report through commandCleanup. The shared read cancellation now carries the reader's partial observation, and resource-signal reports accept provider detail.

* fix(pwntools): report a failed command's output once and expect resource_constraint for overflow

The shared outcome owner already attaches the command's output as capturedOutput, so the pwntools adapter fallback no longer copies the same text into diagnostics.stdout, diagnostics.stderr and diagnostics.captured_output; diagnostics keep the exit facts. Update the overflow expectations the dialect migration missed (layout provider cleanup and over-budget rows, pwntools cleanup) to resource_constraint.

* refactor(wasm): leave WABT cancellation to the shared owners

WabtFailures matched any Error named AbortError as a cancellation. No WABT path produces one: reads, path checks and owned commands report cancellation as ArtifactReaderFailure or OwnedCommandFailure, which classifyProviderFailure and classifyProviderOutcome already classify with the caller's reason. The provider's two signal checks now build the cancellation with cancelledBy. Lowers the AnalysisCancelledError constructor baseline for both WABT files to zero.

* fix(wasm): report a cancelled snapshot write as the caller's cancellation

The private snapshot write passed the caller's signal to Node's writeFile, whose abort envelope reached the dialect's unclassified output failure once the name-based AbortError guess was removed. The write now turns the envelope carrying its own signal's reason into cancelledBy at the producer.

* refactor(firmware): classify firmware failures through the shared provider classifier (#10810)

Firmware producers now report their own deadline expiry: the start check, the output inventory and launcher admission throw the phase timeout where they observe the abort, and a read the phase signal cancelled reports it through the dialect's interruption. The classifier no longer rereads a cancellation's reason as a timeout. A new readCleanup hook keeps firmware's cleanup report shape.

* refactor(captures): classify TShark, sigrok and pcap failures through the shared provider classifier

TsharkCaptureProvider and SigrokRecordingProvider replace #failure with a dialect, and PcapProvider replaces analysisFailure and the tool-identity classifier. ArtifactAnalysisResources exposes its command context and a readCleanupFailure composer so the readCleanup hook keeps each provider's cleanup report shape.

* refactor(jeb): classify JEB failures through the shared provider classifier

JEB's raw MCP failures go through classifyProviderFailure with a JEB dialect;
a typed attribution step reports a shared phase's failure under the REA
operation. Each MCP phase deadline now reports its own interruption when it
aborts, deciding a cancellation's owner from the caller's own signal instead
of comparing abort reasons. A refusal carrying the JVM's OutOfMemoryError
report becomes a resource constraint naming JEB's Java heap.

* fix(jeb): advise the JVM limit that governs the reported memory area

* refactor(ghidra): classify Ghidra failures through the shared provider classifier

PSP extension inspection reports through classifyProviderFailure with a
Ghidra dialect. The startup deadline decides at abort time whether the shared
startup owner closed it, so Ghidra and Hopper no longer compare the abort
reason with the owner's sentinel, and Ghidra's startup no longer compares a
producer cancellation's reason with the deadline's. Three helpers that only
construct one error kind now name that kind instead of AnalysisError.

* refactor(hopper): classify original-source read failures through the shared provider classifier (#10838)

* test(ghidra): pin the aborted startup deadline over a producer cancellation

* refactor(kuna): classify Kuna failures through the shared provider classifier

Session operations, the workspace copy and the project export report raw
failures through classifyProviderFailure with Kuna dialects; the copy and
export keep their cleanup report shapes through the readCleanup hook.

* refactor(adb): report a pulled file's digest failure from its own type

* refactor(sqlite): classify worker-reply read failures through the shared provider classifier

* refactor(ida): classify IDA operation failures through the shared provider classifier

* refactor(process): let artifact-resource consumers classify through the shared provider classifier (#10867)

* test(providers): pin attribution of a cleanup-failed read's typed primary

* refactor(sqlite): classify database failures through the shared provider classifier

* refactor(ghidra): classify Ghidra failures through the shared provider classifier (#10836)

* refactor(ghidra): classify Ghidra failures through the shared provider classifier

PSP extension inspection reports through classifyProviderFailure with a
Ghidra dialect. The startup deadline decides at abort time whether the shared
startup owner closed it, so Ghidra and Hopper no longer compare the abort
reason with the owner's sentinel, and Ghidra's startup no longer compares a
producer cancellation's reason with the deadline's. Three helpers that only
construct one error kind now name that kind instead of AnalysisError.

* refactor(hopper): classify original-source read failures through the shared provider classifier (#10838)

* test(ghidra): pin the aborted startup deadline over a producer cancellation

* refactor(kuna): classify Kuna failures through the shared provider classifier (#10843)

Session operations, the workspace copy and the project export report raw
failures through classifyProviderFailure with Kuna dialects; the copy and
export keep their cleanup report shapes through the readCleanup hook.

* chore(guards): shrink the cancellation construction baseline after merging the base

* refactor(ida): classify IDA operation failures through the shared provider classifier (#10859)

* refactor(ida): classify IDA operation failures through the shared provider classifier

* refactor(process): let artifact-resource consumers classify through the shared provider classifier (#10867)

* test(providers): pin attribution of a cleanup-failed read's typed primary
2026-11-15 21:08:53 +08:00

342 lines
12 KiB
TypeScript

import {
lstat,
open,
readFile,
rename,
symlink,
unlink,
utimes,
writeFile,
} from "node:fs/promises";
import { classifyProviderFailure } from "../../../src/providers/classifyProviderFailure.js";
import { DatabaseSync } from "node:sqlite";
import { createHash } from "node:crypto";
import { expect, it, onTestFinished, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import type { StableArtifactFileSystem } from "../../../src/artifacts/readStableArtifact.js";
import { captureSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseSnapshot.js";
import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js";
import { sqliteDatabaseDialect } from "../../../src/sqlite/SqliteDatabaseFailures.js";
import {
createSqliteDatabaseFixture,
SQLITE_NATIVE_LIMITS_AVAILABLE,
} 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();
const resourceScope = new ArtifactResourceScope();
onTestFinished(async () => {
await resourceScope.close();
if (wal) database.close();
await removeTestWorkspace(workspace.root);
});
const root = await workspace.mkdir("private");
const capture = (fileSystem?: StableArtifactFileSystem) =>
captureSqliteDatabaseSnapshot(
database.path,
root,
resourceScope,
undefined,
fileSystem,
);
return { workspace, database, root, capture };
};
it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE).each(["PERSIST", "TRUNCATE"])(
"inspects a committed %s journal without modifying the source file set",
async (mode) => {
const { database, capture } = 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 capture();
const result = inspectSqliteDatabaseSnapshot(snapshot.snapshotPath, {
path: database.path,
table: "records",
});
expect(result.rows).toMatchObject({ returned_rows: 4, truncated: false });
expect(Buffer.compare(await readFile(journalPath), before)).toBe(0);
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, capture } = 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 capture(fileSystem);
throw new Error("A disappearing source must be rejected");
} catch (cause) {
expect(
classifyProviderFailure(
cause,
sqliteDatabaseDialect(database.path, "artifact-read"),
),
).toMatchObject({ _tag: "AnalysisArtifactChangedError" });
expect(cause).toMatchObject({
message: expect.stringContaining(selectedPath),
});
}
},
);
it.each(["ELOOP", "ENAMETOOLONG"] as const)(
"treats %s during file acquisition as changed evidence, not an invalid input path",
async (code) => {
const { database, capture } = await fixture();
const fileSystem: StableArtifactFileSystem = {
lstat: (path) => lstat(path, { bigint: true }),
open: async (path, flags) => {
if (path === database.path)
throw Object.assign(
new Error(`${code}: path changed after observation`),
{ code },
);
return open(path, flags);
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
message: expect.stringContaining(database.path),
});
},
);
it("retains changed-evidence semantics when a sidecar path failure follows database removal", async () => {
const { database, capture } = await fixture();
const fileSystem: StableArtifactFileSystem = {
lstat: async (path) => {
if (path === database.walPath) {
await unlink(database.path);
throw Object.assign(new Error("derived sidecar path is too long"), {
code: "ENAMETOOLONG",
});
}
return lstat(path, { bigint: true });
},
open,
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
message: expect.stringContaining(database.path),
});
});
it("refuses an active journal even before SQLite writes its magic header", async () => {
const { database, capture } = 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(capture()).rejects.toMatchObject({
reason: "format",
message: expect.stringContaining("recovery"),
});
expect(Buffer.compare(await readFile(database.path), original)).toBe(0);
expect(Buffer.compare(await readFile(journalPath), journal)).toBe(0);
} finally {
writer.exec("ROLLBACK");
writer.close();
}
});
it("refuses an incomplete zeroed journal header", async () => {
const { database, capture } = await fixture();
await writeFile(`${database.path}-journal`, Buffer.alloc(27));
await expect(capture()).rejects.toMatchObject({ reason: "format" });
});
it.runIf(process.platform !== "win32")(
"refuses a journal symlink even when its target is empty",
async () => {
const { database, workspace, capture } = await fixture();
const empty = await workspace.write("empty-journal", "");
await symlink(empty, `${database.path}-journal`);
await expect(capture()).rejects.toMatchObject({ reason: "path" });
},
);
it.runIf(process.platform !== "win32")(
"does not offer a resolved-file retry for a derived WAL symlink",
async () => {
const { database, workspace, capture } = await fixture(true);
const target = workspace.path("wal-target");
await rename(database.walPath, target);
await symlink(target, database.walPath);
await expect(capture()).rejects.toMatchObject({
operationCause: { reason: "path", resolvedPath: undefined },
completedMembers: [{ role: "database", path: database.path }],
});
},
);
it.each(["activate", "replace", "remove"])(
"rejects a cold journal changed during database acquisition: %s",
async (change) => {
const { database, capture } = 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(capture(fileSystem)).rejects.toMatchObject({
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
});
},
);
it("refuses an activated journal even when filesystem metadata cannot distinguish the write", async () => {
const { database, capture } = await fixture();
const journalPath = `${database.path}-journal`;
await writeFile(journalPath, Buffer.alloc(28));
const observed = await lstat(journalPath, { bigint: true });
const fileSystem: StableArtifactFileSystem = {
lstat: (path) =>
path === journalPath
? Promise.resolve(observed)
: lstat(path, { bigint: true }),
open: async (path, flags) => {
if (path === database.path)
await writeFile(journalPath, Buffer.alloc(28, 1));
const handle = await open(path, flags);
if (path === journalPath)
vi.spyOn(handle, "stat").mockResolvedValue(observed);
return handle;
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
});
});
it("rejects a database changed after its copy but before WAL acquisition", async () => {
const { database, root, capture } = 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(capture(fileSystem)).rejects.toMatchObject({
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
completedMembers: expect.arrayContaining([
{
role: "database",
path: database.path,
sha256: createHash("sha256").update(initial).digest("hex"),
bytes: initial.length,
},
]),
});
expect(changed).toBe(true);
expect(
Buffer.compare(await readFile(`${root}/database.snapshot`), initial),
).toBe(0);
});
it.each(["-wal", "-journal"] as const)(
"rejects a previously absent %s created during database acquisition",
async (suffix) => {
const { database, capture } = 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(capture(fileSystem)).rejects.toMatchObject({
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
});
expect(created).toBe(true);
},
);