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
175 lines
5.7 KiB
TypeScript
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]) },
|
|
});
|
|
},
|
|
);
|