Files
rea/tests/boundary/android/androidJniLibrary.test.ts
T
gensokyo-dev 570abe95f2 feat(android): link native declarations to static JNI candidates (#7472)
Link original DEX native declarations to per-ABI function exports and relocated static JNI table candidates through shared CLI/MCP workflows. Preserve global attribution ambiguity, exact provenance, partial coverage and retained Evidence; default MCP delivery to bounded summaries with filtered/batch pages.

Use one immutable ELF relocation decoder with streaming IL2CPP materialization, cooperative bounded export/version parsing and bounded JNI state. Reuse validated session inventories without loading target libraries or executing JNI code.

Validation: relevant type/lint/build and parser/domain/public CLI/MCP regressions; genuine four-ABI NDK fixtures and independent library/slot oracles; actual JDK/JADX Conscrypt workflows with retained-source removal. Full measurements and limitations are recorded in docs/verification/android-jni-bindings-2026-10-27.md.

Closes #6403.
2026-10-27 18:53:22 +08:00

432 lines
14 KiB
TypeScript

import { expect, it, vi } from "vitest";
import {
decodeJniModifiedUtf8,
inspectAndroidJniLibrary,
} from "../../../src/native/android/AndroidJniLibrary.js";
import { androidJniLibraryFactsSchema } from "../../../src/domain/android/androidJniLibraryFacts.js";
import { projectAndroidJniBindings } from "../../../src/domain/android/androidJniBindings.js";
import { jniElfFixture } from "../../fixtures/android/jniElf.js";
import { elfAliasedVersionImage } from "../../fixtures/binaryDiagnostics/nativeExports.js";
import * as nativeExportNames from "../../../src/domain/native/nativeExports.js";
it.each(["name", "descriptor"] as const)(
"keeps a %s beyond the string read allowance unknown and preserves later valid entries",
async (facet) => {
const original = jniElfFixture();
const name = facet === "name" ? "m".repeat(65_536) : "call_猫";
const descriptor =
facet === "descriptor" ? `(L${"a".repeat(65_536)};)V` : "()V";
const text = facet === "name" ? name : descriptor;
const bytes = Buffer.concat([original.bytes, Buffer.from(text + "\0")]);
bytes.writeBigUInt64LE(BigInt(bytes.length - 0x400), 152);
bytes.writeBigUInt64LE(BigInt(bytes.length - 0x400), 160);
bytes.writeBigUInt64LE(
original.base + BigInt(original.bytes.length),
0x800 + (facet === "name" ? 0 : 24) + 16,
);
const parsed = await inspectAndroidJniLibrary(bytes);
if (!parsed.ok) throw new Error(parsed.error.message);
expect(parsed.value.coverage.tables_status).toBe("partial");
expect(parsed.value.unknowns.join("\n")).toContain(
"NUL-termination read allowance",
);
expect(
parsed.value.tables
.flatMap((table) => table.entries)
.map((entry) => entry.name),
).toEqual(["overload", "𐐀"]);
const report = projectAndroidJniBindings({
apk: { path: "/selected/large-native.apk", sha256: "a".repeat(64) },
selection: {},
inventory: {
scope: "apk-root-dex-declarations",
methods: [
{
source_index: 0,
class_name: "fixture.Owner",
method_name: name,
descriptor,
dex_entry: "classes.dex",
dex_entry_index: 0,
class_index: 0,
method_index: 0,
},
],
coverage: {
status: "complete",
dex_entries: 1,
examined_dex_entries: 1,
examined_classes: 1,
examined_methods: 1,
},
unknowns: [],
},
libraries: [
{
library_id: "selected-library",
source_index: 0,
source_key: "zip-entry:0",
path: "lib/arm64-v8a/libfixture.so",
source_path: "lib/arm64-v8a/libfixture.so",
abi: "arm64-v8a",
sha256: "b".repeat(64),
size: bytes.length,
declared_size: bytes.length,
compressed_size: bytes.length,
facts: parsed.value,
failure: null,
},
],
libraryInventory: {
status: "complete",
examined_entries: 1,
selected_libraries: 1,
unknowns: [],
},
});
if (!report.ok) throw new Error(report.error.kind);
expect(report.value.bindings[0]).toMatchObject({
status: "unknown",
coverage: "partial",
candidate_ids: [],
});
},
);
it.each([
[64, 183, "rela"],
[64, 62, "rela"],
[32, 40, "rel"],
[32, 3, "rel"],
[64, 183, "relr"],
[64, 62, "relr"],
[32, 40, "relr"],
[32, 3, "relr"],
] as const)(
"decodes %s-bit machine %s %s static tables with complete slot provenance",
async (bits, machine, encoding) => {
const fixture = jniElfFixture({ bits, machine, encoding });
const original = Buffer.from(fixture.bytes);
const result = await inspectAndroidJniLibrary(fixture.bytes);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error.message);
expect(androidJniLibraryFactsSchema.safeParse(result.value).success).toBe(
true,
);
expect(result.value.exports.map((row) => row.name)).toEqual([
"JNI_OnLoad",
"Java_fixture_Owner_exported",
]);
expect(result.value.coverage).toMatchObject({
status: "complete",
relocations_status: "complete",
tables_status: "complete",
});
expect(result.value.tables.map((table) => table.section_name)).toEqual([
".data",
".data.rel.ro",
]);
expect(
result.value.tables
.flatMap((table) => table.entries)
.map((row) => [
row.name,
row.descriptor,
row.function_address,
row.code_address,
row.function_file_offset,
]),
).toEqual([
[
"call_猫",
"()V",
machine === 40 ? "0x10101" : "0x10100",
"0x10100",
0x100,
],
[
"overload",
"(I)I",
machine === 40 ? "0x10111" : "0x10110",
"0x10110",
0x110,
],
[
"𐐀",
"([Ljava/lang/String;)J",
machine === 40 ? "0x10121" : "0x10120",
"0x10120",
0x120,
],
]);
const first = result.value.tables[0]?.entries[0];
expect(
first?.slots.map((slot) => [
slot.role,
slot.address,
slot.file_offset,
slot.value,
slot.relocation?.encoding,
]),
).toEqual([
["name", "0x10500", 0x500, "0x10400", encoding],
[
"signature",
`0x${(0x10500 + fixture.word).toString(16)}`,
0x500 + fixture.word,
"0x10420",
encoding,
],
[
"function",
`0x${(0x10500 + 2 * fixture.word).toString(16)}`,
0x500 + fixture.word * 2,
machine === 40 ? "0x10101" : "0x10100",
encoding,
],
]);
expect(fixture.bytes.equals(original)).toBe(true);
},
);
it("does not limit the writable scan to named or read-only relocation data", async () => {
const result = await inspectAndroidJniLibrary(
jniElfFixture({ unnamedData: true }).bytes,
);
expect(result.ok && result.value.tables[0]).toMatchObject({
section_index: 3,
section_name: "",
table_address: "0x10500",
});
});
it.each([32, 64] as const)(
"keeps later valid entries after a %s-bit unresolved function relocation",
async (bits) => {
const result = await inspectAndroidJniLibrary(
jniElfFixture({ bits, symbolicFirstFunction: true }).bytes,
);
if (!result.ok) throw new Error(result.error.message);
expect(result.value.coverage).toMatchObject({
status: "partial",
relocations_status: "partial",
tables_status: "partial",
});
expect(
result.value.tables
.flatMap((table) => table.entries)
.map((row) => row.name),
).toEqual(["overload", "𐐀"]);
},
);
it("keeps exports and explicit unknown table coverage for Android packed relocations", async () => {
const result = await inspectAndroidJniLibrary(
jniElfFixture({ packed: true }).bytes,
);
if (!result.ok) throw new Error(result.error.message);
expect(result.value.exports).toHaveLength(2);
expect(result.value.tables).toEqual([]);
expect(result.value.coverage).toMatchObject({
exports_status: "complete",
relocations_status: "unknown",
tables_status: "unknown",
});
expect(result.value.unknowns.join("\n")).toContain("APS2");
});
it("rejects a leading RELR bitmap rather than interpreting it as an address", async () => {
const { bytes } = jniElfFixture({ encoding: "relr" });
bytes.writeBigUInt64LE(7n, 0x800);
expect(await inspectAndroidJniLibrary(bytes)).toMatchObject({
ok: false,
error: {
kind: "malformed",
message: "RELR bitmap has no preceding address",
},
});
});
it("preserves later candidates after malformed Modified UTF-8 and a malformed JNI descriptor", async () => {
const { bytes } = jniElfFixture();
bytes.set([0xf0, 0x90, 0x90, 0x80, 0], 0x400);
bytes.write("(Lbad.name;)V\0", 0x460, "utf8");
const result = await inspectAndroidJniLibrary(bytes);
if (!result.ok) throw new Error(result.error.message);
expect(
result.value.tables
.flatMap((table) => table.entries)
.map((row) => row.name),
).toEqual(["𐐀"]);
});
it.each([
[[], ""],
[[0xc0, 0x80], "\0"],
[[0xed, 0xa0, 0x81, 0xed, 0xb0, 0x80], "𐐀"],
[[0xed, 0xa0, 0x80], "\ud800"],
[[0], undefined],
[[0xc0, 0x81], undefined],
[[0xc1, 0xbf], undefined],
[[0xe0, 0x80, 0x80], undefined],
[[0xf0, 0x90, 0x90, 0x80], undefined],
[[0xe7, 0x8c], undefined],
[[0x80], undefined],
] as const)("decodes exact Modified UTF-8 bytes %o", (bytes, value) => {
expect(decodeJniModifiedUtf8(Uint8Array.from(bytes))).toBe(value);
});
it("returns explicit cancellation before decoding and never runs a target", async () => {
const control = new AbortController();
control.abort(new Error("selected cancellation"));
expect(
await inspectAndroidJniLibrary(jniElfFixture().bytes, {
signal: control.signal,
}),
).toMatchObject({ ok: false, error: { kind: "cancelled" } });
});
it.each([32, 64] as const)(
"preserves an observed %s-bit big-endian ELF rather than assigning an Android ABI",
async (bits) => {
const result = await inspectAndroidJniLibrary(
jniElfFixture({ bits, little: false }).bytes,
);
if (!result.ok) throw new Error(result.error.message);
expect(result.value.byte_order).toBe("big");
expect(result.value.tables.flatMap((table) => table.entries)).toHaveLength(
3,
);
expect(androidJniLibraryFactsSchema.safeParse(result.value).success).toBe(
true,
);
},
);
it("cooperatively preserves cancellation at the first parser checkpoint", async () => {
const controller = new AbortController();
const operation = inspectAndroidJniLibrary(jniElfFixture().bytes, {
signal: controller.signal,
});
controller.abort(new Error("cancel at the first scheduled yield"));
expect(await operation).toMatchObject({
ok: false,
error: { kind: "cancelled" },
});
});
it("observes cancellation during GNU version work before decoding export symbols", async () => {
const controller = new AbortController();
const reason = new Error("cancel after version-name decoding began");
const originalDecode = nativeExportNames.decodeElfExportName;
let enteredVersionWork = false;
const decode = vi
.spyOn(nativeExportNames, "decodeElfExportName")
.mockImplementation((bytes) => {
if (bytes.length === 65_535 && !enteredVersionWork) {
enteredVersionWork = true;
queueMicrotask(() => controller.abort(reason));
}
return originalDecode(bytes);
});
try {
const result = await inspectAndroidJniLibrary(
elfAliasedVersionImage("requirements", 65_535),
{ signal: controller.signal },
);
expect(enteredVersionWork).toBe(true);
expect(controller.signal.reason).toBe(reason);
expect(result).toMatchObject({ ok: false, error: { kind: "cancelled" } });
expect(
decode.mock.calls.filter(([bytes]) => bytes.length === 65_535),
).toHaveLength(1);
expect(
decode.mock.calls.some(
([bytes]) => bytes.toString("utf8") === "versioned_export",
),
).toBe(false);
} finally {
decode.mockRestore();
}
});
const appendRelative = (
bytes: Buffer,
destination: bigint,
value: bigint,
kind = 1027,
) => {
const at = 0x800 + 9 * 24;
bytes.writeBigUInt64LE(destination, at);
bytes.writeBigUInt64LE(BigInt(kind), at + 8);
bytes.writeBigUInt64LE(value, at + 16);
bytes.writeBigUInt64LE(10n * 24n, 0xc18);
};
it("keeps conflicting and partially overwritten pointers unknown while retaining later entries", async () => {
for (const conflict of [
"different-relative-value",
"partial-symbolic-write",
] as const) {
const { bytes } = jniElfFixture();
appendRelative(
bytes,
conflict === "different-relative-value" ? 0x10510n : 0x10511n,
0x10110n,
conflict === "different-relative-value" ? 1027 : 258,
);
const result = await inspectAndroidJniLibrary(bytes);
if (!result.ok) throw new Error(result.error.message);
expect(
result.value.tables
.flatMap((table) => table.entries)
.map((entry) => entry.name),
).toEqual(["overload", "𐐀"]);
expect(result.value.coverage.relocations_status).toBe("partial");
}
});
it("does not recover a method name from stored bytes changed by loader relocations", async () => {
const { bytes } = jniElfFixture();
appendRelative(bytes, 0x10400n, 0n);
const result = await inspectAndroidJniLibrary(bytes);
if (!result.ok) throw new Error(result.error.message);
expect(
result.value.tables
.flatMap((table) => table.entries)
.map((entry) => entry.name),
).toEqual(["overload", "𐐀"]);
expect(result.value.coverage.tables_status).toBe("partial");
expect(result.value.unknowns.join("\n")).toContain(
"candidate strings overlap relocation writes",
);
});
it("reports unavailable section load mappings without hiding later writable sections", async () => {
const { bytes } = jniElfFixture();
bytes.writeBigUInt64LE(0x900000n, 0x1400 + 3 * 64 + 16);
const result = await inspectAndroidJniLibrary(bytes);
if (!result.ok) throw new Error(result.error.message);
expect(
result.value.tables
.flatMap((table) => table.entries)
.map((entry) => entry.name),
).toEqual(["𐐀"]);
expect(result.value.coverage.tables_status).toBe("partial");
expect(result.value.unknowns.join("\n")).toContain(
"not file-backed in PT_LOAD",
);
});
it.each(["rel", "rela", "relr"] as const)(
"accepts empty %s tables without demanding a mapping for an empty span",
async (encoding) => {
const { bytes } = jniElfFixture({ encoding });
bytes.writeBigUInt64LE(0n, 0xc08);
bytes.writeBigUInt64LE(0n, 0xc18);
const result = await inspectAndroidJniLibrary(bytes);
if (!result.ok) throw new Error(result.error.message);
expect(result.value.coverage.relocations_status).toBe("complete");
},
);