mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
feat(il2cpp): retain v108 header and string evidence (#9925)
* feat(il2cpp): retain v108 header and string evidence * fix(il2cpp): preserve v108 BOMs and yield while scanning * fix(il2cpp): preserve uncertainty in v108 layout refusals --------- Co-authored-by: dhruvac29 <dhruvac29@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
4 files changed
+359
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
IL2CPP_BINARY_BYTES,
|
||||
} from "./Il2cppBinaryFormat.js";
|
||||
import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js";
|
||||
import { inspectIl2cppMetadataV108 } from "./Il2cppMetadataV108.js";
|
||||
import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
@@ -299,6 +300,7 @@ const metadataSourceEvidence = (
|
||||
}
|
||||
| undefined = undefined,
|
||||
previous?: AnalysisPartialObservation,
|
||||
v108?: NonNullable<Awaited<ReturnType<typeof inspectIl2cppMetadataV108>>>,
|
||||
) =>
|
||||
createEvidence(
|
||||
{
|
||||
@@ -351,10 +353,16 @@ const metadataSourceEvidence = (
|
||||
...(counts === undefined
|
||||
? {}
|
||||
: { validated_section_row_counts: { ...counts } }),
|
||||
...(v108 === undefined ? {} : { version_108_partial_inventory: v108 }),
|
||||
},
|
||||
confidence: "observed",
|
||||
limitations: [
|
||||
"This partial source observation does not contain a complete metadata inventory. Section row counts, when available, are derived from the validated header extents and record layout; names and relationships may remain unexamined.",
|
||||
...(v108 === undefined
|
||||
? []
|
||||
: [
|
||||
"The version 108 header layout has not been checked against the reporter's file. Only section extents and a bounded sample of metadata strings were observed; type, method, literal, and binary layouts remain unverified and are not decoded.",
|
||||
]),
|
||||
],
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: artifact.path },
|
||||
@@ -470,6 +478,29 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
|
||||
const artifact = sourceArtifact(input.path, snapshot);
|
||||
observed.artifact = artifact;
|
||||
assertArtifactReadActive(options?.signal);
|
||||
const v108 = await inspectIl2cppMetadataV108(
|
||||
snapshot.bytes,
|
||||
async () => {
|
||||
await this.yieldControl();
|
||||
assertArtifactReadActive(options?.signal);
|
||||
},
|
||||
);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
if (v108 !== null) {
|
||||
const failure = new AnalysisUnsupportedTargetError(
|
||||
OPERATION,
|
||||
input.path,
|
||||
"unsupported-version: metadata version 108 has a partial header and string observation; type, method and binary layouts are not decoded",
|
||||
);
|
||||
failure.partialObservation = metadataSourceEvidence(
|
||||
artifact,
|
||||
undefined,
|
||||
selectedBinarySource(),
|
||||
undefined,
|
||||
v108,
|
||||
);
|
||||
return err(failure);
|
||||
}
|
||||
const limit = this.outputBytes;
|
||||
const admission = createIl2cppMetadataAdmission(
|
||||
limit,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { IL2CPP_METADATA_SECTION_NAMES } from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js";
|
||||
import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js";
|
||||
|
||||
// Header order follows LibCpp2IL's Il2CppGlobalMetadataHeader (v108):
|
||||
// https://github.com/SamboyCoding/Cpp2IL/blob/development/LibCpp2IL/Metadata/Il2CppGlobalMetadataHeader.cs
|
||||
// Record layouts and native registration are deliberately outside this observation.
|
||||
const SECTION_NAMES = [
|
||||
...IL2CPP_METADATA_SECTION_NAMES.slice(0, 20),
|
||||
"typeInlineArrays",
|
||||
...IL2CPP_METADATA_SECTION_NAMES.slice(20),
|
||||
"methodSpecsOnGenericType",
|
||||
"genericMethodSpecsOnType",
|
||||
"methodSpecs",
|
||||
"genericMethodFunctionsDefinitions",
|
||||
"genericMethodFunctionsDefinitionsWithAdjustor",
|
||||
"invokerIndices",
|
||||
"rgctxRanges",
|
||||
"rgctxValues",
|
||||
"staticConstructorTypeIndices",
|
||||
] as const;
|
||||
const HEADER_BYTES = 8 + SECTION_NAMES.length * 12;
|
||||
const SAMPLE_STRINGS = 16;
|
||||
const SAMPLE_BYTES = 128;
|
||||
const SCAN_BATCH_BYTES = 64 * 1024;
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
|
||||
/** A bounded, header-only observation; never a decoded metadata report. */
|
||||
export const inspectIl2cppMetadataV108 = async (
|
||||
bytes: Buffer,
|
||||
afterBatch?: () => Promise<void>,
|
||||
) => {
|
||||
const prefix = observeIl2cppMetadataHeaderPrefix(bytes);
|
||||
if (prefix.kind !== "il2cpp" || prefix.version !== 108) return null;
|
||||
if (bytes.length < HEADER_BYTES)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unsupported-version",
|
||||
`version 108 layout is unverified; the file has ${bytes.length} bytes, fewer than the assumed ${HEADER_BYTES}-byte header`,
|
||||
);
|
||||
const sections = SECTION_NAMES.map((name, index) => {
|
||||
const at = 8 + index * 12;
|
||||
return {
|
||||
name,
|
||||
offset: bytes.readInt32LE(at),
|
||||
size: bytes.readInt32LE(at + 4),
|
||||
count: bytes.readInt32LE(at + 8),
|
||||
};
|
||||
});
|
||||
for (const section of sections) {
|
||||
if (
|
||||
section.offset < 0 ||
|
||||
section.size < 0 ||
|
||||
section.count < 0 ||
|
||||
section.offset + section.size > bytes.length ||
|
||||
(section.size > 0 && section.offset < HEADER_BYTES)
|
||||
)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unsupported-version",
|
||||
`version 108 layout is unverified; assumed section ${section.name} (offset ${section.offset}, size ${section.size}, count ${section.count}) is invalid for the ${bytes.length}-byte file`,
|
||||
);
|
||||
}
|
||||
const occupied = sections
|
||||
.filter((section) => section.size > 0)
|
||||
.sort((a, b) => a.offset - b.offset);
|
||||
for (let index = 1; index < occupied.length; index++) {
|
||||
const previous = occupied[index - 1]!;
|
||||
const current = occupied[index]!;
|
||||
if (previous.offset + previous.size > current.offset)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unsupported-version",
|
||||
`version 108 layout is unverified; assumed sections ${previous.name} and ${current.name} overlap at ${current.offset}`,
|
||||
);
|
||||
}
|
||||
const strings = sections[2]!;
|
||||
const end = strings.offset + strings.size;
|
||||
const sample: Array<{
|
||||
offset: number;
|
||||
value: string | null;
|
||||
value_base64: string | null;
|
||||
truncated: boolean;
|
||||
}> = [];
|
||||
let start = strings.offset;
|
||||
let terminatedCount = 0;
|
||||
for (let batch = start; batch < end; batch += SCAN_BATCH_BYTES) {
|
||||
const batchEnd = Math.min(end, batch + SCAN_BATCH_BYTES);
|
||||
for (let at = batch; at < batchEnd; at++) {
|
||||
if (bytes[at] !== 0) continue;
|
||||
terminatedCount++;
|
||||
if (sample.length < SAMPLE_STRINGS) {
|
||||
const length = at - start;
|
||||
const raw = bytes.subarray(
|
||||
start,
|
||||
start + Math.min(length, SAMPLE_BYTES),
|
||||
);
|
||||
let value: string | null;
|
||||
try {
|
||||
value = utf8.decode(raw);
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
sample.push({
|
||||
offset: start - strings.offset,
|
||||
value,
|
||||
value_base64: value === null ? raw.toString("base64") : null,
|
||||
truncated: length > SAMPLE_BYTES,
|
||||
});
|
||||
}
|
||||
start = at + 1;
|
||||
}
|
||||
await afterBatch?.();
|
||||
}
|
||||
return {
|
||||
magic: prefix.magic,
|
||||
metadata_version: 108,
|
||||
header_bytes: HEADER_BYTES,
|
||||
layout_status: "unverified-with-real-v108-artifact",
|
||||
sections,
|
||||
strings: {
|
||||
terminated_count: terminatedCount,
|
||||
unterminated_tail_bytes: end - start,
|
||||
sample,
|
||||
sample_limit: SAMPLE_STRINGS,
|
||||
sample_bytes_per_string: SAMPLE_BYTES,
|
||||
},
|
||||
coverage: "header-and-bounded-string-sample-only",
|
||||
} as const;
|
||||
};
|
||||
@@ -56,6 +56,62 @@ const parseMetadataSummary = (value: unknown) =>
|
||||
parseEvidence(value).normalized_result,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"returns a v108 header inventory in CLI and MCP refusal evidence",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-v108-cli-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const strings = Buffer.from("Player\0");
|
||||
const bytes = Buffer.alloc(500 + strings.length);
|
||||
bytes.writeUInt32LE(0xfab11baf, 0);
|
||||
bytes.writeInt32LE(108, 4);
|
||||
for (let index = 0; index < 41; index++) {
|
||||
bytes.writeInt32LE(
|
||||
500 + (index > 2 ? strings.length : 0),
|
||||
8 + index * 12,
|
||||
);
|
||||
bytes.writeInt32LE(index === 2 ? strings.length : 0, 12 + index * 12);
|
||||
bytes.writeInt32LE(index === 2 ? strings.length : 0, 16 + index * 12);
|
||||
}
|
||||
strings.copy(bytes, 500);
|
||||
await writeFile(path, bytes);
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-il2cpp-metadata", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: {
|
||||
partial_observation: {
|
||||
normalized_result: {
|
||||
artifact: {
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
},
|
||||
version_108_partial_inventory: {
|
||||
coverage: "header-and-bounded-string-sample-only",
|
||||
strings: { sample: [{ value: "Player" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const response = await call("inspect_il2cpp_metadata", { path });
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: expect.any(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"rejects out-of-range module image references through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { Il2cppMetadataProvider } from "../../../src/il2cpp/Il2cppMetadataProvider.js";
|
||||
import { inspectIl2cppMetadataV108 } from "../../../src/il2cpp/Il2cppMetadataV108.js";
|
||||
import { Il2cppGhidraSeedWriter } from "../../../src/il2cpp/Il2cppGhidraSeedWriter.js";
|
||||
import { Il2cppMetadataService } from "../../../src/application/il2cpp/Il2cppMetadataService.js";
|
||||
import { il2cppMetadataSchema } from "../../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
@@ -41,6 +43,149 @@ const inspectMetadata = async (path: string, limit?: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Synthetic v108 header: checks bounded observations, not record-layout compatibility. */
|
||||
const version108Header = (stringBytes: Buffer) => {
|
||||
const header = Buffer.alloc(500);
|
||||
header.writeUInt32LE(0xfab11baf, 0);
|
||||
header.writeInt32LE(108, 4);
|
||||
let cursor = header.length;
|
||||
for (let index = 0; index < 41; index++) {
|
||||
const size = index === 2 ? stringBytes.length : 0;
|
||||
header.writeInt32LE(cursor, 8 + index * 12);
|
||||
header.writeInt32LE(size, 12 + index * 12);
|
||||
header.writeInt32LE(size, 16 + index * 12);
|
||||
cursor += size;
|
||||
}
|
||||
return Buffer.concat([header, stringBytes]);
|
||||
};
|
||||
|
||||
it("retains a bounded v108 header and string observation without claiming decoded rows", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-v108-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, version108Header(Buffer.from("Alpha\0Beta\0")));
|
||||
const result = await inspectMetadata(path);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: {
|
||||
inventory_status: "not-returned-as-complete",
|
||||
version_108_partial_inventory: {
|
||||
metadata_version: 108,
|
||||
header_bytes: 500,
|
||||
coverage: "header-and-bounded-string-sample-only",
|
||||
sections: expect.arrayContaining([
|
||||
{ name: "stringLiterals", offset: 500, size: 0, count: 0 },
|
||||
{ name: "stringLiteralData", offset: 500, size: 0, count: 0 },
|
||||
{ name: "strings", offset: 500, size: 11, count: 11 },
|
||||
]),
|
||||
strings: {
|
||||
terminated_count: 2,
|
||||
unterminated_tail_bytes: 0,
|
||||
sample: [
|
||||
{ offset: 0, value: "Alpha", truncated: false },
|
||||
{ offset: 6, value: "Beta", truncated: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.error.partialObservation)).not.toContain(
|
||||
'"images":[]',
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts padding after the assumed v108 header", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-v108-padding-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const bytes = version108Header(Buffer.from("Name\0"));
|
||||
const padded = Buffer.concat([
|
||||
bytes.subarray(0, 500),
|
||||
Buffer.alloc(16),
|
||||
bytes.subarray(500),
|
||||
]);
|
||||
for (let index = 0; index < 41; index++) {
|
||||
padded.writeInt32LE(bytes.readInt32LE(8 + index * 12) + 16, 8 + index * 12);
|
||||
}
|
||||
await writeFile(path, padded);
|
||||
const result = await inspectMetadata(path);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisUnsupportedTargetError);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: {
|
||||
version_108_partial_inventory: {
|
||||
strings: { sample: [{ value: "Name" }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains source evidence when a v108 section conflicts with the unverified layout", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-v108-invalid-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const bytes = version108Header(Buffer.from("Name\0"));
|
||||
bytes.writeInt32LE(bytes.length + 1, 12 + 2 * 12);
|
||||
await writeFile(path, bytes);
|
||||
const result = await inspectMetadata(path);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisUnsupportedTargetError);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: { artifact: { path, bytes: bytes.length } },
|
||||
});
|
||||
expect(JSON.stringify(result.error.partialObservation)).not.toContain(
|
||||
"version_108_partial_inventory",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds v108 string samples and preserves undecodable bytes and leading BOMs", async () => {
|
||||
const repeated = await inspectIl2cppMetadataV108(
|
||||
version108Header(Buffer.from("x\0".repeat(20))),
|
||||
);
|
||||
expect(repeated?.strings.terminated_count).toBe(20);
|
||||
expect(repeated?.strings.sample).toHaveLength(16);
|
||||
const invalid = await inspectIl2cppMetadataV108(
|
||||
version108Header(Buffer.from([0xff, 0])),
|
||||
);
|
||||
expect(invalid?.strings.sample).toEqual([
|
||||
{ offset: 0, value: null, value_base64: "/w==", truncated: false },
|
||||
]);
|
||||
const bom = await inspectIl2cppMetadataV108(
|
||||
version108Header(Buffer.from([0xef, 0xbb, 0xbf, 0x41, 0])),
|
||||
);
|
||||
expect(bom?.strings.sample).toEqual([
|
||||
{ offset: 0, value: "\uFEFFA", value_base64: null, truncated: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("yields during a large v108 string scan and honors cancellation before evidence", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-v108-cancel-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, version108Header(Buffer.alloc(128 * 1024, 65)));
|
||||
const controller = new AbortController();
|
||||
let batches = 0;
|
||||
const provider = new Il2cppMetadataProvider(undefined, async () => {
|
||||
batches++;
|
||||
controller.abort("cancel v108 scan");
|
||||
await setImmediate();
|
||||
});
|
||||
try {
|
||||
const result = await provider.inspect(
|
||||
{ path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(batches).toBe(1);
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(JSON.stringify(result.error.partialObservation)).not.toContain(
|
||||
"version_108_partial_inventory",
|
||||
);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** Version 29 file whose methods all share one long name. */
|
||||
const sharedNameMetadata = (
|
||||
nameBytes: number | Buffer,
|
||||
|
||||
Reference in new issue
Block a user