From 2dd2ffcd4534e19a03db0f2e591663fe92ce5f42 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:03:02 +0800 Subject: [PATCH] fix(artifacts): bind operations to admitted sources --- .../binary/BinarySessionExecution.ts | 1 + src/artifacts/ArtifactProvider.ts | 8 + src/artifacts/ArtifactSelectedSource.ts | 33 +++ src/artifacts/AsarArtifactReader.ts | 8 +- .../extraction/ArtifactExtraction.ts | 36 ++- src/artifacts/inventory/classify.ts | 78 ++++- src/artifacts/inventory/types.ts | 2 + .../artifactAdmittedIdentityMcp.test.ts | 66 +++++ .../artifactAdmittedIdentity.test.ts | 272 ++++++++++++++++++ .../artifactProvider.archiveInventory.test.ts | 23 +- .../native/artifactProvider.capacity.test.ts | 15 +- .../artifactProvider.cleanupRetry.test.ts | 30 +- .../artifactProvider.directoryErrors.test.ts | 82 +++--- ...rtifactProvider.directoryInventory.test.ts | 29 +- .../artifactProvider.extraction.test.ts | 41 +-- ...tifactProvider.extractionAdmission.test.ts | 30 +- ...actProvider.extractionCompleteness.test.ts | 39 ++- .../artifactProvider.extractionFormat.test.ts | 104 ++++--- ...rtifactProvider.extractionIdentity.test.ts | 15 +- ...tifactProvider.universalExtraction.test.ts | 30 +- 20 files changed, 704 insertions(+), 238 deletions(-) create mode 100644 src/artifacts/ArtifactSelectedSource.ts create mode 100644 tests/acceptance/artifactAdmittedIdentityMcp.test.ts create mode 100644 tests/boundary/filesystem/artifactAdmittedIdentity.test.ts diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index d0c7f3f0c3..37c1f2551b 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -158,6 +158,7 @@ const isArtifactBundleSubject = ( subject: EvidenceSubjectTarget, target: BinaryTarget, ): boolean => + target.bundleInfoPlist !== undefined && (operation === "inventory_artifact" || operation === "inspect_artifact" || operation === "extract_artifact") && diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 79ac6138af..ca5ac153fd 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -311,6 +311,10 @@ class ArtifactClient implements AnalysisClient { const result = await extractArtifact( { inputPath: this.target.sourcePath ?? this.target.path, + expectedRootSha256: + this.target.bundleInfoPlist === undefined + ? this.target.sha256 + : undefined, selectedPath: this.target.artifactSelectedPath, inputFormat: this.target.format, outputRoot: parsed.output_root, @@ -506,6 +510,10 @@ class ArtifactClient implements AnalysisClient { options?: ExecutionOptions, ) { return inventoryArtifact(this.target.sourcePath ?? this.target.path, { + expectedRootSha256: + this.target.bundleInfoPlist === undefined + ? this.target.sha256 + : undefined, selectedPath: this.target.artifactSelectedPath, resourceScope: this.#resourceScope, environment: this.environment, diff --git a/src/artifacts/ArtifactSelectedSource.ts b/src/artifacts/ArtifactSelectedSource.ts new file mode 100644 index 0000000000..fa79e6a5c6 --- /dev/null +++ b/src/artifacts/ArtifactSelectedSource.ts @@ -0,0 +1,33 @@ +import { realpath } from "node:fs/promises"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; + +/** Verify selection spelling independently of canonical descriptor ownership. */ +export const assertArtifactSelectedSource = async ( + canonicalPath: string, + selectedPath: string | undefined, +): Promise => { + if (selectedPath === undefined) return; + const changed = (cause?: unknown) => + new ArtifactReaderFailure( + "integrity", + `Selected artifact no longer resolves to its admitted source: ${selectedPath}`, + { + userMessage: + "The selected artifact path no longer names the opened file. Reopen the selected artifact, then retry.", + ...(cause === undefined ? {} : { cause }), + }, + ); + let current: string; + try { + current = await realpath(selectedPath); + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code)) + ) + throw changed(cause); + throw cause; + } + if (current !== canonicalPath) throw changed(); +}; diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index bda90dd089..a0e5a6a8d0 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -1,3 +1,4 @@ +import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js"; import { appendCallerPath } from "../filesystem/callerPath.js"; import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js"; @@ -437,12 +438,7 @@ export class AsarArtifactReader implements ArtifactReader { // The packed pathname is fixed for this reader; member checks only need // to re-observe the caller's independently selected alias. this.#selectedCanonicalSource ??= await realpath(this.path); - const current = await realpath(selected); - if (this.#selectedCanonicalSource !== current) - throw new ArtifactReaderFailure( - "integrity", - `Selected ASAR archive no longer resolves to its admitted source: ${selected}`, - ); + await assertArtifactSelectedSource(this.#selectedCanonicalSource, selected); } async #openUnpackedStream( diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 2191297b1b..047dd9956b 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -1,3 +1,6 @@ +import { assertAdmittedRootDigest } from "../inventory/classify.js"; +import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; +import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js"; import { absoluteCallerPath } from "../../filesystem/callerPath.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { @@ -56,7 +59,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity export interface ArtifactExtractionInput { readonly asarHeaderAdmission?: AsarHeaderAdmission; readonly inputPath: string; - /** Selected ASAR spelling; packed I/O remains on inputPath. */ + readonly expectedRootSha256?: string | undefined; + /** Selected artifact spelling; packed I/O remains on inputPath. */ readonly selectedPath?: string | undefined; readonly inputFormat: BinaryTarget["format"]; readonly outputRoot: string; @@ -79,6 +83,8 @@ const extractArtifactInScope = async ( // Cancellation wins over every later refusal, including unsupported formats. abortIfNeeded(signal); const sourcePath = await realpath(input.inputPath); + if (input.expectedRootSha256 !== undefined) + await assertArtifactSelectedSource(sourcePath, input.selectedPath); const admission = await requireExtractableFormat(sourcePath, input, signal); const { snapshot, zipSource } = await scanArtifactInventoryForExtractionInScope( @@ -86,6 +92,7 @@ const extractArtifactInScope = async ( { resourceScope: input.resourceScope, selectedPath: input.selectedPath, + expectedRootSha256: input.expectedRootSha256, signal, environment: input.environment, asarHeaderAdmission: @@ -176,7 +183,7 @@ export const materializeArtifactInventoryInScope = async ( ) throw new AnalysisUnsupportedTargetError( "extract_artifact", - input.inputPath, + input.selectedPath ?? input.inputPath, `Godot PCK directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`, ); const selectedOccurrences = snapshot.occurrences.filter( @@ -218,7 +225,7 @@ export const materializeArtifactInventoryInScope = async ( if (occurrence.encrypted) throw new AnalysisUnsupportedTargetError( "extract_artifact", - input.inputPath, + input.selectedPath ?? input.inputPath, `Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`, ); // Inventory retains unavailable source occurrences, but extraction cannot @@ -377,6 +384,8 @@ const materializeSelection = async ({ traversal.snapshot(), reader.provenance(), ); + if (input.expectedRootSha256 !== undefined) + await assertArtifactSelectedSource(sourcePath, input.selectedPath); await output.commit(signal); return completed; } catch (cause: unknown) { @@ -550,7 +559,11 @@ const requireExtractableFormat = async ( path: string, input: Pick< ArtifactExtractionInput, - "inputPath" | "inputFormat" | "resourceScope" + | "inputPath" + | "selectedPath" + | "inputFormat" + | "resourceScope" + | "expectedRootSha256" >, signal?: AbortSignal, previous?: ArtifactEmbeddedFormatProbeFacts, @@ -591,13 +604,26 @@ const requireExtractableFormat = async ( ((format === "elf" || format === "pe" || format === "mach-o") && ((await probes.nodeSea()) !== undefined || (await probes.pyInstaller()))); + if (pack && input.expectedRootSha256 !== undefined) { + const digest = await hashStableRootArtifactHandle( + path, + source.handle, + source.initial, + signal, + ); + assertAdmittedRootDigest( + path, + input.expectedRootSha256, + digest.sha256, + ); + } return { pack, probeFacts: probes.completedFacts() }; }, ); if (!detected.pack) throw new AnalysisUnsupportedTargetError( "extract_artifact", - input.inputPath, + input.selectedPath ?? input.inputPath, `Artifact format has no extraction reader: ${format}`, ); return { directory, probeFacts: detected.probeFacts }; diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 8a31fea340..55ab3a3171 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; @@ -65,7 +66,11 @@ export const classifyAndHashRootForInventory = async ( expectedMetadata: Stats, options: Pick< ArtifactInventoryOptions, - "resourceScope" | "signal" | "asarHeaderAdmission" | "selectedPath" + | "resourceScope" + | "signal" + | "asarHeaderAdmission" + | "selectedPath" + | "expectedRootSha256" >, probeFacts?: ArtifactEmbeddedFormatProbeFacts, ): Promise => { @@ -76,6 +81,15 @@ export const classifyAndHashRootForInventory = async ( `Root artifact changed before inventory: ${path}`, ); } + if (directory && options.expectedRootSha256 !== undefined) + throw new ArtifactReaderFailure( + "integrity", + `Artifact root changed after target admission: expected an admitted file at ${path}, observed a directory`, + { + userMessage: + "The opened file was replaced by a directory. Select the intended artifact and reopen it before retrying.", + }, + ); if (directory) return { format: "directory", @@ -83,6 +97,8 @@ export const classifyAndHashRootForInventory = async ( rootSource: undefined, readerAdmission: undefined, }; + if (options.expectedRootSha256 !== undefined) + await assertArtifactSelectedSource(path, options.selectedPath); const source = await openRootArtifact( path, options.resourceScope, @@ -101,27 +117,46 @@ export const classifyAndHashRootForInventory = async ( "integrity", `Root artifact changed before inventory: ${path}`, ); + // Prior probe state can prove replacement without interpreting any bytes. + const probes = new ArtifactEmbeddedFormatProbes( + path, + source, + options.signal, + probeFacts, + ); + const admittedDigest = + options.expectedRootSha256 === undefined + ? undefined + : await hashStableRootArtifactHandle( + path, + handle, + initial, + options.signal, + ); + if (admittedDigest !== undefined) + assertAdmittedRootDigest( + path, + options.expectedRootSha256, + admittedDigest.sha256, + ); const classified = await classifyRootFormat( path, source, - new ArtifactEmbeddedFormatProbes( - path, - source, - options.signal, - probeFacts, - ), + probes, options.signal, options.asarHeaderAdmission, options.selectedPath, ); const format = typeof classified === "string" ? classified : classified.kind; - const digest = await hashStableRootArtifactHandle( - path, - handle, - initial, - options.signal, - ); + const digest = + admittedDigest ?? + (await hashStableRootArtifactHandle( + path, + handle, + initial, + options.signal, + )); const retainSource = isZipPackageFormat(format) || format === "mach-o-universal" || @@ -357,3 +392,20 @@ const readRootMzWindowsHeaderSignature = async ( if (available === 0) return undefined; return { offset, bytes: window.subarray(0, available) }; }; + +/** Compare physically completed bytes before interpreting an admitted file root. */ +export const assertAdmittedRootDigest = ( + path: string, + expected: string | undefined, + observed: string, +): void => { + if (expected !== undefined && observed !== expected) + throw new ArtifactReaderFailure( + "integrity", + `Artifact root changed after target admission at ${path}: expected ${expected}, observed ${observed}`, + { + userMessage: + "The artifact changed after it was opened. Reopen the selected artifact, then retry.", + }, + ); +}; diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts index 21fb73dc14..659cf43d74 100644 --- a/src/artifacts/inventory/types.ts +++ b/src/artifacts/inventory/types.ts @@ -13,6 +13,8 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = { /** Options shared by artifact inventory scans. */ export interface ArtifactInventoryOptions { readonly resourceScope: ArtifactResourceScope; + /** Identity of a previously admitted file root; directory roots have no file digest. */ + readonly expectedRootSha256?: string | undefined; /** Independently selected root spelling used only for ASAR companion lookup. */ readonly selectedPath?: string | undefined; readonly signal?: AbortSignal | undefined; diff --git a/tests/acceptance/artifactAdmittedIdentityMcp.test.ts b/tests/acceptance/artifactAdmittedIdentityMcp.test.ts new file mode 100644 index 0000000000..392f8203fd --- /dev/null +++ b/tests/acceptance/artifactAdmittedIdentityMcp.test.ts @@ -0,0 +1,66 @@ +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; +import { artifactExtractionResultSchema } from "../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +it("refuses changed admitted archives over stdio MCP before publishing extraction and accepts an explicit reopen", async () => { + const root = await createTestTempDirectory("rea-admitted-archive-mcp-"); + const temporary = join(root, "temporary"); + await mkdir(temporary); + const path = join(root, "selected.zip"); + const writeArchive = async (value: string) => { + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader(value)); + await writeFile(path, await zip.close()); + }; + await writeArchive("admitted version one"); + const { call } = await connectLocalToolsMcp({ + TMPDIR: temporary, + TEMP: temporary, + TMP: temporary, + REA_MCP_TOOL_FAMILIES: "session,artifact", + }); + expect((await call("open_binary", { path })).isError).not.toBe(true); + const openedTemporaryBaseline = (await readdir(temporary)).sort(); + await writeArchive("changed version two"); + for (const operation of ["inspect_artifact", "extract_artifact"]) { + const refused = parseMcpToolError(await call(operation, {})); + expect(refused.error).toMatchObject({ + code: "artifact_operation_failed", + details: { reason: "integrity" }, + message: expect.stringContaining("Reopen the selected artifact"), + }); + expect((await readdir(temporary)).sort()).toEqual(openedTemporaryBaseline); + } + expect((await call("open_binary", { path })).isError).not.toBe(true); + const extracted = await call("extract_artifact", {}); + expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true); + const result = artifactExtractionResultSchema.parse( + parseEvidence(extracted.structuredContent).normalized_result, + ); + try { + expect(dirname(result.output_root)).toBe(temporary); + expect( + await readFile(join(result.output_root, "payload.txt"), "utf8"), + ).toBe("changed version two"); + } finally { + await rm(result.output_root, { recursive: true, force: true }); + } + const reopenedTemporaryBaseline = (await readdir(temporary)).sort(); + await writeFile(path, "not an archive"); + for (const operation of ["inspect_artifact", "extract_artifact"]) { + const refused = parseMcpToolError(await call(operation, {})); + expect(refused.error).toMatchObject({ + code: "artifact_operation_failed", + details: { reason: "integrity" }, + }); + expect((await readdir(temporary)).sort()).toEqual( + reopenedTemporaryBaseline, + ); + } +}); diff --git a/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts b/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts new file mode 100644 index 0000000000..121efd7448 --- /dev/null +++ b/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts @@ -0,0 +1,272 @@ +import { build } from "plist"; +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { + machoImage, + CPU, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; +import { buildGodotPck, pckU64 } from "../../fixtures/godotPck.js"; +import { + access, + mkdir, + readFile, + writeFile, + unlink, + symlink, +} from "node:fs/promises"; +import { join } from "node:path"; +import { createPackage } from "@electron/asar"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../../src/domain/artifactGraph.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + { format: "zip", replacement: "valid" }, + { format: "apk", replacement: "valid" }, + { format: "asar", replacement: "valid" }, + { format: "zip", replacement: "malformed" }, + { format: "asar", replacement: "malformed" }, +])( + "binds $format $replacement archive operations to the admitted source before output creation", + async ({ format, replacement }) => { + const root = await createTestTempDirectory( + "rea-archive-admitted-identity-", + ); + const path = join(root, `selected.${format}`); + const source = join(root, "source"); + await mkdir(source); + const writeArchive = async (value: string) => { + if (format === "asar") { + await writeFile(join(source, "payload.txt"), value); + await createPackage(source, path); + } else { + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader(value)); + await writeFile(path, await zip.close()); + } + }; + await writeArchive("admitted version one"); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + const initial = await session.execute("inventory_artifact", {}); + if (!initial.ok) throw initial.error; + if (replacement === "malformed") await writeFile(path, "not an archive"); + else await writeArchive("changed version two"); + for (const operation of [ + "inventory_artifact", + "inspect_artifact", + "extract_artifact", + ] as const) { + const output = join(root, `refused-${operation}`); + const result = await session.execute( + operation, + operation === "extract_artifact" + ? { output_root: output, integrity_policy: "fail" } + : {}, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining("changed after target admission"), + }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } + if (replacement === "valid") { + const reopened = await session.open(path); + if (!reopened.ok) throw reopened.error; + const inventory = await session.execute("inventory_artifact", {}); + if (!inventory.ok) throw inventory.error; + expect( + artifactInventoryResultSchema.parse(inventory.value.result).manifest + .root_sha256, + ).not.toBe( + artifactInventoryResultSchema.parse(initial.value.result).manifest + .root_sha256, + ); + const extracted = await session.execute("extract_artifact", { + output_root: join(root, "reopened-output"), + integrity_policy: "fail", + }); + if (!extracted.ok) throw extracted.error; + const result = artifactExtractionResultSchema.parse( + extracted.value.result, + ); + expect( + await readFile(join(result.output_root, "payload.txt"), "utf8"), + ).toBe("changed version two"); + } + } finally { + expect(await session.close()).toEqual({ ok: true, value: null }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "rejects retargeted ZIP selections without attributing or publishing the old packed bytes", + async () => { + const root = await createTestTempDirectory("rea-admitted-zip-alias-"); + const stored = join(root, "stored.zip"); + const other = join(root, "other.zip"); + const selected = join(root, "selected.zip"); + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader("same bytes")); + const bytes = await zip.close(); + await writeFile(stored, bytes); + await writeFile(other, bytes); + await symlink(stored, selected); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(selected); + if (!opened.ok) throw opened.error; + await unlink(selected); + await symlink(other, selected); + for (const operation of [ + "inventory_artifact", + "extract_artifact", + ] as const) { + const output = join(root, operation); + const result = await session.execute( + operation, + operation === "extract_artifact" ? { output_root: output } : {}, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining( + "no longer resolves to its admitted source", + ), + }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } + } finally { + await session.close(); + } + }, +); + +it("rejects an admitted archive replaced by a directory", async () => { + const root = await createTestTempDirectory("rea-admitted-file-directory-"); + const path = join(root, "selected.zip"); + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader("original")); + await writeFile(path, await zip.close()); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + await unlink(path); + await mkdir(path); + await writeFile(join(path, "payload.txt"), "replacement directory"); + const output = join(root, "output"); + expect( + await session.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ ok: false, error: { reason: "integrity" } }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await session.close(); + } +}); + +it("keeps app bundle aggregate inventories live when resources change", async () => { + const root = await createTestTempDirectory("rea-admitted-bundle-resources-"); + const app = join(root, "Fixture.app"); + await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); + await writeFile( + join(app, "Contents", "MacOS", "fixture"), + machoImage({ cpu: CPU.arm64e }), + ); + await writeFile( + join(app, "Contents", "Info.plist"), + build({ CFBundlePackageType: "APPL", CFBundleExecutable: "fixture" }), + ); + const payload = join(app, "Contents", "payload.txt"); + await writeFile(payload, "first resource"); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(app); + if (!opened.ok) throw opened.error; + const initial = await session.execute("inventory_artifact", {}); + if (!initial.ok) throw initial.error; + await writeFile(payload, "second resource"); + const current = await session.execute("inventory_artifact", {}); + if (!current.ok) throw current.error; + expect( + artifactInventoryResultSchema.parse(current.value.result).manifest + .root_sha256, + ).not.toBe( + artifactInventoryResultSchema.parse(initial.value.result).manifest + .root_sha256, + ); + const extracted = await session.execute("extract_artifact", { + output_root: join(root, "output"), + }); + if (!extracted.ok) throw extracted.error; + const result = artifactExtractionResultSchema.parse(extracted.value.result); + expect( + await readFile( + join(result.output_root, "Contents", "payload.txt"), + "utf8", + ), + ).toBe("second resource"); + } finally { + await session.close(); + } +}); + +it("checks embedded native packs before acceptance while preserving unsupported fast refusal", async () => { + const root = await createTestTempDirectory("rea-admitted-native-preflight-"); + const path = join(root, "game"); + const writePack = async (text: string) => { + const pack = buildGodotPck(2, [ + { path: "res://payload.txt", data: Buffer.from(text) }, + ]); + await writeFile( + path, + Buffer.concat([ + elf(2, 1, 62), + pack, + pckU64(pack.length), + Buffer.from("GDPC"), + ]), + ); + }; + await writePack("first"); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + await writePack("second"); + const output = join(root, "output"); + expect( + await session.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining("changed after target admission"), + }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + await writeFile(path, "not a native pack"); + expect( + await session.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await session.close(); + } +}); diff --git a/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts index 79765e711a..506212f201 100644 --- a/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts +++ b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts @@ -98,7 +98,7 @@ describe("artifact archive inventory", () => { await writeFile(join(source, "main.js"), "console.log('ok');\n"); const asarPath = join(root, "app.asar"); await createPackage(source, asarPath); - const asarResult = await inventory(target(asarPath, "asar")); + const asarResult = await inventory(await target(asarPath)); expect(asarResult.occurrences).toEqual( expect.arrayContaining([ expect.objectContaining({ logical_path: "main.js" }), @@ -107,7 +107,7 @@ describe("artifact archive inventory", () => { const unpackedPath = join(root, "unpacked.asar"); await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" }); - const unpackedResult = await inventory(target(unpackedPath, "asar")); + const unpackedResult = await inventory(await target(unpackedPath)); expect(unpackedResult.occurrences).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -121,7 +121,7 @@ describe("artifact archive inventory", () => { "changed();\n", ); const corrupted = await new ArtifactProvider(process.env) - .createClient(target(unpackedPath, "asar")) + .createClient(await target(unpackedPath)) .execute("inventory_artifact", {}); expect(corrupted).toMatchObject({ ok: false, @@ -135,7 +135,7 @@ describe("artifact archive inventory", () => { }, }); const failedInspection = await new ArtifactProvider(process.env) - .createClient(target(unpackedPath, "asar")) + .createClient(await target(unpackedPath)) .execute("inspect_artifact", {}); expect(failedInspection).toMatchObject({ ok: false, @@ -151,13 +151,8 @@ const inventory = async (targetValue: BinaryTarget) => { return artifactInventoryResultSchema.parse(result.value.result); }; -const target = ( - path: string, - format: Extract["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: format === "directory" ? "asar" : format, -}); +const target = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.capacity.test.ts b/tests/boundary/providers/native/artifactProvider.capacity.test.ts index c9caa394da..357a1d631a 100644 --- a/tests/boundary/providers/native/artifactProvider.capacity.test.ts +++ b/tests/boundary/providers/native/artifactProvider.capacity.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { cArchiveExecutable, pyzArchive, @@ -28,17 +29,9 @@ it.each([1, 129])( })), }); await writeFile(path, bytes); - const client = new ArtifactProvider({}).createClient({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "executable", - format: "pe", - executableRole: "application", - managed: false, - architecture: "x86_64", - availableArchitectures: ["x86_64"], - }); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + const client = new ArtifactProvider({}).createClient(target.value); try { const result = await client.execute("inventory_artifact", {}); if (count === 1) { diff --git a/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts index d216d18cbc..629159bd0e 100644 --- a/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts +++ b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts @@ -5,6 +5,7 @@ import { createPackage } from "@electron/asar"; import { expect, it, onTestFinished, vi } from "vitest"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; import { AsarArtifactReader } from "../../../../src/artifacts/AsarArtifactReader.js"; import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js"; @@ -29,19 +30,16 @@ const createArchive = async () => { return { root, archive }; }; -const createClient = (path: string) => - new ArtifactProvider(process.env).createClient({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: "asar", - }); +const createClient = async (path: string) => { + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + return new ArtifactProvider(process.env).createClient(target.value); +}; it("retains a failed reader close, blocks new work, and retries the same reader", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalClose = AsarArtifactReader.prototype.close; const closeOwners: AsarArtifactReader[] = []; let readerCloseBlocked = true; @@ -109,7 +107,7 @@ it("retains a failed reader close, blocks new work, and retries the same reader" it("retains a failed rollback owner and preserves the materialization failure", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalWrite = SafeOutputTree.prototype.write; const originalRollback = SafeOutputTree.prototype.rollback; const rollbackOwners: SafeOutputTree[] = []; @@ -173,7 +171,7 @@ it("retains a tree whose setup failed after root identity was captured", async ( const { root, archive } = await createArchive(); const output = join(root, "output"); const blocker = join(output, "unowned.txt"); - const client = createClient(archive); + const client = await createClient(archive); const prototypeHandle = await open(join(root, "prototype-handle"), "w"); const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick< typeof prototypeHandle, @@ -213,7 +211,7 @@ it("retains a tree whose setup failed after root identity was captured", async ( it("waits for admitted extraction before close and rejects later work", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalWrite = SafeOutputTree.prototype.write; const started = deferred(); const finish = deferred(); @@ -247,7 +245,13 @@ it("waits for admitted extraction before close and rejects later work", async () const extraction = client.execute("extract_artifact", { output_root: output, }); - await started.promise; + await Promise.race([ + started.promise, + extraction.then((result) => { + if (!result.ok) throw result.error; + throw new Error("Extraction completed before reaching its write stage"); + }), + ]); const closing = client.close().then((result) => { closeFinished = true; return result; diff --git a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts index 97eecf0ac5..462a540acb 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts @@ -15,53 +15,55 @@ describe("artifact directory diagnostics", () => { "omits inaccessible directory descendants during inventory but refuses incomplete extraction", async () => { const root = await createTestTempDirectory("rea-directory-policy-"); - const source = join(root, "source"); + const source = join(root, "Fixture.app"); const blocked = join(source, "blocked"); const output = join(root, "output"); await mkdir(blocked, { recursive: true }); + await mkdir(join(source, "Contents", "MacOS"), { recursive: true }); + await writeFile( + join(source, "Contents", "Info.plist"), + "CFBundleExecutableFixture", + ); + await writeFile( + join(source, "Contents", "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); await writeFile(join(source, "readable.txt"), "retained bytes\n"); await writeFile(join(blocked, "hidden.txt"), "unexamined bytes\n"); await chmod(blocked, 0); try { - await withArtifactClient( - { - path: source, - sourcePath: source, - sha256: "0".repeat(64), - kind: "archive", - format: "asar", - }, - async (client) => { - const inventoried = await client.execute("inventory_artifact", {}); - if (!inventoried.ok) throw inventoried.error; - const inventory = artifactInventoryResultSchema.parse( - inventoried.value.result, - ); - expect(inventory.coverage.status).toBe("unknown"); - expect(inventory.occurrences).toContainEqual( - expect.objectContaining({ - logical_path: "readable.txt", - hash_status: "verified", - }), - ); - expect(inventory.limitations).toContainEqual( - expect.stringContaining("EACCES"), - ); - const extracted = await client.execute("extract_artifact", { - output_root: output, - }); - expect(extracted).toMatchObject({ - ok: false, - error: { - _tag: "ArtifactOperationError", - detail: expect.stringContaining("EACCES"), - }, - }); - await expect(access(output)).rejects.toMatchObject({ - code: "ENOENT", - }); - }, - ); + const target = await parseBinaryTarget(source); + if (!target.ok) throw target.error; + await withArtifactClient(target.value, async (client) => { + const inventoried = await client.execute("inventory_artifact", {}); + if (!inventoried.ok) throw inventoried.error; + const inventory = artifactInventoryResultSchema.parse( + inventoried.value.result, + ); + expect(inventory.coverage.status).toBe("unknown"); + expect(inventory.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: "readable.txt", + hash_status: "verified", + }), + ); + expect(inventory.limitations).toContainEqual( + expect.stringContaining("EACCES"), + ); + const extracted = await client.execute("extract_artifact", { + output_root: output, + }); + expect(extracted).toMatchObject({ + ok: false, + error: { + _tag: "ArtifactOperationError", + detail: expect.stringContaining("EACCES"), + }, + }); + await expect(access(output)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); } finally { await chmod(blocked, 0o700); } diff --git a/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts index 31f5915f7e..ae47f4e741 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts @@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { + CPU, + machoImage, +} from "../../../../src/artifacts/apple/MachoImage.fixture.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; @@ -18,7 +23,11 @@ describe("artifact directory inventory", () => { await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), { recursive: true, }); - const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]); + const machO = machoImage({ cpu: CPU.arm64 }); + await writeFile( + join(app, "Contents/Info.plist"), + 'CFBundleExecutableFixture', + ); await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, { mode: 0o755, }); @@ -30,7 +39,7 @@ describe("artifact directory inventory", () => { await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n"); await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside")); const client = new ArtifactProvider(process.env).createClient( - target(app, "directory"), + await target(app), ); const input = {}; const first = await client.execute("inventory_artifact", input); @@ -72,13 +81,9 @@ describe("artifact directory inventory", () => { }); }); -const target = ( - path: string, - format: Extract["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: format === "directory" ? "asar" : format, -}); +const target = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.bundleInfoPlist).toBe(join(path, "Contents/Info.plist")); + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 81d1dd9462..de78ae6e22 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { + CPU, + machoImage, +} from "../../../../src/artifacts/apple/MachoImage.fixture.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; import { @@ -19,13 +24,13 @@ describe("artifact extraction", () => { "refuses an invalid parent (%s) before acquiring output and permits a corrected request", async (parent) => { const root = await createTestTempDirectory("rea-extract-invalid-parent-"); - const source = join(root, "source"); + const source = join(root, "source.app"); await mkdir(source); await writeFile(join(source, "payload.txt"), "selected payload\n"); await writeFile(join(root, "file"), "parent is a file"); const output = join(root, "out"); - await withArtifactClient(target(source, "directory"), async (client) => { + await withArtifactClient(await target(source), async (client) => { const refused = await client.execute("extract_artifact", { output_root: `${root}/${parent}/out`, }); @@ -52,12 +57,12 @@ describe("artifact extraction", () => { it("keeps committed files after the owning client closes", async () => { const root = await createTestTempDirectory("rea-extract-client-close-"); - const source = join(root, "source"); + const source = join(root, "source.app"); await mkdir(source); await writeFile(join(source, "payload.txt"), "committed\n"); const output = join(root, "output"); const client = new ArtifactProvider(process.env).createClient( - target(source, "directory"), + await target(source), ); const result = await client.execute("extract_artifact", { @@ -72,11 +77,11 @@ describe("artifact extraction", () => { it("extracts all regular occurrences through an exclusively owned output tree", async () => { const root = await createTestTempDirectory("rea-extract-"); - const source = join(root, "source"); + const source = join(root, "source.app"); await mkdir(join(source, "assets"), { recursive: true }); await writeFile(join(source, "assets", "selected.js"), "selected();\n"); await writeFile(join(source, "assets", "ignored.js"), "ignored();\n"); - const targetValue = target(source, "directory"); + const targetValue = await target(source); const graph = await inventory(targetValue); expect( graph.occurrences.some( @@ -160,7 +165,7 @@ describe("artifact extraction", () => { expect((await readdir(root)).sort()).toEqual([ "output", "relocated-output", - "source", + "source.app", ]); }); }); @@ -172,13 +177,15 @@ const inventory = async (targetValue: BinaryTarget) => { return artifactInventoryResultSchema.parse(result.value.result); }; -const target = ( - path: string, - format: Extract["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: format === "directory" ? "asar" : format, -}); +/** A real bundle target has an executable identity distinct from its resource tree. */ +const target = async (path: string): Promise => { + await writeFile( + join(path, "Info.plist"), + 'CFBundleExecutableMain', + ); + await writeFile(join(path, "Main"), machoImage({ cpu: CPU.arm64 })); + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.bundleInfoPlist).toBe(join(path, "Info.plist")); + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts index 949d0046ae..45b3636190 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { access, appendFile, mkdir, + readFile, rename, rm, writeFile, @@ -33,7 +35,9 @@ it("refuses an unsupported source without hashing its bytes", async () => { { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "archive", format: "dmg", }, @@ -73,7 +77,9 @@ it("preserves cancellation arriving during the detection descriptor's final clos { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "archive", format: "dmg", }, @@ -118,7 +124,9 @@ it("keeps detection I/O failure distinct from unsupported format and closes its { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "archive", format: "dmg", }, @@ -150,7 +158,7 @@ it.each([ "changed same inode", "directory replacement", ] as const)( - "refuses to reuse completed detection facts after %s before hashing", + "refuses to reuse completed detection facts after %s before inventory output", async (change) => { const root = await createTestTempDirectory( "rea-extraction-admission-identity-", @@ -161,11 +169,13 @@ it.each([ buildGodotPck(3, [{ path: "res://main.gd", data: "extends Node\n" }]), ); await writeFile(source, bytes); + let hashesAtMutation: number | undefined; const close = OwnedFileHandle.prototype.close; const closeSpy = vi .spyOn(OwnedFileHandle.prototype, "close") .mockImplementationOnce(async function (this: OwnedFileHandle) { await close.call(this); + hashesAtMutation = hash.mock.calls.length; if (change === "changed same inode") await appendFile(source, "changed"); else if (change === "directory replacement") { @@ -183,7 +193,9 @@ it.each([ { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "executable", format: "elf", architecture: "x86_64", @@ -197,7 +209,9 @@ it.each([ ok: false, error: { _tag: "ArtifactOperationError", reason: "integrity" }, }); - expect(hash).not.toHaveBeenCalled(); + expect(hashesAtMutation).toBeDefined(); + // Initial admission may hash; facts known stale after close must not. + expect(hash).toHaveBeenCalledTimes(hashesAtMutation!); await expect(access(output)).rejects.toMatchObject({ code: "ENOENT", }); @@ -275,7 +289,9 @@ it.each([ { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "executable", format: "pe", executableRole: "application", diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index aa0371d389..12340e4d86 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -5,12 +5,12 @@ import { join } from "node:path"; import { createPackage, createPackageWithOptions } from "@electron/asar"; import { expect, it, vi } from "vitest"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { materializeArtifactInventory, scanArtifactInventory, } from "../../../fixtures/artifactInventory.js"; -import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { DirectoryArtifactReader } from "../../../../src/artifacts/DirectoryArtifactReader.js"; @@ -29,9 +29,7 @@ it.for([false, true])( "unrepresented", ); const snapshot = await scanArtifactInventory(source); - const result = await extract(source, join(root, "output")); - if (!result.ok) throw result.error; - const extracted = artifactExtractionResultSchema.parse(result.value.result); + const extracted = await extractDirectory(source, join(root, "output")); expect(extracted.coverage).toEqual(snapshot.coverage); expect(extracted.coverage.status).toBe( invalidName ? "unknown" : "complete", @@ -317,9 +315,7 @@ it("preserves every equal-content occurrence and copies a nested ASAR as its con await writeFile(join(source, "a.txt"), "same bytes\n"); await writeFile(join(source, "b.txt"), "same bytes\n"); const output = join(root, "output"); - const result = await extract(source, output); - if (!result.ok) throw result.error; - const extracted = artifactExtractionResultSchema.parse(result.value.result); + const extracted = await extractDirectory(source, output); expect(extracted.artifacts.map(({ relative_path }) => relative_path)).toEqual( ["a.txt", "app.asar", "b.txt"], ); @@ -350,11 +346,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr await rm(join(`${archive}.unpacked`, "native", "addon.node")); const nestedOutput = join(root, "nested-output"); - const nestedResult = await extract(source, nestedOutput); - if (!nestedResult.ok) throw nestedResult.error; - const nestedExtraction = artifactExtractionResultSchema.parse( - nestedResult.value.result, - ); + const nestedExtraction = await extractDirectory(source, nestedOutput); expect( nestedExtraction.extraction_manifest.selected_occurrence_ids, ).toHaveLength(1); @@ -369,7 +361,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr ).toBe(0); const activeOutput = join(root, "active-output"); - const activeResult = await extract(archive, activeOutput); + const activeResult = await extractArchive(archive, activeOutput); if (activeResult.ok) throw new Error("Unavailable active ASAR member must fail extraction"); expect(projectAnalysisError(activeResult.error)).toMatchObject({ @@ -396,13 +388,16 @@ const inventoriedExtraction = async (source: string, output: string) => { ); }; -const extract = (source: string, output: string) => - new ArtifactProvider(process.env) - .createClient({ - path: source, - sourcePath: source, - sha256: "0".repeat(64), - kind: "archive", - format: "asar", - }) +/** Raw directory completeness belongs to inventory/materialization, not file target admission. */ +const extractDirectory = async (source: string, output: string) => { + const materialize = await inventoriedExtraction(source, output); + return materialize(); +}; + +const extractArchive = async (source: string, output: string) => { + const target = await parseBinaryTarget(source); + if (!target.ok) throw target.error; + return new ArtifactProvider(process.env) + .createClient(target.value) .execute("extract_artifact", { output_root: output }); +}; diff --git a/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts b/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts index 0415b89625..e52ac5ed59 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts @@ -3,11 +3,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { elfExportImage } from "../../../fixtures/binaryDiagnostics/nativeExports.js"; import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { buildGodotPck, embedGodotPck } from "../../../fixtures/godotPck.js"; +import { buildGodotPck, pckU64 } from "../../../fixtures/godotPck.js"; import { withArtifactClient } from "../../../support/artifactClientScope.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { CPU, @@ -22,13 +24,17 @@ describe("artifact extraction format support", () => { it("refuses unsupported formats before inventory or output", async () => { const root = await createTestTempDirectory("rea-extract-format-"); const image = join(root, "Image.dmg"); - await writeFile(image, "not mounted"); + const udif = Buffer.alloc(512); + udif.write("koly"); + await writeFile(image, udif); const alias = join(root, "Alias.dmg"); await symlink(image, alias); const output = join(root, "output"); + const selected = await admittedTarget(alias); + expect(selected.format).toBe("dmg"); const extract = (signal?: AbortSignal) => new ArtifactProvider(process.env) - .createClient(archiveTarget(alias, "dmg")) + .createClient(selected) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output }), @@ -59,7 +65,7 @@ describe("artifact extraction format support", () => { const output = join(root, "output"); expect( await new ArtifactProvider(process.env) - .createClient(thinMachOTarget(binary)) + .createClient(await admittedTarget(binary)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output }), @@ -99,7 +105,7 @@ describe("artifact extraction format support", () => { ]), ); const result = await new ArtifactProvider(process.env) - .createClient(thinMachOTarget(binary)) + .createClient(await admittedTarget(binary)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -119,39 +125,40 @@ describe("artifact extraction reader selection", () => { const root = await createTestTempDirectory("rea-extract-embedded-pck-"); const binary = join(root, "game"); const output = join(root, "output"); + const pack = buildGodotPck(3, [ + { path: "res://main.gd", data: "extends Node\n" }, + ]); await writeFile( binary, - embedGodotPck( - buildGodotPck(3, [{ path: "res://main.gd", data: "extends Node\n" }]), - ), - ); - await withArtifactClient( - { - path: binary, - sourcePath: binary, - sha256: "0".repeat(64), - kind: "executable", - format: "elf", - architecture: "x86_64", - availableArchitectures: ["x86_64"], - }, - async (client) => { - const result = await client.execute("extract_artifact", { - output_root: output, - }); - if (!result.ok) throw result.error; - const extracted = artifactExtractionResultSchema.parse( - result.value.result, - ); - expect(extracted.manifest.root_format).toBe("godot-pck"); - expect( - extracted.artifacts.map(({ relative_path }) => relative_path), - ).toEqual(["main.gd"]); - expect(await readFile(join(output, "main.gd"), "utf8")).toBe( - "extends Node\n", - ); - }, + Buffer.concat([ + elfExportImage({ symbols: [] }), + pack, + pckU64(pack.length), + Buffer.from("GDPC"), + ]), ); + const selected = await admittedTarget(binary); + expect(selected).toMatchObject({ + kind: "executable", + format: "elf", + architecture: "x86_64", + }); + await withArtifactClient(await admittedTarget(binary), async (client) => { + const result = await client.execute("extract_artifact", { + output_root: output, + }); + if (!result.ok) throw result.error; + const extracted = artifactExtractionResultSchema.parse( + result.value.result, + ); + expect(extracted.manifest.root_format).toBe("godot-pck"); + expect( + extracted.artifacts.map(({ relative_path }) => relative_path), + ).toEqual(["main.gd"]); + expect(await readFile(join(output, "main.gd"), "utf8")).toBe( + "extends Node\n", + ); + }); }); it.each(["zip", "hap", "hsp", "app-pack"] as const)( @@ -164,7 +171,7 @@ describe("artifact extraction reader selection", () => { ); await writeOrderedZip(archive, ["payload.txt"]); const result = await new ArtifactProvider(process.env) - .createClient(archiveTarget(archive, format)) + .createClient(await admittedTarget(archive)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -182,23 +189,8 @@ describe("artifact extraction reader selection", () => { ); }); -const archiveTarget = ( - path: string, - format: Extract["format"], -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format, -}); - -const thinMachOTarget = (path: string): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "executable", - format: "mach-o", - architecture: "arm64", - availableArchitectures: ["arm64"], -}); +const admittedTarget = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts b/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts index 7f47b030cc..99e45631f1 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js"; @@ -16,7 +17,7 @@ describe("artifact extraction identity", () => { const source = join(root, "source.zip"); await writeOrderedZip(source, ["รค.js", "a.js", "Z.js"]); const result = await new ArtifactProvider(process.env) - .createClient(archiveTarget(source)) + .createClient(await archiveTarget(source)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -34,10 +35,8 @@ describe("artifact extraction identity", () => { }); }); -const archiveTarget = (path: string): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: "zip", -}); +const archiveTarget = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts index c89cc9dfce..c61e0a6bb3 100644 --- a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts @@ -4,11 +4,13 @@ import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { CPU_ARM64, universalMacho } from "../../../fixtures/universalMacho.js"; +import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; -import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; const sliceReader = vi.hoisted(() => ({ reached: vi.fn(), @@ -41,10 +43,21 @@ it("routes universal Mach-O extraction through slice inventory", async () => { ); const root = await createTestTempDirectory("rea-extract-universal-"); const image = join(root, "Universal"); - await writeFile(image, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0])); + await writeFile( + image, + universalMacho([ + { + bytes: thinMach(0xfeedfacf, CPU_ARM64), + cpuType: CPU_ARM64, + cpuSubtype: 0, + }, + ]).bytes, + ); + const target = await parseBinaryTarget(image, { hostArchitecture: "arm64" }); + if (!target.ok) throw target.error; const output = join(root, "output"); const result = await new ArtifactProvider(process.env) - .createClient(universalTarget(image)) + .createClient(target.value) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output }), @@ -61,14 +74,3 @@ it("routes universal Mach-O extraction through slice inventory", async () => { expect(sliceReader.reached).toHaveBeenCalledOnce(); await expect(access(output)).rejects.toThrow(); }); - -const universalTarget = (path: string): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "executable", - format: "mach-o-universal", - architecture: "arm64", - availableArchitectures: ["arm64"], - universalSlice: { offset: 0, size: 32 }, -});