rea/tests/composition/android/application.test.ts

233 lines
8.8 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]),
),
);
await writer.add(
"lib/arm64-v8a/libreactnativejni.so",
new Uint8ArrayReader(
Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]),
),
);
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(1);
expect(left.components.javascript).toHaveLength(1);
expect(left.components.signing).toHaveLength(1);
expect(left.runtime_families).toEqual(
expect.arrayContaining([
"dalvik-art",
"javascript",
"native",
"react-native",
]),
);
expect(left.bridge_candidates).toEqual([
expect.objectContaining({ basis: "react-native-convention" }),
]);
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("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.`,
);
});
});