Files
reverse-engineering-anything/tests/boundary/android/JadxAnalysis.test.ts
T
morluto 76b56957e9 refactor(android): own search summaries at the producer boundary
Reuse validated class pages for inline pagination audits while retaining complete provider replies. Move retention into the shared Android workflow and remove the MCP-only service wrapper and application text reparser. Preserve unknown JSON metadata, late cancellation and retention failures.
2026-10-23 21:12:42 +08:00

518 lines
14 KiB
TypeScript

import { expect, it } from "vitest";
import {
analyzeJadxRequest,
type JadxToolPort,
} from "../../../src/android/JadxAnalysis.js";
import {
androidInputSchemas,
androidResultSchemas,
} from "../../../src/domain/android/androidAnalysis.js";
const summary = {
full_name: "fixture.Target",
name: "Target",
method_count: 2,
field_count: 0,
inner_class_count: 0,
methods: [
{
name: "choose",
signature: "choose(String): void",
dex_descriptor: "(Ljava/lang/String;)V",
is_constructor: false,
def_pos: 0,
},
{
name: "choose",
signature: "choose(int): void",
dex_descriptor: "(I)V",
is_constructor: false,
def_pos: 0,
},
],
fields: [],
inner_classes: [],
};
const engine = {
name: "jadx",
version: "0.7.1",
artifact_sha256: "a".repeat(64),
source_revision: null,
worker_count: 1,
heap_limit_mib: null,
};
it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => {
const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`);
names[0] = "fixture.TargetDuplicate";
names[4096] = "fixture.TargetDuplicate";
const calls: Record<string, unknown>[] = [];
const tools: JadxToolPort = {
json: () => Promise.reject(new Error("unexpected JSON call")),
listClasses: (input) => {
calls.push({ ...input });
const offset = Number(input.offset);
return Promise.resolve({
scope: "top_level" as const,
total_classes: 10000,
next_offset: offset === 0 ? 8191 : 10000,
offset,
limit: 4096,
items: offset === 0 ? names.slice(0, 4096) : names.slice(4096),
});
},
text: () => Promise.reject(new Error("unexpected text call")),
};
const result = await analyzeJadxRequest(
tools,
{
operation: "search_android_classes",
input: { path: "/fixture.apk", query: "Target" },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 10000,
resource_count: 0,
threads: 1,
resources: "full",
},
engine,
);
expect(calls).toEqual([
{ query: "Target", offset: 0, limit: 4096 },
{ query: "Target", offset: 8191, limit: 4096 },
]);
expect(result).toMatchObject({
total_classes: 10000,
matches: names,
duplicate_names: [{ name: "fixture.TargetDuplicate", occurrences: 2 }],
coverage: "complete",
});
});
it.each([
{ name: "inventory total", query: "Target", page: { total_classes: 9 } },
{ name: "out-of-range cursor", query: "Target", page: { next_offset: 11 } },
{
name: "impossible examined row count",
query: "Target",
page: { next_offset: 1, items: ["TargetA", "TargetB"] },
},
{ name: "offset identity", query: "Target", page: { offset: 1 } },
{ name: "requested page size", query: "Target", page: { limit: 1 } },
{ name: "nonmatching row", query: "Target", page: { items: ["Other"] } },
{
name: "non-progressing page",
query: "Target",
page: { next_offset: 0, items: [] },
},
{ name: "premature page end", query: "Target", page: { next_offset: 9 } },
{ name: "empty-query completeness", query: "", page: {} },
])(
"refuses changed $name instead of reporting complete search coverage",
async ({ query, page }) => {
const tools: JadxToolPort = {
json: () => Promise.reject(new Error("unexpected JSON call")),
listClasses: () =>
Promise.resolve({
scope: "top_level" as const,
total_classes: 10,
next_offset: 10,
offset: 0,
limit: 4096,
items: ["fixture.Target"],
...page,
}),
text: () => Promise.reject(new Error("unexpected text call")),
};
await expect(
analyzeJadxRequest(
tools,
{
operation: "search_android_classes",
input: { path: "/fixture.apk", query },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 10,
resource_count: 0,
threads: 1,
resources: "full",
},
engine,
),
).rejects.toThrow();
},
);
it("accepts an examined empty inventory without requiring cursor progress", async () => {
const tools: JadxToolPort = {
json: () => Promise.reject(new Error("unexpected JSON call")),
listClasses: () =>
Promise.resolve({
scope: "top_level" as const,
total_classes: 0,
offset: 0,
next_offset: 0,
limit: 4096,
items: [],
}),
text: () => Promise.reject(new Error("unexpected text call")),
};
const result = await analyzeJadxRequest(
tools,
{
operation: "search_android_classes",
input: { path: "/fixture.apk", query: "Target" },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 0,
resource_count: 0,
threads: 1,
resources: "full",
},
engine,
);
expect(result).toMatchObject({
total_classes: 0,
matches: [],
duplicate_names: [],
coverage: "complete",
});
});
it("requires explicit direct invoke coverage state in result evidence", () => {
const result = {
engine,
class_name: "fixture.Target",
method_name: null,
overload_index: null,
reported_signature: null,
dex_descriptor: null,
total: 1,
references: [
{
kind: "method",
name: "caller",
reported_full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
source_line: null,
dex_offset: null,
direct_invokes: null,
},
],
coverage: "complete",
reference_unit: "using_node",
};
expect(
androidResultSchemas.trace_android_references.safeParse(result).success,
).toBe(false);
expect(
androidResultSchemas.trace_android_references.safeParse({
...result,
references: [
{ ...result.references[0], direct_invokes_exhaustive: null },
],
}).success,
).toBe(true);
});
it("publishes exact DEX prototypes instead of discarding bridge metadata", async () => {
const tools: JadxToolPort = {
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: () => Promise.resolve(summary),
text: () => Promise.reject(new Error("unexpected text call")),
};
const result = await analyzeJadxRequest(
tools,
{
operation: "inspect_android_class",
input: androidInputSchemas.inspect_android_class.parse({
path: "/fixture.apk",
class_name: "fixture.Target",
}),
},
{} as never,
engine,
);
expect(result).toMatchObject({
methods: [
{ name: "choose", dex_descriptor: "(Ljava/lang/String;)V" },
{ name: "choose", dex_descriptor: "(I)V" },
],
});
});
it("traces only the selected overloaded method and checks the producer identity", async () => {
const calls: Record<string, unknown>[] = [];
const tools: JadxToolPort = {
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: (name, input) => {
calls.push({ name, ...input });
if (name === "get_class_summary") return Promise.resolve(summary);
return Promise.resolve({
target: "fixture.Target.choose",
overload_index: 1,
reported_signature: "choose(int): void",
total: 1,
count: 1,
resolve_line: false,
items: [
{
kind: "method",
name: "caller",
full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
def_pos: 0,
direct_invokes: [
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
},
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 3,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
},
],
},
],
});
},
text: () => Promise.reject(new Error("unexpected text call")),
};
const request = {
operation: "trace_android_references",
input: androidInputSchemas.trace_android_references.parse({
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
overload_index: 1,
}),
} as const;
const result = await analyzeJadxRequest(tools, request, {} as never, engine);
expect(result).toMatchObject({
method_name: "choose",
overload_index: 1,
reported_signature: "choose(int): void",
dex_descriptor: "(I)V",
total: 1,
reference_unit: "using_node",
references: [
{
direct_invokes: [
{ dex_code_unit_offset: 0 },
{ dex_code_unit_offset: 3 },
],
},
],
});
expect(calls[1]).toMatchObject({
name: "get_xrefs_to_method",
overload_index: 1,
});
});
it("rejects an ambiguous method and a producer response for a different overload", async () => {
const xrefs = {
target: "fixture.Target.choose",
overload_index: 0,
reported_signature: "choose(String): void",
total: 0,
count: 0,
resolve_line: false,
items: [],
};
const tools: JadxToolPort = {
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: (name) =>
Promise.resolve(name === "get_class_summary" ? summary : xrefs),
text: () => Promise.reject(new Error("unexpected text call")),
};
const input = {
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
};
await expect(
analyzeJadxRequest(
tools,
{
operation: "trace_android_references",
input,
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisInputError",
});
await expect(
analyzeJadxRequest(
tools,
{
operation: "trace_android_references",
input: { ...input, overload_index: 1 },
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisOutputError",
});
});
const invokeSite = {
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
opcode: "INVOKE_STATIC",
};
const traceWithReference = (fields: Record<string, unknown>) =>
analyzeJadxRequest(
{
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: (name) =>
Promise.resolve(
name === "get_class_summary"
? summary
: {
target: "fixture.Target.choose",
overload_index: 0,
reported_signature: "choose(String): void",
total: 1,
count: 1,
resolve_line: false,
items: [
{
kind: "method",
name: "caller",
full_name: "fixture.Other.caller",
containing_class: "fixture.Other",
top_class: "fixture.Other",
def_pos: 0,
...fields,
},
],
},
),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "trace_android_references",
input: {
path: "/fixture.apk",
class_name: "fixture.Target",
method_name: "choose",
overload_index: 0,
},
},
{} as never,
engine,
);
it.each([
[{}, null],
[{ direct_invokes: [] }, []],
])(
"preserves unavailable versus observed-empty invoke sites: %j",
async (fields, expected) => {
const result = await traceWithReference(fields as Record<string, unknown>);
expect(result).toMatchObject({
references: [
{ direct_invokes: expected, direct_invokes_exhaustive: null },
],
});
},
);
it.each([
{ ...invokeSite, dex_code_unit_offset: -1 },
{
dex_entry: "classes.dex",
source_raw_method_id: "fixture.Other.caller()V",
dex_code_unit_offset: 0,
dex_method_index: 1,
},
])("rejects malformed direct invoke producer evidence: %j", async (site) => {
await expect(
traceWithReference({ direct_invokes: [site] }),
).rejects.toThrow();
});
it("preserves bounded instruction coverage separately from using-node coverage", async () => {
const result = await traceWithReference({
direct_invokes: [invokeSite],
direct_invokes_exhaustive: false,
});
expect(result).toMatchObject({
coverage: "complete",
reference_unit: "using_node",
references: [
{ direct_invokes: [invokeSite], direct_invokes_exhaustive: false },
],
});
});
it.each([
"not-a-dex-descriptor",
"(Lbad(name;)V",
"(Lbad)name;)V",
"()Lbad(name;",
"()Lbad)name;",
])(
"rejects malformed DEX prototype %s at the adapter boundary",
async (descriptor) => {
const invalidSummary = {
...summary,
methods: summary.methods.map((method) => ({
...method,
dex_descriptor: descriptor,
})),
};
await expect(
analyzeJadxRequest(
{
listClasses: () =>
Promise.reject(new Error("unexpected class inventory call")),
json: () => Promise.resolve(invalidSummary),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "inspect_android_class",
input: { path: "/fixture.apk", class_name: "fixture.Target" },
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisOutputError",
cause: {
issues: expect.arrayContaining([
expect.objectContaining({
path: ["methods", 0, "dex_descriptor"],
}),
]),
},
});
},
);