2026-07-22 20:38:26 +08:00
import { writeFile } from "node:fs/promises" ;
2026-07-20 11:04:57 +00:00
import { join } from "node:path" ;
import {
TextReader ,
Uint8ArrayReader ,
Uint8ArrayWriter ,
ZipWriter ,
} from "@zip.js/zip.js" ;
import { describe , expect , it } from "vitest" ;
2026-07-29 22:00:38 +00:00
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js" ;
2026-10-06 05:00:16 +08:00
import {
createNonApplicationZipInventory ,
requireSuccessfulProjection ,
} from "../../support/applicationSessionFixture.js" ;
2026-07-22 20:38:26 +08:00
2026-10-07 14:09:57 +08:00
import { projectAndroidApplicationEvidence } from "../../../src/application/android/AndroidApplicationService.js" ;
2026-10-09 20:02:52 +08:00
import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js" ;
2026-10-07 14:09:57 +08:00
import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js" ;
2026-07-29 22:00:38 +00:00
import { parseEvidence } from "../../../src/domain/evidence.js" ;
2026-07-20 11:04:57 +00:00
2026-10-09 20:02:52 +08:00
const { runProviderAnalysis } = createDirectAnalysis ({});
2026-07-20 11:04:57 +00:00
describe ( "Android application projection" , () => {
it ( "projects deterministic APK components and explicit bridge hypotheses" , async () => {
2026-07-22 20:38:26 +08:00
const root = await createTestTempDirectory ( "rea-android-" );
2026-07-20 11:04:57 +00:00
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 ]),
),
);
2026-10-10 17:14:47 +08:00
const elf = () =>
2026-07-20 11:04:57 +00:00
new Uint8ArrayReader (
Uint8Array . from ([ 0x7f , 0x45 , 0x4c , 0x46 , 2 , 1 , 1 , 0 ]),
2026-10-10 17:14:47 +08:00
);
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 ());
2026-07-20 11:04:57 +00:00
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 (
2026-09-29 07:08:46 +08:00
await runProviderAnalysis ( path , "inventory_artifact" , {}),
2026-07-20 11:04:57 +00:00
);
const first = projectAndroidApplicationEvidence ({
inventory_evidence : [ inventory ],
});
const second = projectAndroidApplicationEvidence ({
inventory_evidence : [ inventory ],
});
const left = androidApplicationProjectionResultSchema . parse (
2026-10-06 05:00:16 +08:00
requireSuccessfulProjection ( first ). normalized_result ,
2026-07-20 11:04:57 +00:00
);
const right = androidApplicationProjectionResultSchema . parse (
2026-10-06 05:00:16 +08:00
requireSuccessfulProjection ( second ). normalized_result ,
2026-07-20 11:04:57 +00:00
);
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 );
2026-10-10 17:14:47 +08:00
expect ( left . components . native_libraries ). toHaveLength ( 4 );
2026-07-20 11:04:57 +00:00
expect ( left . components . javascript ). toHaveLength ( 1 );
expect ( left . components . signing ). toHaveLength ( 1 );
expect ( left . runtime_families ). toEqual (
expect . arrayContaining ([
"dalvik-art" ,
2026-10-10 17:14:47 +08:00
"flutter" ,
2026-07-20 11:04:57 +00:00
"javascript" ,
"native" ,
"react-native" ,
2026-10-10 17:14:47 +08:00
"unity" ,
2026-07-20 11:04:57 +00:00
]),
);
2026-10-10 17:14:47 +08:00
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 );
2026-10-10 15:21:16 +08:00
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." ,
]),
);
2026-07-20 11:04:57 +00:00
expect ( JSON . stringify ( left )). not . toContain ( "opaque signing" );
});
it ( "rejects non-APK inventory Evidence" , async () => {
2026-10-06 05:00:16 +08:00
const inventory = await createNonApplicationZipInventory (
"rea-android-invalid-" ,
2026-07-20 11:04:57 +00:00
);
expect (
projectAndroidApplicationEvidence ({ inventory_evidence : [ inventory ] }),
). toMatchObject ({
ok : false ,
2026-10-10 06:17:40 -07:00
error : {
_tag : "AnalysisInputError" ,
issues : [
expect . objectContaining ({
message : expect.stringContaining ( "root format is zip" ),
}),
],
},
2026-07-20 11:04:57 +00:00
});
});
2026-09-29 12:42:08 +08:00
2026-10-07 23:00:22 +08:00
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." ,
);
});
2026-10-09 05:09:17 +08:00
});
2026-10-07 23:00:22 +08:00
2026-10-10 06:17:40 -07:00
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 );
});
});
2026-10-10 15:21:16 +08:00
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" ,
);
});
});
2026-10-09 05:09:17 +08:00
describe ( "Android bridge candidate coverage" , () => {
2026-09-29 12:42:08 +08:00
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 ],
});
2026-10-09 03:13:43 +08:00
if ( ! result . ok ) throw result . error ;
2026-09-29 12:42:08 +08:00
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" );
});
2026-10-09 05:09:17 +08:00
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.` ,
);
});
2026-07-20 11:04:57 +00:00
});