chore(release): preserve concurrent main updates
This commit is contained in:
commit
2609352eef
|
|
@ -23,6 +23,14 @@ export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar
|
|||
export JAVA_HOME=/absolute/path/existing-jdk
|
||||
```
|
||||
|
||||
Readiness checks the selected JAR's ZIP directory for the classes consumed by
|
||||
REA's metadata bridge, as well as Java's compiler and version modules. It does
|
||||
not load an APK or execute engine code. Invalid archives and missing bridge
|
||||
classes report JAR-specific recovery; classpath inventory alone does not prove
|
||||
that every class or engine operation will load successfully. Discovery limits
|
||||
each ZIP metadata read to 8 MiB before allocation; a larger directory reports
|
||||
the exhausted read budget and the selected JAR path.
|
||||
|
||||
The current metadata bridge is verified on macOS arm64 with OpenJDK 21 and the
|
||||
public Appium ApiDemos fixture. The POSIX adapter also supports Linux; the new
|
||||
bridge has not yet undergone real verification on Linux/JDK 25.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { access, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
|
||||
import {
|
||||
access,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
|
|
@ -67,6 +74,8 @@ export async function verifyAndroidFailureRecovery({
|
|||
}
|
||||
const withoutJavaHome = { ...environment };
|
||||
delete withoutJavaHome.JAVA_HOME;
|
||||
const invalidJar = join(root, "invalid.jar");
|
||||
await writeFile(invalidJar, "not a Java archive");
|
||||
const environments = [
|
||||
{
|
||||
label: "JRE without compiler",
|
||||
|
|
@ -78,6 +87,12 @@ export async function verifyAndroidFailureRecovery({
|
|||
env: { ...withoutJavaHome, PATH: empty },
|
||||
observation: "ENOENT",
|
||||
},
|
||||
{
|
||||
label: "invalid engine archive with a working JDK",
|
||||
env: { ...environment, REA_JADX_MCP_JAR: invalidJar },
|
||||
observation: "Cannot inspect REA_JADX_MCP_JAR",
|
||||
jarFailure: true,
|
||||
},
|
||||
];
|
||||
if (process.platform === "darwin") {
|
||||
// /usr/bin/java either resolves an installed runtime or reports the actual stub failure.
|
||||
|
|
@ -114,8 +129,16 @@ export async function verifyAndroidFailureRecovery({
|
|||
JSON.stringify(failure).includes(scenario.observation),
|
||||
JSON.stringify(failure),
|
||||
);
|
||||
assert.match(failure.message, /full JDK/u);
|
||||
assert.match(failure.remediation.action, /java --list-modules/u);
|
||||
assert.match(
|
||||
failure.message,
|
||||
scenario.jarFailure ? /REA_JADX_MCP_JAR/u : /full JDK/u,
|
||||
);
|
||||
assert.match(
|
||||
failure.remediation.action,
|
||||
scenario.jarFailure ? /jadx-headless-mcp/u : /java --list-modules/u,
|
||||
);
|
||||
if (scenario.jarFailure)
|
||||
assert.doesNotMatch(failure.message, /Select a full JDK/u);
|
||||
assert.doesNotMatch(JSON.stringify(failure), /rea doctor/u);
|
||||
await verifyAndroidReadiness({
|
||||
entrypoint,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.
|
|||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
|
|
@ -13,6 +14,9 @@ import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
|
|||
|
||||
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
|
||||
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
// Bound ZIP metadata allocation during discovery. The audited 0.7.1 JAR's
|
||||
// central directory occupies 2,294,350 bytes; class bodies are not read here.
|
||||
const JAR_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Caller-supplied tools, resolved only when an Android operation is selected. */
|
||||
export interface JadxConfiguration {
|
||||
|
|
@ -116,9 +120,53 @@ const readJadxConfiguration = async (
|
|||
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
|
||||
const jvmArguments = resolveJvmArguments(environment);
|
||||
await inspectJavaRuntime(java, environment, signal);
|
||||
await requireEngineArchive(jar, signal);
|
||||
return { jar, java, jvmArguments };
|
||||
};
|
||||
|
||||
const requireEngineArchive = async (
|
||||
jar: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
// These classes are consumed directly by ReaJadxBridge.java. This checks the
|
||||
// classpath inventory without loading engine code or starting a JADX session.
|
||||
const missing = new Set([
|
||||
"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 reader = new ZipArtifactReader(jar, "zip", JAR_METADATA_READ_MAX_BYTES);
|
||||
try {
|
||||
for await (const entry of reader.entries(signal)) {
|
||||
if (entry.kind === "file" && !entry.encrypted) missing.delete(entry.path);
|
||||
if (missing.size === 0) return;
|
||||
}
|
||||
} catch (cause) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
throw configurationFailure(
|
||||
"version_unresolved",
|
||||
`Cannot inspect REA_JADX_MCP_JAR ${jar} as a Java archive: ${reason}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`,
|
||||
{
|
||||
jar_path: jar,
|
||||
phase: "jar-inspection",
|
||||
archive_error: reason,
|
||||
maximum_metadata_read_bytes: JAR_METADATA_READ_MAX_BYTES,
|
||||
},
|
||||
cause,
|
||||
);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
throw configurationFailure(
|
||||
"unsupported_version",
|
||||
`REA_JADX_MCP_JAR ${jar} does not contain the required metadata bridge classes: ${[...missing].join(", ")}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`,
|
||||
{ jar_path: jar, phase: "jar-inspection", missing_classes: [...missing] },
|
||||
);
|
||||
};
|
||||
|
||||
const requireSupportedHost = (): void => {
|
||||
if (process.platform === "linux" || process.platform === "darwin") return;
|
||||
throw configurationFailure(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
|
|||
class NodeFileReader extends Reader<string> {
|
||||
#handle: FileHandle | undefined;
|
||||
|
||||
constructor(private readonly path: string) {
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly maximumReadBytes?: number,
|
||||
) {
|
||||
super(path);
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +32,16 @@ class NodeFileReader extends Reader<string> {
|
|||
): Promise<Uint8Array> {
|
||||
const handle = this.#handle;
|
||||
if (handle === undefined) throw new Error("ZIP reader is not initialized");
|
||||
const bytes = Buffer.alloc(Math.min(length, this.size - index));
|
||||
const readBytes = Math.min(length, this.size - index);
|
||||
if (
|
||||
this.maximumReadBytes !== undefined &&
|
||||
readBytes > this.maximumReadBytes
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`ZIP metadata read requires ${readBytes} bytes, exceeding the ${this.maximumReadBytes}-byte read budget`,
|
||||
);
|
||||
const bytes = Buffer.alloc(readBytes);
|
||||
const read = await handle.read(bytes, 0, bytes.length, index);
|
||||
return bytes.subarray(0, read.bytesRead);
|
||||
}
|
||||
|
|
@ -47,9 +59,14 @@ export class ZipArtifactReader implements ArtifactReader {
|
|||
readonly #reader: ZipReader<string>;
|
||||
readonly #entries = new Map<string, Entry>();
|
||||
|
||||
constructor(path: string, format: ZipPackageFormat) {
|
||||
/** Optionally bound each metadata read before allocating its backing buffer. */
|
||||
constructor(
|
||||
path: string,
|
||||
format: ZipPackageFormat,
|
||||
maximumMetadataReadBytes?: number,
|
||||
) {
|
||||
this.format = format;
|
||||
this.#source = new NodeFileReader(path);
|
||||
this.#source = new NodeFileReader(path, maximumMetadataReadBytes);
|
||||
this.#reader = new ZipReader(this.#source, {
|
||||
checkSignature: true,
|
||||
checkOverlappingEntry: true,
|
||||
|
|
|
|||
|
|
@ -9,14 +9,40 @@ import {
|
|||
} 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");
|
||||
|
||||
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(
|
||||
resolveJadxConfiguration(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 writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
|
|
@ -33,7 +59,7 @@ 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 writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
|
|
@ -51,7 +77,7 @@ it("configures heap and visible processors independently without forcing either"
|
|||
const root = await createTestTempDirectory("rea-jadx-config-");
|
||||
const jar = join(root, "engine.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
const configuration = await resolveJadxConfiguration(
|
||||
{
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
|
|
@ -98,7 +124,7 @@ it("configures heap and visible processors independently without forcing either"
|
|||
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 writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
const jreHome = await createFakeJdk(root, false);
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
|
|
@ -142,7 +168,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
|
|||
const bin = join(javaHome, "bin");
|
||||
const java = join(bin, "java");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
await writeFile(
|
||||
java,
|
||||
`#!/bin/sh\nexec ${process.execPath} -e 'process.stdout.write("x".repeat(2 * 1024 * 1024))'\n`,
|
||||
|
|
@ -186,7 +212,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
|
|||
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 writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
const unresolvedHome = await createFakeJdk(root);
|
||||
await writeFile(
|
||||
|
|
@ -233,7 +259,7 @@ it("cancels an in-flight Java readiness probe", async () => {
|
|||
const java = join(bin, "java");
|
||||
const marker = join(root, "java-probe-started");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
await writeFile(
|
||||
java,
|
||||
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
|
||||
|
|
@ -269,7 +295,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async
|
|||
const java = join(bin, "java");
|
||||
const marker = join(root, "java-probe-started");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(jar, "fixture jar");
|
||||
await writeJadxJarInventory(jar);
|
||||
await writeFile(
|
||||
java,
|
||||
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,32 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|||
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
|
||||
it("bounds a requested metadata read without rejecting the underlying valid ZIP", async () => {
|
||||
const root = await createTestTempDirectory("rea-zip-metadata-budget-");
|
||||
const path = join(root, "large-directory.zip");
|
||||
const name = `${"a".repeat(8192)}.class`;
|
||||
await writeOrderedZip(path, [name]);
|
||||
const bounded = new ZipArtifactReader(path, "zip", 4096);
|
||||
const control = new ZipArtifactReader(path, "zip");
|
||||
const collect = async (reader: ZipArtifactReader): Promise<string[]> => {
|
||||
const names: string[] = [];
|
||||
for await (const entry of reader.entries()) names.push(entry.path);
|
||||
return names;
|
||||
};
|
||||
try {
|
||||
await expect(collect(bounded)).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("4096-byte read budget"),
|
||||
});
|
||||
await expect(collect(control)).resolves.toEqual([name]);
|
||||
} finally {
|
||||
await Promise.all([bounded.close(), control.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects overlapping ZIP member data ranges through artifact inventory", async () => {
|
||||
const root = await createTestTempDirectory("rea-zip-overlap-");
|
||||
const path = join(root, "overlap.zip");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
createJadxProtocolFixture as setup,
|
||||
verifyJadxFixtureCleanup as verifyCleanup,
|
||||
} from "../../fixtures/android/jadx.js";
|
||||
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
|
|
@ -104,7 +105,7 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async (
|
|||
const changed = await inspect();
|
||||
expect(changed.subject?.digest.sha256).not.toBe(first.subject?.digest.sha256);
|
||||
await verifyCleanup(launches.slice(0, 1));
|
||||
await writeFile(jar, "new engine bytes");
|
||||
await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]);
|
||||
await inspect();
|
||||
environment.JAVA_TOOL_OPTIONS = "-Xmx8g";
|
||||
environment._JAVA_OPTIONS = "-XX:+UseG1GC";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { writeOrderedZip } from "../artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../temporaryDirectory.js";
|
||||
import { writeJadxJarInventory } from "./jadxJar.js";
|
||||
|
||||
const fixture = fileURLToPath(new URL("./jadx-mcp.mjs", import.meta.url));
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ export const createJadxProtocolFixture = async (mode = "normal") => {
|
|||
);
|
||||
await chmod(java, 0o755);
|
||||
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
|
||||
await writeFile(jar, "synthetic engine bytes; not a Java archive");
|
||||
await writeJadxJarInventory(jar);
|
||||
const launches: {
|
||||
runId: string;
|
||||
cwd: string | undefined;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { writeOrderedZip } from "../artifactEntryOrder.js";
|
||||
|
||||
/** ZIP classpath inventory for synthetic protocol tests; not executable bytecode. */
|
||||
export const writeJadxJarInventory = (
|
||||
path: string,
|
||||
extraEntries: readonly string[] = [],
|
||||
): Promise<void> =>
|
||||
writeOrderedZip(path, [
|
||||
"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",
|
||||
...extraEntries,
|
||||
]);
|
||||
Loading…
Reference in New Issue