390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { expect, it as test } from "vitest";
|
|
|
|
import {
|
|
inspectJadxAvailability,
|
|
resolveJadxConfigurationInputs,
|
|
validateJadxConfiguration,
|
|
} from "../../../src/android/JadxConfiguration.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
|
|
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
|
|
|
|
const it = test.skipIf(process.platform === "win32");
|
|
|
|
const resolveConfiguration = async (
|
|
environment: Parameters<typeof resolveJadxConfigurationInputs>[0],
|
|
operation: Parameters<typeof resolveJadxConfigurationInputs>[1],
|
|
) => {
|
|
const configuration = await resolveJadxConfigurationInputs(
|
|
environment,
|
|
operation,
|
|
);
|
|
await validateJadxConfiguration(configuration, environment, operation);
|
|
return configuration;
|
|
};
|
|
|
|
it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-invalid-jar-");
|
|
const javaHome = await createFakeJdk(root);
|
|
const jar = join(root, "engine.jar");
|
|
for (const malformed of [true, false]) {
|
|
if (malformed) await writeFile(jar, "not a Java archive");
|
|
else await writeOrderedZip(jar, ["unrelated/Library.class"]);
|
|
const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome };
|
|
const availability = await inspectJadxAvailability(environment);
|
|
expect(availability).toMatchObject({
|
|
status: "unavailable",
|
|
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
|
|
diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" },
|
|
});
|
|
await expect(
|
|
resolveConfiguration(environment, "inspect_android_package"),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
|
|
});
|
|
expect(availability.reason).not.toContain("Select a full JDK");
|
|
}
|
|
});
|
|
|
|
it("rejects an explicit JAVA_HOME without an executable java binary", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-config-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
const javaHome = join(root, "missing-jdk");
|
|
await writeJadxJarInventory(jar);
|
|
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining(join(javaHome, "bin", "java")),
|
|
});
|
|
});
|
|
|
|
it("accepts a full JDK and canonicalizes the selected JAR", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-config-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
const javaHome = await createFakeJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
"inspect_android_package",
|
|
),
|
|
).resolves.toEqual({
|
|
jar: await realpath(jar),
|
|
java: join(javaHome, "bin", "java"),
|
|
jvmArguments: [],
|
|
});
|
|
});
|
|
|
|
it("admits engine classes past repeated ZIP resource filenames", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-duplicate-resource-");
|
|
const jar = join(root, "engine.jar");
|
|
const javaHome = await createFakeJdk(root);
|
|
await writeOrderedZip(jar, [
|
|
"META-INF/LICENSE.txt",
|
|
"META-INF/LICENCE.txt",
|
|
"com/atxx/jhmcp/JadxSession.class",
|
|
"com/atxx/jhmcp/SessionHolder.class",
|
|
"jadx/api/JadxDecompiler.class",
|
|
"com/google/gson/Gson.class",
|
|
"io/modelcontextprotocol/kotlin/sdk/server/Server.class",
|
|
]);
|
|
const bytes = await readFile(jar);
|
|
const original = Buffer.from("META-INF/LICENCE.txt");
|
|
let renamed = 0;
|
|
for (
|
|
let offset = bytes.indexOf(original);
|
|
offset >= 0;
|
|
offset = bytes.indexOf(original, offset + original.length)
|
|
) {
|
|
bytes.write("META-INF/LICENSE.txt", offset);
|
|
renamed++;
|
|
}
|
|
expect(renamed).toBe(2);
|
|
await writeFile(jar, bytes);
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
"inspect_android_package",
|
|
),
|
|
).resolves.toMatchObject({ jar: await realpath(jar) });
|
|
});
|
|
|
|
it("configures heap and visible processors independently without forcing either", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-config-");
|
|
const jar = join(root, "engine.jar");
|
|
const javaHome = await createFakeJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
const configuration = await resolveConfiguration(
|
|
{
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
REA_JADX_HEAP_MIB: "8192",
|
|
REA_JADX_ACTIVE_PROCESSOR_COUNT: "4",
|
|
},
|
|
"inspect_android_package",
|
|
);
|
|
expect(configuration.jvmArguments).toEqual([
|
|
"-Xmx8192m",
|
|
"-XX:ActiveProcessorCount=4",
|
|
]);
|
|
await expect(
|
|
resolveConfiguration(
|
|
{
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
REA_JADX_ACTIVE_PROCESSOR_COUNT: "2147483648",
|
|
},
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("32-bit"),
|
|
});
|
|
for (const name of ["REA_JADX_HEAP_MIB", "REA_JADX_ACTIVE_PROCESSOR_COUNT"])
|
|
for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"])
|
|
await expect(
|
|
resolveConfiguration(
|
|
{
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
[name]: value,
|
|
},
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining(name),
|
|
});
|
|
});
|
|
|
|
it("distinguishes a runnable JRE from a missing Java executable", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-runtime-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
await writeJadxJarInventory(jar);
|
|
const jreHome = await createFakeJdk(root, false);
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: jreHome },
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("does not provide jdk.compiler"),
|
|
userMessage: expect.stringContaining("A JRE cannot compile"),
|
|
capturedOutput: {
|
|
stdout: "java.base@21\n",
|
|
stderr: "",
|
|
truncated: false,
|
|
},
|
|
});
|
|
|
|
const emptyPath = join(root, "empty-path");
|
|
await mkdir(emptyPath);
|
|
const availability = await inspectJadxAvailability({
|
|
REA_JADX_MCP_JAR: jar,
|
|
PATH: emptyPath,
|
|
});
|
|
expect(availability).toMatchObject({
|
|
status: "unavailable",
|
|
code: "runtime_missing",
|
|
reason: expect.stringContaining("spawn java ENOENT"),
|
|
diagnostics: {
|
|
java_executable: "java",
|
|
phase: "module-inspection",
|
|
exit_code: null,
|
|
process_error_code: "ENOENT",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves exact Java probe output and marks max-buffer capture as truncated", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-output-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
const javaHome = join(root, "fake-jdk");
|
|
const bin = join(javaHome, "bin");
|
|
const java = join(bin, "java");
|
|
await mkdir(bin, { recursive: true });
|
|
await writeJadxJarInventory(jar);
|
|
await writeFile(
|
|
java,
|
|
`#!/bin/sh\nexec ${process.execPath} -e 'process.stdout.write("x".repeat(2 * 1024 * 1024))'\n`,
|
|
);
|
|
await chmod(java, 0o700);
|
|
|
|
const failure = await resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
"inspect_android_package",
|
|
).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(AnalysisCapabilityUnavailableError);
|
|
if (!(failure instanceof AnalysisCapabilityUnavailableError)) return;
|
|
expect(failure.reason).toContain("exceeded the 1 MiB output budget");
|
|
expect(failure.reason).not.toContain("Select a full JDK");
|
|
expect(failure.capturedOutput).toMatchObject({
|
|
stdout: expect.stringMatching(/^x/u),
|
|
stderr: "",
|
|
truncated: true,
|
|
});
|
|
expect(failure.capturedOutput?.stdout.length).toBeLessThan(2 * 1024 * 1024);
|
|
await expect(
|
|
inspectJadxAvailability({
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: "unavailable",
|
|
code: "version_unresolved",
|
|
diagnostics: {
|
|
exit_code: null,
|
|
process_error_code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
|
failure: "output_limit",
|
|
output_truncated: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("distinguishes an unreported Java version from an unsupported old version", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-version-listing-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
await writeJadxJarInventory(jar);
|
|
|
|
const unresolvedHome = await createFakeJdk(root);
|
|
await writeFile(
|
|
join(unresolvedHome, "bin", "java"),
|
|
"#!/bin/sh\nprintf 'jdk.compiler@21\\n'\n",
|
|
);
|
|
await chmod(join(unresolvedHome, "bin", "java"), 0o700);
|
|
await expect(
|
|
inspectJadxAvailability({
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: unresolvedHome,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: "unavailable",
|
|
code: "version_unresolved",
|
|
reason: expect.stringContaining(
|
|
"did not report a parseable java.base version",
|
|
),
|
|
diagnostics: { stdout: "jdk.compiler@21\n", java_major: null },
|
|
});
|
|
|
|
const oldHome = await createFakeJdk(root);
|
|
await writeFile(
|
|
join(oldHome, "bin", "java"),
|
|
"#!/bin/sh\nprintf 'java.base@16\\njdk.compiler@16\\n'\n",
|
|
);
|
|
await chmod(join(oldHome, "bin", "java"), 0o700);
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: oldHome },
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("must be JDK 17 or newer"),
|
|
});
|
|
});
|
|
|
|
it("cancels an in-flight Java readiness probe", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-cancel-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
const { javaHome, marker } = await createHangingJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
const controller = new AbortController();
|
|
const pending = inspectJadxAvailability(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
controller.signal,
|
|
);
|
|
try {
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
readFile(marker, "utf8").then(
|
|
() => true,
|
|
() => false,
|
|
),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBe(true);
|
|
controller.abort();
|
|
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
|
} finally {
|
|
controller.abort();
|
|
await pending.catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("reports a killed Java probe as unresolved instead of missing runtime", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-timeout-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
const { javaHome, java } = await createHangingJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
|
|
const availability = await inspectJadxAvailability({
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
});
|
|
expect(availability).toMatchObject({
|
|
status: "unavailable",
|
|
code: "version_unresolved",
|
|
reason: expect.stringContaining("within 10 seconds"),
|
|
diagnostics: {
|
|
java_executable: java,
|
|
failure: "probe_terminated",
|
|
killed: true,
|
|
deadline_ms: 10_000,
|
|
},
|
|
});
|
|
expect(availability.reason).not.toContain("Select a full JDK");
|
|
});
|
|
|
|
const createHangingJdk = async (root: string) => {
|
|
const javaHome = join(root, "fake-jdk");
|
|
const bin = join(javaHome, "bin");
|
|
const java = join(bin, "java");
|
|
const marker = join(root, "java-probe-started");
|
|
await mkdir(bin, { recursive: true });
|
|
await writeFile(
|
|
java,
|
|
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
|
|
);
|
|
await chmod(java, 0o700);
|
|
return { javaHome, java, marker };
|
|
};
|
|
|
|
const createFakeJdk = async (
|
|
root: string,
|
|
compiler = true,
|
|
): Promise<string> => {
|
|
const javaHome = join(root, compiler ? "fake-jdk" : "fake-jre");
|
|
const bin = join(javaHome, "bin");
|
|
const java = join(bin, "java");
|
|
await mkdir(bin, { recursive: true });
|
|
await writeFile(
|
|
java,
|
|
`#!/bin/sh\nprintf 'java.base@21${compiler ? "\\njdk.compiler@21" : ""}\\n'\n`,
|
|
);
|
|
await chmod(java, 0o700);
|
|
return javaHome;
|
|
};
|
|
|
|
it("tags unavailable diagnostics with the boundary failure kind", async () => {
|
|
const availability = await inspectJadxAvailability({});
|
|
expect(availability.status).toBe("unavailable");
|
|
if (availability.status !== "unavailable") throw new Error("unreachable");
|
|
expect(availability.diagnostics["failure_kind"]).toBe("jar");
|
|
});
|