mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
570abe95f2
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.
1105 lines
36 KiB
JavaScript
1105 lines
36 KiB
JavaScript
/** Public SDK/stdio JNI investigation, checked against independent NDK/DEX facts. */
|
|
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
readFile,
|
|
writeFile,
|
|
mkdir,
|
|
copyFile,
|
|
unlink,
|
|
stat,
|
|
} from "node:fs/promises";
|
|
import { constants } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { parseArgs } from "node:util";
|
|
import { performance } from "node:perf_hooks";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const { values } = parseArgs({
|
|
options: {
|
|
repository: { type: "string" },
|
|
target: { type: "string" },
|
|
output: { type: "string" },
|
|
"java-home": { type: "string" },
|
|
jar: { type: "string" },
|
|
expectations: { type: "string" },
|
|
variant: { type: "string", default: "ordinary" },
|
|
repeats: { type: "string", default: "2" },
|
|
proof: { type: "boolean", default: false },
|
|
detail: { type: "string", default: "complete" },
|
|
"omit-detail": { type: "boolean", default: false },
|
|
pages: { type: "boolean", default: false },
|
|
cli: { type: "boolean", default: false },
|
|
"retained-removal": { type: "boolean", default: false },
|
|
},
|
|
});
|
|
for (const name of [
|
|
"repository",
|
|
"target",
|
|
"output",
|
|
"java-home",
|
|
"jar",
|
|
"expectations",
|
|
])
|
|
assert.ok(values[name], `Missing --${name}`);
|
|
assert.ok(["complete", "summary"].includes(values.detail));
|
|
assert.ok(
|
|
!values["omit-detail"] || values.detail === "summary",
|
|
"--omit-detail verifies the summary default",
|
|
);
|
|
assert.ok(
|
|
values.detail !== "summary" || values.proof,
|
|
"Summary verification requires its full proof export",
|
|
);
|
|
assert.ok(["ordinary", "relr", "aps2", "conscrypt"].includes(values.variant));
|
|
const repeats = Number(values.repeats);
|
|
assert.ok(Number.isSafeInteger(repeats) && repeats >= 1 && repeats <= 20);
|
|
const repo = resolve(values.repository),
|
|
target = resolve(values.target),
|
|
output = resolve(values.output);
|
|
await mkdir(output, { mode: 0o700 });
|
|
const expectations = JSON.parse(
|
|
await readFile(resolve(values.expectations), "utf8"),
|
|
);
|
|
const targetSha = createHash("sha256")
|
|
.update(await readFile(target))
|
|
.digest("hex");
|
|
const expected =
|
|
values.variant === "conscrypt"
|
|
? expectations.conscrypt
|
|
: expectations.variants[values.variant];
|
|
assert.equal(
|
|
targetSha,
|
|
expected.sha256,
|
|
"Selected target must be the independent oracle's exact APK",
|
|
);
|
|
const canonical = (value) =>
|
|
Array.isArray(value)
|
|
? value.map(canonical)
|
|
: value !== null && typeof value === "object"
|
|
? Object.fromEntries(
|
|
Object.keys(value)
|
|
.sort()
|
|
.map((key) => [key, canonical(value[key])]),
|
|
)
|
|
: value;
|
|
const factDigest = (report) => {
|
|
const { engine: _engine, ...facts } = report;
|
|
return createHash("sha256")
|
|
.update(JSON.stringify(canonical(facts)))
|
|
.digest("hex");
|
|
};
|
|
const declarationKey = (row) =>
|
|
JSON.stringify([
|
|
row.class_name ?? row.class,
|
|
row.method_name ?? row.name,
|
|
row.descriptor,
|
|
]);
|
|
const goldenMethods = new Map(
|
|
expectations.dex.native_methods.map((row) => [declarationKey(row), row]),
|
|
);
|
|
const expectedSlots = (candidate, row) => {
|
|
assert.equal(candidate.address, row.function_address);
|
|
if (row.table_address !== undefined)
|
|
assert.equal(candidate.table.table_address, row.table_address);
|
|
if (row.entry_index !== undefined)
|
|
assert.equal(candidate.table.entry.entry_index, row.entry_index);
|
|
assert.equal(candidate.table.entry.function_address, row.function_address);
|
|
assert.equal(candidate.table.entry.name, row.name);
|
|
assert.equal(candidate.table.entry.descriptor, row.descriptor);
|
|
assert.equal(candidate.table.entry.slots.length, 3);
|
|
for (let index = 0; index < 3; index++) {
|
|
const slot = candidate.table.entry.slots[index],
|
|
gold = row.slots[index];
|
|
assert.equal(slot.role, ["name", "signature", "function"][index]);
|
|
assert.equal(slot.address, gold.address);
|
|
assert.equal(slot.file_offset, Number(BigInt(gold.file_offset)));
|
|
assert.equal(slot.value, gold.resolved_word);
|
|
assert.equal(slot.relocation?.encoding, gold.encoding.toLowerCase());
|
|
}
|
|
};
|
|
|
|
function checkFixture(report, query) {
|
|
assert.equal(report.native_inventory.coverage.status, "complete");
|
|
assert.equal(report.library_inventory.status, "complete");
|
|
assert.equal(
|
|
report.declarations.length,
|
|
goldenMethods.size,
|
|
"Selection must not erase global pair ambiguity",
|
|
);
|
|
assert.deepEqual(
|
|
new Set(report.declarations.map(declarationKey)),
|
|
new Set(goldenMethods.keys()),
|
|
);
|
|
const declarations = new Map(
|
|
report.declarations.map((row) => [row.declaration_id, row]),
|
|
);
|
|
const libraries = new Map(
|
|
report.libraries.map((row) => [row.library_id, row]),
|
|
);
|
|
const candidates = new Map(
|
|
report.candidates.map((row) => [row.candidate_id, row]),
|
|
);
|
|
const expectedLibraries = Object.entries(expected.libraries).filter(
|
|
([path]) =>
|
|
(query.abis === undefined || query.abis.includes(path.split("/")[1])) &&
|
|
(query.library_paths === undefined || query.library_paths.includes(path)),
|
|
);
|
|
assert.deepEqual(
|
|
report.libraries.map((row) => row.path).sort(),
|
|
expectedLibraries.map(([path]) => path).sort(),
|
|
);
|
|
for (const library of report.libraries) {
|
|
const gold = expected.libraries[library.path];
|
|
assert.equal(library.sha256, gold.sha256);
|
|
assert.equal(library.abi, library.path.split("/")[1]);
|
|
assert.equal(library.failure, null);
|
|
if (values.variant === "aps2") {
|
|
assert.notEqual(library.coverage.tables_status, "complete");
|
|
assert.ok(library.unknowns.some((text) => /APS2|packed/i.test(text)));
|
|
} else {
|
|
assert.ok(["complete", "partial"].includes(library.coverage.status));
|
|
if (library.coverage.status === "partial")
|
|
assert.ok(library.unknowns.length > 0);
|
|
}
|
|
}
|
|
const selectedMethods = report.declarations.filter(
|
|
(row) =>
|
|
(query.class_name === undefined || row.class_name === query.class_name) &&
|
|
(query.method_names === undefined ||
|
|
query.method_names.includes(row.method_name)) &&
|
|
(query.descriptors === undefined ||
|
|
query.descriptors.includes(row.descriptor)),
|
|
);
|
|
assert.deepEqual(
|
|
report.selected_declaration_ids,
|
|
selectedMethods.map((row) => row.declaration_id),
|
|
);
|
|
const abis = query.abis ?? [
|
|
...new Set(report.libraries.map((row) => row.abi)),
|
|
];
|
|
assert.equal(report.bindings.length, selectedMethods.length * abis.length);
|
|
for (const binding of report.bindings) {
|
|
const declaration = declarations.get(binding.declaration_id);
|
|
assert.ok(selectedMethods.includes(declaration));
|
|
const gold = expected.expected_methods.find(
|
|
(row) =>
|
|
declarationKey(row) === declarationKey(declaration) &&
|
|
row.abi === binding.abi,
|
|
);
|
|
assert.ok(gold);
|
|
const wanted = gold.implementations.filter((row) =>
|
|
expectedLibraries.some(([path]) => path === row.library),
|
|
);
|
|
const observed = binding.candidate_ids.map((id) => candidates.get(id));
|
|
assert.equal(observed.length, wanted.length);
|
|
for (const impl of wanted) {
|
|
const candidate = observed.find(
|
|
(row) =>
|
|
libraries.get(row.library_id)?.path === impl.library &&
|
|
row.address === (impl.address ?? impl.function_address),
|
|
);
|
|
assert.ok(
|
|
candidate,
|
|
`Missing source-known candidate for ${declarationKey(declaration)}`,
|
|
);
|
|
assert.equal(
|
|
libraries.get(candidate.library_id).sha256,
|
|
impl.library_sha256,
|
|
);
|
|
assert.ok(candidate.declaration_ids.includes(binding.declaration_id));
|
|
if (gold.basis === "export") {
|
|
assert.equal(candidate.basis, "jni-export");
|
|
assert.equal(candidate.name, gold.symbol);
|
|
} else {
|
|
assert.equal(candidate.basis, "static-registration-table");
|
|
expectedSlots(candidate, impl);
|
|
if (gold.candidate_classes) {
|
|
assert.equal(candidate.class_attribution, "ambiguous-native-pair");
|
|
assert.deepEqual(
|
|
candidate.declaration_ids
|
|
.map((id) => declarations.get(id).class_name)
|
|
.sort(),
|
|
[...gold.candidate_classes].sort(),
|
|
);
|
|
} else assert.equal(candidate.class_attribution, "unique-native-pair");
|
|
}
|
|
}
|
|
const ambiguousPair = gold.candidate_classes !== undefined;
|
|
const distinct = new Set(
|
|
wanted.map((row) =>
|
|
JSON.stringify([row.library, row.address ?? row.function_address]),
|
|
),
|
|
).size;
|
|
if ((ambiguousPair && wanted.length > 0) || distinct > 1)
|
|
assert.equal(binding.status, "ambiguous");
|
|
else if (distinct === 1)
|
|
assert.equal(
|
|
binding.status,
|
|
"matched",
|
|
"Known candidate presence survives partial coverage",
|
|
);
|
|
else if (binding.coverage !== "complete") {
|
|
assert.equal(
|
|
binding.status,
|
|
"unknown",
|
|
"Incomplete coverage must not establish absence",
|
|
);
|
|
assert.ok(binding.unknowns.length > 0);
|
|
} else assert.equal(binding.status, "unmatched");
|
|
}
|
|
}
|
|
|
|
function checkConscrypt(report, query) {
|
|
const descriptor = "([B[B[B[B[B[B[B[B)J";
|
|
assert.equal(report.native_inventory.coverage.status, "complete");
|
|
const declaration = report.declarations.filter(
|
|
(row) =>
|
|
row.class_name === query.class_name &&
|
|
row.method_name === "EVP_PKEY_new_RSA" &&
|
|
row.descriptor === descriptor,
|
|
);
|
|
assert.equal(declaration.length, 1);
|
|
assert.deepEqual(report.selected_declaration_ids, [
|
|
declaration[0].declaration_id,
|
|
]);
|
|
assert.equal(report.bindings.length, 1);
|
|
const binding = report.bindings[0];
|
|
assert.equal(binding.declaration_id, declaration[0].declaration_id);
|
|
assert.equal(binding.abi, "arm64-v8a");
|
|
assert.equal(binding.status, "matched");
|
|
const library = report.libraries.find(
|
|
(row) => row.path === "lib/arm64-v8a/libconscrypt_jni.so",
|
|
);
|
|
assert.ok(library);
|
|
assert.equal(
|
|
library.sha256,
|
|
expected.libraries.find((row) => row.member === library.path).sha256,
|
|
);
|
|
assert.equal(library.jni_on_load, true);
|
|
const candidate = report.candidates.find(
|
|
(row) =>
|
|
binding.candidate_ids.includes(row.candidate_id) &&
|
|
row.address === "0xfa11c",
|
|
);
|
|
assert.ok(candidate);
|
|
assert.equal(candidate.library_id, library.library_id);
|
|
assert.equal(candidate.basis, "static-registration-table");
|
|
assert.equal(candidate.class_attribution, "unique-native-pair");
|
|
expectedSlots(candidate, {
|
|
name: "EVP_PKEY_new_RSA",
|
|
descriptor,
|
|
function_address: "0xfa11c",
|
|
slots: [
|
|
{
|
|
address: "0x2ada68",
|
|
file_offset: "0x2a5a68",
|
|
resolved_word: "0x749a8",
|
|
encoding: "RELA",
|
|
},
|
|
{
|
|
address: "0x2ada70",
|
|
file_offset: "0x2a5a70",
|
|
resolved_word: "0x7affa",
|
|
encoding: "RELA",
|
|
},
|
|
{
|
|
address: "0x2ada78",
|
|
file_offset: "0x2a5a78",
|
|
resolved_word: "0xfa11c",
|
|
encoding: "RELA",
|
|
},
|
|
],
|
|
});
|
|
assert.equal(
|
|
BigInt(candidate.table.table_address) +
|
|
BigInt(candidate.table.entry.entry_index) * 24n,
|
|
0x2ada68n,
|
|
);
|
|
}
|
|
|
|
function checkSummary(view, report) {
|
|
assert.equal(view.kind, "summary");
|
|
assert.equal(view.parent_operation, "inspect_android_jni_bindings");
|
|
assert.deepEqual(view.artifact, report.apk);
|
|
const summary = view.summary;
|
|
for (const collection of [
|
|
"bindings",
|
|
"declarations",
|
|
"candidates",
|
|
"libraries",
|
|
])
|
|
assert.equal(summary.counts[collection], report[collection].length);
|
|
assert.equal(summary.bindings.length, Math.min(64, report.bindings.length));
|
|
assert.deepEqual(summary.bindings, report.bindings.slice(0, 64));
|
|
assert.equal(view.coverage.total, report.bindings.length);
|
|
assert.deepEqual(summary.native_inventory, report.native_inventory);
|
|
assert.deepEqual(summary.library_inventory, report.library_inventory);
|
|
assert.deepEqual(
|
|
summary.libraries,
|
|
report.libraries,
|
|
"Summary retains diagnostics even without positive candidate edges",
|
|
);
|
|
assert.equal(
|
|
summary.counts.selected_declarations,
|
|
report.selected_declaration_ids.length,
|
|
);
|
|
assert.deepEqual(
|
|
summary.selected_declaration_ids,
|
|
report.selected_declaration_ids.slice(0, 64),
|
|
);
|
|
assert.equal(
|
|
summary.declaration_coverage.total,
|
|
report.selected_declaration_ids.length,
|
|
);
|
|
assert.equal(
|
|
summary.declaration_coverage.examined,
|
|
Math.min(64, report.selected_declaration_ids.length),
|
|
);
|
|
assert.equal(
|
|
summary.declaration_coverage.exhausted,
|
|
report.selected_declaration_ids.length <= 64,
|
|
);
|
|
assert.equal(
|
|
summary.declaration_coverage.next_offset,
|
|
report.selected_declaration_ids.length <= 64 ? null : 64,
|
|
);
|
|
for (const [collection, identity] of [
|
|
["declarations", "declaration_id"],
|
|
["candidates", "candidate_id"],
|
|
["libraries", "library_id"],
|
|
]) {
|
|
const originals = new Map(
|
|
report[collection].map((row) => [row[identity], row]),
|
|
);
|
|
for (const row of summary[collection])
|
|
assert.deepEqual(row, originals.get(row[identity]));
|
|
}
|
|
const declarations = new Set(
|
|
summary.declarations.map((row) => row.declaration_id),
|
|
);
|
|
const libraries = new Set(summary.libraries.map((row) => row.library_id));
|
|
const candidates = new Map(
|
|
summary.candidates.map((row) => [row.candidate_id, row]),
|
|
);
|
|
for (const id of summary.selected_declaration_ids)
|
|
assert.ok(declarations.has(id));
|
|
for (const binding of summary.bindings) {
|
|
assert.ok(declarations.has(binding.declaration_id));
|
|
for (const id of binding.candidate_ids) {
|
|
const candidate = candidates.get(id);
|
|
assert.ok(candidate && libraries.has(candidate.library_id));
|
|
for (const declaration of candidate.declaration_ids)
|
|
assert.ok(declarations.has(declaration));
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkPage(view, report) {
|
|
assert.equal(view.kind, "android-jni");
|
|
assert.equal(view.parent_operation, "inspect_android_jni_bindings");
|
|
assert.deepEqual(view.artifact, report.apk);
|
|
const request = view.view;
|
|
assert.deepEqual(view.source, {
|
|
native_inventory: report.native_inventory,
|
|
library_inventory: report.library_inventory,
|
|
selection: report.selection,
|
|
});
|
|
const accepts = (values, value) =>
|
|
values === undefined || values.includes(value);
|
|
const rows = report[request.collection]
|
|
.map((row, row_index) => ({ ...row, row_index }))
|
|
.filter((row) => {
|
|
if (!accepts(request.indices, row.row_index)) return false;
|
|
switch (request.collection) {
|
|
case "declarations":
|
|
return (
|
|
accepts(request.declaration_ids, row.declaration_id) &&
|
|
accepts(request.descriptors, row.descriptor) &&
|
|
accepts(request.class_names, row.class_name) &&
|
|
accepts(request.method_names, row.method_name)
|
|
);
|
|
case "libraries":
|
|
return (
|
|
accepts(request.library_ids, row.library_id) &&
|
|
accepts(request.abis, row.abi)
|
|
);
|
|
case "candidates":
|
|
return (
|
|
accepts(request.candidate_ids, row.candidate_id) &&
|
|
accepts(request.library_ids, row.library_id) &&
|
|
(request.declaration_ids === undefined ||
|
|
row.declaration_ids.some((id) =>
|
|
request.declaration_ids.includes(id),
|
|
))
|
|
);
|
|
case "bindings":
|
|
return (
|
|
accepts(request.declaration_ids, row.declaration_id) &&
|
|
accepts(request.abis, row.abi) &&
|
|
accepts(request.statuses, row.status)
|
|
);
|
|
default:
|
|
throw new Error(`Unknown retained collection ${request.collection}`);
|
|
}
|
|
});
|
|
const selected = rows
|
|
.slice(request.offset, request.offset + request.limit)
|
|
.map((row) =>
|
|
Object.fromEntries(
|
|
Object.entries(row).filter(
|
|
([key]) =>
|
|
key === "row_index" ||
|
|
request.fields === undefined ||
|
|
request.fields.includes(key),
|
|
),
|
|
),
|
|
);
|
|
assert.deepEqual(view.items, selected);
|
|
assert.equal(view.coverage.total, rows.length);
|
|
assert.equal(view.coverage.examined, selected.length);
|
|
const exhausted =
|
|
request.offset >= rows.length ||
|
|
request.offset + selected.length >= rows.length;
|
|
assert.equal(view.coverage.exhausted, exhausted);
|
|
assert.equal(
|
|
view.coverage.next_offset,
|
|
exhausted ? null : request.offset + selected.length,
|
|
);
|
|
}
|
|
|
|
function collectionPages(preview) {
|
|
const declarationIds = preview.selected_declaration_ids.slice(0, 2);
|
|
const candidateIds = preview.candidates
|
|
.slice(0, 2)
|
|
.map((row) => row.candidate_id);
|
|
assert.ok(declarationIds.length > 0 && candidateIds.length > 0);
|
|
const page = (collection, extra) => ({
|
|
kind: "android-jni",
|
|
collection,
|
|
offset: 0,
|
|
limit: 2,
|
|
...extra,
|
|
});
|
|
return [
|
|
page("bindings", {
|
|
limit: 7,
|
|
fields: ["declaration_id", "abi", "status", "coverage", "candidate_ids"],
|
|
}),
|
|
page("bindings", {
|
|
offset: 1,
|
|
statuses: ["ambiguous"],
|
|
fields: ["declaration_id", "abi", "status"],
|
|
}),
|
|
page("bindings", { statuses: [], fields: ["declaration_id", "coverage"] }),
|
|
page("declarations", {
|
|
offset: 1,
|
|
fields: [
|
|
"declaration_id",
|
|
"class_name",
|
|
"method_name",
|
|
"descriptor",
|
|
"source_index",
|
|
"dex_entry",
|
|
"dex_entry_index",
|
|
"class_index",
|
|
"method_index",
|
|
],
|
|
}),
|
|
page("declarations", {
|
|
class_names: [],
|
|
fields: ["declaration_id", "source_index"],
|
|
}),
|
|
page("libraries", {
|
|
offset: 1,
|
|
fields: [
|
|
"library_id",
|
|
"path",
|
|
"source_path",
|
|
"abi",
|
|
"sha256",
|
|
"source_key",
|
|
"source_index",
|
|
"coverage",
|
|
"failure",
|
|
],
|
|
}),
|
|
page("libraries", { library_ids: [], fields: ["library_id", "coverage"] }),
|
|
page("candidates", {
|
|
indices: [0, 2],
|
|
fields: [
|
|
"candidate_id",
|
|
"library_id",
|
|
"basis",
|
|
"name",
|
|
"descriptor",
|
|
"address",
|
|
"declaration_ids",
|
|
"table",
|
|
],
|
|
}),
|
|
page("candidates", {
|
|
declaration_ids: [],
|
|
fields: ["candidate_id", "table"],
|
|
}),
|
|
page("declarations", { limit: 1, fields: [] }),
|
|
page("declarations", {
|
|
declaration_ids: declarationIds,
|
|
fields: [
|
|
"declaration_id",
|
|
"class_name",
|
|
"method_name",
|
|
"descriptor",
|
|
"dex_entry_index",
|
|
"class_index",
|
|
"method_index",
|
|
"source_index",
|
|
],
|
|
}),
|
|
page("candidates", {
|
|
candidate_ids: candidateIds,
|
|
fields: [
|
|
"candidate_id",
|
|
"library_id",
|
|
"declaration_ids",
|
|
"address",
|
|
"table",
|
|
],
|
|
}),
|
|
];
|
|
}
|
|
|
|
async function qualifyRetainedRemoval(invoke) {
|
|
const ownedCopy = join(output, "owned-retained-source.apk");
|
|
assert.notEqual(ownedCopy, target);
|
|
let copyOwned = false;
|
|
const start = performance.now(),
|
|
firstCall = calls.length;
|
|
try {
|
|
await copyFile(target, ownedCopy, constants.COPYFILE_EXCL);
|
|
copyOwned = true;
|
|
const query =
|
|
values.variant === "conscrypt"
|
|
? { ...queries[0], path: ownedCopy }
|
|
: {
|
|
path: ownedCopy,
|
|
class_name: "fixture.jni.Bindings",
|
|
method_names: ["registeredData"],
|
|
descriptors: ["(I)I"],
|
|
abis: ["arm64-v8a"],
|
|
library_paths: ["lib/arm64-v8a/libfixture.so"],
|
|
};
|
|
const parent = await invoke("inspect_android_jni_bindings", {
|
|
...query,
|
|
detail: "complete",
|
|
});
|
|
assert.equal(parent.operation, "inspect_android_jni_bindings");
|
|
assert.equal(parent.subject.digest.sha256, targetSha);
|
|
assert.equal(parent.normalized_result.apk.path, ownedCopy);
|
|
const report = parent.normalized_result;
|
|
(values.variant === "conscrypt" ? checkConscrypt : checkFixture)(
|
|
report,
|
|
query,
|
|
);
|
|
const before = factDigest(report);
|
|
await unlink(ownedCopy);
|
|
copyOwned = false;
|
|
await assert.rejects(stat(ownedCopy), { code: "ENOENT" });
|
|
// After deletion only retained views and a bundle export are requested.
|
|
// No target-analysis call or caller's original path is used for recovery.
|
|
const source = {
|
|
kind: "retained-evidence",
|
|
evidence_id: parent.evidence_id,
|
|
};
|
|
const summary = await invoke("inspect_analysis_view", {
|
|
source,
|
|
view: { kind: "summary" },
|
|
});
|
|
assert.equal(
|
|
summary.normalized_result.parent_evidence_id,
|
|
parent.evidence_id,
|
|
);
|
|
checkSummary(summary.normalized_result, report);
|
|
const declarations = report.bindings.map(
|
|
(binding) => binding.declaration_id,
|
|
);
|
|
const pages = [
|
|
{
|
|
kind: "android-jni",
|
|
collection: "candidates",
|
|
offset: 0,
|
|
limit: 64,
|
|
declaration_ids: declarations,
|
|
fields: [
|
|
"candidate_id",
|
|
"library_id",
|
|
"basis",
|
|
"name",
|
|
"descriptor",
|
|
"address",
|
|
"declaration_ids",
|
|
"class_attribution",
|
|
"table",
|
|
],
|
|
},
|
|
{
|
|
kind: "android-jni",
|
|
collection: "bindings",
|
|
offset: 0,
|
|
limit: 1,
|
|
fields: [
|
|
"declaration_id",
|
|
"abi",
|
|
"status",
|
|
"coverage",
|
|
"candidate_ids",
|
|
],
|
|
},
|
|
];
|
|
let tableEntries = 0;
|
|
for (const view of pages) {
|
|
const evidence = await invoke("inspect_analysis_view", { source, view });
|
|
assert.equal(
|
|
evidence.normalized_result.parent_evidence_id,
|
|
parent.evidence_id,
|
|
);
|
|
checkPage(evidence.normalized_result, report);
|
|
tableEntries += evidence.normalized_result.items.filter(
|
|
(row) => row.table != null,
|
|
).length;
|
|
}
|
|
if (values.variant !== "aps2")
|
|
assert.ok(
|
|
tableEntries > 0,
|
|
"Relocated slot evidence must remain available after APK removal",
|
|
);
|
|
const proof = join(output, "retained-removal-evidence.json");
|
|
await invoke("export_evidence_bundle", { path: proof });
|
|
const bundle = JSON.parse(await readFile(proof, "utf8"));
|
|
const retained = bundle.records.find(
|
|
(row) => row.evidence_id === parent.evidence_id,
|
|
);
|
|
assert.ok(retained);
|
|
assert.deepEqual(retained, parent);
|
|
assert.equal(factDigest(retained.normalized_result), before);
|
|
assert.equal(
|
|
createHash("sha256")
|
|
.update(await readFile(target))
|
|
.digest("hex"),
|
|
targetSha,
|
|
);
|
|
return {
|
|
name: "retained-removal",
|
|
elapsed_ms: performance.now() - start,
|
|
calls: calls.length - firstCall,
|
|
first_call: firstCall,
|
|
question:
|
|
"After deleting only the harness-owned APK copy, retrieve retained summary, exact candidate slots and selected binding fields, then export unchanged complete parent Evidence.",
|
|
owned_copy: ownedCopy,
|
|
original_path: target,
|
|
original_sha256: targetSha,
|
|
parent_evidence_id: parent.evidence_id,
|
|
fact_digest: before,
|
|
retained_table_rows: tableEntries,
|
|
scope:
|
|
"Observed public SDK success after owned-copy removal; only retained-view/export calls follow deletion. No target code executes; internal engine calls are not directly instrumented by this public harness.",
|
|
};
|
|
} finally {
|
|
if (copyOwned) await unlink(ownedCopy);
|
|
}
|
|
}
|
|
|
|
const require = createRequire(join(repo, "package.json"));
|
|
const { Client } = await import(
|
|
pathToFileURL(require.resolve("@modelcontextprotocol/client"))
|
|
);
|
|
const { StdioClientTransport } = await import(
|
|
pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio"))
|
|
);
|
|
const { Ajv2020 } = await import(
|
|
pathToFileURL(require.resolve("ajv/dist/2020.js"))
|
|
);
|
|
const environment = {
|
|
PATH: process.env.PATH,
|
|
JAVA_HOME: resolve(values["java-home"]),
|
|
REA_JADX_MCP_JAR: resolve(values.jar),
|
|
REA_JADX_HEAP_MIB: "1024",
|
|
REA_JADX_ACTIVE_PROCESSOR_COUNT: "2",
|
|
NODE_OPTIONS: "--max-old-space-size=3072",
|
|
UV_THREADPOOL_SIZE: "2",
|
|
REA_LOG_LEVEL: "silent",
|
|
REA_MCP_TOOL_FAMILIES:
|
|
values.pages || values["retained-removal"]
|
|
? "android,application,session"
|
|
: values.proof
|
|
? "android,session"
|
|
: "android",
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
|
};
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [join(repo, "scripts/rea.mjs"), "mcp"],
|
|
cwd: repo,
|
|
env: environment,
|
|
stderr: "pipe",
|
|
maxBufferSize: 16 * 1024 * 1024,
|
|
});
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk) => {
|
|
if (stderr.length < 65536)
|
|
stderr += chunk.toString().slice(0, 65536 - stderr.length);
|
|
});
|
|
const client = new Client({ name: "rea-real-android-jni", version: "1" });
|
|
const calls = [],
|
|
runs = [],
|
|
parents = [];
|
|
let firstReport;
|
|
let receipt;
|
|
let callPhase = "task";
|
|
let validateJniOutput;
|
|
const queries =
|
|
values.variant === "conscrypt"
|
|
? [
|
|
{
|
|
path: target,
|
|
class_name: "org.conscrypt.NativeCrypto",
|
|
method_names: ["EVP_PKEY_new_RSA"],
|
|
descriptors: ["([B[B[B[B[B[B[B[B)J"],
|
|
abis: ["arm64-v8a"],
|
|
library_paths: ["lib/arm64-v8a/libconscrypt_jni.so"],
|
|
},
|
|
]
|
|
: [
|
|
{ path: target },
|
|
{
|
|
path: target,
|
|
class_name: "fixture.jni.Bindings",
|
|
method_names: ["ambiguous"],
|
|
descriptors: ["(I)I"],
|
|
abis: ["arm64-v8a"],
|
|
},
|
|
{ path: target, method_names: ["registered_表𐐀"], abis: ["arm64-v8a"] },
|
|
{
|
|
path: target,
|
|
method_names: ["sharedExport"],
|
|
abis: ["arm64-v8a"],
|
|
library_paths: ["lib/arm64-v8a/libduplicate.so"],
|
|
},
|
|
];
|
|
try {
|
|
const discoveryStart = performance.now();
|
|
await client.connect(transport);
|
|
const catalog = await client.listTools();
|
|
const startup_catalog_elapsed_ms = performance.now() - discoveryStart;
|
|
await writeFile(join(output, "catalog.json"), JSON.stringify(catalog));
|
|
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
|
const contracts = new Map();
|
|
for (const name of [
|
|
"inspect_android_jni_bindings",
|
|
...(values.proof || values["retained-removal"]
|
|
? ["export_evidence_bundle"]
|
|
: []),
|
|
...(values.pages || values["retained-removal"]
|
|
? ["inspect_analysis_view"]
|
|
: []),
|
|
]) {
|
|
const selected = catalog.tools.filter((tool) => tool.name === name);
|
|
assert.equal(selected.length, 1);
|
|
assert.ok(selected[0].outputSchema);
|
|
contracts.set(name, {
|
|
definition: selected[0],
|
|
input: ajv.compile(selected[0].inputSchema),
|
|
output: ajv.compile(selected[0].outputSchema),
|
|
});
|
|
}
|
|
validateJniOutput = contracts.get("inspect_android_jni_bindings").output;
|
|
const invoke = async (name, args) => {
|
|
const contract = contracts.get(name);
|
|
const sent = { ...args };
|
|
if (
|
|
values["omit-detail"] &&
|
|
name === "inspect_android_jni_bindings" &&
|
|
sent.detail === "summary"
|
|
)
|
|
delete sent.detail;
|
|
assert.ok(contract.input(sent), JSON.stringify(contract.input.errors));
|
|
const start = performance.now();
|
|
const result = await client.callTool(
|
|
{ name, arguments: sent },
|
|
{ toolDefinition: contract.definition, timeout: 120000 },
|
|
);
|
|
calls.push({
|
|
phase: callPhase,
|
|
name,
|
|
arguments: sent,
|
|
result,
|
|
elapsed_ms: performance.now() - start,
|
|
});
|
|
assert.notEqual(result.isError, true, JSON.stringify(result.content));
|
|
assert.ok(
|
|
contract.output(result.structuredContent),
|
|
JSON.stringify(contract.output.errors),
|
|
);
|
|
return result.structuredContent;
|
|
};
|
|
let coldDigests;
|
|
for (let pass = 0; pass < repeats; pass++) {
|
|
const start = performance.now(),
|
|
firstCall = calls.length,
|
|
firstParent = parents.length;
|
|
const digests = [];
|
|
for (const query of queries) {
|
|
const selectedView =
|
|
values.detail === "summary" && values.pages && query === queries[1]
|
|
? {
|
|
kind: "android-jni",
|
|
collection: "bindings",
|
|
offset: 0,
|
|
limit: 1,
|
|
fields: [
|
|
"declaration_id",
|
|
"abi",
|
|
"status",
|
|
"coverage",
|
|
"candidate_ids",
|
|
],
|
|
}
|
|
: undefined;
|
|
const evidence = await invoke("inspect_android_jni_bindings", {
|
|
...query,
|
|
detail: values.detail,
|
|
...(selectedView === undefined ? {} : { view: selectedView }),
|
|
});
|
|
assert.equal(
|
|
evidence.operation,
|
|
values.detail === "summary"
|
|
? "inspect_analysis_view"
|
|
: "inspect_android_jni_bindings",
|
|
);
|
|
assert.equal(evidence.subject.digest.sha256, targetSha);
|
|
const parent = {
|
|
id:
|
|
values.detail === "summary"
|
|
? evidence.normalized_result.parent_evidence_id
|
|
: evidence.evidence_id,
|
|
query,
|
|
normalized:
|
|
values.detail === "complete" ? evidence.normalized_result : undefined,
|
|
view:
|
|
values.detail === "summary" ? evidence.normalized_result : undefined,
|
|
pages: [],
|
|
};
|
|
if (values.detail === "summary") assert.equal(evidence.raw_result, null);
|
|
parents.push(parent);
|
|
if (values.pages && query === queries[0]) {
|
|
const preview =
|
|
values.detail === "summary"
|
|
? evidence.normalized_result.summary
|
|
: evidence.normalized_result;
|
|
for (const view of collectionPages(preview)) {
|
|
const result = await invoke("inspect_analysis_view", {
|
|
source: { kind: "retained-evidence", evidence_id: parent.id },
|
|
view,
|
|
});
|
|
parent.pages.push(result.normalized_result);
|
|
assert.equal(result.normalized_result.parent_evidence_id, parent.id);
|
|
}
|
|
}
|
|
}
|
|
let bundle;
|
|
if (values.proof) {
|
|
const path = join(output, `pass-${pass}-evidence.json`);
|
|
await invoke("export_evidence_bundle", { path });
|
|
bundle = JSON.parse(await readFile(path, "utf8"));
|
|
}
|
|
for (const delivered of parents.slice(firstParent)) {
|
|
let report = delivered.normalized;
|
|
if (values.proof) {
|
|
const parent = bundle.records.find(
|
|
(record) => record.evidence_id === delivered.id,
|
|
);
|
|
assert.ok(parent, "Complete source Evidence must remain exportable");
|
|
assert.equal(parent.operation, "inspect_android_jni_bindings");
|
|
assert.equal(parent.subject.digest.sha256, targetSha);
|
|
if (report !== undefined)
|
|
assert.deepEqual(parent.normalized_result, report);
|
|
report = parent.normalized_result;
|
|
for (const [key, value] of Object.entries(delivered.query))
|
|
assert.deepEqual(parent.parameters[key], value);
|
|
}
|
|
assert.ok(report);
|
|
const { path: _path, ...selection } = delivered.query;
|
|
assert.deepEqual(report.selection, selection);
|
|
assert.equal(report.apk.path, target);
|
|
assert.equal(report.apk.sha256, targetSha);
|
|
(values.variant === "conscrypt" ? checkConscrypt : checkFixture)(
|
|
report,
|
|
delivered.query,
|
|
);
|
|
if (delivered.view?.kind === "android-jni")
|
|
checkPage(delivered.view, report);
|
|
else if (delivered.view) checkSummary(delivered.view, report);
|
|
for (const page of delivered.pages) checkPage(page, report);
|
|
digests.push(factDigest(report));
|
|
firstReport ??= report;
|
|
}
|
|
if (pass === 0) coldDigests = digests;
|
|
else
|
|
assert.deepEqual(
|
|
digests,
|
|
coldDigests,
|
|
"Warm reuse must preserve exact normalized facts",
|
|
);
|
|
runs.push({
|
|
temperature: pass === 0 ? "cold-provider" : "warm-session",
|
|
elapsed_ms: performance.now() - start,
|
|
calls: calls.length - firstCall,
|
|
fact_digests: digests,
|
|
});
|
|
}
|
|
const summary = {
|
|
targetSha,
|
|
variant: values.variant,
|
|
detail: values.detail,
|
|
omit_detail: values["omit-detail"],
|
|
proof: values.proof,
|
|
pages: values.pages,
|
|
question:
|
|
values.variant === "conscrypt"
|
|
? "Resolve NativeCrypto.EVP_PKEY_new_RSA to its static arm64 Conscrypt implementation and retain exact library identity and relocated slot evidence."
|
|
: "Resolve every fixture native declaration by ABI and library; preserve overloads, Unicode, global class ambiguity, duplicate libraries, unmatched methods and relocation uncertainty.",
|
|
baseline:
|
|
"Earlier main has no batch JNI operation. Manual inventory/export lookup cannot recover RegisterNatives with equivalent coverage; no equivalent before-speedup claim.",
|
|
startup_catalog_elapsed_ms,
|
|
elapsed_scope:
|
|
"Per-run task includes investigation calls, selected pages, proof export and local oracle checks. Startup/catalog and final transport cleanup are separate one-time costs. Resource supervisor measures the whole process family, including these phases.",
|
|
runs,
|
|
task_calls: calls.length,
|
|
calls: calls.length,
|
|
qualifications: [],
|
|
};
|
|
receipt = summary;
|
|
await writeFile(
|
|
join(output, "summary.json"),
|
|
JSON.stringify(summary, null, 2),
|
|
);
|
|
await writeFile(join(output, "calls.json"), JSON.stringify(calls));
|
|
if (values["retained-removal"]) {
|
|
callPhase = "qualification";
|
|
summary.qualifications.push(await qualifyRetainedRemoval(invoke));
|
|
summary.calls = calls.length;
|
|
await writeFile(
|
|
join(output, "summary.json"),
|
|
JSON.stringify(summary, null, 2),
|
|
);
|
|
}
|
|
console.log(JSON.stringify(summary));
|
|
} finally {
|
|
const closeStart = performance.now();
|
|
try {
|
|
await client.close();
|
|
} finally {
|
|
if (receipt) {
|
|
receipt.transport_close_elapsed_ms = performance.now() - closeStart;
|
|
await writeFile(
|
|
join(output, "summary.json"),
|
|
JSON.stringify(receipt, null, 2),
|
|
);
|
|
}
|
|
await writeFile(join(output, "calls.json"), JSON.stringify(calls));
|
|
await writeFile(join(output, "stderr.log"), stderr);
|
|
}
|
|
}
|
|
|
|
// CLI parity runs once after the MCP transport has joined its provider cleanup,
|
|
// keeping a second JVM out of the live warm-session measurement and memory set.
|
|
if (values.cli) {
|
|
const { path: _path, ...query } = queries[0];
|
|
const proof = join(output, "cli-evidence.json");
|
|
const args = [
|
|
join(repo, "scripts/rea.mjs"),
|
|
"inspect-android-jni-bindings",
|
|
target,
|
|
"--query",
|
|
JSON.stringify(query),
|
|
"--detail",
|
|
values.detail,
|
|
"--json",
|
|
];
|
|
if (values.detail === "summary") args.push("--evidence_output", proof);
|
|
const page =
|
|
values.pages && values.detail === "summary"
|
|
? {
|
|
kind: "android-jni",
|
|
collection: "bindings",
|
|
offset: 0,
|
|
limit: 7,
|
|
fields: [
|
|
"declaration_id",
|
|
"abi",
|
|
"status",
|
|
"coverage",
|
|
"candidate_ids",
|
|
],
|
|
}
|
|
: undefined;
|
|
if (page) args.push("--view", JSON.stringify(page));
|
|
const start = performance.now();
|
|
let command;
|
|
try {
|
|
command = await promisify(execFile)(process.execPath, args, {
|
|
cwd: repo,
|
|
env: environment,
|
|
encoding: "utf8",
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
timeout: 120000,
|
|
killSignal: "SIGINT",
|
|
});
|
|
} catch (cause) {
|
|
await writeFile(join(output, "cli-stdout.json"), cause.stdout ?? "");
|
|
await writeFile(join(output, "cli-stderr.log"), cause.stderr ?? "");
|
|
throw cause;
|
|
}
|
|
const { stdout, stderr } = command;
|
|
await writeFile(join(output, "cli-stdout.json"), stdout);
|
|
await writeFile(join(output, "cli-stderr.log"), stderr);
|
|
const response = JSON.parse(stdout);
|
|
const evidence = values.detail === "summary" ? response.summary : response;
|
|
assert.ok(
|
|
validateJniOutput(evidence),
|
|
JSON.stringify(validateJniOutput.errors),
|
|
);
|
|
let report = evidence.normalized_result;
|
|
if (values.detail === "summary") {
|
|
const bundle = JSON.parse(await readFile(proof, "utf8"));
|
|
const parent = bundle.records.find(
|
|
(row) =>
|
|
row.evidence_id === evidence.normalized_result.parent_evidence_id,
|
|
);
|
|
assert.ok(parent);
|
|
report = parent.normalized_result;
|
|
if (page) checkPage(evidence.normalized_result, report);
|
|
else checkSummary(evidence.normalized_result, report);
|
|
}
|
|
assert.equal(factDigest(report), factDigest(firstReport));
|
|
await writeFile(
|
|
join(output, "cli-parity.json"),
|
|
JSON.stringify(
|
|
{
|
|
arguments: args,
|
|
elapsed_ms: performance.now() - start,
|
|
fact_digest: factDigest(report),
|
|
response,
|
|
stderr,
|
|
scope:
|
|
"One cold CLI parity check after MCP cleanup; separate from MCP task/token measurements.",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|