diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts index 1a9aee98d2..d9dd7b8b35 100644 --- a/src/sqlite/SqliteDatabaseFailures.ts +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -10,6 +10,7 @@ import { } from "../domain/analysisErrorProjection.js"; import { AnalysisError, + primaryAnalysisError, type AnalysisCapturedOutput, type AnalysisErrorOptions, } from "../domain/analysisErrorBase.js"; @@ -34,6 +35,7 @@ import { MAX_SQLITE_WORKER_TIMEOUT_MS, } from "../domain/sqlite/sqliteDatabase.js"; import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; +import { SqliteSnapshotAcquisitionFailure } from "./SqliteDatabaseSnapshot.js"; /** Preserve diagnostics independently of worker exit and private-root cleanup. */ export const capturedSqliteOutput = ( @@ -91,6 +93,26 @@ export const sqliteDatabaseFailure = ( workerTimeoutMs = DEFAULT_SQLITE_WORKER_TIMEOUT_MS, ): AnalysisError => { const operation = "inspect_sqlite_database"; + if (cause instanceof SqliteSnapshotAcquisitionFailure) { + const failure = sqliteDatabaseFailure( + cause.operationCause, + path, + phase, + signal, + workerTimeoutMs, + ); + const diagnostics = { + snapshot_acquisition: { + consistency: "unverified", + members: cause.completedMembers.map((member) => ({ ...member })), + }, + }; + failure.retainDiagnostics(diagnostics); + // Keep the facts on the semantic error after its cleanup envelope resolves. + const primary = primaryAnalysisError(failure); + if (primary !== failure) primary.retainDiagnostics(diagnostics); + return failure; + } if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure) { if (cause.cleanup !== undefined) { diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts index 1589ee963c..e11ab0d7d1 100644 --- a/src/sqlite/SqliteDatabaseSnapshot.ts +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -14,7 +14,9 @@ import { assertArtifactReadActive, } from "../artifacts/ArtifactFileOperation.js"; import { + completedStableArtifact, readStableArtifact, + type StableArtifactSnapshot, type StableArtifactFileSystem, } from "../artifacts/readStableArtifact.js"; import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; @@ -24,6 +26,34 @@ const fileSystem: StableArtifactFileSystem = { open, }; +type SnapshotMemberRole = "database" | "wal"; +type CompletedSnapshotMember = { + readonly role: SnapshotMemberRole; + readonly path: string; + readonly sha256: string; + readonly bytes: number; +}; + +/** Individual source reads do not establish that the whole file set is consistent. */ +export class SqliteSnapshotAcquisitionFailure extends Error { + readonly _tag = "SqliteSnapshotAcquisitionFailure"; + readonly completedMembers: readonly CompletedSnapshotMember[]; + + constructor( + readonly operationCause: unknown, + members: readonly CompletedSnapshotMember[], + ) { + super( + operationCause instanceof Error + ? operationCause.message + : "SQLite source acquisition failed after completed member reads", + { cause: operationCause }, + ); + this.name = this._tag; + this.completedMembers = members.map((member) => ({ ...member })); + } +} + /** Probe a generated sibling only after the caller-selected database is observed. */ const optionalSidecarStat = async ( path: string, @@ -248,112 +278,147 @@ export const captureSqliteDatabaseSnapshot = async ( signal?: AbortSignal, selectedFileSystem: StableArtifactFileSystem = fileSystem, ) => { - assertArtifactReadActive(signal); const walPath = `${path}-wal`; - const journalPath = `${path}-journal`; - const paths = [path, walPath, journalPath] as const; - const database = await optionalStat(path, selectedFileSystem); - if (database === null) throw missingDatabase(path); - const wal = await optionalSidecarStat( - walPath, - path, - database, - "WAL", - selectedFileSystem, - ); - const journal = await optionalSidecarStat( - journalPath, - path, - database, - "rollback journal", - selectedFileSystem, - ); - const before = [database, wal, journal] as const; - const selectedSize = database.size + (wal?.size ?? 0n); - if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes)) - throw new AnalysisResourceConstraintError( - "inspect_sqlite_database", - "file-size", - `Selected SQLite database and WAL total ${selectedSize.toString()} bytes, exceeding the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte offline snapshot budget: ${path}`, - { - sqlite_snapshot_input_bytes: SQLITE_DATABASE_LIMITS.inputBytes, - observed_sqlite_snapshot_input_bytes: selectedSize.toString(), - }, - { - remediationAction: `Use the application's supported export workflow to create a consistent, smaller offline SQLite snapshot containing only the relevant data, then inspect that snapshot. REA will not truncate or modify the selected source. The combined input allowance is ${String(SQLITE_DATABASE_LIMITS.inputBytes)} bytes. This release has no runtime override. worker_timeout_ms does not raise it.`, - }, - ); - if (journal !== null) - await verifyInactiveJournal( - `${path}-journal`, - journal, - selectedFileSystem, - resourceScope, - "initial", - signal, - ); - const snapshotPath = join(root, "database.snapshot"); - const captured = await readObservedArtifact( - path, - database, - SQLITE_DATABASE_LIMITS.inputBytes, - selectedFileSystem, - resourceScope, - signal, - ); - await writeFile(snapshotPath, captured.bytes, { - flag: "wx", - mode: 0o600, - ...(signal === undefined ? {} : { signal }), - }); - const artifact = { - path, - sha256: captured.sha256, - bytes: captured.bytes.length, + const completedMembers: CompletedSnapshotMember[] = []; + const recordMember = ( + role: SnapshotMemberRole, + snapshot: StableArtifactSnapshot, + ) => { + completedMembers.push({ + role, + path: role === "database" ? path : walPath, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }); + return snapshot; }; - let walArtifact: typeof artifact | null = null; - if (wal !== null) { - const capturedWal = await readObservedArtifact( - `${path}-wal`, - wal, - SQLITE_DATABASE_LIMITS.inputBytes - artifact.bytes, + const readMember = async ( + role: SnapshotMemberRole, + observed: BigIntStats, + maximumBytes: number, + ) => { + try { + return recordMember( + role, + await readObservedArtifact( + role === "database" ? path : walPath, + observed, + maximumBytes, + selectedFileSystem, + resourceScope, + signal, + ), + ); + } catch (cause: unknown) { + const completed = completedStableArtifact(cause); + if (completed !== undefined) recordMember(role, completed); + throw cause; + } + }; + try { + assertArtifactReadActive(signal); + const journalPath = `${path}-journal`; + const paths = [path, walPath, journalPath] as const; + const database = await optionalStat(path, selectedFileSystem); + if (database === null) throw missingDatabase(path); + const wal = await optionalSidecarStat( + walPath, + path, + database, + "WAL", selectedFileSystem, - resourceScope, - signal, ); - await writeFile(`${snapshotPath}-wal`, capturedWal.bytes, { + const journal = await optionalSidecarStat( + journalPath, + path, + database, + "rollback journal", + selectedFileSystem, + ); + const before = [database, wal, journal] as const; + const selectedSize = database.size + (wal?.size ?? 0n); + if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes)) + throw new AnalysisResourceConstraintError( + "inspect_sqlite_database", + "file-size", + `Selected SQLite database and WAL total ${selectedSize.toString()} bytes, exceeding the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte offline snapshot budget: ${path}`, + { + sqlite_snapshot_input_bytes: SQLITE_DATABASE_LIMITS.inputBytes, + observed_sqlite_snapshot_input_bytes: selectedSize.toString(), + }, + { + remediationAction: `Use the application's supported export workflow to create a consistent, smaller offline SQLite snapshot containing only the relevant data, then inspect that snapshot. REA will not truncate or modify the selected source. The combined input allowance is ${String(SQLITE_DATABASE_LIMITS.inputBytes)} bytes. This release has no runtime override. worker_timeout_ms does not raise it.`, + }, + ); + if (journal !== null) + await verifyInactiveJournal( + `${path}-journal`, + journal, + selectedFileSystem, + resourceScope, + "initial", + signal, + ); + const snapshotPath = join(root, "database.snapshot"); + const captured = await readMember( + "database", + database, + SQLITE_DATABASE_LIMITS.inputBytes, + ); + await writeFile(snapshotPath, captured.bytes, { flag: "wx", mode: 0o600, ...(signal === undefined ? {} : { signal }), }); - walArtifact = { - path: `${path}-wal`, - sha256: capturedWal.sha256, - bytes: capturedWal.bytes.length, + const artifact = { + path, + sha256: captured.sha256, + bytes: captured.bytes.length, }; - } - // Identity alone does not establish an inactive journal when timestamps are - // coarse. Re-read the header after copying, using the same validity owner. - if (journal !== null) - await verifyInactiveJournal( - `${path}-journal`, - journal, - selectedFileSystem, - resourceScope, - "after-copy", - signal, - ); - const after = await Promise.all( - paths.map((path) => - duringAcquisition(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}`, + let walArtifact: typeof artifact | null = null; + if (wal !== null) { + const capturedWal = await readMember( + "wal", + wal, + SQLITE_DATABASE_LIMITS.inputBytes - artifact.bytes, ); - assertArtifactReadActive(signal); - return { snapshotPath, artifact, wal: walArtifact }; + await writeFile(`${snapshotPath}-wal`, capturedWal.bytes, { + flag: "wx", + mode: 0o600, + ...(signal === undefined ? {} : { signal }), + }); + walArtifact = { + path: `${path}-wal`, + sha256: capturedWal.sha256, + bytes: capturedWal.bytes.length, + }; + } + // Identity alone does not establish an inactive journal when timestamps are + // coarse. Re-read the header after copying, using the same validity owner. + if (journal !== null) + await verifyInactiveJournal( + `${path}-journal`, + journal, + selectedFileSystem, + resourceScope, + "after-copy", + signal, + ); + const after = await Promise.all( + paths.map((path) => + duringAcquisition(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}`, + ); + assertArtifactReadActive(signal); + return { snapshotPath, artifact, wal: walArtifact }; + } catch (cause: unknown) { + if (completedMembers.length === 0) throw cause; + throw new SqliteSnapshotAcquisitionFailure(cause, completedMembers); + } }; diff --git a/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts index 954a1e0bba..db5158c277 100644 --- a/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts @@ -183,6 +183,16 @@ const serveInspection = async ( return { output: JSON.parse(stdout) as unknown, exitCode: process.exitCode }; }; +const expectedMember = async (role: string, path: string) => { + const bytes = await readFile(path); + return { + role, + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }; +}; + it.each(["database", "WAL", "journal"] as const)( "retains a failed %s descriptor and retries it at shutdown", async (member) => { @@ -190,6 +200,7 @@ it.each(["database", "WAL", "journal"] as const)( const first = await selected.provider.inspect({ path: selected.database.path, }); + if (first.ok) throw new Error("Expected incomplete source cleanup"); expect(first).toMatchObject({ ok: false, error: { @@ -197,6 +208,29 @@ it.each(["database", "WAL", "journal"] as const)( cleanupResources: [expect.stringContaining(selected.path)], }, }); + const sources = + member === "journal" + ? [] + : [ + { role: "database", path: selected.database.path }, + ...(member === "WAL" + ? [{ role: "wal", path: selected.database.walPath }] + : []), + ]; + const members = await Promise.all( + sources.map((source) => expectedMember(source.role, source.path)), + ); + if (members.length > 0) + expect( + projectAnalysisError(first.error).details?.diagnostics, + ).toMatchObject({ + snapshot_acquisition: { consistency: "unverified", members }, + }); + else + expect( + projectAnalysisError(first.error).details?.diagnostics, + ).not.toHaveProperty("snapshot_acquisition"); + expect(first.error.partialObservation).toBeUndefined(); const handle = descriptorCleanup.handles[0]; if (handle === undefined) throw new Error("Expected owned source descriptor"); @@ -217,11 +251,46 @@ it.each(["database", "WAL", "journal"] as const)( await selected.provider.close(); expect(descriptorCleanup.attempts).toBe(4); expect(handle.fd).toBe(-1); + if (members.length > 0) + expect( + projectAnalysisError(first.error).details?.diagnostics, + ).toMatchObject({ + snapshot_acquisition: { consistency: "unverified", members }, + }); for (const root of selected.roots) await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); }, ); +it("delivers individually completed database and WAL facts through CLI cleanup failure", async () => { + const selected = await fixture("WAL"); + const members = await Promise.all([ + expectedMember("database", selected.database.path), + expectedMember("wal", selected.database.walPath), + ]); + const result = await serveInspection( + selected.provider, + selected.database.path, + ); + expect(result).toMatchObject({ + exitCode: 1, + output: { + code: "cleanup_incomplete", + details: { + diagnostics: { + primary_error: { + details: { + diagnostics: { + snapshot_acquisition: { consistency: "unverified", members }, + }, + }, + }, + }, + }, + }, + }); +}); + it.each(["EACCES", "EPERM"])( "retains the original %s inspection diagnostics through CLI shutdown", async (code) => { @@ -604,6 +673,10 @@ it.each(["EACCES", "EPERM"])( diagnostics: { previous_error: { code: "access_denied" } }, }, }); + if (result.ok) throw new Error("Expected physical source denial"); + expect( + projectAnalysisError(result.error).details?.diagnostics, + ).not.toHaveProperty("snapshot_acquisition"); }, ); @@ -636,6 +709,15 @@ it.each(["database", "WAL", "journal"] as const)( _tag: "AnalysisCancelledError", }); expect(result.error.cleanupPrimary?.cause).toBe(reason); + if (member === "WAL") + expect( + projectAnalysisError(result.error).details?.diagnostics, + ).toMatchObject({ + snapshot_acquisition: { + consistency: "unverified", + members: [await expectedMember("database", selected.database.path)], + }, + }); for (const root of selected.roots) await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); }, diff --git a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts index cbefb2b2c5..5cfd3a6f8d 100644 --- a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts @@ -9,6 +9,7 @@ import { writeFile, } from "node:fs/promises"; 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"; @@ -227,7 +228,8 @@ it.each(["activate", "replace", "remove"])( }, }; await expect(capture(fileSystem)).rejects.toMatchObject({ - reason: "integrity", + _tag: "SqliteSnapshotAcquisitionFailure", + operationCause: { reason: "integrity" }, }); }, ); @@ -252,7 +254,8 @@ it("refuses an activated journal even when filesystem metadata cannot distinguis }, }; await expect(capture(fileSystem)).rejects.toMatchObject({ - reason: "integrity", + _tag: "SqliteSnapshotAcquisitionFailure", + operationCause: { reason: "integrity" }, }); }); @@ -276,7 +279,16 @@ it("rejects a database changed after its copy but before WAL acquisition", async }, }; await expect(capture(fileSystem)).rejects.toMatchObject({ - reason: "integrity", + _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( @@ -303,7 +315,8 @@ it.each(["-wal", "-journal"] as const)( }, }; await expect(capture(fileSystem)).rejects.toMatchObject({ - reason: "integrity", + _tag: "SqliteSnapshotAcquisitionFailure", + operationCause: { reason: "integrity" }, }); expect(created).toBe(true); },