Files
reverse-engineering-anything/src/android/JadxJavaProbe.ts
T
morluto 63b0426e40 fix(errors): rethrow internal invariant violations from plist consumers and the native output projection (#11029)
* fix(plist): rethrow internal invariant violations from plist consumers

KeyedArchiveReader reported any non-limit decode failure as a format
failure, InterfaceBuilderAnalysis listed it as an invalid archive,
ReleaseExposureReader fell back to scanning raw bytes, and the native
plist parser reported it as provider output. An InternalInvariantError
is a REA defect, not evidence about the input, so each consumer now
rethrows it. The native parser's binary path now shares its one cause
projection instead of a duplicate.

* fix(native): rethrow internal invariant violations from the native output projection

nativeOutputFailure wrapped any producer failure as AnalysisOutputError,
so a REA defect thrown while reading a plist or dispatching a command was
reported as native output parsing. It now rethrows InternalInvariantError,
which reaches the provider's adapter-failure path with the invariant as
its cause.

* fix(artifacts): rethrow internal invariant violations from the shared artifact failure mapper

translateArtifactFailure turned every unrecognised cause into an
ArtifactOperationError with reason io, so the keyed-archive and
Interface Builder rethrows still reached users as artifact read
failures. The shared mapper now rethrows InternalInvariantError, and the
deferred client reports it as a ProviderAdapterError. Provider-level
tests drive both operations through that client.

* refactor(dotnet): report managed reader defects through invariantViolation (#11030)

* refactor(dotnet): report managed reader defects through invariantViolation

The managed readers threw plain TypeError and RangeError for states their
callers rule out: an allowance or method page outside the input schema, a
missing snapshot after identity admission, and a missing CLI header after
the caller already returned for it. These are REA defects, not malformed
targets, so they throw invariantViolation. The two CLI re-checks are gone:
the callers pass the CLI header they already narrowed. The dotnet
parser-plain-throw baseline drops from 6 to 0.

* refactor(providers): refuse unusable tool paths and record marker mismatches without throwing to their own catch (#11031)

* refactor(firmware): refuse unusable tool paths without throwing to its own catch

resolveFirmwareCommand threw a plain TypeError for a configured engine or
limiter path that is not a regular file, only to catch it in the next line
and wrap it as an unavailable capability. A helper now returns why a path
cannot run, and each caller builds the one refusal directly. Refusal
reasons are unchanged; the directory tests now pin them exactly. The
firmware parser-plain-throw baseline drops from 2 to 0.

* refactor(pwndbg): refuse unusable debugger paths without throwing to its own catch

inspectPwndbgCore threw plain Errors for a relative or wrong-kind
configured path only to catch them and wrap them as an unavailable
capability. A helper now returns why a path cannot be used, with any host
error as its cause, and the loop builds the one refusal. Cancellation
between host checks still propagates. Refusal reasons are unchanged. The
PwndbgCoreContext parser-plain-throw baseline entry is removed.

* refactor(pwntools): record a mismatched failure marker without throwing to its own catch

readPwntoolsFailureEvidence threw a plain Error when the private bridge
marker disagreed with the exit status, only to catch it and record its
message as the marker failure. The mismatch is now recorded directly. The
recorded evidence is unchanged; a unit test pins both the matching and
the mismatched marker. The PwntoolsResourceLimits parser-plain-throw
baseline entry is removed.

* fix(android): report an unobserved Java probe as a REA defect, not a Java refusal (#11032)

* fix(android): report an unobserved Java probe as a REA defect, not a Java refusal

When runOwnedCommand resolved without observing the completed run, the
probe threw a plain Error that its catch then classified as a Java
runtime refusal, telling the caller to review their launcher. The state
is a broken internal assumption, so the probe throws invariantViolation
and projects it as a jadx adapter failure. The JadxJavaProbe
parser-plain-throw baseline entry is removed.

* refactor(native): report native provider defects through invariantViolation (#11033)

* refactor(native): report native provider defects through invariantViolation

Native providers threw plain Error and TypeError for states their own
earlier steps rule out: a client with no target, a measured slice with no
selection, a read longer than requested, a decoder command without its
runtime, an LLDB root released before its children, and an IPS integer
fact from another reader. These are REA defects, so they throw
invariantViolation. NativeUiInterruptionControl no longer needs a throw:
recording the deadline interruption returns the interruption its error is
built from. Six native parser-plain-throw baseline entries drop.

* refactor(native): report malformed lipo, swift-demangle and UI helper output through ParseFailure (#11034)

* refactor(native): report malformed lipo and swift-demangle output through ParseFailure

The lipo and swift-demangle output parsers threw plain TypeError for
output that breaks their framing, and the lipo result classifier caught
every TypeError as malformed output, so a host TypeError from a REA defect
was reported as lipo output. Both parsers now throw malformedInput with
their own format, and the lipo classifier accepts ParseFailureError
instead of TypeError. Messages and the consumers' output and format
failure categories are unchanged. Four native parser-plain-throw
baseline entries drop.

* refactor(native): report the UI helper's non-JSON output through ParseFailure

The native UI helper runtime threw a plain Error when the helper's
stdout was not JSON. It now throws malformedInput with its own format and
the JSON failure as cause. The observation boundary still reports it as a
native-macos adapter failure with the same reason. The
NativeUiHelperRuntime parser-plain-throw baseline entry is removed.

* refactor(verify): keep process-lifecycle owners out of the parser plain-throw guard (#11035)

* refactor(verify): keep process-lifecycle owners out of the parser plain-throw guard

JadxWorkspaceRoot and PwntoolsDecoder sit in parser folders but own a
private helper process; their remaining plain throws report the helper's
startup, handshake and cleanup outcomes, not parse faults. The guard now
names them as process-lifecycle owners and leaves them out, so their
five baseline entries drop without inventing parse errors for process
failures.

* refactor(parsers): report the remaining artifact, il2cpp, model and domain defects through invariantViolation (#11036)

* refactor(parsers): report the remaining artifact, il2cpp, model and domain defects through invariantViolation

Nine plain Error, TypeError and RangeError throws in parser folders
reported broken internal assumptions or caller contracts (an
uninitialized ZIP or DMG reader, a missing scan result, a non-byte
stream chunk, an invalid traversal or marshal limit, a header count or
linking fact the reader itself should have produced, a misordered
Safetensors budget, an append after fingerprinting). None is an input
fault, and no catch site depends on their class. They now throw
invariantViolation with the same messages, and their parser-plain-throw
baseline entries drop.

* refactor(evidence): refuse an earlier Evidence format through ParseFailure (#11037)

* refactor(evidence): refuse an earlier Evidence format through ParseFailure

UnsupportedEvidenceFormatError restated the ParseFailure algebra as its
own TypeError subclass. The format owner now throws unsupportedInput for
the evidence-bundle format at the evidence_format field and exports the
predicate its consumers use. The bundle reader's unsupported-format kind
and message are unchanged. The snapshot reader surfaced only TypeError
messages, so it now also surfaces the format refusal and keeps its
recreate guidance; a boundary test pins that.

* refactor(native): report Mach-O reader and inspection defects through invariantViolation (#11042)

The Mach-O inspection plan reads the lipo, nm and dyld_info captures it
just ran, the dispatch metadata reader is called only for Mach-O
executables, and the command-name reader takes limits its callers
derive. A missing capture, a non-Mach-O target or a malformed limit is a
REA defect, not an input fault, so these six sites throw
invariantViolation with their existing messages instead of TypeError.
No catch site depends on their class. Their parser-plain-throw baseline
entries drop.

* fix(artifacts): join the Interface Builder cleanup failure to a catalog defect

When catalog work failed while the concurrent Interface Builder reader
failed to close, analyzeAppleAssetCatalogs built the primary through
translateArtifactFailure before joining cleanup. That mapper now rethrows
an InternalInvariantError, so a defect skipped the join and the reader's
retained resources were lost. The defect now becomes the artifact graph
provider's adapter failure at that site and joins the cleanup failure as
before.
2026-11-15 19:35:22 +08:00

419 lines
15 KiB
TypeScript

import type { ProcessInspectionFailure } from "../process/ProcessInspectionCleanup.js";
import { classifyLaunchFailureCode } from "../process/launchFailure.js";
import { randomUUID } from "node:crypto";
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
import {
AnalysisError,
type AnalysisCapturedOutput,
} from "../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisTimeoutError,
cancelledBy,
carriedReason,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import {
InternalInvariantError,
invariantViolation,
} from "../domain/internalInvariant.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
prepareOwnedCommand,
runOwnedCommand,
OwnedCommandFailure,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import {
retainOwnedCommandCleanup,
processInspectionCancellationError,
processInspectionFailure,
bindProcessInspectionCancellations,
} from "../providers/OwnedCommandCleanup.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { jadxOperationTimeoutGuidance } from "./JadxOperationTimeout.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
export interface JadxJavaProbeOptions {
readonly resourceOwner: RetainedResourceScope;
readonly signal?: AbortSignal | undefined;
readonly operation?: string;
/** The Android operation owns this deadline; the probe also has its own bound. */
readonly operationDeadline?: {
readonly signal: AbortSignal;
readonly timeoutMs: number;
};
readonly launcher?: NonNullable<
Parameters<typeof runOwnedCommand>[2]
>["launcher"];
readonly timeoutMs?: number;
}
export interface JadxJavaProbeRefusal {
/** Actual preparation record; retained without changing timeout or fallback policy. */
readonly inspectionFailure?: ProcessInspectionFailure;
readonly code:
| "runtime_missing"
| "runtime_unavailable"
| "version_unresolved"
| "unsupported_version";
readonly message: string;
readonly diagnostics: Readonly<Record<string, JsonValue>>;
readonly kind: "java_probe" | "java_runtime";
}
interface JavaModules {
readonly compiler: true;
readonly major: number;
}
const refusal = (
code: JadxJavaProbeRefusal["code"],
message: string,
diagnostics: JadxJavaProbeRefusal["diagnostics"],
kind: JadxJavaProbeRefusal["kind"],
): Result<never, JadxJavaProbeRefusal> =>
err({ code, message, diagnostics, kind });
const capturedOutput = (
snapshot: ProviderProcessSnapshot,
): AnalysisCapturedOutput => ({
stdout: snapshot.stdout.text,
stderr: snapshot.stderr.text,
stdout_bytes: snapshot.stdout.observedBytes,
stderr_bytes: snapshot.stderr.observedBytes,
truncated:
snapshot.diagnosticTruncated === true || snapshot.outputIncomplete === true,
exit_code: snapshot.exitCode ?? null,
signal: snapshot.signal ?? null,
});
const projectRefusal = (
failure: JadxJavaProbeRefusal,
operation: string,
cause: unknown,
output?: AnalysisCapturedOutput,
) => {
const { stdout: _stdout, stderr: _stderr, ...facts } = failure.diagnostics;
return new AnalysisCapabilityUnavailableError(
"jadx",
operation,
failure.message,
{
cause,
userMessage: failure.message,
diagnostics: {
...facts,
failure_kind: failure.kind,
failure_code: failure.code,
},
...(output === undefined ? {} : { capturedOutput: output }),
},
);
};
/** Interpret one complete module listing before joining final cleanup. */
export const inspectJadxJava = async (
java: string,
environment: Readonly<Record<string, string | undefined>>,
options: JadxJavaProbeOptions,
): Promise<Result<JavaModules, JadxJavaProbeRefusal>> => {
const operation = options.operation ?? "inspect_jadx_configuration";
try {
return await options.resourceOwner.run(async () => {
let observed: Result<JavaModules, JadxJavaProbeRefusal> | undefined;
try {
await prepareOwnedCommand(options.signal);
await runOwnedCommand(
{
command: java,
arguments: ["--list-modules"],
runId: randomUUID(),
expectedCommand: null,
hostEnvironment: environment,
sweepTokenOwnedProcesses: true,
},
{
timeoutMs: options.timeoutMs ?? JAVA_MODULE_LISTING_TIMEOUT_MS,
diagnosticBytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
acceptNonZeroExit: true,
observeCompleted: (snapshot) => {
observed = parseJavaModules(java, snapshot);
},
},
{
signal: options.signal,
...(options.launcher === undefined
? {}
: { launcher: options.launcher }),
},
);
// runOwnedCommand observes every completed run before it resolves.
if (observed === undefined)
throw invariantViolation(
"Java probe completed without an observation",
);
return observed;
} catch (cause: unknown) {
const command =
cause instanceof OwnedCommandFailure ? cause : undefined;
const preparation = processInspectionFailure(cause);
const output =
command?.snapshot == null
? preparation instanceof DarwinProcessOwnershipInspectionError
? preparation.capturedOutput
: undefined
: capturedOutput(command.snapshot);
const inspectionCancellation =
command === undefined || command.preparationFailure !== undefined
? processInspectionCancellationError(cause, operation)
: undefined;
const cleanups = retainOwnedCommandCleanup(
cause,
options.resourceOwner,
"jadx",
operation,
);
let primary: AnalysisError;
if (observed !== undefined && !observed.ok) {
if (cleanups.length === 0) return observed;
primary = projectRefusal(observed.error, operation, cause, output);
} else if (
command?.reason === "cancelled" &&
command.cancellation !== undefined
) {
const reason = command.cancellation.reason;
const errorOptions = {
...(output === undefined ? {} : { capturedOutput: output }),
...(observed?.ok === true
? {
diagnostics: {
java_module_observation: {
compiler: observed.value.compiler,
major: observed.value.major,
},
},
}
: {}),
};
primary =
options.operationDeadline?.signal.aborted === true &&
reason === options.operationDeadline.signal.reason
? new AnalysisTimeoutError(
operation,
options.operationDeadline.timeoutMs,
{
...errorOptions,
cause: reason,
...jadxOperationTimeoutGuidance(
options.operationDeadline.timeoutMs,
),
},
)
: (inspectionCancellation ??
cancelledBy(operation, carriedReason(reason), errorOptions));
} else if (inspectionCancellation !== undefined) {
primary = inspectionCancellation;
} else if (
observed?.ok === true &&
command?.cleanupOwner !== undefined
) {
primary = new ProviderAdapterError("jadx", operation, {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
diagnostics: {
stage: "cleanup",
java_module_observation: {
compiler: observed.value.compiler,
major: observed.value.major,
},
},
});
} else if (preparation !== undefined && command?.reason !== "timeout") {
primary = new ProviderAdapterError("jadx", operation, {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
diagnostics: {
stage: "ownership_preparation",
...(!(
preparation instanceof DarwinProcessOwnershipInspectionError
) || preparation.compilerOutcome === undefined
? {}
: {
compiler_outcome: jsonValueSchema.parse(
preparation.compilerOutcome,
),
}),
},
});
} else if (cause instanceof AnalysisError) {
primary = cause;
} else if (cause instanceof InternalInvariantError) {
// A REA defect says nothing about the selected Java runtime.
primary = new ProviderAdapterError("jadx", operation, {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
});
} else {
const failure = commandProbeRefusal(
java,
command,
cause,
options.timeoutMs ?? JAVA_MODULE_LISTING_TIMEOUT_MS,
);
if (preparation !== undefined)
Object.defineProperty(failure, "inspectionFailure", {
value: preparation,
});
if (cleanups.length === 0) return err(failure);
primary = projectRefusal(failure, operation, cause, output);
}
for (const cleanup of cleanups)
primary = analysisErrorWithCleanupFailure(
primary,
cleanup,
operation,
).bindCleanupDependencies([primary, cleanup]);
throw bindProcessInspectionCancellations(cause, primary, operation);
}
});
} catch (cause: unknown) {
if (cause instanceof AnalysisError) throw cause;
throw analysisClientFailureForOperation(cause, "jadx", operation);
}
};
const commandProbeRefusal = (
java: string,
command:
| Pick<OwnedCommandFailure, "snapshot" | "reason" | "processError">
| undefined,
cause: unknown,
timeoutMs: number,
): JadxJavaProbeRefusal => {
const output =
command?.snapshot == null ? undefined : capturedOutput(command.snapshot);
const processError =
command?.processError ?? (cause instanceof Error ? cause : undefined);
const systemCode =
processError === undefined ? undefined : Reflect.get(processError, "code");
const missing = classifyLaunchFailureCode(systemCode)?.kind === "missing";
const limited = command?.reason === "output-limit";
const timedOut = command?.reason === "timeout";
return {
code: missing
? "runtime_missing"
: limited || timedOut
? "version_unresolved"
: "runtime_unavailable",
kind: missing ? "java_runtime" : "java_probe",
message: limited
? `The selected Java launcher ${java} exceeded the ${String(JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES)}-byte module-listing output budget. This release has no runtime override. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; java --list-modules should return a concise module listing. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.`
: timedOut
? `The selected Java launcher ${java} did not finish the module-listing probe within ${String(timeoutMs)} ms. This release has no runtime override. Inspect its startup responsiveness and verify it with java --list-modules. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.`
: `Cannot inspect selected Java runtime ${java}: ${missing ? `spawn java ${String(systemCode)}` : cause instanceof Error ? cause.message : cause === undefined ? `exit ${String(output?.exit_code ?? "unknown")}${output?.signal == null ? "" : ` (${output.signal})`}` : String(cause)}. Review the selected launcher and retained diagnostics.`,
diagnostics: {
java_executable: java,
phase: "module-inspection",
exit_code: output?.exit_code ?? null,
process_error_code: typeof systemCode === "string" ? systemCode : null,
signal: output?.signal ?? null,
killed: null,
stdout: output?.stdout ?? "",
stderr: output?.stderr ?? "",
output_truncated: output?.truncated ?? true,
...(limited
? {
failure: "output_limit",
output_limit_bytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
}
: {}),
...(timedOut
? { failure: "probe_terminated", deadline_ms: timeoutMs }
: {}),
},
};
};
const parseJavaModules = (
java: string,
snapshot: ProviderProcessSnapshot,
): Result<JavaModules, JadxJavaProbeRefusal> => {
if (snapshot.exitCode !== 0 || snapshot.signal !== null)
return err(
commandProbeRefusal(
java,
{ reason: "process", snapshot, processError: undefined },
undefined,
JAVA_MODULE_LISTING_TIMEOUT_MS,
),
);
const stdout = snapshot.stdout.text;
const stderr = snapshot.stderr.text;
const compiler = /^jdk\.compiler(?:@|$)/mu.test(stdout);
if (!compiler)
return refusal(
"runtime_missing",
`Selected Java runtime ${java} does not provide jdk.compiler. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules. A JRE cannot compile REA's source bridge.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: false,
stdout,
stderr,
output_truncated: false,
},
"java_runtime",
);
const major = /^java\.base@(\d+)(?:[.+_-]|$)/mu.exec(stdout)?.[1];
if (major === undefined)
return refusal(
"version_unresolved",
`The selected Java launcher ${java} did not report a parseable java.base version in its module listing. Inspect the launcher output and verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: null,
stdout,
stderr,
output_truncated: false,
},
"java_probe",
);
const majorNumber = Number(major);
if (!Number.isSafeInteger(majorNumber) || majorNumber <= 0)
return refusal(
"version_unresolved",
`The selected Java launcher ${java} reported an invalid java.base major version. Review the retained module listing.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: null,
unparsed_java_major: major,
stdout,
stderr,
output_truncated: false,
},
"java_probe",
);
if (majorNumber < 17)
return refusal(
"unsupported_version",
`Selected Java runtime ${java} must be JDK 17 or newer and include jdk.compiler; verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: major ?? null,
stdout,
stderr,
output_truncated: false,
},
"java_runtime",
);
return ok({ compiler: true, major: majorNumber });
};