import { createHash } from "node:crypto"; 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, inventoryApplicationPackage, requireSuccessfulProjection, } from "../../../support/applicationSessionFixture.js"; import { projectAndroidApplicationEvidence } from "../../../../src/application/android/AndroidApplicationService.js"; import { androidApplicationProjectionResultSchema } from "../../../../src/domain/android/androidApplication.js"; import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; import { parseEvidence } from "../../../../src/domain/evidence.js"; 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 = await inventoryApplicationPackage(path); 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([ expect.stringMatching( /bridge basis.*inferred.*native path.*every managed component/iu, ), "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 = await inventoryApplicationPackage(path); 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.", ); }); }); it("preserves the selected inventory subject through application projection", async () => { const root = await createTestTempDirectory("rea-android-subject-"); const path = join(root, "Fixture.apk"); const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add("AndroidManifest.xml", new TextReader("manifest")); await writeFile(path, await writer.close()); const inventory = await inventoryApplicationPackage(path); const selected = parseEvidence({ ...inventory, subject: { ...inventory.subject, name: "Selected application" }, }); const result = projectAndroidApplicationEvidence({ inventory_evidence: [selected], }); expect(requireSuccessfulProjection(result).subject).toEqual(selected.subject); }); it("projects APK v1 metadata candidates at any META-INF depth", async () => { const root = await createTestTempDirectory("rea-android-v1-metadata-"); const path = join(root, "NestedSigning.apk"); const writer = new ZipWriter(new Uint8ArrayWriter()); const names = [ "META-INF/CERT.SF", "META-INF/CERT.EC", "META-INF/keys/CERT.SF", "META-INF/keys/CERT.EC", "META-INF/keys/readme.txt", "META-INF/keys/MANIFEST.MF", "assets/not-signing.SF", "meta-inf/lower.SF", ]; for (const entry of names) await writer.add(entry, new TextReader(`content:${entry}`)); await writeFile(path, await writer.close()); const inventory = await inventoryApplicationPackage(path); const projection = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), ).normalized_result, ); expect(projection.components.signing.map(({ path }) => path).sort()).toEqual([ "META-INF/CERT.EC", "META-INF/CERT.SF", "META-INF/keys/CERT.EC", "META-INF/keys/CERT.SF", ]); for (const name of [ "META-INF/CERT.SF", "META-INF/CERT.EC", "META-INF/keys/CERT.SF", "META-INF/keys/CERT.EC", ]) { const component = projection.components.signing.find( ({ path }) => path === name, ); expect(component).toMatchObject({ path: name, artifact_id: expect.stringMatching(/^art_/u), sha256: createHash("sha256").update(`content:${name}`).digest("hex"), }); } expect(projection.limitations).toContain( "Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.", ); }); 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) => { 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 = await inventoryApplicationPackage(path); 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); }); }); it("retains encrypted native library paths as unknown component evidence", async () => { const root = await createTestTempDirectory("rea-android-encrypted-"); const path = join(root, "Encrypted.apk"); const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add( "lib/arm64-v8a/libentry.so", new TextReader("encrypted native library"), { password: "fixture-only" }, ); await writeFile(path, await writer.close()); const inventory = await inventoryApplicationPackage(path); const projection = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), ).normalized_result, ); expect(projection.unavailable_components).toContainEqual( expect.objectContaining({ path: "lib/arm64-v8a/libentry.so", artifact_id: null, sha256: null, encrypted: true, }), ); expect(projection.coverage).toMatchObject({ status: "partial", inventory_complete: true, }); expect(projection.limitations).toContainEqual( expect.stringContaining("component absence is unknown"), ); }); 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 = await inventoryApplicationPackage(path); expect(inventory.normalized_result).toMatchObject({ coverage: { scope: "container-entries", status: "complete" }, }); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); const projection = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection(result).normalized_result, ); expect(projection.root_format).toBe("apk"); expect(projection.coverage).toEqual({ status: "complete-within-inventory", inventory_complete: true, }); expect(projection.limitations.join(" ")).toContain( "case-insensitive destination", ); expect(projection.components.manifests).toHaveLength(1); expect(projection.components.dex).toHaveLength(1); const graph = artifactInventoryResultSchema.parse( inventory.normalized_result, ); for (const logicalPath of ["res/2F.xml", "res/2f.xml"]) { expect(graph.occurrences).toContainEqual( expect.objectContaining({ logical_path: logicalPath, limitations: expect.arrayContaining([ expect.stringContaining("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 = await inventoryApplicationPackage(path); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); if (!result.ok) throw result.error; const projection = androidApplicationProjectionResultSchema.parse( result.value.evidence.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 = await inventoryApplicationPackage(path); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); if (!result.ok) throw result.error; const projection = androidApplicationProjectionResultSchema.parse( result.value.evidence.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.`, ); }); }); it("does not infer a runtime from library-named directories or their text descendants", async () => { const root = await createTestTempDirectory("rea-android-runtime-directory-"); const path = join(root, "Fixture.apk"); const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add("AndroidManifest.xml", new TextReader("fixture")); for (const name of ["libflutter.so", "libunity.so", "libreactnative.so"]) { await writer.add(`assets/${name}/`, undefined, { directory: true }); await writer.add(`assets/${name}/NOTICE.txt`, new TextReader("inert text")); } await writeFile(path, await writer.close()); const inventory = await inventoryApplicationPackage(path); const graph = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), ).normalized_result, ); expect(graph.components.native_libraries).toEqual([]); expect(graph.runtime_families).toEqual([]); expect(graph.coverage.status).toBe("complete-within-inventory"); }); it("retains an encrypted root manifest occurrence without claiming absence", async () => { const root = await createTestTempDirectory("rea-android-locked-manifest-"); const path = join(root, "Locked.apk"); const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add("AndroidManifest.xml", new TextReader("manifest"), { password: "fixture-only", }); await writeFile(path, await writer.close()); const inventory = await inventoryApplicationPackage(path); const graph = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), ).normalized_result, ); expect(graph.components.manifests).toEqual([]); expect(graph.unavailable_components).toContainEqual( expect.objectContaining({ path: "AndroidManifest.xml", encrypted: true, artifact_id: null, sha256: null, }), ); expect(graph.coverage.status).toBe("partial"); expect( graph.limitations.some((text) => text.startsWith("No root AndroidManifest.xml"), ), ).toBe(false); expect(graph.limitations).toContainEqual( expect.stringContaining("component absence is unknown"), ); });