fix(application): budget bridge candidate projection by bytes
This commit is contained in:
parent
73b09f4c5c
commit
3d450a29fc
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue