mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(mobile): share authenticated inventory projection sources
Authenticate inventory requests once before family projection, remove obsolete schema aliases and raw facades, and preserve unresolved occurrence references from partial pages without fabricating content identities.
This commit is contained in:
@@ -1,23 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
createEvidence,
|
||||
type Evidence,
|
||||
type EvidenceProvider,
|
||||
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 } from "./workflowInputError.js";
|
||||
import { ArtifactInventoryCarrierError } from "../domain/artifactInventoryEvidence.js";
|
||||
|
||||
interface InventoryProjectionInput {
|
||||
readonly inventory_evidence: readonly Evidence[];
|
||||
readonly limits?: unknown;
|
||||
}
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ParsedInventorySet } from "../domain/artifactInventoryEvidence.js";
|
||||
import { readApplicationInventoryProjection } from "../domain/mobileApplicationGraphProjection.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
|
||||
interface InventoryProjectionResult {
|
||||
readonly root_sha256: string;
|
||||
@@ -31,86 +23,56 @@ export interface InventoryProjectionEvidenceResult {
|
||||
}
|
||||
|
||||
interface InventoryProjectionOptions<
|
||||
Input extends InventoryProjectionInput,
|
||||
Output extends InventoryProjectionResult,
|
||||
Output extends InventoryProjectionResult & JsonValue,
|
||||
> {
|
||||
readonly rawInput: unknown;
|
||||
readonly schema: z.ZodType<Input>;
|
||||
readonly project: (
|
||||
input: Input,
|
||||
) => MobileApplicationProjectionWithSources<Output>;
|
||||
input: ParsedInventorySet,
|
||||
) => Result<Output, AnalysisInputError>;
|
||||
readonly operation: string;
|
||||
readonly predicateType: string;
|
||||
readonly provider: EvidenceProvider;
|
||||
readonly subjectFormat: (first: Evidence) => EvidenceSubjectTarget["format"];
|
||||
}
|
||||
|
||||
/** Parse one inventory projection and wrap its deterministic result in Evidence. */
|
||||
/** Resolve source inventory once, then wrap its family projection in Evidence. */
|
||||
export const projectInventoryEvidence = <
|
||||
Input extends InventoryProjectionInput,
|
||||
Output extends InventoryProjectionResult,
|
||||
Output extends InventoryProjectionResult & JsonValue,
|
||||
>(
|
||||
options: InventoryProjectionOptions<Input, Output>,
|
||||
options: InventoryProjectionOptions<Output>,
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
const parsed = options.schema.safeParse(options.rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
requestInputError(options.operation, parsed.error, options.rawInput),
|
||||
);
|
||||
try {
|
||||
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
|
||||
: {
|
||||
path: first.subject.local_path,
|
||||
sha256: result.root_sha256,
|
||||
format: options.subjectFormat(first),
|
||||
},
|
||||
options.provider,
|
||||
{
|
||||
predicateType: options.predicateType,
|
||||
operation: options.operation,
|
||||
parameters: {
|
||||
inventory_evidence_ids: [...result.source_evidence_ids],
|
||||
...("limits" in parsed.data && parsed.data.limits !== undefined
|
||||
? { limits: jsonValueSchema.parse(parsed.data.limits) }
|
||||
: {}),
|
||||
const source = readApplicationInventoryProjection(
|
||||
options.rawInput,
|
||||
options.operation,
|
||||
);
|
||||
if (!source.ok) return source;
|
||||
const projected = options.project(source.value);
|
||||
if (!projected.ok) return projected;
|
||||
const result = projected.value;
|
||||
const first = source.value.evidence[0];
|
||||
return ok({
|
||||
evidence: createEvidence(
|
||||
first?.subject === null || first?.subject === undefined
|
||||
? undefined
|
||||
: {
|
||||
path: first.subject.local_path,
|
||||
sha256: result.root_sha256,
|
||||
format: options.subjectFormat(first),
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
environment: null,
|
||||
limitations: result.limitations,
|
||||
evidenceLinks: result.source_evidence_ids,
|
||||
},
|
||||
),
|
||||
sources: projected.sources,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
if (cause instanceof ArtifactInventoryCarrierError)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
options.operation,
|
||||
{ cause, userMessage: cause.message },
|
||||
[
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
...(cause.index === undefined ? [] : [cause.index]),
|
||||
...(cause.index === undefined ? [] : ["operation"]),
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: cause.message,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
options.provider,
|
||||
{
|
||||
predicateType: options.predicateType,
|
||||
operation: options.operation,
|
||||
parameters: { inventory_evidence_ids: [...result.source_evidence_ids] },
|
||||
result,
|
||||
rawResult: null,
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
environment: null,
|
||||
limitations: result.limitations,
|
||||
evidenceLinks: result.source_evidence_ids,
|
||||
},
|
||||
),
|
||||
sources: source.value.evidence,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
androidApplicationProjectionInputSchema,
|
||||
projectAndroidApplication,
|
||||
} from "../../domain/android/androidApplication.js";
|
||||
import { projectAndroidInventory } from "../../domain/android/androidApplication.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { ANDROID_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
@@ -18,8 +15,7 @@ export const projectAndroidApplicationEvidence = (
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: androidApplicationProjectionInputSchema,
|
||||
project: projectAndroidApplication,
|
||||
project: projectAndroidInventory,
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.android-application-graph",
|
||||
provider: ANDROID_APPLICATION_PROVIDER,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
appleApplicationProjectionInputSchema,
|
||||
projectAppleApplication,
|
||||
} from "../../domain/apple/appleApplication.js";
|
||||
import { projectAppleInventory } from "../../domain/apple/appleApplication.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
@@ -18,8 +15,7 @@ export const projectAppleApplicationEvidence = (
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: appleApplicationProjectionInputSchema,
|
||||
project: projectAppleApplication,
|
||||
project: projectAppleInventory,
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.apple-application-graph",
|
||||
provider: APPLE_APPLICATION_PROVIDER,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
harmonyApplicationProjectionInputSchema,
|
||||
projectHarmonyApplication,
|
||||
} from "../../domain/harmony/harmonyApplication.js";
|
||||
import { projectHarmonyInventory } from "../../domain/harmony/harmonyApplication.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { HARMONY_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
@@ -18,8 +15,7 @@ export const projectHarmonyApplicationEvidence = (
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: harmonyApplicationProjectionInputSchema,
|
||||
project: projectHarmonyApplication,
|
||||
project: projectHarmonyInventory,
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.harmony-application-graph",
|
||||
provider: HARMONY_APPLICATION_PROVIDER,
|
||||
|
||||
@@ -6,7 +6,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inventoryArtifact } from "../../../tests/fixtures/artifactInventory.js";
|
||||
import { compareArtifacts } from "../../domain/artifactComparison.js";
|
||||
import { projectAppleApplication } from "../../domain/apple/appleApplication.js";
|
||||
import { parseArtifactInventoryEvidence } from "../../domain/artifactInventoryEvidence.js";
|
||||
import { projectAppleInventory } from "../../domain/apple/appleApplication.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { thinMach } from "../../domain/binaryTarget.fixture.js";
|
||||
@@ -115,8 +116,8 @@ describe("content identity and occurrence facts", () => {
|
||||
);
|
||||
await writeFile(path, await writer.close());
|
||||
const inventory = await inventoryArtifact(path);
|
||||
const projection = projectAppleApplication({
|
||||
inventory_evidence: [
|
||||
const projectionResult = projectAppleInventory(
|
||||
parseArtifactInventoryEvidence([
|
||||
createEvidence(
|
||||
{ path, sha256: inventory.manifest.root_sha256, format: "zip" },
|
||||
{
|
||||
@@ -132,8 +133,10 @@ describe("content identity and occurrence facts", () => {
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
),
|
||||
],
|
||||
}).projection;
|
||||
]),
|
||||
);
|
||||
if (!projectionResult.ok) throw projectionResult.error;
|
||||
const projection = projectionResult.value;
|
||||
expect(
|
||||
projection.components.javascript.map((component) => component.path),
|
||||
).toEqual(["Demo.app/Contents/Resources/a.js"]);
|
||||
|
||||
@@ -6,10 +6,7 @@ import { parseCliJsonInput } from "../cliJsonInput.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { androidApplicationProjectionInputSchema } from "../domain/android/androidApplication.js";
|
||||
import { appleApplicationProjectionInputSchema } from "../domain/apple/appleApplication.js";
|
||||
import { harmonyApplicationProjectionInputSchema } from "../domain/harmony/harmonyApplication.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Logger } from "pino";
|
||||
import { traceApplicationFeatureInputSchema } from "../domain/javascript/javascriptFeatureTraceSchemas.js";
|
||||
import { traceJavaScriptSemanticsInputSchema } from "../domain/javascript/javascriptSemanticTraceSchemas.js";
|
||||
@@ -86,14 +83,12 @@ export const registerApplicationCommands = (
|
||||
name: CLI_COMMANDS.projectAndroidApplicationGraph,
|
||||
description:
|
||||
"Project authenticated APK inventory Evidence into an Android application graph",
|
||||
inputSchema: androidApplicationProjectionInputSchema,
|
||||
inputSchema: z.unknown(),
|
||||
workflow: async (input) => {
|
||||
const { projectAndroidApplicationEvidence } =
|
||||
await import("../application/android/AndroidApplicationService.js");
|
||||
const result = projectAndroidApplicationEvidence(input);
|
||||
return result.ok
|
||||
? { ok: true, value: jsonValueSchema.parse(result.value.evidence) }
|
||||
: result;
|
||||
return result.ok ? { ok: true, value: result.value.evidence } : result;
|
||||
},
|
||||
});
|
||||
registerJsonCommand({
|
||||
@@ -102,14 +97,12 @@ export const registerApplicationCommands = (
|
||||
name: CLI_COMMANDS.projectAppleApplicationGraph,
|
||||
description:
|
||||
"Project authenticated IPA or macOS app inventory Evidence into an Apple application graph",
|
||||
inputSchema: appleApplicationProjectionInputSchema,
|
||||
inputSchema: z.unknown(),
|
||||
workflow: async (input) => {
|
||||
const { projectAppleApplicationEvidence } =
|
||||
await import("../application/apple/AppleApplicationService.js");
|
||||
const result = projectAppleApplicationEvidence(input);
|
||||
return result.ok
|
||||
? { ok: true, value: jsonValueSchema.parse(result.value.evidence) }
|
||||
: result;
|
||||
return result.ok ? { ok: true, value: result.value.evidence } : result;
|
||||
},
|
||||
});
|
||||
registerJsonCommand({
|
||||
@@ -118,14 +111,12 @@ export const registerApplicationCommands = (
|
||||
name: CLI_COMMANDS.projectHarmonyApplicationGraph,
|
||||
description:
|
||||
"Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an application graph",
|
||||
inputSchema: harmonyApplicationProjectionInputSchema,
|
||||
inputSchema: z.unknown(),
|
||||
workflow: async (input) => {
|
||||
const { projectHarmonyApplicationEvidence } =
|
||||
await import("../application/harmony/HarmonyApplicationService.js");
|
||||
const result = projectHarmonyApplicationEvidence(input);
|
||||
return result.ok
|
||||
? { ok: true, value: jsonValueSchema.parse(result.value.evidence) }
|
||||
: result;
|
||||
return result.ok ? { ok: true, value: result.value.evidence } : result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
sourceToBundleSummaryViewResultSchema,
|
||||
@@ -21,18 +22,9 @@ import {
|
||||
reconstructionObligationLedgerInputSchema,
|
||||
reconstructionObligationLedgerSchema,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import {
|
||||
androidApplicationProjectionInputSchema,
|
||||
androidApplicationProjectionResultSchema,
|
||||
} from "../domain/android/androidApplication.js";
|
||||
import {
|
||||
appleApplicationProjectionInputSchema,
|
||||
appleApplicationProjectionResultSchema,
|
||||
} from "../domain/apple/appleApplication.js";
|
||||
import {
|
||||
harmonyApplicationProjectionInputSchema,
|
||||
harmonyApplicationProjectionResultSchema,
|
||||
} from "../domain/harmony/harmonyApplication.js";
|
||||
import { androidApplicationProjectionResultSchema } from "../domain/android/androidApplication.js";
|
||||
import { appleApplicationProjectionResultSchema } from "../domain/apple/appleApplication.js";
|
||||
import { harmonyApplicationProjectionResultSchema } from "../domain/harmony/harmonyApplication.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
@@ -280,7 +272,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
||||
description:
|
||||
"Project authenticated APK inventory_artifact Evidence into an execution-free Android application inventory. Reports exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without decoding DEX, executing the APK, or claiming observed runtime calls.",
|
||||
kind: "application",
|
||||
inputSchema: androidApplicationProjectionInputSchema,
|
||||
inputSchema: applicationInventoryProjectionInputSchema,
|
||||
outputSchema: evidenceResultOf(androidApplicationProjectionResultSchema),
|
||||
examples: [
|
||||
{
|
||||
@@ -296,7 +288,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
||||
description:
|
||||
"Project authenticated IPA, macOS .app directory, ZIP, or DMG inventory Evidence into an execution-free Apple application inventory. Reports application roots, nested bundles with path-convention roles (app extensions, XPC services, login items, privileged helpers, system extensions, frameworks), launchd plists, exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without parsing plist/CMS semantics or claiming observed runtime calls.",
|
||||
kind: "application",
|
||||
inputSchema: appleApplicationProjectionInputSchema,
|
||||
inputSchema: applicationInventoryProjectionInputSchema,
|
||||
outputSchema: evidenceResultOf(appleApplicationProjectionResultSchema),
|
||||
examples: [
|
||||
{
|
||||
@@ -316,7 +308,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
||||
description:
|
||||
"Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an execution-free application inventory. Reports packaging-model hints (Stage module.json versus FA config.json), exact component paths and hashes (manifests, Ark bytecode, resources, native libraries, JavaScript, signing), App Pack child packages, runtime-family hints, and path-based N-API bridge hypotheses without decoding Ark bytecode, parsing manifest semantics, or claiming observed runtime calls.",
|
||||
kind: "application",
|
||||
inputSchema: harmonyApplicationProjectionInputSchema,
|
||||
inputSchema: applicationInventoryProjectionInputSchema,
|
||||
outputSchema: evidenceResultOf(harmonyApplicationProjectionResultSchema),
|
||||
examples: [
|
||||
{
|
||||
|
||||
@@ -7,15 +7,13 @@ import { AnalysisInputError } from "../analysisErrorCore.js";
|
||||
|
||||
import {
|
||||
hasCompleteInventoryCoverage,
|
||||
parseArtifactInventoryEvidence,
|
||||
type ParsedInventorySet,
|
||||
} from "../artifactInventoryEvidence.js";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { prefixedDigestSchema } from "../digests.js";
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
import { nativeRuntimeConvention } from "../nativeConvention.js";
|
||||
import {
|
||||
applicationInventoryProjectionInputSchema,
|
||||
type MobileApplicationProjectionWithSources,
|
||||
projectedBridgeCandidateSchema,
|
||||
projectedComponentSchema,
|
||||
projectedInventoryEntries,
|
||||
@@ -25,28 +23,22 @@ import {
|
||||
unavailableComponentSchema,
|
||||
} from "../mobileApplicationGraphProjection.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
|
||||
const evidenceIdSchema = projectionEvidenceIdSchema;
|
||||
const componentSchema = projectedComponentSchema;
|
||||
|
||||
/** Authenticated APK inventory pages projected as one Android application. */
|
||||
export const androidApplicationProjectionInputSchema =
|
||||
applicationInventoryProjectionInputSchema;
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
|
||||
/** Deterministic, execution-free Android application inventory projection. */
|
||||
export const androidApplicationProjectionResultSchema = z.strictObject({
|
||||
projection_id: prefixedDigestSchema("adp"),
|
||||
root_sha256: digestSchema,
|
||||
root_format: z.literal("apk"),
|
||||
source_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
source_evidence_ids: z.array(projectionEvidenceIdSchema).min(1),
|
||||
components: z.strictObject({
|
||||
manifests: z.array(componentSchema),
|
||||
resources: z.array(componentSchema),
|
||||
dex: z.array(componentSchema),
|
||||
jvm_classes: z.array(componentSchema),
|
||||
native_libraries: z.array(componentSchema),
|
||||
javascript: z.array(componentSchema),
|
||||
signing: z.array(componentSchema),
|
||||
manifests: z.array(projectedComponentSchema),
|
||||
resources: z.array(projectedComponentSchema),
|
||||
dex: z.array(projectedComponentSchema),
|
||||
jvm_classes: z.array(projectedComponentSchema),
|
||||
native_libraries: z.array(projectedComponentSchema),
|
||||
javascript: z.array(projectedComponentSchema),
|
||||
signing: z.array(projectedComponentSchema),
|
||||
}),
|
||||
unavailable_components: z.array(unavailableComponentSchema),
|
||||
runtime_families: z.array(
|
||||
@@ -78,37 +70,34 @@ export const androidApplicationProjectionResultSchema = z.strictObject({
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type AndroidApplicationProjectionInput = z.infer<
|
||||
typeof androidApplicationProjectionInputSchema
|
||||
>;
|
||||
export type AndroidApplicationProjectionResult = z.infer<
|
||||
typeof androidApplicationProjectionResultSchema
|
||||
>;
|
||||
type Component = z.infer<typeof componentSchema>;
|
||||
type Component = z.infer<typeof projectedComponentSchema>;
|
||||
|
||||
/** Project exact APK paths and hashes without decoding or executing target code. */
|
||||
export const projectAndroidApplication = (
|
||||
input: AndroidApplicationProjectionInput,
|
||||
): MobileApplicationProjectionWithSources<AndroidApplicationProjectionResult> => {
|
||||
const parsed = androidApplicationProjectionInputSchema.parse(input);
|
||||
const { evidence, inventory } = parseArtifactInventoryEvidence(
|
||||
parsed.inventory_evidence,
|
||||
);
|
||||
export const projectAndroidInventory = (
|
||||
source: ParsedInventorySet,
|
||||
): Result<AndroidApplicationProjectionResult, AnalysisInputError> => {
|
||||
const { evidence, inventory } = source;
|
||||
if (inventory.manifest.root_format !== "apk")
|
||||
throw new AnalysisInputError("project_android_application_graph", {}, [
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
0,
|
||||
"normalized_result",
|
||||
"manifest",
|
||||
"root_format",
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: `Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`,
|
||||
},
|
||||
]);
|
||||
const entries = projectedInventoryEntries(inventory);
|
||||
return err(
|
||||
new AnalysisInputError("project_android_application_graph", {}, [
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
0,
|
||||
"normalized_result",
|
||||
"manifest",
|
||||
"root_format",
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: `Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { entries, limitations: unresolved } =
|
||||
projectedInventoryEntries(inventory);
|
||||
const all = projectedFileComponents(entries);
|
||||
const unavailable = entries.flatMap(({ unavailable }) =>
|
||||
unavailable === undefined ? [] : [unavailable],
|
||||
@@ -120,6 +109,7 @@ export const projectAndroidApplication = (
|
||||
);
|
||||
const components = classified;
|
||||
const limitations = [
|
||||
...unresolved,
|
||||
...inventory.limitations,
|
||||
...(!hasCompleteInventoryCoverage(inventory, "container-entries")
|
||||
? ["Source inventory coverage is partial; absence is unknown."]
|
||||
@@ -163,13 +153,12 @@ export const projectAndroidApplication = (
|
||||
),
|
||||
limitations,
|
||||
};
|
||||
return {
|
||||
projection: androidApplicationProjectionResultSchema.parse({
|
||||
return ok(
|
||||
androidApplicationProjectionResultSchema.parse({
|
||||
...withoutId,
|
||||
projection_id: `adp_${digestCanonicalValue(withoutId, "Android application projection")}`,
|
||||
}),
|
||||
sources: evidence,
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
const classify = (all: readonly Component[]) => ({
|
||||
|
||||
@@ -4,7 +4,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ArtifactOccurrence } from "../artifactGraph.js";
|
||||
|
||||
import { projectAppleApplication } from "./appleApplication.js";
|
||||
import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js";
|
||||
import { projectAppleInventory } from "./appleApplication.js";
|
||||
import { AnalysisInputError } from "../analysisErrorCore.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
@@ -161,8 +162,13 @@ const inventoryEvidence = (
|
||||
);
|
||||
};
|
||||
|
||||
const project = (evidence: Evidence) =>
|
||||
projectAppleApplication({ inventory_evidence: [evidence] }).projection;
|
||||
const project = (evidence: Evidence) => {
|
||||
const result = projectAppleInventory(
|
||||
parseArtifactInventoryEvidence([evidence]),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
};
|
||||
|
||||
const macho = (path: string): FixtureEntry => ({ path, format: "mach-o" });
|
||||
|
||||
@@ -857,19 +863,17 @@ describe("Apple path conventions", () => {
|
||||
it.each(["file", "slice"] as const)(
|
||||
"retains non-encrypted unavailable %s facts without classifying content",
|
||||
(kind) => {
|
||||
const projection = projectAppleApplication({
|
||||
inventory_evidence: [
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: "Payload/Fixture.app/Info.plist", format: "plist" },
|
||||
{
|
||||
path: "Payload/Fixture.app/libentry.dylib",
|
||||
kind,
|
||||
format: "file",
|
||||
unavailable: true,
|
||||
},
|
||||
]),
|
||||
],
|
||||
}).projection;
|
||||
const projection = project(
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: "Payload/Fixture.app/Info.plist", format: "plist" },
|
||||
{
|
||||
path: "Payload/Fixture.app/libentry.dylib",
|
||||
kind,
|
||||
format: "file",
|
||||
unavailable: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(projection.unavailable_components).toEqual([
|
||||
{
|
||||
path: "Payload/Fixture.app/libentry.dylib",
|
||||
|
||||
@@ -17,9 +17,8 @@ import {
|
||||
} from "../nativeConvention.js";
|
||||
import {
|
||||
hasCompleteInventoryCoverage,
|
||||
parseArtifactInventoryEvidence,
|
||||
type ParsedInventorySet,
|
||||
} from "../artifactInventoryEvidence.js";
|
||||
import { evidenceSchema } from "../evidence.js";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { prefixedDigestSchema } from "../digests.js";
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
@@ -27,9 +26,10 @@ import {
|
||||
bridgeCandidateCoverageSchema,
|
||||
projectCartesianCandidates,
|
||||
} from "../bridgeCandidateProjection.js";
|
||||
import type { MobileApplicationProjectionWithSources } from "../mobileApplicationGraphProjection.js";
|
||||
import {
|
||||
projectedComponentSchema,
|
||||
projectionEvidenceIdSchema,
|
||||
projectionPathSchema,
|
||||
projectedInventoryEntries,
|
||||
projectedFileComponents,
|
||||
unavailableComponentSchema,
|
||||
@@ -37,25 +37,18 @@ import {
|
||||
} from "../mobileApplicationGraphProjection.js";
|
||||
import { uniqueSortedBy } from "../canonicalOrdering.js";
|
||||
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
|
||||
|
||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
const pathSchema = z.string().min(1);
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
|
||||
const APPLE_ROOT_FORMATS = ["ipa", "directory", "zip", "dmg"] as const;
|
||||
|
||||
/** Authenticated artifact inventory pages projected as one Apple application. */
|
||||
export const appleApplicationProjectionInputSchema = z.strictObject({
|
||||
inventory_evidence: z.array(evidenceSchema).min(1),
|
||||
});
|
||||
|
||||
/** Deterministic, execution-free Apple application inventory projection. */
|
||||
export const appleApplicationProjectionResultSchema = z.strictObject({
|
||||
projection_id: prefixedDigestSchema("aap"),
|
||||
root_sha256: digestSchema,
|
||||
root_format: z.enum(APPLE_ROOT_FORMATS),
|
||||
platforms: z.array(z.enum(["ios", "macos"])),
|
||||
source_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
application_roots: z.array(pathSchema),
|
||||
source_evidence_ids: z.array(projectionEvidenceIdSchema).min(1),
|
||||
application_roots: z.array(projectionPathSchema),
|
||||
bundles: z.array(appleBundleSchema),
|
||||
components: z.strictObject({
|
||||
bundle_metadata: z.array(projectedComponentSchema),
|
||||
@@ -71,7 +64,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({
|
||||
helpers: z.array(projectedComponentSchema),
|
||||
}),
|
||||
unavailable_components: z.array(unavailableComponentSchema),
|
||||
symlinks: z.array(pathSchema),
|
||||
symlinks: z.array(projectionPathSchema),
|
||||
runtime_families: z.array(
|
||||
z.enum([
|
||||
"native",
|
||||
@@ -84,8 +77,8 @@ export const appleApplicationProjectionResultSchema = z.strictObject({
|
||||
),
|
||||
bridge_candidates: z.array(
|
||||
z.strictObject({
|
||||
source_path: pathSchema,
|
||||
native_path: pathSchema,
|
||||
source_path: projectionPathSchema,
|
||||
native_path: projectionPathSchema,
|
||||
basis: z.enum([
|
||||
"javascript-and-native-content",
|
||||
"react-native-convention",
|
||||
@@ -103,9 +96,6 @@ export const appleApplicationProjectionResultSchema = z.strictObject({
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type AppleApplicationProjectionInput = z.infer<
|
||||
typeof appleApplicationProjectionInputSchema
|
||||
>;
|
||||
export type AppleApplicationProjectionResult = z.infer<
|
||||
typeof appleApplicationProjectionResultSchema
|
||||
>;
|
||||
@@ -113,16 +103,16 @@ export type AppleApplicationProjectionResult = z.infer<
|
||||
type Component = z.infer<typeof projectedComponentSchema>;
|
||||
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,
|
||||
): MobileApplicationProjectionWithSources<AppleApplicationProjectionResult> => {
|
||||
const parsed = appleApplicationProjectionInputSchema.parse(input);
|
||||
const { evidence, inventory } = parseArtifactInventoryEvidence(
|
||||
parsed.inventory_evidence,
|
||||
);
|
||||
const rootFormat = appleRootFormat(inventory.manifest.root_format);
|
||||
const entries = projectedInventoryEntries(inventory);
|
||||
/** Project an already authenticated inventory without reparsing its source. */
|
||||
export const projectAppleInventory = (
|
||||
source: ParsedInventorySet,
|
||||
): Result<AppleApplicationProjectionResult, AnalysisInputError> => {
|
||||
const { evidence, inventory } = source;
|
||||
const rootFormatResult = appleRootFormat(inventory.manifest.root_format);
|
||||
if (!rootFormatResult.ok) return rootFormatResult;
|
||||
const rootFormat = rootFormatResult.value;
|
||||
const { entries, limitations: unresolved } =
|
||||
projectedInventoryEntries(inventory);
|
||||
const inventoried = projectedFileComponents(entries);
|
||||
const { roots, bundles } = projectAppleBundleAnatomy(entries, {
|
||||
name: evidence[0]?.subject?.name ?? "",
|
||||
@@ -161,6 +151,7 @@ export const projectAppleApplication = (
|
||||
);
|
||||
const complete = inventoryComplete && !uninventoriedImage;
|
||||
const limitations = [
|
||||
...unresolved,
|
||||
...inventory.limitations,
|
||||
...projectionLimitations({
|
||||
complete: inventoryComplete,
|
||||
@@ -203,13 +194,12 @@ export const projectAppleApplication = (
|
||||
},
|
||||
limitations,
|
||||
};
|
||||
return {
|
||||
projection: appleApplicationProjectionResultSchema.parse({
|
||||
return ok(
|
||||
appleApplicationProjectionResultSchema.parse({
|
||||
...withoutId,
|
||||
projection_id: appleProjectionId(withoutId),
|
||||
}),
|
||||
sources: evidence,
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
/** Identify the complete projection, including source Evidence provenance. */
|
||||
@@ -235,23 +225,27 @@ const attributeComponents = <Entry extends { readonly path: string }>(
|
||||
return { all, unattributed: visible.length - all.length };
|
||||
};
|
||||
|
||||
const appleRootFormat = (format: string): RootFormat => {
|
||||
const appleRootFormat = (
|
||||
format: string,
|
||||
): Result<RootFormat, AnalysisInputError> => {
|
||||
const root = APPLE_ROOT_FORMATS.find((candidate) => candidate === format);
|
||||
if (root === undefined)
|
||||
throw new AnalysisInputError("project_apple_application_graph", {}, [
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
0,
|
||||
"normalized_result",
|
||||
"manifest",
|
||||
"root_format",
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: `Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got ${format})`,
|
||||
},
|
||||
]);
|
||||
return root;
|
||||
return err(
|
||||
new AnalysisInputError("project_apple_application_graph", {}, [
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
0,
|
||||
"normalized_result",
|
||||
"manifest",
|
||||
"root_format",
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: `Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got ${format})`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(root);
|
||||
};
|
||||
|
||||
const isNativeLibrary = (path: string): boolean =>
|
||||
|
||||
@@ -7,15 +7,13 @@ import { AnalysisInputError } from "../analysisErrorCore.js";
|
||||
|
||||
import {
|
||||
hasCompleteInventoryCoverage,
|
||||
parseArtifactInventoryEvidence,
|
||||
type ParsedInventorySet,
|
||||
} from "../artifactInventoryEvidence.js";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { prefixedDigestSchema } from "../digests.js";
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
import { nativeRuntimeConvention } from "../nativeConvention.js";
|
||||
import {
|
||||
applicationInventoryProjectionInputSchema,
|
||||
type MobileApplicationProjectionWithSources,
|
||||
projectedBridgeCandidateSchema,
|
||||
projectedComponentSchema,
|
||||
projectedInventoryEntries,
|
||||
@@ -25,34 +23,28 @@ import {
|
||||
unavailableComponentSchema,
|
||||
} from "../mobileApplicationGraphProjection.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
|
||||
const evidenceIdSchema = projectionEvidenceIdSchema;
|
||||
const componentSchema = projectedComponentSchema;
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
|
||||
const HARMONY_ROOT_FORMATS = ["hap", "hsp", "app-pack"] as const;
|
||||
type RootFormat = (typeof HARMONY_ROOT_FORMATS)[number];
|
||||
|
||||
/** Authenticated HarmonyOS inventory pages projected as one application. */
|
||||
export const harmonyApplicationProjectionInputSchema =
|
||||
applicationInventoryProjectionInputSchema;
|
||||
|
||||
/** Deterministic, execution-free HarmonyOS application inventory projection. */
|
||||
export const harmonyApplicationProjectionResultSchema = z.strictObject({
|
||||
projection_id: prefixedDigestSchema("hmp"),
|
||||
root_sha256: digestSchema,
|
||||
root_format: z.enum(HARMONY_ROOT_FORMATS),
|
||||
source_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
source_evidence_ids: z.array(projectionEvidenceIdSchema).min(1),
|
||||
packaging_model: z.enum(["stage", "fa", "unknown"]),
|
||||
components: z.strictObject({
|
||||
manifests: z.array(componentSchema),
|
||||
bytecode: z.array(componentSchema),
|
||||
resources: z.array(componentSchema),
|
||||
native_libraries: z.array(componentSchema),
|
||||
javascript: z.array(componentSchema),
|
||||
signing: z.array(componentSchema),
|
||||
manifests: z.array(projectedComponentSchema),
|
||||
bytecode: z.array(projectedComponentSchema),
|
||||
resources: z.array(projectedComponentSchema),
|
||||
native_libraries: z.array(projectedComponentSchema),
|
||||
javascript: z.array(projectedComponentSchema),
|
||||
signing: z.array(projectedComponentSchema),
|
||||
}),
|
||||
unavailable_components: z.array(unavailableComponentSchema),
|
||||
app_pack_children: z.array(componentSchema),
|
||||
app_pack_children: z.array(projectedComponentSchema),
|
||||
runtime_families: z.array(
|
||||
z.enum(["ark", "native", "javascript", "react-native", "flutter", "unity"]),
|
||||
),
|
||||
@@ -71,24 +63,21 @@ export const harmonyApplicationProjectionResultSchema = z.strictObject({
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type HarmonyApplicationProjectionInput = z.infer<
|
||||
typeof harmonyApplicationProjectionInputSchema
|
||||
>;
|
||||
export type HarmonyApplicationProjectionResult = z.infer<
|
||||
typeof harmonyApplicationProjectionResultSchema
|
||||
>;
|
||||
type Component = z.infer<typeof componentSchema>;
|
||||
type Component = z.infer<typeof projectedComponentSchema>;
|
||||
|
||||
/** Project exact HarmonyOS package paths and hashes without decoding or executing target code. */
|
||||
export const projectHarmonyApplication = (
|
||||
input: HarmonyApplicationProjectionInput,
|
||||
): MobileApplicationProjectionWithSources<HarmonyApplicationProjectionResult> => {
|
||||
const parsed = harmonyApplicationProjectionInputSchema.parse(input);
|
||||
const { evidence, inventory } = parseArtifactInventoryEvidence(
|
||||
parsed.inventory_evidence,
|
||||
);
|
||||
const rootFormat = harmonyRootFormat(inventory.manifest.root_format);
|
||||
const entries = projectedInventoryEntries(inventory);
|
||||
export const projectHarmonyInventory = (
|
||||
source: ParsedInventorySet,
|
||||
): Result<HarmonyApplicationProjectionResult, AnalysisInputError> => {
|
||||
const { evidence, inventory } = source;
|
||||
const rootFormatResult = harmonyRootFormat(inventory.manifest.root_format);
|
||||
if (!rootFormatResult.ok) return rootFormatResult;
|
||||
const rootFormat = rootFormatResult.value;
|
||||
const { entries, limitations: unresolved } =
|
||||
projectedInventoryEntries(inventory);
|
||||
const all = projectedFileComponents(entries);
|
||||
const unavailable = entries.flatMap(({ unavailable }) =>
|
||||
unavailable === undefined ? [] : [unavailable],
|
||||
@@ -99,6 +88,7 @@ export const projectHarmonyApplication = (
|
||||
classified.native_libraries,
|
||||
);
|
||||
const limitations = [
|
||||
...unresolved,
|
||||
...inventory.limitations,
|
||||
...(!hasCompleteInventoryCoverage(inventory, "container-entries")
|
||||
? ["Source inventory coverage is partial; absence is unknown."]
|
||||
@@ -149,32 +139,35 @@ export const projectHarmonyApplication = (
|
||||
),
|
||||
limitations,
|
||||
};
|
||||
return {
|
||||
projection: harmonyApplicationProjectionResultSchema.parse({
|
||||
return ok(
|
||||
harmonyApplicationProjectionResultSchema.parse({
|
||||
...withoutId,
|
||||
projection_id: `hmp_${digestCanonicalValue(withoutId, "HarmonyOS application projection")}`,
|
||||
}),
|
||||
sources: evidence,
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
const harmonyRootFormat = (format: string): RootFormat => {
|
||||
const harmonyRootFormat = (
|
||||
format: string,
|
||||
): Result<RootFormat, AnalysisInputError> => {
|
||||
const root = HARMONY_ROOT_FORMATS.find((candidate) => candidate === format);
|
||||
if (root === undefined)
|
||||
throw new AnalysisInputError("project_harmony_application_graph", {}, [
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
0,
|
||||
"normalized_result",
|
||||
"manifest",
|
||||
"root_format",
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: `HarmonyOS application projection requires HAP, HSP, or App Pack inventory Evidence (got ${format})`,
|
||||
},
|
||||
]);
|
||||
return root;
|
||||
return err(
|
||||
new AnalysisInputError("project_harmony_application_graph", {}, [
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
0,
|
||||
"normalized_result",
|
||||
"manifest",
|
||||
"root_format",
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: `HarmonyOS application projection requires HAP, HSP, or App Pack inventory Evidence (got ${format})`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(root);
|
||||
};
|
||||
|
||||
const packagingModel = (
|
||||
|
||||
@@ -2,9 +2,15 @@ import { z } from "zod";
|
||||
|
||||
import {
|
||||
hasCompleteInventoryCoverage,
|
||||
parseArtifactInventoryEvidence,
|
||||
ArtifactInventoryCarrierError,
|
||||
type ParsedInventorySet,
|
||||
} from "./artifactInventoryEvidence.js";
|
||||
import { evidenceSchema, type Evidence } from "./evidence.js";
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
import { AnalysisInputError } from "./analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "./evidenceErrors.js";
|
||||
import { analysisInputErrorFromIssues } from "./inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import { digestSchema } from "./digests.js";
|
||||
import { prefixedDigestSchema } from "./digests.js";
|
||||
|
||||
@@ -17,6 +23,43 @@ export const applicationInventoryProjectionInputSchema = z.strictObject({
|
||||
inventory_evidence: z.array(evidenceSchema).min(1),
|
||||
});
|
||||
|
||||
const applicationInventoryRequestSchema =
|
||||
applicationInventoryProjectionInputSchema.extend({
|
||||
inventory_evidence: z.array(z.unknown()).min(1),
|
||||
});
|
||||
|
||||
/** Resolve the request and authenticate inventory pages once for all projections. */
|
||||
export const readApplicationInventoryProjection = (
|
||||
input: unknown,
|
||||
operation: string,
|
||||
): Result<ParsedInventorySet, AnalysisInputError | EvidenceIntegrityError> => {
|
||||
const frame = applicationInventoryRequestSchema.safeParse(input);
|
||||
if (!frame.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(operation, frame.error.issues, input, {
|
||||
cause: frame.error,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return ok(parseArtifactInventoryEvidence(frame.data.inventory_evidence));
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof EvidenceIntegrityError) return err(cause);
|
||||
if (!(cause instanceof ArtifactInventoryCarrierError)) throw cause;
|
||||
return err(
|
||||
new AnalysisInputError(operation, { cause, userMessage: cause.message }, [
|
||||
{
|
||||
path: [
|
||||
"inventory_evidence",
|
||||
...(cause.index === undefined ? [] : [cause.index, "operation"]),
|
||||
],
|
||||
reason: "invalid_value",
|
||||
message: cause.message,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** One authenticated inventory entry retained by application graph projections. */
|
||||
export const projectedComponentSchema = z.strictObject({
|
||||
path: projectionPathSchema,
|
||||
@@ -37,12 +80,6 @@ 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[]],
|
||||
@@ -72,7 +109,7 @@ export const projectionCoverage = (
|
||||
inventory_complete: inventory.complete,
|
||||
});
|
||||
|
||||
/** One occurrence, preserving entry kind and either authenticated or unavailable content. */
|
||||
/** One occurrence with resolved content, unavailable identity, or an unresolved node reference. */
|
||||
export type ProjectedInventoryEntry = {
|
||||
readonly path: string;
|
||||
readonly kind: ParsedInventorySet["inventory"]["occurrences"][number]["entry_kind"];
|
||||
@@ -82,16 +119,21 @@ export type ProjectedInventoryEntry = {
|
||||
readonly component: undefined;
|
||||
readonly unavailable: z.infer<typeof unavailableComponentSchema>;
|
||||
}
|
||||
| { readonly component: undefined; readonly unavailable: undefined }
|
||||
);
|
||||
|
||||
/** Interpret identity once, before application-specific attribution and classification. */
|
||||
/** Resolve available identities and report references outside the supplied pages. */
|
||||
export const projectedInventoryEntries = (
|
||||
inventory: ParsedInventorySet["inventory"],
|
||||
): ProjectedInventoryEntry[] => {
|
||||
): {
|
||||
readonly entries: ProjectedInventoryEntry[];
|
||||
readonly limitations: string[];
|
||||
} => {
|
||||
const nodes = new Map(
|
||||
inventory.nodes.map((node) => [node.artifact_id, node]),
|
||||
);
|
||||
return inventory.occurrences
|
||||
const limitations: string[] = [];
|
||||
const entries = inventory.occurrences
|
||||
.filter(({ logical_path }) => logical_path !== ".")
|
||||
.map((occurrence): ProjectedInventoryEntry => {
|
||||
const path = occurrence.logical_path;
|
||||
@@ -111,10 +153,12 @@ export const projectedInventoryEntries = (
|
||||
},
|
||||
};
|
||||
const node = nodes.get(occurrence.artifact_id);
|
||||
if (node === undefined)
|
||||
throw new TypeError(
|
||||
"Application projection occurrence has no artifact node",
|
||||
if (node === undefined) {
|
||||
limitations.push(
|
||||
`Inventory occurrence ${JSON.stringify(path)} references ${occurrence.artifact_id} (${occurrence.artifact_format}), whose node is outside the supplied pages. Its content SHA-256 and component classification remain unknown; the source Evidence retains the occurrence.`,
|
||||
);
|
||||
return { path, kind, component: undefined, unavailable: undefined };
|
||||
}
|
||||
return {
|
||||
path,
|
||||
kind,
|
||||
@@ -127,6 +171,7 @@ export const projectedInventoryEntries = (
|
||||
unavailable: undefined,
|
||||
};
|
||||
});
|
||||
return { entries, limitations };
|
||||
};
|
||||
|
||||
/** Directory trees and links retain inventory identity but are not file components. */
|
||||
|
||||
@@ -14,7 +14,7 @@ import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for([
|
||||
const graphExamples = [
|
||||
{
|
||||
name: "Android",
|
||||
command: "project-android-application-graph",
|
||||
@@ -39,7 +39,9 @@ cliTest.for([
|
||||
operation: "project_harmony_application_graph",
|
||||
example: HARMONY_APPLICATION_GRAPH_EXAMPLE,
|
||||
},
|
||||
] as const)(
|
||||
] as const;
|
||||
|
||||
cliTest.for(graphExamples)(
|
||||
"CLI executes the canonical $name graph example without target files",
|
||||
async ({ command, operation, example }, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-mobile-graph-example-");
|
||||
@@ -61,6 +63,34 @@ cliTest.for([
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for(graphExamples)(
|
||||
"CLI classifies malformed $name source Evidence separately from request errors",
|
||||
async ({ command, example }, { cli }) => {
|
||||
const source = example.inventory_evidence[0]!;
|
||||
for (const { input, code } of [
|
||||
{
|
||||
input: {
|
||||
inventory_evidence: [{ ...source, evidence_id: "not-a-digest" }],
|
||||
},
|
||||
code: "evidence_integrity_mismatch",
|
||||
},
|
||||
{
|
||||
input: { inventory_evidence: [source], unexpected: true },
|
||||
code: "invalid_request",
|
||||
},
|
||||
{ input: { inventory_evidence: [] }, code: "invalid_request" },
|
||||
]) {
|
||||
const result = await cli.run({
|
||||
arguments: [command, JSON.stringify(input), "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"CLI still rejects an unsupported inventory coverage status",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
|
||||
import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
@@ -115,7 +118,9 @@ it("advertises and executes Android and Apple inventory projections", async () =
|
||||
name: "project_android_application_graph",
|
||||
arguments: { inventory_evidence: [tamperedSource] },
|
||||
});
|
||||
expect(rejected.isError).toBe(true);
|
||||
expect(parseMcpToolError(rejected)).toMatchObject({
|
||||
error: { code: "evidence_integrity_mismatch" },
|
||||
});
|
||||
const bundleAfterRejectedSource = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
@@ -143,6 +148,160 @@ it("advertises and executes Android and Apple inventory projections", async () =
|
||||
});
|
||||
});
|
||||
|
||||
it("projects a single inventory page as partial and links only that source", async () => {
|
||||
const client = await connect();
|
||||
const root = await createTestTempDirectory("rea-mobile-partial-page-mcp-");
|
||||
const inventory = parseEvidence(
|
||||
await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}),
|
||||
);
|
||||
const facts = artifactInventoryResultSchema.parse(
|
||||
inventory.normalized_result,
|
||||
);
|
||||
const subject = inventory.subject;
|
||||
if (subject === null) throw new Error("Expected an inventory subject");
|
||||
const page = createEvidence(
|
||||
{
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
},
|
||||
inventory.provider,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: inventory.parameters,
|
||||
result: { ...facts, nodes: [] },
|
||||
confidence: "derived",
|
||||
authority: inventory.authority,
|
||||
},
|
||||
);
|
||||
|
||||
const reply = await client.callTool({
|
||||
name: "project_android_application_graph",
|
||||
arguments: { inventory_evidence: [page] },
|
||||
});
|
||||
if (reply.isError === true) throw new Error(JSON.stringify(reply, null, 2));
|
||||
const projection = parseEvidence(reply.structuredContent);
|
||||
const result = androidApplicationProjectionResultSchema.parse(
|
||||
projection.normalized_result,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
source_evidence_ids: [page.evidence_id],
|
||||
coverage: { status: "partial", inventory_complete: false },
|
||||
});
|
||||
expect(result.limitations).toContain(
|
||||
"Source inventory coverage is partial; absence is unknown.",
|
||||
);
|
||||
expect(result.components.dex).toEqual([]);
|
||||
expect(result.unavailable_components).toEqual([]);
|
||||
const dex = facts.occurrences.find(
|
||||
({ logical_path }) => logical_path === "classes.dex",
|
||||
);
|
||||
if (dex?.artifact_id === null || dex === undefined)
|
||||
throw new Error("Expected a known DEX artifact reference");
|
||||
const dexArtifactId = dex.artifact_id;
|
||||
const unresolved = result.limitations.find((limitation) =>
|
||||
limitation.includes(dexArtifactId),
|
||||
);
|
||||
expect(unresolved).toContain('"classes.dex"');
|
||||
expect(unresolved).toContain(dex.artifact_format);
|
||||
expect(unresolved).toContain("unknown");
|
||||
expect(projection.evidence_links).toEqual([page.evidence_id]);
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
expect(bundle.structuredContent).toMatchObject({
|
||||
result: { records: expect.arrayContaining([page, projection]) },
|
||||
});
|
||||
const completed = await client.callTool({
|
||||
name: "project_android_application_graph",
|
||||
arguments: { inventory_evidence: [page, inventory] },
|
||||
});
|
||||
if (completed.isError === true) throw new Error(JSON.stringify(completed));
|
||||
const resolved = androidApplicationProjectionResultSchema.parse(
|
||||
parseEvidence(completed.structuredContent).normalized_result,
|
||||
);
|
||||
expect(resolved.coverage.inventory_complete).toBe(true);
|
||||
expect(resolved.components.dex).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "classes.dex",
|
||||
artifact_id: dex.artifact_id,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
resolved.limitations.some((limitation) =>
|
||||
limitation.includes(dexArtifactId),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a valid non-inventory carrier as caller-selected input", async () => {
|
||||
const client = await connect();
|
||||
const wrongCarrier = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "analyze_binary", parameters: {}, result: {} },
|
||||
);
|
||||
|
||||
const rejected = await client.callTool({
|
||||
name: "project_android_application_graph",
|
||||
arguments: { inventory_evidence: [wrongCarrier] },
|
||||
});
|
||||
expect(parseMcpToolError(rejected)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["inventory_evidence", 0, "operation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
expect(bundle.structuredContent).toMatchObject({ result: { records: [] } });
|
||||
});
|
||||
|
||||
it("rejects authentic pages from different roots without retaining either", async () => {
|
||||
const client = await connect();
|
||||
const root = await createTestTempDirectory("rea-mobile-mixed-pages-mcp-");
|
||||
const apk = parseEvidence(
|
||||
await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}),
|
||||
);
|
||||
const ipa = parseEvidence(
|
||||
await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}),
|
||||
);
|
||||
|
||||
const rejected = await client.callTool({
|
||||
name: "project_android_application_graph",
|
||||
arguments: {
|
||||
inventory_evidence: [
|
||||
fragmentInventoryEvidence(apk, 2)[0],
|
||||
fragmentInventoryEvidence(ipa, 2)[0],
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(rejected)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
details: {
|
||||
issues: [{ path: ["inventory_evidence"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
expect(bundle.structuredContent).toMatchObject({ result: { records: [] } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
family: "Harmony",
|
||||
|
||||
Reference in New Issue
Block a user