From 6a6adf703fa44100643947d20fbc670f86260b1b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:21:16 +0800 Subject: [PATCH] fix(resources)!: inspect app resources without an executable Add a read-only app resource session shared by CLI and MCP. Preserve resource-root provenance without inventing executable identity, reject binary-only snapshots and operations, and retain failed cleanup owners across switches and retries. Keep filesystem admission failures typed and preserve cancellation ordering from observed cleanup facts. Document resource routing in the packaged investigation skill. BREAKING CHANGE: resource-only observations may have a null Evidence subject and null executable target digests. Clients must use resource_root and reported per-resource provenance instead. Refs #5589 --- .../skills/reverse-engineer-anything/SKILL.md | 4 + .../references/native-and-artifacts.md | 16 + docs/mcp-contracts.md | 11 + src/application/AnalysisProvider.ts | 48 +- src/application/AppBundleExecutable.ts | 58 ++ src/application/AppBundleResourceRoot.test.ts | 101 +++ src/application/DirectAnalysis.ts | 46 +- .../DirectAnalysisStatFailure.test.ts | 117 +++ src/application/FunctionAnalysisEvidence.ts | 34 +- .../android/AndroidAnalysisService.ts | 26 +- .../binary/AnalysisSnapshotCache.test.ts | 78 ++ .../binary/AnalysisSnapshotCache.ts | 21 +- src/application/binary/BinarySession.ts | 431 +++++++++- .../BinarySessionBundleResources.test.ts | 799 ++++++++++++++++++ .../binary/BinarySessionComposition.ts | 3 + .../binary/BinarySessionExecution.ts | 5 +- .../binary/BinarySessionLiveness.test.ts | 39 + src/application/binary/BinarySessionPort.ts | 6 + src/application/binary/BinarySessionStatus.ts | 73 +- .../binary/BundleResourceSession.test.ts | 106 +++ .../binary/BundleResourceSession.ts | 85 ++ .../firmware/FirmwareAnalysisService.ts | 2 +- .../javascript/JavaScriptRecoveryService.ts | 2 +- src/application/native/NativeValueTrace.ts | 2 +- .../sqlite/SqliteDatabaseService.ts | 2 +- src/artifacts/ArtifactProvider.ts | 121 ++- .../BundleResourceArtifactClient.test.ts | 60 ++ .../apple/AppleAssetCatalogAnalysis.ts | 9 +- .../apple/InterfaceBuilderAnalysis.ts | 6 +- src/artifacts/apple/KeyedArchiveReader.ts | 6 +- src/cliBundleResource.test.ts | 69 ++ src/composition/binary.ts | 73 +- src/contracts/sessionLifecycleInputs.ts | 5 + src/contracts/sessionToolContracts.ts | 10 +- src/contracts/toolContractExamples.ts | 1 + src/contracts/toolEffects.ts | 1 + src/contracts/toolOutputSchemaGroups.ts | 7 + src/domain/analysisView/analysisView.ts | 7 +- .../analysisView/interfaceBuilderView.test.ts | 44 + .../analysisView/interfaceBuilderView.ts | 23 +- src/domain/apple/appleAssetCatalog.test.ts | 28 + src/domain/apple/appleAssetCatalog.ts | 33 +- src/domain/apple/bundleResourceRoot.ts | 33 + .../apple/interfaceBuilderGraph.test.ts | 27 + src/domain/apple/interfaceBuilderGraph.ts | 25 +- src/domain/apple/keyedArchive.ts | 70 +- src/domain/native/nativeInspection.ts | 2 + src/domain/native/nativeInvestigationGraph.ts | 24 +- src/domain/pcap/pcapEvidence.ts | 2 +- src/native/NativeMacOSProvider.ts | 67 +- src/native/NativePlistResourceRoot.test.ts | 46 + src/native/NativePlistSource.ts | 16 + src/providers/AnalysisClientCleanup.ts | 26 +- src/server/BundleResourcesMcp.test.ts | 428 ++++++++++ src/server/registerEvidenceTools.ts | 4 +- src/server/registerSessionTools.ts | 34 + .../bundleResourceRootCliMcp.test.ts | 234 +++++ .../bundleResourceComposition.test.ts | 115 +++ 58 files changed, 3582 insertions(+), 189 deletions(-) create mode 100644 src/application/AppBundleResourceRoot.test.ts create mode 100644 src/application/DirectAnalysisStatFailure.test.ts create mode 100644 src/application/binary/BinarySessionBundleResources.test.ts create mode 100644 src/application/binary/BundleResourceSession.test.ts create mode 100644 src/application/binary/BundleResourceSession.ts create mode 100644 src/artifacts/BundleResourceArtifactClient.test.ts create mode 100644 src/cliBundleResource.test.ts create mode 100644 src/domain/apple/bundleResourceRoot.ts create mode 100644 src/native/NativePlistResourceRoot.test.ts create mode 100644 src/server/BundleResourcesMcp.test.ts create mode 100644 tests/acceptance/applications/bundleResourceRootCliMcp.test.ts create mode 100644 tests/boundary/providers/bundleResourceComposition.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 510ddb97fc..e283e35b62 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -94,6 +94,10 @@ explicit path or endpoint and do not need it. - Archive/package member inventory (ZIP/APK/IPA/MSIX/AppX or DMG): `open_binary` with the supplied local path; use `inspect_artifact` when its graph and findings help answer the question. +- Apple app plist, Interface Builder, asset catalog, or keyed archive resources: + `open_bundle_resources`, then the selected resource tool. This does not + require `CFBundleExecutable` or a deep provider. Resource sessions have no + executable digest or binary snapshot; use `open_binary` for native code. - Android APK code, classes, methods, or incoming references: `inspect_android_package`, then focused Android tools when advertised. Archive member inventory still uses the archive route above. diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 122e510a99..896378dc9a 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -11,6 +11,22 @@ Select columns, batch IDs and page individual facts; aliases remain unresolved. Unpaired observations do not prove additions/removals. Existing matched dossier pairs include inputs for `compare_functions`; otherwise analyze just chosen pairs. +## Apple app resources + +Use `open_bundle_resources` with the selected app path, then `inspect_plist`, +`decode_interface_builder`, `inspect_asset_catalog`, or `inspect_keyed_archive`. +The resource session reads the app without resolving `CFBundleExecutable` or +starting a deep provider. Preserve `resource_root` provenance, per-resource +hashes where reported, and null executable identity; a directory path is not +an executable digest. Export retained Evidence before closing or switching +roots. Binary analysis snapshots do not apply. Use `open_binary` to switch to +executable analysis, and `close_binary` to release either session kind. + +The four matching CLI commands accept the app path directly. For an archive +inside the app, use `inspect-keyed-archive APP_PATH RELATIVE_ARCHIVE_PATH`. +Host requirements for plist and compiled asset/Interface Builder tooling still +apply; a resource-only session does not establish provider availability. + ## Desktop installation directories When an installation directory is supplied and its runtime is unknown, use diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 0168c51ad8..845e8c8719 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -528,6 +528,17 @@ concrete version, selection source, and complete analysis profile through choices return typed selection details. A selected provider is never replaced automatically after a runtime failure. +`open_bundle_resources` selects a separate read-only app resource session +without resolving `CFBundleExecutable`, including when it is absent or +unresolvable. `binary_session` reports `session_kind: "bundle_resources"` and the +caller and canonical resource-root paths; it does not report a binary target or +deep-provider binding. `inspect_plist`, `decode_interface_builder`, +`inspect_asset_catalog`, and `inspect_keyed_archive` retain resource-root +provenance and use a null Evidence subject when executable identity is unknown. +Binary-only operations and binary analysis snapshots do not apply in this +session. `open_binary` can switch back to executable analysis, and +`close_binary` retires either session kind. + Each provider client receives `binary_session.analysis_run.run_id` before any provider startup, including clients whose startup fails. `process_lineage` is `not_observed` until a dynamic provider starts, then becomes `snapshots` with diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 70f5cc464e..70d61ddd4b 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -68,7 +68,11 @@ export interface AnalysisExecution { */ readonly referencesTruncated?: boolean; readonly locations: readonly EvidenceLocation[]; - readonly subject: EvidenceSubjectTarget | null; + /** + * Missing means the selected operation's default subject should be bound by + * its caller. Explicit null means the observation has no file subject. + */ + readonly subject?: EvidenceSubjectTarget | null; } interface CreateAnalysisExecutionOptions { @@ -79,7 +83,7 @@ interface CreateAnalysisExecutionOptions { readonly relationshipCoverage?: RelationshipCoverage; readonly referencesTruncated?: boolean; readonly locations?: readonly EvidenceLocation[]; - readonly subject?: EvidenceSubjectTarget; + readonly subject?: EvidenceSubjectTarget | null; } /** @@ -129,7 +133,7 @@ export function createAnalysisExecution( ? {} : { referencesTruncated: options.referencesTruncated }), locations: [...(options.locations ?? [])], - subject: options.subject ?? null, + ...(options.subject === undefined ? {} : { subject: options.subject }), }; } @@ -143,21 +147,27 @@ export const createAnalysisExecutionEvidence = ( execution: AnalysisExecution, confidence: "observed" | "derived", ): Evidence => - createEvidence(execution.subject ?? undefined, execution.provider, { - operation, - parameters: jsonObjectSchema.parse(input), - result: execution.result, - rawResult: execution.rawResult, - limitations: execution.limitations, - ...(execution.analysisProfile === undefined - ? {} - : { analysisProfile: execution.analysisProfile }), - ...(execution.relationshipCoverage === undefined - ? {} - : { relationshipCoverage: execution.relationshipCoverage }), - locations: execution.locations, - confidence, - }); + createEvidence( + execution.subject === undefined + ? undefined + : (execution.subject ?? undefined), + execution.provider, + { + operation, + parameters: jsonObjectSchema.parse(input), + result: execution.result, + rawResult: execution.rawResult, + limitations: execution.limitations, + ...(execution.analysisProfile === undefined + ? {} + : { analysisProfile: execution.analysisProfile }), + ...(execution.relationshipCoverage === undefined + ? {} + : { relationshipCoverage: execution.relationshipCoverage }), + locations: execution.locations, + confidence, + }, + ); /** Provider-neutral application capability for one analysis operation. */ export interface AnalysisOperationPort { @@ -206,6 +216,8 @@ export interface AnalysisClient extends AnalysisOperationPort { close( options?: Pick & { readonly retainDocument?: boolean; + /** Report each owned cleanup failure as soon as its owner observes it. */ + readonly onCleanupFailure?: (failure: AnalysisError) => void; }, ): Promise>; } diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index eff93d5dae..eb160ead28 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -14,6 +14,7 @@ import { import { decodeXmlPlistText } from "../domain/propertyListXmlText.js"; import { hasBinaryPlistSignature } from "../domain/apple/appleArchiveFormat.js"; import { err, ok, type Result } from "../domain/result.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; /** Decoded representation bound for one binary Info.plist. */ const BINARY_PLIST_DECODE_BYTES = 16 * 1024 * 1024; @@ -42,6 +43,18 @@ export interface ResolvedAppBundle { readonly infoPlist?: string; } +/** Resource-only identity for an app directory; it is not a BinaryTarget. */ +export interface ResolvedAppBundleResourceRoot { + /** Path selected by the caller, retained even when it is a symlink alias. */ + readonly callerPath: string; + /** Canonical directory whose resources are inspected. */ + readonly canonicalPath: string; + readonly infoPlistPath: string; + readonly layout: "contents" | "root"; + /** No executable was opened or hashed by resource-root discovery. */ + readonly executableSha256: string | null; +} + /** Filesystem operations used by the resolver, injectable for boundary tests. */ export interface AppBundleFileSystem { readonly readFile: (path: string) => Promise; @@ -90,6 +103,51 @@ export const resolveAppBundleExecutable = async ( ); }; +/** + * Resolve only the root and Info.plist layout needed for read-only resource + * inspection. This intentionally does not require or guess CFBundleExecutable. + */ +export const resolveAppBundleResourceRoot = async ( + path: string, + options: { readonly cwd?: string } = {}, +): Promise> => { + const callerPath = absoluteCallerPath( + path, + options.cwd === undefined ? {} : { cwd: options.cwd }, + ); + try { + const canonicalInput = await realpath(callerPath); + const directLayout = await presentLayout(canonicalInput); + const wrapped = + directLayout === undefined + ? await wrappedBundle(canonicalInput) + : undefined; + const root = wrapped ?? canonicalInput; + const layout = directLayout ?? (await presentLayout(root)); + if (layout === undefined) + return err( + new BinaryTargetError( + callerPath, + `app resource root has no supported Info.plist layout: ${callerPath}`, + ), + ); + const canonicalPath = await realpath(root); + return ok({ + callerPath, + canonicalPath, + infoPlistPath: join(canonicalPath, ...layout.plist), + layout: layout.plist[0] === "Contents" ? "contents" : "root", + executableSha256: null, + }); + } catch (cause: unknown) { + return err( + new BinaryTargetError(callerPath, errorReason(cause, callerPath), { + cause, + }), + ); + } +}; + const presentLayout = async ( bundle: string, ): Promise => { diff --git a/src/application/AppBundleResourceRoot.test.ts b/src/application/AppBundleResourceRoot.test.ts new file mode 100644 index 0000000000..9b8a53da5b --- /dev/null +++ b/src/application/AppBundleResourceRoot.test.ts @@ -0,0 +1,101 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { + createAnalysisExecution, + createAnalysisExecutionEvidence, +} from "./AnalysisProvider.js"; +import { resolveAppBundleResourceRoot } from "./AppBundleExecutable.js"; +import { bindExecutionTarget } from "./binary/BinarySessionExecution.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; + +const provider = { id: "test", name: "test", version: null } as const; +const target = { + path: "/Fixture.app/Contents/Info.plist", + sha256: "a".repeat(64), + kind: "artifact", + format: "plist", +} satisfies BinaryTarget; + +it("resolves resource roots without requiring or hashing CFBundleExecutable", async () => { + const temp = await mkdtemp(join(tmpdir(), "rea-app-resource-root-")); + try { + const bundle = join(temp, "Fixture.app"); + const contents = join(bundle, "Contents"); + const alias = join(temp, "Selected.app"); + await mkdir(contents, { recursive: true }); + await writeFile(join(contents, "Info.plist"), ""); + await symlink(bundle, alias); + + const resolved = await resolveAppBundleResourceRoot(alias); + + expect(resolved).toEqual({ + ok: true, + value: { + callerPath: alias, + canonicalPath: bundle, + infoPlistPath: join(bundle, "Contents", "Info.plist"), + layout: "contents", + executableSha256: null, + }, + }); + } finally { + await rm(temp, { recursive: true, force: true }); + } +}); + +it("resolves a flat bundle resource root without inferring executable identity", async () => { + const temp = await mkdtemp(join(tmpdir(), "rea-ios-resource-root-")); + try { + const bundle = join(temp, "Fixture.app"); + await mkdir(bundle); + await writeFile(join(bundle, "Info.plist"), ""); + + const resolved = await resolveAppBundleResourceRoot(bundle); + + expect(resolved).toMatchObject({ + ok: true, + value: { + callerPath: bundle, + canonicalPath: bundle, + infoPlistPath: join(bundle, "Info.plist"), + layout: "root", + executableSha256: null, + }, + }); + } finally { + await rm(temp, { recursive: true, force: true }); + } +}); + +it("distinguishes default target binding from an explicitly unbound observation", () => { + const defaultSubject = createAnalysisExecution({ ok: true }, provider); + const unbound = createAnalysisExecution({ ok: true }, provider, { + subject: null, + }); + + const bound = bindExecutionTarget( + { ok: true, value: defaultSubject }, + "inspect_plist", + target, + ); + const preserved = bindExecutionTarget( + { ok: true, value: unbound }, + "inspect_plist", + target, + ); + + expect(bound.ok && bound.value.subject).toEqual({ + path: target.path, + sha256: target.sha256, + format: "plist", + }); + expect(preserved.ok && preserved.value.subject).toBeNull(); + expect( + createAnalysisExecutionEvidence("inspect_plist", {}, unbound, "observed") + .subject, + ).toBeNull(); +}); diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 157309c616..d6255030ba 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -24,10 +24,14 @@ import { } from "../domain/evidenceErrors.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { access } from "node:fs/promises"; +import { access, stat } from "node:fs/promises"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { readAnalysisSnapshot } from "./binary/AnalysisSnapshotFiles.js"; import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js"; -import { parseBinaryTarget } from "./BinaryTargetResolver.js"; +import { + classifyBinaryTargetFilesystemFailure, + parseBinaryTarget, +} from "./BinaryTargetResolver.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { snapshotEvidenceForQuery, @@ -56,11 +60,13 @@ import type { AnalysisProviderSelector } from "../contracts/providerSelection.js import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; import { resolveXrefsAddress } from "./XrefsAddressResolution.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import type { BinaryTargetError } from "../domain/configurationErrors.js"; import { directAnalysisCleanupFailure, managedAnalysisCleanupFailure, withSessionCleanup, } from "./DirectAnalysisCleanup.js"; +import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.js"; type DirectAnalysisTool = | "annotate_native_function" @@ -97,7 +103,6 @@ const NATIVE_INSPECTION_OPERATIONS: ReadonlySet = new Set([ const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet = new Set( ARTIFACT_ANALYSIS_OPERATIONS, ); - /** * Open one binary, execute one tool, and always release provider resources. * Unlike MCP mode, every CLI invocation is intentionally isolated and does not @@ -249,6 +254,24 @@ const runAnalysis = async ( return withSessionCleanup( session, async () => { + let isDirectory = false; + if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) { + const inspected = await inspectBundleResourceInput(path); + if (!inspected.ok) return cliError(inspected.error); + isDirectory = inspected.value; + } + if (BUNDLE_RESOURCE_OPERATIONS.has(tool) && isDirectory) { + const opened = await session.openBundleResources(path, { signal }); + if (!opened.ok) return cliError(opened.error); + const execution = await session.execute(tool, arguments_, { signal }); + if (!execution.ok) return cliError(execution.error); + return createAnalysisExecutionEvidence( + tool, + arguments_, + execution.value, + "observed", + ); + } const prepared = await prepareSnapshot({ path, snapshotPath, @@ -493,7 +516,9 @@ const executeAnalysisTool = async (input: { } if (!result.ok) return { output: cliError(result.error) }; const evidence = createEvidence( - result.value.subject ?? input.openedTarget, + result.value.subject === undefined + ? input.openedTarget + : (result.value.subject ?? undefined), result.value.provider, { operation: tool, @@ -577,6 +602,19 @@ const prepareSnapshot = async (options: { return ok({ snapshot: loaded.value, target: target.value }); }; +const inspectBundleResourceInput = async ( + path: string, +): Promise> => { + const callerPath = absoluteCallerPath(path); + try { + return ok((await stat(callerPath)).isDirectory()); + } catch (cause: unknown) { + const failure = classifyBinaryTargetFilesystemFailure(callerPath, cause); + if (failure !== undefined) return err(failure); + throw cause; + } +}; + const allowsSnapshotReplay = ( route: SessionProviderRoute, tool: diff --git a/src/application/DirectAnalysisStatFailure.test.ts b/src/application/DirectAnalysisStatFailure.test.ts new file mode 100644 index 0000000000..0d82f7b493 --- /dev/null +++ b/src/application/DirectAnalysisStatFailure.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, it, vi } from "vitest"; + +const statFailures = vi.hoisted(() => new Map()); + +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + stat: vi.fn(async (...args: Parameters) => { + const failure = statFailures.get(String(args[0])); + if (failure !== undefined) throw failure; + return original.stat(...args); + }), + }; +}); + +import { parseConfig } from "../config/parseConfig.js"; +import { classifyBinaryTargetFilesystemFailure } from "./BinaryTargetResolver.js"; +import type { BinarySession } from "./binary/BinarySession.js"; +import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js"; +import { runProviderAnalysis } from "./DirectAnalysis.js"; + +afterEach(() => statFailures.clear()); + +it("retains the original filesystem cause on the shared typed error", () => { + const cause = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + const failure = classifyBinaryTargetFilesystemFailure( + "/tmp/rea-stat-failure-cause.app", + cause, + ); + + expect(failure?.cause).toBe(cause); + expect(failure?.systemCode).toBe("EACCES"); +}); + +it.each([ + { code: "ENOENT", projectedCode: "target_unavailable" }, + { code: "ENOTDIR", projectedCode: "target_unavailable" }, + { code: "EACCES", projectedCode: "access_denied", systemCode: "EACCES" }, + { code: "EPERM", projectedCode: "access_denied", systemCode: "EPERM" }, + { code: "EIO", projectedCode: "target_unavailable" }, +])( + "stops CLI resource dispatch on stat failure $code and preserves its path", + async ({ code, projectedCode, systemCode }) => { + const path = `/tmp/rea-stat-failure-${code}.app`; + const cause = Object.assign(new Error(`${code}: injected stat failure`), { + code, + }); + statFailures.set(path, cause); + const session = { + close: vi.fn(async () => ({ ok: true as const, value: null })), + open: vi.fn(), + openBundleResources: vi.fn(), + execute: vi.fn(), + } as unknown as BinarySession; + const dependencies: DirectAnalysisDependencies = { + readConfiguration: () => parseConfig({}), + createBinarySession: () => session, + createNativeDispatchBinarySession: () => session, + createArtifactBinarySession: () => session, + createNativeBinarySession: () => session, + createManagedBinarySession: () => session, + }; + + const output = await runProviderAnalysis( + dependencies, + path, + "inspect_keyed_archive", + { path: "Resources/Archive.plist" }, + ); + + expect(output).toMatchObject({ + code: projectedCode, + details: { + path, + reason: expect.stringContaining(cause.message), + ...(systemCode === undefined ? {} : { system_code: systemCode }), + }, + }); + expect(session.open).not.toHaveBeenCalled(); + expect(session.openBundleResources).not.toHaveBeenCalled(); + expect(session.execute).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + }, +); + +it("rethrows an unclassified stat failure instead of treating it as a file", async () => { + const path = "/tmp/rea-stat-failure-opaque.app"; + const cause = new Error("opaque filesystem failure"); + statFailures.set(path, cause); + const session = { + close: vi.fn(async () => ({ ok: true as const, value: null })), + open: vi.fn(), + openBundleResources: vi.fn(), + execute: vi.fn(), + } as unknown as BinarySession; + const dependencies: DirectAnalysisDependencies = { + readConfiguration: () => parseConfig({}), + createBinarySession: () => session, + createNativeDispatchBinarySession: () => session, + createArtifactBinarySession: () => session, + createNativeBinarySession: () => session, + createManagedBinarySession: () => session, + }; + + await expect( + runProviderAnalysis(dependencies, path, "inspect_keyed_archive", { + path: "Resources/Archive.plist", + }), + ).rejects.toBe(cause); + expect(session.open).not.toHaveBeenCalled(); + expect(session.openBundleResources).not.toHaveBeenCalled(); + expect(session.execute).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); +}); diff --git a/src/application/FunctionAnalysisEvidence.ts b/src/application/FunctionAnalysisEvidence.ts index 00ef766a8f..9042082137 100644 --- a/src/application/FunctionAnalysisEvidence.ts +++ b/src/application/FunctionAnalysisEvidence.ts @@ -39,19 +39,25 @@ export const executeFunctionAnalysisEvidence = async ( if (!dossier.ok) return dossier; const observation = execution.value; return ok( - createEvidence(observation.subject ?? target, observation.provider, { - operation: "analyze_function", - parameters: arguments_, - result: dossier.value, - rawResult: observation.rawResult, - limitations: observation.limitations, - locations: observation.locations, - ...(observation.relationshipCoverage === undefined - ? {} - : { relationshipCoverage: observation.relationshipCoverage }), - ...(observation.analysisProfile === undefined - ? {} - : { analysisProfile: observation.analysisProfile }), - }), + createEvidence( + observation.subject === undefined + ? target + : (observation.subject ?? undefined), + observation.provider, + { + operation: "analyze_function", + parameters: arguments_, + result: dossier.value, + rawResult: observation.rawResult, + limitations: observation.limitations, + locations: observation.locations, + ...(observation.relationshipCoverage === undefined + ? {} + : { relationshipCoverage: observation.relationshipCoverage }), + ...(observation.analysisProfile === undefined + ? {} + : { analysisProfile: observation.analysisProfile }), + }, + ), ); }; diff --git a/src/application/android/AndroidAnalysisService.ts b/src/application/android/AndroidAnalysisService.ts index 45c802dc96..4e2e5841d4 100644 --- a/src/application/android/AndroidAnalysisService.ts +++ b/src/application/android/AndroidAnalysisService.ts @@ -45,16 +45,22 @@ export class AndroidAnalysisService { if (!executed.ok) return executed; const execution = executed.value; return completeProviderEvidence( - createEvidence(execution.subject ?? target.value, execution.provider, { - operation, - parameters: jsonObjectSchema.parse(request.data.input), - result: execution.result, - rawResult: execution.rawResult, - limitations: execution.limitations, - locations: execution.locations, - confidence: - operation === "inspect_android_method" ? "derived" : "observed", - }), + createEvidence( + execution.subject === undefined + ? target.value + : (execution.subject ?? undefined), + execution.provider, + { + operation, + parameters: jsonObjectSchema.parse(request.data.input), + result: execution.result, + rawResult: execution.rawResult, + limitations: execution.limitations, + locations: execution.locations, + confidence: + operation === "inspect_android_method" ? "derived" : "observed", + }, + ), options?.signal, ); } diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 215d1b1dde..df77808dc6 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -175,6 +175,84 @@ it("rebinds replayed target subjects while preserving child artifact subjects", ).toMatchObject(childSubject); }); +it("binds an omitted subject to the selected target in snapshot replay", () => { + const cache = new AnalysisSnapshotCache(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const relocatedTarget = { ...target, path: "/tmp/relocated-default-target" }; + const parameters = { address: "0x2a00" }; + const execution = createAnalysisExecution( + { name: "default-bound" }, + ANALYSIS_SNAPSHOT_PROVIDER, + { analysisProfile: profile }, + ); + expect(execution).not.toHaveProperty("subject"); + cache.record({ + target, + profile, + operation: "procedure_info", + parameters, + execution, + }); + + const snapshot = cache.export( + target, + profile, + createEvidenceBundle([ + createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "procedure_info", + parameters, + result: { name: "default-bound" }, + analysisProfile: profile, + }), + ]), + ); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.entries[0]?.execution.subject?.path).toBe(target.path); + const reparsed = parseAnalysisSnapshot( + JSON.parse(serializeAnalysisSnapshot(snapshot.value)), + ); + expect(reparsed.entries[0]?.execution.subject?.path).toBe(target.path); + cache.clear(); + const imported = cache.import( + reparsed, + { target: relocatedTarget, profile }, + () => ok(0), + ); + expect(imported.ok).toBe(true); + expect( + cache.lookup(relocatedTarget, profile, "procedure_info", parameters) + ?.subject?.path, + ).toBe(relocatedTarget.path); +}); + +it("preserves an explicitly unbound subject through snapshot replay", () => { + const cache = new AnalysisSnapshotCache(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const parameters = { address: "0x3000" }; + cache.record({ + target, + profile, + operation: "procedure_info", + parameters, + execution: createAnalysisExecution( + { name: "unbound" }, + ANALYSIS_SNAPSHOT_PROVIDER, + { subject: null, analysisProfile: profile }, + ), + }); + + expect( + cache.lookup( + { ...target, path: "/tmp/relocated-unbound-target" }, + profile, + "procedure_info", + parameters, + )?.subject, + ).toBeNull(); +}); + describe("analysis snapshot cache capacity", () => { it("shares capacity across query kinds while allowing replacements and fresh partitions", () => { const cache = new AnalysisSnapshotCache(); diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 79e212cac6..cc64030b7d 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -254,7 +254,7 @@ export class AnalysisSnapshotCache { readonly limitations: readonly string[]; readonly relationshipCoverage?: AnalysisExecution["relationshipCoverage"]; readonly locations: AnalysisExecution["locations"]; - readonly subject: AnalysisExecution["subject"]; + readonly subject: NonNullable | null; }; }): void { this.select(input.target, input.profile); @@ -292,7 +292,7 @@ export class AnalysisSnapshotCache { if (cached === undefined) return undefined; const cachedSubject = cached.execution.subject; const subject = - cachedSubject === null + cachedSubject == null ? null : rebindEvidenceSubjectTargetPath( { @@ -328,7 +328,7 @@ export class AnalysisSnapshotCache { relationshipCoverage: cached.execution.relationship_coverage, }), locations: cached.execution.locations, - subject, + subject: subject ?? null, }); } @@ -357,7 +357,20 @@ export class AnalysisSnapshotCache { binding: snapshotBinding(profile), operation, parameters, - execution, + execution: { + ...execution, + subject: + execution.subject === undefined + ? { + path: target.path, + sha256: target.sha256, + format: target.format, + ...(target.architecture === undefined + ? {} + : { architecture: target.architecture }), + } + : execution.subject, + }, }); this.#entries.set(entry.query_id, entry); } diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 425c530126..ed8e64fbfa 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -7,9 +7,10 @@ import { } from "../../domain/analysisProfile.js"; import { AnalysisCancelledError, + AnalysisCapabilityUnavailableError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { createEvidence } from "../../domain/evidence.js"; @@ -48,7 +49,19 @@ import { } from "./BinarySessionExecution.js"; import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js"; +import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; +import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js"; +import { + resolveAppBundleResourceRoot, + type ResolvedAppBundleResourceRoot, +} from "../AppBundleExecutable.js"; +import { + BUNDLE_RESOURCE_OPERATIONS, + type ActiveBundleResourceSession, + type BundleResourceClientFactory, +} from "./BundleResourceSession.js"; const OFFICIAL_OPERATIONS: ReadonlySet = new Set( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), ); @@ -69,6 +82,56 @@ interface SessionBinding extends SessionRun { nativeSourceAdmitted: boolean; } +interface CleanupFailureTracker { + readonly error: AnalysisError | undefined; + readonly onFailure: (failure: AnalysisError) => void; +} + +const trackCleanupFailures = ( + signal: AbortSignal | undefined, + operation: string, +): CleanupFailureTracker => { + let error: AnalysisError | undefined; + let cleanupFailure: AnalysisError | undefined; + let cancellation: AnalysisCancelledError | undefined; + const observed = new Set(); + return { + get error() { + return error; + }, + onFailure: (failure) => { + if ( + observed.has(failure) || + (failure.cleanupPrimary !== undefined && + observed.has(failure.cleanupPrimary)) + ) + return; + observed.add(failure); + if (cleanupFailure === undefined) { + if (isAborted(signal)) + cancellation = new AnalysisCancelledError(operation, { + cause: signal?.reason, + }); + cleanupFailure = failure; + } else { + cleanupFailure = analysisErrorWithCleanupFailure( + cleanupFailure, + failure, + operation, + ); + } + error = + cancellation === undefined + ? cleanupFailure + : analysisErrorWithCleanupFailure( + cancellation, + cleanupFailure, + operation, + ); + }, + }; +}; + /** * Owns the single active target shared by CLI and MCP adapters. * @@ -94,6 +157,9 @@ export class BinarySession readonly #calls = new Map, number>(); readonly #promptCompletionControllers = new Set(); readonly #providerRouter: SessionProviderRouter; + readonly #bundleResourceFactory: BundleResourceClientFactory | undefined; + #activeBundleResources: ActiveBundleResourceSession | undefined; + #pendingBundleResourceCleanup: ActiveBundleResourceSession[] = []; readonly #runtimeUnavailability = new Map< string, { readonly reason: string } @@ -103,9 +169,161 @@ export class BinarySession constructor( providerRouter: SessionProviderRouter, records: InvestigationRecords = new InvestigationRecords(), + bundleResourceFactory?: BundleResourceClientFactory, ) { super(records); this.#providerRouter = providerRouter; + this.#bundleResourceFactory = bundleResourceFactory; + } + + /** Open an app resource root without requiring CFBundleExecutable. */ + openBundleResources( + path: string, + options: Pick = {}, + ): Promise> { + return this.#serialize(async (admittedThrough) => { + await this.#drainCalls(admittedThrough); + if (isAborted(options.signal)) + return err( + new AnalysisCancelledError("open_bundle_resources", { + cause: options.signal?.reason, + }), + ); + if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) + return err(this.#externalMutationCleanupFailure); + if (this.#bundleResourceFactory === undefined) + return err( + new AnalysisCapabilityUnavailableError( + "bundle-resources", + "open_bundle_resources", + "No app resource reader is configured.", + ), + ); + const resolved = await resolveAppBundleResourceRoot(path); + if (!resolved.ok) return resolved; + if (isAborted(options.signal)) + return err( + new AnalysisCancelledError("open_bundle_resources", { + cause: options.signal?.reason, + }), + ); + const pendingClosed = await this.#retryPendingCleanup(); + if (!pendingClosed.ok) return pendingClosed; + const pendingResourcesClosed = + await this.#retryPendingBundleResourceCleanup(); + if (!pendingResourcesClosed.ok) return pendingResourcesClosed; + if (isAborted(options.signal)) + return err( + new AnalysisCancelledError("open_bundle_resources", { + cause: options.signal?.reason, + }), + ); + const runId = randomUUID(); + let bundleClient: Awaited< + ReturnType + >; + try { + bundleClient = await this.#bundleResourceFactory.createClient( + resolved.value, + ); + } catch (cause: unknown) { + return err( + cause instanceof AnalysisError + ? cause + : new ProviderAdapterError( + "bundle-resources", + "open_bundle_resources", + { cause }, + ), + ); + } + const candidate: ActiveBundleResourceSession = { + root: resolved.value, + client: bundleClient.client, + capabilities: bundleClient.capabilities, + runId, + }; + const started = await candidate.client.execute("health", {}, options); + if (!started.ok) { + this.#pendingBundleResourceCleanup.push(candidate); + const closed = await this.#retryPendingBundleResourceCleanup(); + return err( + closed.ok + ? started.error + : analysisErrorWithCleanupFailure( + started.error, + closed.error, + "open_bundle_resources", + ), + ); + } + if (isAborted(options.signal)) { + const cancelled = new AnalysisCancelledError("open_bundle_resources", { + cause: options.signal?.reason, + }); + this.#pendingBundleResourceCleanup.push(candidate); + const closed = await this.#retryPendingBundleResourceCleanup(); + return err( + closed.ok + ? cancelled + : analysisErrorWithCleanupFailure( + cancelled, + closed.error, + "open_bundle_resources", + ), + ); + } + const cleanupFailures = trackCleanupFailures( + options.signal, + "open_bundle_resources", + ); + const closed = await this.#closeActive( + {}, + false, + cleanupFailures.onFailure, + ); + if (!closed.ok) { + this.#pendingBundleResourceCleanup.push(candidate); + const candidateClosed = await this.#retryPendingBundleResourceCleanup( + undefined, + cleanupFailures.onFailure, + ); + const primary = cleanupFailures.error ?? closed.error; + return err( + cleanupFailures.error ?? + (candidateClosed.ok + ? primary + : analysisErrorWithCleanupFailure( + primary, + candidateClosed.error, + "open_bundle_resources", + )), + ); + } + if (isAborted(options.signal)) { + const cancelled = new AnalysisCancelledError("open_bundle_resources", { + cause: options.signal?.reason, + }); + this.#pendingBundleResourceCleanup.push(candidate); + const candidateClosed = await this.#retryPendingBundleResourceCleanup( + undefined, + cleanupFailures.onFailure, + ); + return err( + cleanupFailures.error ?? + (candidateClosed.ok + ? cancelled + : analysisErrorWithCleanupFailure( + cancelled, + candidateClosed.error, + "open_bundle_resources", + )), + ); + } + this.#activeBundleResources = candidate; + this.resetSnapshotInvalidation(); + return resolved; + }); } /** @@ -123,7 +341,11 @@ export class BinarySession ): Promise | undefined> { const live = await this.#retainedClientLive(retained.client, options); if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); if (!live) return undefined; if (options.snapshot !== undefined) { const imported = this.importAnalysisSnapshot(options.snapshot); @@ -158,6 +380,7 @@ export class BinarySession /** External live state cannot be replayed by a direct or composed CLI workflow. */ allowsSnapshotReplay(operation: AnalysisOperation): boolean { + if (this.#activeBundleResources !== undefined) return false; const capabilities = this.#active?.route.capabilities; const descriptor = capabilities?.get(operation); if (descriptor !== undefined) return descriptor.cachePolicy !== "live"; @@ -201,7 +424,13 @@ export class BinarySession options: BinarySessionOpenOptions = {}, ): Promise> { if (isAborted(options.signal)) - return Promise.resolve(err(new AnalysisCancelledError("open_binary"))); + return Promise.resolve( + err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ), + ); return resolveSessionTarget({ router: this.#providerRouter, current: this.#active, @@ -285,7 +514,11 @@ export class BinarySession ): Promise> { if (options.loadSnapshot === undefined) return ok(options); if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); const loaded = await options.loadSnapshot(); if (!loaded.ok) return loaded; const { loadSnapshot: _loaded, ...rest } = options; @@ -300,13 +533,21 @@ export class BinarySession if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) return err(this.#externalMutationCleanupFailure); if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); const resolved = await resolve(); if (!resolved.ok) return resolved; const { target, route, sameTarget } = resolved.value; const { profile } = route; if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); const activeProfile = this.#active?.profile; const sameProfile = activeProfile === null || activeProfile === undefined @@ -314,11 +555,22 @@ export class BinarySession : profile !== null && analysisProfilesEqual(activeProfile, profile); await this.#drainCalls(admittedThrough); if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); const pendingClosed = await this.#retryPendingCleanup(); if (!pendingClosed.ok) return pendingClosed; + const pendingResourcesClosed = + await this.#retryPendingBundleResourceCleanup(); + if (!pendingResourcesClosed.ok) return pendingResourcesClosed; if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); const retained = sameTarget && sameProfile ? this.#active : undefined; if (retained !== undefined) { const fastPath = await this.#tryRetainedFastPath( @@ -363,10 +615,43 @@ export class BinarySession await this.#failedOpen( run, previous, - new AnalysisCancelledError("open_binary"), + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), ), ); } + const cleanupFailures = trackCleanupFailures(options.signal, "open_binary"); + if (this.#activeBundleResources !== undefined) { + this.#pendingBundleResourceCleanup.push(this.#activeBundleResources); + this.#activeBundleResources = undefined; + const retiredResources = await this.#retryPendingBundleResourceCleanup( + undefined, + cleanupFailures.onFailure, + ); + if (!retiredResources.ok) { + const primary = cleanupFailures.error ?? retiredResources.error; + const failed = await this.#failedOpen( + run, + previous, + primary, + cleanupFailures.onFailure, + ); + return err(cleanupFailures.error ?? failed); + } + } + if (isAborted(options.signal)) { + const cancelled = new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }); + const failed = await this.#failedOpen( + run, + previous, + cancelled, + cleanupFailures.onFailure, + ); + return err(cleanupFailures.error ?? failed); + } const profilelessWorkflowProfile = profile === null ? profilelessNativeDispatchProfile(route) : undefined; this.#active = { @@ -409,6 +694,14 @@ export class BinarySession ) => Promise>, ): Promise, AnalysisError>> { return this.#serialize(async (admittedThrough) => { + if (this.#activeBundleResources !== undefined) + return err( + new AnalysisCapabilityUnavailableError( + "bundle-resources", + operationName, + "External mutation requires an active executable analysis target.", + ), + ); if (isAborted(signal)) return err(new AnalysisCancelledError(operationName)); await this.#drainCalls(admittedThrough); @@ -499,14 +792,20 @@ export class BinarySession readonly retainProviderDocuments?: boolean; }, clearRecords = true, + onCleanupFailure?: (failure: AnalysisError) => void, ): Promise> { const previous = this.#active; + const previousResources = this.#activeBundleResources; this.#active = undefined; + this.#activeBundleResources = undefined; + if (previousResources !== undefined) + this.#pendingBundleResourceCleanup.push(previousResources); const closed = previous === undefined ? await this.#retryPendingCleanup( options.progress, options.retainProviderDocuments, + onCleanupFailure, ) : await this.#retireClient(previous, { ...(options.progress === undefined @@ -515,12 +814,20 @@ export class BinarySession ...(options.retainProviderDocuments === true ? { retainDocument: true } : {}), + ...(onCleanupFailure === undefined ? {} : { onCleanupFailure }), }); if (clearRecords) this.clearSessionRecords(); this.#clearRuntimeAvailability(); const fileResources = await this.closeWriteResources(); + if (!fileResources.ok) onCleanupFailure?.(fileResources.error); const candidateResources = await this.#providerRouter.closeOwnedResources(); + if (!candidateResources.ok) onCleanupFailure?.(candidateResources.error); + const bundleClosed = await this.#retryPendingBundleResourceCleanup( + options.progress, + onCleanupFailure, + ); const resourceFailures = [ + ...(bundleClosed.ok ? [] : [bundleClosed.error]), ...(fileResources.ok ? [] : [fileResources.error]), ...(candidateResources.ok ? [] : [candidateResources.error]), ]; @@ -544,22 +851,61 @@ export class BinarySession return resourceFailure === undefined ? ok(null) : err(resourceFailure); } + async #retryPendingBundleResourceCleanup( + progress?: ExecutionOptions["progress"], + onCleanupFailure?: (failure: AnalysisError) => void, + ): Promise> { + if (this.#pendingBundleResourceCleanup.length === 0) return ok(null); + const pending = this.#pendingBundleResourceCleanup; + const retained: ActiveBundleResourceSession[] = []; + let failure: AnalysisError | undefined; + for (const session of pending) { + const closed = await closeAnalysisClient( + session.client, + "bundle-resources", + { + ...(progress === undefined ? {} : { progress }), + ...(onCleanupFailure === undefined ? {} : { onCleanupFailure }), + }, + ); + if (!closed.ok) { + retained.push(session); + failure = + failure === undefined + ? closed.error + : analysisErrorWithCleanupFailure( + failure, + closed.error, + "close_binary", + ); + } + } + this.#pendingBundleResourceCleanup = retained; + return failure === undefined ? ok(null) : err(failure); + } + async #retireClient( run: SessionRun, options: Pick & { readonly retainDocument?: boolean; + readonly onCleanupFailure?: (failure: AnalysisError) => void; } = {}, ): Promise> { this.#pendingCleanup = { run, retainDocument: options.retainDocument === true, }; - return this.#retryPendingCleanup(options.progress); + return this.#retryPendingCleanup( + options.progress, + undefined, + options.onCleanupFailure, + ); } async #retryPendingCleanup( progress?: ExecutionOptions["progress"], retainDocument?: boolean, + onCleanupFailure?: (failure: AnalysisError) => void, ): Promise> { let pending = this.#pendingCleanup; if (pending === undefined) return ok(null); @@ -573,6 +919,7 @@ export class BinarySession { ...(progress === undefined ? {} : { progress }), ...(pending.retainDocument ? { retainDocument: true } : {}), + ...(onCleanupFailure === undefined ? {} : { onCleanupFailure }), }, ); if (closed.ok) this.#pendingCleanup = undefined; @@ -583,8 +930,12 @@ export class BinarySession run: SessionRun, previous: SessionBinding | undefined, primary: AnalysisError, + onCleanupFailure?: (failure: AnalysisError) => void, ): Promise { - const closed = await this.#retireClient(run); + const closed = await this.#retireClient( + run, + onCleanupFailure === undefined ? {} : { onCleanupFailure }, + ); if (!closed.ok) return analysisErrorWithCleanupFailure( primary, @@ -606,11 +957,22 @@ export class BinarySession /** Describe the active binary session. */ status(options: { readonly signal?: AbortSignal } = {}): Promise { - const run = this.#active ?? this.#pendingCleanup?.run; + const resourceRun = this.#activeBundleResources; + const run = + this.#active ?? + resourceRun ?? + this.#pendingBundleResourceCleanup[0] ?? + this.#pendingCleanup?.run; const client = run?.client; return binarySessionStatus({ ...options, target: this.#active?.target, + ...(resourceRun === undefined + ? {} + : { + bundleResourceRoot: resourceRun.root, + resourceCapabilities: resourceRun.capabilities, + }), route: this.#currentRoute(), router: this.#providerRouter, runtimeUnavailability: this.#runtimeUnavailability, @@ -620,6 +982,7 @@ export class BinarySession providerOperationHealth: client?.operationHealthSnapshot?.() ?? null, cleanupPending: this.#pendingCleanup !== undefined || + this.#pendingBundleResourceCleanup.length > 0 || this.hasPendingFileResources || this.#providerRouter.hasPendingOwnedResources(), }); @@ -632,6 +995,31 @@ export class BinarySession : structuredClone(this.#active.target); } + activeBundleResourceRoot(): ResolvedAppBundleResourceRoot | undefined { + const root = this.#activeBundleResources?.root; + return root === undefined ? undefined : structuredClone(root); + } + + override exportAnalysisSnapshot() { + if (this.#activeBundleResources !== undefined) + return err( + new AnalysisSnapshotMismatchError( + "Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.", + ), + ); + return super.exportAnalysisSnapshot(); + } + + override importAnalysisSnapshot(snapshot: AnalysisSnapshot) { + if (this.#activeBundleResources !== undefined) + return err( + new AnalysisSnapshotMismatchError( + "Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.", + ), + ); + return super.importAnalysisSnapshot(snapshot); + } + protected activeAnalysisBinding() { return this.#active; } @@ -723,6 +1111,21 @@ export class BinarySession transition, ); if (!transitioned.ok) return transitioned; + const resources = this.#activeBundleResources; + if (resources !== undefined) { + if (!BUNDLE_RESOURCE_OPERATIONS.has(name)) + return err( + new AnalysisCapabilityUnavailableError( + "bundle-resources", + name, + "This operation requires an executable analysis target; the active session contains only app resource access.", + ), + ); + const result = await resources.client.execute(name, arguments_, options); + if (!result.ok) return result; + const execution = { ...result.value, subject: null }; + return ok(execution); + } const prepared = prepareSessionExecution({ active: this.#active, operation: name, @@ -783,7 +1186,9 @@ export class BinarySession name !== "trace_dylib_resolution" ) { const evidence = createEvidence( - execution.value.subject ?? active.target, + execution.value.subject === undefined + ? active.target + : (execution.value.subject ?? undefined), execution.value.provider, { operation: name, diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts new file mode 100644 index 0000000000..376fa51ee0 --- /dev/null +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -0,0 +1,799 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; + +import { + createAnalysisExecution, + type AnalysisClient, + type ProviderIdentity, +} from "../AnalysisProvider.js"; +import { parseBinaryTarget } from "../BinaryTargetResolver.js"; +import { InvestigationRecords } from "../investigation/InvestigationRecords.js"; +import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js"; +import { BinarySession } from "./BinarySession.js"; +import { SessionProviderRouter } from "./SessionProviderRouter.js"; +import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js"; +import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js"; +import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { err } from "../../domain/result.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { createEvidence } from "../../domain/evidence.js"; +import { createAnalysisProfile } from "../../domain/analysisProfile.js"; +import { ok } from "../../domain/result.js"; +import { createTestProviderRouter } from "../../../tests/fixtures/binarySession.js"; + +const identity: ProviderIdentity = { + id: "bundle-resource-fixture", + name: "Bundle resource fixture", + version: "1", +}; +const binaryIdentity: ProviderIdentity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", +}; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("opens a resource-only bundle without executable identity and keeps snapshot operations inapplicable", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-session-")); + temporaryDirectories.push(rootPath); + await writeFile( + join(rootPath, "Info.plist"), + '', + ); + let openedRoot: string | undefined; + let closed = false; + const client: AnalysisClient = { + execute: async (operation) => { + if (operation === "health") + return { ok: true, value: createAnalysisExecution(null, identity) }; + return { + ok: true, + value: createAnalysisExecution( + { operation, target_sha256: null }, + identity, + ), + }; + }, + close: async () => { + closed = true; + return { ok: true, value: null }; + }, + }; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: (root) => { + openedRoot = root.canonicalPath; + return { client, capabilities: [] }; + }, + }, + ); + + const opened = await session.openBundleResources(rootPath); + expect(opened.ok).toBe(true); + if (!opened.ok) throw opened.error; + expect(opened.value.executableSha256).toBeNull(); + expect(openedRoot).toBe(opened.value.canonicalPath); + expect( + bundleResourceRootSchema.parse({ + caller_path: opened.value.callerPath, + canonical_path: opened.value.canonicalPath, + info_plist_path: opened.value.infoPlistPath, + layout: opened.value.layout, + executable_sha256: opened.value.executableSha256, + }).executable_sha256, + ).toBeNull(); + + const result = await session.execute("inspect_plist", {}); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.subject).toBeNull(); + const binaryOnly = await session.execute("binary_overview", {}); + expect(binaryOnly.ok).toBe(false); + expect(await session.status()).toMatchObject({ + open: true, + session_kind: "bundle_resources", + resource_root: { canonical_path: opened.value.canonicalPath }, + }); + const snapshot = session.exportAnalysisSnapshot(); + expect(snapshot.ok).toBe(false); + if (!snapshot.ok) + expect(snapshot.error).toBeInstanceOf(AnalysisSnapshotMismatchError); + const imported = session.importAnalysisSnapshot({} as AnalysisSnapshot); + expect(imported.ok).toBe(false); + if (!imported.ok) + expect(imported.error).toBeInstanceOf(AnalysisSnapshotMismatchError); + const closedResult = await session.close(); + expect(closedResult.ok).toBe(true); + expect(closed).toBe(true); +}); + +it("keeps the exact caller reason for cancellation before resource-root resolution", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-pre-cancel-")); + temporaryDirectories.push(rootPath); + const reason = { origin: "caller-pre-aborted" }; + const controller = new AbortController(); + controller.abort(reason); + let clientCreated = false; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => { + clientCreated = true; + throw new Error("pre-aborted call must not create a client"); + }, + }, + ); + const result = await session.openBundleResources(rootPath, { + signal: controller.signal, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: reason }, + }); + expect(clientCreated).toBe(false); +}); + +it("retains ownership when an unhealthy resource client cannot be closed", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cleanup-")); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let closeAttempts = 0; + const client: AnalysisClient = { + execute: async () => err(new ProviderAdapterError(identity.id, "health")), + close: async () => { + closeAttempts += 1; + return closeAttempts === 1 + ? err(new ProviderAdapterError(identity.id, "close_binary")) + : { ok: true, value: null }; + }, + }; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { createClient: () => ({ client, capabilities: [] }) }, + ); + + const opened = await session.openBundleResources(rootPath); + expect(opened.ok).toBe(false); + expect(closeAttempts).toBe(1); + expect(await session.status()).toMatchObject({ cleanup_pending: true }); + expect(await session.status()).toMatchObject({ open: false }); + + const closed = await session.close(); + expect(closed.ok).toBe(true); + expect(closeAttempts).toBe(2); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); +}); + +it("cancels a resource switch during prior-client retirement and closes its candidate", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-switch-")); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + + let closeStarted!: () => void; + const closing = new Promise((resolve) => { + closeStarted = resolve; + }); + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + let created = 0; + let candidateCloseCalls = 0; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => { + created += 1; + const candidate = created > 1; + return { + client: { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + if (candidate) candidateCloseCalls += 1; + else { + closeStarted(); + await closeGate; + } + return { ok: true, value: null }; + }, + }, + capabilities: [], + }; + }, + }, + ); + + expect((await session.openBundleResources(rootPath)).ok).toBe(true); + const controller = new AbortController(); + const reason = { phase: "prior-resource-retirement" }; + const opening = session.openBundleResources(rootPath, { + signal: controller.signal, + }); + await Promise.race([ + closing, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("prior resource client close did not start")), + 1_000, + ), + ), + ]); + controller.abort(reason); + releaseClose(); + + const result = await opening; + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); + if (!result.ok) expect(result.error.cause).toBe(reason); + expect(candidateCloseCalls).toBe(1); + expect(await session.status()).toMatchObject({ open: false }); + expect(await session.close()).toMatchObject({ ok: true }); +}); + +it("keeps cancellation primary when resource retirement fails and retries the owner", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-resource-cancel-close-failure-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let closeStarted!: () => void; + const closing = new Promise((resolve) => { + closeStarted = resolve; + }); + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + let created = 0; + let oldCloseAttempts = 0; + let candidateCloseAttempts = 0; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => { + created += 1; + const candidate = created > 1; + return { + client: { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + if (candidate) { + candidateCloseAttempts += 1; + return { ok: true, value: null }; + } + oldCloseAttempts += 1; + if (oldCloseAttempts === 1) { + closeStarted(); + await closeGate; + } + return oldCloseAttempts < 3 + ? err(new ProviderAdapterError(identity.id, "close_binary")) + : { ok: true, value: null }; + }, + }, + capabilities: [], + }; + }, + }, + ); + + expect((await session.openBundleResources(rootPath)).ok).toBe(true); + const controller = new AbortController(); + const reason = { phase: "resource-close-failed" }; + const opening = session.openBundleResources(rootPath, { + signal: controller.signal, + }); + await Promise.race([ + closing, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("resource close did not start")), + 1_000, + ), + ), + ]); + controller.abort(reason); + releaseClose(); + + const result = await opening; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected failed resource switch"); + expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cleanupPrimary?.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(true); + expect(candidateCloseAttempts).toBe(1); + expect(oldCloseAttempts).toBe(2); + expect(await session.status()).toMatchObject({ + open: false, + cleanup_pending: true, + }); + expect(await session.close()).toMatchObject({ ok: true }); + expect(oldCloseAttempts).toBe(3); +}); + +it("keeps an earlier resource close failure primary when a later cleanup is cancelled", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-resource-close-before-cancel-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let candidateCloseStarted!: () => void; + const candidateClosing = new Promise((resolve) => { + candidateCloseStarted = resolve; + }); + let releaseCandidateClose!: () => void; + const candidateCloseGate = new Promise((resolve) => { + releaseCandidateClose = resolve; + }); + let created = 0; + let oldCloseAttempts = 0; + const originalFailure = new ProviderAdapterError(identity.id, "close_binary"); + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => { + created += 1; + const candidate = created > 1; + return { + client: { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + if (candidate) { + candidateCloseStarted(); + await candidateCloseGate; + return { ok: true, value: null }; + } + oldCloseAttempts += 1; + return oldCloseAttempts === 1 + ? err(originalFailure) + : { ok: true, value: null }; + }, + }, + capabilities: [], + }; + }, + }, + ); + + expect((await session.openBundleResources(rootPath)).ok).toBe(true); + const controller = new AbortController(); + const reason = { phase: "after-physical-close-failure" }; + const opening = session.openBundleResources(rootPath, { + signal: controller.signal, + }); + await Promise.race([ + candidateClosing, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("candidate close did not start")), + 1_000, + ), + ), + ]); + controller.abort(reason); + releaseCandidateClose(); + + const result = await opening; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected failed resource switch"); + expect(result.error).toBe(originalFailure); + expect(result.error.cleanupPrimary).toBeUndefined(); + expect(oldCloseAttempts).toBe(2); + expect(await session.close()).toMatchObject({ ok: true }); +}); + +it("keeps cancellation primary when binary retirement fails before opening resources", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-binary-close-cancel-resource-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let closeStarted!: () => void; + const closing = new Promise((resolve) => { + closeStarted = resolve; + }); + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + let binaryCloseAttempts = 0; + let resourceCloseAttempts = 0; + const binaryClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" ? null : "fixture-name", + binaryIdentity, + ), + }), + close: async () => { + binaryCloseAttempts += 1; + if (binaryCloseAttempts === 1) { + closeStarted(); + await closeGate; + return err(new ProviderAdapterError(binaryIdentity.id, "close_binary")); + } + return { ok: true, value: null }; + }, + }; + const resourceClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" ? null : { operation, target_sha256: null }, + identity, + { subject: null }, + ), + }), + close: async () => { + resourceCloseAttempts += 1; + return { ok: true, value: null }; + }, + }; + const session = new BinarySession( + createTestProviderRouter(() => binaryClient, { + resolveAnalysisProfile: async () => + ok({ + profile: createAnalysisProfile( + { ...binaryIdentity, version: binaryIdentity.version ?? "1" }, + { fixture: true }, + ), + }), + }), + new InvestigationRecords(), + { + createClient: () => ({ client: resourceClient, capabilities: [] }), + }, + ); + expect((await session.open(process.execPath)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + + const controller = new AbortController(); + const reason = { phase: "binary-retirement" }; + const opening = session.openBundleResources(rootPath, { + signal: controller.signal, + }); + await Promise.race([ + closing, + new Promise((_, reject) => + setTimeout(() => reject(new Error("binary close did not start")), 1_000), + ), + ]); + controller.abort(reason); + releaseClose(); + + const result = await opening; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected failed resource open"); + expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cleanupPrimary?.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(true); + expect(resourceCloseAttempts).toBe(1); + expect(await session.status()).toMatchObject({ + open: false, + cleanup_pending: true, + }); + expect(await session.close()).toMatchObject({ ok: true }); + expect(binaryCloseAttempts).toBe(2); +}); + +it("keeps an earlier resource close failure primary when binary candidate cleanup is cancelled", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-resource-close-before-binary-cancel-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let candidateCloseStarted!: () => void; + const candidateClosing = new Promise((resolve) => { + candidateCloseStarted = resolve; + }); + let releaseCandidateClose!: () => void; + const candidateCloseGate = new Promise((resolve) => { + releaseCandidateClose = resolve; + }); + let resourceCloseAttempts = 0; + let binaryCloseAttempts = 0; + const originalFailure = new ProviderAdapterError(identity.id, "close_binary"); + const resourceClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" ? null : { operation, target_sha256: null }, + identity, + { subject: null }, + ), + }), + close: async () => { + resourceCloseAttempts += 1; + return resourceCloseAttempts === 1 + ? err(originalFailure) + : { ok: true, value: null }; + }, + }; + const binaryClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" ? null : "fixture-name", + binaryIdentity, + ), + }), + close: async () => { + binaryCloseAttempts += 1; + if (binaryCloseAttempts === 1) { + candidateCloseStarted(); + await candidateCloseGate; + } + return { ok: true, value: null }; + }, + }; + const router = createTestProviderRouter(() => binaryClient); + const route = router.initialRoute(); + const parsedTarget = await parseBinaryTarget(process.execPath); + if (!parsedTarget.ok) throw parsedTarget.error; + const session = new BinarySession(router, new InvestigationRecords(), { + createClient: () => ({ client: resourceClient, capabilities: [] }), + }); + expect((await session.openBundleResources(rootPath)).ok).toBe(true); + + const controller = new AbortController(); + const reason = { phase: "after-resource-close-failure" }; + const opening = session.openResolvedTarget( + { target: parsedTarget.value, route }, + { signal: controller.signal }, + ); + await Promise.race([ + candidateClosing, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("binary candidate close did not start")), + 1_000, + ), + ), + ]); + controller.abort(reason); + releaseCandidateClose(); + + const result = await opening; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected failed binary open"); + expect(result.error).toBe(originalFailure); + expect(result.error.cleanupPrimary).toBeUndefined(); + expect(binaryCloseAttempts).toBe(1); + expect(resourceCloseAttempts).toBe(1); + expect(await session.close()).toMatchObject({ ok: true }); + expect(resourceCloseAttempts).toBe(2); +}); + +it("keeps an earlier binary close failure primary when candidate cleanup is cancelled", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-binary-close-before-cancel-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let candidateCloseStarted!: () => void; + const candidateClosing = new Promise((resolve) => { + candidateCloseStarted = resolve; + }); + let releaseCandidateClose!: () => void; + const candidateCloseGate = new Promise((resolve) => { + releaseCandidateClose = resolve; + }); + let binaryCloseAttempts = 0; + let resourceCloseAttempts = 0; + const originalFailure = new ProviderAdapterError( + binaryIdentity.id, + "close_binary", + ); + const binaryClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" ? null : "fixture-name", + binaryIdentity, + ), + }), + close: async () => { + binaryCloseAttempts += 1; + return binaryCloseAttempts === 1 + ? err(originalFailure) + : { ok: true, value: null }; + }, + }; + const resourceClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" ? null : { operation, target_sha256: null }, + identity, + { subject: null }, + ), + }), + close: async () => { + resourceCloseAttempts += 1; + if (resourceCloseAttempts === 1) { + candidateCloseStarted(); + await candidateCloseGate; + } + return { ok: true, value: null }; + }, + }; + const session = new BinarySession( + createTestProviderRouter(() => binaryClient, { + resolveAnalysisProfile: async () => + ok({ + profile: createAnalysisProfile( + { ...binaryIdentity, version: binaryIdentity.version ?? "1" }, + { fixture: true }, + ), + }), + }), + new InvestigationRecords(), + { + createClient: () => ({ client: resourceClient, capabilities: [] }), + }, + ); + expect((await session.open(process.execPath)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + + const controller = new AbortController(); + const reason = { phase: "after-binary-close-failure" }; + const opening = session.openBundleResources(rootPath, { + signal: controller.signal, + }); + await Promise.race([ + candidateClosing, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("resource candidate close did not start")), + 1_000, + ), + ), + ]); + controller.abort(reason); + releaseCandidateClose(); + + const result = await opening; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected failed resource open"); + expect(result.error).toBe(originalFailure); + expect(result.error.cleanupPrimary).toBeUndefined(); + expect(binaryCloseAttempts).toBe(1); + expect(resourceCloseAttempts).toBe(1); + expect(await session.close()).toMatchObject({ ok: true }); + expect(binaryCloseAttempts).toBe(2); +}); + +it("cancels a binary switch during resource retirement and retains null-subject Evidence", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-binary-")); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let resourceCloseStarted!: () => void; + const closing = new Promise((resolve) => { + resourceCloseStarted = resolve; + }); + let releaseResourceClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseResourceClose = resolve; + }); + let resourceCloseAttempts = 0; + const binaryClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" + ? null + : { document: "fixture", procedure_count: 0, string_count: 0 }, + binaryIdentity, + ), + }), + close: async () => ({ ok: true, value: null }), + }; + const resourceClient: AnalysisClient = { + execute: async (operation) => ({ + ok: true, + value: createAnalysisExecution( + operation === "health" ? null : { operation, target_sha256: null }, + identity, + { subject: null }, + ), + }), + close: async () => { + resourceCloseAttempts += 1; + resourceCloseStarted(); + if (resourceCloseAttempts === 1) { + await closeGate; + return err(new ProviderAdapterError(identity.id, "close_binary")); + } + return { ok: true, value: null }; + }, + }; + const session = new BinarySession( + createTestProviderRouter(() => binaryClient, { + resolveAnalysisProfile: async () => + ok({ + profile: createAnalysisProfile( + { ...binaryIdentity, version: binaryIdentity.version ?? "1" }, + { fixture: true }, + ), + }), + }), + new InvestigationRecords(), + { + createClient: () => ({ client: resourceClient, capabilities: [] }), + }, + ); + expect((await session.openBundleResources(rootPath)).ok).toBe(true); + const observed = await session.execute("inspect_plist", {}); + expect(observed.ok).toBe(true); + if (!observed.ok) throw observed.error; + const recorded = session.recordEvidence( + createEvidence(undefined, identity, { + operation: "inspect_plist", + parameters: {}, + result: observed.value.result, + }), + ); + expect(recorded.ok).toBe(true); + const recordsBefore = session.exportEvidenceBundle().records; + expect(recordsBefore).toHaveLength(1); + const evidenceId = recordsBefore[0]?.evidence_id; + if (evidenceId === undefined) throw new Error("Expected retained Evidence"); + + const controller = new AbortController(); + const reason = { phase: "resource-retirement" }; + const opening = session.open(process.execPath, { signal: controller.signal }); + await Promise.race([ + closing, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("resource client close did not start")), + 1_000, + ), + ), + ]); + controller.abort(reason); + releaseResourceClose(); + + const result = await opening; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancelled binary open"); + expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cleanupPrimary?.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(true); + expect(await session.status()).toMatchObject({ open: false }); + expect(session.exportEvidenceBundle().records).toHaveLength(1); + expect(session.evidenceById(evidenceId)).toMatchObject({ subject: null }); + expect(await session.status()).toMatchObject({ cleanup_pending: true }); + expect(await session.close()).toMatchObject({ ok: true }); + expect(resourceCloseAttempts).toBe(2); +}); diff --git a/src/application/binary/BinarySessionComposition.ts b/src/application/binary/BinarySessionComposition.ts index 6131a84cd3..233754696a 100644 --- a/src/application/binary/BinarySessionComposition.ts +++ b/src/application/binary/BinarySessionComposition.ts @@ -3,6 +3,7 @@ import type { AnalysisProvider } from "../AnalysisProvider.js"; import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js"; import { BinarySession } from "./BinarySession.js"; import { SessionProviderRouter } from "./SessionProviderRouter.js"; +import type { BundleResourceClientFactory } from "./BundleResourceSession.js"; /** * Compose one provider-neutral session from deep-provider selection and @@ -11,8 +12,10 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js"; export const composeBinarySession = ( registry: AnalysisProviderRegistry, auxiliaryProviders: readonly AnalysisProvider[] = [], + bundleResourceFactory?: BundleResourceClientFactory, ): BinarySession => new BinarySession( SessionProviderRouter.selectable(registry, auxiliaryProviders), new InvestigationRecords(), + bundleResourceFactory, ); diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index 73dfe4eeae..7f42ba3259 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -134,6 +134,7 @@ export const bindExecutionTarget = ( : { architecture: target.architecture }), }; if ( + result.value.subject !== undefined && result.value.subject !== null && result.value.subject.sha256 !== target.sha256 && !isArtifactBundleSubject(operation, result.value.subject, target) @@ -144,7 +145,9 @@ export const bindExecutionTarget = ( `${operation}:subject`, ), ); - return ok({ ...result.value, subject: result.value.subject ?? subject }); + return result.value.subject === undefined + ? ok({ ...result.value, subject }) + : result; }; const isArtifactBundleSubject = ( diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index 59b056a970..c7455328be 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -40,6 +40,39 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js"; * `operationHealthSnapshot`, the way `HopperClient.operationHealth` reports a * recorded process exit. */ +it("binds omitted provider subjects to the selected target", async () => { + const { session } = openSession(); + try { + const opened = await session.open(process.execPath); + expect(opened.ok).toBe(true); + const execution = await session.execute("address_name", {}); + expect(execution.ok).toBe(true); + if (!execution.ok || !opened.ok) return; + expect(execution.value.subject).toEqual({ + path: opened.value.path, + sha256: opened.value.sha256, + format: opened.value.format, + ...(opened.value.architecture === undefined + ? {} + : { architecture: opened.value.architecture }), + }); + } finally { + await session.close(); + } +}); + +it("preserves an explicitly unbound provider observation", async () => { + const { session } = openSession({ explicitNullSubject: true }); + try { + expect((await session.open(process.execPath)).ok).toBe(true); + const execution = await session.execute("address_name", {}); + expect(execution.ok).toBe(true); + if (execution.ok) expect(execution.value.subject).toBeNull(); + } finally { + await session.close(); + } +}); + describe("BinarySession open liveness", () => { it("retires a locally blocked owner even when its native health is idle", async () => { const { session, deep } = openSession(); @@ -349,6 +382,7 @@ const openSession = ( operationHealth?: boolean; failOperationInitially?: boolean; beforeFirstClose?: () => Promise; + explicitNullSubject?: boolean; } = {}, ): { session: BinarySession; @@ -383,6 +417,7 @@ const killableDeepProvider = (options: { operationHealth?: boolean; failOperationInitially?: boolean; beforeFirstClose?: () => Promise; + explicitNullSubject?: boolean; }): { provider: AnalysisProviderCandidate; created: KillableChild[]; @@ -433,6 +468,10 @@ const killableDeepProvider = (options: { createAnalysisExecution( operation === "health" ? null : `liveness:${operation}`, deepIdentity, + operation !== "health" && + options.explicitNullSubject === true + ? { subject: null } + : {}, ), ), ) diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts index 7e8dee1098..dfabfee771 100644 --- a/src/application/binary/BinarySessionPort.ts +++ b/src/application/binary/BinarySessionPort.ts @@ -13,6 +13,7 @@ import type { Result } from "../../domain/result.js"; import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import type { AnalysisSnapshotLoader } from "./BinarySessionOpen.js"; import type { WorkflowSnapshotRecordInput } from "./BinarySessionRecords.js"; +import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js"; import type { AnalysisExecution, @@ -76,6 +77,11 @@ export interface BinarySessionPort readonly providerId?: AnalysisProviderSelector; }, ): Promise>; + /** Open a resource-only app root without selecting or inventing an executable target. */ + openBundleResources( + path: string, + options?: Pick, + ): Promise>; close( options?: Pick & { readonly retainProviderDocuments?: boolean; diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index f18358e6d6..104d7e1011 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -1,4 +1,5 @@ import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js"; import { binaryTargetSummary } from "./BinaryTargetSummary.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js"; @@ -20,6 +21,8 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js" interface BinarySessionStatusInput { readonly target: BinaryTarget | undefined; + readonly bundleResourceRoot?: ResolvedAppBundleResourceRoot; + readonly resourceCapabilities?: readonly CapabilityDescriptor[]; readonly route: SessionProviderRoute; readonly router: Pick; readonly runtimeUnavailability: ReadonlyMap< @@ -37,6 +40,8 @@ interface BinarySessionStatusInput { /** Project internal provider routing state into the caller-visible session status. */ export const binarySessionStatus = async ({ target, + bundleResourceRoot, + resourceCapabilities = [], route, router, runtimeUnavailability, @@ -47,7 +52,11 @@ export const binarySessionStatus = async ({ cleanupPending = false, signal, }: BinarySessionStatusInput): Promise => { - const routeCapabilities = [...route.capabilities.values()] + const routeCapabilities = ( + bundleResourceRoot === undefined + ? [...route.capabilities.values()] + : [...resourceCapabilities] + ) .filter(({ operation }) => operation !== "inventory_artifact") .sort((left, right) => compareUnicodeCodePoints(left.operation, right.operation), @@ -64,29 +73,32 @@ export const binarySessionStatus = async ({ }); const capabilities = providerCapabilitySets(routeCapabilities); const analysisProviderBinding = - route.binding === null + bundleResourceRoot !== undefined ? null - : { - provider: providerSummary(route.binding.identity), - selection_source: route.binding.selectionSource, - analysis_profile: structuredClone(route.binding.profile), - }; - const analysisProviderCandidates = ( - await router.candidateStatuses(route, signal) - ).map((candidate) => ({ - provider: providerSummary(candidate.provider), - availability: structuredClone(candidate.availability), - target_support: structuredClone(candidate.targetSupport), - selected: candidate.selected, - capabilities: providerCapabilitySets( - candidate.capabilities - .slice() - .filter(({ operation }) => operation !== "inventory_artifact") - .sort((left, right) => - compareUnicodeCodePoints(left.operation, right.operation), - ), - ), - })); + : route.binding === null + ? null + : { + provider: providerSummary(route.binding.identity), + selection_source: route.binding.selectionSource, + analysis_profile: structuredClone(route.binding.profile), + }; + const analysisProviderCandidates = + bundleResourceRoot !== undefined + ? [] + : (await router.candidateStatuses(route, signal)).map((candidate) => ({ + provider: providerSummary(candidate.provider), + availability: structuredClone(candidate.availability), + target_support: structuredClone(candidate.targetSupport), + selected: candidate.selected, + capabilities: providerCapabilitySets( + candidate.capabilities + .slice() + .filter(({ operation }) => operation !== "inventory_artifact") + .sort((left, right) => + compareUnicodeCodePoints(left.operation, right.operation), + ), + ), + })); const common = { cleanup_pending: cleanupPending, capabilities, @@ -105,7 +117,20 @@ export const binarySessionStatus = async ({ ), }; return target === undefined - ? { open: false, ...common } + ? bundleResourceRoot === undefined + ? { open: false, ...common } + : { + open: true, + ...common, + session_kind: "bundle_resources", + resource_root: { + caller_path: bundleResourceRoot.callerPath, + canonical_path: bundleResourceRoot.canonicalPath, + info_plist_path: bundleResourceRoot.infoPlistPath, + layout: bundleResourceRoot.layout, + executable_sha256: bundleResourceRoot.executableSha256, + }, + } : { open: true, ...common, diff --git a/src/application/binary/BundleResourceSession.test.ts b/src/application/binary/BundleResourceSession.test.ts new file mode 100644 index 0000000000..4f51ee5096 --- /dev/null +++ b/src/application/binary/BundleResourceSession.test.ts @@ -0,0 +1,106 @@ +import { expect, it } from "vitest"; + +import { combineBundleResourceClients } from "./BundleResourceSession.js"; +import { + createAnalysisExecution, + type AnalysisClient, + type ProviderIdentity, +} from "../AnalysisProvider.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { err } from "../../domain/result.js"; + +const identity: ProviderIdentity = { + id: "bundle-resource-close-fixture", + name: "Bundle resource close fixture", + version: "1", +}; + +it("retains both child close failures when a resource owner rejects", async () => { + const attempts: string[] = []; + const artifact: AnalysisClient = { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + attempts.push("artifact"); + throw new Error("artifact close rejected"); + }, + }; + const native: AnalysisClient = { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + attempts.push("native"); + throw new Error("native close rejected"); + }, + }; + + const closed = await combineBundleResourceClients(artifact, native).close(); + expect(attempts.sort()).toEqual(["artifact", "native"]); + expect(closed.ok).toBe(false); + if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true); +}); + +it("reports the first mixed child failure before awaiting the other owner and retries both", async () => { + let artifactCloseAttempts = 0; + let nativeCloseAttempts = 0; + let nativeCloseStarted!: () => void; + const nativeStarted = new Promise((resolve) => { + nativeCloseStarted = resolve; + }); + let releaseNativeClose!: () => void; + const nativeCloseGate = new Promise((resolve) => { + releaseNativeClose = resolve; + }); + let cleanupFailureObserved!: (failure: Error) => void; + const failureObserved = new Promise((resolve) => { + cleanupFailureObserved = resolve; + }); + const artifact: AnalysisClient = { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + artifactCloseAttempts += 1; + return artifactCloseAttempts === 1 + ? err( + new ProviderCleanupError(identity.id, ["artifact-reader"], { + reason: "artifact owner remains open", + }), + ) + : { ok: true, value: null }; + }, + }; + const native: AnalysisClient = { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + nativeCloseAttempts += 1; + if (nativeCloseAttempts === 1) { + nativeCloseStarted(); + await nativeCloseGate; + } + return { ok: true, value: null }; + }, + }; + const combined = combineBundleResourceClients(artifact, native); + const closing = combined.close({ + onCleanupFailure: cleanupFailureObserved, + }); + await Promise.all([nativeStarted, failureObserved]); + releaseNativeClose(); + const first = await closing; + expect(first.ok).toBe(false); + if (first.ok) throw new Error("Expected mixed close failure"); + expect(first.error.cleanupIncomplete).toBe(true); + + expect(await combined.close()).toMatchObject({ ok: true }); + expect(artifactCloseAttempts).toBe(2); + expect(nativeCloseAttempts).toBe(2); +}); diff --git a/src/application/binary/BundleResourceSession.ts b/src/application/binary/BundleResourceSession.ts new file mode 100644 index 0000000000..a511238312 --- /dev/null +++ b/src/application/binary/BundleResourceSession.ts @@ -0,0 +1,85 @@ +import type { + AnalysisClient, + AnalysisOperation, + CapabilityDescriptor, +} from "../AnalysisProvider.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { err, ok } from "../../domain/result.js"; +import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js"; +import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js"; + +export const BUNDLE_NATIVE_RESOURCE_OPERATIONS = new Set([ + "inspect_plist", +]); + +export const BUNDLE_ARTIFACT_RESOURCE_OPERATIONS = new Set([ + "decode_interface_builder", + "inspect_asset_catalog", + "inspect_keyed_archive", +]); + +export const BUNDLE_RESOURCE_OPERATIONS = new Set([ + ...BUNDLE_NATIVE_RESOURCE_OPERATIONS, + ...BUNDLE_ARTIFACT_RESOURCE_OPERATIONS, +]); + +/** Provider-specific owner of the four read-only app-resource operations. */ +export interface BundleResourceClientFactory { + createClient( + root: ResolvedAppBundleResourceRoot, + ): BundleResourceClientBinding | Promise; +} + +export interface BundleResourceClientBinding { + readonly client: AnalysisClient; + readonly capabilities: readonly CapabilityDescriptor[]; +} + +export interface ActiveBundleResourceSession { + readonly root: ResolvedAppBundleResourceRoot; + readonly client: AnalysisClient; + readonly capabilities: readonly CapabilityDescriptor[]; + readonly runId: string; +} + +/** Route each resource read to the owner that understands its format. */ +export const combineBundleResourceClients = ( + artifactClient: AnalysisClient, + nativeClient: AnalysisClient, +): AnalysisClient => ({ + execute: (operation, parameters, options) => { + if (operation === "health") + return artifactClient.execute(operation, parameters, options); + if (!BUNDLE_RESOURCE_OPERATIONS.has(operation)) + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "bundle-resources", + operation, + "This session provides read-only app resource inspection only.", + ), + ), + ); + return BUNDLE_NATIVE_RESOURCE_OPERATIONS.has(operation) + ? nativeClient.execute(operation, parameters, options) + : artifactClient.execute(operation, parameters, options); + }, + close: async (options) => { + const [artifact, native] = await Promise.all([ + closeAnalysisClient(artifactClient, "bundle-resources", options), + closeAnalysisClient(nativeClient, "bundle-resources", options), + ]); + if (!artifact.ok && !native.ok) + return err( + analysisErrorWithCleanupFailure( + artifact.error, + native.error, + "close_binary", + ), + ); + if (!artifact.ok) return artifact; + if (!native.ok) return native; + return ok(null); + }, +}); diff --git a/src/application/firmware/FirmwareAnalysisService.ts b/src/application/firmware/FirmwareAnalysisService.ts index 765a9da13d..0d3344646a 100644 --- a/src/application/firmware/FirmwareAnalysisService.ts +++ b/src/application/firmware/FirmwareAnalysisService.ts @@ -46,7 +46,7 @@ export class FirmwareAnalysisService { const executed = await this.provider.execute(parsed.data, options); if (!executed.ok) return executed; const execution = executed.value; - if (execution.subject === null) + if (execution.subject == null) return err( new AnalysisOutputError( operation, diff --git a/src/application/javascript/JavaScriptRecoveryService.ts b/src/application/javascript/JavaScriptRecoveryService.ts index 687b71d67d..4a5b282159 100644 --- a/src/application/javascript/JavaScriptRecoveryService.ts +++ b/src/application/javascript/JavaScriptRecoveryService.ts @@ -82,7 +82,7 @@ export class JavaScriptRecoveryService { const result = javascriptRecoveryResultSchema.safeParse( execution.value.result, ); - if (!result.success || execution.value.subject === null) + if (!result.success || execution.value.subject == null) return err( new AnalysisOutputError( OPERATION, diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts index f7ff79200c..92564a18fc 100644 --- a/src/application/native/NativeValueTrace.ts +++ b/src/application/native/NativeValueTrace.ts @@ -113,7 +113,7 @@ export const traceNativeValues = async ( continue; } const parsed = functionDossierSchema.safeParse(execution.value.result); - if (!parsed.success || execution.value.subject === null) + if (!parsed.success || execution.value.subject == null) return err( new AnalysisOutputError( "trace_native_values", diff --git a/src/application/sqlite/SqliteDatabaseService.ts b/src/application/sqlite/SqliteDatabaseService.ts index 7f8aaeb73e..eddc109ac8 100644 --- a/src/application/sqlite/SqliteDatabaseService.ts +++ b/src/application/sqlite/SqliteDatabaseService.ts @@ -95,7 +95,7 @@ export class SqliteDatabaseService { const report = sqliteDatabaseSchema.safeParse(execution.result); if ( !report.success || - execution.subject === null || + execution.subject == null || !matchesSelectedDatabase(report.data, input.data, execution.subject) ) return err( diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 446a824334..2a115286bc 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -44,6 +44,9 @@ import { createEvidence } from "../domain/evidence.js"; import { freezeJsonSnapshot } from "../domain/immutableJson.js"; import { createArtifactInspection } from "../domain/artifactInspection.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; +import type { BundleResourceRoot } from "../domain/apple/bundleResourceRoot.js"; +import { BUNDLE_ARTIFACT_RESOURCE_OPERATIONS } from "../application/binary/BundleResourceSession.js"; /** Read-only inventory and exclusively owned extraction provider. */ export class ArtifactProvider implements AnalysisProvider { @@ -71,6 +74,120 @@ export class ArtifactProvider implements AnalysisProvider { createClient(target: BinaryTarget): AnalysisClient { return new ArtifactClient(target, this.environment, this.#platform); } + + createBundleResourceClient( + root: ResolvedAppBundleResourceRoot, + ): AnalysisClient { + return new BundleResourceArtifactClient( + root, + this.environment, + this.#platform, + ); + } +} + +const serializedResourceRoot = ( + root: ResolvedAppBundleResourceRoot, +): BundleResourceRoot => ({ + caller_path: root.callerPath, + canonical_path: root.canonicalPath, + info_plist_path: root.infoPlistPath, + layout: root.layout, + executable_sha256: root.executableSha256, +}); + +class BundleResourceArtifactClient implements AnalysisClient { + readonly #resourceScope = new ArtifactResourceScope(); + + constructor( + private readonly root: ResolvedAppBundleResourceRoot, + private readonly environment: Readonly, + private readonly platform: NodeJS.Platform, + ) {} + + async execute( + operation: AnalysisOperation, + parameters: Readonly>, + options?: ExecutionOptions, + ) { + if (operation === "health") + return ok(createAnalysisExecution(null, ARTIFACT_GRAPH_PROVIDER)); + if (!BUNDLE_ARTIFACT_RESOURCE_OPERATIONS.has(operation)) + return err( + new AnalysisCapabilityUnavailableError( + ARTIFACT_GRAPH_PROVIDER.id, + operation, + "The app resource reader supports only plist, Interface Builder, asset catalog, and keyed archive inspection.", + ), + ); + try { + const resourceRoot = serializedResourceRoot(this.root); + const result = + operation === "decode_interface_builder" + ? await analyzeInterfaceBuilderBundle({ + bundlePath: this.root.canonicalPath, + targetSha256: null, + resourceRoot, + limits: interfaceBuilderLimitsSchema.parse(parameters), + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + }) + : operation === "inspect_keyed_archive" + ? await inspectBundleKeyedArchive({ + bundlePath: this.root.canonicalPath, + targetSha256: null, + resourceRoot, + parameters, + platform: this.platform, + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + }) + : operation === "inspect_asset_catalog" + ? await analyzeAppleAssetCatalogs({ + bundlePath: this.root.canonicalPath, + targetSha256: null, + resourceRoot, + environment: this.environment, + page: parameters, + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + }) + : (() => { + throw new AnalysisCapabilityUnavailableError( + ARTIFACT_GRAPH_PROVIDER.id, + operation, + "Property list inspection requires a platform-native reader.", + ); + })(); + return ok( + createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, { + limitations: "limitations" in result ? result.limitations : [], + }), + ); + } catch (cause: unknown) { + return err( + translateArtifactFailure(operation as ArtifactAnalysisOperation, cause), + ); + } + } + + close(): Promise> { + return this.#resourceScope.close().then( + () => ok(null), + (cause: unknown) => + err( + new ProviderCleanupError( + ARTIFACT_GRAPH_PROVIDER.id, + ["app bundle resource reader"], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { cause, operation: "close_binary" }, + ), + ), + ); + } } class ArtifactClient implements AnalysisClient { @@ -225,7 +342,9 @@ class ArtifactClient implements AnalysisClient { }), ); } catch (cause: unknown) { - return err(translateArtifactFailure(operation, cause)); + return err( + translateArtifactFailure(operation as ArtifactAnalysisOperation, cause), + ); } } diff --git a/src/artifacts/BundleResourceArtifactClient.test.ts b/src/artifacts/BundleResourceArtifactClient.test.ts new file mode 100644 index 0000000000..90fd7ef12a --- /dev/null +++ b/src/artifacts/BundleResourceArtifactClient.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build } from "plist"; +import { afterEach, expect, it } from "vitest"; + +import { ArtifactProvider } from "./ArtifactProvider.js"; +import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("returns keyed archive evidence with root provenance and unknown executable identity", async () => { + const bundlePath = await mkdtemp(join(tmpdir(), "rea-resource-reader-")); + temporaryDirectories.push(bundlePath); + await mkdir(join(bundlePath, "Resources")); + await writeFile( + join(bundlePath, "Info.plist"), + '', + ); + const archive = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "sample"], + $top: { root: { UID: 1 } }, + }); + await writeFile(join(bundlePath, "Resources", "Archive.plist"), archive); + const root: ResolvedAppBundleResourceRoot = { + callerPath: bundlePath, + canonicalPath: bundlePath, + infoPlistPath: join(bundlePath, "Info.plist"), + layout: "root", + executableSha256: null, + }; + const provider = new ArtifactProvider({}, "linux"); + const client = provider.createBundleResourceClient(root); + const execution = await client.execute("inspect_keyed_archive", { + path: "Resources/Archive.plist", + }); + expect(execution.ok).toBe(true); + if (execution.ok) { + expect(execution.value.result).toMatchObject({ + target_sha256: null, + resource_root: { + caller_path: bundlePath, + canonical_path: bundlePath, + executable_sha256: null, + }, + archive_path: "Resources/Archive.plist", + }); + } + expect((await client.close()).ok).toBe(true); +}); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 84809ec08b..0388a69c17 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -21,7 +21,8 @@ const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024; /** Inspect compiled asset catalog metadata without extracting rendition bytes. */ export const analyzeAppleAssetCatalogs = async (input: { readonly bundlePath: string; - readonly targetSha256: string; + readonly targetSha256: string | null; + readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot; readonly environment: Readonly; readonly page?: unknown; readonly signal?: AbortSignal; @@ -46,6 +47,9 @@ export const analyzeAppleAssetCatalogs = async (input: { )({ bundlePath: input.bundlePath, targetSha256: input.targetSha256, + ...(input.resourceRoot === undefined + ? {} + : { resourceRoot: input.resourceRoot }), signal: input.signal === undefined ? interfaceBuilderCancel.signal @@ -71,6 +75,9 @@ export const analyzeAppleAssetCatalogs = async (input: { ); const result = projectAppleAssetCatalogPage({ targetSha256: input.targetSha256, + ...(input.resourceRoot === undefined + ? {} + : { resourceRoot: input.resourceRoot }), catalogs, offset: page.offset, limit: page.limit, diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index f05c03b780..26efffab26 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -55,7 +55,8 @@ const MAX_REPORTED_OMITTED_ARCHIVES = 64; /** Decode compiled Interface Builder archives from a local app bundle. */ export const analyzeInterfaceBuilderBundle = async (input: { readonly bundlePath: string; - readonly targetSha256: string; + readonly targetSha256: string | null; + readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot; readonly limits?: unknown; readonly signal?: AbortSignal; }) => { @@ -205,6 +206,9 @@ export const analyzeInterfaceBuilderBundle = async (input: { } const result = buildInterfaceBuilderAnalysis({ targetSha256: input.targetSha256, + ...(input.resourceRoot === undefined + ? {} + : { resourceRoot: input.resourceRoot }), toolVersion: "rea-interface-builder/1", documents, limits, diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 5081bdf01d..a15b8cfbe5 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -152,7 +152,8 @@ const keyedArchiveKindRemediation = ( /** Read exactly one regular, contained bundle entry without following symlinks. */ export const inspectBundleKeyedArchive = async (input: { bundlePath: string; - targetSha256: string; + targetSha256: string | null; + resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot; parameters: unknown; signal?: AbortSignal; /** Host whose available workflows the remediation names. */ @@ -228,6 +229,9 @@ export const inspectBundleKeyedArchive = async (input: { try { return keyedArchiveResultSchema.parse({ target_sha256: input.targetSha256, + ...(input.resourceRoot === undefined + ? {} + : { resource_root: input.resourceRoot }), archive_path: entry.path, archive_sha256: createHash("sha256").update(bytes).digest("hex"), ...decodeKeyedArchiveBytes(bytes, selected), diff --git a/src/cliBundleResource.test.ts b/src/cliBundleResource.test.ts new file mode 100644 index 0000000000..f366a0cb2a --- /dev/null +++ b/src/cliBundleResource.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { build } from "plist"; +import { afterEach, expect, it } from "vitest"; + +import { createCli } from "./cli.js"; +import { parseEvidence } from "./domain/evidence.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("inspects a keyless app bundle through the public CLI and preserves resource provenance", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-bundle-resource-cli-")); + temporaryDirectories.push(parent); + const bundle = join(parent, "Fixture.app"); + const resources = join(bundle, "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile(join(bundle, "Info.plist"), ""); + await writeFile( + join(resources, "Archive.plist"), + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "resource-only-value"], + $top: { root: { UID: 1 } }, + }), + ); + + const cli = createCli({ REA_LOG_LEVEL: "silent" }); + let stdout = ""; + let exitCode = 0; + await cli.serve( + ["inspect-keyed-archive", bundle, "Resources/Archive.plist", "--json"], + { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }, + ); + expect(exitCode, stdout).toBe(0); + expect(parseEvidence(JSON.parse(stdout))).toMatchObject({ + subject: null, + normalized_result: { + archive_path: "Resources/Archive.plist", + target_sha256: null, + resource_root: { + caller_path: bundle, + canonical_path: bundle, + executable_sha256: null, + }, + objects: expect.arrayContaining([ + expect.objectContaining({ value: "resource-only-value" }), + ]), + }, + }); +}); diff --git a/src/composition/binary.ts b/src/composition/binary.ts index 03fb2aacd7..43f99bb46c 100644 --- a/src/composition/binary.ts +++ b/src/composition/binary.ts @@ -12,6 +12,11 @@ import { AnalysisProviderRegistry } from "../application/binary/AnalysisProvider import { composeBinarySession } from "../application/binary/BinarySessionComposition.js"; import { LazyAnalysisProvider } from "../application/binary/LazyAnalysisProvider.js"; import { ManagedStaticProvider } from "../dotnet/ManagedStaticProvider.js"; +import { + BUNDLE_ARTIFACT_RESOURCE_OPERATIONS, + BUNDLE_NATIVE_RESOURCE_OPERATIONS, + combineBundleResourceClients, +} from "../application/binary/BundleResourceSession.js"; /** * Compose the target-switching runtime shared directly by CLI and MCP adapters. @@ -67,9 +72,49 @@ const composeDeepBinarySession = ( auxiliaryDeclarations.map( (declaration) => new LazyAnalysisProvider(declaration), ), + createBundleResourceFactory(environment, process.platform), ); }; +const createBundleResourceFactory = ( + environment: Readonly, + platform: NodeJS.Platform, +) => { + return { + createClient: async ( + root: import("../application/AppBundleExecutable.js").ResolvedAppBundleResourceRoot, + ) => { + const [{ ArtifactProvider }, { NativeMacOSProvider }] = await Promise.all( + [ + import("../artifacts/ArtifactProvider.js"), + import("../native/NativeMacOSProvider.js"), + ], + ); + const artifact = new ArtifactProvider(environment, platform); + const native = new NativeMacOSProvider(environment, undefined, platform); + const capabilities = [ + ...artifact + .capabilities() + .filter(({ operation }) => + BUNDLE_ARTIFACT_RESOURCE_OPERATIONS.has(operation), + ), + ...native + .capabilities() + .filter(({ operation }) => + BUNDLE_NATIVE_RESOURCE_OPERATIONS.has(operation), + ), + ]; + return { + client: combineBundleResourceClients( + artifact.createBundleResourceClient(root), + native.createBundleResourceClient(root), + ), + capabilities, + }; + }, + }; +}; + /** Compose an execution-free managed session without native provider selection. */ export const createManagedBinarySession = (): BinarySession => composeBinarySession(new AnalysisProviderRegistry([]), [ @@ -82,12 +127,19 @@ export const createArtifactBinarySession = ( platform: NodeJS.Platform = process.platform, ): Promise => import("../artifacts/ArtifactProvider.js").then(({ ArtifactProvider }) => - composeBinarySession(new AnalysisProviderRegistry([]), [ - new ArtifactProvider( + composeBinarySession( + new AnalysisProviderRegistry([]), + [ + new ArtifactProvider( + snapshotEnvironment(selectedEnvironment, platform), + platform, + ), + ], + createBundleResourceFactory( snapshotEnvironment(selectedEnvironment, platform), platform, ), - ]), + ), ); /** Compose native inspection utilities without candidates for deep analysis. */ @@ -96,11 +148,18 @@ export const createNativeBinarySession = ( platform: NodeJS.Platform = process.platform, ): Promise => import("../native/NativeMacOSProvider.js").then(({ NativeMacOSProvider }) => - composeBinarySession(new AnalysisProviderRegistry([]), [ - new NativeMacOSProvider( + composeBinarySession( + new AnalysisProviderRegistry([]), + [ + new NativeMacOSProvider( + snapshotEnvironment(selectedEnvironment, platform), + undefined, + platform, + ), + ], + createBundleResourceFactory( snapshotEnvironment(selectedEnvironment, platform), - undefined, platform, ), - ]), + ), ); diff --git a/src/contracts/sessionLifecycleInputs.ts b/src/contracts/sessionLifecycleInputs.ts index 4b03c1f66d..dba92baa75 100644 --- a/src/contracts/sessionLifecycleInputs.ts +++ b/src/contracts/sessionLifecycleInputs.ts @@ -65,6 +65,11 @@ export const openBinaryInputSchema = z }); }); +/** Open only app resources when no declared executable is available. */ +export const openBundleResourcesInputSchema = z.strictObject({ + path: binaryTargetPathSchema, +}); + /** Input contract for closing a target after an optional atomic snapshot. */ export const closeBinaryInputSchema = z.strictObject({ snapshot_path: snapshotPathSchema.optional(), diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index f43f4802de..4bb7e44e1a 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -17,6 +17,7 @@ import { sessionOutputSchemas } from "./toolOutputSchemaGroups.js"; import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { closeBinaryInputSchema, + openBundleResourcesInputSchema, openBinaryInputSchema, } from "./sessionLifecycleInputs.js"; import { binarySessionInputSchema } from "./sessionStatusContract.js"; @@ -67,14 +68,19 @@ export const SESSION_TOOL_CONTRACTS = [ "Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Call close_binary when the investigation is finished.", openBinaryInputSchema, ), + session( + "open_bundle_resources", + "Open a local app bundle for read-only plist, Interface Builder, asset catalog, and keyed archive inspection without resolving or inventing its executable identity. This switches the shared session to a resource-only lane; executable analysis is unavailable until another target is opened.", + openBundleResourcesInputSchema, + ), session( "close_binary", - "When the investigation is finished, drain earlier provider requests, optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active target and every provider resource started for it. Later provider requests wait until this lifecycle operation finishes. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost. Metadata edits prevent immutable snapshot saves until the session is recreated; export_evidence_bundle preserves mutable observations before closing without a snapshot.", + "When the investigation is finished, drain earlier provider requests, optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active analysis session and every provider resource started for it. Resource-only app sessions have no binary snapshot binding, so close them without snapshot_path. Later provider requests wait until this lifecycle operation finishes. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost. Metadata edits prevent immutable snapshot saves until the session is recreated; export_evidence_bundle preserves mutable observations before closing without a snapshot.", closeBinaryInputSchema, ), session( "binary_session", - "Report the complete current target, provider, capability availability, client-feature, analysis, and server-identity status without starting analysis.", + "Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis.", binarySessionInputSchema, ), session( diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index e2f96d1583..af218b43df 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -53,6 +53,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< trace_call_path: { start: "0x1000", goal: "0x1100" }, trace_native_ui_action: { action: "buildTapped:" }, open_binary: { path: "/tmp/fixture" }, + open_bundle_resources: { path: "/tmp/Fixture.app" }, export_evidence_bundle: { path: "/tmp/evidence.json" }, get_evidence_bundle: { detail: "summary", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 651a4245c1..0e11470c0a 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -679,6 +679,7 @@ export const TOOL_EFFECTS: Readonly> = { project_apple_application_graph: evidence, project_harmony_application_graph: evidence, open_binary: effects({ mutatesSession: true, launchesProcess: true }), + open_bundle_resources: effects({ mutatesSession: true }), close_binary: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index c1c1ba5413..cd1864c99c 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -26,6 +26,7 @@ import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunction import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js"; import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js"; import { nativeValueTraceSchema } from "../domain/native/nativeValueTrace.js"; +import { bundleResourceRootSchema } from "../domain/apple/bundleResourceRoot.js"; import { nativeDataTypeSchema } from "../domain/native/nativeDataType.js"; import { nativeCallObservationResultSchema } from "../domain/native/nativeCallObservation.js"; import { @@ -511,6 +512,7 @@ export const sessionOutputSchemas = { rawLifecycleTargetSchema, ]), ), + open_bundle_resources: lifecycleResultOf(bundleResourceRootSchema), close_binary: lifecycleResultOf( z.union([ z.null(), @@ -540,6 +542,11 @@ export const sessionOutputSchemas = { binary_session: lifecycleResultOf( z.union([ sessionProvider.extend({ open: z.literal(false) }), + sessionProvider.extend({ + open: z.literal(true), + session_kind: z.literal("bundle_resources"), + resource_root: bundleResourceRootSchema, + }), sessionProvider.extend({ open: z.literal(true), ...detectedLifecycleTargetSchema.shape, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index d991e789ed..c16b443ad6 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -78,6 +78,7 @@ import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { safeParseOwnedBinaryLayout } from "../native/binaryLayout.js"; +import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js"; import { functionDossierSchema } from "../hopperValues.js"; import { artifactGraphManifestSchema, @@ -346,7 +347,9 @@ const coverageSchema = z.strictObject({ const artifactSchema = z.strictObject({ path: z .string() - .describe("Exact recorded artifact path, including an empty string."), + .describe( + "Selected artifact path, or the canonical resource root when resource-only Evidence has no executable subject.", + ), sha256: digestSchema.nullable(), }); @@ -524,6 +527,7 @@ export const machoSummaryViewResultSchema = z.strictObject({ export const interfaceBuilderSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("decode_interface_builder"), + resource_root: bundleResourceRootSchema.optional(), summary: interfaceBuilderSummarySchema, }); @@ -782,6 +786,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ view: interfaceBuilderViewRequestSchema, items: interfaceBuilderViewItemsSchema, ...viewResultBase, + resource_root: bundleResourceRootSchema.optional(), ...interfaceBuilderViewContextSchema.shape, parent_operation: z.literal("decode_interface_builder"), }), diff --git a/src/domain/analysisView/interfaceBuilderView.test.ts b/src/domain/analysisView/interfaceBuilderView.test.ts index a591d3194a..b3f02916ea 100644 --- a/src/domain/analysisView/interfaceBuilderView.test.ts +++ b/src/domain/analysisView/interfaceBuilderView.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; import { + analysisViewResultSchema, projectAnalysisView, type AnalysisViewParent, } from "./analysisView.js"; const target = "a".repeat(64); +const resourceRoot = { + caller_path: "/Applications/Keyless.app", + canonical_path: "/Applications/Keyless.app", + info_plist_path: "/Applications/Keyless.app/Info.plist", + layout: "root" as const, + executable_sha256: null, +}; const evidence = (artifact_path: string) => [ { kind: "interface_builder_resource" as const, @@ -68,6 +76,15 @@ const report = (truncated = false) => ({ }, limitations: ["Recognized archive fields only."], }); +const resourceReport = () => { + const decoded = report(); + return { + ...decoded, + target_sha256: null, + resource_root: resourceRoot, + graph: { ...decoded.graph, target_sha256: null }, + }; +}; const parent = ( normalizedResult: unknown = report(), sha256 = target, @@ -86,6 +103,33 @@ const parent = ( }); describe("Interface Builder analysis views", () => { + it("keeps resource-root provenance in both summary and detail views without a subject", () => { + const source: AnalysisViewParent = { + ...parent(resourceReport()), + subject: null, + }; + const summary = projectAnalysisView(source, { kind: "summary" }); + const detail = projectAnalysisView(source, { + kind: "interface-builder", + collection: "documents", + offset: 0, + limit: 64, + }); + if (!summary.ok) throw summary.error; + if (!detail.ok) throw detail.error; + + expect(analysisViewResultSchema.parse(summary.value)).toMatchObject({ + artifact: { path: resourceRoot.canonical_path, sha256: null }, + resource_root: resourceRoot, + summary: { target_sha256: null }, + }); + expect(analysisViewResultSchema.parse(detail.value)).toMatchObject({ + artifact: { path: resourceRoot.canonical_path, sha256: null }, + resource_root: resourceRoot, + items: report().documents, + }); + }); + it("summarizes documents and graph counts without rows", () => { const projected = projectAnalysisView(parent(), { kind: "summary" }); if (!projected.ok) throw projected.error; diff --git a/src/domain/analysisView/interfaceBuilderView.ts b/src/domain/analysisView/interfaceBuilderView.ts index 75a2c196ee..a4f49a92e4 100644 --- a/src/domain/analysisView/interfaceBuilderView.ts +++ b/src/domain/analysisView/interfaceBuilderView.ts @@ -6,10 +6,12 @@ import { interfaceBuilderAnalysisSchema, interfaceBuilderDocumentSchema, } from "../apple/interfaceBuilderGraph.js"; +import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js"; import { nativeInvestigationCoverageSchema, nativeInvestigationEdgeSchema, nativeInvestigationNodeSchema, + nativeInvestigationProviderSchema, } from "../native/nativeInvestigationGraph.js"; import { err, ok, type Result } from "../result.js"; import { parseAnalysisViewResult } from "./analysisViewParent.js"; @@ -67,9 +69,12 @@ const countsSchema = z.record(z.string(), z.number().int().positive()); /** Every decoded document with graph counts; rows and evidence stay retained. */ export const interfaceBuilderSummarySchema = z.strictObject({ - target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256, - provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider, - documents: interfaceBuilderAnalysisSchema.shape.documents, + target_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + provider: nativeInvestigationProviderSchema, + documents: interfaceBuilderDocumentSchema.array().max(64), available_counts: z.strictObject({ nodes: z.number().int().nonnegative(), edges: z.number().int().nonnegative(), @@ -132,7 +137,7 @@ const summarizeInterfaceBuilder = ( graph.edges.length + graph.coverage.length; return { - summary: { + summary: interfaceBuilderSummarySchema.parse({ target_sha256: report.target_sha256, provider: graph.provider, documents: report.documents, @@ -146,7 +151,7 @@ const summarizeInterfaceBuilder = ( coverage_statuses: tally(graph.coverage.map((row) => row.status)), truncated: graph.truncated, limitation_count: limitationCount, - }, + }), coverage: completeWithinViewCoverage(total, total), }; }; @@ -180,9 +185,15 @@ export const projectInterfaceBuilderView = ( parent_operation: "decode_interface_builder" as const, parent_digest: parent.evidenceId.slice(3), artifact: { - path: parent.subject?.local_path ?? "", + path: + parent.subject?.local_path ?? + report.resource_root?.canonical_path ?? + "", sha256: report.target_sha256, }, + ...(report.resource_root === undefined + ? {} + : { resource_root: report.resource_root }), limitations, unknowns: [ "Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.", diff --git a/src/domain/apple/appleAssetCatalog.test.ts b/src/domain/apple/appleAssetCatalog.test.ts index fdab85399c..ee8ee0d14c 100644 --- a/src/domain/apple/appleAssetCatalog.test.ts +++ b/src/domain/apple/appleAssetCatalog.test.ts @@ -1,11 +1,39 @@ import { describe, expect, it } from "vitest"; import { + appleAssetCatalogResultSchema, appleAssetCatalogInputSchema, parseAppleAssetCatalogRecords, projectAppleAssetCatalogPage, } from "./appleAssetCatalog.js"; +it("requires resource-root provenance when asset results have no executable identity", () => { + const base = { + target_sha256: null, + catalogs: [], + total_records: 0, + offset: 0, + limit: 1, + records: [], + resource_key_matches: [], + next_offset: null, + truncated: false, + limitations: [], + }; + const resource_root = { + caller_path: "/tmp/Fixture.app", + canonical_path: "/tmp/Fixture.app", + info_plist_path: "/tmp/Fixture.app/Info.plist", + layout: "root" as const, + executable_sha256: null, + }; + expect( + appleAssetCatalogResultSchema.parse({ ...base, resource_root }) + .resource_root, + ).toEqual(resource_root); + expect(appleAssetCatalogResultSchema.safeParse(base).success).toBe(false); +}); + describe("compiled Apple asset catalog projection", () => { it("preserves metadata and paginates across catalog boundaries", () => { const catalogs = [ diff --git a/src/domain/apple/appleAssetCatalog.ts b/src/domain/apple/appleAssetCatalog.ts index 1c89957976..81a96c3cc3 100644 --- a/src/domain/apple/appleAssetCatalog.ts +++ b/src/domain/apple/appleAssetCatalog.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { bundleResourceBoundResultSchema } from "./bundleResourceRoot.js"; import { jsonValueSchema } from "../jsonValue.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; @@ -66,18 +67,20 @@ const resourceKeyMatchSchema = z.strictObject({ }); /** Normalized result returned by asset catalog inspection. */ -export const appleAssetCatalogResultSchema = z.strictObject({ - target_sha256: z.string().regex(/^[a-f0-9]{64}$/u), - catalogs: z.array(catalogSchema), - total_records: z.number().int().nonnegative(), - offset: z.number().int().nonnegative(), - limit: z.number().int().positive(), - records: z.array(recordSchema), - resource_key_matches: z.array(resourceKeyMatchSchema), - next_offset: z.number().int().nonnegative().nullable(), - truncated: z.boolean(), - limitations: z.array(z.string()), -}); +export const appleAssetCatalogResultSchema = bundleResourceBoundResultSchema( + z.string().regex(/^[a-f0-9]{64}$/u), + { + catalogs: z.array(catalogSchema), + total_records: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + limit: z.number().int().positive(), + records: z.array(recordSchema), + resource_key_matches: z.array(resourceKeyMatchSchema), + next_offset: z.number().int().nonnegative().nullable(), + truncated: z.boolean(), + limitations: z.array(z.string()), + }, +); export type AppleAssetCatalogRecord = z.infer; @@ -106,7 +109,8 @@ export const parseAppleAssetCatalogRecords = ( /** Project catalog records and apply stable global pagination. */ export const projectAppleAssetCatalogPage = (input: { - readonly targetSha256: string; + readonly targetSha256: string | null; + readonly resourceRoot?: import("./bundleResourceRoot.js").BundleResourceRoot; readonly catalogs: readonly { readonly path: string; readonly sha256: string; @@ -175,6 +179,9 @@ export const projectAppleAssetCatalogPage = (input: { }); return appleAssetCatalogResultSchema.parse({ target_sha256: input.targetSha256, + ...(input.resourceRoot === undefined + ? {} + : { resource_root: input.resourceRoot }), catalogs: input.catalogs.map( ({ path, sha256, records: catalogRecords }) => ({ path, diff --git a/src/domain/apple/bundleResourceRoot.ts b/src/domain/apple/bundleResourceRoot.ts new file mode 100644 index 0000000000..35cb5eeaee --- /dev/null +++ b/src/domain/apple/bundleResourceRoot.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +/** Provenance for app resources, independent of executable file identity. */ +export const bundleResourceRootSchema = z.strictObject({ + caller_path: z.string().min(1), + canonical_path: z.string().min(1), + info_plist_path: z.string().min(1), + layout: z.enum(["contents", "root"]), + executable_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), +}); + +export type BundleResourceRoot = z.infer; + +/** Output identity: executable-bound, or explicitly rooted resource-only. */ +export const bundleResourceBoundResultSchema = ( + executableSha256: z.ZodType, + fields: Fields, +) => + z.union([ + z.strictObject({ + target_sha256: executableSha256, + resource_root: bundleResourceRootSchema.optional(), + ...fields, + }), + z.strictObject({ + target_sha256: z.null(), + resource_root: bundleResourceRootSchema, + ...fields, + }), + ]); diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index 8f5bcc06b1..4b55b155bc 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -9,6 +9,33 @@ import { parseInterfaceBuilderRecords } from "./interfaceBuilderKeyedArchive.js" const hash = "a".repeat(64); +it("requires resource-root provenance when executable identity is unknown", () => { + const resourceRoot = { + caller_path: "/tmp/Fixture.app", + canonical_path: "/tmp/Fixture.app", + info_plist_path: "/tmp/Fixture.app/Info.plist", + layout: "root" as const, + executable_sha256: null, + }; + const result = buildInterfaceBuilderAnalysis({ + targetSha256: null, + resourceRoot, + toolVersion: "test", + documents: [], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + expect(result.target_sha256).toBeNull(); + expect(result.resource_root).toEqual(resourceRoot); + expect(() => + buildInterfaceBuilderAnalysis({ + targetSha256: null, + toolVersion: "test", + documents: [], + limits: interfaceBuilderLimitsSchema.parse({}), + }), + ).toThrow(); +}); + describe("compiled Interface Builder graph projection", () => { it("normalizes object, hierarchy, action, outlet, and segue facts", () => { const raw = { diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index e38a6d326c..9f1409bd1b 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { + bundleResourceRootSchema, + bundleResourceBoundResultSchema, + type BundleResourceRoot, +} from "./bundleResourceRoot.js"; import { createPlistNumberProjection, type PlistNumberLiterals, @@ -27,12 +32,14 @@ export const interfaceBuilderDocumentSchema = z.strictObject({ }); /** Normalized read-only Interface Builder graph with archive provenance. */ -export const interfaceBuilderAnalysisSchema = z.strictObject({ - target_sha256: z.string().regex(/^[a-f0-9]{64}$/u), - documents: z.array(interfaceBuilderDocumentSchema).max(64), - graph: nativeInvestigationGraphSchema, - limitations: z.array(z.string()), -}); +export const interfaceBuilderAnalysisSchema = bundleResourceBoundResultSchema( + z.string().regex(/^[a-f0-9]{64}$/u), + { + documents: z.array(interfaceBuilderDocumentSchema).max(64), + graph: nativeInvestigationGraphSchema, + limitations: z.array(z.string()), + }, +); export interface InterfaceBuilderDocumentInput { readonly relativePath: string; @@ -50,7 +57,8 @@ type InvestigationEdge = z.infer; /** Build the graph returned by the static Interface Builder archive decoder. */ export const buildInterfaceBuilderAnalysis = (input: { - readonly targetSha256: string; + readonly targetSha256: string | null; + readonly resourceRoot?: BundleResourceRoot; readonly toolVersion: string; readonly documents: readonly InterfaceBuilderDocumentInput[]; readonly limits: z.output; @@ -581,6 +589,9 @@ export const buildInterfaceBuilderAnalysis = (input: { }); return interfaceBuilderAnalysisSchema.parse({ target_sha256: input.targetSha256, + ...(input.resourceRoot === undefined + ? {} + : { resource_root: input.resourceRoot }), documents: summaries, graph: { target_sha256: input.targetSha256, diff --git a/src/domain/apple/keyedArchive.ts b/src/domain/apple/keyedArchive.ts index 32d0fbf29c..152de4f1a3 100644 --- a/src/domain/apple/keyedArchive.ts +++ b/src/domain/apple/keyedArchive.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { bundleResourceBoundResultSchema } from "./bundleResourceRoot.js"; import { AnalysisInputError } from "../analysisErrorCore.js"; import { jsonValueSchema, type JsonValue } from "../jsonValue.js"; import { localPathStringSchema } from "../localPath.js"; @@ -35,37 +36,44 @@ const linkSchema = z.strictObject({ raw: jsonValueSchema, }); /** Archive object identity is its original $objects index, never a recursive copy. */ -export const keyedArchiveResultSchema = z.strictObject({ - target_sha256: z.string(), - archive_path: z.string(), - archive_sha256: z.string(), - archive_format: z.enum(["binary-plist", "xml-plist"]), - roots: z.record(z.string(), jsonValueSchema), - objects: z.array( - z.strictObject({ - id: z.number().int().nonnegative(), - kind: z.enum([ - "nil", - "scalar", - "array", - "dictionary", - "class-descriptor", - "archived-object", - ]), - class_id: z.number().int().nonnegative().nullable(), - class_name: z.string().nullable(), - status: z.enum(["observed", "unknown-class", "unresolved-class", "nil"]), - value: jsonValueSchema, - }), - ), - references: z.array(linkSchema), - total_objects: z.number().int().nonnegative(), - total_references: z.number().int().nonnegative(), - offset: z.number().int().nonnegative(), - next_offset: z.number().int().nonnegative().nullable(), - truncated: z.boolean(), - limitations: z.array(z.string()), -}); +export const keyedArchiveResultSchema = bundleResourceBoundResultSchema( + z.string(), + { + archive_path: z.string(), + archive_sha256: z.string(), + archive_format: z.enum(["binary-plist", "xml-plist"]), + roots: z.record(z.string(), jsonValueSchema), + objects: z.array( + z.strictObject({ + id: z.number().int().nonnegative(), + kind: z.enum([ + "nil", + "scalar", + "array", + "dictionary", + "class-descriptor", + "archived-object", + ]), + class_id: z.number().int().nonnegative().nullable(), + class_name: z.string().nullable(), + status: z.enum([ + "observed", + "unknown-class", + "unresolved-class", + "nil", + ]), + value: jsonValueSchema, + }), + ), + references: z.array(linkSchema), + total_objects: z.number().int().nonnegative(), + total_references: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + next_offset: z.number().int().nonnegative().nullable(), + truncated: z.boolean(), + limitations: z.array(z.string()), + }, +); const record = ( value: JsonValue | undefined, diff --git a/src/domain/native/nativeInspection.ts b/src/domain/native/nativeInspection.ts index f89f494c6e..434a75a1fa 100644 --- a/src/domain/native/nativeInspection.ts +++ b/src/domain/native/nativeInspection.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js"; import { jsonValueSchema } from "../jsonValue.js"; const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u); @@ -170,6 +171,7 @@ export const inspectPlistSchema = z.object({ short_version: z.string().nullable(), }), source_path: z.string(), + resource_root: bundleResourceRootSchema.optional(), provenance: z.array(nativeCommandInvocationSchema), limitations: z.array(z.string()), }); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index 9b9efc4128..89a00eeb5e 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -106,14 +106,19 @@ export const nativeInvestigationCoverageSchema = z.strictObject({ omitted: z.number().int().nonnegative(), }); +export const nativeInvestigationProviderSchema = z.strictObject({ + id: z.string().min(1), + version: z.string().nullable(), + tool_version: z.string().min(1), +}); + /** Complete provider-neutral graph bound to one immutable target identity. */ export const nativeInvestigationGraphSchema = z.strictObject({ - target_sha256: z.string().regex(/^[a-f0-9]{64}$/u), - provider: z.strictObject({ - id: z.string().min(1), - version: z.string().nullable(), - tool_version: z.string().min(1), - }), + target_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + provider: nativeInvestigationProviderSchema, nodes: z.array(nativeInvestigationNodeSchema), edges: z.array(nativeInvestigationEdgeSchema), coverage: z.array(nativeInvestigationCoverageSchema), @@ -137,8 +142,11 @@ export type NativeInvestigationTraceLimits = z.infer< /** Exact graph route and any unresolved edges encountered along the route. */ export const nativeInvestigationTraceSchema = z.strictObject({ - target_sha256: z.string().regex(/^[a-f0-9]{64}$/u), - provider: nativeInvestigationGraphSchema.shape.provider, + target_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + provider: nativeInvestigationProviderSchema, start: z.string().min(1), direction: z.enum(["forward", "backward"]), nodes: z.array(nativeInvestigationNodeSchema), diff --git a/src/domain/pcap/pcapEvidence.ts b/src/domain/pcap/pcapEvidence.ts index 848350fe36..d2379ac75f 100644 --- a/src/domain/pcap/pcapEvidence.ts +++ b/src/domain/pcap/pcapEvidence.ts @@ -4,7 +4,7 @@ import type { PcapRequest } from "./pcapAnalysis.js"; /** Domain facts needed by both successful and retained partial observations. */ interface PcapObservation { - readonly subject: Parameters[0] | null; + readonly subject?: Parameters[0] | null; readonly provider: Parameters[1]; readonly result: JsonValue; readonly rawResult: JsonValue | null; diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 7541e84398..f3e8da8e08 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -121,6 +121,8 @@ import { type NativeUiHelperRuntime, } from "./NativeUiHelperRuntime.js"; import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js"; +import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; +import { resolveBundleResourcePlistSelection } from "./NativePlistSource.js"; type SignatureRuntimeRootFactory = () => Promise< Pick @@ -166,6 +168,23 @@ export class NativeMacOSProvider implements AnalysisProvider { this.#privateRuntimeRootFactory, ); } + + createBundleResourceClient( + root: ResolvedAppBundleResourceRoot, + ): AnalysisClient { + const plistAvailable = this.#capabilities.some( + ({ operation, available }) => operation === "inspect_plist" && available, + ); + return new NativeMacOSClient( + undefined, + this.runner, + this.#tracerFactory(), + this.#environment, + this.#privateRuntimeRootFactory, + root, + plistAvailable, + ); + } } class NativeMacOSClient implements AnalysisClient { @@ -179,15 +198,23 @@ class NativeMacOSClient implements AnalysisClient { #closed = false; constructor( - private readonly target: BinaryTarget, + private readonly activeTarget: BinaryTarget | undefined, private readonly runner: NativeCommandRunner, private readonly tracer: NativeCallTracer, private readonly environment: Readonly, private readonly privateRuntimeRootFactory: | SignatureRuntimeRootFactory | undefined, + private readonly bundleResourceRoot?: ResolvedAppBundleResourceRoot, + private readonly bundlePlistAvailable = true, ) {} + private get target(): BinaryTarget { + if (this.activeTarget === undefined) + throw new Error("This provider client has no executable target"); + return this.activeTarget; + } + async execute( operation: AnalysisOperation, parameters: Readonly>, @@ -207,6 +234,22 @@ class NativeMacOSClient implements AnalysisClient { ); if (operation === "health") return ok(createAnalysisExecution(null, IDENTITY)); + if (this.activeTarget === undefined && operation !== "inspect_plist") + return err( + new AnalysisCapabilityUnavailableError( + IDENTITY.id, + operation, + "This client is bound to app resource access only.", + ), + ); + if (this.bundleResourceRoot !== undefined && !this.bundlePlistAvailable) + return err( + new AnalysisCapabilityUnavailableError( + IDENTITY.id, + "inspect_plist", + "Property list inspection requires a macOS host.", + ), + ); if (operation === "inspect_native_dispatch_metadata") { const input = toolContract(operation).inputSchema.safeParse(parameters); if (!input.success) @@ -806,10 +849,13 @@ class NativeMacOSClient implements AnalysisClient { try { return await this.#plistResources.run(async () => { try { - const plist = await resolveNativePlistSelection( - this.target, - requested, - ); + const plist = + this.bundleResourceRoot === undefined + ? await resolveNativePlistSelection(this.target, requested) + : resolveBundleResourcePlistSelection( + this.bundleResourceRoot, + requested, + ); if (!plist.ok) return plist; const fileInput = { selection: plist.value, @@ -898,6 +944,17 @@ class NativeMacOSClient implements AnalysisClient { value: parsed.value.value, bundle: parsed.value.bundle, source_path: plist.value.path, + ...(this.bundleResourceRoot === undefined + ? {} + : { + resource_root: { + caller_path: this.bundleResourceRoot.callerPath, + canonical_path: this.bundleResourceRoot.canonicalPath, + info_plist_path: this.bundleResourceRoot.infoPlistPath, + layout: this.bundleResourceRoot.layout, + executable_sha256: this.bundleResourceRoot.executableSha256, + }, + }), provenance, limitations: parsed.value.limitations, }); diff --git a/src/native/NativePlistResourceRoot.test.ts b/src/native/NativePlistResourceRoot.test.ts new file mode 100644 index 0000000000..81ef2beb38 --- /dev/null +++ b/src/native/NativePlistResourceRoot.test.ts @@ -0,0 +1,46 @@ +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; +import { NativeMacOSProvider } from "./NativeMacOSProvider.js"; +import { resolveBundleResourcePlistSelection } from "./NativePlistSource.js"; +import { NativeFixtureRunner } from "../../tests/fixtures/nativeCommands.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { writeFile } from "node:fs/promises"; + +it("inspects a resolved app plist without executable identity", async () => { + const bundle = await createTestTempDirectory("rea-plist-resource-root-"); + const infoPlist = join(bundle, "Info.plist"); + await writeFile(infoPlist, ''); + const root = { + callerPath: bundle, + canonicalPath: bundle, + infoPlistPath: infoPlist, + layout: "root" as const, + executableSha256: null, + }; + expect(resolveBundleResourcePlistSelection(root, undefined)).toEqual({ + ok: true, + value: { path: infoPlist, origin: "default" }, + }); + expect(resolveBundleResourcePlistSelection(root, "Info.plist")).toEqual({ + ok: true, + value: { path: infoPlist, origin: "selected" }, + }); + const client = new NativeMacOSProvider( + {}, + new NativeFixtureRunner(), + "darwin", + ).createBundleResourceClient(root); + onTestFinished(async () => { + await client.close(); + }); + const inspected = await client.execute("inspect_plist", {}); + expect(inspected.ok).toBe(true); + if (inspected.ok) + expect(inspected.value.result).toMatchObject({ + source_path: infoPlist, + resource_root: { + canonical_path: bundle, + executable_sha256: null, + }, + }); +}); diff --git a/src/native/NativePlistSource.ts b/src/native/NativePlistSource.ts index 4c4d38d014..8c6f2e5828 100644 --- a/src/native/NativePlistSource.ts +++ b/src/native/NativePlistSource.ts @@ -4,6 +4,7 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { FileHandle } from "node:fs/promises"; import type { z } from "zod"; import type { inspectPlistSchema } from "../domain/native/nativeInspection.js"; +import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; import type { AnalysisError, AnalysisErrorOptions, @@ -68,6 +69,21 @@ export const resolveNativePlistSelection = async ( } }; +/** Resolve a plist selection from the separately identified app resource root. */ +export const resolveBundleResourcePlistSelection = ( + root: ResolvedAppBundleResourceRoot, + requested: string | undefined, +): Result => { + if (requested === undefined) + return ok({ path: root.infoPlistPath, origin: "default" }); + return ok({ + path: isAbsolute(requested) + ? resolve(requested) + : resolve(root.canonicalPath, requested), + origin: "selected", + }); +}; + /** The bundle whose Contents/MacOS directory directly holds the program. */ const macOSBundleRoot = (path: string): string | undefined => { const programs = dirname(path); diff --git a/src/providers/AnalysisClientCleanup.ts b/src/providers/AnalysisClientCleanup.ts index b926488f0c..6bb3057687 100644 --- a/src/providers/AnalysisClientCleanup.ts +++ b/src/providers/AnalysisClientCleanup.ts @@ -12,21 +12,25 @@ export const closeAnalysisClient = async ( providerId: string, options: Pick & { readonly retainDocument?: boolean; + readonly onCleanupFailure?: (failure: AnalysisError) => void; } = {}, ): Promise> => { try { - return await client.close(options); + const result = await client.close(options); + if (!result.ok) options.onCleanupFailure?.(result.error); + return result; } catch (cause: unknown) { // A typed cleanup failure already names its leftover resources. - if (cause instanceof AnalysisError && cause.cleanupIncomplete) - return err(cause); - return err( - new ProviderCleanupError( - providerId, - ["provider-client"], - { reason: "provider client close rejected unexpectedly" }, - { cause }, - ), - ); + const failure = + cause instanceof AnalysisError && cause.cleanupIncomplete + ? cause + : new ProviderCleanupError( + providerId, + ["provider-client"], + { reason: "provider client close rejected unexpectedly" }, + { cause }, + ); + options.onCleanupFailure?.(failure); + return err(failure); } }; diff --git a/src/server/BundleResourcesMcp.test.ts b/src/server/BundleResourcesMcp.test.ts new file mode 100644 index 0000000000..be852c1eaf --- /dev/null +++ b/src/server/BundleResourcesMcp.test.ts @@ -0,0 +1,428 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { afterEach, expect, it } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +import { + createAnalysisExecution, + type AnalysisClient, + type ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import { InvestigationRecords } from "../application/investigation/InvestigationRecords.js"; +import { BinarySession } from "../application/binary/BinarySession.js"; +import { AnalysisProviderRegistry } from "../application/binary/AnalysisProviderRegistry.js"; +import { SessionProviderRouter } from "../application/binary/SessionProviderRouter.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import { createEvidence } from "../domain/evidence.js"; +import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; +import { createServer } from "./createServer.js"; +import { createArtifactBinarySession } from "../composition/binary.js"; +import { build } from "plist"; + +const identity: ProviderIdentity = { + id: "bundle-resource-mcp-fixture", + name: "Bundle resource MCP fixture", + version: "1", +}; + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all(resources.splice(0).map((resource) => resource.close())); +}); + +it("routes bundle resource MCP calls with null executable identity and retained Evidence", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-bundle-resource-mcp-")); + const bundle = join(parent, "Fixture.app"); + await mkdir(bundle); + const plistPath = join(bundle, "Info.plist"); + await writeFile(plistPath, ''); + + const resourceClient: AnalysisClient = { + execute: async (operation) => { + if (operation === "health") + return { ok: true, value: createAnalysisExecution(null, identity) }; + return { + ok: true, + value: createAnalysisExecution( + { + format: "xml", + value: {}, + bundle: { + identifier: null, + executable: null, + name: null, + version: null, + short_version: null, + }, + source_path: plistPath, + resource_root: { + caller_path: bundle, + canonical_path: bundle, + info_plist_path: plistPath, + layout: "root", + executable_sha256: null, + }, + provenance: [], + limitations: [], + }, + identity, + { subject: null }, + ), + }; + }, + close: async () => ({ ok: true, value: null }), + }; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { createClient: () => ({ client: resourceClient, capabilities: [] }) }, + ); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "bundle-resource-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push({ + close: async () => { + await client.close(); + await server.close(); + await session.close(); + await rm(parent, { recursive: true, force: true }); + }, + }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + await expectResourceSchemaBranches(client); + + const opened = await client.callTool({ + name: "open_bundle_resources", + arguments: { path: bundle }, + }); + expect(opened.isError).not.toBe(true); + expect( + toolContract("open_bundle_resources").outputSchema.parse( + opened.structuredContent, + ), + ).toMatchObject({ + result: { + canonical_path: bundle, + executable_sha256: null, + }, + }); + + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(status.isError).not.toBe(true); + expect( + toolContract("binary_session").outputSchema.parse(status.structuredContent), + ).toMatchObject({ + result: { + open: true, + session_kind: "bundle_resources", + resource_root: { canonical_path: bundle, executable_sha256: null }, + }, + }); + + const inspected = await client.callTool({ + name: "inspect_plist", + arguments: { path: plistPath }, + }); + expect(inspected.isError).not.toBe(true); + const evidence = toolContract("inspect_plist").outputSchema.parse( + inspected.structuredContent, + ); + expect(evidence).toMatchObject({ + subject: null, + normalized_result: { + source_path: plistPath, + resource_root: { executable_sha256: null }, + }, + }); + const evidenceId = z + .object({ evidence_id: z.string() }) + .parse(evidence).evidence_id; + expect(session.evidenceById(evidenceId)).toMatchObject({ + subject: null, + operation: "inspect_plist", + normalized_result: { + resource_root: { canonical_path: bundle, executable_sha256: null }, + }, + }); +}); + +it("exposes the production resource session through the MCP SDK for a keyless bundle", async () => { + const parent = await mkdtemp( + join(tmpdir(), "rea-bundle-resource-production-mcp-"), + ); + const bundle = join(parent, "Fixture.app"); + const resourceDirectory = join(bundle, "Resources"); + await mkdir(resourceDirectory, { recursive: true }); + await writeFile(join(bundle, "Info.plist"), ""); + await writeFile( + join(resourceDirectory, "Archive.plist"), + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "resource-only-value"], + $top: { root: { UID: 1 } }, + }), + ); + + const session = await createArtifactBinarySession({}, process.platform); + const server = createServer({ kind: "session", session }); + const client = new Client({ + name: "production-bundle-resource-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push({ + close: async () => { + await client.close(); + await server.close(); + await session.close(); + await rm(parent, { recursive: true, force: true }); + }, + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const opened = await client.callTool({ + name: "open_bundle_resources", + arguments: { path: bundle }, + }); + expect(opened.isError).not.toBe(true); + expect( + toolContract("open_bundle_resources").outputSchema.parse( + opened.structuredContent, + ), + ).toMatchObject({ + result: { canonical_path: bundle, executable_sha256: null }, + }); + + const decoded = await client.callTool({ + name: "decode_interface_builder", + arguments: {}, + }); + expect(decoded.isError).not.toBe(true); + const decodedEvidence = toolContract( + "decode_interface_builder", + ).outputSchema.parse(decoded.structuredContent); + expect(decodedEvidence).toMatchObject({ + subject: null, + normalized_result: { + target_sha256: null, + resource_root: { canonical_path: bundle, executable_sha256: null }, + }, + }); + const decodedEvidenceId = z + .object({ evidence_id: z.string() }) + .parse(decodedEvidence).evidence_id; + for (const view of [ + { kind: "summary" }, + { + kind: "interface-builder", + collection: "documents", + offset: 0, + limit: 64, + }, + ] as const) { + const projected = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "retained-evidence", evidence_id: decodedEvidenceId }, + view, + }, + }); + expect(projected.isError).not.toBe(true); + expect( + toolContract("inspect_analysis_view").outputSchema.parse( + projected.structuredContent, + ), + ).toMatchObject({ + normalized_result: { + artifact: { path: bundle, sha256: null }, + resource_root: { + caller_path: bundle, + canonical_path: bundle, + executable_sha256: null, + }, + }, + }); + } + + const inspected = await client.callTool({ + name: "inspect_keyed_archive", + arguments: { path: "Resources/Archive.plist" }, + }); + expect(inspected.isError).not.toBe(true); + const evidence = toolContract("inspect_keyed_archive").outputSchema.parse( + inspected.structuredContent, + ); + expect(evidence).toMatchObject({ + subject: null, + normalized_result: { + archive_path: "Resources/Archive.plist", + target_sha256: null, + resource_root: { canonical_path: bundle, executable_sha256: null }, + objects: expect.arrayContaining([ + expect.objectContaining({ value: "resource-only-value" }), + ]), + }, + }); + const evidenceId = z + .object({ evidence_id: z.string() }) + .parse(evidence).evidence_id; + expect(session.evidenceById(evidenceId)).toMatchObject({ + subject: null, + operation: "inspect_keyed_archive", + normalized_result: { + resource_root: { canonical_path: bundle, executable_sha256: null }, + }, + }); +}); + +const expectResourceSchemaBranches = async (client: Client): Promise => { + const schemaCases: Array<{ + readonly name: + | "decode_interface_builder" + | "inspect_asset_catalog" + | "inspect_keyed_archive"; + readonly validResult: JsonValue; + }> = [ + { + name: "decode_interface_builder", + validResult: jsonObjectSchema.parse({ + target_sha256: "a".repeat(64), + documents: [], + graph: { + target_sha256: "a".repeat(64), + provider: { id: "fixture", version: null, tool_version: "1" }, + nodes: [], + edges: [], + coverage: [], + truncated: false, + }, + limitations: [], + }), + }, + { + name: "inspect_asset_catalog", + validResult: jsonObjectSchema.parse({ + target_sha256: "a".repeat(64), + catalogs: [], + total_records: 0, + offset: 0, + limit: 1, + records: [], + resource_key_matches: [], + next_offset: null, + truncated: false, + limitations: [], + }), + }, + { + name: "inspect_keyed_archive", + validResult: jsonObjectSchema.parse({ + target_sha256: "a".repeat(64), + archive_path: "Resources/data.archive", + archive_sha256: "b".repeat(64), + archive_format: "binary-plist", + roots: {}, + objects: [], + references: [], + total_objects: 0, + total_references: 0, + offset: 0, + next_offset: null, + truncated: false, + limitations: [], + }), + }, + ] as const; + const advertisedTools = new Map( + (await client.listTools()).tools.map((tool) => [tool.name, tool]), + ); + const resourceRoot = { + caller_path: "/tmp/Fixture.app", + canonical_path: "/tmp/Fixture.app", + info_plist_path: "/tmp/Fixture.app/Info.plist", + layout: "root", + executable_sha256: null, + } as const; + for (const { name, validResult } of schemaCases) { + const contract = toolContract(name); + const validEvidence = createEvidence(undefined, identity, { + operation: name, + parameters: {}, + result: validResult, + }); + expect(contract.outputSchema.safeParse(validEvidence).success, name).toBe( + true, + ); + const malformedResult = structuredClone(validResult) as Record< + string, + JsonValue + >; + malformedResult.target_sha256 = null; + if (name === "decode_interface_builder") { + malformedResult.graph = { + ...(malformedResult.graph as Record), + target_sha256: null, + }; + } + const malformedEvidence = createEvidence(undefined, identity, { + operation: name, + parameters: {}, + result: malformedResult, + }); + expect( + contract.outputSchema.safeParse(malformedEvidence).success, + `${name} canonical rejects missing resource_root`, + ).toBe(false); + const advertised = advertisedTools.get(name)?.outputSchema; + expect(advertised, `${name} is advertised`).toBeDefined(); + if (advertised === undefined) continue; + const validateAdvertised = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(advertised as AnySchema); + const resourceResult = structuredClone(validResult) as Record< + string, + JsonValue + >; + resourceResult.target_sha256 = null; + resourceResult.resource_root = resourceRoot; + if (name === "decode_interface_builder") { + resourceResult.graph = { + ...(resourceResult.graph as Record), + target_sha256: null, + }; + } + const resourceEvidence = createEvidence(undefined, identity, { + operation: name, + parameters: {}, + result: resourceResult, + }); + expect( + contract.outputSchema.safeParse(resourceEvidence).success, + name, + ).toBe(true); + expect( + validateAdvertised(resourceEvidence), + `${name} advertised root branch`, + ).toBe(true); + expect( + validateAdvertised(malformedEvidence), + `${name} advertised schema rejects missing resource_root`, + ).toBe(false); + } +}; diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 8fc3867d64..6d87921842 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -107,7 +107,9 @@ export const registerEvidenceTools = ( options.sourceEvidence?.(contract.name, execution.value.result) ?? []; const evidence = createEvidence( - execution.value.subject ?? options.activeTarget?.(), + execution.value.subject === undefined + ? options.activeTarget?.() + : (execution.value.subject ?? undefined), execution.value.provider, { operation: contract.name, diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 68c6ddbaa6..4bc6ac6bb8 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -278,6 +278,7 @@ export const registerSessionTools = ( options: SessionToolOptions, ): void => { const openContract = toolContract("open_binary"); + const openBundleResourcesContract = toolContract("open_bundle_resources"); const closeContract = toolContract("close_binary"); const statusContract = toolContract("binary_session"); const exportContract = toolContract("export_evidence_bundle"); @@ -289,6 +290,39 @@ export const registerSessionTools = ( const compareBundlesContract = toolContract("compare_bundles"); const snapshotContract = toolContract("get_evidence_bundle"); const processResources = new ProcessCaptureResourceScope(); + server.registerTool( + openBundleResourcesContract.name, + toolRegistrationOptions(openBundleResourcesContract), + async (input, context) => { + const opened = await logToolExecution( + logger, + openBundleResourcesContract.name, + () => + session.openBundleResources(input.path, { + signal: context.mcpReq.signal, + }), + ); + if (!opened.ok) + return server.delivery.toCallToolResult( + opened, + openBundleResourcesContract, + ); + const root = opened.value; + return server.delivery.toCallToolResult( + { + ok: true, + value: { + caller_path: root.callerPath, + canonical_path: root.canonicalPath, + info_plist_path: root.infoPlistPath, + layout: root.layout, + executable_sha256: root.executableSha256, + }, + }, + openBundleResourcesContract, + ); + }, + ); registerLifecycleTools( { ...options, diff --git a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts new file mode 100644 index 0000000000..00539fd2e6 --- /dev/null +++ b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts @@ -0,0 +1,234 @@ +import { build } from "plist"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const resourceOperations = [ + "inspect_plist", + "decode_interface_builder", + "inspect_asset_catalog", + "inspect_keyed_archive", +] as const; + +cliTest.skipIf(process.platform !== "darwin")( + "inspects a keyless app resource root through the compiled CLI", + async ({ cli }) => { + const temporaryRoot = await createTestTempDirectory( + "rea-bundle-resource-acceptance-", + ); + const firstBundle = await createKeylessBundle(temporaryRoot, "First"); + + for (const scenario of cliScenarios(firstBundle)) { + const response = await cli.run({ + arguments: [...scenario.arguments, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect( + response.exitCode, + `${scenario.operation}: ${response.stdout} ${response.stderr}`, + ).toBe(0); + const evidence = expectResourceEvidence( + response.json, + scenario.operation, + firstBundle, + ); + if (scenario.operation === "inspect_keyed_archive") + expect(evidence.normalized_result).toMatchObject({ + archive_path: "Resources/Archive.plist", + objects: expect.arrayContaining([ + expect.objectContaining({ value: "resource-only-value" }), + ]), + }); + } + }, +); + +cliTest.skipIf(process.platform !== "darwin")( + "retains and switches keyless resource sessions through stdio MCP", + async () => { + const temporaryRoot = await createTestTempDirectory( + "rea-resource-session-acceptance-", + ); + const firstBundle = await createKeylessBundle(temporaryRoot, "First"); + const secondBundle = await createKeylessBundle(temporaryRoot, "Second"); + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_bundle_resources", { path: firstBundle }); + expect(opened.isError).not.toBe(true); + expect( + toolContract("open_bundle_resources").outputSchema.parse( + opened.structuredContent, + ), + ).toMatchObject({ + result: { + caller_path: firstBundle, + canonical_path: firstBundle, + executable_sha256: null, + }, + }); + + const firstStatus = await call("binary_session", {}); + expect(firstStatus.isError).not.toBe(true); + expect( + toolContract("binary_session").outputSchema.parse( + firstStatus.structuredContent, + ), + ).toMatchObject({ + result: { + open: true, + session_kind: "bundle_resources", + analysis_provider_binding: null, + analysis_provider_candidates: [], + resource_root: { + canonical_path: firstBundle, + executable_sha256: null, + }, + }, + }); + + for (const operation of resourceOperations) { + const arguments_ = + operation === "inspect_keyed_archive" + ? { path: "Resources/Archive.plist" } + : {}; + const response = await call(operation, arguments_); + expect(response.isError, operation).not.toBe(true); + const evidence = expectResourceEvidence( + response.structuredContent, + operation, + firstBundle, + ); + if (operation === "inspect_keyed_archive") + expect(evidence.normalized_result).toMatchObject({ + archive_path: "Resources/Archive.plist", + objects: expect.arrayContaining([ + expect.objectContaining({ value: "resource-only-value" }), + ]), + }); + } + + const retained = await call("get_evidence_bundle", {}); + expect(retained.isError).not.toBe(true); + const evidenceBundle = toolContract( + "get_evidence_bundle", + ).outputSchema.parse(retained.structuredContent); + for (const operation of resourceOperations) { + const record = evidenceBundle.result.records.find( + (record) => record.operation === operation, + ); + expectResourceEvidence(record, operation, firstBundle); + } + + const switched = await call("open_bundle_resources", { + path: secondBundle, + }); + expect(switched.isError).not.toBe(true); + expect( + toolContract("open_bundle_resources").outputSchema.parse( + switched.structuredContent, + ), + ).toMatchObject({ result: { canonical_path: secondBundle } }); + const switchedStatus = await call("binary_session", {}); + expect(switchedStatus.isError).not.toBe(true); + expect( + toolContract("binary_session").outputSchema.parse( + switchedStatus.structuredContent, + ), + ).toMatchObject({ + result: { + open: true, + session_kind: "bundle_resources", + resource_root: { + caller_path: secondBundle, + canonical_path: secondBundle, + executable_sha256: null, + }, + }, + }); + + const closed = await call("close_binary", {}); + expect(closed.isError).not.toBe(true); + toolContract("close_binary").outputSchema.parse(closed.structuredContent); + const finalStatus = await call("binary_session", {}); + expect(finalStatus.isError).not.toBe(true); + expect( + toolContract("binary_session").outputSchema.parse( + finalStatus.structuredContent, + ), + ).toMatchObject({ result: { open: false, cleanup_pending: false } }); + }, +); + +const createKeylessBundle = async ( + root: string, + name: string, +): Promise => { + const bundle = join(root, `${name}.app`); + await mkdir(join(bundle, "Resources"), { recursive: true }); + await mkdir(join(bundle, "Assets.xcassets")); + await writeFile( + join(bundle, "Info.plist"), + build({ CFBundleIdentifier: `org.example.${name.toLowerCase()}` }), + ); + await writeFile( + join(bundle, "Resources", "Archive.plist"), + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "resource-only-value"], + $top: { root: { UID: 1 } }, + }), + ); + return bundle; +}; + +const cliScenarios = (bundle: string) => + [ + { operation: "inspect_plist", arguments: ["inspect-plist", bundle] }, + { + operation: "decode_interface_builder", + arguments: ["decode-interface-builder", bundle], + }, + { + operation: "inspect_asset_catalog", + arguments: ["inspect-asset-catalog", bundle], + }, + { + operation: "inspect_keyed_archive", + arguments: ["inspect-keyed-archive", bundle, "Resources/Archive.plist"], + }, + ] as const; + +const expectResourceEvidence = ( + value: unknown, + operation: (typeof resourceOperations)[number], + bundle: string, +) => { + const evidence = parseEvidence( + toolContract(operation).outputSchema.parse(value), + ); + expect(evidence).toMatchObject({ + operation, + subject: null, + normalized_result: { + resource_root: { + caller_path: bundle, + canonical_path: bundle, + executable_sha256: null, + }, + }, + }); + if (operation === "inspect_plist") + expect(evidence.normalized_result).toMatchObject({ + source_path: join(bundle, "Info.plist"), + bundle: { executable: null }, + }); + else + expect(evidence.normalized_result).toMatchObject({ target_sha256: null }); + return evidence; +}; diff --git a/tests/boundary/providers/bundleResourceComposition.test.ts b/tests/boundary/providers/bundleResourceComposition.test.ts new file mode 100644 index 0000000000..448c04e1df --- /dev/null +++ b/tests/boundary/providers/bundleResourceComposition.test.ts @@ -0,0 +1,115 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build } from "plist"; +import { afterEach, expect, it } from "vitest"; + +import { parseConfig } from "../../../src/config/parseConfig.js"; +import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js"; +import { runProviderAnalysis } from "../../../src/application/DirectAnalysis.js"; +import { + createArtifactBinarySession, + createBinarySession, + createManagedBinarySession, + createNativeBinarySession, + createNativeDispatchBinarySession, +} from "../../../src/composition/binary.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("routes a CLI app resource path through the resource session and preserves null target identity", async () => { + const bundlePath = await mkdtemp(join(tmpdir(), "rea-cli-resource-root-")); + temporaryDirectories.push(bundlePath); + await mkdir(join(bundlePath, "Resources")); + await writeFile( + join(bundlePath, "Info.plist"), + '', + ); + await writeFile( + join(bundlePath, "Resources", "Archive.plist"), + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "sample"], + $top: { root: { UID: 1 } }, + }), + ); + const dependencies: DirectAnalysisDependencies = { + readConfiguration: () => parseConfig({}), + createBinarySession: (config, logger) => + createBinarySession(config, logger, {}), + createNativeDispatchBinarySession: (config, logger) => + createNativeDispatchBinarySession(config, logger, {}), + createArtifactBinarySession: () => + createArtifactBinarySession({}, process.platform), + createNativeBinarySession: () => + createNativeBinarySession({}, process.platform), + createManagedBinarySession, + }; + const result = await runProviderAnalysis( + dependencies, + bundlePath, + "inspect_keyed_archive", + { path: "Resources/Archive.plist" }, + ); + expect(result).toMatchObject({ + operation: "inspect_keyed_archive", + subject: null, + normalized_result: { + target_sha256: null, + resource_root: { canonical_path: bundlePath, executable_sha256: null }, + }, + }); +}); + +it("reports resource capabilities from their providers on supported and unsupported hosts", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-capabilities-")); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + + for (const [platform, expected] of [ + ["darwin", { plist: true, assets: true }], + ["linux", { plist: false, assets: false }], + ] as const) { + const session = await createArtifactBinarySession({}, platform); + const opened = await session.openBundleResources(rootPath); + expect(opened.ok, platform).toBe(true); + const status = await session.status(); + const capabilitySets = ( + status as { + capabilities: Array<{ + groups: Array<{ operations: string[]; available: boolean }>; + }>; + } + ).capabilities; + const operationAvailability = new Map( + capabilitySets.flatMap(({ groups }) => + groups.flatMap(({ operations, available }) => + operations.map((operation) => [operation, available] as const), + ), + ), + ); + expect(operationAvailability.get("inspect_plist"), platform).toBe( + expected.plist, + ); + expect(operationAvailability.get("inspect_asset_catalog"), platform).toBe( + expected.assets, + ); + expect( + operationAvailability.get("decode_interface_builder"), + platform, + ).toBe(true); + expect(operationAvailability.get("inspect_keyed_archive"), platform).toBe( + true, + ); + await session.close(); + } +});