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
This commit is contained in:
morluto committed 2026-10-21 04:22:20 +08:00
1 parent 6389e50337
commit 6a6adf703f
58 files changed
+3582 -189

No files matched your search

@@ -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.
@@ -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
+11
View File
@@ -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
+30 -18
View File
@@ -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<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
/** Report each owned cleanup failure as soon as its owner observes it. */
readonly onCleanupFailure?: (failure: AnalysisError) => void;
},
): Promise<Result<null, AnalysisError>>;
}
+58
View File
@@ -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<Buffer>;
@@ -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<Result<ResolvedAppBundleResourceRoot, BinaryTargetError>> => {
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<BundleLayout | undefined> => {
@@ -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"), "<plist><dict/></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"), "<plist><dict/></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();
});
+42 -4
View File
@@ -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<string> = new Set([
const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet<string> = 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<Result<boolean, BinaryTargetError>> => {
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:
@@ -0,0 +1,117 @@
import { afterEach, expect, it, vi } from "vitest";
const statFailures = vi.hoisted(() => new Map<string, unknown>());
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
stat: vi.fn(async (...args: Parameters<typeof original.stat>) => {
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();
});
+20 -14
View File
@@ -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 }),
},
),
);
};
@@ -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,
);
}
@@ -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();
@@ -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<AnalysisExecution["subject"]> | 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);
}
+418 -13
View File
@@ -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<string> = 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<AnalysisError>();
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<Promise<unknown>, number>();
readonly #promptCompletionControllers = new Set<AbortController>();
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<ExecutionOptions, "signal"> = {},
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>> {
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<BundleResourceClientFactory["createClient"]>
>;
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<Result<BinaryTarget, AnalysisError> | 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<Result<ResolvedSessionOpen, AnalysisError>> {
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<Result<BinarySessionOpenOptions, AnalysisError>> {
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<Result<null, AnalysisError>>,
): Promise<Result<Result<Value, AnalysisError>, 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<Result<null, AnalysisError>> {
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<Result<null, AnalysisError>> {
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<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
readonly onCleanupFailure?: (failure: AnalysisError) => void;
} = {},
): Promise<Result<null, AnalysisError>> {
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<Result<null, AnalysisError>> {
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<AnalysisError> {
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<JsonValue> {
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,
@@ -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"),
'<?xml version="1.0"?><plist><dict/></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"), "<plist><dict/></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"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((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<never>((_, 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"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((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<never>((_, 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"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((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<never>((_, 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"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((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<never>((_, 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"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((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<never>((_, 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"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((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<never>((_, 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"), "<plist><dict/></plist>");
let resourceCloseStarted!: () => void;
const closing = new Promise<void>((resolve) => {
resourceCloseStarted = resolve;
});
let releaseResourceClose!: () => void;
const closeGate = new Promise<void>((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<never>((_, 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);
});
@@ -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,
);
@@ -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 = (
@@ -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<void>;
explicitNullSubject?: boolean;
} = {},
): {
session: BinarySession;
@@ -383,6 +417,7 @@ const killableDeepProvider = (options: {
operationHealth?: boolean;
failOperationInitially?: boolean;
beforeFirstClose?: () => Promise<void>;
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 }
: {},
),
),
)
@@ -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<Result<BinaryTarget, AnalysisError>>;
/** Open a resource-only app root without selecting or inventing an executable target. */
openBundleResources(
path: string,
options?: Pick<ExecutionOptions, "signal">,
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>>;
close(
options?: Pick<ExecutionOptions, "progress"> & {
readonly retainProviderDocuments?: boolean;
+49 -24
View File
@@ -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<SessionProviderRouter, "candidateStatuses">;
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<JsonValue> => {
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,
@@ -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<void>((resolve) => {
nativeCloseStarted = resolve;
});
let releaseNativeClose!: () => void;
const nativeCloseGate = new Promise<void>((resolve) => {
releaseNativeClose = resolve;
});
let cleanupFailureObserved!: (failure: Error) => void;
const failureObserved = new Promise<Error>((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);
});
@@ -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<AnalysisOperation>([
"inspect_plist",
]);
export const BUNDLE_ARTIFACT_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
"decode_interface_builder",
"inspect_asset_catalog",
"inspect_keyed_archive",
]);
export const BUNDLE_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
...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<BundleResourceClientBinding>;
}
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);
},
});
@@ -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,
@@ -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,
+1 -1
View File
@@ -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",
@@ -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(
+120 -1
View File
@@ -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<NodeJS.ProcessEnv>,
private readonly platform: NodeJS.Platform,
) {}
async execute(
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
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<Result<null, AnalysisError>> {
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),
);
}
}
@@ -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"),
'<?xml version="1.0"?><plist><dict/></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);
});
@@ -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<NodeJS.ProcessEnv>;
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,
@@ -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,
+5 -1
View File
@@ -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),
+69
View File
@@ -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"), "<plist><dict/></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" }),
]),
},
});
});
+66 -7
View File
@@ -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<NodeJS.ProcessEnv>,
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<BinarySession> =>
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<BinarySession> =>
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,
),
]),
),
);
+5
View File
@@ -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(),
+8 -2
View File
@@ -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(
+1
View File
@@ -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",
+1
View File
@@ -679,6 +679,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
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,
+7
View File
@@ -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,
+6 -1
View File
@@ -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"),
}),
@@ -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;
@@ -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.",
@@ -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 = [
+20 -13
View File
@@ -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<typeof recordSchema>;
@@ -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,
+33
View File
@@ -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<typeof bundleResourceRootSchema>;
/** Output identity: executable-bound, or explicitly rooted resource-only. */
export const bundleResourceBoundResultSchema = <Fields extends z.ZodRawShape>(
executableSha256: z.ZodType<string>,
fields: Fields,
) =>
z.union([
z.strictObject({
target_sha256: executableSha256,
resource_root: bundleResourceRootSchema.optional(),
...fields,
}),
z.strictObject({
target_sha256: z.null(),
resource_root: bundleResourceRootSchema,
...fields,
}),
]);
@@ -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 = {
+18 -7
View File
@@ -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<typeof nativeInvestigationEdgeSchema>;
/** 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<typeof interfaceBuilderLimitsSchema>;
@@ -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,
+39 -31
View File
@@ -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,
+2
View File
@@ -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()),
});
+16 -8
View File
@@ -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),
+1 -1
View File
@@ -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<typeof createEvidence>[0] | null;
readonly subject?: Parameters<typeof createEvidence>[0] | null;
readonly provider: Parameters<typeof createEvidence>[1];
readonly result: JsonValue;
readonly rawResult: JsonValue | null;
+62 -5
View File
@@ -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<PrivateRuntimeRoot, "path" | "close">
@@ -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<NodeJS.ProcessEnv>,
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<Record<string, JsonValue>>,
@@ -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,
});
@@ -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, '<?xml version="1.0"?><plist><dict/></plist>');
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,
},
});
});
+16
View File
@@ -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<NativePlistSelection, AnalysisError> => {
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);
+15 -11
View File
@@ -12,21 +12,25 @@ export const closeAnalysisClient = async (
providerId: string,
options: Pick<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
readonly onCleanupFailure?: (failure: AnalysisError) => void;
} = {},
): Promise<Result<null, AnalysisError>> => {
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);
}
};
+428
View File
@@ -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<unknown> }> = [];
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, '<?xml version="1.0"?><plist><dict/></plist>');
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"), "<plist><dict/></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<void> => {
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<string, JsonValue>),
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<string, JsonValue>),
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);
}
};
+3 -1
View File
@@ -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,
+34
View File
@@ -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,
@@ -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<string> => {
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;
};
@@ -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"),
'<?xml version="1.0"?><plist><dict/></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"), "<plist><dict/></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();
}
});