Files
reverse-engineering-anything/src/domain/analysisView/androidSymbolView.test.ts
T
gensokyo-devand555aaditya 03f17cf41d feat(android): apply caller-selected R8 mapping evidence (#7456)
Apply caller-selected R8/ProGuard mappings to shared Android inspections, preserving raw identities, exact verification, ambiguity and provenance. Add retained summaries and bounded/cached parsing, with measured real R8/Conscrypt workflows and review fixes.

Fixes #6329.

Co-authored-by: 555aaditya <107977627+555aaditya@users.noreply.github.com>
2026-10-27 15:47:08 +08:00

314 lines
9.0 KiB
TypeScript

import { expect, it, vi } from "vitest";
import { createEvidence } from "../evidence.js";
import { immutableJsonSnapshotMember } from "../immutableJson.js";
import {
androidClassSummaryViewResultSchema,
androidMethodSummaryViewResultSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
import {
androidClassSummarySchema,
projectAndroidSymbolSummary,
} from "./androidSymbolView.js";
type Operation = "inspect_android_class" | "inspect_android_method";
const engine = {
name: "jadx",
version: "0.7.1",
artifact_sha256: "e".repeat(64),
source_revision: null,
worker_count: 1,
heap_limit_mib: null,
};
const method = {
name: "chooseAlias",
reported_signature: "chooseAlias(int): int",
overload_index: 1,
is_constructor: false,
dex_descriptor: "(I)I",
};
const mapping = {
association: "caller-selected",
file: {
path: "/caller-selected/原名 mapping.txt",
sha256: "c".repeat(64),
bytes: 84,
},
reported: {
compiler: "R8",
compiler_version: "8.0.40",
min_api: "21",
map_version: "2.1",
pg_map_id: null,
pg_map_hash: null,
},
records: { classes: 1, methods: 1, fields: 0, malformed: 1 },
malformed: [{ line: 4, reason: "member-line" }],
symbols: [
{
kind: "class",
name: "defpackage.a",
raw_name: "a",
resolution: {
status: "mapped",
mapped: { name: "original.原名" },
verified_by: "class",
},
},
{
kind: "method",
class_raw_name: "a",
name: method.name,
overload_index: 1,
raw_name: "b",
dex_descriptor: method.dex_descriptor,
resolution: { status: "unknown", reason: "projection-budget" },
},
],
coverage: { symbols: 2, mapped: 1, ambiguous: 0, unmapped: 0, unknown: 1 },
};
const classReport = () => ({
engine,
class_name: "defpackage.a",
methods: [
{
...method,
overload_index: 0,
reported_signature: "chooseAlias(String): int",
dex_descriptor: "(Ljava/lang/String;)I",
},
method,
],
fields: [
{
name: "valueAlias",
reported_type: "int",
dex_static_value: { type: "int", value: 7 },
},
],
inner_classes: [],
mapping: {
...mapping,
symbols: [
...mapping.symbols,
{
kind: "method",
class_raw_name: "a",
name: method.name,
overload_index: 0,
raw_name: "c",
dex_descriptor: "(Ljava/lang/String;)I",
resolution: { status: "unmapped" },
},
{
kind: "field",
class_raw_name: "a",
name: "valueAlias",
raw_name: "a",
dex_type: "I",
resolution: { status: "unmapped" },
},
],
coverage: { ...mapping.coverage, symbols: 4, unmapped: 2 },
},
});
const methodReport = () => ({
engine,
class_name: "defpackage.a",
method,
overload_count: 2,
representation: "smali",
body_status: "available",
fell_back: true,
markers: ["source_truncated"],
source: {
status: "partial",
text: "return value; // 😀",
reported_total_bytes: 4096,
},
mapping,
});
const fixture = (operation: Operation, report: unknown, rawMetadata = "") => {
const evidence = createEvidence(
{ path: "/retained/selected.apk", sha256: "a".repeat(64), format: "apk" },
{ id: "jadx", name: "JADX", version: "0.7.1" },
{
operation,
parameters: {
path: "/caller/selected.apk",
mapping_path: mapping.file.path,
},
result: report,
rawResult: {
calls: [
{
operation: "get_class_summary",
response: {
irrelevant_member_metadata: rawMetadata,
},
},
],
},
limitations: [
"The caller-selected mapping is not verified against the APK build.",
],
},
{ immutable: true },
);
const parent: AnalysisViewParent = {
evidenceId: evidence.evidence_id,
operation,
normalizedResult:
immutableJsonSnapshotMember(evidence, "normalized_result") ??
evidence.normalized_result,
subject: evidence.subject,
sealedResult: true,
limitations: evidence.limitations,
};
return { parent, evidence };
};
it.each(["inspect_android_class", "inspect_android_method"] as const)(
"preserves exact %s normalized facts while leaving large raw metadata in complete parent Evidence",
(operation) => {
const facts =
operation === "inspect_android_class" ? classReport() : methodReport();
const { parent, evidence } = fixture(
operation,
facts,
"x".repeat(1024 * 1024 + 128),
);
const before = JSON.stringify(evidence);
const result = projectAnalysisView(parent, { kind: "summary" });
if (!result.ok) throw result.error;
const view =
operation === "inspect_android_class"
? androidClassSummaryViewResultSchema.parse(result.value)
: androidMethodSummaryViewResultSchema.parse(result.value);
expect(view.summary).toEqual(facts);
expect(view.artifact).toEqual({
path: "/retained/selected.apk",
sha256: "a".repeat(64),
});
expect(view.parent_evidence_id).toBe(evidence.evidence_id);
expect(view.parent_digest).toBe(evidence.evidence_id.slice(3));
expect(view.coverage).toEqual({
status: "complete-within-view",
examined: 1,
total: 1,
next_offset: null,
exhausted: true,
});
expect(view.limitations).toEqual(evidence.limitations);
expect(view.summary.mapping).toEqual(facts.mapping);
expect(JSON.stringify(view)).not.toContain("irrelevant_member_metadata");
expect(JSON.stringify(evidence)).toBe(before);
if (operation === "inspect_android_method")
expect(view.unknowns.join(" ")).toContain("source is partial");
},
);
it("preserves native absence and optional historical omission without inventing mapping facts", () => {
const { mapping: _mapping, ...native } = methodReport();
const facts = {
...native,
body_status: "not_available",
markers: ["native_method"],
source: { status: "complete", text: "", reported_total_bytes: 0 },
};
const { parent } = fixture("inspect_android_method", facts);
const result = projectAnalysisView(parent, { kind: "summary" });
if (!result.ok) throw result.error;
const view = androidMethodSummaryViewResultSchema.parse(result.value);
expect(view.summary).toEqual(facts);
expect(Object.hasOwn(view.summary, "mapping")).toBe(false);
});
it.each(["inspect_android_class", "inspect_android_method"] as const)(
"rejects malformed historical %s facts with a typed source error",
(operation) => {
const { parent } = fixture(operation, { engine, class_name: "broken" });
const result = projectAnalysisView(parent, { kind: "summary" });
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: expect.arrayContaining([
expect.objectContaining({
path: expect.arrayContaining(["source", "normalized_result"]),
}),
]),
},
});
},
);
it.each(["inspect_android_class", "inspect_android_method"] as const)(
"requires the recorded APK identity for %s instead of using the engine JAR digest",
(operation) => {
const { parent } = fixture(
operation,
operation === "inspect_android_class" ? classReport() : methodReport(),
);
expect(
projectAnalysisView({ ...parent, subject: null }, { kind: "summary" }),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [{ path: ["source", "subject"] }],
},
});
},
);
it("rejects non-summary selection without silently ignoring caller choices", () => {
const { parent } = fixture("inspect_android_class", classReport());
expect(
projectAndroidSymbolSummary(parent, {
kind: "android",
facet: "components",
offset: 0,
limit: 1,
}),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError", issues: [{ path: ["view"] }] },
});
});
it("refuses an admitted one-MiB source plus summary context without truncation and retains recovery", () => {
const facts = methodReport();
facts.source = {
status: "complete",
text: "x".repeat(1024 * 1024),
reported_total_bytes: 1024 * 1024,
};
const { parent } = fixture("inspect_android_method", facts);
const result = projectAnalysisView(parent, { kind: "summary" });
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisResourceConstraintError",
reportedLimits: {
parent_evidence_id: parent.evidenceId,
limit_bytes: 1024 * 1024,
},
},
});
expect(parent.normalizedResult).toEqual(facts);
});
it("reuses validation of an authenticated sealed class result across retained summaries", () => {
const { parent } = fixture("inspect_android_class", classReport());
const parse = vi.spyOn(androidClassSummarySchema, "safeParse");
try {
for (let attempt = 0; attempt < 2; attempt++)
expect(projectAnalysisView(parent, { kind: "summary" }).ok).toBe(true);
expect(parse).toHaveBeenCalledTimes(1);
} finally {
parse.mockRestore();
}
});