diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index df68ee9ccc..dab4efad9e 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -23,7 +23,9 @@ rea inspect-dart-aot /targets/Example.apk --abi armeabi-v7a The equivalent MCP methods are `identify_flutter_build` (with `path`) and `inspect_dart_aot` (with `path` and an optional `abi`; the default prefers arm64-v8a, then x86_64, then x86, then armeabi-v7a, then the remaining -ABIs alphabetically). +ABIs alphabetically). An explicit `abi` is the directory label exactly as +`identify_flutter_build` reports it, so a payload under a nonstandard label +stays selectable and keeps that label in the result. ## Optional retained AOT delivery diff --git a/src/application/ProviderOperationExecution.test.ts b/src/application/ProviderOperationExecution.test.ts new file mode 100644 index 0000000000..7729cb42b7 --- /dev/null +++ b/src/application/ProviderOperationExecution.test.ts @@ -0,0 +1,89 @@ +import { expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { executeProviderOperation } from "./ProviderOperationExecution.js"; + +it("projects request-schema issues without exposing the request envelope", async () => { + const execute = vi.fn(); + const result = await executeProviderOperation( + "list_jeb_units", + { parent_unit_path: "" }, + undefined, + z.strictObject({ + operation: z.literal("list_jeb_units"), + input: z.strictObject({ parent_unit_path: z.string().min(1) }), + }), + { execute }, + "observed", + ); + + expect(result.ok).toBe(false); + if (result.ok || !(result.error instanceof AnalysisInputError)) + throw new Error("Expected input error"); + expect(result.error.issues).toEqual([ + { + path: ["parent_unit_path"], + reason: "out_of_range", + minimum: 1, + }, + ]); + expect(result.error.cause).toBeInstanceOf(z.ZodError); + expect(execute).not.toHaveBeenCalled(); +}); + +it("removes only the owned envelope prefix while preserving nested caller paths", async () => { + const execute = vi.fn(); + const result = await executeProviderOperation( + "inspect", + { input: "", rows: [{}], mode: "foreign", extra: "caller-only-marker" }, + undefined, + z.strictObject({ + operation: z.literal("inspect"), + input: z.strictObject({ + input: z.string().min(1), + rows: z.array(z.strictObject({ name: z.string() })), + mode: z.enum(["first", "second"]), + }), + }), + { execute }, + "observed", + ); + if (result.ok || !(result.error instanceof AnalysisInputError)) + throw new Error("Expected input error"); + expect(result.error.issues).toEqual([ + { path: ["input"], reason: "out_of_range", minimum: 1 }, + { + path: ["rows", 0, "name"], + reason: "missing_argument", + expected: "string", + }, + { path: ["mode"], reason: "invalid_value", expected: ["first", "second"] }, + { path: ["extra"], reason: "unknown_argument" }, + ]); + expect(JSON.stringify(result.error.issues)).not.toContain( + "caller-only-marker", + ); + expect(execute).not.toHaveBeenCalled(); +}); + +it("preserves operation correction outside the input envelope", async () => { + const execute = vi.fn(); + const result = await executeProviderOperation( + "unknown", + {}, + undefined, + z.strictObject({ + operation: z.literal("known"), + input: z.strictObject({}), + }), + { execute }, + "observed", + ); + if (result.ok || !(result.error instanceof AnalysisInputError)) + throw new Error("Expected input error"); + expect(result.error.issues).toEqual([ + { path: ["operation"], reason: "invalid_value", expected: ["known"] }, + ]); + expect(execute).not.toHaveBeenCalled(); +}); diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index 3cb46ee354..7a48e9ae88 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -8,6 +8,7 @@ import { AnalysisInputError, } from "../domain/analysisErrorCore.js"; import type { Evidence } from "../domain/evidence.js"; +import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../domain/result.js"; import { createAnalysisExecutionEvidence, @@ -52,7 +53,18 @@ export const executeProviderOperation = async < ); const request = schema.safeParse({ operation, input }); if (!request.success) - return err(new AnalysisInputError(operation, { cause: request.error })); + return err( + new AnalysisInputError( + operation, + { cause: request.error }, + projectInputIssues(request.error.issues, { operation, input }).map( + (issue) => + issue.path[0] === "input" + ? { ...issue, path: issue.path.slice(1) } + : issue, + ), + ), + ); const executed = await provider.execute(request.data, options); if (!executed.ok) return executed; const evidence = createAnalysisExecutionEvidence( diff --git a/src/application/jeb/JebAnalysisService.test.ts b/src/application/jeb/JebAnalysisService.test.ts index 9f1e975e16..f4b3f0f01d 100644 --- a/src/application/jeb/JebAnalysisService.test.ts +++ b/src/application/jeb/JebAnalysisService.test.ts @@ -52,10 +52,17 @@ describe("JebAnalysisService admission", () => { const result = await service.execute("list_jeb_units", { count: 101, index: -1, + parent_unit_path: "", }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error).toBeInstanceOf(AnalysisInputError); + if (!(result.error instanceof AnalysisInputError)) throw result.error; + expect(result.error.issues).toContainEqual({ + path: ["parent_unit_path"], + reason: "out_of_range", + minimum: 1, + }); expect(requests).toHaveLength(0); }); diff --git a/src/cli/flutterCommands.ts b/src/cli/flutterCommands.ts index 07aa2c8cdb..4879d1cea8 100644 --- a/src/cli/flutterCommands.ts +++ b/src/cli/flutterCommands.ts @@ -4,7 +4,10 @@ import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBui import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import type { FlutterOperation } from "../domain/flutter/flutterBuildAnalysis.js"; +import { + flutterInputSchemas, + type FlutterOperation, +} from "../domain/flutter/flutterBuildAnalysis.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -35,7 +38,7 @@ export const registerFlutterCommands = ( description: "Identify a Flutter build's Dart snapshot hash and engine facts by pure APK parsing", argumentSchema: z.object({ - path: z.string().min(1).describe("Local APK file to inspect"), + path: flutterInputSchemas.identify_flutter_build.shape.path, }), operation: "identify_flutter_build", toInput: (arguments_) => arguments_, @@ -45,18 +48,10 @@ export const registerFlutterCommands = ( description: "Inspect one ABI's Dart AOT snapshot: sections, headers, and the string pool", argumentSchema: z.object({ - path: z - .string() - .min(1) - .describe("Local APK carrying a Flutter payload"), + path: flutterInputSchemas.inspect_dart_aot.shape.path, }), optionSchema: z.object({ - abi: z - .string() - .min(2) - .max(16) - .optional() - .describe("ABI under lib/ to inspect; defaults to the preferred one"), + abi: flutterInputSchemas.inspect_dart_aot.shape.abi, }), operation: "inspect_dart_aot", toInput: (arguments_, options) => ({ diff --git a/src/cli/jebCommands.ts b/src/cli/jebCommands.ts index 18009c2f71..0aab2617a7 100644 --- a/src/cli/jebCommands.ts +++ b/src/cli/jebCommands.ts @@ -9,6 +9,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { jebInputSchemas } from "../domain/jeb/jebAnalysis.js"; /** Expose the same explicit JEB requests through one-shot CLI commands. */ export const registerJebCommands = ( @@ -40,9 +41,7 @@ export const registerJebCommands = ( description: "Open or create a JEB project from an artifact or .jdb2 database", args: z.object({ - path: z - .string() - .describe("Artifact or .jdb2 path resolved by the JEB engine host"), + path: jebInputSchemas.open_jeb_project.shape.path, }), run: ({ args }) => execute(CLI_COMMANDS.openJebProject, "open_jeb_project", args), @@ -51,27 +50,10 @@ export const registerJebCommands = ( description: "List JEB project units by path and type with optional filter", args: z.object({}), options: z.object({ - filter: z - .string() - .optional() - .describe("Wildcard filter matched against unit paths"), - "parent-unit-path": z - .string() - .optional() - .describe("Restrict results to descendants of this unit path"), - index: z - .number() - .int() - .nonnegative() - .default(0) - .describe("Zero-based index of the first result to return"), - count: z - .number() - .int() - .min(1) - .max(100) - .default(100) - .describe("Maximum results; the engine caps unit pages at 100"), + filter: jebInputSchemas.list_jeb_units.shape.filter, + "parent-unit-path": jebInputSchemas.list_jeb_units.shape.parent_unit_path, + index: jebInputSchemas.list_jeb_units.shape.index, + count: jebInputSchemas.list_jeb_units.shape.count, }), run: ({ options }) => execute(CLI_COMMANDS.listJebUnits, "list_jeb_units", { @@ -86,22 +68,11 @@ export const registerJebCommands = ( cli.command(CLI_COMMANDS.decompileJebItem, { description: "Decompile one JEB type or method to engine pseudo-code", args: z.object({ - "item-address": z - .string() - .min(1) - .describe("Type or method address within the unit"), - "item-kind": z - .enum(["type", "method"]) - .describe("Kind of item to decompile"), + "item-address": jebInputSchemas.decompile_jeb_item.shape.item_address, + "item-kind": jebInputSchemas.decompile_jeb_item.shape.item_kind, }), options: z.object({ - "unit-path": z - .string() - .min(1) - .optional() - .describe( - "Target code unit; the engine's first code unit when omitted", - ), + "unit-path": jebInputSchemas.decompile_jeb_item.shape.unit_path, }), run: ({ args, options }) => execute(CLI_COMMANDS.decompileJebItem, "decompile_jeb_item", { diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts index 10c5b7b009..37c3987b8f 100644 --- a/src/domain/flutter/flutterBuildAnalysis.ts +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -2,6 +2,33 @@ import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; import { digestSchema } from "../digests.js"; +/** Full logical ZIP path budget, enforced by Flutter library inventory. */ +export const MAX_FLUTTER_LIBRARY_PATH_BYTES = 1024; + +/** + * A libapp label can occupy every remaining byte of an admitted path. Its + * Unicode character count cannot exceed that byte count. The query permits + * unmatched labels; inventory owns the exact UTF-8 byte check on real paths. + */ +const ABI_SELECTOR_MAX_CHARACTERS = + MAX_FLUTTER_LIBRARY_PATH_BYTES - "lib/".length - "/libapp.so".length; + +/** + * ZIP labels are literal names, including backslashes and controls. They are + * never used as extraction paths. A Unicode regex gives runtime validation + * and JSON Schema the same character unit; Zod max() counts UTF-16 units. + * The final lookahead requires the actual end, including a trailing newline. + */ +const abiSelectorSchema = z + .string() + .regex( + new RegExp( + `^[^/]{1,${String(ABI_SELECTOR_MAX_CHARACTERS)}}(?![\\s\\S])`, + "u", + ), + `ABI must be the exact lib/ directory label: 1–${String(ABI_SELECTOR_MAX_CHARACTERS)} Unicode characters without '/'.`, + ); + /** * Caller intent for Flutter build identification. This family is pure * parsing: it reads an APK's `lib//libapp.so` and `libflutter.so` @@ -15,17 +42,10 @@ export const flutterInputSchemas = { path: localPathStringSchema.describe( "Local APK file carrying a Flutter AOT payload", ), - abi: z - .string() - .min(2) - .max(16) - .regex( - /^[a-z0-9_\x2d]+$/u, - "ABI name as it appears under lib/, e.g. arm64-v8a", - ) + abi: abiSelectorSchema .optional() .describe( - "ABI to inspect; defaults to arm64-v8a, x86_64, x86, armeabi-v7a, then remaining ABIs alphabetically", + "Exact lib/ directory label reported by identify_flutter_build; defaults to arm64-v8a, x86_64, x86, armeabi-v7a, then remaining ABIs alphabetically", ), }), } as const; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index d50eaacf9b..ca37a58bc7 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -15,7 +15,10 @@ import { createAnalysisExecutionEvidence, type AnalysisExecution, } from "../application/AnalysisProvider.js"; -import type { FlutterRequest } from "../domain/flutter/flutterBuildAnalysis.js"; +import { + MAX_FLUTTER_LIBRARY_PATH_BYTES, + type FlutterRequest, +} from "../domain/flutter/flutterBuildAnalysis.js"; import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, @@ -71,7 +74,6 @@ const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024; // engine labels this keeps expanded result metadata below 512 KiB for ordinary // ABI names, independently of the archive's compressed-byte budget. const MAX_FLUTTER_ABIS = 32; -const MAX_LIBRARY_PATH_BYTES = 1024; const MAX_POOL_URIS = 500; const MAX_POOL_TOKENS = 2_000; /** A complete AOT image carries exactly one VM and one isolate section. */ @@ -284,7 +286,7 @@ const collectFlutterLibraries = async ( abis.add(match[1]!); if ( abis.size > MAX_FLUTTER_ABIS || - Buffer.byteLength(entry.path) > MAX_LIBRARY_PATH_BYTES + Buffer.byteLength(entry.path) > MAX_FLUTTER_LIBRARY_PATH_BYTES ) throw new AnalysisResourceConstraintError( operation, @@ -292,7 +294,7 @@ const collectFlutterLibraries = async ( "Flutter library inventory exceeds the bounded ABI projection budget", { max_flutter_abis: MAX_FLUTTER_ABIS, - max_library_path_bytes: MAX_LIBRARY_PATH_BYTES, + max_library_path_bytes: MAX_FLUTTER_LIBRARY_PATH_BYTES, }, ); libraries.push({ diff --git a/tests/boundary/cli/flutterCli.test.ts b/tests/boundary/cli/flutterCli.test.ts index 4b424479df..8d09fc90ae 100644 --- a/tests/boundary/cli/flutterCli.test.ts +++ b/tests/boundary/cli/flutterCli.test.ts @@ -1,11 +1,14 @@ import { rm } from "node:fs/promises"; import { Cli } from "incur"; import { expect, it, onTestFinished } from "vitest"; +import { Ajv } from "ajv"; +import type { AnySchema } from "ajv"; import { registerFlutterCommands } from "../../../src/cli/flutterCommands.js"; import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js"; import { FlutterBuildProvider } from "../../../src/flutter/FlutterBuildProvider.js"; import { silentLogger } from "../../../src/logger.js"; +import { flutterInputSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js"; import { createFlutterApk } from "../../fixtures/flutterApk.js"; import { serveCli } from "./cliServeHarness.js"; @@ -27,6 +30,94 @@ it("reports an unreadable target with an input refusal", async () => { expect(result.stdout).toContain("invalid_request"); }); +it.each(["identify-flutter-build", "inspect-dart-aot"])( + "%s uses canonical local-path validation before provider admission", + async (command) => { + const result = await serveCli([command, "fixture\u0000.apk", "--json"]); + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ + path: "path", + message: expect.stringContaining("NUL"), + }), + ], + }); + }, +); + +it("advertises and validates one ABI selector grammar with field correction guidance", async () => { + const advertised = await serveCli(["inspect-dart-aot", "--schema", "--json"]); + expect(advertised.exitCode).toBe(0); + const schema = JSON.parse(advertised.stdout) as { options: AnySchema }; + const ajv = new Ajv({ strict: false }); + expect(ajv.validateSchema(schema.options)).toBe(true); + const accepts = ajv.compile(schema.options); + const abiSchema = flutterInputSchemas.inspect_dart_aot.shape.abi; + for (const abi of [ + "ARM64-V8A", + "ARM64", + "arm64.v8a", + "A", + "a", + "abi\\label", + "abi\nlabel", + "l".repeat(1010), + "😀".repeat(1010), + ]) { + expect(accepts({ abi })).toBe(true); + expect(abiSchema.safeParse(abi).success).toBe(true); + } + for (const abi of [ + "", + "lib/x86_64", + "l".repeat(1011), + "😀".repeat(1011), + "l".repeat(1010) + "\n", + ]) { + expect(accepts({ abi })).toBe(false); + expect(abiSchema.safeParse(abi).success).toBe(false); + const rejected = await serveCli([ + "inspect-dart-aot", + "/nonexistent/app.apk", + "--abi", + abi, + "--json", + ]); + expect(JSON.parse(rejected.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ + path: "abi", + message: expect.stringContaining("ABI"), + }), + ], + }); + } +}); + +it("inspects the caller-selected nonstandard ABI through the public CLI", async () => { + const path = await createFlutterApk("selector.apk", [ + { path: "lib/ARM64-V8A/libapp.so", bytes: aotFixtureImage("a".repeat(32)) }, + { path: "lib/x86_64/libapp.so", bytes: aotFixtureImage("b".repeat(32)) }, + ]); + const selected = await serveCli([ + "inspect-dart-aot", + path, + "--abi", + "ARM64-V8A", + "--json", + ]); + expect(selected.exitCode).toBe(0); + expect(JSON.parse(selected.stdout)).toMatchObject({ + parameters: { path, abi: "ARM64-V8A" }, + normalized_result: { + abi: "ARM64-V8A", + libapp: { snapshot_hash: "a".repeat(32), coverage: "complete" }, + }, + }); +}); + /** Serve one command whose first snapshot-root removal fails and is retried on close. */ const serveWithCleanupRetry = async ( command: string, diff --git a/tests/boundary/cli/jebInputDiagnostics.test.ts b/tests/boundary/cli/jebInputDiagnostics.test.ts new file mode 100644 index 0000000000..7e459873cf --- /dev/null +++ b/tests/boundary/cli/jebInputDiagnostics.test.ts @@ -0,0 +1,111 @@ +import { Cli } from "incur"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; + +import type { JebAnalysisPort } from "../../../src/application/jeb/JebAnalysisPort.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { registerJebCommands } from "../../../src/cli/jebCommands.js"; +import { silentLogger } from "../../../src/logger.js"; +import { ok } from "../../../src/domain/result.js"; +import type { JebRequest } from "../../../src/domain/jeb/jebAnalysis.js"; +import { jebResultSchemas } from "../../../src/domain/jeb/jebAnalysis.js"; + +const invoke = async (argv: readonly string[]) => { + const requests: JebRequest[] = []; + const provider: JebAnalysisPort = { + async inspectAvailability() { + throw new Error("unexpected provider probe"); + }, + async close() {}, + async execute(request) { + requests.push(request); + return ok( + createAnalysisExecution( + jebResultSchemas.list_jeb_units.parse({ + engine: { + name: "JEB test provider", + version: null, + endpoint: "http://127.0.0.1:1/mcp", + gui_client: null, + }, + filter: null, + parent_unit_path: + request.operation === "list_jeb_units" + ? (request.input.parent_unit_path ?? null) + : null, + index: 0, + count: 100, + units: [], + coverage: "complete", + }), + { id: "jeb-test", name: "JEB test provider", version: null }, + ), + ); + }, + }; + const cli = Cli.create("rea-jeb-input-test", { sync: false }); + registerJebCommands(cli, silentLogger, {}, provider); + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + return { + exitCode, + output: JSON.parse(stdout) as Record, + requests, + }; +}; + +it("keeps JEB CLI correction details aligned with canonical list-unit validation", async () => { + const invalid = await invoke([ + "list-jeb-units", + "--parent-unit-path", + "", + "--json", + ]); + expect(invalid.output).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [{ path: "parent-unit-path", code: "too_small" }], + }); + expect(invalid.requests).toEqual([]); + + const valid = await invoke([ + "list-jeb-units", + "--parent-unit-path", + "sample.dex", + "--json", + ]); + expect(valid.output).not.toHaveProperty("code"); + expect(valid.requests).toMatchObject([ + { input: { parent_unit_path: "sample.dex" } }, + ]); +}); + +it("advertises the canonical minimum for a JEB parent unit path", async () => { + const { output } = await invoke(["list-jeb-units", "--schema", "--json"]); + const schema = output.options as Parameters[0]; + const validate = new Ajv2020({ strict: false }).compile(schema); + expect(validate({ index: 0, count: 100 })).toBe(true); + expect(validate({ index: 0, count: 100, "parent-unit-path": "" })).toBe( + false, + ); + expect( + validate({ index: 0, count: 100, "parent-unit-path": "sample.dex" }), + ).toBe(true); +}); + +it("advertises the canonical nonempty, NUL-free JEB project path", async () => { + const { output } = await invoke(["open-jeb-project", "--schema", "--json"]); + const schema = output.args as Parameters[0]; + const validate = new Ajv2020({ strict: false }).compile(schema); + expect(validate({ path: "sample.apk" })).toBe(true); + expect(validate({ path: "" })).toBe(false); + expect(validate({ path: "sample\u0000.apk" })).toBe(false); +}); diff --git a/tests/boundary/mcp/flutterMcp.test.ts b/tests/boundary/mcp/flutterMcp.test.ts index 646f545113..03e879f0e9 100644 --- a/tests/boundary/mcp/flutterMcp.test.ts +++ b/tests/boundary/mcp/flutterMcp.test.ts @@ -1,5 +1,6 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, onTestFinished } from "vitest"; +import { Ajv2020 } from "ajv/dist/2020.js"; import type { FlutterBuildAnalysisPort } from "../../../src/application/flutter/FlutterBuildAnalysisPort.js"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; @@ -8,6 +9,91 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createFlutterBuildAnalysisProvider } from "../../../src/composition/flutter.js"; +import { flutterResultSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js"; +import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js"; +import { createFlutterApk } from "../../fixtures/flutterApk.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; + +it.each([ + { label: "ARM64-V8A", paired: false }, + ...[ + "ARM64-V8A", + "arm64.v8a", + "A", + ".", + "..", + "abi\\label", + "abi\u0000label", + "abi\nlabel", + "abi\u0085label", + "l".repeat(336), + "l".repeat(1010), + "界".repeat(336), + "😀".repeat(252), + ].map((label) => ({ label, paired: true })), +])( + "selects the exact reported archive ABI $label (paired=$paired)", + async ({ label, paired }) => { + const path = await createFlutterApk("reported-label.apk", [ + { + path: `lib/${label}/libapp.so`, + bytes: aotFixtureImage("a".repeat(32)), + }, + ...(paired + ? [ + { + path: "lib/x86_64/libapp.so", + bytes: aotFixtureImage("b".repeat(32)), + }, + ] + : []), + ]); + const provider = createFlutterBuildAnalysisProvider(); + onTestFinished(() => provider.close()); + const harness = await createApplicationMcpHarness({ + serverOptions: { flutterAnalysis: provider }, + }); + onTestFinished(harness.close); + const call = (name: string, abi?: string) => + harness.client.callTool({ + name, + arguments: { path, ...(abi === undefined ? {} : { abi }) }, + }); + const inventoryReply = await call("identify_flutter_build"); + expect(inventoryReply.isError).not.toBe(true); + const inventory = flutterResultSchemas.identify_flutter_build.parse( + parseEvidence(inventoryReply.structuredContent).normalized_result, + ); + expect(inventory.abis.map((row) => row.abi)).toContain(label); + const defaultReply = await call("inspect_dart_aot"); + expect(defaultReply.isError).not.toBe(true); + const defaultFacts = flutterResultSchemas.inspect_dart_aot.parse( + parseEvidence(defaultReply.structuredContent).normalized_result, + ); + expect(defaultFacts.abi).toBe(paired ? "x86_64" : label); + expect(defaultFacts.libapp.snapshot_hash).toBe( + (paired ? "b" : "a").repeat(32), + ); + const selectedReply = await call("inspect_dart_aot", label); + expect(selectedReply.isError).not.toBe(true); + const evidence = parseEvidence(selectedReply.structuredContent); + const selected = flutterResultSchemas.inspect_dart_aot.parse( + evidence.normalized_result, + ); + expect(selected.abi).toBe(label); + expect(selected.libapp.snapshot_hash).toBe("a".repeat(32)); + expect(selected.libapp.coverage).toBe("complete"); + expect(evidence.parameters).toEqual({ path, abi: label }); + const tool = (await harness.client.listTools()).tools.find( + (tool) => tool.name === "inspect_dart_aot", + ); + if (tool === undefined) throw new Error("Missing AOT tool"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(tool.inputSchema)).toBe(true); + expect(ajv.validate(tool.inputSchema, { path, abi: label })).toBe(true); + }, +); const identity = { id: "flutter", @@ -122,6 +208,51 @@ const fakeProvider = (): FlutterBuildAnalysisPort => ({ }, }); +it("advertises and enforces the same Unicode selector grammar through MCP", async () => { + const harness = await createApplicationMcpHarness({ + serverOptions: { flutterAnalysis: fakeProvider() }, + }); + onTestFinished(harness.close); + const tool = (await harness.client.listTools()).tools.find( + (tool) => tool.name === "inspect_dart_aot", + ); + if (tool === undefined) throw new Error("Missing AOT tool"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(tool.inputSchema)).toBe(true); + for (const abi of ["😀".repeat(1010), "l".repeat(1010)]) { + const arguments_ = { path: "/fixture.apk", abi }; + expect(ajv.validate(tool.inputSchema, arguments_)).toBe(true); + expect( + ( + await harness.client.callTool({ + name: "inspect_dart_aot", + arguments: arguments_, + }) + ).isError, + ).not.toBe(true); + } + for (const abi of [ + "", + "lib/x86_64", + "😀".repeat(1011), + "l".repeat(1010) + "\n", + ]) { + const arguments_ = { path: "/fixture.apk", abi }; + expect(ajv.validate(tool.inputSchema, arguments_)).toBe(false); + const refused = await harness.client.callTool({ + name: "inspect_dart_aot", + arguments: arguments_, + }); + expect(refused.isError).toBe(true); + expect(refused.content).toContainEqual({ + type: "text", + text: expect.stringContaining( + "abi: ABI must be the exact lib/ directory label", + ), + }); + } +}); + it("advertises an exact valid Flutter schema and records inline evidence through MCP", async () => { const session = createTestBinarySession(() => { throw new Error("deep provider must not start");