diff --git a/docs/go-binaries.md b/docs/go-binaries.md index 01cd0d1afc..46b6eddddb 100644 --- a/docs/go-binaries.md +++ b/docs/go-binaries.md @@ -5,6 +5,12 @@ modules, replacements and build settings are embedded in one selected local executable or library. It reads the file without running it and requires no Go installation, active binary target, Hopper, Ghidra or IDA. +If a stable read completes but descriptor finalization fails or receives +cancellation, the error retains the selected path, SHA-256 and byte length as +a `file-snapshot-hash` partial observation. It does not claim that Go metadata +was decoded. Failed cleanup stays incomplete until its exact owner confirms +release; cancellation keeps the caller's reason. + ```bash rea inspect-go-binary /absolute/path/to/application --json ``` diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 9f83929deb..b888bc83e1 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -1,7 +1,11 @@ import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + type StableArtifactSnapshot, +} from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFailure, + artifactFileCompletion, assertArtifactReadActive, bindArtifactFileCleanup, artifactFileCancellationCause, @@ -38,6 +42,7 @@ import { BinaryResourceFailure, } from "../native/metadata/BinaryContainer.js"; import { parseGoModuleBytes, parseGoModuleText } from "./GoModuleText.js"; +import { fileSnapshotPartialObservationSchema } from "../domain/fileSnapshotObservation.js"; /** Bundled format reader identity; embedded compiler versions are separate observed facts. */ export const GO_BINARY_PROVIDER_IDENTITY = { @@ -131,6 +136,20 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { const failureFor = (cause: unknown, path: string): AnalysisError => { const primary = failureForPrimary(artifactFilePrimaryFailure(cause), path); + const completed = artifactFileCompletion(cause); + if (completed !== undefined) { + // This inspect boundary acquires exactly one StableArtifactSnapshot before + // metadata decoding. Completion describes that read, not a decoded report. + const snapshot = completed.value as StableArtifactSnapshot; + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); + } if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/tests/boundary/go/goBinaryFinalization.test.ts b/tests/boundary/go/goBinaryFinalization.test.ts new file mode 100644 index 0000000000..7522082264 --- /dev/null +++ b/tests/boundary/go/goBinaryFinalization.test.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { GoBinaryProvider } from "../../../src/go/GoBinaryProvider.js"; +import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createGoBinaryFixture } from "../../fixtures/go/image.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["control", "close-failure", "close-cancel", "close-both"] as const)( + "retains completed Go acquisition through %s", + async (mode) => { + const root = await createTestTempDirectory("rea-go-finalization-"); + const path = join(root, "selected.elf"); + const bytes = createGoBinaryFixture().bytes; + await writeFile(path, bytes); + const controller = new AbortController(); + const reason = { request: "cancel during Go close" }; + const closeFailure = Object.assign(new Error("controlled Go close EIO"), { + code: "EIO", + }); + const original = OwnedFileHandle.prototype.close; + let owner: OwnedFileHandle | undefined; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementationOnce(async function (this: OwnedFileHandle) { + owner = this; + if (mode === "close-cancel" || mode === "close-both") + controller.abort(reason); + if (mode === "close-failure" || mode === "close-both") + throw closeFailure; + await original.call(this); + }); + const service = new GoBinaryService(new GoBinaryProvider()); + try { + const result = await service.inspect( + { path }, + { signal: controller.signal }, + ); + if (mode === "control") { + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + build_info: { go_version: "go1.26.0" }, + }); + } else { + if (result.ok) throw new Error("Expected finalization failure"); + const acquired = { + kind: "file-snapshot-hash", + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }; + expect(result.error.partialObservation).toEqual(acquired); + expect( + projectAnalysisError(result.error).details?.partial_observation, + ).toEqual(acquired); + const failedClose = mode === "close-failure" || mode === "close-both"; + expect(result.error.cleanupIncomplete).toBe(failedClose); + if (failedClose) expect(owner?.handle.fd).toBeGreaterThanOrEqual(0); + if (mode !== "close-failure") { + const primary = result.error.cleanupPrimary ?? result.error; + expect(primary).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + } + close.mockRestore(); + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); + } + expect(owner?.handle.fd).toBe(-1); + } finally { + close.mockRestore(); + await service.close(); + } + }, +);