Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6876d0d249 | ||
|
|
3e6508f4c5 | ||
|
|
dabb22c09d | ||
|
|
c101193eb3 | ||
|
|
172503355d | ||
|
|
8d72e2d75e |
No files matched your search
@@ -0,0 +1,55 @@
|
||||
name: Real Android metadata bridge
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- .github/workflows/real-android-metadata.yml
|
||||
- bridge/android/**
|
||||
- src/android/JadxRelease.ts
|
||||
- scripts/fixtures/android/**
|
||||
- tests/conformance/android/**
|
||||
- tests/fixtures/android/static-initializers/**
|
||||
- tests/fixtures/android/direct-invokes/**
|
||||
- .nvmrc
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: android-metadata-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-metadata:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Fetch checksum-pinned engine and public APK fixture
|
||||
run: node scripts/fixtures/android/fetch.mjs "$RUNNER_TEMP/rea-android"
|
||||
- name: Compile and exercise the real metadata bridge
|
||||
shell: bash
|
||||
run: |
|
||||
export JAVA_HOME="$JAVA_HOME_21_X64"
|
||||
android_root="$RUNNER_TEMP/rea-android"
|
||||
classes="$android_root/classes"
|
||||
jar="$android_root/jadx-headless-mcp-0.7.1-all.jar"
|
||||
mkdir -p "$classes"
|
||||
"$JAVA_HOME/bin/javac" -J-Xmx512m -J-XX:ActiveProcessorCount=2 \
|
||||
-classpath "$jar" -d "$classes" \
|
||||
bridge/android/ReaJadxBridge.java \
|
||||
tests/conformance/android/ReaJadxMetadataRegression.java
|
||||
"$JAVA_HOME/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \
|
||||
-classpath "$classes:$jar" ReaJadxMetadataRegression \
|
||||
"$android_root/ApiDemos-debug.apk" \
|
||||
"$GITHUB_WORKSPACE/tests/fixtures/android/static-initializers" \
|
||||
"$classes" "$GITHUB_WORKSPACE/tests/fixtures/android/direct-invokes" \
|
||||
| tee "$android_root/metadata.log"
|
||||
grep -F 'RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true' \
|
||||
"$android_root/metadata.log"
|
||||
@@ -269,12 +269,16 @@ class ReaJadxBridge {
|
||||
"type", field.getType().toString(), "def_pos", 0)).toList();
|
||||
List<String> inner = node.getInnerClasses().stream().map(ClassNode::getFullName).toList();
|
||||
Map<String, Object> summary = Map.of(
|
||||
"full_name", cls.getFullName(), "name", cls.getName(),
|
||||
"full_name", cls.getFullName(), "raw_name", cls.getRawName(), "name", cls.getName(),
|
||||
"method_count", methods.size(), "field_count", fields.size(),
|
||||
"inner_class_count", inner.size(), "inner_classes", inner,
|
||||
"methods", methods.stream().map(MethodSnapshot::summary).toList(), "fields", fields);
|
||||
index.computeIfAbsent(cls.getFullName(), ignored -> new ArrayList<>())
|
||||
.add(new ClassSnapshot(cls, methods, summary));
|
||||
ClassSnapshot snapshot = new ClassSnapshot(cls, methods, summary);
|
||||
index.computeIfAbsent(cls.getFullName(), ignored -> new ArrayList<>()).add(snapshot);
|
||||
// Keep the binary identity supplied by JADX; '$' can also be a literal
|
||||
// name character, so replacing separators would invent lookup aliases.
|
||||
if (!cls.getRawName().equals(cls.getFullName()))
|
||||
index.computeIfAbsent(cls.getRawName(), ignored -> new ArrayList<>()).add(snapshot);
|
||||
}
|
||||
}
|
||||
classes = Collections.unmodifiableMap(index);
|
||||
@@ -454,7 +458,7 @@ class ReaJadxBridge {
|
||||
"markers", code.getMarkers(), "body", code.getText()));
|
||||
}
|
||||
List<JavaNode> uses;
|
||||
String target = string(args, "class_name");
|
||||
String target = (String) cls.summary().get("full_name");
|
||||
MethodSnapshot selectedMethod = null;
|
||||
int overloadIndex = 0;
|
||||
if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn();
|
||||
@@ -522,7 +526,7 @@ class ReaJadxBridge {
|
||||
SessionHolder holder = new SessionHolder(defaults);
|
||||
ServerCapabilities capabilities = new ServerCapabilities(
|
||||
new ServerCapabilities.Tools(null), null, null, null, null, null, null, Map.of());
|
||||
Server server = new Server(new Implementation("rea-jadx-bridge", "5", null, null, null),
|
||||
Server server = new Server(new Implementation("rea-jadx-bridge", "6", null, null, null),
|
||||
new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE);
|
||||
SessionToolsKt.registerSessionTools(server, holder);
|
||||
ClassToolsKt.registerClassTools(server, holder);
|
||||
|
||||
@@ -193,6 +193,13 @@ the same source byte limit and explicit truncation reporting.
|
||||
full reported display types, fields and inner class names. Synthetic members
|
||||
that code generation hides can appear in the inventory; it stays consistent
|
||||
after method decompilation within the retained session.
|
||||
Class and method inspection and reference tracing accept either the exact
|
||||
reported class name or its original binary name, including `$` in nested and
|
||||
anonymous classes. The bridge keeps both identities from JADX; it does not
|
||||
replace separators or guess suffix matches. Ambiguous selectors are refused.
|
||||
Follow-up calls preserve the selected name, while results report the frozen
|
||||
JADX display name. Quote `$` names in the shell, for example
|
||||
`'com.example.Manager$WorkerThread'`.
|
||||
Exact DEX prototypes are reported from frozen JADX method metadata as
|
||||
`dex_descriptor`, for example `(I)V`. Display names can be JADX aliases;
|
||||
the descriptor alone is not a complete raw DEX method ID. Overload indices
|
||||
|
||||
@@ -62,10 +62,11 @@ Collections are `sections`, `images`, `types`, `methods`, `fields`,
|
||||
`generic_method_instances` and `generic_method_issues`. Every returned row includes
|
||||
its source `index`, scoped by artifact SHA-256 and collection. All collections
|
||||
accept batch `indices` and `fields`. Exact `names` filtering is supported for
|
||||
`sections`, `images`, `types`, `methods`, `fields`, `properties`, `events` and
|
||||
`parameters`; native `return_types`, `field_types` and `parameter_types` rows
|
||||
are selected by source indices (or `method_indices` for `parameter_types`),
|
||||
not rendered names. Types also accept `image_indices`, `namespaces` and
|
||||
`sections`, `images`, `types`, `methods`, `fields`, `properties`, `events`,
|
||||
`parameters`, `return_types`, `field_types`, `parameter_types` and
|
||||
`generic_method_instances`. Native type names match the retained rendered display value exactly; every same-name row
|
||||
keeps its source index, and an unavailable (`null`) name never matches a string.
|
||||
Types also accept `image_indices`, `namespaces` and
|
||||
`declaring_types`; methods, fields, properties and events accept
|
||||
`declaring_types`. Parameters accept exact `method_indices`, so a batch of
|
||||
method indices retrieves their parameters without reading the entire parameter
|
||||
|
||||
@@ -511,6 +511,16 @@ to the linked Mach-O. This classification requires both the recognized native
|
||||
refusal and an independently parsed dSYM Mach header; other tool failures keep
|
||||
their failure outcome.
|
||||
|
||||
Observed `dyld_info` diagnostics include `unknown mh.filetype 4` and
|
||||
`unknown filetype 4` for `MH_CORE`, and the corresponding `3` spellings for
|
||||
`MH_FVMLIB`. These refusals report `unsupported_target` when the independently
|
||||
parsed Mach header confirms the reported file type. The error retains the exact
|
||||
native diagnostic and recommends a linked Mach-O executable or relocatable
|
||||
object. This classifies the selected target's refusal; it does not establish
|
||||
support for core-dump analysis, historical FVMLIB layouts, or unobserved native
|
||||
diagnostic spellings. Unknown or contradictory header values and signalled
|
||||
commands remain failures.
|
||||
|
||||
`inspect_macho.selected_architecture` identifies the exact selected lipo slice
|
||||
and its `container_file_offset` in the input file. A thin Mach-O has a known
|
||||
offset of zero; an unknown universal-slice offset stays null. The Evidence
|
||||
|
||||
@@ -94,6 +94,14 @@ Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_t
|
||||
## Cutter bridge
|
||||
|
||||
Cutter commands use the local REA Python bridge and authenticated loopback messages.
|
||||
Discovery reads the session descriptors that the plugin publishes in the bridge
|
||||
directory. Set `REA_CUTTER_BRIDGE_DIR` in both REA's environment and the environment
|
||||
that starts Cutter to select a custom directory. Both expand `~` and `~/` paths
|
||||
against the current user's home, including `~\` paths on Windows. By default,
|
||||
they use `%LOCALAPPDATA%\REA\CutterBridge` on Windows when `LOCALAPPDATA` is set,
|
||||
and `~/.cache/rea/cutter-bridge` otherwise. The two processes must resolve the
|
||||
same directory for discovery to find the published sessions.
|
||||
|
||||
The client and plugin must support the current v2 handshake; older peers are
|
||||
rejected before dispatch because they cannot preserve cross-caller recovery proof.
|
||||
The client checks the final encoded request against
|
||||
|
||||
@@ -1002,6 +1002,16 @@ application execution is required. The lane compares real CLI/MCP package,
|
||||
class search, class inventory, method decompilation and incoming references.
|
||||
See [Android analysis](android-analysis.md) for boundaries and resource budgets.
|
||||
|
||||
The **Real Android metadata bridge** workflow compiles the Java bridge and runs
|
||||
its metadata regressions against the same checksum-pinned engine and public APK.
|
||||
It runs on branch pushes affecting its bridge, fixture or engine inputs, and can
|
||||
also be dispatched manually or called by another workflow. It uses the runner's
|
||||
JDK with two active processors and a 512 MiB heap; it needs no npm dependency
|
||||
installation. This lane checks exact raw/display class selectors and the existing
|
||||
cold-metadata regressions. It does not establish CLI/MCP parity or application
|
||||
execution behavior. The broader `verify:android` lane checks CLI/MCP parity;
|
||||
neither lane runs the APK.
|
||||
|
||||
`verify:jeb` requires a caller-started JEB client serving MCP at
|
||||
`REA_JEB_MCP_URL` (default `http://127.0.0.1:8425/mcp`) with a project
|
||||
already open, and exercises shared application/provider client inspection, unit listing coverage,
|
||||
|
||||
@@ -77,6 +77,10 @@ export const verifyAndroidMetadata = async ({
|
||||
result.stdout,
|
||||
/CLASS_SEARCH_REGRESSION_PASS pages=\d+ nonzero=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
|
||||
@@ -103,6 +107,9 @@ export const verifyAndroidMetadata = async ({
|
||||
console.log(
|
||||
"PASS real Java filtered pages, nonzero inventory cursors and complete combined results",
|
||||
);
|
||||
console.log(
|
||||
"PASS real nested class binary selectors preserve metadata and reference identity",
|
||||
);
|
||||
console.log(
|
||||
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
|
||||
);
|
||||
|
||||
@@ -91,7 +91,10 @@ const classSummary = async (
|
||||
{ cause: summary.error },
|
||||
);
|
||||
const value = summary.data;
|
||||
if (value.full_name !== request.input.class_name)
|
||||
if (
|
||||
value.full_name !== request.input.class_name &&
|
||||
value.raw_name !== request.input.class_name
|
||||
)
|
||||
inputFailure(
|
||||
request,
|
||||
"class_name",
|
||||
@@ -233,7 +236,7 @@ const inspectMethod = async (
|
||||
);
|
||||
const body = jadxMethodSchema.parse(
|
||||
await tools.json("get_method_body", {
|
||||
class_name: summary.full_name,
|
||||
class_name: request.input.class_name,
|
||||
method_name: request.input.method_name,
|
||||
overload_index: index,
|
||||
smali_fallback: true,
|
||||
@@ -327,7 +330,7 @@ const traceReferences = async (
|
||||
await tools.json(
|
||||
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
|
||||
{
|
||||
class_name: summary.full_name,
|
||||
class_name: request.input.class_name,
|
||||
...(methodName === undefined
|
||||
? {}
|
||||
: { method_name: methodName, overload_index: index }),
|
||||
|
||||
@@ -80,7 +80,8 @@ export const parseJadxClassPage = (value: JsonValue): JadxClassPage => {
|
||||
};
|
||||
|
||||
export const jadxClassSchema = z.object({
|
||||
full_name: z.string(),
|
||||
full_name: z.string().min(1),
|
||||
raw_name: z.string().min(1),
|
||||
name: z.string(),
|
||||
method_count: count,
|
||||
field_count: count,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const JADX_RELEASE = Object.freeze({
|
||||
/** Identity of REA's metadata bridge, distinct from the producing engine. */
|
||||
export const JADX_BRIDGE_IDENTITY = Object.freeze({
|
||||
name: "rea-jadx-bridge",
|
||||
version: "5",
|
||||
version: "6",
|
||||
});
|
||||
|
||||
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
|
||||
|
||||
@@ -21,7 +21,9 @@ export const inspectAndroidPackage = async (
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const operation = "inspect_android_package";
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: options.signal.reason }),
|
||||
);
|
||||
const parsed = androidPackageInspectionInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
|
||||
@@ -736,10 +736,14 @@ export class BinarySession
|
||||
),
|
||||
);
|
||||
if (isAborted(signal))
|
||||
return err(new AnalysisCancelledError(operationName));
|
||||
return err(
|
||||
new AnalysisCancelledError(operationName, { cause: signal?.reason }),
|
||||
);
|
||||
await this.#drainCalls(admittedThrough);
|
||||
if (isAborted(signal))
|
||||
return err(new AnalysisCancelledError(operationName));
|
||||
return err(
|
||||
new AnalysisCancelledError(operationName, { cause: signal?.reason }),
|
||||
);
|
||||
|
||||
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) {
|
||||
if (cleanupRecovery === undefined)
|
||||
@@ -1450,14 +1454,18 @@ export class BinarySession
|
||||
transition: Promise<void>,
|
||||
): Promise<Result<undefined, AnalysisCancelledError>> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
||||
);
|
||||
if (signal === undefined) {
|
||||
await transition;
|
||||
return ok(undefined);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = (): void => {
|
||||
resolve(err(new AnalysisCancelledError(operation)));
|
||||
resolve(
|
||||
err(new AnalysisCancelledError(operation, { cause: signal.reason })),
|
||||
);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
transition.then(
|
||||
@@ -1465,7 +1473,11 @@ export class BinarySession
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
signal.aborted
|
||||
? err(new AnalysisCancelledError(operation))
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
)
|
||||
: ok(undefined),
|
||||
);
|
||||
},
|
||||
@@ -1473,7 +1485,11 @@ export class BinarySession
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
signal.aborted
|
||||
? err(new AnalysisCancelledError(operation))
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
)
|
||||
: ok(undefined),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -110,6 +110,25 @@ const analysis: AnalysisOperationPort = {
|
||||
},
|
||||
};
|
||||
describe("native value trace seed admission", () => {
|
||||
it("preserves the exact abort reason before attempting a seed", async () => {
|
||||
const reason = { request: "cancel before native trace" };
|
||||
const attempted: unknown[] = [];
|
||||
const result = await traceNativeValues(
|
||||
{
|
||||
execute: async (operation, parameters) => {
|
||||
attempted.push([operation, parameters]);
|
||||
return analysis.execute(operation, parameters);
|
||||
},
|
||||
},
|
||||
{ procedure: "0x1000" },
|
||||
AbortSignal.abort(reason),
|
||||
);
|
||||
if (result.ok) throw new Error("Expected pre-seed cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(attempted).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
new AnalysisInputError("analyze_function", {
|
||||
userMessage: "Unknown selector: missing",
|
||||
|
||||
@@ -73,7 +73,11 @@ export const traceNativeValues = async (
|
||||
};
|
||||
while (pending.length > 0) {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("trace_native_values"));
|
||||
return err(
|
||||
new AnalysisCancelledError("trace_native_values", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const next = pending.shift();
|
||||
if (
|
||||
next === undefined ||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views rLine truncated
|
||||
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views rLine truncated
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -227,13 +227,13 @@ export const il2cppMetadataViewRequestSchema = z.discriminatedUnion(
|
||||
}),
|
||||
...(["return_types", "field_types"] as const).map((collection) =>
|
||||
z.strictObject({
|
||||
...indexPage,
|
||||
...page,
|
||||
collection: z.literal(collection),
|
||||
fields: z.array(typeReferenceRowSchema.keyof()).optional(),
|
||||
}),
|
||||
),
|
||||
z.strictObject({
|
||||
...indexPage,
|
||||
...page,
|
||||
collection: z.literal("parameter_types"),
|
||||
// Owners come from the retained parameters collection.
|
||||
method_indices: z.array(indexSchema).optional(),
|
||||
|
||||
@@ -5,40 +5,55 @@ import { withoutEchoedPathHeader } from "./parsers/echoedPath.js";
|
||||
|
||||
const MISSING_UUID_DIAGNOSTIC = "missing LC_UUID load command";
|
||||
|
||||
const dyldFailureDiagnostics = (
|
||||
capture: NativeCommandCapture,
|
||||
outputs: readonly string[],
|
||||
): readonly string[] =>
|
||||
capture.tool !== "dyld_info" ||
|
||||
capture.exitCode === null ||
|
||||
capture.exitCode === 0 ||
|
||||
capture.signal !== null
|
||||
? []
|
||||
: outputs.flatMap((output) =>
|
||||
withoutEchoedPathHeader(output, capture.arguments.at(-1))
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim().replace(/^(?:dyld_info:\s*)/u, "")),
|
||||
);
|
||||
|
||||
/** Recognize only dyld_info's target-specific missing-UUID refusal. */
|
||||
export const isMissingUuidDyldFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
): boolean =>
|
||||
capture.tool === "dyld_info" &&
|
||||
capture.exitCode !== null &&
|
||||
capture.exitCode !== 0 &&
|
||||
capture.signal === null &&
|
||||
withoutEchoedPathHeader(capture.stdout, capture.arguments.at(-1))
|
||||
.split(/\r?\n/u)
|
||||
.some(
|
||||
(line) =>
|
||||
line.trim().replace(/^(?:dyld_info:\s*)/u, "") ===
|
||||
MISSING_UUID_DIAGNOSTIC,
|
||||
);
|
||||
dyldFailureDiagnostics(capture, [capture.stdout]).includes(
|
||||
MISSING_UUID_DIAGNOSTIC,
|
||||
);
|
||||
|
||||
/** The narrowly observed dyld refusal for an unbacked dSYM text section. */
|
||||
export const isUnbackedTextDyldFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
): boolean =>
|
||||
capture.tool === "dyld_info" &&
|
||||
capture.exitCode !== null &&
|
||||
capture.exitCode !== 0 &&
|
||||
capture.signal === null &&
|
||||
[capture.stdout, capture.stderr].some((output) =>
|
||||
withoutEchoedPathHeader(output, capture.arguments.at(-1))
|
||||
.split(/\r?\n/u)
|
||||
.some(
|
||||
(line) =>
|
||||
line.trim().replace(/^(?:dyld_info:\s*)/u, "") ===
|
||||
"section '__TEXT/__text' has offset=0 but is not a zero-fill section type",
|
||||
),
|
||||
dyldFailureDiagnostics(capture, [capture.stdout, capture.stderr]).includes(
|
||||
"section '__TEXT/__text' has offset=0 but is not a zero-fill section type",
|
||||
);
|
||||
|
||||
/** Reported CORE/FVMLIB refusals still require an independently matching header. */
|
||||
export const parseDyldFileTypeRefusal = (
|
||||
capture: NativeCommandCapture,
|
||||
): {
|
||||
readonly fileType: "3" | "4";
|
||||
readonly diagnostic: string;
|
||||
} | null => {
|
||||
for (const line of dyldFailureDiagnostics(capture, [
|
||||
capture.stdout,
|
||||
capture.stderr,
|
||||
])) {
|
||||
const fileType = /^unknown (?:mh\.)?filetype ([34])$/u.exec(line)?.[1];
|
||||
if (fileType === "3" || fileType === "4")
|
||||
return { fileType, diagnostic: line };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Complete captures come from the settled native command owner. */
|
||||
export const nativeMachoCapturedOutput = (
|
||||
capture: NativeCommandCapture,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
isUnbackedTextDyldFailure,
|
||||
nativeMachoCapturedOutput,
|
||||
missingUuidLimitation,
|
||||
parseDyldFileTypeRefusal,
|
||||
unusableNativeCommandOutput,
|
||||
} from "./NativeMachoCommandDiagnostics.js";
|
||||
|
||||
@@ -118,7 +119,8 @@ export const inspectNativeMacho = async (
|
||||
tool === "dyld_info"
|
||||
? (capture) =>
|
||||
isMissingUuidDyldFailure(capture) ||
|
||||
isUnbackedTextDyldFailure(capture)
|
||||
isUnbackedTextDyldFailure(capture) ||
|
||||
parseDyldFileTypeRefusal(capture) !== null
|
||||
: undefined,
|
||||
);
|
||||
// vtool knows no variant subtypes, so no spelling selects an arm64e.x1 slice.
|
||||
@@ -139,7 +141,8 @@ export const inspectNativeMacho = async (
|
||||
if (
|
||||
(captured.value.exitCode !== 0 || captured.value.signal !== null) &&
|
||||
!isMissingUuidDyldFailure(captured.value) &&
|
||||
!isUnbackedTextDyldFailure(captured.value)
|
||||
!isUnbackedTextDyldFailure(captured.value) &&
|
||||
parseDyldFileTypeRefusal(captured.value) === null
|
||||
)
|
||||
return err(unusableNativeCommandOutput(captured.value));
|
||||
captures.push(captured.value);
|
||||
@@ -157,17 +160,6 @@ export const inspectNativeMacho = async (
|
||||
const dyldExports = captures.filter(({ tool }) => tool === "dyld_info")[1];
|
||||
if (nmIndex < 0 || dyldExports === undefined)
|
||||
throw new TypeError("Missing nm or dyld_info export capture");
|
||||
// A stripped symbol table (strip -T) can omit names the export trie keeps.
|
||||
// Only then does llvm-nm's dyld-info pass add names, and its exact names
|
||||
// disambiguate dyld_info rows, so rerun it rather than lose them.
|
||||
if (
|
||||
!isMissingUuidDyldFailure(dyldExports) &&
|
||||
!namesEveryExport(captures[nmIndex]!.stdout, dyldExports)
|
||||
) {
|
||||
const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS);
|
||||
if (!withDyldInfo.ok) return err(withDyldInfo.error);
|
||||
captures[nmIndex] = withDyldInfo.value;
|
||||
}
|
||||
const otool = captures.find(({ tool }) => tool === "otool");
|
||||
if (otool === undefined) throw new TypeError("Missing otool capture");
|
||||
let load: ReturnType<typeof parseOtoolLoadCommands>;
|
||||
@@ -202,12 +194,43 @@ export const inspectNativeMacho = async (
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const fileTypeFailure of captures) {
|
||||
const refusal = parseDyldFileTypeRefusal(fileTypeFailure);
|
||||
if (refusal === null) continue;
|
||||
const { fileType, diagnostic } = refusal;
|
||||
const typeName = fileType === "4" ? "CORE" : "FVMLIB";
|
||||
if (![fileType, typeName, `MH_${typeName}`].includes(load.fileType ?? ""))
|
||||
return err(unusableNativeCommandOutput(fileTypeFailure));
|
||||
return err(
|
||||
new AnalysisUnsupportedTargetError(
|
||||
"inspect_macho",
|
||||
context.target.path,
|
||||
`dyld_info refused the selected Mach-O file type MH_${typeName} (${fileType}): ${diagnostic}`,
|
||||
{
|
||||
capturedOutput: nativeMachoCapturedOutput(fileTypeFailure),
|
||||
remediationAction:
|
||||
"Select a linked Mach-O executable or relocatable object for native inspection; dyld_info refused the selected file type.",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
const uuidlessDyldFailure = captures.find(isMissingUuidDyldFailure);
|
||||
if (
|
||||
uuidlessDyldFailure !== undefined &&
|
||||
load.commands.some(({ kind }) => kind === "LC_UUID")
|
||||
)
|
||||
return err(conflictingUuidCommandEvidence(uuidlessDyldFailure));
|
||||
// A stripped symbol table (strip -T) can omit names the export trie keeps.
|
||||
// Only then does llvm-nm's dyld-info pass add names, and its exact names
|
||||
// disambiguate dyld_info rows. Refusals above are not export rows.
|
||||
if (
|
||||
!isMissingUuidDyldFailure(dyldExports) &&
|
||||
!namesEveryExport(captures[nmIndex]!.stdout, dyldExports)
|
||||
) {
|
||||
const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS);
|
||||
if (!withDyldInfo.ok) return err(withDyldInfo.error);
|
||||
captures[nmIndex] = withDyldInfo.value;
|
||||
}
|
||||
const selectedArchitectureName =
|
||||
selectedArchitecture ??
|
||||
(architectures.length === 1 ? (architectures[0]?.name ?? null) : null);
|
||||
|
||||
@@ -125,7 +125,11 @@ const registerImportEvidenceTool = ({
|
||||
);
|
||||
if (signal.aborted)
|
||||
return server.delivery.toCallToolResult(
|
||||
err(new AnalysisCancelledError("import_evidence_bundle")),
|
||||
err(
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
),
|
||||
importContract,
|
||||
);
|
||||
const imported = session.importEvidenceBundle(loaded.value);
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
type JadxToolPort,
|
||||
} from "../../../src/android/JadxAnalysis.js";
|
||||
import {
|
||||
type AndroidRequest,
|
||||
androidInputSchemas,
|
||||
androidResultSchemas,
|
||||
} from "../../../src/domain/android/androidAnalysis.js";
|
||||
|
||||
const summary = {
|
||||
full_name: "fixture.Target",
|
||||
raw_name: "fixture.Target",
|
||||
name: "Target",
|
||||
method_count: 2,
|
||||
field_count: 0,
|
||||
@@ -44,6 +46,111 @@ const engine = {
|
||||
heap_limit_mib: null,
|
||||
};
|
||||
|
||||
const nestedClassInput = {
|
||||
path: "/fixture.apk",
|
||||
class_name: "fixture.Outer$Worker",
|
||||
};
|
||||
const nestedClassRequests: AndroidRequest[] = [
|
||||
{ operation: "inspect_android_class", input: nestedClassInput },
|
||||
{
|
||||
operation: "inspect_android_method",
|
||||
input: { ...nestedClassInput, method_name: "choose" },
|
||||
},
|
||||
{ operation: "trace_android_references", input: nestedClassInput },
|
||||
{
|
||||
operation: "trace_android_references",
|
||||
input: { ...nestedClassInput, method_name: "choose" },
|
||||
},
|
||||
];
|
||||
|
||||
it.each(nestedClassRequests)(
|
||||
"preserves the raw nested class selector for $operation and its followups: $input",
|
||||
async (request) => {
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
const fullName = "fixture.Outer.Worker";
|
||||
const selected = summary.methods[1]!;
|
||||
const tools: JadxToolPort = {
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
json: (name, input) => {
|
||||
calls.push({ name, ...input });
|
||||
if (name === "get_class_summary")
|
||||
return Promise.resolve({
|
||||
...summary,
|
||||
full_name: fullName,
|
||||
raw_name: nestedClassInput.class_name,
|
||||
method_count: 1,
|
||||
methods: [selected],
|
||||
});
|
||||
if (name === "get_method_body")
|
||||
return Promise.resolve({
|
||||
class_name: fullName,
|
||||
method_name: selected.name,
|
||||
full_name: `${fullName}.${selected.name}`,
|
||||
overload_index: 0,
|
||||
overload_count: 1,
|
||||
dex_descriptor: selected.dex_descriptor,
|
||||
mode: "java",
|
||||
fell_back: false,
|
||||
markers: [],
|
||||
body: "void choose(int value) {}",
|
||||
});
|
||||
return Promise.resolve({
|
||||
target:
|
||||
input.method_name === undefined
|
||||
? fullName
|
||||
: `${fullName}.${selected.name}`,
|
||||
...(input.method_name === undefined
|
||||
? {}
|
||||
: {
|
||||
overload_index: 0,
|
||||
reported_signature: selected.signature,
|
||||
}),
|
||||
total: 0,
|
||||
count: 0,
|
||||
resolve_line: false,
|
||||
items: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
const result = await analyzeJadxRequest(
|
||||
tools,
|
||||
request,
|
||||
{} as never,
|
||||
engine,
|
||||
);
|
||||
expect(result).toMatchObject({ class_name: fullName });
|
||||
expect(calls.map((call) => call.class_name)).toEqual(
|
||||
Array.from({ length: calls.length }, () => nestedClassInput.class_name),
|
||||
);
|
||||
expect(calls).toHaveLength(
|
||||
request.operation === "inspect_android_class" ? 1 : 2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a nested class when neither producer identity matches the requested selector", async () => {
|
||||
await expect(
|
||||
analyzeJadxRequest(
|
||||
{
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...summary,
|
||||
full_name: "fixture.Outer.Worker",
|
||||
raw_name: "fixture.Outer$Different",
|
||||
}),
|
||||
},
|
||||
{ operation: "inspect_android_class", input: nestedClassInput },
|
||||
{} as never,
|
||||
engine,
|
||||
),
|
||||
).rejects.toMatchObject({ _tag: "AnalysisInputError" });
|
||||
});
|
||||
|
||||
it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => {
|
||||
const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`);
|
||||
names[0] = "fixture.TargetDuplicate";
|
||||
|
||||
@@ -3,12 +3,41 @@ import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import {
|
||||
machoImage,
|
||||
segmentCommand,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
/** Match the readable synthetic CORE/header fixture reported in #5708. */
|
||||
const refusedFileTypeImage = (fileType: 3 | 4): Buffer => {
|
||||
const segment = Buffer.from(
|
||||
segmentCommand({
|
||||
name: "__TEXT",
|
||||
vmaddr: 0x100000000n,
|
||||
vmsize: 0x4000n,
|
||||
}),
|
||||
);
|
||||
segment.writeBigUInt64LE(4096n, 48);
|
||||
segment.writeUInt32LE(7, 56);
|
||||
segment.writeUInt32LE(5, 60);
|
||||
const uuid = Buffer.alloc(24);
|
||||
uuid.writeUInt32LE(0x1b, 0);
|
||||
uuid.writeUInt32LE(uuid.length, 4);
|
||||
uuid.set(
|
||||
Array.from({ length: 16 }, (_, index) => index),
|
||||
8,
|
||||
);
|
||||
const image = Buffer.alloc(4096);
|
||||
image.set(machoImage({ fileType, commands: [segment, uuid] }));
|
||||
return image;
|
||||
};
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native Mach-O CLI without LC_UUID",
|
||||
() => {
|
||||
@@ -100,6 +129,51 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
expect(report.exports).toEqual(missingUuid.exports);
|
||||
expect(report.load_commands).toEqual(missingUuid.load_commands);
|
||||
expect((await call("close_binary", {})).isError).not.toBe(true);
|
||||
|
||||
// These are synthetic readable headers, not an OS core dump or a
|
||||
// conforming historical FVMLIB (the latter omits LC_IDFVMLIB).
|
||||
for (const [name, fileType] of [
|
||||
["core", 4],
|
||||
["fvmlib", 3],
|
||||
] as const) {
|
||||
const path = join(directory, name);
|
||||
await writeFile(path, refusedFileTypeImage(fileType));
|
||||
const diagnosticPattern = new RegExp(
|
||||
`unknown (?:mh\\.)?filetype ${fileType}(?:\\r?\\n|$)`,
|
||||
"u",
|
||||
);
|
||||
const expectedRefusal = {
|
||||
code: "unsupported_target",
|
||||
category: "unsupported_target",
|
||||
remediation: { action: expect.stringContaining("linked Mach-O") },
|
||||
details: {
|
||||
path,
|
||||
reason: expect.stringMatching(diagnosticPattern),
|
||||
captured_output: {
|
||||
stdout: expect.stringMatching(diagnosticPattern),
|
||||
exit_code: 1,
|
||||
signal: null,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const refused = await cli.run({
|
||||
arguments: ["inspect-macho", path, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(refused.exitCode, refused.stdout + refused.stderr).toBe(1);
|
||||
expect(refused.json, JSON.stringify(refused.json)).toMatchObject(
|
||||
expectedRefusal,
|
||||
);
|
||||
|
||||
expect((await call("open_binary", { path })).isError).not.toBe(true);
|
||||
const inspected = await call("inspect_macho", {});
|
||||
expect(inspected.isError).toBe(true);
|
||||
expect(parseMcpToolError(inspected)).toMatchObject({
|
||||
error: expectedRefusal,
|
||||
});
|
||||
expect((await call("close_binary", {})).isError).not.toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -373,12 +373,30 @@ it.each([false, true])(
|
||||
const full = [];
|
||||
for await (const commit of fixture.github.mergeCommitIterator("main"))
|
||||
full.push(commit);
|
||||
const omitted = [];
|
||||
for await (const commit of fixture.github.mergeCommitIterator("main", {
|
||||
expect(fixture.pages.length).toBeGreaterThan(0);
|
||||
expect(fixture.pages.every((page) => page.includeFiles === true)).toBe(
|
||||
true,
|
||||
);
|
||||
const fullPageCount = fixture.pages.length;
|
||||
// The runtime patch adds this option after source checks read upstream types.
|
||||
const options = {
|
||||
includeFiles: false,
|
||||
backfillFiles: true,
|
||||
}))
|
||||
} satisfies NonNullable<
|
||||
Parameters<typeof fixture.github.mergeCommitIterator>[1]
|
||||
> & { includeFiles: boolean };
|
||||
const omitted = [];
|
||||
for await (const commit of fixture.github.mergeCommitIterator(
|
||||
"main",
|
||||
options,
|
||||
))
|
||||
omitted.push(commit);
|
||||
expect(fixture.pages.length).toBeGreaterThan(fullPageCount);
|
||||
expect(
|
||||
fixture.pages
|
||||
.slice(fullPageCount)
|
||||
.every((page) => page.includeFiles === false),
|
||||
).toBe(true);
|
||||
expect(full[0]?.pullRequest?.number).toBe(rebased ? 7 : 42);
|
||||
expect(omitted).toEqual(
|
||||
full.map(({ files: _files, pullRequest, ...commit }) => ({
|
||||
|
||||
@@ -46,6 +46,15 @@ import {
|
||||
environmentStateCaptureFixture,
|
||||
} from "../../fixtures/analysisView.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createLinkedIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
|
||||
import {
|
||||
createIl2cppBinaryFixture,
|
||||
FIXTURE_RETURN_TYPE_NAME,
|
||||
} from "../../fixtures/il2cpp/binary.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
il2cppMetadataSummaryViewResultSchema,
|
||||
} from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { BINARY_LAYOUT_TEST_PROVIDER } from "../../fixtures/binaryDiagnostics/layout.js";
|
||||
import { wasmArtifactFixture } from "../../fixtures/wasm/artifact.js";
|
||||
import { evmInterfaceFixture } from "../../fixtures/evm/interface.js";
|
||||
@@ -86,6 +95,115 @@ const connect = async (
|
||||
return { client, session };
|
||||
};
|
||||
|
||||
it("selects every IL2CPP native type reference by rendered name with stable row identity", async () => {
|
||||
const { client } = await connect();
|
||||
const root = await createTestTempDirectory("rea-il2cpp-native-names-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const binaryPath = join(root, "GameAssembly.dylib");
|
||||
await writeFile(binaryPath, createIl2cppBinaryFixture());
|
||||
const inspect = async (version: 27 | 31) => {
|
||||
await writeFile(path, createLinkedIl2cppMetadataFixture({ version }).bytes);
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_il2cpp_metadata",
|
||||
arguments: { path, binary_path: binaryPath },
|
||||
});
|
||||
expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true);
|
||||
const summary = il2cppMetadataSummaryViewResultSchema.parse(
|
||||
parseEvidence(inspected.structuredContent).normalized_result,
|
||||
);
|
||||
return {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
};
|
||||
};
|
||||
let source = await inspect(31);
|
||||
const select = async (
|
||||
collection: "return_types" | "field_types" | "parameter_types",
|
||||
filters: Record<string, unknown>,
|
||||
) => {
|
||||
const response = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: { kind: "il2cpp-metadata", collection, ...filters },
|
||||
},
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const page = analysisViewResultSchema.parse(
|
||||
parseEvidence(response.structuredContent).normalized_result,
|
||||
);
|
||||
if (page.kind !== "il2cpp-metadata")
|
||||
throw new Error("Expected IL2CPP page");
|
||||
return page;
|
||||
};
|
||||
for (const [collection, count] of [
|
||||
["return_types", 5],
|
||||
["field_types", 2],
|
||||
["parameter_types", 1],
|
||||
] as const) {
|
||||
const names = [FIXTURE_RETURN_TYPE_NAME];
|
||||
const expected = Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
type: 0,
|
||||
name: FIXTURE_RETURN_TYPE_NAME,
|
||||
}));
|
||||
const unfiltered = await select(collection, {});
|
||||
expect(unfiltered.items).toEqual(expected);
|
||||
const all = await select(collection, { names });
|
||||
expect(all.items).toEqual(expected);
|
||||
expect(all.coverage).toMatchObject({ total: count, exhausted: true });
|
||||
const selected = await select(collection, {
|
||||
names,
|
||||
indices: [count - 1, 0, count - 1],
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
fields: ["type"],
|
||||
});
|
||||
expect(selected.items).toEqual(
|
||||
count === 1 ? [] : [{ index: count - 1, type: 0 }],
|
||||
);
|
||||
expect(selected.coverage).toMatchObject({
|
||||
total: Math.min(count, 2),
|
||||
exhausted: true,
|
||||
});
|
||||
for (const missing of [[], ["absent"], ["system.Größe<int>[]"]]) {
|
||||
const empty = await select(collection, { names: missing });
|
||||
expect(empty.items).toEqual([]);
|
||||
expect(empty.coverage).toMatchObject({ total: 0, exhausted: true });
|
||||
}
|
||||
}
|
||||
const owned = await select("parameter_types", {
|
||||
names: [FIXTURE_RETURN_TYPE_NAME],
|
||||
method_indices: [2],
|
||||
});
|
||||
expect(owned.items).toEqual([
|
||||
{ index: 0, type: 0, name: FIXTURE_RETURN_TYPE_NAME },
|
||||
]);
|
||||
const otherOwner = await select("parameter_types", {
|
||||
names: [FIXTURE_RETURN_TYPE_NAME],
|
||||
method_indices: [0],
|
||||
});
|
||||
expect(otherOwner.items).toEqual([]);
|
||||
// Version 27 intentionally retains unavailable native display names as null.
|
||||
source = await inspect(27);
|
||||
for (const collection of [
|
||||
"return_types",
|
||||
"field_types",
|
||||
"parameter_types",
|
||||
] as const) {
|
||||
const unfiltered = await select(collection, {});
|
||||
expect(unfiltered.items.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
unfiltered.items.every((row) => "name" in row && row.name === null),
|
||||
).toBe(true);
|
||||
const unavailable = await select(collection, {
|
||||
names: [FIXTURE_RETURN_TYPE_NAME, "null", ""],
|
||||
});
|
||||
expect(unavailable.items).toEqual([]);
|
||||
expect(unavailable.coverage).toMatchObject({ total: 0, exhausted: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves native relationship coverage through inline and retained MCP views", async () => {
|
||||
const { client, session } = await connect();
|
||||
const { ajv, outputSchema } = await advertisedMcpToolSchemas(
|
||||
|
||||
@@ -264,14 +264,16 @@ it("rejects unavailable retention, invalid selectors and cancellation before pro
|
||||
retention,
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
expect(
|
||||
await inspectAndroidPackage(
|
||||
service,
|
||||
{ path: "/missing.apk", detail: "summary" },
|
||||
retention,
|
||||
{ signal: AbortSignal.abort() },
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
const reason = { request: "cancel before Android admission" };
|
||||
const cancelled = await inspectAndroidPackage(
|
||||
service,
|
||||
{ path: "/missing.apk", detail: "summary" },
|
||||
retention,
|
||||
{ signal: AbortSignal.abort(reason) },
|
||||
);
|
||||
if (cancelled.ok) throw new Error("Expected Android admission cancellation");
|
||||
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(cancelled.error.cause).toBe(reason);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Unexpected deep provider");
|
||||
|
||||
@@ -1,16 +1,83 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { ServerContext, ToolCallback } from "@modelcontextprotocol/server";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("preserves the exact reason when import is cancelled after reading the bundle", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-mcp-import-post-read-cancel-",
|
||||
);
|
||||
const path = join(root, "bundle.json");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Evidence import must not start a binary provider");
|
||||
});
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "imported_observation",
|
||||
parameters: {},
|
||||
result: { retained: true },
|
||||
},
|
||||
);
|
||||
await writeFile(path, JSON.stringify(createEvidenceBundle([evidence])));
|
||||
const registration = vi.spyOn(EvidenceMcpServer.prototype, "registerTool");
|
||||
const server = createServer({ kind: "session", session });
|
||||
onTestFinished(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const contract = toolContract("import_evidence_bundle");
|
||||
const registered = registration.mock.calls.find(
|
||||
([name]) => name === contract.name,
|
||||
);
|
||||
if (registered === undefined)
|
||||
throw new Error("Missing bundle import handler");
|
||||
const handler = registered[2] as unknown as ToolCallback<
|
||||
typeof contract.inputSchema
|
||||
>;
|
||||
const reason = { request: "cancel after authenticated bundle read" };
|
||||
const controller = new AbortController();
|
||||
const before = session.exportEvidenceBundle();
|
||||
const snapshot = session.immutableEvidenceBundle.bind(session);
|
||||
const snapshotRead = vi
|
||||
.spyOn(session, "immutableEvidenceBundle")
|
||||
.mockImplementationOnce(() => {
|
||||
const bundle = snapshot();
|
||||
controller.abort(reason);
|
||||
return bundle;
|
||||
});
|
||||
const imported = vi.spyOn(session, "importEvidenceBundle");
|
||||
const delivery = vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult");
|
||||
|
||||
const response = await handler(contract.inputSchema.parse({ path }), {
|
||||
mcpReq: { signal: controller.signal },
|
||||
} as ServerContext);
|
||||
const delivered = delivery.mock.calls.find(
|
||||
([, deliveredContract]) => deliveredContract.name === contract.name,
|
||||
)?.[0];
|
||||
if (delivered === undefined || delivered.ok)
|
||||
throw new Error("Expected import cancellation at delivery");
|
||||
expect(response.isError).toBe(true);
|
||||
expect(delivered.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(delivered.error.cause).toBe(reason);
|
||||
expect(snapshotRead).toHaveBeenCalledOnce();
|
||||
expect(imported).not.toHaveBeenCalled();
|
||||
expect(session.exportEvidenceBundle()).toEqual(before);
|
||||
});
|
||||
|
||||
it("keeps the complete session unchanged after SDK import cancellation and permits a retry", async () => {
|
||||
const root = await createTestTempDirectory("rea-mcp-import-cancel-");
|
||||
const path = join(root, "bundle.json");
|
||||
|
||||
@@ -42,11 +42,16 @@ const otoolOutputWithoutUuid = async () => {
|
||||
|
||||
class UuidlessDyldRunner implements NativeCommandRunner {
|
||||
readonly fixture: NativeFixtureRunner;
|
||||
readonly commands: {
|
||||
readonly tool: string;
|
||||
readonly args: readonly string[];
|
||||
}[] = [];
|
||||
|
||||
constructor(
|
||||
otool: string,
|
||||
readonly diagnostic = missingUuidDiagnostic,
|
||||
readonly stderr = "",
|
||||
readonly signal: string | null = null,
|
||||
) {
|
||||
this.fixture = new NativeFixtureRunner({
|
||||
"otool:-h": otool,
|
||||
@@ -59,6 +64,7 @@ class UuidlessDyldRunner implements NativeCommandRunner {
|
||||
arguments_: readonly string[],
|
||||
options: NativeCommandOptions,
|
||||
) {
|
||||
this.commands.push({ tool, args: arguments_ });
|
||||
if (tool !== "dyld_info") return this.fixture.run(tool, arguments_);
|
||||
const capture: NativeCommandCapture = {
|
||||
tool,
|
||||
@@ -74,7 +80,7 @@ class UuidlessDyldRunner implements NativeCommandRunner {
|
||||
),
|
||||
stderrBytes: Buffer.byteLength(this.stderr),
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
signal: this.signal,
|
||||
};
|
||||
if (options.acceptNonZero) return ok(capture);
|
||||
return err(
|
||||
@@ -91,6 +97,85 @@ class UuidlessDyldRunner implements NativeCommandRunner {
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ fileType: "4", diagnostic: "unknown mh.filetype 4" },
|
||||
{ fileType: "CORE", diagnostic: "unknown mh.filetype 4" },
|
||||
{ fileType: "3", diagnostic: "unknown mh.filetype 3" },
|
||||
{ fileType: "4", diagnostic: "unknown filetype 4" },
|
||||
{ fileType: "3", diagnostic: "unknown filetype 3" },
|
||||
{
|
||||
fileType: "10",
|
||||
diagnostic:
|
||||
"section '__TEXT/__text' has offset=0 but is not a zero-fill section type",
|
||||
},
|
||||
])(
|
||||
"classifies $diagnostic for Mach-O type $fileType before nm fallback",
|
||||
async ({ fileType, diagnostic }) => {
|
||||
const otool = [
|
||||
"Mach header",
|
||||
"magic cputype cpusubtype caps filetype ncmds sizeofcmds flags",
|
||||
`0xfeedfacf 16777228 0 0x00 ${fileType} 0 0 0x0`,
|
||||
].join("\n");
|
||||
const runner = new UuidlessDyldRunner(otool, diagnostic);
|
||||
const path = "/fixture/refused-type";
|
||||
const result = await new NativeMacOSProvider({}, runner, "darwin")
|
||||
.createClient(nativeMachoTarget(path))
|
||||
.execute("inspect_macho", {});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisUnsupportedTargetError",
|
||||
path,
|
||||
reason: expect.stringContaining(
|
||||
fileType === "10" ? "dSYM" : diagnostic,
|
||||
),
|
||||
remediationAction: expect.stringContaining("linked Mach-O"),
|
||||
capturedOutput: {
|
||||
stdout: dyldDiagnosticOutput(path, diagnostic),
|
||||
exit_code: 1,
|
||||
signal: null,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(runner.commands.filter(({ tool }) => tool === "nm")).toEqual([
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(["--no-dyldinfo"]),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ fileType: "2", signal: null, diagnostic: "unknown mh.filetype 4" },
|
||||
{ fileType: "UNKNOWN", signal: null, diagnostic: "unknown mh.filetype 4" },
|
||||
{ fileType: "4", signal: "SIGTERM", diagnostic: "unknown mh.filetype 4" },
|
||||
{ fileType: "2", signal: null, diagnostic: "unknown filetype 4" },
|
||||
{ fileType: "4", signal: "SIGTERM", diagnostic: "unknown filetype 4" },
|
||||
])(
|
||||
"keeps $diagnostic as a failure for header $fileType and signal $signal",
|
||||
async ({ fileType, signal, diagnostic }) => {
|
||||
const otool = [
|
||||
"Mach header",
|
||||
"magic cputype cpusubtype caps filetype ncmds sizeofcmds flags",
|
||||
`0xfeedfacf 16777228 0 0x00 ${fileType} 0 0 0x0`,
|
||||
].join("\n");
|
||||
const runner = new UuidlessDyldRunner(otool, diagnostic, "", signal);
|
||||
const result = await new NativeMacOSProvider({}, runner, "darwin")
|
||||
.createClient(nativeMachoTarget("/fixture/conflicting-type"))
|
||||
.execute("inspect_macho", {});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: signal === null ? "AnalysisOutputError" : "ProviderAdapterError",
|
||||
capturedOutput: { exit_code: 1, signal },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("returns readable Mach-O Evidence when dyld_info identifies a missing UUID", async () => {
|
||||
const withoutUuid = await otoolOutputWithoutUuid();
|
||||
const runner = new UuidlessDyldRunner(withoutUuid);
|
||||
|
||||
@@ -174,6 +174,25 @@ describe("detached provider and target metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the exact abort reason before external mutation admission", async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Cancelled mutation must not start a provider");
|
||||
});
|
||||
const reason = { request: "cancel before external mutation" };
|
||||
const operation = vi.fn(async () => resultOk(null));
|
||||
const result = await session.withAdmittedExternalMutation(
|
||||
undefined,
|
||||
AbortSignal.abort(reason),
|
||||
operation,
|
||||
(_value, error) => error,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected external mutation cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("drains session calls and retires analysis after an admitted external mutation", async () => {
|
||||
const [target] = await createBinarySessionTargets();
|
||||
const alias = join(dirname(target), "first-alias.hop");
|
||||
@@ -1097,6 +1116,24 @@ describe("provider health and availability", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the exact abort reason before composed admission", async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Cancelled admission must not start a provider");
|
||||
});
|
||||
const reason = { request: "cancel before composed admission" };
|
||||
const operation = vi.fn(async () => null);
|
||||
const result = await session.withAdmittedAnalysis(
|
||||
"binary_overview",
|
||||
AbortSignal.abort(reason),
|
||||
operation,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected composed admission cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
describe("typed unavailability without dispatching", () => {
|
||||
it("returns typed unavailability without dispatching a partial provider", async () => {
|
||||
const [first] = await createBinarySessionTargets();
|
||||
@@ -1228,18 +1265,26 @@ describe("typed unavailability without dispatching", () => {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
controller.abort();
|
||||
await expect(admitted).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "binary_overview",
|
||||
},
|
||||
});
|
||||
expect(invoked).toBe(false);
|
||||
health.resolve(ok(null));
|
||||
expect((await opening).ok).toBe(true);
|
||||
await session.close();
|
||||
const reason = { request: "cancel queued composed admission" };
|
||||
controller.abort(reason);
|
||||
try {
|
||||
const result = await admitted;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "binary_overview",
|
||||
},
|
||||
});
|
||||
if (result.ok) throw new Error("Expected queued admission cancellation");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(invoked).toBe(false);
|
||||
} finally {
|
||||
health.resolve(ok(null));
|
||||
const opened = await opening;
|
||||
await session.close();
|
||||
expect(opened.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -158,6 +158,32 @@ public class ReaJadxMetadataRegression {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void verifyRawClassSelectors(Method handle, Object bridge, JadxDecompiler decompiler)
|
||||
throws Exception {
|
||||
List<JavaClass> classes = decompiler.getClassesWithInners();
|
||||
JavaClass nested = classes.stream()
|
||||
.filter(cls -> cls.getRawName().contains("$") && !cls.getRawName().equals(cls.getFullName()))
|
||||
.filter(cls -> classes.stream().filter(other ->
|
||||
other.getFullName().equals(cls.getFullName()) || other.getRawName().equals(cls.getFullName()))
|
||||
.count() == 1)
|
||||
.findFirst().orElseThrow(() -> new AssertionError("Fixture has no unambiguous nested class"));
|
||||
JsonObject raw = arguments(nested.getRawName());
|
||||
JsonObject display = arguments(nested.getFullName());
|
||||
JsonObject summary = call(handle, bridge, "get_class_summary", raw);
|
||||
require(summary.get("raw_name").getAsString().equals(nested.getRawName()),
|
||||
"Class summary discarded original binary identity");
|
||||
require(summary.get("full_name").getAsString().equals(nested.getFullName()),
|
||||
"Class summary replaced the displayed name with the selector");
|
||||
require(summary.equals(call(handle, bridge, "get_class_summary", display)),
|
||||
"Raw and displayed selectors resolved different class metadata");
|
||||
JsonObject references = call(handle, bridge, "get_xrefs_to_class", raw);
|
||||
require(references.get("target").getAsString().equals(nested.getFullName()),
|
||||
"Reference target mixed its lookup selector with its displayed identity");
|
||||
require(references.equals(call(handle, bridge, "get_xrefs_to_class", display)),
|
||||
"Raw and displayed selectors resolved different incoming class references");
|
||||
System.out.println("RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true");
|
||||
}
|
||||
|
||||
private static void verifyClassPages(Method handle, Object bridge, List<String> inventory)
|
||||
throws Exception {
|
||||
int pages = 0;
|
||||
@@ -553,6 +579,7 @@ public class ReaJadxMetadataRegression {
|
||||
Method handle = bridgeType.getDeclaredMethod("handle", String.class, kotlinx.serialization.json.JsonObject.class);
|
||||
handle.setAccessible(true);
|
||||
verifyClassPages(handle, bridge, session.getClassFqns());
|
||||
verifyRawClassSelectors(handle, bridge, decompiler);
|
||||
JsonObject apiArgs = arguments("io.appium.android.apis.ApiDemos");
|
||||
JsonObject apiSummary = call(handle, bridge, "get_class_summary", apiArgs);
|
||||
for (var entry : states.entrySet()) require(entry.getKey().getState() == entry.getValue(),
|
||||
|
||||
Vendored
+1
@@ -158,6 +158,7 @@ register("list_classes", ({ offset, limit, query = "" }) => {
|
||||
register("get_class_summary", () =>
|
||||
json({
|
||||
full_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target",
|
||||
raw_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target",
|
||||
name: "Target",
|
||||
method_count: mode === "summary-error" ? -1 : methods.length,
|
||||
field_count: 0,
|
||||
|
||||
Reference in new issue
Block a user