fix(android): bind JADX snapshots and hashes to admitted sources

This commit is contained in:
morluto
2026-10-11 00:28:25 +08:00
parent 5daff4d67e
commit db8ccaa251
3 changed files with 197 additions and 52 deletions
+107 -21
View File
@@ -1,20 +1,19 @@
import { constants, createReadStream } from "node:fs";
import { chmod, copyFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import type { Stats } from "node:fs";
import { chmod, open, rm, stat, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { Transform, type TransformCallback } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
AnalysisInputError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import {
openRegularFile,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
/** Stream a local file digest without retaining the APK or JAR in memory. */
export const hashAndroidFile = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
/** Bind decompiler input to the admitted artifact, even if the original changes. */
export const snapshotAndroidTarget = async (
path: string,
@@ -23,18 +22,20 @@ export const snapshotAndroidTarget = async (
operation: AndroidOperation,
): Promise<string> => {
const snapshot = join(root, "target.apk");
await copyFile(path, snapshot, constants.COPYFILE_EXCL);
await chmod(snapshot, 0o400);
if ((await hashAndroidFile(snapshot)) !== sha256)
throw new AnalysisInputError(operation, undefined, [
try {
await copyAndroidSnapshot(path, snapshot, sha256);
await chmod(snapshot, 0o400);
return snapshot;
} catch (cause: unknown) {
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
throw new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message:
"APK bytes changed after admission; retry against a stable file.",
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
},
]);
return snapshot;
}
};
/** Fingerprint the actual immutable engine bytes executed for this observation. */
@@ -45,13 +46,98 @@ export const snapshotAndroidEngine = async (
operation: AndroidOperation,
): Promise<{ path: string; sha256: string }> => {
const path = join(root, "engine.jar");
await copyFile(source, path, constants.COPYFILE_EXCL);
await chmod(path, 0o400);
if ((await hashAndroidFile(path)) !== sha256)
try {
const observed = await copyAndroidSnapshot(source, path, sha256);
await chmod(path, 0o400);
return { path, sha256: observed };
} catch (cause: unknown) {
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
throw new AnalysisCapabilityUnavailableError(
"jadx",
operation,
"REA_JADX_MCP_JAR bytes changed during admission; retry with a stable engine file.",
`REA_JADX_MCP_JAR bytes changed during admission at ${source}; retry with a stable engine file.`,
{ cause },
);
return { path, sha256 };
}
};
const copyAndroidSnapshot = async (
sourcePath: string,
snapshotPath: string,
expectedSha256: string,
): Promise<string> => {
const source = await openRegularFile(sourcePath, { symlinks: "follow" });
let destination: FileHandle | undefined;
let copiedBytes = 0;
const hash = createHash("sha256");
try {
const initial = await source.stat();
const digest = new Transform({
transform(
chunk: Buffer,
_encoding: BufferEncoding,
callback: TransformCallback,
) {
copiedBytes += chunk.length;
if (copiedBytes > initial.size) {
callback(
new AndroidSnapshotChangedError(
`Android input grew while being copied: ${sourcePath}`,
),
);
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
try {
destination = await open(snapshotPath, "wx", 0o600);
await pipeline(
source.createReadStream({
start: 0,
end: initial.size,
autoClose: false,
}),
digest,
destination.createWriteStream({ autoClose: false }),
);
await verifySourceState(source, sourcePath, initial, copiedBytes);
const observedSha256 = hash.digest("hex");
if (observedSha256 !== expectedSha256)
throw new AndroidSnapshotChangedError(
`Android snapshot digest mismatch for ${sourcePath}`,
);
return observedSha256;
} catch (cause: unknown) {
if (destination !== undefined)
await rm(snapshotPath, { force: true }).catch(() => undefined);
throw cause;
}
} finally {
await Promise.all([source.close(), destination?.close()]);
}
};
const verifySourceState = async (
handle: Awaited<ReturnType<typeof openRegularFile>>,
path: string,
initial: Stats,
observedBytes: number,
): Promise<void> => {
const [opened, currentPath] = await Promise.all([handle.stat(), stat(path)]);
if (
observedBytes !== initial.size ||
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath)
)
throw new AndroidSnapshotChangedError(
`Android input changed while being read: ${path}`,
);
};
class AndroidSnapshotChangedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
+87 -26
View File
@@ -21,9 +21,10 @@ import { err, ok, type Result } from "../domain/result.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import {
snapshotAndroidEngine,
hashAndroidFile,
snapshotAndroidTarget,
} from "./AndroidTargetSnapshot.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import {
inspectJadxAvailability,
resolveJadxConfiguration,
@@ -104,6 +105,50 @@ const executionError = (context: {
});
};
const androidTargetChanged = (
operation: string,
path: string,
cause?: unknown,
): AnalysisInputError =>
new AnalysisInputError(
operation,
cause === undefined ? undefined : { cause },
[
{
path: ["path"],
reason: "invalid_value",
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
},
],
);
const androidSourceChanged = (
operation: string,
source: string,
cause?: unknown,
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(
"jadx",
operation,
`Selected JADX input bytes changed during admission at ${source}; retry with a stable installation.`,
cause === undefined ? undefined : { cause },
);
const hashAndroidSource = async (
path: string,
operation: string,
signal: AbortSignal,
): Promise<string> => {
try {
return (await hashStableFile(path, signal)).sha256;
} catch (cause: unknown) {
if (signal.aborted) throw cause;
if (cause instanceof RegularFileChangedError)
throw androidSourceChanged(operation, path, cause);
throw cause;
}
};
interface PendingCleanup {
session: JadxSession | undefined;
root: PrivateRuntimeRoot | undefined;
@@ -147,17 +192,18 @@ const cleanup = async (
await root?.close();
resources.root = undefined;
} catch (cause) {
return err(
new ProviderCleanupError(
"jadx",
[root?.path ?? "unknown"],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause: previousError ?? cause },
),
const error = new ProviderCleanupError(
"jadx",
[root?.path ?? "unknown"],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause: previousError ?? cause },
);
if (previousError?.partialObservation !== undefined)
error.retainPartialObservation(previousError.partialObservation);
return err(error);
}
return ok(undefined);
};
@@ -293,17 +339,27 @@ export class JadxProvider implements AndroidAnalysisPort {
signal,
);
signal.throwIfAborted();
const jarHash = await hashAndroidFile(configuration.jar);
const bridgeHash = await hashAndroidFile(BRIDGE_SOURCE);
if ((await hashAndroidFile(target.path)) !== target.sha256)
throw new AnalysisInputError(request.operation, undefined, [
{
path: ["path"],
reason: "invalid_value",
message:
"APK bytes changed after admission; retry against a stable file.",
},
]);
const jarHash = await hashAndroidSource(
configuration.jar,
request.operation,
signal,
);
const bridgeHash = await hashAndroidSource(
BRIDGE_SOURCE,
request.operation,
signal,
);
let targetHash: string;
try {
targetHash = (await hashStableFile(target.path, signal)).sha256;
} catch (cause: unknown) {
if (signal.aborted) throw cause;
if (cause instanceof RegularFileChangedError)
throw androidTargetChanged(request.operation, target.path, cause);
throw cause;
}
if (targetHash !== target.sha256)
throw androidTargetChanged(request.operation, target.path);
const key = JSON.stringify({
path: target.path,
sha256: target.sha256,
@@ -342,11 +398,16 @@ export class JadxProvider implements AndroidAnalysisPort {
const bridge = join(root.path, "ReaJadxBridge.java");
await copyFile(BRIDGE_SOURCE, bridge);
await chmod(bridge, 0o400);
if ((await hashAndroidFile(bridge)) !== bridgeHash)
throw new AnalysisCapabilityUnavailableError(
"jadx",
const copiedBridgeHash = await hashAndroidSource(
bridge,
request.operation,
signal,
);
if (copiedBridgeHash !== bridgeHash)
throw androidSourceChanged(
request.operation,
"REA Android bridge bytes changed during admission; retry with a stable installation.",
BRIDGE_SOURCE,
undefined,
);
signal.throwIfAborted();
session = new JadxSession(
@@ -4,10 +4,8 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
import { JadxProvider } from "../../../src/android/JadxProvider.js";
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
import {
hashAndroidFile,
snapshotAndroidEngine,
} from "../../../src/android/AndroidTargetSnapshot.js";
import { snapshotAndroidEngine } from "../../../src/android/AndroidTargetSnapshot.js";
import { hashStableFile } from "../../../src/filesystem/StableFileHash.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import {
createJadxProtocolFixture as setup,
@@ -43,7 +41,7 @@ it("cancels Android admission while hashing the selected APK", async () => {
it("rejects a copied JAR that differs from its admitted session identity", async () => {
const { jar } = await setup();
const admitted = await hashAndroidFile(jar);
const admitted = (await hashStableFile(jar)).sha256;
await writeFile(jar, "engine changed between hashing and copying");
const root = await PrivateRuntimeRoot.create({
prefix: "rea-jadx-admission-",