diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 941b75d0f..a711f266b 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -24,6 +24,7 @@ import type { BinaryTarget } from "../domain/binaryTarget.js"; import { AnalysisCapabilityUnavailableError, AnalysisInputError, + AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../domain/artifactOperationError.js"; import { type AnalysisError } from "../domain/analysisErrorBase.js"; @@ -89,8 +90,8 @@ class ArtifactClient implements AnalysisClient { this.target.sourcePath === undefined || !this.target.sourcePath.toLowerCase().endsWith(".app") ) - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + operation, "decode_interface_builder requires an active .app bundle target", ); const limits = interfaceBuilderLimitsSchema.parse(parameters); @@ -135,8 +136,8 @@ class ArtifactClient implements AnalysisClient { bundlePath === undefined || (!standalone && !bundlePath.toLowerCase().endsWith(".app")) ) - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + operation, "inspect_keyed_archive requires an active plist or .app bundle", ); const result = await inspectBundleKeyedArchive({ @@ -214,6 +215,18 @@ class ArtifactClient implements AnalysisClient { return Promise.resolve(); } + /** The active target's kind is outside this operation's supported targets. */ + private unsupportedTarget( + operation: string, + reason: string, + ): AnalysisUnsupportedTargetError { + return new AnalysisUnsupportedTargetError( + operation, + this.target.sourcePath ?? this.target.path, + reason, + ); + } + private async inspectAssetCatalog( parameters: Readonly>, options?: ExecutionOptions, @@ -224,8 +237,8 @@ class ArtifactClient implements AnalysisClient { bundlePath === undefined || !bundlePath.toLowerCase().endsWith(".app") ) - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + "inspect_asset_catalog", "inspect_asset_catalog requires an active .app bundle target", ); const result = await analyzeAppleAssetCatalogs({ @@ -250,8 +263,8 @@ class ArtifactClient implements AnalysisClient { options?: ExecutionOptions, ) { if (this.target.kind !== "executable" || this.target.format !== "mach-o") - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + "trace_dylib_resolution", "trace_dylib_resolution requires an active Mach-O or .app bundle target", ); // Only a target opened from an app bundle directory carries its Info.plist; @@ -360,8 +373,13 @@ const translateFailure = ( cause.details, cause.message, ); - // Caller-selection failures are already typed; keep their correction details. - if (cause instanceof AnalysisInputError) return cause; + // Caller-selection and unsupported-target failures are already typed; keep + // their correction details instead of reducing them to an I/O failure. + if ( + cause instanceof AnalysisInputError || + cause instanceof AnalysisUnsupportedTargetError + ) + return cause; return new ArtifactOperationError(operation, "io"); }; diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 57e8a0066..4cd226831 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -22,6 +22,7 @@ import { type ArtifactNode, type ArtifactOccurrence, } from "../../domain/artifactGraph.js"; +import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../../domain/binaryTarget.js"; import { scanArtifactInventory } from "../inventory/ArtifactInventory.js"; @@ -38,6 +39,7 @@ export const extractArtifact = async ( signal?: AbortSignal, ): Promise => { const sourcePath = await realpath(input.inputPath); + await requireExtractableFormat(sourcePath, input.inputFormat); const snapshot = await scanArtifactInventory(sourcePath, { signal, }); @@ -65,15 +67,22 @@ export const extractArtifact = async ( nodes, }; const selected = selectedOccurrences.map((occurrence) => { + // REA never decrypts archive entries, so an encrypted entry makes the + // complete extraction unsupported rather than the archive invalid. + if (occurrence.encrypted) + throw new AnalysisUnsupportedTargetError( + "extract_artifact", + sourcePath, + `Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`, + ); if ( (occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") || occurrence.artifact_id === null || - occurrence.encrypted || occurrence.logical_path === "." ) throw new ArtifactReaderFailure( "format", - `Selected occurrence is not an extractable regular child file: ${occurrence.occurrence_id}`, + `Selected occurrence is not an extractable regular child file: ${occurrence.logical_path} (${occurrence.occurrence_id})`, ); const node = inventory.nodes.get(occurrence.artifact_id); if (node === undefined) @@ -240,26 +249,45 @@ const collectNodes = ( if (selected.has(item.artifact_id)) output.set(item.artifact_id, item); }; +const ZIP_FORMATS = ["ipa", "apk", "msix", "appx", "zip"] as const; + +const isZipFormat = ( + format: BinaryTarget["format"], +): format is (typeof ZIP_FORMATS)[number] => + (ZIP_FORMATS as readonly string[]).includes(format); + +/** + * Refuse a format without an extraction reader before inventory work starts. + * The target kind, not the host, is unsupported by extraction. + */ +const requireExtractableFormat = async ( + path: string, + format: BinaryTarget["format"], +): Promise => { + const directory = (await lstat(path)).isDirectory(); + if ( + !directory && + format !== "asar" && + format !== "mach-o" && + !isZipFormat(format) + ) + throw new AnalysisUnsupportedTargetError( + "extract_artifact", + path, + `Artifact format has no extraction reader: ${format}`, + ); + return directory; +}; + const createReader = async ( path: string, format: BinaryTarget["format"], ): Promise => { - if ((await lstat(path)).isDirectory()) + if (await requireExtractableFormat(path, format)) return new DirectoryArtifactReader(path); if (format === "asar") return new AsarArtifactReader(path); - if ( - format === "ipa" || - format === "apk" || - format === "msix" || - format === "appx" || - format === "zip" - ) - return new ZipArtifactReader(path, format); - if (format === "mach-o") return new MachOSliceArtifactReader(path); - throw new ArtifactReaderFailure( - "unavailable", - `Artifact format has no extraction reader: ${format}`, - ); + if (isZipFormat(format)) return new ZipArtifactReader(path, format); + return new MachOSliceArtifactReader(path); }; const preflight = (entry: ArtifactEntry): void => { diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 61b6b47c4..1796f1bc7 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -89,6 +89,22 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => { hash_status: "unavailable", }), ); + // REA does not decrypt entries, so complete extraction is unsupported. + const extractionRoot = join(root, "encrypted-output"); + expect( + await new ArtifactProvider() + .createClient(encryptedTarget.value) + .execute("extract_artifact", { output_root: extractionRoot }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisUnsupportedTargetError", + reason: expect.stringContaining("Archive entry secret.txt is encrypted"), + }, + }); + await expect(readFile(extractionRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); const corruptPath = join(root, "corrupt-crc.zip"); const corruptWriter = new ZipWriter(new Uint8ArrayWriter()); diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index e4cbfc4a2..2bb0d7076 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -126,7 +126,41 @@ it("rejects targets that are not Mach-O", async () => { arguments: {}, }); expect(called.isError).toBe(true); - expect(JSON.stringify(called.structuredContent)).toContain("unavailable"); + expect(called.structuredContent).toMatchObject({ + error: { code: "unsupported_target", category: "unsupported_target" }, + }); + }); +}); + +it("reports target kinds that artifact operations cannot inspect as unsupported", async () => { + const directory = await createTestTempDirectory("rea-artifact-target-"); + const plist = join(directory, "Info.plist"); + await writeFile(plist, ""); + const tool = join(directory, "tool"); + await writeFile(tool, machoImage({ commands: [buildVersionCommand(1)] })); + await withClient(async (client) => { + for (const [path, operation, requirement] of [ + [plist, "decode_interface_builder", "requires an active .app bundle"], + // Elsewhere the session reports asset catalogs as unavailable on the + // host before the provider checks the target kind. + ...(process.platform === "darwin" + ? ([ + [plist, "inspect_asset_catalog", "requires an active .app bundle"], + ] as const) + : []), + [tool, "inspect_keyed_archive", "requires an active plist or .app"], + ] as const) { + const open = { name: "open_binary", arguments: { path } }; + expect((await client.callTool(open)).isError, path).not.toBe(true); + const called = await client.callTool({ name: operation, arguments: {} }); + expect(called.structuredContent, operation).toMatchObject({ + error: { + code: "unsupported_target", + message: expect.stringContaining(`${operation} at ${path}`), + details: { reason: expect.stringContaining(requirement) }, + }, + }); + } }); }); diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 534ff59d1..509a34798 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -133,6 +133,27 @@ describe("artifact extraction", () => { "source", ]); }); + it("refuses a format without an extraction reader before inventory or output", async () => { + const root = await createTestTempDirectory("rea-extract-dmg-"); + const image = join(root, "Image.dmg"); + await writeFile(image, "not mounted"); + const output = join(root, "output"); + const result = await new ArtifactProvider() + .createClient(target(image, "dmg")) + .execute( + "extract_artifact", + artifactExtractionExecutionSchema.parse({ output_root: output }), + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisUnsupportedTargetError", + operation: "extract_artifact", + reason: "Artifact format has no extraction reader: dmg", + }, + }); + await expect(access(output)).rejects.toThrow(); + }); }); const inventory = async (targetValue: BinaryTarget) => { const result = await new ArtifactProvider()