chore: merge latest main before publishing audit fixes

This commit is contained in:
morluto committed 2026-10-20 19:29:26 +08:00
commit 00a055be0f
24 files changed
+1707 -315

No files matched your search

@@ -41,6 +41,11 @@ const report: Il2cppMetadata = {
images: [],
types: [],
methods: [],
fields: [],
properties: [],
events: [],
parameters: [],
string_literals: [],
limitations: [],
};
@@ -100,7 +100,13 @@ export class Il2cppMetadataService {
name === "typeDefinitions" ||
name === "methods" ||
name === "nestedTypes" ||
name === "strings"),
name === "strings" ||
name === "fields" ||
name === "properties" ||
name === "events" ||
name === "parameters" ||
name === "stringLiterals" ||
name === "stringLiteralData"),
)
.map(({ offset, size }) => ({
kind: "file-offset-range" as const,
+2 -2
View File
@@ -18,11 +18,11 @@ export const registerIl2cppCommands = (
): void => {
cli.command(CLI_COMMANDS.inspectIl2cppMetadata, {
description:
"Inventory IL2CPP global-metadata.dat images, types and methods",
"Inventory IL2CPP global-metadata.dat names, owners and string literals",
args: z.object({
path: z
.string()
.describe("Local global-metadata.dat (version 29, 31 or 39)"),
.describe("Local global-metadata.dat (version 24, 27, 29, 31 or 39)"),
}),
options: z.object(analysisEvidenceDeliveryOptions),
run: ({ args, options }) =>
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. 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. 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 non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module refeLine truncated
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. 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. 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 non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module refeLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+1 -1
View File
@@ -15,7 +15,7 @@ export const IL2CPP_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_il2cpp_metadata"),
kind: "artifact-provider",
description:
"Inspect a local Unity IL2CPP global-metadata.dat (versions 29, 31 and 39). Default summary retains complete Evidence and returns artifact identity, version and counts and parent_evidence_id. Query inspect_analysis_view kind il2cpp-metadata, collection sections/images/types/methods, with exact indices or names, fields and pagination. Types also accept image_indices, namespaces and declaring_types; methods accept declaring_types. Filters intersect and empty arrays select none. Each row always keeps index, scoped to its artifact SHA-256 and collection; page offsets count matching rows in metadata order. Explicit detail complete returns all rows. Reads existing files, with 256 MiB input and 64 MiB encoded-report guards and one active inspection per reader. Unsupported versions and malformed metadata return reasons. Type declaring_type is derived from nestedTypes; native code addresses and runtime behavior remain unknown.",
"Inspect a local Unity IL2CPP global-metadata.dat (versions 24, 27, 29, 31 and 39). Default summary retains complete Evidence and returns artifact identity, version and counts and parent_evidence_id. Query inspect_analysis_view kind il2cpp-metadata, collection sections/images/types/methods/fields/properties/events/parameters/string_literals, with exact indices or names, fields and pagination. Types also accept image_indices, namespaces and declaring_types; methods, fields, properties and events accept declaring_types. Parameters accept method_indices. Filters intersect and empty arrays select none. Each row always keeps index, scoped to its artifact SHA-256 and collection; page offsets count matching rows in metadata order. Explicit detail complete returns all rows. Reads existing files, with 256 MiB input and 64 MiB encoded-report guards and one active inspection per reader. Unsupported versions and malformed metadata return reasons. Type declaring_type is derived from nestedTypes, and member and parameter owners from index ranges; native code addresses and runtime behavior remain unknown.",
inputSchema: inspectIl2cppMetadataInputSchema,
outputSchema: evidenceResultOf(
z.union([il2cppMetadataSchema, il2cppMetadataSummaryViewResultSchema]),
@@ -1,11 +1,12 @@
import { afterEach, expect, it, vi } from "vitest";
import {
IL2CPP_METADATA_SECTION_NAMES,
il2cppMetadataSchema,
il2cppMetadataParentSchema,
type Il2cppMetadata,
} from "../il2cpp/il2cppMetadata.js";
import { createEvidence } from "../evidence.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { jsonObjectSchema } from "../jsonValue.js";
import { projectIl2cppMetadataView } from "./il2cppMetadataView.js";
afterEach(() => {
@@ -55,6 +56,11 @@ const report: Il2cppMetadata = {
},
],
methods: [{ name: "Run", declaring_type: 0 }],
fields: [{ name: "value", declaring_type: 0 }],
properties: [],
events: [],
parameters: [{ name: "times", method: 0 }],
string_literals: [{ value: "Go", value_base64: null }],
limitations: [],
};
@@ -74,27 +80,139 @@ const page = {
} as const;
it("validates a sealed parent once across pages and keeps counts exact", () => {
const parse = vi.spyOn(il2cppMetadataSchema, "safeParse");
const parse = vi.spyOn(il2cppMetadataParentSchema, "safeParse");
const sealed = parent(freezeJsonSnapshot(structuredClone(report)), true);
for (const offset of [0, 1]) {
const view = projectIl2cppMetadataView(sealed, { ...page, offset });
if (!view.ok) throw view.error;
expect(view.value).toMatchObject({
items: [{ index: offset, ...report.types[offset] }],
counts: { images: 1, types: 2, methods: 1, nested_types: 1 },
counts: {
sections: report.sections.length,
images: 1,
types: 2,
methods: 1,
nested_types: 1,
fields: 1,
properties: 0,
events: 0,
parameters: 1,
string_literals: 1,
},
});
}
expect(parse).toHaveBeenCalledTimes(1);
});
it("revalidates unsealed inline input on every page", () => {
const parse = vi.spyOn(il2cppMetadataSchema, "safeParse");
const parse = vi.spyOn(il2cppMetadataParentSchema, "safeParse");
const inline = parent(structuredClone(report), false);
projectIl2cppMetadataView(inline, page);
projectIl2cppMetadataView(inline, page);
expect(parse).toHaveBeenCalledTimes(2);
});
it("preserves historical omission as unknown while keeping existing metadata queries usable", () => {
const historical = jsonObjectSchema.parse(report);
for (const key of [
"fields",
"properties",
"events",
"parameters",
"string_literals",
])
delete historical[key];
const source = parent(freezeJsonSnapshot(historical), true);
const summary = projectIl2cppMetadataView(source, { kind: "summary" });
if (!summary.ok) throw summary.error;
expect(summary.value).toMatchObject({
summary: {
counts: { types: 2, methods: 1, fields: null, parameters: null },
},
unknowns: expect.arrayContaining([
expect.stringContaining("did not collect fields"),
]),
});
const existing = projectIl2cppMetadataView(source, page);
if (!existing.ok) throw existing.error;
expect(existing.value).toMatchObject({
items: [{ index: 0, name: "Outer" }],
});
const unavailable = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "parameters",
offset: 0,
limit: 1,
});
expect(unavailable.ok).toBe(false);
});
it.each([
{ fields: [{ name: "bad", declaring_type: 2 }] },
{ parameters: [{ name: "bad", method: 1 }] },
{ string_literals: [{ value: null, value_base64: null }] },
{ string_literals: [{ value: "Go", value_base64: "R28=" }] },
])(
"rejects inconsistent member ownership or literal representation in authenticated parent facts: %j",
(changed) => {
const result = projectIl2cppMetadataView(
parent({ ...report, ...changed }, false),
{ kind: "summary" },
);
expect(result.ok).toBe(false);
},
);
it("selects a batch of parameter owners before paging and intersects exact identities and names", () => {
const source = parent(
freezeJsonSnapshot({
...report,
parameters: [
{ name: "first", method: 0 },
{ name: "unclaimed", method: -1 },
{ name: "last", method: 0 },
],
}),
true,
);
const query = {
kind: "il2cpp-metadata",
collection: "parameters",
method_indices: [0, 0, 99],
fields: ["name", "method"],
offset: 1,
limit: 1,
} as const;
const result = projectIl2cppMetadataView(source, {
...query,
method_indices: [...query.method_indices],
fields: [...query.fields],
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
items: [{ index: 2, name: "last", method: 0 }],
coverage: { total: 2, exhausted: true, next_offset: null },
});
for (const selection of [
{ method_indices: [] },
{ method_indices: [99] },
{ method_indices: [0], indices: [1] },
{ method_indices: [0], names: ["unclaimed"] },
]) {
const empty = projectIl2cppMetadataView(source, {
...query,
...selection,
offset: 0,
fields: [...query.fields],
});
if (!empty.ok) throw empty.error;
expect(empty.value).toMatchObject({
items: [],
coverage: { total: 0, exhausted: true },
});
}
});
it("intersects batch identities, fields, image ranges and exact names in source order", () => {
const source = parent(freezeJsonSnapshot(structuredClone(report)), true);
const result = projectIl2cppMetadataView(source, {
+125 -17
View File
@@ -5,10 +5,14 @@ import type { JsonValue } from "../jsonValue.js";
import { isImmutableJsonSnapshot } from "../immutableJson.js";
import {
il2cppMetadataImageSchema,
il2cppMetadataMemberSchema,
il2cppMetadataMethodSchema,
il2cppMetadataSectionSchema,
il2cppMetadataParameterSchema,
il2cppMetadataStringLiteralSchema,
il2cppMetadataObjectSchema,
il2cppMetadataSchema,
il2cppMetadataParentSchema,
il2cppMetadataMemberCollections,
il2cppMetadataTypeSchema,
} from "../il2cpp/il2cppMetadata.js";
import { err, ok, type Result } from "../result.js";
@@ -29,10 +33,25 @@ const countsSchema = z.strictObject({
types: z.number().int().nonnegative(),
methods: z.number().int().nonnegative(),
nested_types: z.number().int().nonnegative(),
fields: z.number().int().nonnegative().nullable(),
properties: z.number().int().nonnegative().nullable(),
events: z.number().int().nonnegative().nullable(),
parameters: z.number().int().nonnegative().nullable(),
string_literals: z.number().int().nonnegative().nullable(),
});
export const il2cppMetadataSummarySchema = il2cppMetadataObjectSchema
.omit({ images: true, types: true, methods: true, sections: true })
.omit({
sections: true,
images: true,
types: true,
methods: true,
fields: true,
properties: true,
events: true,
parameters: true,
string_literals: true,
})
.extend({ counts: countsSchema });
const indexSchema = z.number().int().nonnegative();
@@ -44,13 +63,22 @@ const typeRowSchema = il2cppMetadataTypeSchema.extend({ index: indexSchema });
const methodRowSchema = il2cppMetadataMethodSchema.extend({
index: indexSchema,
});
const page = {
const memberRowSchema = il2cppMetadataMemberSchema.extend({
index: indexSchema,
});
const parameterRowSchema = il2cppMetadataParameterSchema.extend({
index: indexSchema,
});
const stringLiteralRowSchema = il2cppMetadataStringLiteralSchema.extend({
index: indexSchema,
});
const indexPage = {
kind: z.literal("il2cpp-metadata"),
indices: z.array(indexSchema).optional(),
names: z.array(z.string()).optional(),
offset: indexSchema.default(0),
limit: z.number().int().positive().default(64),
};
const page = { ...indexPage, names: z.array(z.string()).optional() };
const declaringTypes = z.array(z.number().int().min(-1)).optional();
/** Filters intersect; pages use metadata order and always retain the source row index. */
@@ -81,6 +109,25 @@ export const il2cppMetadataViewRequestSchema = z.discriminatedUnion(
declaring_types: declaringTypes,
fields: z.array(methodRowSchema.keyof()).optional(),
}),
...(["fields", "properties", "events"] as const).map((collection) =>
z.strictObject({
...page,
collection: z.literal(collection),
declaring_types: declaringTypes,
fields: z.array(memberRowSchema.keyof()).optional(),
}),
),
z.strictObject({
...page,
collection: z.literal("parameters"),
method_indices: z.array(indexSchema).optional(),
fields: z.array(parameterRowSchema.keyof()).optional(),
}),
z.strictObject({
...indexPage,
collection: z.literal("string_literals"),
fields: z.array(stringLiteralRowSchema.keyof()).optional(),
}),
],
);
@@ -89,6 +136,9 @@ export const il2cppMetadataViewItemsSchema = z.union([
z.array(imageRowSchema.partial().required({ index: true })),
z.array(typeRowSchema.partial().required({ index: true })),
z.array(methodRowSchema.partial().required({ index: true })),
z.array(memberRowSchema.partial().required({ index: true })),
z.array(parameterRowSchema.partial().required({ index: true })),
z.array(stringLiteralRowSchema.partial().required({ index: true })),
]);
export const il2cppMetadataViewContextSchema = z.strictObject({
@@ -96,9 +146,9 @@ export const il2cppMetadataViewContextSchema = z.strictObject({
counts: countsSchema,
});
type Report = z.infer<typeof il2cppMetadataSchema>;
type Report = z.infer<typeof il2cppMetadataParentSchema>;
type Request = z.infer<typeof il2cppMetadataViewRequestSchema>;
type Row = Report[Request["collection"]][number];
type Row = NonNullable<Report[Request["collection"]]>[number];
interface CheckedReport {
readonly report: Report;
readonly counts: z.infer<typeof countsSchema>;
@@ -119,7 +169,7 @@ const parseMetadataReport = (
isImmutableJsonSnapshot(key);
const cached = reusable ? checkedReports.get(key) : undefined;
if (cached !== undefined) return ok(cached);
const parsed = parseAnalysisViewResult(il2cppMetadataSchema, parent);
const parsed = parseAnalysisViewResult(il2cppMetadataParentSchema, parent);
if (!parsed.ok) return parsed;
// This schema has no coercions/transforms; the authenticated owner value is
// equivalent to the validated clone and remains immutable across pages.
@@ -135,6 +185,11 @@ const parseMetadataReport = (
(count, type) => count + Number(type.declaring_type !== -1),
0,
),
fields: report.fields?.length ?? null,
properties: report.properties?.length ?? null,
events: report.events?.length ?? null,
parameters: report.parameters?.length ?? null,
string_literals: report.string_literals?.length ?? null,
},
};
if (reusable) checkedReports.set(key, checked);
@@ -168,12 +223,18 @@ const selectFields = (
/** Compile caller selections once; large batches do not multiply per-row work. */
const selection = (view: Request, report: Report) => {
const names = view.names === undefined ? undefined : new Set(view.names);
const names =
!("names" in view) || view.names === undefined
? undefined
: new Set(view.names);
const owners =
(view.collection !== "types" && view.collection !== "methods") ||
view.declaring_types === undefined
!("declaring_types" in view) || view.declaring_types === undefined
? undefined
: new Set(view.declaring_types);
const methods =
view.collection === "parameters" && view.method_indices !== undefined
? new Set(view.method_indices)
: undefined;
const namespaces =
view.collection !== "types" || view.namespaces === undefined
? undefined
@@ -203,7 +264,11 @@ const selection = (view: Request, report: Report) => {
}
}
return (row: Row, index: number) => {
if (names !== undefined && !names.has(row.name)) return false;
if (
names !== undefined &&
(!("name" in row) || row.name === null || !names.has(row.name))
)
return false;
if (
owners !== undefined &&
(!("declaring_type" in row) || !owners.has(row.declaring_type))
@@ -214,6 +279,11 @@ const selection = (view: Request, report: Report) => {
(!("namespace" in row) || !namespaces.has(row.namespace))
)
return false;
if (
methods !== undefined &&
(!("method" in row) || !methods.has(row.method))
)
return false;
if (imageIndices === undefined) return true;
let lower = 0;
let upper = ranges.length;
@@ -239,7 +309,7 @@ export const projectIl2cppMetadataView = (
path: ["view"],
reason: "invalid_value",
message:
"IL2CPP metadata Evidence supports summary and selected sections/images/types/methods pages.",
"IL2CPP metadata Evidence supports summary and selected sections, images, types, methods, member, parameter and string literal pages.",
},
]),
);
@@ -262,7 +332,29 @@ export const projectIl2cppMetadataView = (
},
]),
);
const { sections, images, types, methods, ...identity } = report;
const {
sections,
images,
types,
methods,
fields,
properties,
events,
parameters,
string_literals,
...identity
} = report;
const allRows = {
sections,
images,
types,
methods,
fields,
properties,
events,
parameters,
string_literals,
};
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_il2cpp_metadata" as const,
@@ -270,7 +362,13 @@ export const projectIl2cppMetadataView = (
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
limitations: [...parent.limitations],
unknowns: [
"Counts and page exhaustion cover retained metadata facts, not runtime loading or native code addresses.",
...il2cppMetadataMemberCollections
.filter((name) => report[name] === undefined)
.map(
(name) =>
`Retained Evidence did not collect ${name}; its count is unknown. Reinspect the metadata to acquire these facts.`,
),
"Names come from unencrypted metadata only; native code addresses and runtime registration remain unknown.",
...(parent.subject == null
? [
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
@@ -286,9 +384,19 @@ export const projectIl2cppMetadataView = (
summary: { ...identity, counts },
coverage: completeWithinViewCoverage(1, 1),
});
const rows = { sections, images, types, methods }[view.collection];
const rows = allRows[view.collection];
if (rows === undefined)
return err(
analysisViewInputError([
{
path: ["view", "collection"],
reason: "invalid_value",
message: `Retained Evidence did not collect ${view.collection}; inspect the metadata again to acquire these facts.`,
},
]),
);
const matches = selection(view, report);
const fields =
const selectedFields =
view.fields === undefined ? undefined : new Set<string>(view.fields);
const items = [];
let total = 0;
@@ -296,7 +404,7 @@ export const projectIl2cppMetadataView = (
const row = rows[index]!;
if (!matches(row, index)) continue;
if (total >= view.offset && items.length < view.limit)
items.push(selectFields(row, index, fields));
items.push(selectFields(row, index, selectedFields));
total++;
}
return ok({
+148 -20
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { localPathStringSchema } from "../localPath.js";
import { canonicalBase64Schema } from "../stringPatterns.js";
/** Unsigned little-endian signature observed at file offset zero. */
export const IL2CPP_METADATA_MAGIC = 0xfab11baf;
@@ -40,8 +41,43 @@ export const IL2CPP_METADATA_SECTION_NAMES = [
"exportedTypeDefinitions",
] as const;
/** Decoded metadata versions; 29 and 31 use fixed 4-byte indices. */
export const IL2CPP_METADATA_VERSIONS = [29, 31, 39] as const;
/** Version 27 names for the four custom-attribute and indirect-call sections. */
export const IL2CPP_METADATA_V27_SECTION_NAMES = [
...IL2CPP_METADATA_SECTION_NAMES.slice(0, 24),
"attributesInfo",
"attributeTypes",
"unresolvedVirtualCallParameterTypes",
"unresolvedVirtualCallParameterRanges",
...IL2CPP_METADATA_SECTION_NAMES.slice(28),
] as const;
/** Version 24 header: 32 (offset, byte size) pairs in this order. */
export const IL2CPP_METADATA_V24_SECTION_NAMES = [
...IL2CPP_METADATA_SECTION_NAMES.slice(0, 22),
"metadataUsageLists",
"metadataUsagePairs",
"fieldRefs",
"referencedAssemblies",
"attributesInfo",
"attributeTypes",
"unresolvedVirtualCallParameterTypes",
"unresolvedVirtualCallParameterRanges",
"windowsRuntimeTypeNames",
"exportedTypeDefinitions",
] as const;
/** Decoded metadata versions; all but 39 use fixed 4-byte indices. */
export const IL2CPP_METADATA_VERSIONS = [24, 27, 29, 31, 39] as const;
/** Header section names in order for one metadata version. */
export const il2cppMetadataSectionNames = (
version: number,
): readonly IL2cppMetadataSectionName[] =>
version === 24
? IL2CPP_METADATA_V24_SECTION_NAMES
: version === 27
? IL2CPP_METADATA_V27_SECTION_NAMES
: IL2CPP_METADATA_SECTION_NAMES;
const count = z.number().int().nonnegative();
/** Serialized metadata indices use -1 for an absent reference. */
@@ -57,12 +93,24 @@ export const inspectIl2cppMetadataInputSchema = z.strictObject({
.enum(["complete", "summary"])
.default("summary")
.describe(
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type and method row inline",
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
),
});
export type IL2cppMetadataSectionName = z.infer<
typeof il2cppMetadataSectionSchema
>["name"];
export const il2cppMetadataSectionSchema = z.strictObject({
name: z.enum(IL2CPP_METADATA_SECTION_NAMES),
name: z.enum([
...IL2CPP_METADATA_SECTION_NAMES,
"attributesInfo",
"attributeTypes",
"unresolvedVirtualCallParameterTypes",
"unresolvedVirtualCallParameterRanges",
"metadataUsageLists",
"metadataUsagePairs",
]),
offset: count,
size: count,
/** Header-recorded entry count; versions 29 and 31 record none. */
@@ -98,6 +146,26 @@ export const il2cppMetadataMethodSchema = z.strictObject({
),
});
/** Field, property or event name and the type whose member range holds it. */
export const il2cppMetadataMemberSchema = z.strictObject({
name: z.string(),
/** -1 when no type range claims the row. */
declaring_type: optionalIndex,
});
/** Parameter name and the method whose parameter range holds it. */
export const il2cppMetadataParameterSchema = z.strictObject({
name: z.string(),
/** -1 when no method range claims the row. */
method: optionalIndex,
});
/** Managed string literal; undecodable UTF-8 keeps its exact bytes instead. */
export const il2cppMetadataStringLiteralSchema = z.strictObject({
value: z.string().nullable(),
value_base64: canonicalBase64Schema.nullable(),
});
/** Unrefined report fields, shared with projections that omit row collections. */
export const il2cppMetadataObjectSchema = z.strictObject({
artifact: z.strictObject({
@@ -120,34 +188,94 @@ export const il2cppMetadataObjectSchema = z.strictObject({
"fixed-width-version",
]),
}),
sections: z
.array(il2cppMetadataSectionSchema)
.length(IL2CPP_METADATA_SECTION_NAMES.length),
sections: z.array(il2cppMetadataSectionSchema),
images: z.array(il2cppMetadataImageSchema),
types: z.array(il2cppMetadataTypeSchema),
methods: z.array(il2cppMetadataMethodSchema),
fields: z.array(il2cppMetadataMemberSchema),
properties: z.array(il2cppMetadataMemberSchema),
events: z.array(il2cppMetadataMemberSchema),
parameters: z.array(il2cppMetadataParameterSchema),
string_literals: z.array(il2cppMetadataStringLiteralSchema),
limitations: z.array(z.string()),
});
/** Complete name and count inventory of one decoded global-metadata.dat file. */
export const il2cppMetadataSchema = il2cppMetadataObjectSchema.superRefine(
(report, context) => {
report.sections.forEach((section, index) => {
if (section.name !== IL2CPP_METADATA_SECTION_NAMES[index])
export const il2cppMetadataMemberCollections = [
"fields",
"properties",
"events",
"parameters",
"string_literals",
] as const;
/** Historical reports retain omission instead of inventing empty member inventories. */
const il2cppMetadataParentObjectSchema = il2cppMetadataObjectSchema.partial({
fields: true,
properties: true,
events: true,
parameters: true,
string_literals: true,
});
const validateMetadataFacts = (
report: z.infer<typeof il2cppMetadataParentObjectSchema>,
context: z.RefinementCtx,
): void => {
const names = il2cppMetadataSectionNames(report.metadata_version);
if (report.sections.length !== names.length)
context.addIssue({
code: "custom",
path: ["sections"],
message: `Version ${report.metadata_version} headers have ${names.length} sections.`,
});
report.sections.forEach((section, index) => {
if (section.name !== names[index])
context.addIssue({
code: "custom",
path: ["sections", index, "name"],
message: "Sections must follow header order.",
});
if (section.offset + section.size > report.artifact.bytes)
context.addIssue({
code: "custom",
path: ["sections", index],
message: "Section range is outside the selected artifact.",
});
});
for (const collection of ["fields", "properties", "events"] as const)
report[collection]?.forEach((row, index) => {
if (row.declaring_type >= report.types.length)
context.addIssue({
code: "custom",
path: ["sections", index, "name"],
message: "Sections must follow header order.",
});
if (section.offset + section.size > report.artifact.bytes)
context.addIssue({
code: "custom",
path: ["sections", index],
message: "Section range is outside the selected artifact.",
path: [collection, index, "declaring_type"],
message: "Member owner is outside the retained type definitions.",
});
});
},
report.parameters?.forEach((row, index) => {
if (row.method >= report.methods.length)
context.addIssue({
code: "custom",
path: ["parameters", index, "method"],
message: "Parameter owner is outside the retained method definitions.",
});
});
report.string_literals?.forEach((row, index) => {
if ((row.value === null) === (row.value_base64 === null))
context.addIssue({
code: "custom",
path: ["string_literals", index],
message:
"A literal must retain exactly one UTF-8 or byte-exact base64 representation.",
});
});
};
export const il2cppMetadataSchema = il2cppMetadataObjectSchema.superRefine(
validateMetadataFacts,
);
export const il2cppMetadataParentSchema =
il2cppMetadataParentObjectSchema.superRefine(validateMetadataFacts);
export type InspectIl2cppMetadataInput = z.infer<
typeof inspectIl2cppMetadataInputSchema
+66
View File
@@ -0,0 +1,66 @@
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
import type {
Il2cppMetadataReadOptions,
Il2cppMetadataRowCounts,
} from "./Il2cppMetadataFile.js";
// Minimum serialized row shapes; actual integer widths and the report envelope
// are checked exactly after decoding. This lower bound never refuses a fitting report.
const emptyRows = {
images: { name: "", type_start: 0, type_count: 0 },
types: {
name: "",
namespace: "",
method_start: 0,
method_count: 0,
nested_type_start: 0,
nested_type_count: 0,
declaring_type: 0,
},
methods: { name: "", declaring_type: 0 },
fields: { name: "", declaring_type: 0 },
properties: { name: "", declaring_type: 0 },
events: { name: "", declaring_type: 0 },
parameters: { name: "", method: 0 },
string_literals: { value: "", value_base64: null },
};
/** Per-snapshot admission, before text expansion and each retained fact. */
export const createIl2cppMetadataAdmission = (
limit: number,
exceed: () => never,
): Required<Il2cppMetadataReadOptions> => {
let used = 0;
// Keys/decoded names/literal spans are immutable within this one decoder run.
const lengths = new Map<string, number>();
const text = (value: string): number => {
const cached = lengths.get(value);
if (cached !== undefined) return cached;
const length = jsonStringByteLength(value, limit - used);
lengths.set(value, length);
return length;
};
return {
beforeRows(counts: Il2cppMetadataRowCounts) {
let minimum = 0;
for (const key of Object.keys(emptyRows) as (keyof typeof emptyRows)[])
minimum += counts[key] * JSON.stringify(emptyRows[key]).length;
if (minimum > limit) exceed();
},
beforeText(bytes) {
if (bytes > limit - used) exceed();
},
onRow(row) {
let bytes = 2,
first = true;
for (const [key, value] of Object.entries(row)) {
bytes += (first ? 0 : 1) + text(key) + 1;
first = false;
bytes +=
typeof value === "string" ? text(value) : value === null ? 4 : 1;
if (used + bytes > limit) exceed();
}
used += bytes;
},
};
};
+146 -88
View File
@@ -1,54 +1,24 @@
import {
checkRange,
Il2cppMetadataFormatFailure,
} from "./Il2cppMetadataFormat.js";
import {
IL2CPP_METADATA_MAGIC,
IL2CPP_METADATA_SECTION_NAMES,
IL2CPP_METADATA_VERSIONS,
il2cppMetadataSectionNames,
type Il2cppMetadata,
type Il2cppMetadataSection,
type IL2cppMetadataSectionName,
} from "../domain/il2cpp/il2cppMetadata.js";
import {
readIl2cppMembers,
readIl2cppMemberTables,
type MetadataOwnerRange,
} from "./Il2cppMetadataMembers.js";
const NESTED_TYPE_ENTRY_BYTES = 4;
/** Stable machine-readable reason for each rejected input. */
export const IL2CPP_METADATA_FAILURE_REASONS = {
unsupported: ["unrecognized-magic", "unsupported-version"],
corrupt: [
"truncated-header",
"unexpected-header-size",
"section-out-of-bounds",
"section-overlap",
"record-size-mismatch",
"type-index-width",
"index-out-of-range",
"nested-type-conflict",
"declaring-type-mismatch",
"image-range-overlap",
"invalid-string",
],
} as const;
export type Il2cppMetadataFailureKind =
keyof typeof IL2CPP_METADATA_FAILURE_REASONS;
export type Il2cppMetadataFailureReason =
(typeof IL2CPP_METADATA_FAILURE_REASONS)[Il2cppMetadataFailureKind][number];
/** Distinguish inputs outside decoder coverage from damaged metadata. */
export class Il2cppMetadataFormatFailure extends Error {
readonly kind: Il2cppMetadataFailureKind;
constructor(
readonly reason: Il2cppMetadataFailureReason,
message: string,
options?: ErrorOptions,
) {
super(`${reason}: ${message}`, options);
this.name = "Il2cppMetadataFormatFailure";
this.kind = (
IL2CPP_METADATA_FAILURE_REASONS.unsupported as readonly string[]
).includes(reason)
? "unsupported"
: "corrupt";
}
}
type SectionName = (typeof IL2CPP_METADATA_SECTION_NAMES)[number];
type SectionName = IL2cppMetadataSectionName;
type IndexWidth = 1 | 2 | 4;
export type Il2cppMetadataVersion = (typeof IL2CPP_METADATA_VERSIONS)[number];
type RecordSection = "typeDefinitions" | "images" | "methods";
@@ -64,14 +34,24 @@ export type Il2cppMetadataDecoded = Omit<
*/
interface VersionLayout {
readonly indexing: "fixed" | "counted";
/** Version 29 and 31 type records also carry elementTypeIndex. */
readonly typeDefinitionTypeIndices: 3 | 4;
/** Type index fields: version 24 adds byref, 24 to 31 add elementTypeIndex. */
readonly typeDefinitionTypeIndices: 3 | 4 | 5;
/** Version 31 added returnParameterToken to method records. */
readonly methodReturnParameterToken: boolean;
}
const VERSION_LAYOUTS: Readonly<Record<Il2cppMetadataVersion, VersionLayout>> =
{
27: {
indexing: "fixed",
typeDefinitionTypeIndices: 4,
methodReturnParameterToken: false,
},
24: {
indexing: "fixed",
typeDefinitionTypeIndices: 5,
methodReturnParameterToken: false,
},
29: {
indexing: "fixed",
typeDefinitionTypeIndices: 4,
@@ -95,7 +75,7 @@ const sectionEntryBytes = (layout: VersionLayout) =>
/** Magic, version and one entry per section. */
export const il2cppMetadataHeaderBytes = (version: Il2cppMetadataVersion) =>
8 +
IL2CPP_METADATA_SECTION_NAMES.length *
il2cppMetadataSectionNames(version).length *
sectionEntryBytes(VERSION_LAYOUTS[version]);
const isSupportedVersion = (value: number): value is Il2cppMetadataVersion =>
@@ -142,7 +122,7 @@ const isIndexWidth = (value: number): value is IndexWidth =>
value === 1 || value === 2 || value === 4;
/** Read a serialized index; the all-ones value of narrow widths means -1. */
const readIndex = (bytes: Buffer, offset: number, width: IndexWidth) => {
export const readIndex = (bytes: Buffer, offset: number, width: IndexWidth) => {
if (width === 1) {
const value = bytes.readUInt8(offset);
return value === 0xff ? -1 : value;
@@ -174,17 +154,17 @@ const readHeader = (bytes: Buffer) => {
`the file has ${bytes.length} bytes, fewer than the ${headerBytes}-byte version ${version} header`,
);
const entryBytes = sectionEntryBytes(VERSION_LAYOUTS[version]);
const sections: Il2cppMetadataSection[] = IL2CPP_METADATA_SECTION_NAMES.map(
(name, index) => {
const at = 8 + index * entryBytes;
return {
name,
offset: bytes.readInt32LE(at),
size: bytes.readInt32LE(at + 4),
count: entryBytes === 12 ? bytes.readInt32LE(at + 8) : null,
};
},
);
const sections: Il2cppMetadataSection[] = il2cppMetadataSectionNames(
version,
).map((name, index) => {
const at = 8 + index * entryBytes;
return {
name,
offset: bytes.readInt32LE(at),
size: bytes.readInt32LE(at + 4),
count: entryBytes === 12 ? bytes.readInt32LE(at + 8) : null,
};
});
return { magic, version, headerBytes, sections } as const;
};
@@ -351,27 +331,17 @@ const recordCounts = (
};
};
const checkRange = (
label: string,
start: number,
length: number,
total: number,
) => {
if (length === 0 && start >= -1 && start <= total) return;
if (start < 0 || start + length > total)
throw new Il2cppMetadataFormatFailure(
"index-out-of-range",
`${label} range [${start}, ${start + length}) is outside the ${total} available entries`,
);
};
/**
* Decode NUL-terminated UTF-8 names once each from the strings section. An
* index must start a string, so a shifted section or a pointer into another
* name fails instead of returning a different name, and total decoding stays
* bounded by the section size.
*/
const createStringReader = (bytes: Buffer, strings: Il2cppMetadataSection) => {
const createStringReader = (
bytes: Buffer,
strings: Il2cppMetadataSection,
beforeText?: (bytes: number) => void,
) => {
const stringEnd = strings.offset + strings.size;
const pool = bytes.subarray(strings.offset, stringEnd);
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
@@ -390,6 +360,7 @@ const createStringReader = (bytes: Buffer, strings: Il2cppMetadataSection) => {
"invalid-string",
`${label} string index ${index} starts inside another string`,
);
beforeText?.(end - index);
let value: string;
try {
value = decoder.decode(pool.subarray(index, end));
@@ -406,14 +377,19 @@ const createStringReader = (bytes: Buffer, strings: Il2cppMetadataSection) => {
};
/** Checked header facts shared by the record readers. */
interface MetadataTables {
export interface MetadataTables {
readonly bytes: Buffer;
readonly section: (name: SectionName) => Il2cppMetadataSection;
readonly widths: Il2cppMetadataDecoded["index_widths"];
readonly recordBytes: ReturnType<typeof il2cppMetadataRecordBytes>;
readonly counts: ReturnType<typeof recordCounts>;
readonly typeDefinitionTypeIndices: number;
readonly indexing: "fixed" | "counted";
readonly methodReturnParameterToken: boolean;
readonly readString: ReturnType<typeof createStringReader>;
readonly members: ReturnType<typeof readIl2cppMemberTables>;
readonly beforeText: Il2cppMetadataReadOptions["beforeText"];
readonly onRow: Il2cppMetadataReadOptions["onRow"];
}
function* readImages({
@@ -423,6 +399,7 @@ function* readImages({
recordBytes: sizes,
counts,
readString,
onRow,
}: MetadataTables): Generator<void, Il2cppMetadataDecoded["images"]> {
const images = section("images");
const typeTotal = counts.typeDefinitions;
@@ -442,6 +419,7 @@ function* readImages({
image.type_count,
typeTotal,
);
onRow?.(image);
result.push(image);
}
return result;
@@ -517,14 +495,27 @@ function* rejectNestingCycles(types: Il2cppMetadataDecoded["types"]) {
}
}
function* readTypes(
tables: MetadataTables,
): Generator<void, Il2cppMetadataDecoded["types"]> {
function* readTypes(tables: MetadataTables): Generator<
void,
{
types: Il2cppMetadataDecoded["types"];
memberRanges: {
fields: MetadataOwnerRange[];
properties: MetadataOwnerRange[];
events: MetadataOwnerRange[];
};
}
> {
const { bytes, section, widths, recordBytes: sizes, counts } = tables;
const { readString, typeDefinitionTypeIndices } = tables;
const typeDefinitions = section("typeDefinitions");
const methodTotal = counts.methods;
const types: Il2cppMetadataDecoded["types"] = [];
const memberRanges = {
fields: [] as MetadataOwnerRange[],
properties: [] as MetadataOwnerRange[],
events: [] as MetadataOwnerRange[],
};
for (let index = 0; index < counts.typeDefinitions; index++) {
if (index % 256 === 0) yield;
const at = typeDefinitions.offset + index * sizes.typeDefinitions;
@@ -554,10 +545,23 @@ function* readTypes(
type.method_count,
methodTotal,
);
memberRanges.fields.push({
start: bytes.readInt32LE(starts),
count: bytes.readUInt16LE(countsAt + 4),
});
memberRanges.events.push({
start: bytes.readInt32LE(starts + 8),
count: bytes.readUInt16LE(countsAt + 6),
});
memberRanges.properties.push({
start: bytes.readInt32LE(starts + 12),
count: bytes.readUInt16LE(countsAt + 2),
});
tables.onRow?.(type);
types.push(type);
}
yield* assignNesting(tables, types);
return types;
return { types, memberRanges };
}
function* readMethods({
@@ -567,10 +571,19 @@ function* readMethods({
recordBytes: sizes,
counts,
readString,
}: MetadataTables): Generator<void, Il2cppMetadataDecoded["methods"]> {
methodReturnParameterToken,
onRow,
}: MetadataTables): Generator<
void,
{
methods: Il2cppMetadataDecoded["methods"];
parameterRanges: MetadataOwnerRange[];
}
> {
const methods = section("methods");
const typeTotal = counts.typeDefinitions;
const result: Il2cppMetadataDecoded["methods"] = [];
const parameterRanges: MetadataOwnerRange[] = [];
for (let index = 0; index < counts.methods; index++) {
if (index % 256 === 0) yield;
const at = methods.offset + index * sizes.methods;
@@ -583,9 +596,22 @@ function* readMethods({
"index-out-of-range",
`method ${index} declares type ${method.declaring_type} outside the ${typeTotal} type definitions`,
);
parameterRanges.push({
start: readIndex(
bytes,
at +
4 +
widths.type_definition +
widths.type +
(methodReturnParameterToken ? 4 : 0),
widths.parameter,
),
count: bytes.readUInt16LE(at + sizes.methods - 2),
});
onRow?.(method);
result.push(method);
}
return result;
return { methods: result, parameterRanges };
}
/**
@@ -656,15 +682,28 @@ export interface Il2cppMetadataRowCounts {
readonly images: number;
readonly types: number;
readonly methods: number;
readonly fields: number;
readonly properties: number;
readonly events: number;
readonly parameters: number;
readonly string_literals: number;
}
/** Decode image, type and method names without native binaries or decryption. */
export interface Il2cppMetadataReadOptions {
/** Called before any row is decoded; may throw to refuse the file. */
readonly beforeRows?: (counts: Il2cppMetadataRowCounts) => void;
/** Guard raw text before allocating its decoded or base64 representation. */
readonly beforeText?: (bytes: number) => void;
/** Admit each fact before retention, including repeated strings. */
readonly onRow?: (
row: Readonly<Record<string, string | number | null>>,
) => void;
}
/** Decode image, type, method and member names and string literals without native binaries or decryption. */
export function* readIl2cppMetadataSteps(
bytes: Buffer,
options: {
/** Called before any row is decoded; may throw to refuse the file. */
readonly beforeRows?: (counts: Il2cppMetadataRowCounts) => void;
} = {},
options: Il2cppMetadataReadOptions = {},
): Generator<void, Il2cppMetadataDecoded> {
const { magic, version, headerBytes, sections } = readHeader(bytes);
checkSections(bytes, headerBytes, sections);
@@ -681,19 +720,37 @@ export function* readIl2cppMetadataSteps(
recordBytes: expected,
counts: recordCounts(version, section, expected),
typeDefinitionTypeIndices: layout.typeDefinitionTypeIndices,
readString: createStringReader(bytes, section("strings")),
indexing: layout.indexing,
methodReturnParameterToken: layout.methodReturnParameterToken,
readString: createStringReader(
bytes,
section("strings"),
options.beforeText,
),
members: readIl2cppMemberTables({
widths,
indexing: layout.indexing,
section,
}),
beforeText: options.beforeText,
onRow: options.onRow,
};
options.beforeRows?.({
images: tables.counts.images,
types: tables.counts.typeDefinitions,
methods: tables.counts.methods,
...tables.members.counts,
});
yield;
const images = yield* readImages(tables);
yield* checkImageRanges(images);
const types = yield* readTypes(tables);
const methods = yield* readMethods(tables);
const { types, memberRanges } = yield* readTypes(tables);
const { methods, parameterRanges } = yield* readMethods(tables);
yield* checkMethodOwners(types, methods);
const members = yield* readIl2cppMembers(tables, {
...memberRanges,
parameters: parameterRanges,
});
return {
magic,
metadata_version: version,
@@ -702,13 +759,14 @@ export function* readIl2cppMetadataSteps(
images,
types,
methods,
...members,
};
}
/** Synchronous consumption of the same decoder for byte-level callers. */
export const readIl2cppMetadata = (
bytes: Buffer,
options: Parameters<typeof readIl2cppMetadataSteps>[1] = {},
options: Il2cppMetadataReadOptions = {},
): Il2cppMetadataDecoded => {
const steps = readIl2cppMetadataSteps(bytes, options);
for (;;) {
+53
View File
@@ -0,0 +1,53 @@
/** Stable machine-readable reason for each rejected input. */
export const IL2CPP_METADATA_FAILURE_REASONS = {
unsupported: ["unrecognized-magic", "unsupported-version"],
corrupt: [
"truncated-header",
"unexpected-header-size",
"section-out-of-bounds",
"section-overlap",
"record-size-mismatch",
"type-index-width",
"index-out-of-range",
"nested-type-conflict",
"declaring-type-mismatch",
"image-range-overlap",
"invalid-string",
],
} as const;
export type Il2cppMetadataFailureKind =
keyof typeof IL2CPP_METADATA_FAILURE_REASONS;
export type Il2cppMetadataFailureReason =
(typeof IL2CPP_METADATA_FAILURE_REASONS)[Il2cppMetadataFailureKind][number];
/** Distinguish inputs outside decoder coverage from damaged metadata. */
export class Il2cppMetadataFormatFailure extends Error {
readonly kind: Il2cppMetadataFailureKind;
constructor(
readonly reason: Il2cppMetadataFailureReason,
message: string,
options?: ErrorOptions,
) {
super(`${reason}: ${message}`, options);
this.name = "Il2cppMetadataFormatFailure";
this.kind = (
IL2CPP_METADATA_FAILURE_REASONS.unsupported as readonly string[]
).includes(reason)
? "unsupported"
: "corrupt";
}
}
export const checkRange = (
label: string,
start: number,
length: number,
total: number,
) => {
if (length === 0 && start >= -1 && start <= total) return;
if (start < 0 || start + length > total)
throw new Il2cppMetadataFormatFailure(
"index-out-of-range",
`${label} range [${start}, ${start + length}) is outside the ${total} available entries`,
);
};
+226
View File
@@ -0,0 +1,226 @@
import type {
Il2cppMetadata,
Il2cppMetadataSection,
} from "../domain/il2cpp/il2cppMetadata.js";
import {
checkRange,
Il2cppMetadataFormatFailure,
} from "./Il2cppMetadataFormat.js";
import type { MetadataTables } from "./Il2cppMetadataFile.js";
type Members = Pick<
Il2cppMetadata,
"fields" | "properties" | "events" | "parameters" | "string_literals"
>;
export interface MetadataOwnerRange {
readonly start: number;
readonly count: number;
}
export interface MetadataMemberOwners {
readonly fields: readonly MetadataOwnerRange[];
readonly properties: readonly MetadataOwnerRange[];
readonly events: readonly MetadataOwnerRange[];
readonly parameters: readonly MetadataOwnerRange[];
}
const entries = (
indexing: MetadataTables["indexing"],
section: Il2cppMetadataSection,
recordBytes: number,
) => {
const count =
indexing === "counted" ? (section.count ?? 0) : section.size / recordBytes;
if (!Number.isInteger(count) || section.size !== count * recordBytes)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
`section ${section.name} size ${section.size} does not hold whole ${recordBytes}-byte records`,
);
return count;
};
/** Own member record widths and validate all counts before any row allocation. */
export const readIl2cppMemberTables = ({
widths,
indexing,
section,
}: Pick<MetadataTables, "widths" | "indexing" | "section">) => {
const recordBytes = {
fields: 8 + widths.type,
properties: 20,
events: 20 + widths.type,
parameters: 8 + widths.type,
stringLiterals: indexing === "counted" ? 4 : 8,
};
const literalRecords = entries(
indexing,
section("stringLiterals"),
recordBytes.stringLiterals,
);
return {
recordBytes,
literalRecords,
counts: {
fields: entries(indexing, section("fields"), recordBytes.fields),
properties: entries(
indexing,
section("properties"),
recordBytes.properties,
),
events: entries(indexing, section("events"), recordBytes.events),
parameters: entries(
indexing,
section("parameters"),
recordBytes.parameters,
),
string_literals:
indexing === "counted"
? Math.max(literalRecords - 1, 0)
: literalRecords,
},
};
};
/** Owners come from already parsed type/method facts, never a second raw-table interpretation. */
function* claim(
ranges: readonly MetadataOwnerRange[],
total: number,
label: string,
): Generator<void, Int32Array> {
const owners = new Int32Array(total).fill(-1);
let work = 0;
for (const [owner, range] of ranges.entries()) {
if (++work % 256 === 0) yield;
checkRange(
`${label} range of owner ${owner}`,
range.start,
range.count,
total,
);
for (let row = range.start; row < range.start + range.count; row++) {
if (++work % 256 === 0) yield;
if (owners[row] !== -1)
throw new Il2cppMetadataFormatFailure(
"declaring-type-mismatch",
`${label} ${row} is claimed by owners ${owners[row]} and ${owner}`,
);
owners[row] = owner;
}
}
return owners;
}
function* readNamed<
T extends { name: string } & Record<string, string | number>,
>(
tables: MetadataTables,
name: "fields" | "properties" | "events" | "parameters",
ranges: readonly MetadataOwnerRange[],
makeRow: (name: string, owner: number) => T,
): Generator<void, T[]> {
const section = tables.section(name),
total = tables.members.counts[name];
const owners = yield* claim(ranges, total, name),
rows: T[] = [];
for (let index = 0; index < total; index++) {
if (index % 256 === 0) yield;
const row = makeRow(
tables.readString(
tables.bytes.readInt32LE(
section.offset + index * tables.members.recordBytes[name],
),
`${name} ${index} name`,
),
owners[index]!,
);
tables.onRow?.(row);
rows.push(row);
}
return rows;
}
function* readStringLiterals(
tables: MetadataTables,
): Generator<void, Members["string_literals"]> {
const { bytes, section, indexing, members } = tables;
const literals = section("stringLiterals"),
data = section("stringLiteralData");
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
// Repeated spans reuse their decoded text for this immutable file snapshot.
const decoded = new Map<string, Members["string_literals"][number]>();
const rows: Members["string_literals"] = [];
if (indexing === "counted" && members.literalRecords === 1) {
const sentinel = bytes.readInt32LE(literals.offset);
if (sentinel < 0 || sentinel > data.size)
throw new Il2cppMetadataFormatFailure(
"index-out-of-range",
`empty string literal sentinel ${sentinel} is outside the ${data.size}-byte literal data`,
);
}
for (let index = 0; index < members.counts.string_literals; index++) {
if (index % 256 === 0) yield;
const at = literals.offset + index * members.recordBytes.stringLiterals;
const start = bytes.readInt32LE(at + (indexing === "counted" ? 0 : 4));
const length =
indexing === "counted"
? bytes.readInt32LE(at + 4) - start
: bytes.readUInt32LE(at);
if (start < 0 || length < 0 || start + length > data.size)
throw new Il2cppMetadataFormatFailure(
"index-out-of-range",
`string literal ${index} [${start}, ${start + length}) is outside the ${data.size}-byte literal data`,
);
const key = `${start}:${length}`;
let literal = decoded.get(key);
if (literal === undefined) {
// Strict UTF-8 round trips preserve at least these bytes; base64 is larger.
// Refuse impossible output before allocating either representation.
tables.beforeText?.(length);
const value = bytes.subarray(
data.offset + start,
data.offset + start + length,
);
try {
literal = { value: decoder.decode(value), value_base64: null };
} catch {
tables.beforeText?.(4 * Math.ceil(length / 3));
literal = { value: null, value_base64: value.toString("base64") };
}
decoded.set(key, literal);
}
tables.onRow?.(literal);
rows.push({ ...literal });
}
return rows;
}
/** Decode each fact directly into its final row shape, yielding through ownership and content. */
export function* readIl2cppMembers(
tables: MetadataTables,
owners: MetadataMemberOwners,
): Generator<void, Members> {
const member = (name: string, declaring_type: number) => ({
name,
declaring_type,
});
const fields = yield* readNamed(tables, "fields", owners.fields, member);
const properties = yield* readNamed(
tables,
"properties",
owners.properties,
member,
);
const events = yield* readNamed(tables, "events", owners.events, member);
const parameters = yield* readNamed(
tables,
"parameters",
owners.parameters,
(name, method) => ({ name, method }),
);
return {
fields,
properties,
events,
parameters,
string_literals: yield* readStringLiterals(tables),
};
}
+10 -31
View File
@@ -1,3 +1,4 @@
import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js";
import { setImmediate } from "node:timers/promises";
import {
readStableArtifact,
@@ -17,6 +18,7 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import type { Il2cppMetadataPort } from "../application/il2cpp/Il2cppMetadataPort.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { createEvidence } from "../domain/evidence.js";
import { createIl2cppMetadataAdmission } from "./Il2cppMetadataAdmission.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import {
AnalysisAccessDeniedError,
@@ -36,7 +38,6 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
Il2cppMetadataFormatFailure,
readIl2cppMetadataSteps,
type Il2cppMetadataRowCounts,
} from "./Il2cppMetadataFile.js";
@@ -51,25 +52,6 @@ const OPERATION = "inspect_il2cpp_metadata";
const INPUT_BYTES = 256 * 1024 * 1024;
// A 12.9 MB Unity 6000.4 file with 112,232 methods serializes to 7.8 MB.
const OUTPUT_BYTES = 64 * 1024 * 1024;
// The smallest JSON a row can take: empty names and one-digit numbers.
const EMPTY_NAME = JSON.stringify("").length;
/** JSON characters of one row besides its strings, with one-digit numbers. */
const rowOverhead = (row: Record<string, string | number>) =>
JSON.stringify(row).length -
2 * Object.values(row).filter((value) => typeof value === "string").length;
const IMAGE_ROW = rowOverhead({ name: "", type_start: 0, type_count: 0 });
const TYPE_ROW = rowOverhead({
name: "",
namespace: "",
method_start: 0,
method_count: 0,
nested_type_start: 0,
nested_type_count: 0,
declaring_type: 0,
});
const METHOD_ROW = rowOverhead({ name: "", declaring_type: 0 });
const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
@@ -170,12 +152,6 @@ const reportLimit = (path: string, maximumBytes: number) =>
{ maximum_report_bytes: maximumBytes },
);
/** Lower bound from row counts alone, so oversized files fail before rows are allocated. */
const countLowerBound = ({ images, types, methods }: Il2cppMetadataRowCounts) =>
images * (IMAGE_ROW + EMPTY_NAME) +
types * (TYPE_ROW + 2 * EMPTY_NAME) +
methods * (METHOD_ROW + EMPTY_NAME);
const sourceArtifact = (path: string, snapshot: StableArtifactSnapshot) => ({
path,
sha256: snapshot.sha256,
@@ -301,11 +277,14 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
observed.artifact = artifact;
assertArtifactReadActive(options?.signal);
const limit = this.outputBytes;
const admission = createIl2cppMetadataAdmission(limit, () => {
throw reportLimit(input.path, limit);
});
const steps = readIl2cppMetadataSteps(snapshot.bytes, {
...admission,
beforeRows: (counts) => {
observed.counts = counts;
if (countLowerBound(counts) > limit)
throw reportLimit(input.path, limit);
admission.beforeRows(counts);
},
});
let step = steps.next();
@@ -319,9 +298,9 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
...decoded,
artifact,
limitations: [
"Only unencrypted global-metadata.dat versions 29, 31 and 39 are decoded; image, type and method names and counts are observed file content, not runtime behavior.",
"Native code addresses, IL2CPP binary registration, fields, properties, signatures and generic instances are not recovered.",
"declaring_type for types is derived from the nestedTypes section; row positions are the metadata TypeDefinitionIndex and MethodIndex values.",
"Only unencrypted global-metadata.dat versions 24, 27, 29, 31 and 39 are decoded; names, counts and string literals are observed file content, not runtime behavior.",
"Native code addresses, IL2CPP binary registration, member types, signatures and generic instances are not recovered.",
"declaring_type for types is derived from the nestedTypes section, and member and parameter owners from the owners' index ranges; row positions are the metadata indices.",
],
};
if (jsonValueByteLength(report, limit) > limit)