From a929c9f714f5f27d3232bee83a162fa21d5dc449 Mon Sep 17 00:00:00 2001 From: Thang Luu Date: Sat, 10 Oct 2026 22:42:03 +0900 Subject: [PATCH] fix(mobile): bound framework conventions to path segments (#1506) Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/android-analysis.md | 7 +- docs/apple-application-analysis.md | 10 ++ src/domain/android/androidApplication.ts | 2 +- src/domain/apple/appleApplication.test.ts | 2 +- src/domain/apple/appleApplication.ts | 3 +- src/domain/nativeConvention.ts | 9 +- .../mobileFrameworkConventions.test.ts | 137 ++++++++++++++++++ 7 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 tests/acceptance/applications/mobileFrameworkConventions.test.ts diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 1ef5e7f40..808eeea49 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -153,7 +153,12 @@ rea project-android-application-graph '{"inventory_evidence":[]}' ``` diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 5d6e73469..915873cfb 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -222,7 +222,7 @@ const bridgeBasis = ( if (convention === "react-native") return "react-native-convention"; if (convention === "flutter") return "flutter-convention"; if (convention === "unity") return "unity-convention"; - if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(path.toLowerCase())) + if (/(?:^|\/)lib\/[^/]+\/lib[^/]+\.so$/iu.test(path)) return "jni-library-convention"; return "managed-and-native-content"; }; diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 173334f2d..8acde829f 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -624,7 +624,7 @@ describe("Apple path conventions", () => { ).toBe(true); expect(result.limitations).toEqual( expect.arrayContaining([ - "Runtime families are inferred from inventory formats and paths.", + "Runtime families are inferred from inventory formats and bounded framework path conventions, not observed runtime loading.", "A bridge basis is inferred from the native path and is repeated for every managed component.", ]), ); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 79dff1c63..fae33f78d 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -431,7 +431,8 @@ const projectionLimitations = (facts: { : []), "Bundle roles follow path conventions. Read each info_plist_path with inspect_plist for CFBundleExecutable, identifiers, and declared services.", "Bundle identifiers and signing claims require dedicated plist and CMS parsing; this projection reports only exact paths and hashes.", - "Runtime families are inferred from inventory formats and paths.", + "Runtime families are inferred from inventory formats and bounded framework path conventions, not observed runtime loading.", + "Generic framework and dylib paths do not establish Swift or Objective-C; this inventory projection does not decode language metadata.", "Bridge candidates are path-based hypotheses, not observed runtime calls.", "A bridge basis is inferred from the native path and is repeated for every managed component.", ]; diff --git a/src/domain/nativeConvention.ts b/src/domain/nativeConvention.ts index 448dcef00..cc9049adc 100644 --- a/src/domain/nativeConvention.ts +++ b/src/domain/nativeConvention.ts @@ -7,6 +7,10 @@ const foldSegment = (segment: string): string => const REACT_NATIVE_SEGMENTS = new Set([ "react.framework", + "reactnative.framework", + "libreactnative.so", + "libreactnativejni.so", + "libhermes_executor.so", "libhermes.so", "hermes.framework", ]); @@ -15,15 +19,14 @@ const UNITY_SEGMENTS = new Set(["libunity.so", "unityframework.framework"]); /** * A runtime family named by one filename or directory segment. - * `reactnative` may occur inside a segment (`libreactnativejni.so`). - * The other names match the whole segment. + * Only recognized complete library and framework names establish a convention. */ export const nativeRuntimeConvention = ( path: string, ): NativeRuntimeConvention | null => { for (const segment of path.split("/")) { const folded = foldSegment(segment); - if (folded.includes("reactnative") || REACT_NATIVE_SEGMENTS.has(folded)) + if (REACT_NATIVE_SEGMENTS.has(folded)) return "react-native"; if (FLUTTER_SEGMENTS.has(folded)) return "flutter"; if (UNITY_SEGMENTS.has(folded)) return "unity"; diff --git a/tests/acceptance/applications/mobileFrameworkConventions.test.ts b/tests/acceptance/applications/mobileFrameworkConventions.test.ts new file mode 100644 index 000000000..0691067cc --- /dev/null +++ b/tests/acceptance/applications/mobileFrameworkConventions.test.ts @@ -0,0 +1,137 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + TextReader, + Uint8ArrayReader, + Uint8ArrayWriter, + ZipWriter, +} from "@zip.js/zip.js"; +import { expect } from "vitest"; +import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const android = { + extension: "apk", + operation: "project_android_application_graph", + command: "project-android-application-graph", + scripts: ["classes.dex", "extra.dex"], + nativeBytes: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), + libraries: { + "lib/arm64-v8a/libcommunity.so": "jni-library-convention", + "lib/arm64-v8a/libpreact.so": "jni-library-convention", + "lib/arm64-v8a/libreactivity.so": "jni-library-convention", + "lib/arm64-v8a/libhermesfake.so": "jni-library-convention", + "lib/arm64-v8a/libflutter.so.backup.so": "jni-library-convention", + }, + positives: { + "lib/arm64-v8a/libreactnativejni.so": "react-native-convention", + "lib/arm64-v8a/libhermes.so": "react-native-convention", + "lib/arm64-v8a/libflutter.so": "flutter-convention", + "lib/arm64-v8a/libunity.so": "unity-convention", + }, +}; +const apple = { + extension: "ipa", + operation: "project_apple_application_graph", + command: "project-apple-application-graph", + scripts: ["Payload/Fixture.app/main.js", "Payload/Fixture.app/extra.js"], + nativeBytes: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]), + libraries: { + "Payload/Fixture.app/Frameworks/App.framework/App": + "javascript-and-native-content", + "Payload/Fixture.app/Frameworks/MyApp.framework/MyApp": + "javascript-and-native-content", + "Payload/Fixture.app/Frameworks/Community.framework/Community": + "javascript-and-native-content", + "Payload/Fixture.app/Frameworks/Preact.framework/Preact": + "javascript-and-native-content", + "Payload/Fixture.app/Frameworks/Reactivity.framework/Reactivity": + "javascript-and-native-content", + "Payload/Fixture.app/Frameworks/HermesExtra.framework/HermesExtra": + "javascript-and-native-content", + "Payload/Fixture.app/Frameworks/Flutter.framework.backup/Flutter": + "javascript-and-native-content", + "Payload/Fixture.app/Frameworks/plain.dylib": + "javascript-and-native-content", + }, + positives: { + "Payload/Fixture.app/Frameworks/React.framework/React": + "react-native-convention", + "Payload/Fixture.app/Frameworks/hermes.framework/hermes": + "react-native-convention", + "Payload/Fixture.app/Frameworks/Flutter.framework/Flutter": + "flutter-convention", + "Payload/Fixture.app/Frameworks/UnityFramework.framework/UnityFramework": + "unity-convention", + }, +}; + +cliTest( + "bounds mobile conventions and preserves inference provenance through compiled CLI and real MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-mobile-conventions-"); + const { call } = await connectLocalToolsMcp(); + const { runProviderAnalysis } = createDirectAnalysis({}); + for (const fixture of [android, apple]) { + for (const positive of [false, true]) { + const libraries = positive ? fixture.positives : fixture.libraries; + const path = join( + root, + `${positive ? "positive" : "negative"}.${fixture.extension}`, + ); + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const script of fixture.scripts) + await writer.add(script, new TextReader("export const value = 1;")); + for (const name of Object.keys(libraries)) + await writer.add(name, new Uint8ArrayReader(fixture.nativeBytes)); + await writeFile(path, await writer.close()); + const inventory = parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}), + ); + const input = { inventory_evidence: [inventory] }; + const result = await cli.run({ + arguments: [fixture.command, JSON.stringify(input), "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = parseEvidence(result.json); + const response = await call(fixture.operation, input); + expect(response.isError).not.toBe(true); + const mcp = parseEvidence(response.structuredContent); + expect(mcp).toEqual(evidence); + expect(evidence.normalized_result).toMatchObject({ + runtime_families: positive + ? fixture.extension === "apk" + ? ["dalvik-art", "flutter", "native", "react-native", "unity"] + : ["flutter", "javascript", "native", "react-native", "unity"] + : fixture.extension === "apk" + ? ["dalvik-art", "native"] + : ["javascript", "native"], + coverage: { + status: "complete-within-inventory", + inventory_complete: true, + }, + limitations: expect.arrayContaining([ + "A bridge basis is inferred from the native path and is repeated for every managed component.", + ]), + bridge_candidates: expect.arrayContaining( + Object.entries(libraries).flatMap(([native_path, basis]) => + fixture.scripts.map((script) => + expect.objectContaining({ + native_path, + basis, + ...(fixture.extension === "apk" + ? { managed_path: script } + : { source_path: script }), + }), + ), + ), + ), + }); + } + } + }, + 30_000, +);