mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 07:16:13 +00:00
feat(il2cpp): link native methods and export Ghidra seeds
Link IL2CPP metadata names to native addresses and types through shared CLI/MCP workflows, with bounded queries and atomic Ghidra seed export. Preserve the original implementation by 8rulerstar and follow-up correctness, resource, provenance, and CI dependency repairs. Verified against real Windows/Android artifacts, independent references, actual Ghidra, and focused CLI/MCP tests. Fixes #5112.
This commit is contained in:
1 parent
e50a271c72
commit
52d1a72af0
43 files changed
+5060
-368
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { createEmptyIl2cppMetadataFixture } from "../../domain/il2cpp/il2cppMetadata.fixture.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { Il2cppMetadataService } from "./Il2cppMetadataService.js";
|
||||
import {
|
||||
@@ -6,48 +8,18 @@ import {
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
IL2CPP_METADATA_SECTION_NAMES,
|
||||
type Il2cppMetadata,
|
||||
} from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import type { Il2cppMetadata } from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { EvidenceFileError } from "../../domain/evidenceErrors.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import type { Il2cppSeedWriterPort } from "./Il2cppSeedWriterPort.js";
|
||||
|
||||
const identity = {
|
||||
id: "fixture-il2cpp",
|
||||
name: "Source-owned IL2CPP reader seam",
|
||||
version: "1",
|
||||
};
|
||||
const report: Il2cppMetadata = {
|
||||
artifact: {
|
||||
path: "/artifacts/global-metadata.dat",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 1024,
|
||||
},
|
||||
magic: 0xfab11baf,
|
||||
metadata_version: 39,
|
||||
index_widths: {
|
||||
type_definition: 1,
|
||||
generic_container: 1,
|
||||
parameter: 1,
|
||||
type: 1,
|
||||
type_width_source: "interface-offsets-record",
|
||||
},
|
||||
sections: IL2CPP_METADATA_SECTION_NAMES.map((name) => ({
|
||||
name,
|
||||
offset: 380,
|
||||
size: 0,
|
||||
count: 0,
|
||||
})),
|
||||
images: [],
|
||||
types: [],
|
||||
methods: [],
|
||||
fields: [],
|
||||
properties: [],
|
||||
events: [],
|
||||
parameters: [],
|
||||
string_literals: [],
|
||||
limitations: [],
|
||||
};
|
||||
const report = createEmptyIl2cppMetadataFixture();
|
||||
|
||||
const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) =>
|
||||
new Il2cppMetadataService({
|
||||
@@ -68,8 +40,10 @@ it("rejects invalid paths and extra arguments before invoking the reader", async
|
||||
{ path: report.artifact.path, detail: "rows" },
|
||||
]) {
|
||||
const result = await service.inspect(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.any(AnalysisInputError),
|
||||
});
|
||||
}
|
||||
expect(calls.count).toBe(0);
|
||||
});
|
||||
@@ -150,3 +124,148 @@ it("reports cancellation before and after reading", async () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const linkedReport = (): Il2cppMetadata => ({
|
||||
...report,
|
||||
binary: {
|
||||
artifact: {
|
||||
path: "/artifacts/GameAssembly.dll",
|
||||
sha256: "b".repeat(64),
|
||||
bytes: 4096,
|
||||
},
|
||||
format: "pe",
|
||||
architecture: "amd64",
|
||||
image_base: "0x1000",
|
||||
code_gen_modules_field: "0x1100",
|
||||
code_gen_modules_array: "0x1200",
|
||||
code_gen_modules: [],
|
||||
types_field: "0x1300",
|
||||
types_count: 0,
|
||||
},
|
||||
});
|
||||
const seedWriter = (
|
||||
write: Il2cppSeedWriterPort["write"],
|
||||
close: () => Promise<void> = async () => {},
|
||||
): Il2cppSeedWriterPort => ({ check: async () => ok(undefined), write, close });
|
||||
const selectedPair = {
|
||||
path: report.artifact.path,
|
||||
binary_path: "/artifacts/GameAssembly.dll",
|
||||
detail: "complete",
|
||||
} as const;
|
||||
it("preserves source Evidence and requested output choices when publication fails", async () => {
|
||||
const failure = new EvidenceFileError("write", "io", {
|
||||
path: "/output/seeds.tsv",
|
||||
});
|
||||
const service = new Il2cppMetadataService(
|
||||
{ identity, inspect: async () => ok(linkedReport()) },
|
||||
seedWriter(async () => err(failure)),
|
||||
);
|
||||
const result = await service.inspectWithGhidraSeeds(selectedPair, {
|
||||
path: "/output/seeds.tsv",
|
||||
base: "0x2000",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed publication accepted");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
parameters: {
|
||||
path: selectedPair.path,
|
||||
binary_path: selectedPair.binary_path,
|
||||
ghidra_seeds: "/output/seeds.tsv",
|
||||
seed_base: "0x2000",
|
||||
},
|
||||
});
|
||||
await service.close();
|
||||
});
|
||||
it("retains a published seed receipt if cancellation arrives after the atomic write", async () => {
|
||||
const controller = new AbortController(),
|
||||
reason = new Error("cancel after publication");
|
||||
const receipt = {
|
||||
path: "/output/seeds.tsv",
|
||||
bytes: 200,
|
||||
functions: 0,
|
||||
folded: 0,
|
||||
no_body: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
const service = new Il2cppMetadataService(
|
||||
{ identity, inspect: async () => ok(linkedReport()) },
|
||||
seedWriter(async () => {
|
||||
controller.abort(reason);
|
||||
return ok(receipt);
|
||||
}),
|
||||
);
|
||||
const result = await service.inspectWithGhidraSeeds(
|
||||
selectedPair,
|
||||
{ path: receipt.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Cancellation ignored");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.diagnostics).toMatchObject({
|
||||
published_seed_output: receipt,
|
||||
});
|
||||
await service.close();
|
||||
});
|
||||
it("closes both ports and keeps combined cleanup incomplete until both owners resolve", async () => {
|
||||
const metadata = new ProviderCleanupError(
|
||||
identity.id,
|
||||
["metadata-reader"],
|
||||
{},
|
||||
),
|
||||
output = new ProviderCleanupError(identity.id, ["seed-output"], {});
|
||||
let metadataCloses = 0,
|
||||
seedCloses = 0;
|
||||
const service = new Il2cppMetadataService(
|
||||
{
|
||||
identity,
|
||||
inspect: async () => ok(report),
|
||||
close: async () => {
|
||||
metadataCloses++;
|
||||
throw metadata;
|
||||
},
|
||||
},
|
||||
seedWriter(
|
||||
async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async () => {
|
||||
seedCloses++;
|
||||
throw output;
|
||||
},
|
||||
),
|
||||
);
|
||||
let combined;
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
combined = cause;
|
||||
}
|
||||
expect(metadataCloses).toBe(1);
|
||||
expect(seedCloses).toBe(1);
|
||||
expect(combined).toBeInstanceOf(AnalysisError);
|
||||
if (!(combined instanceof AnalysisError))
|
||||
throw new Error("Missing cleanup failure");
|
||||
const failure = combined;
|
||||
expect([...failure.cleanupResources].sort()).toEqual([
|
||||
"metadata-reader",
|
||||
"seed-output",
|
||||
]);
|
||||
output.resolveCleanup();
|
||||
expect(failure.cleanupIncomplete).toBe(true);
|
||||
metadata.resolveCleanup();
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
it.each([false, true])(
|
||||
"classifies native association confidence (inferred: %s)",
|
||||
async (inferred) => {
|
||||
const value = linkedReport();
|
||||
if (inferred) value.binary!.code_gen_modules_field = null;
|
||||
const result = await serviceReturning(value).inspect(selectedPair);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.confidence).toBe(inferred ? "inferred" : "derived");
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,10 @@
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import type {
|
||||
Il2cppSeedOutput,
|
||||
Il2cppSeedWriterPort,
|
||||
Il2cppSeedReceipt,
|
||||
} from "./Il2cppSeedWriterPort.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import {
|
||||
@@ -6,7 +13,7 @@ import {
|
||||
type AnalysisViewRetention,
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
import type { Il2cppMetadataPort } from "./Il2cppMetadataPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
@@ -16,6 +23,8 @@ import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
il2cppMetadataSchema,
|
||||
inspectIl2cppMetadataInputSchema,
|
||||
type Il2cppMetadata,
|
||||
type Il2cppMetadataProviderInput,
|
||||
} from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -28,17 +37,68 @@ const NO_RETENTION: AnalysisViewRetention = {
|
||||
|
||||
/** Shared exact input validation, artifact-bound Evidence and summary delivery for CLI and MCP. */
|
||||
export class Il2cppMetadataService {
|
||||
constructor(readonly provider: Il2cppMetadataPort) {}
|
||||
constructor(
|
||||
readonly provider: Il2cppMetadataPort,
|
||||
private readonly seeds?: Il2cppSeedWriterPort,
|
||||
) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
async close(): Promise<void> {
|
||||
let failure: AnalysisError | undefined;
|
||||
for (const close of [
|
||||
() => this.provider.close?.(),
|
||||
() => this.seeds?.close(),
|
||||
]) {
|
||||
try {
|
||||
await close();
|
||||
} catch (cause) {
|
||||
const next =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
this.provider.identity.id,
|
||||
[],
|
||||
{
|
||||
reason:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ cause, operation: OPERATION },
|
||||
);
|
||||
failure =
|
||||
failure === undefined
|
||||
? next
|
||||
: analysisErrorWithCleanupFailure(
|
||||
failure,
|
||||
next,
|
||||
OPERATION,
|
||||
).bindCleanupDependencies([failure, next]);
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
}
|
||||
|
||||
/** Inspect one explicitly selected file; detail changes delivery, not decoded coverage. */
|
||||
async inspect(
|
||||
inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
retention: AnalysisViewRetention = NO_RETENTION,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
return this.#inspect(rawInput, options, retention);
|
||||
}
|
||||
|
||||
/** Explicit output workflow for CLI/SDK callers; ordinary inspection stays read-only. */
|
||||
inspectWithGhidraSeeds(
|
||||
rawInput: unknown,
|
||||
output: Il2cppSeedOutput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
return this.#inspect(rawInput, options, NO_RETENTION, output);
|
||||
}
|
||||
|
||||
/** Inspect one explicitly selected file; detail changes delivery, not decoded coverage. */
|
||||
async #inspect(
|
||||
rawInput: unknown,
|
||||
options: ExecutionOptions | undefined,
|
||||
retention: AnalysisViewRetention,
|
||||
output?: Il2cppSeedOutput,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
@@ -51,16 +111,54 @@ export class Il2cppMetadataService {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
for (const field of ["path", "binary_path"] as const) {
|
||||
const value = input.data[field];
|
||||
if (value !== undefined && !isAbsolute(value))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: [field],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.data.binary_architecture !== undefined &&
|
||||
input.data.binary_path === undefined
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
path: ["binary_architecture"],
|
||||
reason: "invalid_value",
|
||||
message: "binary_architecture selects a slice of binary_path.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (output !== undefined) {
|
||||
if (input.data.binary_path === undefined)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["ghidra_seeds"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"--ghidra-seeds needs --binary to link method addresses.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (this.seeds === undefined)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"No seed output adapter is configured.",
|
||||
),
|
||||
);
|
||||
const ready = await this.seeds.check(output, options);
|
||||
if (!ready.ok) return ready;
|
||||
}
|
||||
const { detail, ...providerInput } = input.data;
|
||||
if (detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(OPERATION, retention);
|
||||
@@ -69,7 +167,14 @@ export class Il2cppMetadataService {
|
||||
const inspected = await this.provider.inspect(providerInput, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
const report = il2cppMetadataSchema.safeParse(inspected.value);
|
||||
if (!report.success || report.data.artifact.path !== providerInput.path)
|
||||
if (
|
||||
!report.success ||
|
||||
report.data.artifact.path !== providerInput.path ||
|
||||
(report.data.binary?.artifact.path ?? undefined) !==
|
||||
providerInput.binary_path ||
|
||||
(providerInput.binary_architecture !== undefined &&
|
||||
report.data.binary?.architecture !== providerInput.binary_architecture)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
@@ -77,7 +182,38 @@ export class Il2cppMetadataService {
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
const evidence = createEvidence(
|
||||
const evidence = this.#evidence(value, providerInput, output);
|
||||
let seedReceipt: Il2cppSeedReceipt | undefined;
|
||||
const cancelled = () =>
|
||||
err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options?.signal?.reason,
|
||||
partialObservation: evidence,
|
||||
...(seedReceipt === undefined
|
||||
? {}
|
||||
: { diagnostics: { published_seed_output: { ...seedReceipt } } }),
|
||||
}),
|
||||
);
|
||||
if (options?.signal?.aborted) return cancelled();
|
||||
if (output !== undefined && this.seeds !== undefined) {
|
||||
const written = await this.seeds.write(value, output, options);
|
||||
if (!written.ok) {
|
||||
written.error.retainPartialObservation(evidence);
|
||||
return written;
|
||||
}
|
||||
seedReceipt = written.value;
|
||||
}
|
||||
if (options?.signal?.aborted) return cancelled();
|
||||
return detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence);
|
||||
}
|
||||
#evidence(
|
||||
value: Il2cppMetadata,
|
||||
providerInput: Il2cppMetadataProviderInput,
|
||||
output: Il2cppSeedOutput | undefined,
|
||||
): Evidence {
|
||||
return createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
@@ -86,12 +222,39 @@ export class Il2cppMetadataService {
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: providerInput,
|
||||
parameters: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(providerInput).filter(
|
||||
([, value]) => value !== undefined,
|
||||
),
|
||||
) as Record<string, string>),
|
||||
...(output === undefined
|
||||
? {}
|
||||
: {
|
||||
ghidra_seeds: output.path,
|
||||
...(output.base === undefined
|
||||
? {}
|
||||
: { seed_base: output.base }),
|
||||
}),
|
||||
},
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
confidence:
|
||||
value.binary === null
|
||||
? "observed"
|
||||
: value.binary.code_gen_modules_field === null
|
||||
? "inferred"
|
||||
: "derived",
|
||||
limitations: value.limitations,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: value.artifact.path },
|
||||
...(value.binary === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "artifact-path" as const,
|
||||
path: value.binary.artifact.path,
|
||||
},
|
||||
]),
|
||||
...value.sections
|
||||
.filter(
|
||||
({ name, size }) =>
|
||||
@@ -116,15 +279,5 @@ export class Il2cppMetadataService {
|
||||
],
|
||||
},
|
||||
);
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
partialObservation: evidence,
|
||||
}),
|
||||
);
|
||||
return detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Il2cppMetadata } from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
|
||||
/** Explicit file output, separate from read-only metadata inspection. */
|
||||
export interface Il2cppSeedOutput {
|
||||
readonly path: string;
|
||||
readonly base?: string;
|
||||
}
|
||||
export interface Il2cppSeedReceipt {
|
||||
readonly path: string;
|
||||
readonly bytes: number;
|
||||
readonly functions: number;
|
||||
readonly folded: number;
|
||||
readonly no_body: number;
|
||||
readonly unknown: number;
|
||||
}
|
||||
|
||||
/** Shared output workflow; adapters own file format and publication resources. */
|
||||
export interface Il2cppSeedWriterPort {
|
||||
check(
|
||||
output: Il2cppSeedOutput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<void, AnalysisError>>;
|
||||
write(
|
||||
report: Il2cppMetadata,
|
||||
output: Il2cppSeedOutput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Il2cppSeedReceipt, AnalysisError>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
Reference in new issue
Block a user