Files
rea/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts
T
morluto c192c58815 fix(apple): retain unavailable application components
Preserve unread file and slice facts before Apple attribution. Report partial
component coverage while keeping manifest count completeness independent.
Directories and symlinks retain their existing representation.

Consolidate occurrence identity interpretation and component schemas across
Apple, Android and Harmony; remove superseded producers. Android/Harmony
completed source replies and projection IDs remain byte-identical in controls.
Apple's additive unavailable_components field changes its projection IDs.

Adapt the unavailable-component proposal and real ZIP regressions from
PR #4692 by @rodricksz4h5, adding directory, CLI, MCP and non-encrypted controls.

Validation: 77 focused checks; check:fast; docs:check (5 README checks).
18 tiny completed source cost controls; no real-provider/target execution
or heavy profiling. Published npm package and large-input costs unverified.

Fixes #4688
2026-10-17 19:46:20 +08:00

175 lines
5.7 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { afterEach, expect, it } from "vitest";
import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createServer } from "../../../src/server/createServer.js";
const { runProviderAnalysis } = createDirectAnalysis({});
const resources: Array<{ close(): Promise<unknown> }> = [];
afterEach(async () => {
for (const resource of resources.splice(0)) await resource.close();
});
async function connect() {
const session = createTestBinarySession(() => {
throw new Error("mobile graph projection must not launch a provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({
name: "mobile-application-graph-test",
version: "1",
});
resources.push(client, server, session);
const [a, b] = InMemoryTransport.createLinkedPair();
await server.connect(b);
await client.connect(a);
return client;
}
const writeApk = async (directory: string): Promise<string> => {
const path = join(directory, "Fixture.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"AndroidManifest.xml",
new Uint8ArrayReader(Uint8Array.from([3, 0, 8, 0])),
);
await writer.add(
"classes.dex",
new Uint8ArrayReader(
Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]),
),
);
await writeFile(path, await writer.close());
return path;
};
const writeIpa = async (directory: string): Promise<string> => {
const path = join(directory, "Fixture.ipa");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"Payload/Fixture.app/Info.plist",
new TextReader("<plist></plist>"),
);
await writer.add(
"Payload/Fixture.app/Fixture",
new Uint8ArrayReader(
Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]),
),
);
await writeFile(path, await writer.close());
return path;
};
it("advertises and executes Android and Apple inventory projections", async () => {
const client = await connect();
const { tools } = await client.listTools();
const names = new Set(tools.map(({ name }) => name));
expect(names.has("project_android_application_graph")).toBe(true);
expect(names.has("project_apple_application_graph")).toBe(true);
const root = await createTestTempDirectory("rea-mobile-graph-mcp-");
const apkInventory = parseEvidence(
await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}),
);
const android = await client.callTool({
name: "project_android_application_graph",
arguments: { inventory_evidence: [apkInventory] },
});
if (android.isError === true)
throw new Error(JSON.stringify(android, null, 2));
expect(android.structuredContent).toMatchObject({
operation: "project_android_application_graph",
provider: { id: "rea-android-application" },
});
const ipaInventory = parseEvidence(
await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}),
);
const apple = await client.callTool({
name: "project_apple_application_graph",
arguments: { inventory_evidence: [ipaInventory] },
});
if (apple.isError === true) throw new Error(JSON.stringify(apple, null, 2));
expect(apple.structuredContent).toMatchObject({
operation: "project_apple_application_graph",
provider: { id: "rea-apple-application" },
});
});
it.each([
{
family: "Harmony",
name: "Locked.hap",
manifest: "module.json",
unread: "ets/modules.abc",
operation: "project_harmony_application_graph",
},
{
family: "Android",
name: "Locked.apk",
manifest: "AndroidManifest.xml",
unread: "classes.dex",
operation: "project_android_application_graph",
},
{
family: "Apple",
name: "Locked.ipa",
manifest: "Payload/Locked.app/Info.plist",
unread: "Payload/Locked.app/libentry.dylib",
operation: "project_apple_application_graph",
},
])(
"retains $family unavailable components and their source Evidence through MCP",
async ({ name, manifest, unread, operation }) => {
const client = await connect();
const root = await createTestTempDirectory("rea-harmony-graph-mcp-");
const path = join(root, name);
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(manifest, new TextReader("{}"));
await writer.add(unread, new TextReader("opaque bytecode"), {
password: "fixture-only",
});
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
const reply = await client.callTool({
name: operation,
arguments: { inventory_evidence: [inventory] },
});
if (reply.isError === true) throw new Error(JSON.stringify(reply));
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.normalized_result).toMatchObject({
unavailable_components: [
expect.objectContaining({
path: unread,
artifact_id: null,
sha256: null,
encrypted: true,
}),
],
coverage: { status: "partial", inventory_complete: true },
});
const bundle = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
expect(bundle.structuredContent).toMatchObject({
result: { records: expect.arrayContaining([inventory, evidence]) },
});
},
);