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

278 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]),
),
);
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(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.`,
);
});
});