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/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/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/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");