331 lines
13 KiB
TypeScript
331 lines
13 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",
|
|
issues: [
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("root format is zip"),
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
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("APK family without a root manifest", () => {
|
|
const missingManifest =
|
|
"No root AndroidManifest.xml occurrence was observed. The APK family comes from the .apk suffix of a ZIP archive, so these bytes may not be an APK.";
|
|
const projectApk = async (entries: Record<string, string>) => {
|
|
const root = await createTestTempDirectory("rea-android-manifest-");
|
|
const path = join(root, "Plain.apk");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
for (const [entry, text] of Object.entries(entries))
|
|
await writer.add(entry, new TextReader(text));
|
|
await writeFile(path, await writer.close());
|
|
const inventory = parseEvidence(
|
|
await runProviderAnalysis(path, "inventory_artifact", {}),
|
|
);
|
|
return androidApplicationProjectionResultSchema.parse(
|
|
requireSuccessfulProjection(
|
|
projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }),
|
|
).normalized_result,
|
|
);
|
|
};
|
|
|
|
it("says when the APK family rests only on the ZIP filename suffix", async () => {
|
|
expect((await projectApk({ "readme.txt": "hello" })).limitations).toContain(
|
|
missingManifest,
|
|
);
|
|
expect(
|
|
(await projectApk({ "AndroidManifest.xml": "manifest" })).limitations,
|
|
).not.toContain(missingManifest);
|
|
});
|
|
});
|
|
|
|
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.`,
|
|
);
|
|
});
|
|
});
|