diff --git a/docs/apk-signature.md b/docs/apk-signature.md index ffdcfc0e10..035d7224a9 100644 --- a/docs/apk-signature.md +++ b/docs/apk-signature.md @@ -239,13 +239,19 @@ serialized size of the report together with its limitations (which repeat archive names) is then checked against an 8 MiB output budget (the exact check is authoritative); an over-budget report is refused, not truncated. -The whole-file reader retains read chunks and a concatenated buffer; total -process memory exceeds the selected file's size. It also computes the complete +The whole-file reader allocates the selected snapshot and bounded read scratch; +total process memory exceeds the selected file's size. It also computes the complete file SHA-256, so inspection is not header-only. Content digests are recomputed at most once per SHA-256/SHA-512 algorithm during one inspection. Input, inflation, decoded-record and serialized-output bounds keep work finite; resource refusals are distinct from malformed format errors. +If the stable read completes but closing its descriptor fails or is cancelled, +the error retains `details.partial_observation` with the selected path, complete +file SHA-256 and byte count. These facts describe the acquired file; they do not +establish any decoded signing facts. Failed cleanup remains visible until the +provider releases the descriptor on a cleanup retry. + A file that is not a ZIP is refused as an unsupported target. A damaged ZIP (local headers but no end-of-central-directory), a central directory that cannot be walked or whose declared record count does not account for all of diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts index b625311fcc..12ac3854a2 100644 --- a/src/apkSignature/apkSignatureFailures.ts +++ b/src/apkSignature/apkSignatureFailures.ts @@ -1,4 +1,5 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { completedStableArtifact } from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -6,6 +7,7 @@ import { bindArtifactFileCleanup, } from "../artifacts/ArtifactFileOperation.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../domain/fileSnapshotObservation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { @@ -128,6 +130,16 @@ export const apkSignatureFailure = ( path: string, ): AnalysisError => { const primary = primaryFailure(artifactFilePrimaryFacts(cause), path); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + 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/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts index 28d8b40c37..7db127b149 100644 --- a/src/native/apple/AppleIpsProvider.ts +++ b/src/native/apple/AppleIpsProvider.ts @@ -1,6 +1,9 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + completedStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -10,6 +13,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import type { AppleIpsPort } from "../../application/binaryDiagnostics/AppleIpsPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -278,6 +282,16 @@ const appleIpsFailure = ( artifactFilePrimaryFacts(cause), input, ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index 7c8ec6529a..c755069b3c 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -1,6 +1,9 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + completedStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -9,6 +12,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import type { ElfNotesPort } from "../../application/binaryDiagnostics/ElfNotesPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; @@ -240,6 +244,16 @@ const elfNotesFailure = (cause: unknown, input: InspectElfNotesInput) => { artifactFilePrimaryFacts(cause), input, ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/native/elf/KernelModuleProvider.ts b/src/native/elf/KernelModuleProvider.ts index e937dfa978..21d85b55d9 100644 --- a/src/native/elf/KernelModuleProvider.ts +++ b/src/native/elf/KernelModuleProvider.ts @@ -1,6 +1,9 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + completedStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -9,6 +12,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import type { KernelModulePort } from "../../application/binaryDiagnostics/KernelModulePort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; @@ -235,6 +239,16 @@ const kernelModuleFailure = ( artifactFilePrimaryFacts(cause), input, ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index f361be9cec..6daef0dac6 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -1,6 +1,9 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + completedStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -10,6 +13,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import type { NativeExportsPort } from "../../application/binaryDiagnostics/NativeExportsPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -202,6 +206,16 @@ const nativeExportsFailure = ( artifactFilePrimaryFacts(cause), input, ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 45b339c80c..287daf6fe0 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -1,6 +1,9 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + completedStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -9,6 +12,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import type { PeCertificatesPort } from "../../application/binaryDiagnostics/PeCertificatesPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; @@ -192,6 +196,16 @@ const peCertificatesFailure = ( artifactFilePrimaryFacts(cause), input, ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 94d1b013bb..aa0e932b5a 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -1,6 +1,9 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + completedStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -10,6 +13,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import type { PeImportsPort } from "../../application/binaryDiagnostics/PeImportsPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -245,6 +249,16 @@ const peImportsFailure = ( artifactFilePrimaryFacts(cause), input, ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index b3a9f5d58f..b524ac7982 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,7 +1,10 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + readStableArtifact, + completedStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -10,6 +13,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; import type { PeResourcesPort } from "../../application/binaryDiagnostics/PeResourcesPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; @@ -212,6 +216,16 @@ const peResourcesFailure = (cause: unknown, input: InspectPeResourcesInput) => { artifactFilePrimaryFacts(cause), input, ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.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/providers/nativeArtifactFinalization.test.ts b/tests/boundary/providers/nativeArtifactFinalization.test.ts new file mode 100644 index 0000000000..0ae55f617a --- /dev/null +++ b/tests/boundary/providers/nativeArtifactFinalization.test.ts @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { inspectPeResourcesInputSchema } from "../../../src/domain/native/peResources.js"; +import { inspectPeCertificatesInputSchema } from "../../../src/domain/native/peCertificates.js"; +import { inspectPeImportsInputSchema } from "../../../src/domain/native/peImports.js"; +import { inspectElfNotesInputSchema } from "../../../src/domain/native/elfNotes.js"; +import { inspectKernelModuleInputSchema } from "../../../src/domain/native/kernelModule.js"; +import { inspectNativeExportsInputSchema } from "../../../src/domain/native/nativeExports.js"; +import { inspectAppleIpsInputSchema } from "../../../src/domain/native/appleIps.js"; +import { inspectApkSignatureInputSchema } from "../../../src/domain/apkSignature/apkSignatureQuery.js"; +import { PeResourcesProvider } from "../../../src/native/pe/PeResourcesProvider.js"; +import { PeCertificatesProvider } from "../../../src/native/pe/PeCertificatesProvider.js"; +import { PeImportsProvider } from "../../../src/native/pe/PeImportsProvider.js"; +import { ElfNotesProvider } from "../../../src/native/elf/ElfNotesProvider.js"; +import { KernelModuleProvider } from "../../../src/native/elf/KernelModuleProvider.js"; +import { NativeExportsProvider } from "../../../src/native/exports/NativeExportsProvider.js"; +import { AppleIpsProvider } from "../../../src/native/apple/AppleIpsProvider.js"; +import { ApkSignatureProvider } from "../../../src/apkSignature/ApkSignatureProvider.js"; +import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const cases = [ + { + name: "PE resources", + make: () => { + const provider = new PeResourcesProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectPeResourcesInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, + { + name: "PE certificates", + make: () => { + const provider = new PeCertificatesProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectPeCertificatesInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, + { + name: "PE imports", + make: () => { + const provider = new PeImportsProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectPeImportsInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, + { + name: "ELF notes", + make: () => { + const provider = new ElfNotesProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectElfNotesInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, + { + name: "kernel modules", + make: () => { + const provider = new KernelModuleProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectKernelModuleInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, + { + name: "native exports", + make: () => { + const provider = new NativeExportsProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectNativeExportsInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, + { + name: "Apple IPS", + make: () => { + const provider = new AppleIpsProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectAppleIpsInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, + { + name: "APK signatures", + make: () => { + const provider = new ApkSignatureProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectApkSignatureInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, +] as const; + +const modes = [ + "close-failure", + "close-cancel", + "close-cancel-null", + "close-both", +] as const; + +it.each( + cases.flatMap(({ name, make }) => + modes.map((mode) => ({ name, make, mode })), + ), +)( + "retains completed $name acquisition through $mode", + async ({ make, mode }) => { + const root = await createTestTempDirectory("rea-native-finalization-"); + const path = join(root, "selected.bin"); + // The descriptor must finalize before any format parser runs, so one + // physical file can exercise every acquisition boundary. + const bytes = peResourceFixture().bytes; + await writeFile(path, bytes); + const controller = new AbortController(); + const reason = + mode === "close-cancel-null" ? null : { request: "cancel during close" }; + const closeFailure = Object.assign(new Error("controlled 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-failure") controller.abort(reason); + if (mode === "close-failure" || mode === "close-both") + throw closeFailure; + await original.call(this); + }); + const provider = make(); + try { + const result = await provider.inspect(path, controller.signal); + 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 provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(owner?.handle.fd).toBe(-1); + } finally { + close.mockRestore(); + await provider.close(); + } + }, +);