diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index 35098cbe1e..ee1cc11e8e 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -125,7 +125,7 @@ REA writes Kuna's exports only inside `REA_KUNA_CACHE_DIR`: - `trash/` holds retired builds and evicted exports awaiting removal. - `home/` is Kuna's HOME. -Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it. +Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. REA retains the file state verified with each digest, checks it before warm-cache reuse, and checks open descriptors before and after reading text. Assembly indexes are checked against the same state; a changed file makes the whole report retry rather than mixing facets from different exports. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it. ## Verify with a real Kuna diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index 3d318e8318..ce9b976f53 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -177,9 +177,24 @@ const isUnopenableFileTypeError = (cause: unknown): boolean => typeof cause.errno === "number" && UNOPENABLE_FILE_TYPE_ERRNOS.has(cause.errno); +/** Stable content identity; access times are deliberately excluded. */ +export type RegularFileState = Pick< + Stats, + "dev" | "ino" | "size" | "mode" | "mtimeMs" | "ctimeMs" +>; + +export const regularFileState = (stats: Stats): RegularFileState => ({ + dev: stats.dev, + ino: stats.ino, + size: stats.size, + mode: stats.mode, + mtimeMs: stats.mtimeMs, + ctimeMs: stats.ctimeMs, +}); + /** Compare one regular file's identity and mutable metadata across a read. */ export const sameRegularFileState = ( - expected: Stats | BigIntStats, + expected: RegularFileState | BigIntStats, observed: Stats | BigIntStats, ): boolean => observed.isFile() && diff --git a/src/filesystem/StableFileHash.ts b/src/filesystem/StableFileHash.ts index 44d9a01b9b..e8f1fa8659 100644 --- a/src/filesystem/StableFileHash.ts +++ b/src/filesystem/StableFileHash.ts @@ -11,6 +11,8 @@ import { RegularFileOperationCancelled, assertRegularFileActive, sameRegularFileState, + regularFileState, + type RegularFileState, } from "./RegularFile.js"; import type { OwnedFileHandle } from "./OwnedFileHandle.js"; @@ -20,6 +22,7 @@ export interface StableFileHashOptions { } export interface StableFileObservation { + readonly state: RegularFileState; readonly sha256: string; readonly bytes: number; /** The first two bytes, read from the same descriptor and stream as the hash. */ @@ -131,7 +134,9 @@ async function readStableFile( throw new RegularFileChangedError(path); assertRegularFileActive(options.signal); const fingerprint = { sha256: hash.digest("hex"), bytes: position }; - return includePrefix ? { ...fingerprint, prefix } : fingerprint; + return includePrefix + ? { ...fingerprint, prefix, state: regularFileState(initial) } + : fingerprint; }, ); } catch (cause: unknown) { diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index 04a240012e..83623335ba 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -1,3 +1,8 @@ +import type { RegularFileState } from "../filesystem/RegularFile.js"; +import { + assertKunaProjectState, + type KunaProjectFileStates, +} from "./KunaProjectState.js"; import { createHash, randomUUID } from "node:crypto"; import { mkdir, @@ -26,7 +31,8 @@ import { import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { - hashStableFile, + observeStableFile, + type StableFileObservation, stableFileHashCause, } from "../filesystem/StableFileHash.js"; import { @@ -121,6 +127,7 @@ export type KunaProjectManifest = z.infer; /** One verified, published export. */ export interface KunaProject { + readonly fileStates: KunaProjectFileStates; readonly key: string; /** Directory holding the export's `.c`, `.h`, `.asm`, README and inventory. */ readonly directory: string; @@ -362,7 +369,18 @@ export class KunaProjectCache { } const verified = this.#verified.get(key); if (verified !== undefined) { - // Another process may have evicted it since this one verified it. + try { + await assertKunaProjectState( + verified.directory, + verified.manifest.files, + verified.fileStates, + signal, + ); + } catch (cause: unknown) { + if (!isDamaged(cause)) throw cause; + await this.#discard(key); + return undefined; + } if (await this.#touch(key)) return verified; this.#verified.delete(key); return undefined; @@ -383,11 +401,17 @@ export class KunaProjectCache { return undefined; } const directory = join(entry, "project"); - for (const file of Object.values(manifest.files)) { - let observed: { readonly sha256: string; readonly bytes: number }; + const fileStates: Partial< + Record + > = {}; + for (const role of Object.keys( + manifest.files, + ) as (keyof KunaProjectFileStates)[]) { + const file = manifest.files[role]; + let observed: StableFileObservation; try { const path = join(directory, file.path); - observed = await hashStableFile(path, { + observed = await observeStableFile(path, { ...(signal === undefined ? {} : { signal }), retainFailedClose: (owner) => this.#retainHashFile(owner, key, null, path), @@ -405,6 +429,7 @@ export class KunaProjectCache { await this.#discard(key); return undefined; } + fileStates[role] = observed.state; if (observed.sha256 !== file.sha256 || observed.bytes !== file.bytes) { await this.#discard(key); return undefined; @@ -414,13 +439,23 @@ export class KunaProjectCache { directory, manifest.files, manifest.summary, + fileStates as KunaProjectFileStates, signal, - ); - if (!parsed.ok) { + ).catch((cause: unknown) => { + if (!isDamaged(cause)) throw cause; + return undefined; + }); + if (parsed === undefined || !parsed.ok) { await this.#discard(key); return undefined; } - const project = { key, directory, manifest, ...parsed.value }; + const project = { + key, + directory, + manifest, + fileStates: fileStates as KunaProjectFileStates, + ...parsed.value, + }; if (!(await this.#touch(key))) return undefined; this.#verified.set(key, project); return project; @@ -948,6 +983,7 @@ export class KunaProjectCache { key, directory: join(destination, "project"), manifest, + fileStates: exported.fileStates, functions: exported.functions, inventory: exported.inventory, }; diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index 3c40699b5b..e2266d7177 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -1,3 +1,9 @@ +import type { RegularFileState } from "../filesystem/RegularFile.js"; +import { createHash } from "node:crypto"; +import { + assertKunaProjectState, + type KunaProjectFileStates, +} from "./KunaProjectState.js"; import { createReadStream } from "node:fs"; import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -12,6 +18,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { hashStableFile, + observeStableFile, StableFileHashFailure, } from "../filesystem/StableFileHash.js"; import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; @@ -64,6 +71,7 @@ export interface KunaExportFiles { /** One validated export: its files, Kuna's counts and the facts parsed from them. */ export interface KunaExport { + readonly fileStates: KunaProjectFileStates; readonly files: KunaExportFiles; readonly summary: KunaExportSummary; /** Decompiled function blocks in `.c` order. */ @@ -288,6 +296,9 @@ export const exportKunaProject = async ( inventory: INVENTORY_FILE, } as const; const files: Partial> = {}; + const fileStates: Partial< + Record + > = {}; for (const [role, name] of Object.entries(names) as [ keyof KunaExportFiles, string, @@ -299,16 +310,25 @@ export const exportKunaProject = async ( }); if (status === undefined || !status.isFile()) return unusable(`Kuna's export has no regular ${name}.`); + const observed = await observeStableFile(path, { + signal, + retainFailedClose: (owner) => request.retainFailedFileClose(owner, path), + }); files[role] = { path: name, - ...(await hashStableFile(path, { - signal, - retainFailedClose: (owner) => - request.retainFailedFileClose(owner, path), - })), + sha256: observed.sha256, + bytes: observed.bytes, }; + fileStates[role] = observed.state; } const complete = files as KunaExportFiles; + const states = fileStates as KunaProjectFileStates; + if ( + complete.inventory.sha256 !== + createHash("sha256").update(listed.snapshot.stdout.text).digest("hex") + ) + return unusable("the retained inventory changed before export sealing"); + await assertKunaProjectState(request.output, complete, states, signal); const functions = await readKunaExportSource( request.output, complete.c, @@ -316,6 +336,7 @@ export const exportKunaProject = async ( signal, ); if (!functions.ok) return unusable(functions.error); + await assertKunaProjectState(request.output, complete, states, signal); const identity = await verifyKunaExecutable( installation, signal, @@ -328,6 +349,7 @@ export const exportKunaProject = async ( kind: "done", result: ok({ files: complete, + fileStates: states, summary, functions: functions.value, inventory: inventory.value, @@ -355,6 +377,7 @@ export const readKunaExport = async ( directory: string, files: KunaExportFiles, summary: KunaExportSummary, + fileStates: KunaProjectFileStates, signal?: AbortSignal, ): Promise< Result< @@ -365,6 +388,7 @@ export const readKunaExport = async ( string > > => { + await assertKunaProjectState(directory, files, fileStates, signal); const functions = await readKunaExportSource( directory, files.c, @@ -379,5 +403,6 @@ export const readKunaExport = async ( }), ); if (!inventory.ok) return inventory; + await assertKunaProjectState(directory, files, fileStates, signal); return ok({ functions: functions.value, inventory: inventory.value }); }; diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index da04b7609e..ee4a31243e 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -1,3 +1,9 @@ +import type { RegularFileState } from "../filesystem/RegularFile.js"; +import { + RegularFileChangedError, + sameRegularFileState, +} from "../filesystem/RegularFile.js"; +import { assertKunaFileState } from "./KunaProjectState.js"; import { createReadStream } from "node:fs"; import { open } from "node:fs/promises"; import { join } from "node:path"; @@ -67,6 +73,7 @@ export interface KunaSpanOwnership { const readSpan = async ( operation: string, path: string, + expected: RegularFileState, offset: number, length: number, ownership: KunaSpanOwnership, @@ -101,10 +108,15 @@ const readSpan = async ( const owner = new OwnedFileHandle(opened.value); let read: Result; try { + if (!sameRegularFileState(expected, await owner.handle.stat())) + throw new RegularFileChangedError(path); const buffer = Buffer.alloc(length); const { bytesRead } = await owner.handle.read(buffer, 0, length, offset); if (bytesRead !== length) throw new Error(`${path} is shorter than its recorded span`); + if (!sameRegularFileState(expected, await owner.handle.stat())) + throw new RegularFileChangedError(path); + await assertKunaFileState(path, expected); read = ok(buffer.toString("utf8")); } catch (cause: unknown) { read = err(cause); @@ -136,12 +148,15 @@ const readSpan = async ( : await ownership.finishSpan(finish); return ok(value); } catch (cause: unknown) { - if (cause instanceof OwnedRegularFileOperationFailure) + if (cause instanceof OwnedRegularFileOperationFailure) { + if (cause.operationCause instanceof RegularFileChangedError) + throw cause.operationCause; return err( new ProviderAdapterError(KUNA_PROVIDER_ID, operation, { cause: cause.operationCause, }), ); + } if (cause instanceof RegularFileCleanupFailure) return err( spanCloseFailure( @@ -268,6 +283,11 @@ export class KunaProjectReader { operation: string, signal?: AbortSignal, ): Promise> { + await assertKunaFileState( + join(this.project.directory, this.project.manifest.files.c.path), + this.project.fileStates.c, + signal ?? this.lifetime.signal, + ); this.#blocks ??= new Map( this.project.functions.map((block) => [block.address, block]), ); @@ -296,6 +316,7 @@ export class KunaProjectReader { const text = await readSpan( operation, join(this.project.directory, this.project.manifest.files.c.path), + this.project.fileStates.c, block.cOffset, block.cLength, this.lifetime, @@ -329,6 +350,7 @@ export class KunaProjectReader { const text = await readSpan( operation, join(this.project.directory, this.project.manifest.files.asm.path), + this.project.fileStates.asm, span.offset, span.length, this.lifetime, @@ -362,13 +384,29 @@ export class KunaProjectReader { operation: string, signal: AbortSignal | undefined, ): Promise> { + const path = join( + this.project.directory, + this.project.manifest.files.asm.path, + ); + await assertKunaFileState( + path, + this.project.fileStates.asm, + signal ?? this.lifetime.signal, + ); this.#assembly ??= this.lifetime.own( scanKunaAssembly( createReadStream( join(this.project.directory, this.project.manifest.files.asm.path), { signal: this.lifetime.signal }, ), - ), + ).then(async (index) => { + await assertKunaFileState( + path, + this.project.fileStates.asm, + this.lifetime.signal, + ); + return index; + }), ); const index = await waitForAbortable(this.#assembly, signal); if (index === ABORTED) diff --git a/src/kuna/KunaProjectState.ts b/src/kuna/KunaProjectState.ts new file mode 100644 index 0000000000..6719f1d2f9 --- /dev/null +++ b/src/kuna/KunaProjectState.ts @@ -0,0 +1,39 @@ +import { stat } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertRegularFileActive, + RegularFileChangedError, + sameRegularFileState, + type RegularFileState, +} from "../filesystem/RegularFile.js"; +import type { KunaExportFiles } from "./KunaProjectExport.js"; + +/** Verified local states accompany digests in memory, never in the persisted manifest. */ +export type KunaProjectFileStates = Readonly< + Record +>; + +export const assertKunaFileState = async ( + path: string, + expected: RegularFileState, + signal?: AbortSignal, +): Promise => { + assertRegularFileActive(signal); + if (!sameRegularFileState(expected, await stat(path))) + throw new RegularFileChangedError(path); + assertRegularFileActive(signal); +}; + +export const assertKunaProjectState = async ( + directory: string, + files: KunaExportFiles, + states: KunaProjectFileStates, + signal?: AbortSignal, +): Promise => { + for (const role of Object.keys(files) as (keyof KunaExportFiles)[]) + await assertKunaFileState( + join(directory, files[role].path), + states[role], + signal, + ); +}; diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 050c33fca2..106c589805 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -1,3 +1,4 @@ +import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js"; import { AnalysisCleanupLease, AnalysisCleanupLeaseGroup, @@ -162,11 +163,13 @@ const waitFor = async ( : result; }; -/** A file the adopted export named is gone, e.g. evicted by another REA process. */ -const isMissing = (cause: unknown): boolean => +/** An adopted export file disappeared or changed since its digest was verified. */ +const isUnavailableProject = (cause: unknown): boolean => cause instanceof Error && "code" in cause && - (cause.code === "ENOENT" || cause.code === "ENOTDIR"); + (cause.code === "ENOENT" || + cause.code === "ENOTDIR" || + cause.code === "ESTALE"); /** Read through a call so a check before an await does not narrow later ones. */ const cancelled = (signal: AbortSignal | undefined): boolean => @@ -480,8 +483,14 @@ export class KunaSessionClient implements AnalysisClient { ), ); } catch (cause: unknown) { - if (cancelled(options.signal)) - return err(new AnalysisCancelledError(operation, { cause })); + if (cancelled(options.signal)) { + const cancellation = regularFileCancellation(cause); + return err( + new AnalysisCancelledError(operation, { + cause: cancellation === undefined ? cause : cancellation.reason, + }), + ); + } return err(failure(operation, cause)); } } @@ -1030,7 +1039,7 @@ export class KunaSessionClient implements AnalysisClient { source: { kind: "project-export", project: reader.project }, }); } catch (cause: unknown) { - if (!isMissing(cause) || recovered) throw cause; + if (!isUnavailableProject(cause) || recovered) throw cause; recovered = true; this.#invalidateProject(reader); await this.#startProject(installation); @@ -1592,7 +1601,7 @@ export class KunaSessionClient implements AnalysisClient { try { return await read(reader.value); } catch (cause: unknown) { - if (!isMissing(cause) || attempt !== 0) throw cause; + if (!isUnavailableProject(cause) || attempt !== 0) throw cause; this.#invalidateProject(reader.value); await this.#startProject(installation); } diff --git a/tests/boundary/providers/kuna/kunaProjectCache.test.ts b/tests/boundary/providers/kuna/kunaProjectCache.test.ts index 94ee0727f8..a4813b7ff8 100644 --- a/tests/boundary/providers/kuna/kunaProjectCache.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectCache.test.ts @@ -357,9 +357,9 @@ it("resolves a hash failure delivered after its retained owner was released", as const warmCache = newCache(installation); const failureRetained = deferred(); const deliverFailure = deferred(); - const hashFile = stableFileHash.hashStableFile; + const hashFile = stableFileHash.observeStableFile; const hash = vi - .spyOn(stableFileHash, "hashStableFile") + .spyOn(stableFileHash, "observeStableFile") .mockImplementation(async (...args) => { try { return await hashFile(...args); @@ -1145,3 +1145,23 @@ it.each(["missing", "corrupt"])( } }, ); + +it("invalidates a warm export when same-length content changes", async () => { + const { cache, installation, target } = await fixture( + "rea-kuna-warm-integrity-", + ); + try { + const built = await cache.build({ + installation, + ...(await target("program")), + }); + if (!built.ok) throw built.error; + const file = join(built.value.directory, built.value.manifest.files.c.path); + const original = await readFile(file, "utf8"); + await writeFile(file, original.replace("return 0", "return 9")); + expect(await cache.lookup(built.value.key)).toBeUndefined(); + await expect(readFile(file)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + expect(await cache.close()).toMatchObject({ ok: true }); + } +}); diff --git a/tests/boundary/providers/kuna/kunaProjectReaderConcurrency.test.ts b/tests/boundary/providers/kuna/kunaProjectReaderConcurrency.test.ts index f7743c15c5..c228ac7de4 100644 --- a/tests/boundary/providers/kuna/kunaProjectReaderConcurrency.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectReaderConcurrency.test.ts @@ -8,11 +8,9 @@ import { run, session } from "./kunaSessionFixture.js"; const reading = vi.hoisted(() => ({ asm: "", - c: "", held: false, gate: undefined as Promise | undefined, - missing: 0, - onMissing: undefined as (() => void) | undefined, + onSpanClosed: undefined as (() => void) | undefined, })); vi.mock("node:fs/promises", async (importOriginal) => { @@ -20,44 +18,39 @@ vi.mock("node:fs/promises", async (importOriginal) => { return { ...actual, open: async (...args: Parameters) => { - try { - const handle = await actual.open(...args); - if (String(args[0]) !== reading.asm || reading.held) return handle; - return new Proxy(handle, { - get(target, key) { - if (key === "read") - return async (...readArgs: unknown[]) => { - const result: unknown = await Reflect.apply( - target.read, - target, - readArgs, - ); - reading.held = true; - await reading.gate; - return result; - }; - const value: unknown = Reflect.get(target, key, target); - return typeof value === "function" ? value.bind(target) : value; - }, - }); - } catch (cause: unknown) { - if (String(args[0]) === reading.c) { - reading.missing += 1; - if (reading.missing === 2) reading.onMissing?.(); - } - throw cause; - } + const handle = await actual.open(...args); + if (String(args[0]) !== reading.asm || reading.held) return handle; + return new Proxy(handle, { + get(target, key) { + if (key === "close") + return async () => { + await target.close(); + reading.onSpanClosed?.(); + }; + if (key === "read") + return async (...readArgs: unknown[]) => { + const result: unknown = await Reflect.apply( + target.read, + target, + readArgs, + ); + reading.held = true; + await reading.gate; + return result; + }; + const value: unknown = Reflect.get(target, key, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); }, }; }); afterEach(() => { reading.asm = ""; - reading.c = ""; reading.held = false; reading.gate = undefined; - reading.missing = 0; - reading.onMissing = undefined; + reading.onSpanClosed = undefined; }); const barrier = () => { @@ -73,7 +66,7 @@ test.skipIf(process.platform === "win32")( async () => { const assembly = barrier(); const rebuild = barrier(); - const missing = barrier(); + const closed = barrier(); let exports = 0; const { cacheRoot, open } = await session( "rea-kuna-reader-eviction-", @@ -92,9 +85,8 @@ test.skipIf(process.platform === "win32")( const [key] = await readdir(join(cacheRoot, "projects")); const project = join(cacheRoot, "projects", key ?? ""); reading.asm = join(project, "project", "program.asm"); - reading.c = join(project, "project", "program.c"); reading.gate = assembly.promise; - reading.onMissing = missing.release; + reading.onSpanClosed = closed.release; dossier = run(client, "analyze_function", { procedure: "main" }); await expect.poll(() => reading.held).toBe(true); await rename(project, join(cacheRoot, "trash", "evicted-elsewhere")); @@ -107,9 +99,9 @@ test.skipIf(process.platform === "win32")( }); await expect.poll(() => exports).toBe(2); assembly.release(); - // The fixed reader attempts the missing C before retrying the entire - // report. The old reader instead completes with a single-function source. - await Promise.race([dossier, missing.promise]); + // Release the rebuild after the evicted span relinquishes its descriptor. + // Detection may happen through descriptor state or the missing path. + await Promise.race([dossier, closed.promise]); rebuild.release(); const result = await dossier; expect(result.rawResult).toMatchObject({ diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index e6035e9b4b..a58ce8b811 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -977,3 +977,69 @@ it("leaves no private copy behind when workspace setup fails", async () => { else process.env.TMPDIR = previous; } }); + +it.each([ + ["c", "overwrite"], + ["c", "replace"], + ["asm", "overwrite"], + ["asm", "replace"], +] as const)( + "rebuilds a changed %s export (%s) before producing dossier Evidence", + async (extension, mutation) => { + const { open, provider, kuna } = await session( + "rea-kuna-export-integrity-", + ); + const client = open(); + try { + const first = await run(client, "analyze_function", { + procedure: "main", + }); + const source = ( + first.rawResult as { + source: { + directory: string; + files: { file: string; file_sha256: string }[]; + }; + } + ).source; + const selected = source.files.find(({ file }) => + file.endsWith(`.${extension}`), + ); + if (selected === undefined) throw new Error("Missing exported facet"); + const path = join(source.directory, selected.file); + const original = await readFile(path, "utf8"); + const edited = + extension === "c" + ? original.replace("return 0", "return 9") + : original.replace("RET", "NOP"); + expect(edited).not.toBe(original); + expect(Buffer.byteLength(edited)).toBe(Buffer.byteLength(original)); + if (mutation === "replace") { + await writeFile(`${path}.replacement`, edited); + await rename(`${path}.replacement`, path); + } else await writeFile(path, edited); + const recovered = await run(client, "analyze_function", { + procedure: "main", + }); + const recoveredSource = (recovered.rawResult as { source: typeof source }) + .source; + for (const file of recoveredSource.files) { + const bytes = await readFile( + join(recoveredSource.directory, file.file), + ); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + file.file_sha256, + ); + } + expect(recovered.result).toEqual(first.result); + expect( + (await fakeKunaCalls(kuna)).filter( + ({ argv }) => argv[0] === "decompile-project", + ), + ).toHaveLength(2); + } finally { + expect(await client.close()).toMatchObject({ ok: true }); + expect(await provider.closeOwnedResources()).toMatchObject({ ok: true }); + } + }, +); diff --git a/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts b/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts index 91269af03a..3b2f906273 100644 --- a/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts @@ -26,6 +26,7 @@ const fault = vi.hoisted(() => ({ hold: undefined as Promise | undefined, releaseHold: (): void => undefined, afterRead: (): void => undefined, + afterStat: (): void => undefined, readRejection: undefined as { readonly cause: unknown } | undefined, handles: [] as FileHandle[], cause: Object.assign(new Error("controlled close refusal"), { code: "EIO" }), @@ -35,6 +36,11 @@ vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + stat: async (...args: Parameters) => { + const state = await actual.stat(...args); + if (String(args[0]) === fault.span && fault.reads > 0) fault.afterStat(); + return state; + }, open: async (...args: Parameters) => { const handle = await actual.open(...args); if (String(args[0]) !== fault.span) return handle; @@ -81,6 +87,7 @@ afterEach(() => { fault.hold = undefined; fault.releaseHold = () => undefined; fault.afterRead = () => undefined; + fault.afterStat = () => undefined; fault.readRejection = undefined; fault.handles = []; }); @@ -397,7 +404,7 @@ it("does not open a queued span after its caller aborts", async () => { } }); -it.each(["before close", "during close"] as const)( +it.each(["before close", "during verification", "during close"] as const)( "keeps the caller abort reason when span close succeeds (%s)", async (when) => { const reason = { requested: "span-close" }; @@ -417,6 +424,8 @@ it.each(["before close", "during close"] as const)( ); const abort = new AbortController(); if (when === "before close") fault.afterRead = () => abort.abort(reason); + else if (when === "during verification") + fault.afterStat = () => abort.abort(reason); else { fault.hold = new Promise((resolve) => { fault.releaseHold = resolve;