Files
rea/tests/composition/android/application.test.ts
T
morlutoandCursor Agent b4db35f092 fix: bind identity and coverage to the producing rule (#1513)
* fix: bind identity and coverage to the producing rule

Logical paths, container families, examined bytes, path-segment conventions, destination collisions, file-id leases, Mach-O CPU selection, and firmware cleanup now follow the observation that owns each rule.

* fix: fail unmatched Mach-O slices and keep firmware roots independent

A fat target whose architecture lipo did not list now stops before slice tools run. Chained fixups still count unread LC_DYLD_INFO rebase bytes, and a firmware cleanup failure no longer blocks the next workspace.

* test: cover path identity and runtime families at the projection boundary

NFC joins and segment conventions are now asserted by the path resolver, the Apple projection, and a real APK inventory instead of helper unit tests.

* test: expect unread dyld rebase bytes in the Apple dispatch fixture

The legacy LC_DYLD_INFO fixture keeps a rebase range this reader does not decode, so the public CLI and MCP check now requires that partial coverage beside the bind opcodes.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-10 17:14:47 +08:00

294 lines
11 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
createNonApplicationZipInventory,
requireSuccessfulProjection,
} from "../../support/applicationSessionFixture.js";
import { projectAndroidApplicationEvidence } from "../../../src/application/android/AndroidApplicationService.js";
import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
const { runProviderAnalysis } = createDirectAnalysis({});
describe("Android application projection", () => {
it("projects deterministic APK components and explicit bridge hypotheses", async () => {
const root = await createTestTempDirectory("rea-android-");
const path = join(root, "Fixture.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"AndroidManifest.xml",
new Uint8ArrayReader(Uint8Array.from([3, 0, 8, 0])),
);
await writer.add(
"classes.dex",
new Uint8ArrayReader(
Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]),
),
);
const elf = () =>
new Uint8ArrayReader(
Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]),
);
await writer.add("lib/arm64-v8a/libreactnativejni.so", elf());
await writer.add("lib/arm64-v8a/libflutter.so", elf());
await writer.add("lib/arm64-v8a/libunity.so", elf());
await writer.add("lib/arm64-v8a/libcommunity.so", elf());
await writer.add("assets/index.js", new TextReader("bridge();"));
await writer.add("META-INF/FIXTURE.RSA", new TextReader("opaque signing"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
const first = projectAndroidApplicationEvidence({
inventory_evidence: [inventory],
});
const second = projectAndroidApplicationEvidence({
inventory_evidence: [inventory],
});
const left = androidApplicationProjectionResultSchema.parse(
requireSuccessfulProjection(first).normalized_result,
);
const right = androidApplicationProjectionResultSchema.parse(
requireSuccessfulProjection(second).normalized_result,
);
expect(left).toEqual(right);
expect(left).toMatchObject({
root_format: "apk",
coverage: {
status: "complete-within-inventory",
inventory_complete: true,
},
});
expect(left.components.manifests).toHaveLength(1);
expect(left.components.dex).toHaveLength(1);
expect(left.components.native_libraries).toHaveLength(4);
expect(left.components.javascript).toHaveLength(1);
expect(left.components.signing).toHaveLength(1);
expect(left.runtime_families).toEqual(
expect.arrayContaining([
"dalvik-art",
"flutter",
"javascript",
"native",
"react-native",
"unity",
]),
);
expect(left.runtime_families).not.toContain("community");
expect(left.bridge_candidates).toEqual(
expect.arrayContaining([
expect.objectContaining({ basis: "react-native-convention" }),
expect.objectContaining({ basis: "flutter-convention" }),
expect.objectContaining({ basis: "unity-convention" }),
]),
);
const community = left.bridge_candidates.filter(({ native_path }) =>
native_path.endsWith("libcommunity.so"),
);
expect(community.length).toBeGreaterThan(0);
expect(
community.every(({ basis }) => basis === "jni-library-convention"),
).toBe(true);
expect(left.limitations).toEqual(
expect.arrayContaining([
"A bridge basis is inferred from the native path and is repeated for every managed component.",
"ZIP inventory cannot see the APK Signing Block, so an empty signing array does not mean the APK is unsigned.",
]),
);
expect(JSON.stringify(left)).not.toContain("opaque signing");
});
it("rejects non-APK inventory Evidence", async () => {
const inventory = await createNonApplicationZipInventory(
"rea-android-invalid-",
);
expect(
projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it("keeps uppercase path-derived bytecode families distinct from byte validity", async () => {
const root = await createTestTempDirectory("rea-android-suffixes-");
const path = join(root, "Suffixes.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
for (const entry of [
"classes.DEX",
"extra.dex",
"Main.CLASS",
"Other.class",
])
await writer.add(entry, new TextReader("unrecognized bytes"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
const result = projectAndroidApplicationEvidence({
inventory_evidence: [inventory],
});
const projection = androidApplicationProjectionResultSchema.parse(
requireSuccessfulProjection(result).normalized_result,
);
expect(projection.components.dex.map(({ path }) => path).sort()).toEqual([
"classes.DEX",
"extra.dex",
]);
expect(
projection.components.jvm_classes.map(({ path }) => path).sort(),
).toEqual(["Main.CLASS", "Other.class"]);
expect(
projection.components.dex.every(({ format }) => format === "file"),
).toBe(true);
expect(
projection.components.jvm_classes.every(
({ format }) => format === "file",
),
).toBe(true);
expect(projection.runtime_families).toEqual(["dalvik-art", "java-kotlin"]);
expect(projection.limitations).toContain(
"Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid DEX or JVM class bytes.",
);
});
});
describe("case-distinct APK resource names", () => {
it("inventories and projects an APK whose resource names differ only in case", async () => {
const root = await createTestTempDirectory("rea-android-case-");
const path = join(root, "Shrunk.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"AndroidManifest.xml",
new Uint8ArrayReader(Uint8Array.from([3, 0, 8, 0])),
);
await writer.add(
"classes.dex",
new Uint8ArrayReader(
Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]),
),
);
await writer.add("res/2F.xml", new TextReader("upper"));
await writer.add("res/2f.xml", new TextReader("lower"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
const result = projectAndroidApplicationEvidence({
inventory_evidence: [inventory],
});
const projection = androidApplicationProjectionResultSchema.parse(
requireSuccessfulProjection(result).normalized_result,
);
expect(projection.root_format).toBe("apk");
expect(projection.components.manifests).toHaveLength(1);
expect(projection.components.dex).toHaveLength(1);
expect(JSON.stringify(inventory)).toContain("res/2F.xml");
expect(JSON.stringify(inventory)).toContain("res/2f.xml");
expect(JSON.stringify(inventory)).toContain(
"English (en-US) Unicode case folding",
);
});
});
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");
const writer = new ZipWriter(new Uint8ArrayWriter());
const dex = Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]);
for (let index = 0; index < 1_001; index += 1)
await writer.add(
`classes${String(index).padStart(4, "0")}.dex`,
new Uint8ArrayReader(dex),
);
await writer.add("lib/arm64-v8a/libnative.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(1_001);
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.`,
);
});
});