refactor(mobile): retain inventory sources at the projection boundary

This commit is contained in:
morluto committed 2026-10-22 07:48:39 +08:00
1 parent fafc1881b5
commit c1a0561bf3
16 files changed
+124 -61

No files matched your search

+17 -7
View File
@@ -9,6 +9,7 @@ import {
type EvidenceSubjectTarget,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import type { MobileApplicationProjectionWithSources } from "../domain/mobileApplicationGraphProjection.js";
import { err, ok, type Result } from "../domain/result.js";
import { requestInputError, workflowInputError } from "./workflowInputError.js";
@@ -23,13 +24,20 @@ interface InventoryProjectionResult {
readonly limitations: readonly string[];
}
export interface InventoryProjectionEvidenceResult {
readonly evidence: Evidence;
readonly sources: readonly Evidence[];
}
interface InventoryProjectionOptions<
Input extends InventoryProjectionInput,
Output extends InventoryProjectionResult,
> {
readonly rawInput: unknown;
readonly schema: z.ZodType<Input>;
readonly project: (input: Input) => Output;
readonly project: (
input: Input,
) => MobileApplicationProjectionWithSources<Output>;
readonly operation: string;
readonly predicateType: string;
readonly provider: EvidenceProvider;
@@ -43,17 +51,18 @@ export const projectInventoryEvidence = <
Output extends InventoryProjectionResult,
>(
options: InventoryProjectionOptions<Input, Output>,
): Result<Evidence, AnalysisError> => {
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
const parsed = options.schema.safeParse(options.rawInput);
if (!parsed.success)
return err(
requestInputError(options.operation, parsed.error, options.rawInput),
);
try {
const result = options.project(parsed.data);
const first = parsed.data.inventory_evidence[0];
return ok(
createEvidence(
const projected = options.project(parsed.data);
const result = projected.projection;
const first = projected.sources[0];
return ok({
evidence: createEvidence(
first?.subject === null || first?.subject === undefined
? undefined
: {
@@ -80,7 +89,8 @@ export const projectInventoryEvidence = <
evidenceLinks: result.source_evidence_ids,
},
),
);
sources: projected.sources,
});
} catch (cause: unknown) {
return err(
cause instanceof TypeError || cause instanceof z.ZodError
@@ -1,19 +1,21 @@
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import {
androidApplicationProjectionInputSchema,
projectAndroidApplication,
} from "../../domain/android/androidApplication.js";
import type { Result } from "../../domain/result.js";
import { ANDROID_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js";
import {
projectInventoryEvidence,
type InventoryProjectionEvidenceResult,
} from "../InventoryProjectionEvidence.js";
const OPERATION = "project_android_application_graph" as const;
/** Project authenticated APK inventory Evidence into Android application Evidence. */
export const projectAndroidApplicationEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
return projectInventoryEvidence({
rawInput,
schema: androidApplicationProjectionInputSchema,
@@ -1,19 +1,21 @@
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import {
appleApplicationProjectionInputSchema,
projectAppleApplication,
} from "../../domain/apple/appleApplication.js";
import type { Result } from "../../domain/result.js";
import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js";
import {
projectInventoryEvidence,
type InventoryProjectionEvidenceResult,
} from "../InventoryProjectionEvidence.js";
const OPERATION = "project_apple_application_graph" as const;
/** Project authenticated IPA or macOS app inventory Evidence into Apple application evidence. */
export const projectAppleApplicationEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
return projectInventoryEvidence({
rawInput,
schema: appleApplicationProjectionInputSchema,
@@ -1,19 +1,21 @@
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import {
harmonyApplicationProjectionInputSchema,
projectHarmonyApplication,
} from "../../domain/harmony/harmonyApplication.js";
import type { Result } from "../../domain/result.js";
import { HARMONY_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js";
import {
projectInventoryEvidence,
type InventoryProjectionEvidenceResult,
} from "../InventoryProjectionEvidence.js";
const OPERATION = "project_harmony_application_graph" as const;
/** Project authenticated HarmonyOS package inventory Evidence into application evidence. */
export const projectHarmonyApplicationEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
return projectInventoryEvidence({
rawInput,
schema: harmonyApplicationProjectionInputSchema,
@@ -133,7 +133,7 @@ describe("content identity and occurrence facts", () => {
},
),
],
});
}).projection;
expect(
projection.components.javascript.map((component) => component.path),
).toEqual(["Demo.app/Contents/Resources/a.js"]);
+3 -3
View File
@@ -93,7 +93,7 @@ export const registerApplicationCommands = (
await import("../application/android/AndroidApplicationService.js");
const result = projectAndroidApplicationEvidence(input);
return result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
? { ok: true, value: jsonValueSchema.parse(result.value.evidence) }
: result;
},
});
@@ -109,7 +109,7 @@ export const registerApplicationCommands = (
await import("../application/apple/AppleApplicationService.js");
const result = projectAppleApplicationEvidence(input);
return result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
? { ok: true, value: jsonValueSchema.parse(result.value.evidence) }
: result;
},
});
@@ -125,7 +125,7 @@ export const registerApplicationCommands = (
await import("../application/harmony/HarmonyApplicationService.js");
const result = projectHarmonyApplicationEvidence(input);
return result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
? { ok: true, value: jsonValueSchema.parse(result.value.evidence) }
: result;
},
});
+9 -5
View File
@@ -9,6 +9,7 @@ import { prefixedDigestSchema } from "../digests.js";
import { nativeRuntimeConvention } from "../nativeConvention.js";
import {
applicationInventoryProjectionInputSchema,
type MobileApplicationProjectionWithSources,
bridgeCandidateCoverageSchema,
projectCartesianCandidates,
projectedBridgeCandidateSchema,
@@ -85,7 +86,7 @@ type Component = z.infer<typeof componentSchema>;
/** Project exact APK paths and hashes without decoding or executing target code. */
export const projectAndroidApplication = (
input: AndroidApplicationProjectionInput,
): AndroidApplicationProjectionResult => {
): MobileApplicationProjectionWithSources<AndroidApplicationProjectionResult> => {
const parsed = androidApplicationProjectionInputSchema.parse(input);
const { evidence, inventory } = parseArtifactInventoryEvidence(
parsed.inventory_evidence,
@@ -149,10 +150,13 @@ export const projectAndroidApplication = (
),
limitations,
};
return androidApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: `adp_${projectionDigest(withoutId, "Android application projection")}`,
});
return {
projection: androidApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: `adp_${projectionDigest(withoutId, "Android application projection")}`,
}),
sources: evidence,
};
};
const classify = (all: readonly Component[]) => ({
+2 -2
View File
@@ -161,7 +161,7 @@ const inventoryEvidence = (
};
const project = (evidence: Evidence) =>
projectAppleApplication({ inventory_evidence: [evidence] });
projectAppleApplication({ inventory_evidence: [evidence] }).projection;
const macho = (path: string): FixtureEntry => ({ path, format: "mach-o" });
@@ -849,7 +849,7 @@ it.each(["file", "slice"] as const)(
},
]),
],
});
}).projection;
expect(projection.unavailable_components).toEqual([
{
path: "Payload/Fixture.app/libentry.dylib",
+9 -5
View File
@@ -25,6 +25,7 @@ import {
bridgeCandidateCoverageSchema,
projectCartesianCandidates,
} from "../bridgeCandidateProjection.js";
import type { MobileApplicationProjectionWithSources } from "../mobileApplicationGraphProjection.js";
import {
projectedComponentSchema,
projectedInventoryEntries,
@@ -113,7 +114,7 @@ type RootFormat = (typeof APPLE_ROOT_FORMATS)[number];
/** Project exact Apple application paths and hashes without parsing or executing target code. */
export const projectAppleApplication = (
input: AppleApplicationProjectionInput,
): AppleApplicationProjectionResult => {
): MobileApplicationProjectionWithSources<AppleApplicationProjectionResult> => {
const parsed = appleApplicationProjectionInputSchema.parse(input);
const { evidence, inventory } = parseArtifactInventoryEvidence(
parsed.inventory_evidence,
@@ -200,10 +201,13 @@ export const projectAppleApplication = (
},
limitations,
};
return appleApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: appleProjectionId(withoutId),
});
return {
projection: appleApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: appleProjectionId(withoutId),
}),
sources: evidence,
};
};
/** Identify the complete projection, including source Evidence provenance. */
+9 -5
View File
@@ -11,6 +11,7 @@ import {
applicationInventoryProjectionInputSchema,
bridgeCandidateCoverageSchema,
projectCartesianCandidates,
type MobileApplicationProjectionWithSources,
projectedBridgeCandidateSchema,
projectedComponentSchema,
projectedInventoryEntries,
@@ -78,7 +79,7 @@ type Component = z.infer<typeof componentSchema>;
/** Project exact HarmonyOS package paths and hashes without decoding or executing target code. */
export const projectHarmonyApplication = (
input: HarmonyApplicationProjectionInput,
): HarmonyApplicationProjectionResult => {
): MobileApplicationProjectionWithSources<HarmonyApplicationProjectionResult> => {
const parsed = harmonyApplicationProjectionInputSchema.parse(input);
const { evidence, inventory } = parseArtifactInventoryEvidence(
parsed.inventory_evidence,
@@ -145,10 +146,13 @@ export const projectHarmonyApplication = (
),
limitations,
};
return harmonyApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: `hmp_${projectionDigest(withoutId, "HarmonyOS application projection")}`,
});
return {
projection: harmonyApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: `hmp_${projectionDigest(withoutId, "HarmonyOS application projection")}`,
}),
sources: evidence,
};
};
const harmonyRootFormat = (format: string): RootFormat => {
@@ -7,7 +7,7 @@ import {
hasCompleteInventoryCoverage,
type ParsedInventorySet,
} from "./artifactInventoryEvidence.js";
import { evidenceSchema } from "./evidence.js";
import { evidenceSchema, type Evidence } from "./evidence.js";
import { digestSchema } from "./digests.js";
import { prefixedDigestSchema } from "./digests.js";
import {
@@ -44,6 +44,12 @@ export const unavailableComponentSchema = z.strictObject({
export type ProjectedComponent = z.infer<typeof projectedComponentSchema>;
/** A projection and the authenticated source Evidence used to derive it. */
export interface MobileApplicationProjectionWithSources<Projection> {
readonly projection: Projection;
readonly sources: readonly Evidence[];
}
/** Path-pair hypotheses connecting managed content to a native library. */
export const projectedBridgeCandidateSchema = <
Basis extends readonly [string, ...string[]],
@@ -10,8 +10,8 @@ import {
applicationToolContract,
} from "../../contracts/applicationToolContracts.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import type { Result } from "../../domain/result.js";
import type { InventoryProjectionEvidenceResult } from "../../application/InventoryProjectionEvidence.js";
import { recordSessionEvidenceSources } from "../sessionEvidence.js";
import { logToolExecution } from "../toolLogging.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
@@ -47,7 +47,9 @@ const registerProjection = (context: {
readonly server: EvidenceMcpServer;
readonly options: ApplicationToolRegistration;
readonly contract: (typeof APPLICATION_TOOL_CONTRACTS)[number];
readonly project: (input: unknown) => Result<Evidence, AnalysisError>;
readonly project: (
input: unknown,
) => Result<InventoryProjectionEvidenceResult, AnalysisError>;
}): void => {
const { server, options, contract, project } = context;
server.registerTool(
@@ -69,27 +71,16 @@ const registerProjection = (context: {
return server.delivery.toCallToolResult(result, contract);
const recordedSources = recordSessionEvidenceSources(
options.recordEvidence,
inventoryEvidenceSources(input),
result.value.sources,
);
if (!recordedSources.ok)
return server.delivery.toCallToolResult(recordedSources, contract);
return recordResult(
{ ...options, delivery: server.delivery },
contract,
result.value,
result.value.evidence,
);
},
),
);
};
const inventoryEvidenceSources = (input: unknown): readonly Evidence[] => {
if (
typeof input !== "object" ||
input === null ||
!("inventory_evidence" in input) ||
!Array.isArray(input.inventory_evidence)
)
return [];
return input.inventory_evidence as readonly Evidence[];
};
@@ -313,7 +313,7 @@ describe("Android bridge candidate coverage", () => {
if (!result.ok) throw result.error;
const projection = androidApplicationProjectionResultSchema.parse(
result.value.normalized_result,
result.value.evidence.normalized_result,
);
expect(projection.components.dex).toHaveLength(1_001);
expect(projection.bridge_candidates).toHaveLength(1_001);
@@ -346,7 +346,7 @@ describe("Android bridge candidate coverage", () => {
});
if (!result.ok) throw result.error;
const projection = androidApplicationProjectionResultSchema.parse(
result.value.normalized_result,
result.value.evidence.normalized_result,
);
expect(projection.components.dex).toHaveLength(129);
@@ -66,8 +66,11 @@ async function createCompleteAppleProjection() {
});
if (!result.ok) throw new TypeError("Could not project fixture inventory");
expect(result.value.sources.map(({ evidence_id }) => evidence_id)).toEqual(
fragments.map(({ evidence_id }) => evidence_id),
);
const projection = appleApplicationProjectionResultSchema.parse(
result.value.normalized_result,
result.value.evidence.normalized_result,
);
expect([...projection.source_evidence_ids].sort()).toEqual(
fragments.map(({ evidence_id }) => evidence_id).sort(),
@@ -481,7 +484,7 @@ describe("Apple application projection completeness", () => {
if (!result.ok) throw result.error;
expect(
appleApplicationProjectionResultSchema.parse(
result.value.normalized_result,
result.value.evidence.normalized_result,
),
).toMatchObject({
application_roots: [applicationRoot],
@@ -12,6 +12,7 @@ import { afterEach, expect, it } from "vitest";
import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createServer } from "../../../src/server/createServer.js";
@@ -84,9 +85,10 @@ it("advertises and executes Android and Apple inventory projections", async () =
const apkInventory = parseEvidence(
await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}),
);
const apkSources = fragmentInventoryEvidence(apkInventory, 2).toReversed();
const android = await client.callTool({
name: "project_android_application_graph",
arguments: { inventory_evidence: [apkInventory] },
arguments: { inventory_evidence: apkSources },
});
if (android.isError === true)
throw new Error(JSON.stringify(android, null, 2));
@@ -94,6 +96,38 @@ it("advertises and executes Android and Apple inventory projections", async () =
operation: "project_android_application_graph",
provider: { id: "rea-android-application" },
});
const androidEvidence = parseEvidence(android.structuredContent);
const androidBundle = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
expect(androidBundle.structuredContent).toMatchObject({
result: {
records: expect.arrayContaining([...apkSources, androidEvidence]),
},
});
const tamperedSource = {
...apkSources[0]!,
evidence_id: apkSources[1]!.evidence_id,
};
const rejected = await client.callTool({
name: "project_android_application_graph",
arguments: { inventory_evidence: [tamperedSource] },
});
expect(rejected.isError).toBe(true);
const bundleAfterRejectedSource = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
expect(bundleAfterRejectedSource.structuredContent).toMatchObject({
result: {
records: expect.arrayContaining([...apkSources, androidEvidence]),
},
});
expect(bundleAfterRejectedSource.structuredContent).toEqual(
androidBundle.structuredContent,
);
const ipaInventory = parseEvidence(
await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}),
+4 -3
View File
@@ -13,16 +13,17 @@ import { createDirectAnalysis } from "../../src/composition/directAnalysis.js";
import type { AnalysisError } from "../../src/domain/analysisErrorBase.js";
import { type Evidence, parseEvidence } from "../../src/domain/evidence.js";
import type { Result } from "../../src/domain/result.js";
import type { InventoryProjectionEvidenceResult } from "../../src/application/InventoryProjectionEvidence.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const { runProviderAnalysis } = createDirectAnalysis({});
/** Assert a projection succeeded and return its Evidence for schema parsing. */
export const requireSuccessfulProjection = (
result: Result<Evidence, AnalysisError>,
result: Result<InventoryProjectionEvidenceResult, AnalysisError>,
): Evidence => {
if (!result.ok) throw result.error;
return result.value;
return result.value.evidence;
};
/** Write one synthetic ZIP application package from literal entries. */
@@ -61,7 +62,7 @@ export const inventoryApplicationPackage = async (
export const requireDeterministicProjection = <Schema extends z.ZodType>(
project: (input: {
inventory_evidence: readonly Evidence[];
}) => Result<Evidence, AnalysisError>,
}) => Result<InventoryProjectionEvidenceResult, AnalysisError>,
inventory: Evidence,
schema: Schema,
): z.output<Schema> => {