248 lines
8.6 KiB
TypeScript
248 lines
8.6 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { expect } from "vitest";
|
|
import { toolContract } from "../../src/contracts/toolContracts.js";
|
|
import { parseEvidence } from "../../src/domain/evidence.js";
|
|
import { flutterResultSchemas } from "../../src/domain/flutter/flutterBuildAnalysis.js";
|
|
import {
|
|
aotFixtureImage,
|
|
engineFixtureImage,
|
|
REFERENCE_HASH,
|
|
appendElfRanges,
|
|
} from "../../src/flutter/FlutterBuild.fixture.js";
|
|
import {
|
|
createFlutterApk,
|
|
createDuplicateFlutterApk,
|
|
} from "../fixtures/flutterApk.js";
|
|
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
|
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
|
import { cliTest } from "../support/cli/cliFixture.js";
|
|
|
|
cliTest(
|
|
"CLI and stdio MCP retain Flutter identity, selected ABI and duplicate-member refusals",
|
|
async ({ cli }) => {
|
|
const image = aotFixtureImage();
|
|
Buffer.from("Login\0").copy(image, 0x2300);
|
|
const engine = engineFixtureImage({
|
|
notes: Buffer.from(
|
|
"040000000800000003000000474e55000123456789abcdef",
|
|
"hex",
|
|
),
|
|
prefix: Buffer.from(
|
|
"040000000800000003000000474e55001111111111111111",
|
|
"hex",
|
|
),
|
|
labels: ["Android (test) clang version 9.0.8"],
|
|
});
|
|
const path = await createFlutterApk("selected target.apk", [
|
|
{ path: "lib/arm64-v8a/libapp.so", bytes: image },
|
|
{
|
|
path: "lib/arm64-v8a/libflutter.so",
|
|
bytes: engine,
|
|
},
|
|
{ path: "lib/x86_64/libapp.so", bytes: image },
|
|
{
|
|
path: "lib/x86_64/libflutter.so",
|
|
bytes: engine,
|
|
},
|
|
]);
|
|
const { call } = await connectLocalToolsMcp();
|
|
for (const [name, abi] of [
|
|
["identify_flutter_build", undefined],
|
|
["inspect_dart_aot", undefined],
|
|
["inspect_dart_aot", "x86_64"],
|
|
] as const) {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
name.replaceAll("_", "-"),
|
|
path,
|
|
"--json",
|
|
...(abi === undefined ? [] : ["--abi", abi]),
|
|
],
|
|
});
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
const evidence = toolContract(name).outputSchema.parse(result.json);
|
|
const complete = flutterResultSchemas[name].parse(
|
|
evidence.normalized_result,
|
|
);
|
|
const response = await call(name, {
|
|
path,
|
|
...(abi === undefined ? {} : { abi }),
|
|
});
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
const mcp = parseEvidence(response.structuredContent);
|
|
expect(mcp.normalized_result).toEqual(evidence.normalized_result);
|
|
expect(mcp.subject).toEqual(evidence.subject);
|
|
expect(mcp.subject).toMatchObject({
|
|
local_path: path,
|
|
format: "apk",
|
|
digest: { sha256: complete.target.sha256 },
|
|
});
|
|
expect(mcp.evidence_id).toBe(evidence.evidence_id);
|
|
if (name === "inspect_dart_aot") {
|
|
expect(evidence.normalized_result).toMatchObject({
|
|
abi: abi ?? "arm64-v8a",
|
|
libapp: {
|
|
coverage: "complete",
|
|
snapshot_hash: REFERENCE_HASH,
|
|
sha256: createHash("sha256").update(image).digest("hex"),
|
|
},
|
|
string_pool: {
|
|
coverage: "complete",
|
|
class_like_tokens: expect.arrayContaining(["Login"]),
|
|
},
|
|
});
|
|
} else {
|
|
const identified = toolContract(
|
|
"identify_flutter_build",
|
|
).outputSchema.parse(result.json).normalized_result;
|
|
expect(identified.coverage).toBe("complete");
|
|
for (const abi of identified.abis) {
|
|
expect(abi.libflutter).toMatchObject({
|
|
build_id: "0123456789abcdef",
|
|
build_id_candidates: ["0123456789abcdef"],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const duplicate = await createDuplicateFlutterApk();
|
|
for (const name of [
|
|
"identify_flutter_build",
|
|
"inspect_dart_aot",
|
|
] as const) {
|
|
const result = await cli.run({
|
|
arguments: [name.replaceAll("_", "-"), duplicate, "--json"],
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
const mcp = parseMcpToolError(await call(name, { path: duplicate }));
|
|
expect(mcp.error).toEqual(result.json);
|
|
expect(mcp.error).toMatchObject({
|
|
code: "invalid_request",
|
|
details: { issues: [{ message: expect.stringContaining(duplicate) }] },
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"CLI and MCP retain unsupported snapshot kinds as partial evidence",
|
|
async ({ cli }) => {
|
|
const image = aotFixtureImage();
|
|
for (const offset of [0x1000, 0x2000])
|
|
image.writeBigUInt64LE(4n, offset + 12);
|
|
const path = await createFlutterApk("unsupported-kind.apk", [
|
|
{ path: "lib/arm64-v8a/libapp.so", bytes: image },
|
|
]);
|
|
const result = await cli.run({
|
|
arguments: ["inspect-dart-aot", path, "--json"],
|
|
});
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
const evidence = toolContract("inspect_dart_aot").outputSchema.parse(
|
|
result.json,
|
|
);
|
|
const complete = flutterResultSchemas.inspect_dart_aot.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
const { call } = await connectLocalToolsMcp();
|
|
const response = await call("inspect_dart_aot", { path });
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
expect(parseEvidence(response.structuredContent).normalized_result).toEqual(
|
|
evidence.normalized_result,
|
|
);
|
|
expect(evidence.normalized_result).toMatchObject({
|
|
coverage: "partial",
|
|
libapp: { coverage: "partial", snapshot_hash: REFERENCE_HASH },
|
|
string_pool: { coverage: "complete" },
|
|
});
|
|
expect(
|
|
complete.libapp.sections
|
|
.filter((section) => section.name.endsWith("Data"))
|
|
.map((section) => section.kind),
|
|
).toEqual([4, 4]);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"CLI and MCP qualify conflicting load mappings and bounded ELF notes",
|
|
async ({ cli }) => {
|
|
const note = Buffer.from(
|
|
"040000000800000003000000474e55000123456789abcdef",
|
|
"hex",
|
|
);
|
|
const engine = engineFixtureImage({
|
|
notes: Buffer.concat([note, Buffer.alloc(2000)]),
|
|
});
|
|
const denseEngine = appendElfRanges(
|
|
engine,
|
|
"program",
|
|
Array.from({ length: 2000 }, (_, index) => ({
|
|
type: 4,
|
|
offset: 0x400 + note.length + index,
|
|
size: 1,
|
|
})),
|
|
);
|
|
const { call } = await connectLocalToolsMcp();
|
|
for (const conflict of [false, true]) {
|
|
const image = appendElfRanges(aotFixtureImage(), "program", [
|
|
{
|
|
type: 1,
|
|
address: 0x2010,
|
|
offset: conflict ? 0x1010 : 0x2010,
|
|
size: 4,
|
|
},
|
|
]);
|
|
const path = await createFlutterApk(`load-${String(conflict)}.apk`, [
|
|
{ path: "lib/x86_64/libapp.so", bytes: image },
|
|
{
|
|
path: "lib/x86_64/libflutter.so",
|
|
bytes: conflict ? denseEngine : engine,
|
|
},
|
|
]);
|
|
for (const name of [
|
|
"inspect_dart_aot",
|
|
"identify_flutter_build",
|
|
] as const) {
|
|
const result = await cli.run({
|
|
arguments: [name.replaceAll("_", "-"), path, "--json"],
|
|
});
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
const evidence = toolContract(name).outputSchema.parse(result.json);
|
|
const response = await call(name, { path });
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
const mcp = parseEvidence(response.structuredContent);
|
|
expect(mcp.normalized_result).toEqual(evidence.normalized_result);
|
|
expect(mcp.evidence_id).toBe(evidence.evidence_id);
|
|
expect(mcp.subject).toEqual(evidence.subject);
|
|
expect(mcp.limitations).toEqual(evidence.limitations);
|
|
expect(evidence.normalized_result.coverage).toBe(
|
|
conflict ? "partial" : "complete",
|
|
);
|
|
if (name === "inspect_dart_aot") {
|
|
const facts = flutterResultSchemas[name].parse(
|
|
evidence.normalized_result,
|
|
);
|
|
expect(facts.libapp.sections).toHaveLength(conflict ? 3 : 4);
|
|
expect(facts.string_pool.coverage).toBe(
|
|
conflict ? "partial" : "complete",
|
|
);
|
|
if (conflict) {
|
|
expect(facts.string_pool.package_uris).toEqual([]);
|
|
expect(evidence.limitations.join("\n")).toContain(
|
|
"conflicting PT_LOAD",
|
|
);
|
|
}
|
|
} else {
|
|
const facts = flutterResultSchemas[name].parse(
|
|
evidence.normalized_result,
|
|
);
|
|
expect(facts.abis[0]?.libflutter).toMatchObject({
|
|
build_id: conflict ? null : "0123456789abcdef",
|
|
build_id_candidates: ["0123456789abcdef"],
|
|
});
|
|
if (conflict)
|
|
expect(evidence.limitations.join("\n")).toContain("note-range");
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|