* fix(process): classify baseline cleanup with current ownership facts Use the pre-launch baseline only to select candidates for current native identity/token validation. Preserve confirmed absence and verified foreign generations instead of carrying a premature baseline identity failure. Exercise the real Linux enumeration/exit race and retain countercontrols for unknown identities and live token-owned processes. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): distinguish process inventory from historical PID lookups * test(ci): verify decoded historical PID controls before tracing * fix(ci): trace historical PID handles and signal probes * fix(ci): resolve traced directory paths across pathname syscalls Classify producer-decoded FD/path pairs once instead of limiting failed relative lookup detection to openat on /proc. This preserves attempted readlinkat, statx and newfstatat accesses with no returned descriptor. Three regression cases fail against the previous policy and all 33 controls pass with the shared pathname interpretation. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * test(ci): declare unresolved legacy pathname coverage Report the historical-PID access surfaces actually checked and explicitly mark legacy cwd-relative pathname calls unverified. The existing tracer does not provide their cwd identity, and a blanket rejection also rejects legitimate GDB build-ID metadata queries. Retain every committed access guard and document the preexisting limit without claiming absence from missing path evidence. The complete real 44-case CLI/MCP recorded-crash qualification passes with the coverage declaration. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): reject priority access to historical PIDs * fix(ci): preserve C-quoted components in traced path checks strace renders non-ASCII pathname bytes as C octal escapes, not JSON strings. Keep those components opaque when resolving literal ASCII path separators and proc PID selectors, matching the existing absolute-path interpretation. Two controls fail before the correction and all 35 pass afterward. An actual strace observation independently reproduces the rejection of an unrelated Chinese filename and verifies the corrected classification. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): reject historical PID resource and scheduler calls * fix(schema): preserve portable large base64 validation * fix(ci): share explicit PID syscall contracts with tracing Derive tracer selection and reported coverage from the same named target contracts used by the access checker. Cover resource limits, scheduler calls, process metadata, PID groups, capabilities and pidfd operations without mixing UIDs, policy enums, self selectors or output values with target PIDs. Interpret each target once and retain unresolved target facts as a distinct failure. Emit the coverage catalog once per verifier result. The previous oracle misses 28 new controls. All 83 controls pass after the change; real native resource/scheduler probes capture 14 previously missed calls with resource limits and affinity unchanged. All 35 selected syscall names are accepted by the pinned local strace. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(ci): classify I/O priority and qualify PID trace coverage * fix(process): keep ownership retries on the native clock * fix(ci): retain process edges in ownership trace inventory Keep fork/vfork and resumed edges along with clone events so the shared attribution owner receives the complete process relation facts it consumes. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) * fix(process): type native exit predicates and ownership fixtures precisely * fix(ci): retain trace coverage limits after integration Restore explicit unverified symlink aliases and same-Node stat/status purpose in the shared coverage contract and guide. The native-exit and trace-attribution integration does not establish those properties. Preserve both limitations rather than silently broadening the reported qualification. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
1239 lines
42 KiB
TypeScript
1239 lines
42 KiB
TypeScript
import { SdkErrorCode } from "@modelcontextprotocol/client";
|
|
import { expect, it as test, onTestFinished, vi } from "vitest";
|
|
import { access, readFile, truncate, writeFile } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
|
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
|
|
import { JadxProvider } from "../../../src/android/JadxProvider.js";
|
|
import { JadxSession } from "../../../src/android/JadxSession.js";
|
|
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { snapshotAndroidEngine } from "../../../src/android/AndroidTargetSnapshot.js";
|
|
import { hashStableFile } from "../../../src/filesystem/StableFileHash.js";
|
|
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
|
import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js";
|
|
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
|
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
|
import {
|
|
createJadxProtocolFixture as setup,
|
|
verifyJadxFixtureCleanup as verifyCleanup,
|
|
} from "../../fixtures/android/jadx.js";
|
|
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const it = test.skipIf(process.platform === "win32");
|
|
|
|
it("retains a failed input hash descriptor until provider shutdown retries it", async () => {
|
|
const { service, apk, provider, launches } = await setup();
|
|
const closeCause = new Error("fixture source hash close unavailable");
|
|
const owners: OwnedFileHandle[] = [];
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
|
owners.push(this);
|
|
throw closeCause;
|
|
})
|
|
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
|
owners.push(this);
|
|
throw closeCause;
|
|
});
|
|
try {
|
|
const result = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
expect(close).toHaveBeenCalledTimes(2);
|
|
expect(owners).toHaveLength(2);
|
|
expect(owners[0]).toBe(owners[1]);
|
|
if (result.ok) throw new Error("Expected incomplete hash cleanup");
|
|
expect(result.error.cause).toBeInstanceOf(AggregateError);
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(close).toHaveBeenCalledTimes(3);
|
|
expect(owners[0]!.handle.fd).toBe(-1);
|
|
expect(launches).toHaveLength(0);
|
|
} finally {
|
|
close.mockRestore();
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
}
|
|
});
|
|
|
|
it("clears hash cleanup uncertainty when the provider immediately retries the same handle", async () => {
|
|
const { service, apk, provider, launches } = await setup();
|
|
const closeCause = new Error("fixture source hash close unavailable");
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockRejectedValueOnce(closeCause)
|
|
.mockImplementation(async function (this: OwnedFileHandle) {
|
|
return originalClose.call(this);
|
|
});
|
|
try {
|
|
const result = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(result).toMatchObject({ ok: false });
|
|
if (result.ok) throw new Error("Expected the hash close failure");
|
|
expect(close).toHaveBeenCalledTimes(2);
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(result.error.cause).toBeInstanceOf(AggregateError);
|
|
expect(launches).toHaveLength(0);
|
|
} finally {
|
|
close.mockRestore();
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
}
|
|
});
|
|
|
|
it("drains hash descriptors even when session shutdown fails and retains its workspace", async () => {
|
|
const { service, apk, provider, launches } = await setup();
|
|
const initial = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!initial.ok) throw initial.error;
|
|
|
|
const workspace = launches[0]?.cwd;
|
|
if (workspace === undefined)
|
|
throw new Error("Expected an acquired workspace");
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
let fileCloseCalls = 0;
|
|
const fileClose = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementation(async function (this: OwnedFileHandle) {
|
|
fileCloseCalls += 1;
|
|
if (fileCloseCalls === 1)
|
|
throw new Error("fixture input hash close failure");
|
|
return originalClose.call(this);
|
|
});
|
|
const sessionClose = vi
|
|
.spyOn(JadxSession.prototype, "close")
|
|
.mockRejectedValueOnce(new Error("fixture session cleanup failure"));
|
|
try {
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
cleanupIncomplete: true,
|
|
cleanupResources: [expect.any(String), workspace],
|
|
},
|
|
});
|
|
if (outcome.ok) throw new Error("Expected retained session cleanup");
|
|
expect(fileCloseCalls).toBe(2);
|
|
await expect(access(workspace)).resolves.toBeUndefined();
|
|
|
|
sessionClose.mockRestore();
|
|
fileClose.mockRestore();
|
|
await provider.close();
|
|
expect(outcome.error.cleanupIncomplete).toBe(false);
|
|
await verifyCleanup(launches);
|
|
} finally {
|
|
sessionClose.mockRestore();
|
|
fileClose.mockRestore();
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
}
|
|
});
|
|
|
|
it("cancels Android admission while hashing the selected APK", async () => {
|
|
const { apk, launches, provider } = await setup();
|
|
const target = await parseBinaryTarget(apk);
|
|
if (!target.ok) throw target.error;
|
|
await writeFile(apk, Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
|
await truncate(apk, 1024 * 1024 * 1024);
|
|
const controller = new AbortController();
|
|
const abortCause = new Error("fixture caller cancelled JADX hashing");
|
|
const timer = setTimeout(() => controller.abort(abortCause), 100);
|
|
try {
|
|
const result = await provider.execute(
|
|
target.value,
|
|
{ operation: "inspect_android_package", input: { path: apk } },
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
if (result.ok) throw new Error("Expected Android admission cancellation");
|
|
expect(result.error.cause).toBe(abortCause);
|
|
expect(launches).toHaveLength(0);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
}
|
|
});
|
|
|
|
it("preserves cancellation cause when hashing succeeded but descriptor close failed", async () => {
|
|
const { apk, launches, provider } = await setup();
|
|
const target = await parseBinaryTarget(apk);
|
|
if (!target.ok) throw target.error;
|
|
const controller = new AbortController();
|
|
const abortCause = new Error("fixture caller cancelled after hash read");
|
|
const closeCause = new Error("fixture hash descriptor close failed");
|
|
const owners: OwnedFileHandle[] = [];
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
|
owners.push(this);
|
|
controller.abort(abortCause);
|
|
throw closeCause;
|
|
})
|
|
.mockImplementation(async function (this: OwnedFileHandle) {
|
|
owners.push(this);
|
|
return originalClose.call(this);
|
|
});
|
|
try {
|
|
const result = await provider.execute(
|
|
target.value,
|
|
{ operation: "inspect_android_package", input: { path: apk } },
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: false },
|
|
});
|
|
if (result.ok) throw new Error("Expected the descriptor close failure");
|
|
expect(close).toHaveBeenCalledTimes(2);
|
|
expect(result.error.cause).toBeInstanceOf(AggregateError);
|
|
const causes = (result.error.cause as AggregateError).errors;
|
|
expect(causes[0]).toMatchObject({ _tag: "AnalysisCancelledError" });
|
|
expect((causes[0] as Error).cause).toBe(abortCause);
|
|
const cleanupFailure = (causes[1] as Error).cause;
|
|
if (!(cleanupFailure instanceof RegularFileCleanupFailure))
|
|
throw new Error("Expected the retained hash cleanup failure");
|
|
expect(cleanupFailure.cleanupCause).toBe(closeCause);
|
|
expect(owners[1]).toBe(owners[0]);
|
|
expect(owners[0]?.handle.fd).toBe(-1);
|
|
expect(launches).toHaveLength(0);
|
|
} finally {
|
|
close.mockRestore();
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
}
|
|
});
|
|
|
|
it("rejects a copied JAR that differs from its admitted session identity", async () => {
|
|
const { jar } = await setup();
|
|
const hashOwners: OwnedFileHandle[] = [];
|
|
let admitted: string;
|
|
try {
|
|
admitted = (
|
|
await hashStableFile(jar, {
|
|
retainFailedClose: (owner) => hashOwners.push(owner),
|
|
})
|
|
).sha256;
|
|
} finally {
|
|
await Promise.all(hashOwners.map((owner) => owner.close()));
|
|
}
|
|
await writeFile(jar, "engine changed between hashing and copying");
|
|
const root = await PrivateRuntimeRoot.create({
|
|
prefix: "rea-jadx-admission-",
|
|
});
|
|
try {
|
|
await expect(
|
|
snapshotAndroidEngine(
|
|
jar,
|
|
admitted,
|
|
root.path,
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("bytes changed"),
|
|
});
|
|
} finally {
|
|
await root.close();
|
|
}
|
|
});
|
|
|
|
it("reuses immutable engine state while keeping each Evidence observation separate", async () => {
|
|
const { service, apk, launches, provider } = await setup();
|
|
const first = await service.execute("inspect_android_package", { path: apk });
|
|
if (!first.ok) throw first.error;
|
|
const preserved = JSON.stringify(first.value);
|
|
const second = await service.execute("inspect_android_class", {
|
|
path: apk,
|
|
class_name: "fixture.Target",
|
|
});
|
|
if (!second.ok) throw second.error;
|
|
expect(launches).toHaveLength(1);
|
|
expect(JSON.stringify(first.value)).toBe(preserved);
|
|
expect(second.value.raw_result).toMatchObject({
|
|
loaded: { class_count: 401 },
|
|
calls: [
|
|
expect.objectContaining({ operation: "rea_jvm_status" }),
|
|
expect.objectContaining({ operation: "get_class_summary" }),
|
|
],
|
|
});
|
|
expect(
|
|
androidResultSchemas.inspect_android_class.parse(
|
|
second.value.normalized_result,
|
|
).engine.heap_limit_mib,
|
|
).toBe(8192);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("bounds output per operation without repeating prerequisite probes for a retained session", async () => {
|
|
const { service, apk, launches, provider, javaProbeCount } =
|
|
await setup("large-manifest");
|
|
const expectedManifest = "x".repeat(1024 * 1024);
|
|
for (let index = 0; index < 34; index += 1) {
|
|
const result = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
expect(
|
|
androidResultSchemas.inspect_android_package.parse(
|
|
result.value.normalized_result,
|
|
).manifest.text,
|
|
).toBe(expectedManifest);
|
|
}
|
|
expect(launches).toHaveLength(1);
|
|
expect(await javaProbeCount()).toBe(1);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("retire sessions when APK bytes, engine bytes or JVM options change", async () => {
|
|
const { service, apk, jar, launches, provider, environment, javaProbeCount } =
|
|
await setup();
|
|
const inspect = async () => {
|
|
const result = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
return result.value;
|
|
};
|
|
const first = await inspect();
|
|
await writeFile(
|
|
apk,
|
|
Buffer.concat([await readFile(apk), Buffer.from("new artifact bytes")]),
|
|
);
|
|
const changed = await inspect();
|
|
expect(changed.subject?.digest.sha256).not.toBe(first.subject?.digest.sha256);
|
|
await verifyCleanup(launches.slice(0, 1));
|
|
await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]);
|
|
await inspect();
|
|
environment.JAVA_TOOL_OPTIONS = "-Xmx8g";
|
|
environment._JAVA_OPTIONS = "-XX:+UseG1GC";
|
|
await inspect();
|
|
expect(launches).toHaveLength(4);
|
|
expect(await javaProbeCount()).toBe(4);
|
|
expect(launches[3]).toMatchObject({
|
|
javaToolOptions: "-Xmx8g",
|
|
legacyJavaOptions: "-XX:+UseG1GC",
|
|
});
|
|
expect(launches[3]?.arguments).not.toContain("-Xmx512m");
|
|
expect(launches[3]?.arguments).not.toContain("-XX:ActiveProcessorCount=1");
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("retires an idle engine and starts a fresh one for a later request", async () => {
|
|
const { service, apk, launches, provider } = await setup();
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
try {
|
|
const first = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!first.ok) throw first.error;
|
|
vi.advanceTimersByTime(60_000);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
await expect
|
|
.poll(async () => {
|
|
try {
|
|
await verifyCleanup(launches);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
.toBe(true);
|
|
const later = await service.execute("inspect_android_package", { path: apk });
|
|
if (!later.ok) throw later.error;
|
|
expect(launches).toHaveLength(2);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("completes large class inventories with exact case-sensitive search semantics", async () => {
|
|
const { service, apk, provider } = await setup("large-inventory");
|
|
const full = await service.execute("search_android_classes", {
|
|
path: apk,
|
|
query: "",
|
|
});
|
|
if (!full.ok) throw full.error;
|
|
const inventory = androidResultSchemas.search_android_classes.parse(
|
|
full.value.normalized_result,
|
|
);
|
|
expect(inventory.matches).toHaveLength(9001);
|
|
expect(inventory.matches.at(-1)).toBe("fixture.Class8999");
|
|
const mixedCase = await service.execute("search_android_classes", {
|
|
path: apk,
|
|
query: "target",
|
|
});
|
|
if (!mixedCase.ok) throw mixedCase.error;
|
|
expect(
|
|
androidResultSchemas.search_android_classes.parse(
|
|
mixedCase.value.normalized_result,
|
|
).matches,
|
|
).toEqual([]);
|
|
await provider.close();
|
|
});
|
|
|
|
it("close cancels active and queued work and prevents later acquisition", async () => {
|
|
const { service, apk, launches, provider } = await setup("stall");
|
|
const active = service.execute("inspect_android_package", { path: apk });
|
|
await expect.poll(() => launches.length).toBe(1);
|
|
const queued = service.execute("inspect_android_package", { path: apk });
|
|
await provider.close();
|
|
for (const result of [
|
|
await active,
|
|
await queued,
|
|
await service.execute("inspect_android_package", { path: apk }),
|
|
])
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect(launches).toHaveLength(1);
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("does not carry a factory instance's cleanup failure into another instance", async () => {
|
|
const failed = await setup("cleanup-failure");
|
|
const healthy = await setup();
|
|
const failure = await failed.service.execute("inspect_android_package", {
|
|
path: failed.apk,
|
|
});
|
|
expect(failure).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
const result = await healthy.service.execute("inspect_android_package", {
|
|
path: healthy.apk,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
await healthy.provider.close();
|
|
await verifyCleanup(healthy.launches);
|
|
});
|
|
|
|
it("retains APK identity, original input, raw producer data and normalized observations", async () => {
|
|
const { service, apk, launches, provider } = await setup();
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!outcome.ok) throw outcome.error;
|
|
const result = androidResultSchemas.inspect_android_package.parse(
|
|
outcome.value.normalized_result,
|
|
);
|
|
expect(result.package_name).toBe("fixture");
|
|
expect(result.engine.source_revision).toBeNull();
|
|
expect(outcome.value.subject?.local_path).toBe(apk);
|
|
expect(outcome.value.parameters).toEqual({ path: apk });
|
|
expect(outcome.value.raw_result).toMatchObject({
|
|
calls: expect.arrayContaining([
|
|
expect.objectContaining({ operation: "get_android_manifest" }),
|
|
]),
|
|
});
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("pages matching classes independently of the total APK class count", async () => {
|
|
const { service, apk, launches, provider } = await setup("large-inventory");
|
|
const outcome = await service.execute("search_android_classes", {
|
|
path: apk,
|
|
query: "Class",
|
|
});
|
|
if (!outcome.ok) throw outcome.error;
|
|
const result = androidResultSchemas.search_android_classes.parse(
|
|
outcome.value.normalized_result,
|
|
);
|
|
expect(result.total_classes).toBe(9001);
|
|
expect(result.matches).toHaveLength(9000);
|
|
expect(result.matches[0]).toBe("fixture.Class0");
|
|
expect(result.matches[4096]).toBe("fixture.Class4096");
|
|
expect(result.matches.at(-1)).toBe("fixture.Class8999");
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("preserves duplicate class-name rows and occurrence counts", async () => {
|
|
const { service, apk, launches, provider } = await setup(
|
|
"duplicate-class-name",
|
|
);
|
|
const outcome = await service.execute("search_android_classes", {
|
|
path: apk,
|
|
query: "fixture.Target",
|
|
});
|
|
if (!outcome.ok) throw outcome.error;
|
|
const result = androidResultSchemas.search_android_classes.parse(
|
|
outcome.value.normalized_result,
|
|
);
|
|
expect(result.total_classes).toBe(402);
|
|
expect(result.matches).toEqual(["fixture.Target", "fixture.Target"]);
|
|
expect(result.duplicate_names).toEqual([
|
|
{ name: "fixture.Target", occurrences: 2 },
|
|
]);
|
|
const full = await service.execute("search_android_classes", {
|
|
path: apk,
|
|
query: "",
|
|
});
|
|
if (!full.ok) throw full.error;
|
|
const fullResult = androidResultSchemas.search_android_classes.parse(
|
|
full.value.normalized_result,
|
|
);
|
|
expect(fullResult.total_classes).toBe(402);
|
|
expect(fullResult.matches).toHaveLength(402);
|
|
expect(fullResult.duplicate_names).toEqual([
|
|
{ name: "fixture.Target", occurrences: 2 },
|
|
]);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("exposes overload candidates and executes only an explicitly selected ambiguous method", async () => {
|
|
const { service, apk, launches, provider } = await setup();
|
|
const input = {
|
|
path: apk,
|
|
class_name: "fixture.Target",
|
|
method_name: "choose",
|
|
};
|
|
const ambiguous = await service.execute("inspect_android_method", input);
|
|
expect(ambiguous).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisInputError",
|
|
issues: [
|
|
{
|
|
path: ["overload_index"],
|
|
message: expect.stringContaining("1: choose(int)"),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const selected = await service.execute("inspect_android_method", {
|
|
...input,
|
|
overload_index: 1,
|
|
});
|
|
if (!selected.ok) throw selected.error;
|
|
const result = androidResultSchemas.inspect_android_method.parse(
|
|
selected.value.normalized_result,
|
|
);
|
|
expect(result.method).toMatchObject({
|
|
overload_index: 1,
|
|
reported_signature: "choose(int): void",
|
|
dex_descriptor: "(I)V",
|
|
});
|
|
expect(result.source.text).toContain("overload 1");
|
|
const references = await service.execute("trace_android_references", input);
|
|
expect(references).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisInputError",
|
|
issues: [expect.objectContaining({ path: ["overload_index"] })],
|
|
},
|
|
});
|
|
const selectedReferences = await service.execute("trace_android_references", {
|
|
...input,
|
|
overload_index: 1,
|
|
});
|
|
if (!selectedReferences.ok) throw selectedReferences.error;
|
|
expect(
|
|
androidResultSchemas.trace_android_references.parse(
|
|
selectedReferences.value.normalized_result,
|
|
),
|
|
).toMatchObject({
|
|
overload_index: 1,
|
|
reported_signature: "choose(int): void",
|
|
});
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it.each([
|
|
"wrong-apk",
|
|
"empty-page",
|
|
"summary-error",
|
|
"wrong-method",
|
|
"wrong-descriptor",
|
|
"partial-xrefs",
|
|
])(
|
|
"rejects producer failure %s rather than claiming complete analysis",
|
|
async (mode) => {
|
|
const { service, apk, launches, provider } = await setup(mode);
|
|
const operation =
|
|
mode === "wrong-apk"
|
|
? "inspect_android_package"
|
|
: mode === "empty-page"
|
|
? "search_android_classes"
|
|
: mode === "partial-xrefs"
|
|
? "trace_android_references"
|
|
: "inspect_android_method";
|
|
const outcome = await service.execute(operation, {
|
|
path: apk,
|
|
...(operation === "search_android_classes"
|
|
? { query: "" }
|
|
: operation === "inspect_android_package"
|
|
? {}
|
|
: { class_name: "fixture.Target", method_name: "onCreate" }),
|
|
});
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisOutputError" },
|
|
});
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
},
|
|
);
|
|
|
|
it("rejects fuzzy class resolution and unsupported upstream versions", async () => {
|
|
for (const mode of ["wrong-class", "version", "engine-version"]) {
|
|
const { service, apk, launches, provider } = await setup(mode);
|
|
const outcome = await service.execute("inspect_android_class", {
|
|
path: apk,
|
|
class_name: "fixture.Target",
|
|
});
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag:
|
|
mode === "version" || mode === "engine-version"
|
|
? "AnalysisCapabilityUnavailableError"
|
|
: "AnalysisInputError",
|
|
},
|
|
});
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
}
|
|
});
|
|
|
|
it.each(["truncated", "smali"])(
|
|
"retains %s source coverage instead of presenting it as successful complete Java",
|
|
async (mode) => {
|
|
const { service, apk, launches, provider } = await setup(mode);
|
|
const outcome = await service.execute("inspect_android_method", {
|
|
path: apk,
|
|
class_name: "fixture.Target",
|
|
method_name: "onCreate",
|
|
});
|
|
if (!outcome.ok) throw outcome.error;
|
|
const result = androidResultSchemas.inspect_android_method.parse(
|
|
outcome.value.normalized_result,
|
|
);
|
|
expect(result.source.status).toBe(
|
|
mode === "truncated" ? "partial" : "complete",
|
|
);
|
|
expect(result.representation).toBe(mode === "smali" ? "smali" : "java");
|
|
if (mode === "truncated")
|
|
expect(result.source.reported_total_bytes).toBe(100);
|
|
else expect(result.fell_back).toBe(true);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
},
|
|
);
|
|
|
|
it("cancels active and queued requests, cleans ownership and never launches a queued cancelled request", async () => {
|
|
const { service, apk, launches, provider } = await setup("stall");
|
|
const activeController = new AbortController();
|
|
const active = service.execute(
|
|
"inspect_android_package",
|
|
{ path: apk },
|
|
{ signal: activeController.signal },
|
|
);
|
|
await expect.poll(() => launches.length).toBe(1);
|
|
const queuedController = new AbortController();
|
|
const queued = service.execute(
|
|
"inspect_android_package",
|
|
{ path: apk },
|
|
{ signal: queuedController.signal },
|
|
);
|
|
queuedController.abort();
|
|
expect(await queued).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect(launches).toHaveLength(1);
|
|
activeController.abort();
|
|
expect(await active).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("ends each queued stalled operation at the caller-selected deadline and cleans ownership", async () => {
|
|
const { service, apk, launches, provider } = await setup(
|
|
"stall",
|
|
undefined,
|
|
undefined,
|
|
{ REA_JADX_OPERATION_TIMEOUT_MS: "1500" },
|
|
);
|
|
const started = performance.now();
|
|
// The second request waits behind the first; its deadline starts at the
|
|
// queue front, so it gets its own engine launch and runtime reply.
|
|
const outcomes = await Promise.all([
|
|
service.execute("inspect_android_package", { path: apk }),
|
|
service.execute("inspect_android_package", { path: apk }),
|
|
]);
|
|
expect(performance.now() - started).toBeLessThan(20_000);
|
|
for (const outcome of outcomes) {
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisTimeoutError",
|
|
timeoutMs: 1500,
|
|
partialObservation: {
|
|
calls: [expect.objectContaining({ operation: "rea_jvm_status" })],
|
|
},
|
|
},
|
|
});
|
|
if (outcome.ok) throw new Error("Expected the selected deadline");
|
|
expect(projectAnalysisError(outcome.error)).toMatchObject({
|
|
code: "provider_timeout",
|
|
details: { timeout_ms: 1500 },
|
|
});
|
|
}
|
|
expect(launches).toHaveLength(2);
|
|
// The deadline path itself releases ownership before provider shutdown.
|
|
await verifyCleanup(launches);
|
|
await provider.close();
|
|
});
|
|
|
|
it("completes an operation under the largest accepted deadline", async () => {
|
|
const { service, apk, provider, launches } = await setup(
|
|
"normal",
|
|
undefined,
|
|
undefined,
|
|
{ REA_JADX_OPERATION_TIMEOUT_MS: "2147483647" },
|
|
);
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(outcome.ok).toBe(true);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it.each(["", "2147483648"])(
|
|
"rejects REA_JADX_OPERATION_TIMEOUT_MS=%j before probing or launching Java",
|
|
async (value) => {
|
|
const { service, apk, launches, javaProbeCount } = await setup(
|
|
"normal",
|
|
undefined,
|
|
undefined,
|
|
{ REA_JADX_OPERATION_TIMEOUT_MS: value },
|
|
);
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("REA_JADX_OPERATION_TIMEOUT_MS"),
|
|
},
|
|
});
|
|
expect(launches).toHaveLength(0);
|
|
// The fake JDK records each module-listing probe; none may have run.
|
|
await expect(javaProbeCount()).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
|
|
it("bounds each upstream call by the selected deadline instead of a fixed limit", async () => {
|
|
// Provider workflows cannot reach the former fixed 120-second call limit in
|
|
// a test, so exercise the session boundary that owns each upstream call.
|
|
const { apk } = await setup();
|
|
const target = await parseBinaryTarget(apk);
|
|
if (!target.ok) throw target.error;
|
|
const workspace = await createTestTempDirectory("rea-jadx-call-deadline-");
|
|
const pids: number[] = [];
|
|
const session = new JadxSession(
|
|
{
|
|
command: process.execPath,
|
|
arguments: [],
|
|
cwd: workspace,
|
|
hostEnvironment: process.env,
|
|
signal: new AbortController().signal,
|
|
},
|
|
async (options) => {
|
|
const spawned = await spawnOwnedProviderProcess({
|
|
...options,
|
|
command: process.execPath,
|
|
arguments: [
|
|
fileURLToPath(
|
|
new URL("../../fixtures/android/jadx-mcp.mjs", import.meta.url),
|
|
),
|
|
],
|
|
expectedCommand: process.execPath,
|
|
env: { ...options.env, REA_FAKE_JADX_MODE: "stall" },
|
|
});
|
|
if (spawned.process.pid !== undefined) pids.push(spawned.process.pid);
|
|
return spawned;
|
|
},
|
|
);
|
|
onTestFinished(() => session.close());
|
|
try {
|
|
const started = performance.now();
|
|
await expect(
|
|
session.execute({
|
|
request: { operation: "inspect_android_package", input: { path: apk } },
|
|
target: target.value,
|
|
snapshot: apk,
|
|
jarHash: "0".repeat(64),
|
|
bridgeHash: "0".repeat(64),
|
|
signal: new AbortController().signal,
|
|
timeoutMs: 300,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: SdkErrorCode.RequestTimeout,
|
|
data: { timeout: 300 },
|
|
});
|
|
expect(performance.now() - started).toBeLessThan(10_000);
|
|
expect(session.partialObservation()?.calls).toEqual([
|
|
expect.objectContaining({ operation: "rea_jvm_status" }),
|
|
]);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
expect(pids).toHaveLength(1);
|
|
for (const pid of pids)
|
|
expect(() => process.kill(-pid, 0)).toThrowError(
|
|
expect.objectContaining({ code: "ESRCH" }),
|
|
);
|
|
});
|
|
|
|
it("reports missing explicit tools without trying to install or launch them", async () => {
|
|
const { apk } = await setup();
|
|
const provider = new JadxProvider({}, () => {
|
|
throw new Error("must not launch");
|
|
});
|
|
const outcome = await new AndroidAnalysisService(provider).execute(
|
|
"inspect_android_package",
|
|
{ path: apk },
|
|
);
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each(["tool-error", "frame-overflow", "body-error", "heap-decoy"])(
|
|
"preserves the actual failed provider constraint for %s",
|
|
async (mode) => {
|
|
const { service, apk, launches, provider } = await setup(mode);
|
|
const outcome = await service.execute(
|
|
mode === "body-error"
|
|
? "inspect_android_method"
|
|
: "inspect_android_package",
|
|
{
|
|
path: apk,
|
|
...(mode === "body-error"
|
|
? { class_name: "fixture.Target", method_name: "onCreate" }
|
|
: {}),
|
|
},
|
|
);
|
|
if (outcome.ok) throw new Error("Expected a provider failure");
|
|
if (mode !== "frame-overflow")
|
|
expect(outcome.error).toMatchObject({
|
|
_tag: "ProviderAdapterError",
|
|
diagnostics: {
|
|
upstream_operation:
|
|
mode === "body-error"
|
|
? "get_method_body"
|
|
: mode === "heap-decoy"
|
|
? "load_apk"
|
|
: "get_android_manifest",
|
|
reason:
|
|
mode === "body-error"
|
|
? "// ERROR: decompile timed out or failed"
|
|
: mode === "heap-decoy"
|
|
? "load failed: cannot read /inputs/Java heap space.apk"
|
|
: "manifest decoder rejected malformed binary XML",
|
|
},
|
|
});
|
|
else
|
|
expect(outcome.error).toMatchObject({
|
|
_tag: "AnalysisOutputError",
|
|
reason: expect.stringContaining("MCP frame exceeds"),
|
|
});
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
},
|
|
);
|
|
|
|
it.each(["heap-failure", "other-memory-failure"])(
|
|
"projects typed JVM memory failure and cleans its process for %s",
|
|
async (mode) => {
|
|
const { service, apk, launches, provider } = await setup(mode);
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (outcome.ok) throw new Error("Expected memory allocation failure");
|
|
expect(outcome.error).toMatchObject({
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "memory",
|
|
retryable: false,
|
|
reportedLimits: {
|
|
observed_max_heap_bytes: 67108864,
|
|
memory_area: mode === "heap-failure" ? "heap" : "unknown",
|
|
},
|
|
diagnostics: { upstream_operation: "load_apk", target_path: apk },
|
|
});
|
|
const projected = projectAnalysisError(outcome.error);
|
|
expect(projected).toMatchObject({
|
|
code: "resource_constraint",
|
|
retryable: false,
|
|
});
|
|
expect(projected.remediation.action).toContain(
|
|
mode === "heap-failure"
|
|
? "REA_JADX_HEAP_MIB"
|
|
: "increasing heap alone may not help",
|
|
);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
},
|
|
);
|
|
|
|
it("retains typed memory failure through failed cleanup and restores it after retry", async () => {
|
|
let fails = true;
|
|
const { service, apk, launches, provider } = await setup(
|
|
"heap-failure",
|
|
() => fails,
|
|
);
|
|
const originalRootClose = PrivateRuntimeRoot.prototype.close;
|
|
const workspaceCloseCause = new Error("fixture workspace close failure");
|
|
let workspaceCloseFails = true;
|
|
let workspaceCloseAttempts = 0;
|
|
const rootClose = vi
|
|
.spyOn(PrivateRuntimeRoot.prototype, "close")
|
|
.mockImplementation(function (this: PrivateRuntimeRoot) {
|
|
// Independent native helpers own their own roots. Keep the acquired
|
|
// Android workspace's failure active until the test explicitly clears it.
|
|
if (this.path === launches[0]?.cwd) {
|
|
workspaceCloseAttempts += 1;
|
|
if (workspaceCloseFails) return Promise.reject(workspaceCloseCause);
|
|
}
|
|
return originalRootClose.call(this);
|
|
});
|
|
try {
|
|
const result = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (result.ok) throw new Error("Expected failed memory cleanup");
|
|
const failure = result.error;
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
expect(projectAnalysisError(failure)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: { primary_error: { code: "resource_constraint" } },
|
|
},
|
|
});
|
|
const workspace = launches[0]?.cwd;
|
|
expect(failure.cleanupResources).toContain(workspace);
|
|
expect(workspaceCloseAttempts).toBe(0);
|
|
fails = false;
|
|
await expect(provider.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
expect(failure.cleanupResources).toEqual([workspace]);
|
|
expect(workspaceCloseAttempts).toBeGreaterThan(0);
|
|
workspaceCloseFails = false;
|
|
await provider.close();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
expect(failure.cleanupResources).toEqual([]);
|
|
expect(projectAnalysisError(failure)).toMatchObject({
|
|
code: "resource_constraint",
|
|
retryable: false,
|
|
remediation: { action: expect.stringContaining("REA_JADX_HEAP_MIB") },
|
|
details: {
|
|
resource: "memory",
|
|
reported_limits: { observed_max_heap_bytes: 67108864 },
|
|
},
|
|
});
|
|
await verifyCleanup(launches);
|
|
} finally {
|
|
rootClose.mockRestore();
|
|
workspaceCloseFails = false;
|
|
fails = false;
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("rejects bytes changed after target admission without launching an engine", async () => {
|
|
const { provider, apk, launches } = await setup();
|
|
const target = await parseBinaryTarget(apk);
|
|
if (!target.ok) throw target.error;
|
|
await writeFile(
|
|
apk,
|
|
Buffer.concat([
|
|
await readFile(apk),
|
|
Buffer.from("changed after admission"),
|
|
]),
|
|
);
|
|
const outcome = await provider.execute(target.value, {
|
|
operation: "inspect_android_package",
|
|
input: { path: apk },
|
|
});
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisInputError",
|
|
issues: [{ message: expect.stringContaining("bytes changed") }],
|
|
},
|
|
});
|
|
expect(launches).toHaveLength(0);
|
|
});
|
|
|
|
it("retains an uncertain workspace and blocks later launches after cleanup cannot be verified", async () => {
|
|
const { service, apk, launches } = await setup("cleanup-failure");
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
const workspace = launches[0]?.cwd;
|
|
if (workspace === undefined) throw new Error("Expected acquired workspace");
|
|
await expect(access(workspace)).resolves.toBeUndefined();
|
|
expect(
|
|
await service.execute("inspect_android_package", { path: apk }),
|
|
).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
|
|
expect(launches).toHaveLength(1);
|
|
});
|
|
|
|
it("resolves every reported shutdown cleanup failure after ownership recovers", async () => {
|
|
let cleanupFails = true;
|
|
const { service, apk, launches, provider } = await setup(
|
|
"normal",
|
|
() => cleanupFails,
|
|
);
|
|
const closeAfterRecovery = async () => {
|
|
cleanupFails = false;
|
|
// Closing the transport can leave the child between live and exited
|
|
// snapshots. Retry retained ownership until cleanup is actually verified.
|
|
await expect
|
|
.poll(() => provider.close(), { timeout: 5_000 })
|
|
.toBeUndefined();
|
|
};
|
|
try {
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!outcome.ok) throw outcome.error;
|
|
const firstClose = provider.close();
|
|
expect(provider.close()).toBe(firstClose);
|
|
const firstFailure = await firstClose.then(
|
|
() => {
|
|
throw new Error("Expected shutdown cleanup to remain incomplete");
|
|
},
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(firstFailure).toMatchObject({ cleanupIncomplete: true });
|
|
expect(firstFailure).toHaveProperty("cause", expect.any(AggregateError));
|
|
const secondFailure = await provider.close().then(
|
|
() => {
|
|
throw new Error(
|
|
"Expected the second cleanup retry to remain incomplete",
|
|
);
|
|
},
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(secondFailure).toMatchObject({ cleanupIncomplete: true });
|
|
expect(secondFailure).toHaveProperty("cause", expect.any(AggregateError));
|
|
const workspace = launches[0]?.cwd;
|
|
if (workspace === undefined) throw new Error("Expected acquired workspace");
|
|
await expect(access(workspace)).resolves.toBeUndefined();
|
|
await closeAfterRecovery();
|
|
expect(firstFailure).toMatchObject({ cleanupIncomplete: false });
|
|
expect(secondFailure).toMatchObject({ cleanupIncomplete: false });
|
|
await verifyCleanup(launches);
|
|
expect(launches).toHaveLength(1);
|
|
} finally {
|
|
await closeAfterRecovery();
|
|
}
|
|
});
|
|
|
|
it.each(["idle", "target-change"])(
|
|
"retries %s retirement before acquiring another engine",
|
|
async (retirement) => {
|
|
let cleanupFails = true;
|
|
let cleanupAttempts = 0;
|
|
const { service, apk, jar, launches, provider } = await setup(
|
|
"normal",
|
|
() => {
|
|
cleanupAttempts += 1;
|
|
return cleanupFails;
|
|
},
|
|
);
|
|
try {
|
|
if (retirement === "idle")
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const first = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!first.ok) throw first.error;
|
|
if (retirement === "idle") {
|
|
vi.advanceTimersByTime(60_000);
|
|
vi.useRealTimers();
|
|
await expect.poll(() => cleanupAttempts).toBeGreaterThan(0);
|
|
} else {
|
|
await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]);
|
|
expect(
|
|
await service.execute("inspect_android_package", { path: apk }),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
}
|
|
const workspace = launches[0]?.cwd;
|
|
if (workspace === undefined)
|
|
throw new Error("Expected acquired workspace");
|
|
await expect(access(workspace)).resolves.toBeUndefined();
|
|
expect(launches).toHaveLength(1);
|
|
cleanupFails = false;
|
|
const retried = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
if (!retried.ok) throw retried.error;
|
|
expect(launches).toHaveLength(2);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
cleanupFails = false;
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retains the execution failure while cleanup is pending and resumes after cleanup recovers", async () => {
|
|
let cleanupFails = true;
|
|
const { service, apk, launches, provider } = await setup(
|
|
"tool-error",
|
|
() => cleanupFails,
|
|
);
|
|
try {
|
|
const outcome = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(outcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
cleanupIncomplete: true,
|
|
diagnostics: {
|
|
primary_error: {
|
|
details: {
|
|
diagnostics: {
|
|
reason: "manifest decoder rejected malformed binary XML",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (outcome.ok) throw new Error("Expected cleanup failure");
|
|
expect(outcome.error.cause).toBeInstanceOf(AggregateError);
|
|
const causes = (outcome.error.cause as AggregateError).errors;
|
|
expect(causes[0]).toMatchObject({
|
|
_tag: "ProviderAdapterError",
|
|
diagnostics: {
|
|
reason: "manifest decoder rejected malformed binary XML",
|
|
},
|
|
});
|
|
expect(
|
|
await service.execute("inspect_android_package", { path: apk }),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
expect(launches).toHaveLength(1);
|
|
cleanupFails = false;
|
|
const retried = await service.execute("inspect_android_package", {
|
|
path: apk,
|
|
});
|
|
expect(retried).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
cleanupIncomplete: false,
|
|
diagnostics: {
|
|
reason: "manifest decoder rejected malformed binary XML",
|
|
},
|
|
},
|
|
});
|
|
expect(launches).toHaveLength(2);
|
|
expect(outcome.error.cleanupIncomplete).toBe(false);
|
|
expect(outcome.error.cleanupResources).toEqual([]);
|
|
expect(projectAnalysisError(outcome.error)).toMatchObject({
|
|
code: "execution_failure",
|
|
details: {
|
|
diagnostics: {
|
|
reason: "manifest decoder rejected malformed binary XML",
|
|
},
|
|
},
|
|
});
|
|
await verifyCleanup(launches);
|
|
} finally {
|
|
cleanupFails = false;
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("accepts exact selected-overload smali from the bridge", async () => {
|
|
const { service, apk, launches, provider } = await setup(
|
|
"selected-overload-smali",
|
|
);
|
|
const outcome = await service.execute("inspect_android_method", {
|
|
path: apk,
|
|
class_name: "fixture.Target",
|
|
method_name: "choose",
|
|
overload_index: 1,
|
|
});
|
|
if (!outcome.ok) throw outcome.error;
|
|
const result = androidResultSchemas.inspect_android_method.parse(
|
|
outcome.value.normalized_result,
|
|
);
|
|
expect(result).toMatchObject({
|
|
method: { overload_index: 1, dex_descriptor: "(I)V" },
|
|
representation: "smali",
|
|
body_status: "available",
|
|
});
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|
|
|
|
it("retains parsed native body absence without claiming an empty implementation", async () => {
|
|
const { service, apk, launches, provider } = await setup("native-method");
|
|
const outcome = await service.execute("inspect_android_method", {
|
|
path: apk,
|
|
class_name: "fixture.Target",
|
|
method_name: "onCreate",
|
|
});
|
|
if (!outcome.ok) throw outcome.error;
|
|
const result = androidResultSchemas.inspect_android_method.parse(
|
|
outcome.value.normalized_result,
|
|
);
|
|
expect(result.body_status).toBe("not_available");
|
|
expect(result.source.text).toBe("");
|
|
expect(result.markers).toEqual(["native_method"]);
|
|
await provider.close();
|
|
await verifyCleanup(launches);
|
|
});
|