fix(application): budget bridge candidate projection by bytes

This commit is contained in:
morluto 2026-10-09 05:09:17 +08:00
parent 73b09f4c5c
commit 3d450a29fc
7 changed files with 302 additions and 34 deletions

View File

@ -111,7 +111,14 @@ rea project-android-application-graph '{"inventory_evidence":[<inventory_artifac
The projection reports exact component paths and hashes, runtime-family hints,
and path-based bridge hypotheses. It does not decode DEX or claim observed
runtime calls.
runtime calls. Bridge hypotheses form a DEX/JVM-class by native-library
Cartesian product. The projection computes the product and exact serialized
candidate-array size before creating candidate objects, then retains a
deterministic prefix within a 2 MiB UTF-8 JSON budget. If candidates are omitted,
`bridge_candidate_coverage` reports total, emitted, and omitted counts; overall
coverage is `partial` while component arrays still include every component
from the supplied inventory pages. A path pair does not establish that a
managed declaration names, loads, or calls the native library.
## Resource and lifecycle limits

View File

@ -20,16 +20,27 @@ rea project-apple-application-graph '{"inventory_evidence":[<inventory_artifact
}
```
The result includes every component in each inventory category and every
JavaScript-to-native bridge candidate pair. It has no caller-selected component
budget, prefix truncation, or omitted-count fields. Duplicate inventory pages
are merged by artifact identity and occurrence path; their Evidence IDs remain
attached as source Evidence.
The result includes every component in each inventory category. Bridge
hypotheses are the path-based JavaScript/native pairs within each application
root. To keep their Cartesian expansion bounded, the projection limits the
serialized candidate array to 2 MiB. It computes the pair count and
exact UTF-8 JSON size before creating candidate objects, then retains a
deterministic prefix when the byte budget is exceeded. The result's
`bridge_candidate_coverage` reports total, emitted, and omitted pair counts;
overall coverage becomes `partial` and a limitation explains the omission.
Duplicate inventory pages are merged by artifact identity and occurrence path;
their Evidence IDs remain attached as source Evidence.
For IPA inventories, archive components outside every application root remain
in the component lists. Their JavaScript and native candidates are grouped with
other unrooted components, preserving the inventory projection's path pairing
semantics without attributing them to an application bundle.
Coverage is `complete-within-inventory` when the supplied pages reconstruct the
complete authenticated inventory, and `partial` otherwise. A partial result
states that absence is unknown. Component and bridge-candidate arrays are still
the complete projection of the inventory that was supplied.
states that absence is unknown. Component arrays remain complete when candidate
hypotheses are omitted; component arrays still include every component from
the supplied inventory pages, and overall coverage is `partial` in that case.
## Application roots

View File

@ -7,6 +7,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);
@ -61,6 +65,7 @@ export const androidApplicationProjectionResultSchema = z.strictObject({
]),
}),
),
bridge_candidate_coverage: bridgeCandidateCoverageSchema,
coverage: z.strictObject({
status: z.enum(["complete-within-inventory", "partial"]),
inventory_complete: z.boolean(),
@ -120,6 +125,11 @@ export const projectAndroidApplication = (
"Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.",
"Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid DEX or JVM class bytes.",
"Bridge candidates are path-based hypotheses, not decoded JNI declarations or observed runtime calls.",
...(bridgeProjection.coverage.status === "partial"
? [
`Bridge candidate pairs exceeded the projection safety budget; ${bridgeProjection.coverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`,
]
: []),
];
const withoutId = {
root_sha256: inventory.manifest.root_sha256,
@ -130,10 +140,12 @@ export const projectAndroidApplication = (
components,
runtime_families: runtimeFamilies(all),
bridge_candidates: bridgeProjection.candidates,
bridge_candidate_coverage: bridgeProjection.coverage,
coverage: {
status: inventory.complete
? ("complete-within-inventory" as const)
: ("partial" as const),
status:
inventory.complete && bridgeProjection.coverage.status === "complete"
? ("complete-within-inventory" as const)
: ("partial" as const),
inventory_complete: inventory.complete,
},
limitations,
@ -210,16 +222,14 @@ const bridgeCandidates = (
managed: readonly Component[],
native: readonly Component[],
) => {
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 = (

View File

@ -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",
});
});
});

View File

@ -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<string | null, Component[]>();
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.",

View File

@ -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,
},
};
};

View File

@ -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.`,
);
});
});