diff --git a/docs/android-analysis.md b/docs/android-analysis.md index a1f6d27a9..d72ba4977 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -111,7 +111,14 @@ rea project-android-application-graph '{"inventory_evidence":[ { - const candidates = managed.flatMap((source) => - native.map((target) => ({ - managed_path: source.path, + return projectCartesianCandidates({ + groups: [{ left: managed, right: native }], + createCandidate: (managed_path, target) => ({ + managed_path, native_path: target.path, basis: bridgeBasis(target.path), - })), - ); - return { - candidates, - }; + }), + }); }; const bridgeBasis = ( diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index ea5419dc9..2e82c4fd7 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -494,4 +494,64 @@ describe("Apple application bridge candidates", () => { ], ]); }); + + it("reports candidate omission when one root expands beyond the bridge safety budget", () => { + const escapedDirectory = `${"é".repeat(100)}${'"'.repeat(120)}`; + const scripts = Array.from({ length: 129 }, (_, index) => ({ + path: `Payload/Fixture.app/${escapedDirectory}/script-${String(index).padStart(3, "0")}.js`, + format: "javascript-bundle", + })); + const native = Array.from({ length: 128 }, (_, index) => + macho( + `Payload/Fixture.app/${escapedDirectory}/lib${String(index).padStart(3, "0")}.dylib`, + ), + ); + const result = project( + inventoryEvidence("zip", "Fixture.zip", [...scripts, ...native]), + ); + + expect(result.components.javascript).toHaveLength(129); + expect(result.components.native_libraries).toHaveLength(128); + expect(result.bridge_candidate_coverage).toMatchObject({ + status: "partial", + total_candidates: 129 * 128, + emitted_candidates: result.bridge_candidates.length, + omitted_candidates: 129 * 128 - result.bridge_candidates.length, + }); + expect(result.bridge_candidates.length).toBeGreaterThan(0); + expect(result.bridge_candidates.length).toBeLessThan(129 * 128); + expect( + Buffer.byteLength(JSON.stringify(result.bridge_candidates)), + ).toBeLessThanOrEqual(2 * 1024 * 1024); + expect(result.coverage).toMatchObject({ + status: "partial", + inventory_complete: true, + }); + expect(result.limitations).toContain( + `Bridge candidate pairs exceeded the projection safety budget; ${result.bridge_candidate_coverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`, + ); + }); + + it("preserves candidate pairing for components without an application root", () => { + const result = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist" }, + { + path: "Payload/Fixture.app/Resources/app.js", + format: "javascript-bundle", + }, + { path: "SwiftSupport/outside.js", format: "javascript-bundle" }, + { path: "SwiftSupport/liboutside.dylib", format: "mach-o" }, + ]), + ); + + expect(result.bridge_candidates).toContainEqual({ + source_path: "SwiftSupport/outside.js", + native_path: "SwiftSupport/liboutside.dylib", + basis: "javascript-and-native-content", + }); + expect(result.bridge_candidate_coverage).toMatchObject({ + status: "complete", + }); + }); }); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 9a275d3c9..dfda9f675 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -18,6 +18,10 @@ import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js" import { evidenceSchema } from "../evidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; +import { + bridgeCandidateCoverageSchema, + projectCartesianCandidates, +} from "../bridgeCandidateProjection.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const pathSchema = z.string().min(1); @@ -74,6 +78,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({ ]), }), ), + bridge_candidate_coverage: bridgeCandidateCoverageSchema, coverage: z.strictObject({ status: z.enum(["complete-within-inventory", "partial"]), inventory_complete: z.boolean(), @@ -162,6 +167,7 @@ export const projectAppleApplication = ( sidecars: entries.some(({ path }) => isSidecar(path)), symlinks: entries.some(({ kind }) => kind === "symlink"), unattributed, + bridgeCandidateCoverage: bridgeProjection.coverage, }); const withoutId = { root_sha256: inventory.manifest.root_sha256, @@ -179,10 +185,12 @@ export const projectAppleApplication = ( .sort(compare), runtime_families: runtimeFamilies, bridge_candidates: bridgeProjection.candidates, + bridge_candidate_coverage: bridgeProjection.coverage, coverage: { - status: complete - ? ("complete-within-inventory" as const) - : ("partial" as const), + status: + complete && bridgeProjection.coverage.status === "complete" + ? ("complete-within-inventory" as const) + : ("partial" as const), inventory_complete: inventory.complete, }, limitations, @@ -310,7 +318,7 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => { return [...families].sort(compare); }; -/** Pair scripts and native code only within the same application root. */ +/** Pair scripts and native code within matching application-root groups. */ const identifyBridgeCandidates = ( scripts: readonly Component[], native: readonly Component[], @@ -318,18 +326,32 @@ const identifyBridgeCandidates = ( ) => { const owner = (path: string): string | null => roots.find((root) => isWithin(path, root)) ?? null; - return { - candidates: scripts.flatMap((script) => { - const root = owner(script.path); - return native - .filter(({ path }) => owner(path) === root) - .map((item) => ({ - source_path: script.path, - native_path: item.path, - basis: bridgeBasis(item.path), - })); + const scriptGroups: { root: string | null; left: Component[] }[] = []; + for (const script of scripts) { + const root = owner(script.path); + const last = scriptGroups.at(-1); + if (last?.root === root) last.left.push(script); + else scriptGroups.push({ root, left: [script] }); + } + const nativeByRoot = new Map(); + for (const item of native) { + const root = owner(item.path); + const scoped = nativeByRoot.get(root) ?? []; + scoped.push(item); + nativeByRoot.set(root, scoped); + } + const groups = scriptGroups.map(({ root, left }) => ({ + left, + right: nativeByRoot.get(root) ?? [], + })); + return projectCartesianCandidates({ + groups, + createCandidate: (source_path, item) => ({ + source_path, + native_path: item.path, + basis: bridgeBasis(item.path), }), - }; + }); }; const bridgeBasis = ( @@ -355,6 +377,7 @@ const projectionLimitations = (facts: { readonly sidecars: boolean; readonly symlinks: boolean; readonly unattributed: number; + readonly bridgeCandidateCoverage: AppleApplicationProjectionResult["bridge_candidate_coverage"]; }): string[] => [ ...(!facts.complete ? ["Source inventory pages are incomplete; absence is unknown."] @@ -392,6 +415,11 @@ const projectionLimitations = (facts: { "AppleDouble sidecar entries (._* files and __MACOSX/) describe neighbouring files and are excluded from bundle roles.", ] : []), + ...(facts.bridgeCandidateCoverage.status === "partial" + ? [ + `Bridge candidate pairs exceeded the projection safety budget; ${facts.bridgeCandidateCoverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`, + ] + : []), "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.", "Bridge candidates are path-based hypotheses, not observed runtime calls.", diff --git a/src/domain/bridgeCandidateProjection.ts b/src/domain/bridgeCandidateProjection.ts new file mode 100644 index 000000000..bebe1001f --- /dev/null +++ b/src/domain/bridgeCandidateProjection.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +/** Coverage metadata for the bounded projection of candidate relationships. */ +export const bridgeCandidateCoverageSchema = z.strictObject({ + status: z.enum(["complete", "partial"]), + total_candidates: z.number().int().nonnegative(), + emitted_candidates: z.number().int().nonnegative(), + omitted_candidates: z.number().int().nonnegative(), +}); + +// The serialized candidate array is the retained representation; accounting +// its exact UTF-8 JSON size bounds both array objects and repeated path strings. +/** Maximum UTF-8 JSON size of a retained candidate array. */ +export const MAX_BRIDGE_CANDIDATE_JSON_BYTES = 2 * 1024 * 1024; + +type PathComponent = { readonly path: string }; + +const jsonText = (value: unknown): string => { + const serialized = JSON.stringify(value); + if (serialized === undefined) + throw new TypeError("Bridge candidate is not JSON serializable"); + return serialized; +}; + +/** Retain a deterministic prefix of grouped Cartesian candidates within a byte budget. */ +export const projectCartesianCandidates = < + TLeft extends PathComponent, + TRight extends PathComponent, + TCandidate, +>(input: { + readonly groups: readonly { + readonly left: readonly TLeft[]; + readonly right: readonly TRight[]; + }[]; + readonly createCandidate: (leftPath: string, right: TRight) => TCandidate; +}) => { + const total = input.groups.reduce( + (sum, group) => sum + group.left.length * group.right.length, + 0, + ); + const preparedGroups = input.groups.map((group) => ({ + left: group.left.map((value) => ({ + value, + pathBytes: Buffer.byteLength(jsonText(value.path)), + })), + right: group.right.map((value) => { + const emptyLeftCandidate = jsonText(input.createCandidate("", value)); + return { + value, + rowOverhead: Buffer.byteLength(emptyLeftCandidate) - 2, + }; + }), + })); + let estimatedFullBytes = 2 + Math.max(0, total - 1); // JSON array brackets and commas. + for (const group of preparedGroups) { + estimatedFullBytes += + group.left.length * + group.right.reduce((sum, { rowOverhead }) => sum + rowOverhead, 0) + + group.right.length * + group.left.reduce((sum, { pathBytes }) => sum + pathBytes, 0); + } + + const shouldEmitAll = estimatedFullBytes <= MAX_BRIDGE_CANDIDATE_JSON_BYTES; + const candidates: TCandidate[] = []; + let estimatedRetainedBytes = 2; + candidateLoop: for (const group of preparedGroups) { + for (const left of group.left) { + for (const right of group.right) { + const candidateBytes = right.rowOverhead + left.pathBytes; + const additionalBytes = + candidateBytes + (candidates.length > 0 ? 1 : 0); + if ( + !shouldEmitAll && + estimatedRetainedBytes + additionalBytes > + MAX_BRIDGE_CANDIDATE_JSON_BYTES + ) + break candidateLoop; + candidates.push(input.createCandidate(left.value.path, right.value)); + estimatedRetainedBytes += additionalBytes; + } + } + } + + return { + candidates, + coverage: { + status: + candidates.length === total + ? ("complete" as const) + : ("partial" as const), + total_candidates: total, + emitted_candidates: candidates.length, + omitted_candidates: total - candidates.length, + }, + }; +}; diff --git a/tests/composition/android/application.test.ts b/tests/composition/android/application.test.ts index 0ce74967e..ac0318e75 100644 --- a/tests/composition/android/application.test.ts +++ b/tests/composition/android/application.test.ts @@ -142,7 +142,9 @@ describe("Android application projection", () => { "Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid DEX or JVM class bytes.", ); }); +}); +describe("Android bridge candidate coverage", () => { it("returns every component and bridge candidate from the inventory", async () => { const root = await createTestTempDirectory("rea-android-complete-"); const path = join(root, "Fixture.apk"); @@ -171,4 +173,58 @@ describe("Android application projection", () => { expect(projection.bridge_candidates).toHaveLength(1_001); expect(projection.coverage.status).toBe("complete-within-inventory"); }); + + it("marks a multidex by native projection partial when escaped UTF-8 candidate paths exceed its byte budget", async () => { + const root = await createTestTempDirectory("rea-android-bridge-budget-"); + const path = join(root, "Large.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + const dex = Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]); + const escapedDirectory = `${"é".repeat(100)}${'"'.repeat(120)}`; + for (let index = 0; index < 129; index += 1) + await writer.add( + `${escapedDirectory}/classes${String(index + 1).padStart(3, "0")}.dex`, + new Uint8ArrayReader(dex), + ); + for (let index = 0; index < 128; index += 1) + await writer.add( + `lib/arm64-v8a/libnative${String(index).padStart(3, "0")}.so`, + new TextReader("native"), + ); + await writeFile(path, await writer.close()); + + const inventory = parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}), + ); + const result = projectAndroidApplicationEvidence({ + inventory_evidence: [inventory], + }); + if (!result.ok) throw result.error; + const projection = androidApplicationProjectionResultSchema.parse( + result.value.normalized_result, + ); + + expect(projection.components.dex).toHaveLength(129); + expect(projection.components.native_libraries).toHaveLength(128); + expect(projection.bridge_candidate_coverage).toMatchObject({ + status: "partial", + total_candidates: 129 * 128, + emitted_candidates: projection.bridge_candidates.length, + omitted_candidates: 129 * 128 - projection.bridge_candidates.length, + }); + expect(projection.bridge_candidates.length).toBeGreaterThan(0); + expect(projection.bridge_candidates.length).toBeLessThan(129 * 128); + expect( + Buffer.byteLength(JSON.stringify(projection.bridge_candidates)), + ).toBeLessThanOrEqual(2 * 1024 * 1024); + expect(projection.coverage).toMatchObject({ + status: "partial", + inventory_complete: true, + }); + expect(projection.limitations).toContain( + "Bridge candidates are path-based hypotheses, not decoded JNI declarations or observed runtime calls.", + ); + expect(projection.limitations).toContain( + `Bridge candidate pairs exceeded the projection safety budget; ${projection.bridge_candidate_coverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`, + ); + }); });