mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(il2cpp): inventory Unity IL2CPP global-metadata images, types and methods (#3807)
* feat(il2cpp): inventory IL2CPP global-metadata images, types and methods Add inspect_il2cpp_metadata (CLI inspect-il2cpp-metadata), a read-only in-process decoder for unencrypted Unity global-metadata.dat version 39. It reports header sections, serialized index widths, images, type definitions (nesting from the nestedTypes section) and method names. Unrecognized magic and other versions are unsupported; damaged headers, overlapping sections, inconsistent record sizes, out-of-range indices and invalid strings fail with a reason code and no partial success. MCP detail summary retains the complete Evidence and pages rows through inspect_analysis_view kind il2cpp-metadata. Adds an opt-in verify:il2cpp:metadata -- --metadata PATH check. * feat(il2cpp): deliver metadata summaries by default over MCP A 112,232-method file produced a 10.5 MB complete response, above the 10 MiB stdio budget. Default MCP delivery is now the retained summary; detail complete stays available, and the one-shot CLI requests it. * fix(il2cpp): reject shifted strings, nesting cycles and method owner drift Review fuzzing showed that moving the strings section offset by one byte silently renamed every image, type and method. Name indices must now start a string, which also bounds total decoding by the section size. Nesting cycles and methods whose declaring type disagrees with the owning type range are rejected instead of returned. Adds service, shutdown and index-width coverage, and moves environment-specific measurements out of the public guide and tool description. * feat(il2cpp): decode global-metadata versions 29 and 31 Unity 2022.3 writes version 29 or 31 metadata: a 256-byte header of (offset, size) pairs, fixed 4-byte indices, type records with elementTypeIndex, and method records that gained returnParameterToken in version 31. Record counts come from section sizes and must be whole. Sections report a null count for these versions and index widths report fixed-width-version. Image, type and method rows keep the same shape, and the string, nesting and method-ownership checks apply to every version. * fix(il2cpp): bound report size before serializing and tighten coverage Rows share decoded names, so a 130 KB file could expand into gigabytes of JSON before the 64 MiB budget check, ending in an internal RangeError. The provider now refuses from a lower bound on the serialized rows first. The decoder also rejects non-empty sections inside the header, methods outside their declaring type's range and overlapping image type ranges, and keeps a leading byte order mark in names. Layouts now carry one indexing mode instead of two coupled flags, and the tool description explains nested_type_start. * fix(il2cpp): refuse oversized metadata before decoding rows Address review findings on the IL2CPP metadata inventory: - check a lower bound from header row counts before any row object is allocated, so a large rejected file no longer builds every row first; - validate sealed parent Evidence and count its rows once per report instead of on every inspect_analysis_view page; - decode version 39 fixtures with 2- and 4-byte type-definition, generic-container and parameter indices. * docs(il2cpp): attach the decoder comment to readIl2cppMetadata * fix(il2cpp): deliver complete rows by default like the other inspection tools inspect_il2cpp_metadata defaulted to detail summary, the only inspection tool to do so. Omitted detail now returns every row inline, as inspect_artifact, decode_interface_builder and the capture tools do; summary stays one argument away and the guide, CLI reference and skill reference say to select it first for large games, whose complete MCP delivery is refused at the stdio budget with retained Evidence. * test(il2cpp): cover narrow-width -1 sentinels and fix the skill's default An empty image stores its type start as the all-ones value of a one- or two-byte index; the decoder reads it as -1, but no fixture exercised it, so removing either conversion passed the suite. The fixture can now append an empty image, and both widths decode it. The skill still described a summary default; it now says to select summary first, as the guide does. * docs(il2cpp): request summary explicitly in the summary example * chore(il2cpp): hash the verifier metadata through owned file resources * fix(il2cpp): count escaped name length without building the escaped string * fix(il2cpp): keep the verifier's check failure and small-table last page * feat(domain): count JSON string length without building the escaped string * fix(il2cpp): keep descriptor cleanup owners and share the JSON length count * test(domain): give the backslash case real backslashes * fix(il2cpp): select the CLI path and finish the command like the other tools Follow main's absoluteCallerPath (#4067) and pass the command signal to finishAnalysisCommand. * fix(il2cpp): select the verifier input path like the CLI Use absoluteCallerPath (#4067) so a path with a symlink followed by .. names the same file the CLI and MCP inspect. * feat(il2cpp): add compact composable metadata queries Retain source-indexed Evidence for filtered batch and field queries. Bound representation growth before serialization, yield decoding for cancellation, and share CLI Evidence publication with desktop inspection. Verify public Unity metadata against independent byte and managed-DLL oracles and exercise actual stdio SDK workflows. * fix(il2cpp): retain the validated metadata header magic * test(il2cpp): remove obsolete execution wording exception * fix(artifacts): preserve export selection and completed source facts --------- Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
1 parent
82c76e2cba
commit
833136507d
51 files changed
+4666
-116
No files matched your search
@@ -169,6 +169,15 @@ explicit path or endpoint and do not need it.
|
||||
absent metadata does not prove a binary is not Go, and unparsed text records
|
||||
remain explicit. See the
|
||||
[Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md).
|
||||
- Unity IL2CPP image, type or method names from `global-metadata.dat`:
|
||||
`inspect_il2cpp_metadata` with the explicit local file; MCP defaults to compact
|
||||
counts. Query sections, images, types or methods through `inspect_analysis_view`
|
||||
with the returned `parent_evidence_id`, using exact filters, batch indices and
|
||||
selected fields. Versions 29, 31 and 39; no Unity, IL2CPP
|
||||
binary or disassembler is run. Unrecognized magic (possibly encrypted) and
|
||||
other versions are unsupported. Names do not establish native code addresses
|
||||
or runtime behavior. See the
|
||||
[IL2CPP metadata guide](https://github.com/morluto/rea/blob/main/docs/il2cpp-metadata.md).
|
||||
- Embedded cargo-auditable Rust dependency metadata: `inspect_rust_binary`
|
||||
with the explicit local ELF, PE, or thin Mach-O file. This built-in
|
||||
target-free inspection requires no Rust installation or disassembler. Preserve
|
||||
|
||||
@@ -27,6 +27,21 @@ unparsed module-text line. A missing record is an unknown, not proof of a
|
||||
non-Go binary; observed metadata does not authenticate its claims. Function,
|
||||
source and type recovery remain a separate native-analysis question.
|
||||
|
||||
## IL2CPP metadata
|
||||
|
||||
Use `inspect_il2cpp_metadata` for an explicit local Unity `global-metadata.dat`
|
||||
(versions 29, 31 and 39) when the question concerns assembly images, type or method names,
|
||||
namespaces or index ranges. No `open_binary`, Unity installation or target
|
||||
execution is required. The CLI equivalent is `inspect-il2cpp-metadata PATH`.
|
||||
MCP defaults to counts with complete retained Evidence. Query `sections`,
|
||||
`images`, `types` or `methods` through `inspect_analysis_view`; use exact names,
|
||||
batch indices and selected fields. Row indices are scoped by artifact SHA-256
|
||||
and collection; page offsets count filtered matches. Preserve
|
||||
artifact SHA-256 and index widths. Unrecognized magic may mean encryption and
|
||||
is unsupported, not decrypted. Linking names to `GameAssembly` or
|
||||
`UnityFramework` code addresses remains a separate native-analysis question.
|
||||
See [IL2CPP metadata](https://github.com/morluto/rea/blob/main/docs/il2cpp-metadata.md).
|
||||
|
||||
## Rust dependency metadata
|
||||
|
||||
Use `inspect_rust_binary` for an explicit local ELF, PE, or thin Mach-O when
|
||||
|
||||
@@ -180,6 +180,7 @@ Additional tools and host support depend on the target:
|
||||
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](docs/native-investigation.md) |
|
||||
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
|
||||
| Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required |
|
||||
| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 29, 31 and 39; no Unity required |
|
||||
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
|
||||
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Native binaries", link: "/native-investigation" },
|
||||
{ text: "Go build metadata", link: "/go-binaries" },
|
||||
{ text: "IL2CPP metadata", link: "/il2cpp-metadata" },
|
||||
{ text: "Rust dependencies", link: "/rust-binaries" },
|
||||
{
|
||||
text: "JavaScript and Electron",
|
||||
|
||||
+18
-2
@@ -58,8 +58,10 @@ assembly-wide inventory, so the output can still be large.
|
||||
|
||||
Choose the workflow from the artifact's contents rather than its `.exe` or
|
||||
`.dll` extension. The managed parser requires PE/CLI metadata; it does not
|
||||
unpack .NET single-file hosts or decode IL2CPP metadata. NativeAOT method bodies
|
||||
require native analysis. See the [managed-code guide](managed-code-analysis.md)
|
||||
unpack .NET single-file hosts or decode IL2CPP. For the image, type and method
|
||||
names in a Unity `global-metadata.dat`, use
|
||||
[IL2CPP metadata](il2cpp-metadata.md). NativeAOT method bodies require native
|
||||
analysis. See the [managed-code guide](managed-code-analysis.md)
|
||||
for supported deployment forms, selection semantics, and coverage limits.
|
||||
|
||||
## SQLite snapshots
|
||||
@@ -83,6 +85,20 @@ The shared CLI/MCP workflow reports compiler version, modules, replacements,
|
||||
build settings, original bytes and SHA-256. See [Go binaries](go-binaries.md)
|
||||
for supported containers and absent or malformed metadata.
|
||||
|
||||
Inspect Unity IL2CPP metadata names without Unity or a disassembler:
|
||||
|
||||
```bash
|
||||
rea inspect-il2cpp-metadata /absolute/path/global-metadata.dat --json
|
||||
```
|
||||
|
||||
The command decodes a version 29, 31 or 39 `global-metadata.dat` and reports assembly
|
||||
images, type and method names, index ranges, header sections and SHA-256. It
|
||||
does not decrypt metadata or link names to native code addresses. MCP defaults
|
||||
to compact counts with retained Evidence; query indexed rows with filters,
|
||||
batch indices and selected fields. The CLI defaults to complete Evidence;
|
||||
`--detail summary --evidence_output FILE` exports the full result to a new file
|
||||
and returns a compact overview. See [IL2CPP metadata](il2cpp-metadata.md).
|
||||
|
||||
Inspect an embedded cargo-auditable Rust dependency list without installing
|
||||
Rust or configuring a disassembler:
|
||||
|
||||
|
||||
@@ -101,6 +101,11 @@ same bundle. Export writes only the caller-selected output file.
|
||||
| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. |
|
||||
| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. |
|
||||
|
||||
An observed metadata header with supported raw version 29, 31 or 39 offers an
|
||||
`inspect_il2cpp_metadata` follow-up at its admitted file path, including when no
|
||||
native library was found. This probe does not establish full metadata validity
|
||||
or correspondence to a native binary. See [IL2CPP metadata](il2cpp-metadata.md).
|
||||
|
||||
Candidates are derived or inferred from these facts and do not prove loading or
|
||||
execution. BepInEx, MelonLoader, Easy Anti-Cheat and BattlEye labels identify
|
||||
path conventions only; runtime presence stays unknown. No bypass is provided.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# IL2CPP metadata
|
||||
|
||||
`inspect_il2cpp_metadata` / `inspect-il2cpp-metadata` decodes one selected local
|
||||
Unity IL2CPP `global-metadata.dat` file and returns its assembly images, type
|
||||
definitions and method definitions by name, namespace, index range and count,
|
||||
with the header sections and serialized index widths. It reads the file without
|
||||
running it and requires no Unity installation, IL2CPP binary, active target,
|
||||
Hopper, Ghidra or IDA.
|
||||
|
||||
Metadata versions 29, 31 and 39 are supported. For example, Unity 2022.3.30f1
|
||||
wrote version 29, 2022.3.62f3 wrote 31 and 6000.4.2f1 wrote 39; other Unity
|
||||
releases that write these numbers have not been checked. Other versions are
|
||||
rejected as unsupported. The path must name a regular file; directories and
|
||||
symbolic links are rejected.
|
||||
|
||||
## MCP
|
||||
|
||||
MCP defaults to a compact summary and retains the complete decoded Evidence in
|
||||
the session. MCP requires an absolute filesystem path:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_il2cpp_metadata",
|
||||
"arguments": {
|
||||
"path": "/artifacts/Data/Managed/Metadata/global-metadata.dat"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The summary retains the complete decoded Evidence in the session and returns a
|
||||
linked `inspect_analysis_view` overview: artifact identity, metadata version,
|
||||
index widths and `counts` (`sections`, `images`, `types`, `methods` and
|
||||
`nested_types`) without rows. Use `normalized_result.parent_evidence_id` to page
|
||||
the original rows without reopening or decoding the file:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "<parent_evidence_id>"
|
||||
},
|
||||
"view": {
|
||||
"kind": "il2cpp-metadata",
|
||||
"collection": "types",
|
||||
"offset": 0,
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Collections are `sections`, `images`, `types` and `methods`. Every returned row
|
||||
includes its source `index`, scoped by artifact SHA-256 and collection. All
|
||||
collections accept batch `indices`, exact `names` and `fields`. Types also accept
|
||||
`image_indices`, `namespaces` and `declaring_types`; methods accept
|
||||
`declaring_types`. Filters intersect, duplicate indices are returned once, and
|
||||
rows retain metadata order. An empty filter selects no rows; `fields: []`
|
||||
returns indices only. Omitted fields return the complete selected rows.
|
||||
|
||||
For example, after resolving a type index, retrieve selected method facts:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "<parent_evidence_id>"
|
||||
},
|
||||
"view": {
|
||||
"kind": "il2cpp-metadata",
|
||||
"collection": "methods",
|
||||
"declaring_types": [1555],
|
||||
"names": ["SendMessage"],
|
||||
"fields": ["name", "declaring_type"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`offset` counts matching rows, not source indices. `limit` defaults to 64;
|
||||
`coverage.next_offset` continues that filtered page. Coverage describes retained
|
||||
metadata facts only; runtime behavior and correspondence to native code remain
|
||||
unknown. Each view preserves the parent Evidence link, artifact identity and
|
||||
limitations. Queries work after the source file is removed. Select
|
||||
`detail: "complete"` to return every row inline within the separate MCP response
|
||||
budget.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
rea inspect-il2cpp-metadata /absolute/path/global-metadata.dat --json
|
||||
```
|
||||
|
||||
The CLI defaults to complete Evidence. For compact delivery, explicitly preserve
|
||||
the full Evidence in a new file:
|
||||
|
||||
```bash
|
||||
rea inspect-il2cpp-metadata /absolute/path/global-metadata.dat \
|
||||
--detail summary --evidence_output /tmp/metadata-evidence.json --json
|
||||
jq -c '{source: {kind: "inline", evidence: .records[0]}, view: {kind: "il2cpp-metadata", collection: "methods", indices: [9785], fields: ["name", "declaring_type"]}}' \
|
||||
/tmp/metadata-evidence.json > /tmp/metadata-query.json
|
||||
rea inspect-analysis-view /tmp/metadata-query.json --json
|
||||
```
|
||||
|
||||
Use the exported Evidence with the CLI's inline Evidence source format described
|
||||
in [CLI and Evidence](cli.md#run-a-command). Compact delivery requires
|
||||
`--evidence_output`; existing files are never overwritten. The full export
|
||||
preserves every decoded row for subsequent queries without the original file.
|
||||
|
||||
## Observations
|
||||
|
||||
The result contains:
|
||||
|
||||
- `artifact`: path, SHA-256 and byte length;
|
||||
- `magic`: unsigned little-endian header signature (`0xFAB11BAF`, delivered as a JSON number);
|
||||
- `metadata_version`: `29`, `31` or `39`;
|
||||
- `index_widths`: the serialized widths (1, 2 or 4 bytes) of
|
||||
`type_definition`, `generic_container`, `parameter` and `type` indices, and
|
||||
`type_width_source`. Version 39 derives the type index width from the
|
||||
`interface-offsets-record` or the `type-definition-record`; versions 29 and
|
||||
31 always use 4-byte indices (`fixed-width-version`);
|
||||
- `sections`: all 31 header sections in header order, each with `offset`, `size`
|
||||
and `count`. Versions 29 and 31 record no counts, so `count` is `null` and
|
||||
record counts come from the section sizes;
|
||||
- `images`: `name`, `type_start` and `type_count`;
|
||||
- `types`: `name`, `namespace`, `method_start`, `method_count`,
|
||||
`nested_type_start`, `nested_type_count` and `declaring_type`;
|
||||
- `methods`: `name` and `declaring_type`;
|
||||
- `limitations`.
|
||||
|
||||
A row's position in its collection is its metadata index, so `type_start`,
|
||||
`method_start` and `declaring_type` refer to positions in `types` or `methods`.
|
||||
`nested_type_start` instead indexes the metadata's `nestedTypes` table, which is
|
||||
not returned; each nested type's `declaring_type` carries that relationship.
|
||||
A type's `declaring_type` is derived from the `nestedTypes` section and is `-1`
|
||||
for top-level types. The start of an empty range can retain the original value
|
||||
`-1`.
|
||||
|
||||
The complete inventory of a large game is several megabytes and can exceed
|
||||
the MCP response budget. Summary delivery and filtered, projected pages keep
|
||||
unneeded rows out of agent context without discarding the source Evidence.
|
||||
|
||||
## Failures
|
||||
|
||||
A file whose magic is not `0xFAB11BAF` fails as an unsupported target with
|
||||
`unrecognized-magic`. The file may be encrypted or may not be IL2CPP metadata.
|
||||
A version other than 29, 31 or 39 fails as an unsupported target with
|
||||
`unsupported-version`.
|
||||
|
||||
Damaged metadata returns an input error (`invalid_format`) whose
|
||||
message names one reason:
|
||||
|
||||
| Reason | Meaning |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `truncated-header` | The file is shorter than the header. |
|
||||
| `unexpected-header-size` | `stringLiterals.offset` is not the header size: 256 bytes for versions 29 and 31, 380 for 39. |
|
||||
| `section-out-of-bounds` | A section extends beyond the file, or a non-empty section starts inside the header. |
|
||||
| `section-overlap` | Two sections overlap. Zero-size sections are exempt and gaps are allowed. |
|
||||
| `record-size-mismatch` | A section size does not divide into its records, or the record size differs from the one the index widths imply. |
|
||||
| `type-index-width` | The record used to derive the type index width implies no 1, 2 or 4 byte width. |
|
||||
| `index-out-of-range` | A serialized index or range points outside its target collection. |
|
||||
| `nested-type-conflict` | A type is listed as nested in two types, in itself, or in a cycle of declaring types. |
|
||||
| `declaring-type-mismatch` | A method inside a type's method range names a different type, or a method lies outside its type's range. |
|
||||
| `image-range-overlap` | Two images claim the same type definitions. |
|
||||
| `invalid-string` | A name index does not start a string, or the name is unterminated or not valid UTF-8. |
|
||||
|
||||
A version field that disagrees with the file's layout, for example a version
|
||||
39 file relabeled as 29, usually surfaces as `unexpected-header-size` or
|
||||
`record-size-mismatch`.
|
||||
|
||||
Two checks are stricter than the IL2CPP runtime: a name index must start a
|
||||
string, and an empty range's start must lie between -1 and the collection size.
|
||||
Every Unity-written file checked so far satisfies both; metadata rewritten by
|
||||
other tools may be rejected.
|
||||
|
||||
An incomplete inventory is never reported as complete. Failures after a verified
|
||||
snapshot retain partial source Evidence with its SHA-256, byte length and any
|
||||
validated section row counts; these counts do not establish that names and
|
||||
relationships were decoded. A file that changes during reading and a cancelled
|
||||
request keep distinct errors. Decode checkpoints yield for cancellation, and
|
||||
close waits for active work. Each reader admits one inspection at a time.
|
||||
Resource guards bound the selected file to 256 MiB and the encoded complete
|
||||
report to 64 MiB, checking JSON/UTF-8 expansion before serialization. MCP also applies its
|
||||
[response transport budget](mcp-contracts.md); a complete report within the
|
||||
report limit can still require the summary and paging path.
|
||||
|
||||
## Outside this tool
|
||||
|
||||
- Decrypting or deobfuscating protected metadata.
|
||||
- Linking metadata to the native binary (`GameAssembly`, `UnityFramework`),
|
||||
including method RVAs and code addresses.
|
||||
- Fields, properties, signatures, parameters and generic instances.
|
||||
- Ghidra or other disassembler integration. Use the
|
||||
[native workflow](native-investigation.md) for native code.
|
||||
- Metadata versions other than 29, 31 and 39.
|
||||
|
||||
Names come from the metadata file only. They do not establish which methods
|
||||
were compiled into the native binary or how they behave at runtime.
|
||||
|
||||
## Real producer verification
|
||||
|
||||
`npm run verify:il2cpp:metadata -- --metadata PATH` exercises a caller-supplied
|
||||
`global-metadata.dat` through the public CLI and MCP. It is opt-in, like
|
||||
`verify:pcap`, because no real game metadata ships with the repository. Normal
|
||||
inspection does not need it.
|
||||
|
||||
The follow-up was checked against the public Cpp2IL
|
||||
[`Simple_2022_3_35` sample](https://github.com/SamboyCoding/Cpp2IL/tree/b5ad444b82267cb1e4b88b8b373c008105bdea52/TestFiles/Simple_2022_3_35):
|
||||
version 31, 1,892,268 bytes, SHA-256
|
||||
`2441635a7c234a1a34deccc6a9906d6f5abcbdab078a38b2bf488a52a4962c48`.
|
||||
An independent byte-layout oracle matched all 36 images, 2,468 types and 12,409
|
||||
methods. Corresponding managed DLLs independently matched names and ownership
|
||||
for 35 images, 2,463 types and 12,406 methods; the generated image has no matching
|
||||
DLL. This does not establish real-producer coverage for versions 29 or 39.
|
||||
@@ -119,9 +119,11 @@ returning an empty one.
|
||||
|
||||
The canonical parser admits PE/CLI bytes and their metadata/CIL without loading
|
||||
an assembly. It reports observed implementation markers and unavailable facts;
|
||||
it does not unpack .NET single-file hosts, decode IL2CPP metadata, or infer a
|
||||
NativeAOT identity from an ordinary native PE. Inputs without admitted CLI
|
||||
metadata do not become managed assemblies through naming or routing guesses.
|
||||
it does not unpack .NET single-file hosts or infer a NativeAOT identity from an
|
||||
ordinary native PE. It does not decode IL2CPP; use
|
||||
[IL2CPP metadata](il2cpp-metadata.md) for the image, type and method names in a
|
||||
Unity `global-metadata.dat`. Inputs without admitted CLI metadata do not
|
||||
become managed assemblies through naming or routing guesses.
|
||||
Inspect separately obtained components explicitly and preserve their identities.
|
||||
|
||||
A valid implementation marker establishes a candidate native boundary, not
|
||||
|
||||
@@ -291,6 +291,7 @@
|
||||
"verify:web:http3": "npm run build:cached && node scripts/verify-http3-network-captures.mjs",
|
||||
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
|
||||
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
|
||||
"verify:il2cpp:metadata": "npm run build:cached && node scripts/verify-il2cpp-metadata.mjs",
|
||||
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
|
||||
"verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs",
|
||||
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
|
||||
|
||||
@@ -34,6 +34,7 @@ export const toolFamilyCatalog = (sources) => {
|
||||
...sources.desktopContracts.DESKTOP_APPLICATION_TOOL_CONTRACTS,
|
||||
...sources.evmContracts.EVM_TOOL_CONTRACTS,
|
||||
...sources.goContracts.GO_TOOL_CONTRACTS,
|
||||
...sources.il2cppContracts.IL2CPP_TOOL_CONTRACTS,
|
||||
...sources.rustContracts.RUST_TOOL_CONTRACTS,
|
||||
...sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS,
|
||||
@@ -266,6 +267,10 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY,
|
||||
contracts: sources.goContracts.GO_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.il2cppProvider.IL2CPP_METADATA_PROVIDER_IDENTITY,
|
||||
contracts: sources.il2cppContracts.IL2CPP_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY,
|
||||
contracts: sources.rustContracts.RUST_TOOL_CONTRACTS,
|
||||
|
||||
@@ -69,6 +69,8 @@ const SOURCE_PATHS = {
|
||||
evmProvider: "dist/evm/EvmoleRelease.js",
|
||||
goContracts: "dist/contracts/go/goToolContracts.js",
|
||||
goProvider: "dist/go/GoBinaryProvider.js",
|
||||
il2cppContracts: "dist/contracts/il2cpp/il2cppToolContracts.js",
|
||||
il2cppProvider: "dist/il2cpp/Il2cppMetadataProvider.js",
|
||||
rustContracts: "dist/contracts/rust/rustToolContracts.js",
|
||||
rustProvider: "dist/rust/RustBinaryProvider.js",
|
||||
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
||||
|
||||
@@ -8,6 +8,7 @@ const MIGRATED_PROVIDER_ROOTS = new Set([
|
||||
"sqlite",
|
||||
"evm",
|
||||
"go",
|
||||
"il2cpp",
|
||||
"rust",
|
||||
"wasm",
|
||||
"captures",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { absoluteCallerPath } from "../dist/filesystem/callerPath.js";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { analysisViewResultSchema } from "../dist/domain/analysisView/analysisView.js";
|
||||
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
|
||||
import { hashVerifierFile } from "./lib/verifier-file-resources.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
console.log(
|
||||
"Usage: npm run verify:il2cpp:metadata -- [--metadata PATH]\nPATH is a caller-owned, unencrypted global-metadata.dat (version 29, 31 or 39). Without --metadata, only the advertised contract is verified.",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!(args.length === 0 || (args.length === 2 && args[0] === "--metadata")))
|
||||
throw new Error("Expected --metadata PATH");
|
||||
const path = args[1] === undefined ? null : absoluteCallerPath(args[1]);
|
||||
const operation = "inspect_il2cpp_metadata";
|
||||
const contract = toolContract(operation);
|
||||
const entrypoint = resolve("scripts/rea.mjs");
|
||||
const execute = promisify(execFile);
|
||||
const client = new Client({ name: "il2cpp-metadata-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([, value]) => typeof value === "string",
|
||||
),
|
||||
),
|
||||
stderr: "pipe",
|
||||
});
|
||||
transport.stderr?.resume();
|
||||
const call = async (name, input) => {
|
||||
const response = await client.callTool(
|
||||
{ name, arguments: input },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
return JSON.parse(mcpTextValue(response));
|
||||
};
|
||||
const resources = new ArtifactResourceScope();
|
||||
let primaryError;
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const { tools } = await client.listTools();
|
||||
assert(tools.some((tool) => tool.name === operation));
|
||||
if (path === null)
|
||||
console.log("[unverified] Metadata inspection: no --metadata was supplied");
|
||||
else {
|
||||
const identity = await hashVerifierFile(path, resources);
|
||||
const started = performance.now();
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-il2cpp-metadata", path, "--json"],
|
||||
{ timeout: 120_000, maxBuffer: 128 * 1024 * 1024 },
|
||||
);
|
||||
const cliMs = Math.round(performance.now() - started);
|
||||
const cli = contract.outputSchema.parse(JSON.parse(stdout));
|
||||
const report = cli.normalized_result;
|
||||
assert.equal(report.artifact.path, path);
|
||||
assert.equal(report.artifact.sha256, identity.sha256);
|
||||
assert.equal(report.magic, 0xfab11baf);
|
||||
const complete = await client.callTool(
|
||||
{ name: operation, arguments: { path, detail: "complete" } },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
if (complete.isError === true) {
|
||||
// Large inventories exceed the stdio budget; the result must stay
|
||||
// retained and addressable rather than truncated.
|
||||
const { error } = JSON.parse(mcpTextValue(complete));
|
||||
assert.equal(error.code, "resource_constraint");
|
||||
assert.equal(error.details.reported_limits.boundary, "mcp-response");
|
||||
assert.match(error.details.reported_limits.evidence_id, /^ev_/u);
|
||||
console.log(
|
||||
`[ok] MCP complete delivery refused at the stdio budget (${error.details.reported_limits.response_bytes_at_least} bytes) with retained Evidence`,
|
||||
);
|
||||
} else {
|
||||
const mcp = contract.outputSchema.parse(
|
||||
JSON.parse(mcpTextValue(complete)),
|
||||
);
|
||||
assert.deepEqual(mcp.normalized_result, report);
|
||||
console.log("[ok] CLI/MCP complete Evidence parity");
|
||||
}
|
||||
|
||||
// Cross-check independently reported ranges against the row tables.
|
||||
assert.equal(
|
||||
report.images.reduce((total, image) => total + image.type_count, 0),
|
||||
report.types.length,
|
||||
);
|
||||
assert.equal(
|
||||
report.types.reduce((total, type) => total + type.method_count, 0),
|
||||
report.methods.length,
|
||||
);
|
||||
report.types.forEach((type, index) => {
|
||||
for (let at = 0; at < type.method_count; at += 1)
|
||||
assert.equal(
|
||||
report.methods[type.method_start + at].declaring_type,
|
||||
index,
|
||||
`method ${type.method_start + at} is outside its declaring type range`,
|
||||
);
|
||||
});
|
||||
console.log("[ok] image type ranges and type method ranges agree");
|
||||
|
||||
const summary = contract.outputSchema.parse(
|
||||
await call(operation, { path }),
|
||||
).normalized_result;
|
||||
assert.equal(summary.summary.magic, report.magic);
|
||||
assert.deepEqual(summary.summary.counts, {
|
||||
sections: report.sections.length,
|
||||
images: report.images.length,
|
||||
types: report.types.length,
|
||||
methods: report.methods.length,
|
||||
nested_types: report.types.filter((type) => type.declaring_type !== -1)
|
||||
.length,
|
||||
});
|
||||
const page = analysisViewResultSchema.parse(
|
||||
(
|
||||
await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
},
|
||||
view: { kind: "il2cpp-metadata", collection: "methods", limit: 16 },
|
||||
})
|
||||
).normalized_result,
|
||||
);
|
||||
assert.deepEqual(
|
||||
page.items,
|
||||
report.methods.slice(0, 16).map((row, index) => ({ ...row, index })),
|
||||
);
|
||||
const last = analysisViewResultSchema.parse(
|
||||
(
|
||||
await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "types",
|
||||
offset: Math.max(0, report.types.length - 4),
|
||||
limit: 16,
|
||||
},
|
||||
})
|
||||
).normalized_result,
|
||||
);
|
||||
const lastOffset = Math.max(0, report.types.length - 4);
|
||||
assert.deepEqual(
|
||||
last.items,
|
||||
report.types.slice(lastOffset).map((row, index) => ({
|
||||
...row,
|
||||
index: lastOffset + index,
|
||||
})),
|
||||
);
|
||||
assert.equal(last.coverage.next_offset ?? null, null);
|
||||
assert.deepEqual(
|
||||
await hashVerifierFile(path, resources),
|
||||
identity,
|
||||
"Input metadata must remain unchanged",
|
||||
);
|
||||
console.log(
|
||||
`[ok] summary and page parity; ${report.images.length} images, ${report.types.length} types, ${report.methods.length} methods; complete Evidence ${Buffer.byteLength(stdout)} bytes, summary ${Buffer.byteLength(JSON.stringify(summary))} bytes; CLI ${cliMs} ms`,
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await closeVerifierResources(
|
||||
[() => client.close(), () => transport.close(), () => resources.close()],
|
||||
primaryError,
|
||||
);
|
||||
}
|
||||
console.log("All asserted IL2CPP metadata CLI/MCP checks passed");
|
||||
@@ -411,6 +411,7 @@ const workflowAvailabilityFor = ({
|
||||
if (
|
||||
name === "inspect_desktop_application" ||
|
||||
name === "inspect_go_binary" ||
|
||||
name === "inspect_il2cpp_metadata" ||
|
||||
name === "inspect_rust_binary" ||
|
||||
name === "inspect_electron_fuses"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolve } from "node:path";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
import {
|
||||
describeEvidenceBundleFailure,
|
||||
@@ -83,7 +83,7 @@ const readBundle = async (
|
||||
if (failure.kind === "single-record")
|
||||
return err(
|
||||
new EvidenceFileError("read", "wrong-kind", {
|
||||
path: resolve(path),
|
||||
path: absoluteCallerPath(path),
|
||||
cause,
|
||||
userMessage: failure.message,
|
||||
}),
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
/** Request control and its owning operation for an interruptible atomic write. */
|
||||
export interface TextWriteCancellation {
|
||||
@@ -94,7 +95,7 @@ const readJsonFileInput = async <Value>(
|
||||
): Promise<
|
||||
Result<Value, EvidenceFileError | AnalysisResourceConstraintError>
|
||||
> => {
|
||||
const requestedPath = resolve(path);
|
||||
const requestedPath = absoluteCallerPath(path);
|
||||
try {
|
||||
const decoded = await read(requestedPath);
|
||||
if (!decoded.ok) {
|
||||
@@ -173,7 +174,7 @@ export function writeTextParts(
|
||||
return err(
|
||||
new EvidenceFileError("write", "io", {
|
||||
cause,
|
||||
path: resolve(path),
|
||||
path: absoluteCallerPath(path),
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
}),
|
||||
);
|
||||
@@ -187,9 +188,13 @@ const writeTextPartsOwned = async (
|
||||
resources: ArtifactResourceScope,
|
||||
cancellation?: TextWriteCancellation,
|
||||
): Promise<TextWriteResult> => {
|
||||
const requestedPath = resolve(path);
|
||||
const requestedPath = absoluteCallerPath(path);
|
||||
try {
|
||||
assertWriteActive(cancellation);
|
||||
if (process.platform !== "win32" && requestedPath.endsWith("/"))
|
||||
return err(
|
||||
new EvidenceFileError("write", "not-file", { path: requestedPath }),
|
||||
);
|
||||
const canonicalParent = await realpath(dirname(requestedPath));
|
||||
const destination = resolve(canonicalParent, basename(requestedPath));
|
||||
const existing = await lstat(destination).catch((cause: unknown) => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
Il2cppMetadata,
|
||||
Il2cppMetadataProviderInput,
|
||||
} from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Replaceable offline metadata reader with no target-execution or decryption authority. */
|
||||
export interface Il2cppMetadataPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
close?(): Promise<void>;
|
||||
inspect(
|
||||
input: Il2cppMetadataProviderInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Il2cppMetadata, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { Il2cppMetadataService } from "./Il2cppMetadataService.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
IL2CPP_METADATA_SECTION_NAMES,
|
||||
type Il2cppMetadata,
|
||||
} from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import { ok } from "../../domain/result.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: [],
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) =>
|
||||
new Il2cppMetadataService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls.count += 1;
|
||||
return Promise.resolve(ok(value));
|
||||
},
|
||||
});
|
||||
|
||||
it("rejects invalid paths and extra arguments before invoking the reader", async () => {
|
||||
const calls = { count: 0 };
|
||||
const service = serviceReturning(report, calls);
|
||||
for (const input of [
|
||||
{ path: "relative/global-metadata.dat", detail: "complete" },
|
||||
{ path: "/artifacts/\0global-metadata.dat", detail: "complete" },
|
||||
{ path: report.artifact.path, detail: "complete", decrypt: true },
|
||||
{ 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(calls.count).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["summary", undefined] as const)(
|
||||
"refuses %s summary delivery without Evidence retention before reading",
|
||||
async (detail) => {
|
||||
const calls = { count: 0 };
|
||||
const result = await serviceReturning(report, calls).inspect({
|
||||
path: report.artifact.path,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
|
||||
expect(calls.count).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns explicit complete artifact-bound Evidence without retention", async () => {
|
||||
const result = await serviceReturning(report).inspect({
|
||||
path: report.artifact.path,
|
||||
detail: "complete",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.parameters).toEqual({ path: report.artifact.path });
|
||||
expect(result.value.normalized_result).toEqual(report);
|
||||
expect(result.value.subject).toMatchObject({
|
||||
format: "file",
|
||||
local_path: report.artifact.path,
|
||||
digest: { sha256: report.artifact.sha256 },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a reader that changes the selected artifact identity", async () => {
|
||||
const result = await serviceReturning({
|
||||
...report,
|
||||
artifact: { ...report.artifact, path: "/artifacts/other.dat" },
|
||||
}).inspect({ path: report.artifact.path, detail: "complete" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("reports cancellation before and after reading", async () => {
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
const calls = { count: 0 };
|
||||
const before = await serviceReturning(report, calls).inspect(
|
||||
{ path: report.artifact.path, detail: "complete" },
|
||||
{ signal: aborted.signal },
|
||||
);
|
||||
expect(before.ok).toBe(false);
|
||||
if (!before.ok) {
|
||||
expect(before.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(before.error.cause).toBe(aborted.signal.reason);
|
||||
}
|
||||
expect(calls.count).toBe(0);
|
||||
|
||||
const during = new AbortController();
|
||||
const service = new Il2cppMetadataService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
during.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const after = await service.inspect(
|
||||
{ path: report.artifact.path, detail: "complete" },
|
||||
{ signal: during.signal },
|
||||
);
|
||||
expect(after.ok).toBe(false);
|
||||
if (!after.ok) {
|
||||
expect(after.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(after.error.cause).toBe(during.signal.reason);
|
||||
expect(after.error.partialObservation).toMatchObject({
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
normalized_result: report,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
type AnalysisViewRetention,
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
import type { Il2cppMetadataPort } from "./Il2cppMetadataPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
il2cppMetadataSchema,
|
||||
inspectIl2cppMetadataInputSchema,
|
||||
} from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
const OPERATION = "inspect_il2cpp_metadata";
|
||||
const NO_RETENTION: AnalysisViewRetention = {
|
||||
evidenceById: undefined,
|
||||
recordEvidence: undefined,
|
||||
};
|
||||
|
||||
/** Shared exact input validation, artifact-bound Evidence and summary delivery for CLI and MCP. */
|
||||
export class Il2cppMetadataService {
|
||||
constructor(readonly provider: Il2cppMetadataPort) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/** Inspect one explicitly selected file; detail changes delivery, not decoded coverage. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
retention: AnalysisViewRetention = NO_RETENTION,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const input = inspectIl2cppMetadataInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { detail, ...providerInput } = input.data;
|
||||
if (detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(OPERATION, retention);
|
||||
if (!ready.ok) return ready;
|
||||
}
|
||||
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)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"IL2CPP metadata reader returned malformed metadata or changed the selected artifact identity.",
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: "file",
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: providerInput,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: value.artifact.path },
|
||||
...value.sections
|
||||
.filter(
|
||||
({ name, size }) =>
|
||||
size > 0 &&
|
||||
(name === "images" ||
|
||||
name === "typeDefinitions" ||
|
||||
name === "methods" ||
|
||||
name === "nestedTypes" ||
|
||||
name === "strings"),
|
||||
)
|
||||
.map(({ offset, size }) => ({
|
||||
kind: "file-offset-range" as const,
|
||||
start: offset,
|
||||
end: offset + size,
|
||||
})),
|
||||
],
|
||||
},
|
||||
);
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
partialObservation: evidence,
|
||||
}),
|
||||
);
|
||||
return detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { DesktopFile, DesktopFact } from "../domain/desktopApplication.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import { IL2CPP_METADATA_MAGIC } from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import { parsePeRecord, peRvaToFileOffset } from "../domain/binaryTarget.js";
|
||||
import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js";
|
||||
import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js";
|
||||
@@ -28,24 +28,6 @@ class DesktopProbeLimit extends ArtifactReaderFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/** Count the producer-owned, shallow fact shape without expanding target strings. */
|
||||
const factJsonBytes = (value: JsonValue): number => {
|
||||
if (typeof value === "string") return jsonStringByteLength(value);
|
||||
if (Array.isArray(value)) {
|
||||
let bytes = 2 + Math.max(0, value.length - 1);
|
||||
for (const child of value) bytes += factJsonBytes(child);
|
||||
return bytes;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
let bytes = 2 + Math.max(0, entries.length - 1);
|
||||
for (const [key, child] of entries)
|
||||
bytes += jsonStringByteLength(key) + 1 + factJsonBytes(child);
|
||||
return bytes;
|
||||
}
|
||||
return String(value).length;
|
||||
};
|
||||
|
||||
/** Inspect selected ranges on an already admitted descriptor; never hash payloads. */
|
||||
export const inspectDesktopFile = async (
|
||||
file: FileHandle,
|
||||
@@ -103,7 +85,9 @@ export const inspectDesktopFile = async (
|
||||
return result;
|
||||
};
|
||||
const fact = (observation: DesktopFact) => {
|
||||
const reservation = 512 + factJsonBytes(observation.value) * 12;
|
||||
const remaining = budget.maximumRetainedBytes - budget.retainedBytes;
|
||||
const reservation =
|
||||
512 + jsonValueByteLength(observation.value, (remaining - 512) / 12) * 12;
|
||||
if (reservation > budget.maximumRetainedBytes - budget.retainedBytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
@@ -114,7 +98,7 @@ export const inspectDesktopFile = async (
|
||||
row.facts.push(observation);
|
||||
};
|
||||
const prefix = await read(0, Math.min(size, 64));
|
||||
if (prefix.length >= 8 && prefix.readUInt32LE(0) === 0xfab11baf)
|
||||
if (prefix.length >= 8 && prefix.readUInt32LE(0) === IL2CPP_METADATA_MAGIC)
|
||||
fact({
|
||||
kind: "il2cpp-metadata-header",
|
||||
offset: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createDirectAnalysis } from "./composition/directAnalysis.js";
|
||||
import { registerEvmCommands } from "./cli/evmCommands.js";
|
||||
import { registerDesktopApplicationCommands } from "./cli/desktopApplicationCommands.js";
|
||||
import { registerGoCommands } from "./cli/goCommands.js";
|
||||
import { registerIl2cppCommands } from "./cli/il2cppCommands.js";
|
||||
import { registerRustCommands } from "./cli/rustCommands.js";
|
||||
import { registerSqliteCommands } from "./cli/sqliteCommands.js";
|
||||
import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js";
|
||||
@@ -119,6 +120,7 @@ export const createCli = (
|
||||
registerEvmCommands(cli, logger, environment);
|
||||
registerDesktopApplicationCommands(cli, logger);
|
||||
registerGoCommands(cli, logger);
|
||||
registerIl2cppCommands(cli, logger);
|
||||
registerRustCommands(cli, logger);
|
||||
registerSqliteCommands(cli, logger, environment);
|
||||
registerArtifactCommands(cli, logger, analysis.runProviderAnalysis);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { z } from "incur";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { AnalysisErrorProjection } from "../domain/analysisErrorProjection.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import { writeEvidenceBundle } from "../application/EvidenceBundleFiles.js";
|
||||
import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
|
||||
export const analysisEvidenceDeliveryOptions = {
|
||||
detail: z
|
||||
.enum(["complete", "summary"])
|
||||
.default("complete")
|
||||
.describe("Deliver complete Evidence, or a summary with --evidence_output"),
|
||||
evidence_output: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Save complete Evidence as a bundle; required for summary delivery. Never overwrites.",
|
||||
),
|
||||
};
|
||||
|
||||
const isEvidence = (
|
||||
value: Evidence | AnalysisErrorProjection,
|
||||
): value is Evidence => "evidence_id" in value && "normalized_result" in value;
|
||||
|
||||
/** One-shot delivery owns report publication; analysis keeps its own cleanup owner. */
|
||||
export const deliverCliAnalysisEvidence = async (input: {
|
||||
readonly run: () => Promise<Result<Evidence, AnalysisError>>;
|
||||
readonly service: { close(): Promise<void> };
|
||||
readonly providerId: string;
|
||||
readonly operation: string;
|
||||
readonly signal: AbortSignal;
|
||||
readonly detail: "complete" | "summary";
|
||||
readonly evidenceOutput: string | undefined;
|
||||
}) => {
|
||||
const {
|
||||
run,
|
||||
service,
|
||||
providerId,
|
||||
operation,
|
||||
signal,
|
||||
detail,
|
||||
evidenceOutput,
|
||||
} = input;
|
||||
if (detail === "summary" && evidenceOutput === undefined)
|
||||
return finishAnalysisCommand(
|
||||
async () => ({
|
||||
ok: false,
|
||||
error: new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["evidence_output"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Summary delivery requires --evidence_output so complete source Evidence remains available.",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
service,
|
||||
providerId,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
const complete = await finishAnalysisCommand(
|
||||
run,
|
||||
service,
|
||||
providerId,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
if (!isEvidence(complete) || evidenceOutput === undefined) return complete;
|
||||
const resources = new ArtifactResourceScope();
|
||||
let receipt: { readonly path: string; readonly bytes: number } | undefined;
|
||||
const delivered = await finishAnalysisCommand(
|
||||
async () => {
|
||||
const written = await writeEvidenceBundle(
|
||||
createEvidenceBundle([complete]),
|
||||
absoluteCallerPath(evidenceOutput),
|
||||
false,
|
||||
resources,
|
||||
signal,
|
||||
);
|
||||
if (!written.ok) {
|
||||
if (written.error.partialObservation === undefined)
|
||||
written.error.retainPartialObservation(complete);
|
||||
return written;
|
||||
}
|
||||
receipt = written.value;
|
||||
return detail === "summary"
|
||||
? inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: complete },
|
||||
view: { kind: "summary" },
|
||||
})
|
||||
: { ok: true as const, value: complete };
|
||||
},
|
||||
{ close: () => resources.close() },
|
||||
providerId,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
return isEvidence(delivered) && detail === "summary"
|
||||
? { summary: delivered, evidence_bundle: receipt }
|
||||
: delivered;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { AnalysisErrorProjection } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
analysisEvidenceDeliveryOptions,
|
||||
deliverCliAnalysisEvidence,
|
||||
} from "./analysisEvidenceDelivery.js";
|
||||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
@@ -8,17 +10,6 @@ import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js";
|
||||
import { writeEvidenceBundle } from "../application/EvidenceBundleFiles.js";
|
||||
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
|
||||
const isEvidenceResult = (
|
||||
value: Evidence | AnalysisErrorProjection,
|
||||
): value is Evidence => "evidence_id" in value && "normalized_result" in value;
|
||||
|
||||
/** One-shot complete Evidence can be saved and queried with inspect-analysis-view. */
|
||||
export const registerDesktopApplicationCommands = (
|
||||
@@ -41,19 +32,7 @@ export const registerDesktopApplicationCommands = (
|
||||
.describe(
|
||||
"Maximum directory entries to examine; exhaustion leaves partial coverage",
|
||||
),
|
||||
detail: z
|
||||
.enum(["complete", "summary"])
|
||||
.default("complete")
|
||||
.describe(
|
||||
"Deliver complete Evidence, or a summary with --evidence_output",
|
||||
),
|
||||
evidence_output: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Save complete Evidence as a bundle; required for summary delivery. Never overwrites.",
|
||||
),
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
max_probe_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -69,65 +48,20 @@ export const registerDesktopApplicationCommands = (
|
||||
CLI_COMMANDS.inspectDesktopApplication,
|
||||
async () => {
|
||||
const { detail, evidence_output, ...limits } = options;
|
||||
if (detail === "summary" && evidence_output === undefined)
|
||||
return projectAnalysisError(
|
||||
new AnalysisInputError(
|
||||
"inspect_desktop_application",
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
path: ["evidence_output"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Summary delivery requires --evidence_output so complete source Evidence remains available.",
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
const service = createDesktopApplicationService();
|
||||
const complete = await finishAnalysisCommand(
|
||||
() =>
|
||||
return deliverCliAnalysisEvidence({
|
||||
run: () =>
|
||||
service.inspect(
|
||||
{ path: absoluteCallerPath(args.path), ...limits },
|
||||
{ signal },
|
||||
),
|
||||
service,
|
||||
"rea-artifact-graph",
|
||||
"inspect_desktop_application",
|
||||
providerId: "rea-artifact-graph",
|
||||
operation: "inspect_desktop_application",
|
||||
signal,
|
||||
);
|
||||
if (!isEvidenceResult(complete) || evidence_output === undefined)
|
||||
return complete;
|
||||
const resources = new ArtifactResourceScope();
|
||||
let receipt:
|
||||
| { readonly path: string; readonly bytes: number }
|
||||
| undefined;
|
||||
const delivered = await finishAnalysisCommand(
|
||||
async () => {
|
||||
const written = await writeEvidenceBundle(
|
||||
createEvidenceBundle([complete]),
|
||||
absoluteCallerPath(evidence_output),
|
||||
false,
|
||||
resources,
|
||||
signal,
|
||||
);
|
||||
if (!written.ok) return written;
|
||||
receipt = written.value;
|
||||
return detail === "summary"
|
||||
? inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: complete },
|
||||
view: { kind: "summary" },
|
||||
})
|
||||
: { ok: true as const, value: complete };
|
||||
},
|
||||
{ close: () => resources.close() },
|
||||
"rea-artifact-graph",
|
||||
"inspect_desktop_application",
|
||||
signal,
|
||||
);
|
||||
return isEvidenceResult(delivered) && detail === "summary"
|
||||
? { summary: delivered, evidence_bundle: receipt }
|
||||
: delivered;
|
||||
detail,
|
||||
evidenceOutput: evidence_output,
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { z } from "incur";
|
||||
import { createIl2cppMetadataService } from "../composition/il2cpp.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import {
|
||||
analysisEvidenceDeliveryOptions,
|
||||
deliverCliAnalysisEvidence,
|
||||
} from "./analysisEvidenceDelivery.js";
|
||||
|
||||
/** CLI and MCP share inspection; one-shot summaries preserve a complete Evidence file. */
|
||||
export const registerIl2cppCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.inspectIl2cppMetadata, {
|
||||
description:
|
||||
"Inventory IL2CPP global-metadata.dat images, types and methods",
|
||||
args: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe("Local global-metadata.dat (version 29, 31 or 39)"),
|
||||
}),
|
||||
options: z.object(analysisEvidenceDeliveryOptions),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => {
|
||||
const service = createIl2cppMetadataService();
|
||||
return deliverCliAnalysisEvidence({
|
||||
run: () =>
|
||||
service.inspect(
|
||||
{ path: absoluteCallerPath(args.path), detail: "complete" },
|
||||
{ signal },
|
||||
),
|
||||
service,
|
||||
providerId: "il2cpp-metadata",
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
signal,
|
||||
detail: options.detail,
|
||||
evidenceOutput: options.evidence_output,
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -59,6 +59,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
|
||||
inspectDesktopApplication: "inspect-desktop-application",
|
||||
inspectGoBinary: "inspect-go-binary",
|
||||
inspectIl2cppMetadata: "inspect-il2cpp-metadata",
|
||||
inspectRustBinary: "inspect-rust-binary",
|
||||
inspectPeResources: "inspect-pe-resources",
|
||||
inspectAnalysisView: "inspect-analysis-view",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Il2cppMetadataService } from "../application/il2cpp/Il2cppMetadataService.js";
|
||||
import { Il2cppMetadataProvider } from "../il2cpp/Il2cppMetadataProvider.js";
|
||||
|
||||
/** Compose bundled offline inspection without acquiring a toolchain or starting any process. */
|
||||
export const createIl2cppMetadataService = (): Il2cppMetadataService =>
|
||||
new Il2cppMetadataService(new Il2cppMetadataProvider());
|
||||
@@ -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 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. 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>}. 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 references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recordeLine 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. 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>}. 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 references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paLine truncated
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { il2cppMetadataSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
|
||||
import {
|
||||
il2cppMetadataSchema,
|
||||
inspectIl2cppMetadataInputSchema,
|
||||
} from "../../domain/il2cpp/il2cppMetadata.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
|
||||
/** Static IL2CPP metadata is independent of native-analysis providers and Unity installations. */
|
||||
export const IL2CPP_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_il2cpp_metadata",
|
||||
...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.",
|
||||
inputSchema: inspectIl2cppMetadataInputSchema,
|
||||
outputSchema: evidenceResultOf(
|
||||
z.union([il2cppMetadataSchema, il2cppMetadataSummaryViewResultSchema]),
|
||||
),
|
||||
examples: [
|
||||
{
|
||||
title: "Summarize metadata and retain rows for paging",
|
||||
input: {
|
||||
path: "/artifacts/Data/Managed/Metadata/global-metadata.dat",
|
||||
detail: "summary",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -4,6 +4,7 @@ import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js";
|
||||
import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
|
||||
import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "./desktopApplicationToolContracts.js";
|
||||
import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js";
|
||||
import { IL2CPP_TOOL_CONTRACTS } from "./il2cpp/il2cppToolContracts.js";
|
||||
import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.js";
|
||||
import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js";
|
||||
import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js";
|
||||
@@ -47,6 +48,7 @@ export const TOOL_CONTRACTS = [
|
||||
...DESKTOP_APPLICATION_TOOL_CONTRACTS,
|
||||
...EVM_TOOL_CONTRACTS,
|
||||
...GO_TOOL_CONTRACTS,
|
||||
...IL2CPP_TOOL_CONTRACTS,
|
||||
...RUST_TOOL_CONTRACTS,
|
||||
...WASM_TOOL_CONTRACTS,
|
||||
...SQLITE_TOOL_CONTRACTS,
|
||||
|
||||
@@ -127,6 +127,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
}),
|
||||
inspect_desktop_application: evidence,
|
||||
inspect_go_binary: evidence,
|
||||
inspect_il2cpp_metadata: evidence,
|
||||
inspect_rust_binary: evidence,
|
||||
inspect_sqlite_database: effects({
|
||||
mutatesSession: true,
|
||||
|
||||
@@ -128,6 +128,13 @@ import {
|
||||
dartAotViewRequestSchema,
|
||||
dartAotViewContextSchema,
|
||||
} from "./dartAotView.js";
|
||||
import {
|
||||
projectIl2cppMetadataView,
|
||||
il2cppMetadataSummarySchema,
|
||||
il2cppMetadataViewRequestSchema,
|
||||
il2cppMetadataViewContextSchema,
|
||||
il2cppMetadataViewItemsSchema,
|
||||
} from "./il2cppMetadataView.js";
|
||||
|
||||
const summaryViewSchema = z.strictObject({ kind: z.literal("summary") });
|
||||
const facetViewSchema = z.strictObject({
|
||||
@@ -253,6 +260,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
qlogViewRequestSchema,
|
||||
pcapViewRequestSchema,
|
||||
dartAotViewRequestSchema,
|
||||
il2cppMetadataViewRequestSchema,
|
||||
]);
|
||||
|
||||
// The application owns admission and authenticated Evidence parsing. SDK and
|
||||
@@ -374,6 +382,7 @@ const viewResultBase = {
|
||||
"inspect_android_package",
|
||||
"inspect_pcap_capture",
|
||||
"inspect_dart_aot",
|
||||
"inspect_il2cpp_metadata",
|
||||
]),
|
||||
parent_digest: digestSchema,
|
||||
view_digest: digestSchema,
|
||||
@@ -505,6 +514,13 @@ export const dartAotSummaryViewResultSchema = z.strictObject({
|
||||
summary: dartAotSummarySchema,
|
||||
});
|
||||
|
||||
/** Metadata identity and row counts for selected IL2CPP delivery. */
|
||||
export const il2cppMetadataSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("inspect_il2cpp_metadata"),
|
||||
summary: il2cppMetadataSummarySchema,
|
||||
});
|
||||
|
||||
/** Projected facts for one selected view of retained analysis Evidence. */
|
||||
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
@@ -545,6 +561,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
androidPackageSummarySchema,
|
||||
pcapSummarySchema,
|
||||
dartAotSummarySchema,
|
||||
il2cppMetadataSummarySchema,
|
||||
]),
|
||||
}),
|
||||
z.strictObject({
|
||||
@@ -665,6 +682,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
...dartAotViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_dart_aot"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("il2cpp-metadata"),
|
||||
view: il2cppMetadataViewRequestSchema,
|
||||
items: il2cppMetadataViewItemsSchema,
|
||||
...viewResultBase,
|
||||
...il2cppMetadataViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_il2cpp_metadata"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("inventory"),
|
||||
view: inventoryViewSchema,
|
||||
@@ -770,6 +795,10 @@ export type UnsignedAnalysisView =
|
||||
| Omit<
|
||||
Extract<AnalysisViewResult, { readonly kind: "dart-aot" }>,
|
||||
"view_digest"
|
||||
>
|
||||
| Omit<
|
||||
Extract<AnalysisViewResult, { readonly kind: "il2cpp-metadata" }>,
|
||||
"view_digest"
|
||||
>;
|
||||
|
||||
/** Attach the content digest that must change when projected bytes change. */
|
||||
@@ -817,7 +846,7 @@ const unsupportedParent = (
|
||||
parentArtifactPath(parent),
|
||||
parent.operation === "capture_browser_scenario"
|
||||
? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest view.`
|
||||
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, or web network capture Evidence.`,
|
||||
);
|
||||
|
||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
@@ -852,6 +881,7 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
["inspect_managed_members", projectManagedMemberView],
|
||||
["inspect_android_package", projectAndroidPackageView],
|
||||
["inspect_dart_aot", projectDartAotView],
|
||||
["inspect_il2cpp_metadata", projectIl2cppMetadataView],
|
||||
["inspect_artifact", projectArtifactInventoryView],
|
||||
["inventory_artifact", projectArtifactInventoryView],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
IL2CPP_METADATA_SECTION_NAMES,
|
||||
il2cppMetadataSchema,
|
||||
type Il2cppMetadata,
|
||||
} from "../il2cpp/il2cppMetadata.js";
|
||||
import { createEvidence } from "../evidence.js";
|
||||
import { freezeJsonSnapshot } from "../immutableJson.js";
|
||||
import { projectIl2cppMetadataView } from "./il2cppMetadataView.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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: [{ name: "A.dll", type_start: 0, type_count: 2 }],
|
||||
types: [
|
||||
{
|
||||
name: "Outer",
|
||||
namespace: "N",
|
||||
method_start: 0,
|
||||
method_count: 1,
|
||||
nested_type_start: 0,
|
||||
nested_type_count: 1,
|
||||
declaring_type: -1,
|
||||
},
|
||||
{
|
||||
name: "Inner",
|
||||
namespace: "",
|
||||
method_start: -1,
|
||||
method_count: 0,
|
||||
nested_type_start: -1,
|
||||
nested_type_count: 0,
|
||||
declaring_type: 0,
|
||||
},
|
||||
],
|
||||
methods: [{ name: "Run", declaring_type: 0 }],
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const parent = (normalizedResult: unknown, sealedResult: boolean) => ({
|
||||
evidenceId: `ev_${"b".repeat(64)}`,
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
normalizedResult,
|
||||
sealedResult,
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
const page = {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "types",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
} as const;
|
||||
|
||||
it("validates a sealed parent once across pages and keeps counts exact", () => {
|
||||
const parse = vi.spyOn(il2cppMetadataSchema, "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 },
|
||||
});
|
||||
}
|
||||
expect(parse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("revalidates unsealed inline input on every page", () => {
|
||||
const parse = vi.spyOn(il2cppMetadataSchema, "safeParse");
|
||||
const inline = parent(structuredClone(report), false);
|
||||
projectIl2cppMetadataView(inline, page);
|
||||
projectIl2cppMetadataView(inline, page);
|
||||
expect(parse).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("intersects batch identities, fields, image ranges and exact names in source order", () => {
|
||||
const source = parent(freezeJsonSnapshot(structuredClone(report)), true);
|
||||
const result = projectIl2cppMetadataView(source, {
|
||||
...page,
|
||||
indices: [1, 0, 1, 100],
|
||||
image_indices: [0],
|
||||
names: ["Inner"],
|
||||
namespaces: [""],
|
||||
declaring_types: [0],
|
||||
fields: ["name"],
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
items: [{ index: 1, name: "Inner" }],
|
||||
coverage: { total: 1, exhausted: true },
|
||||
counts: { images: 1, types: 2, methods: 1 },
|
||||
});
|
||||
const identityOnly = projectIl2cppMetadataView(source, {
|
||||
...page,
|
||||
fields: [],
|
||||
});
|
||||
if (!identityOnly.ok) throw identityOnly.error;
|
||||
expect(identityOnly.value).toMatchObject({ items: [{ index: 0 }] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"indices",
|
||||
"image_indices",
|
||||
"names",
|
||||
"namespaces",
|
||||
"declaring_types",
|
||||
] as const)("keeps an empty %s selection empty", (field) => {
|
||||
const result = projectIl2cppMetadataView(parent(report, false), {
|
||||
...page,
|
||||
[field]: [],
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
items: [],
|
||||
coverage: { total: 0, exhausted: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trust a forged seal flag or a shallow frozen mutable report", () => {
|
||||
const mutable = structuredClone(report);
|
||||
Object.freeze(mutable);
|
||||
const source = parent(mutable, true);
|
||||
const first = projectIl2cppMetadataView(source, page);
|
||||
if (!first.ok) throw first.error;
|
||||
mutable.types[0]!.name = "Changed";
|
||||
const second = projectIl2cppMetadataView(source, page);
|
||||
if (!second.ok) throw second.error;
|
||||
expect(second.value).toMatchObject({
|
||||
items: [{ index: 0, name: "Changed" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a parent whose subject contradicts the recorded artifact identity", () => {
|
||||
const result = projectIl2cppMetadataView(
|
||||
{
|
||||
...parent(report, false),
|
||||
subject: createEvidence(
|
||||
{ path: report.artifact.path, format: "file", sha256: "c".repeat(64) },
|
||||
{ id: "fixture", name: "fixture", version: "1" },
|
||||
{
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
parameters: {},
|
||||
result: report,
|
||||
},
|
||||
).subject,
|
||||
},
|
||||
page,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { z } from "zod";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { isImmutableJsonSnapshot } from "../immutableJson.js";
|
||||
import {
|
||||
il2cppMetadataImageSchema,
|
||||
il2cppMetadataMethodSchema,
|
||||
il2cppMetadataSectionSchema,
|
||||
il2cppMetadataObjectSchema,
|
||||
il2cppMetadataSchema,
|
||||
il2cppMetadataTypeSchema,
|
||||
} from "../il2cpp/il2cppMetadata.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import type {
|
||||
AnalysisViewParent,
|
||||
AnalysisViewRequest,
|
||||
UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
|
||||
const countsSchema = z.strictObject({
|
||||
sections: z.number().int().nonnegative(),
|
||||
images: z.number().int().nonnegative(),
|
||||
types: z.number().int().nonnegative(),
|
||||
methods: z.number().int().nonnegative(),
|
||||
nested_types: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const il2cppMetadataSummarySchema = il2cppMetadataObjectSchema
|
||||
.omit({ images: true, types: true, methods: true, sections: true })
|
||||
.extend({ counts: countsSchema });
|
||||
|
||||
const indexSchema = z.number().int().nonnegative();
|
||||
const sectionRowSchema = il2cppMetadataSectionSchema.extend({
|
||||
index: indexSchema,
|
||||
});
|
||||
const imageRowSchema = il2cppMetadataImageSchema.extend({ index: indexSchema });
|
||||
const typeRowSchema = il2cppMetadataTypeSchema.extend({ index: indexSchema });
|
||||
const methodRowSchema = il2cppMetadataMethodSchema.extend({
|
||||
index: indexSchema,
|
||||
});
|
||||
const page = {
|
||||
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 declaringTypes = z.array(z.number().int().min(-1)).optional();
|
||||
|
||||
/** Filters intersect; pages use metadata order and always retain the source row index. */
|
||||
export const il2cppMetadataViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("sections"),
|
||||
fields: z.array(sectionRowSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("images"),
|
||||
fields: z.array(imageRowSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("types"),
|
||||
image_indices: z.array(indexSchema).optional(),
|
||||
namespaces: z.array(z.string()).optional(),
|
||||
declaring_types: declaringTypes,
|
||||
fields: z.array(typeRowSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("methods"),
|
||||
declaring_types: declaringTypes,
|
||||
fields: z.array(methodRowSchema.keyof()).optional(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export const il2cppMetadataViewItemsSchema = z.union([
|
||||
z.array(sectionRowSchema.partial().required({ index: true })),
|
||||
z.array(imageRowSchema.partial().required({ index: true })),
|
||||
z.array(typeRowSchema.partial().required({ index: true })),
|
||||
z.array(methodRowSchema.partial().required({ index: true })),
|
||||
]);
|
||||
|
||||
export const il2cppMetadataViewContextSchema = z.strictObject({
|
||||
metadata_version: il2cppMetadataObjectSchema.shape.metadata_version,
|
||||
counts: countsSchema,
|
||||
});
|
||||
|
||||
type Report = z.infer<typeof il2cppMetadataSchema>;
|
||||
type Request = z.infer<typeof il2cppMetadataViewRequestSchema>;
|
||||
type Row = Report[Request["collection"]][number];
|
||||
interface CheckedReport {
|
||||
readonly report: Report;
|
||||
readonly counts: z.infer<typeof countsSchema>;
|
||||
}
|
||||
|
||||
// Authenticated, frozen parents own these facts. Cache validation/counts for
|
||||
// their lifetime without a second report copy or a live filesystem cache.
|
||||
const checkedReports = new WeakMap<object, CheckedReport>();
|
||||
|
||||
const parseMetadataReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<CheckedReport, AnalysisError> => {
|
||||
const key = parent.normalizedResult;
|
||||
const reusable =
|
||||
parent.sealedResult === true &&
|
||||
typeof key === "object" &&
|
||||
key !== null &&
|
||||
isImmutableJsonSnapshot(key);
|
||||
const cached = reusable ? checkedReports.get(key) : undefined;
|
||||
if (cached !== undefined) return ok(cached);
|
||||
const parsed = parseAnalysisViewResult(il2cppMetadataSchema, 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.
|
||||
const report = reusable ? (key as Report) : parsed.value;
|
||||
const checked = {
|
||||
report,
|
||||
counts: {
|
||||
sections: report.sections.length,
|
||||
images: report.images.length,
|
||||
types: report.types.length,
|
||||
methods: report.methods.length,
|
||||
nested_types: report.types.reduce(
|
||||
(count, type) => count + Number(type.declaring_type !== -1),
|
||||
0,
|
||||
),
|
||||
},
|
||||
};
|
||||
if (reusable) checkedReports.set(key, checked);
|
||||
return ok(checked);
|
||||
};
|
||||
|
||||
/** Direct batch lookup visits selected indices only; metadata order is stable. */
|
||||
function* rowIndices(length: number, indices: number[] | undefined) {
|
||||
if (indices === undefined) {
|
||||
for (let index = 0; index < length; index++) yield index;
|
||||
} else {
|
||||
for (const index of [...new Set(indices)].sort((a, b) => a - b))
|
||||
if (index < length) yield index;
|
||||
}
|
||||
}
|
||||
|
||||
const selectFields = (
|
||||
row: Row,
|
||||
index: number,
|
||||
fields: Set<string> | undefined,
|
||||
) => {
|
||||
const item: { index: number } & Record<string, JsonValue> = { index };
|
||||
const values: Readonly<Record<string, JsonValue>> = row;
|
||||
for (const key of Object.keys(values)) {
|
||||
const value = values[key];
|
||||
if (value !== undefined && (fields === undefined || fields.has(key)))
|
||||
item[key] = value;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
/** 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 owners =
|
||||
(view.collection !== "types" && view.collection !== "methods") ||
|
||||
view.declaring_types === undefined
|
||||
? undefined
|
||||
: new Set(view.declaring_types);
|
||||
const namespaces =
|
||||
view.collection !== "types" || view.namespaces === undefined
|
||||
? undefined
|
||||
: new Set(view.namespaces);
|
||||
const imageIndices =
|
||||
view.collection === "types" ? view.image_indices : undefined;
|
||||
const ranges: { start: number; end: number }[] = [];
|
||||
if (imageIndices !== undefined) {
|
||||
const selected = [...new Set(imageIndices)]
|
||||
.flatMap((index) => {
|
||||
const image = report.images[index];
|
||||
return image === undefined || image.type_count === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
start: image.type_start,
|
||||
end: image.type_start + image.type_count,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => a.start - b.start);
|
||||
for (const range of selected) {
|
||||
const last = ranges.at(-1);
|
||||
if (last !== undefined && range.start <= last.end)
|
||||
last.end = Math.max(last.end, range.end);
|
||||
else ranges.push(range);
|
||||
}
|
||||
}
|
||||
return (row: Row, index: number) => {
|
||||
if (names !== undefined && !names.has(row.name)) return false;
|
||||
if (
|
||||
owners !== undefined &&
|
||||
(!("declaring_type" in row) || !owners.has(row.declaring_type))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
namespaces !== undefined &&
|
||||
(!("namespace" in row) || !namespaces.has(row.namespace))
|
||||
)
|
||||
return false;
|
||||
if (imageIndices === undefined) return true;
|
||||
let lower = 0;
|
||||
let upper = ranges.length;
|
||||
while (lower < upper) {
|
||||
const middle = lower + Math.floor((upper - lower) / 2);
|
||||
if (ranges[middle]!.start <= index) lower = middle + 1;
|
||||
else upper = middle;
|
||||
}
|
||||
const range = ranges[lower - 1];
|
||||
return range !== undefined && index < range.end;
|
||||
};
|
||||
};
|
||||
|
||||
/** Reuse retained facts; source indices stay distinct from filtered page offsets. */
|
||||
export const projectIl2cppMetadataView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary" && view.kind !== "il2cpp-metadata")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"IL2CPP metadata Evidence supports summary and selected sections/images/types/methods pages.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = parseMetadataReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const { report, counts } = parsed.value;
|
||||
if (
|
||||
parent.subject != null &&
|
||||
(parent.subject.format !== "file" ||
|
||||
parent.subject.local_path !== report.artifact.path ||
|
||||
parent.subject.digest.sha256 !== report.artifact.sha256)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "subject"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"IL2CPP metadata artifact identity does not match its parent subject.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { sections, images, types, methods, ...identity } = report;
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_il2cpp_metadata" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
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.",
|
||||
...(parent.subject == null
|
||||
? [
|
||||
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
kind: "summary",
|
||||
view,
|
||||
...shared,
|
||||
summary: { ...identity, counts },
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
});
|
||||
const rows = { sections, images, types, methods }[view.collection];
|
||||
const matches = selection(view, report);
|
||||
const fields =
|
||||
view.fields === undefined ? undefined : new Set<string>(view.fields);
|
||||
const items = [];
|
||||
let total = 0;
|
||||
for (const index of rowIndices(rows.length, view.indices)) {
|
||||
const row = rows[index]!;
|
||||
if (!matches(row, index)) continue;
|
||||
if (total >= view.offset && items.length < view.limit)
|
||||
items.push(selectFields(row, index, fields));
|
||||
total++;
|
||||
}
|
||||
return ok({
|
||||
kind: "il2cpp-metadata",
|
||||
view,
|
||||
...shared,
|
||||
items,
|
||||
metadata_version: report.metadata_version,
|
||||
counts,
|
||||
coverage: pageViewCoverage(view.offset, items.length, total),
|
||||
});
|
||||
};
|
||||
@@ -147,6 +147,7 @@ export const desktopProbeSchema = z.strictObject({
|
||||
probe_id: prefixedDigestSchema("dsp"),
|
||||
operation: z.enum([
|
||||
"inspect_managed_artifact",
|
||||
"inspect_il2cpp_metadata",
|
||||
"open_binary",
|
||||
"analyze_javascript_application",
|
||||
]),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IL2CPP_METADATA_VERSIONS } from "./il2cpp/il2cppMetadata.js";
|
||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
||||
import type { DesktopApplication, DesktopFile } from "./desktopApplication.js";
|
||||
|
||||
@@ -62,6 +63,21 @@ export const projectDesktopApplication = (
|
||||
"Managed assemblies in a Unity Data/Managed layout reference UnityEngine assemblies.",
|
||||
);
|
||||
const metadata = files.filter((file) => has(file, "il2cpp-metadata-header"));
|
||||
for (const file of metadata)
|
||||
if (
|
||||
file.facts.some(
|
||||
(fact) =>
|
||||
fact.kind === "il2cpp-metadata-header" &&
|
||||
IL2CPP_METADATA_VERSIONS.some(
|
||||
(version) => version === fact.value.raw_version,
|
||||
),
|
||||
)
|
||||
)
|
||||
probe(
|
||||
"inspect_il2cpp_metadata",
|
||||
file,
|
||||
"Inventory this supported-version metadata candidate; complete validity and native binary correspondence remain unverified.",
|
||||
);
|
||||
const il2cpp = files.filter(
|
||||
(file) =>
|
||||
has(file, "il2cpp-library-convention") &&
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
/** Unsigned little-endian signature observed at file offset zero. */
|
||||
export const IL2CPP_METADATA_MAGIC = 0xfab11baf;
|
||||
|
||||
/** Header section order shared by global-metadata.dat versions 29, 31 and 39. */
|
||||
export const IL2CPP_METADATA_SECTION_NAMES = [
|
||||
"stringLiterals",
|
||||
"stringLiteralData",
|
||||
"strings",
|
||||
"events",
|
||||
"properties",
|
||||
"methods",
|
||||
"parameterDefaultValues",
|
||||
"fieldDefaultValues",
|
||||
"fieldAndParameterDefaultValueData",
|
||||
"fieldMarshaledSizes",
|
||||
"parameters",
|
||||
"fields",
|
||||
"genericParameters",
|
||||
"genericParameterConstraints",
|
||||
"genericContainers",
|
||||
"nestedTypes",
|
||||
"interfaces",
|
||||
"vtableMethods",
|
||||
"interfaceOffsets",
|
||||
"typeDefinitions",
|
||||
"images",
|
||||
"assemblies",
|
||||
"fieldRefs",
|
||||
"referencedAssemblies",
|
||||
"attributeData",
|
||||
"attributeDataRanges",
|
||||
"unresolvedIndirectCallParameterTypes",
|
||||
"unresolvedIndirectCallParameterRanges",
|
||||
"windowsRuntimeTypeNames",
|
||||
"windowsRuntimeStrings",
|
||||
"exportedTypeDefinitions",
|
||||
] as const;
|
||||
|
||||
/** Decoded metadata versions; 29 and 31 use fixed 4-byte indices. */
|
||||
export const IL2CPP_METADATA_VERSIONS = [29, 31, 39] as const;
|
||||
|
||||
const count = z.number().int().nonnegative();
|
||||
/** Serialized metadata indices use -1 for an absent reference. */
|
||||
const optionalIndex = z.number().int().min(-1);
|
||||
const indexWidth = z.union([z.literal(1), z.literal(2), z.literal(4)]);
|
||||
|
||||
/** One explicit local file; detail selects delivery, never decoding coverage. */
|
||||
export const inspectIl2cppMetadataInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file",
|
||||
),
|
||||
detail: z
|
||||
.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",
|
||||
),
|
||||
});
|
||||
|
||||
export const il2cppMetadataSectionSchema = z.strictObject({
|
||||
name: z.enum(IL2CPP_METADATA_SECTION_NAMES),
|
||||
offset: count,
|
||||
size: count,
|
||||
/** Header-recorded entry count; versions 29 and 31 record none. */
|
||||
count: count.nullable(),
|
||||
});
|
||||
|
||||
/** Assembly image and its contiguous type-definition range. */
|
||||
export const il2cppMetadataImageSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
type_start: optionalIndex,
|
||||
type_count: count,
|
||||
});
|
||||
|
||||
/** Type definition; row position is its TypeDefinitionIndex. */
|
||||
export const il2cppMetadataTypeSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
namespace: z.string(),
|
||||
method_start: optionalIndex,
|
||||
method_count: count,
|
||||
nested_type_start: optionalIndex,
|
||||
nested_type_count: count,
|
||||
/** Enclosing type derived from the nestedTypes section. */
|
||||
declaring_type: optionalIndex.describe(
|
||||
"Enclosing TypeDefinitionIndex derived from nestedTypes; -1 means no enclosing metadata type was recorded",
|
||||
),
|
||||
});
|
||||
|
||||
/** Method definition; row position is its MethodIndex. */
|
||||
export const il2cppMetadataMethodSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
declaring_type: optionalIndex.describe(
|
||||
"Declared TypeDefinitionIndex from this MethodDefinition; -1 is an absent reference",
|
||||
),
|
||||
});
|
||||
|
||||
/** Unrefined report fields, shared with projections that omit row collections. */
|
||||
export const il2cppMetadataObjectSchema = z.strictObject({
|
||||
artifact: z.strictObject({
|
||||
path: localPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
bytes: count,
|
||||
}),
|
||||
magic: z.literal(IL2CPP_METADATA_MAGIC),
|
||||
metadata_version: z.union(
|
||||
IL2CPP_METADATA_VERSIONS.map((version) => z.literal(version)),
|
||||
),
|
||||
index_widths: z.strictObject({
|
||||
type_definition: indexWidth,
|
||||
generic_container: indexWidth,
|
||||
parameter: indexWidth,
|
||||
type: indexWidth,
|
||||
type_width_source: z.enum([
|
||||
"interface-offsets-record",
|
||||
"type-definition-record",
|
||||
"fixed-width-version",
|
||||
]),
|
||||
}),
|
||||
sections: z
|
||||
.array(il2cppMetadataSectionSchema)
|
||||
.length(IL2CPP_METADATA_SECTION_NAMES.length),
|
||||
images: z.array(il2cppMetadataImageSchema),
|
||||
types: z.array(il2cppMetadataTypeSchema),
|
||||
methods: z.array(il2cppMetadataMethodSchema),
|
||||
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])
|
||||
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.",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export type InspectIl2cppMetadataInput = z.infer<
|
||||
typeof inspectIl2cppMetadataInputSchema
|
||||
>;
|
||||
export type Il2cppMetadataProviderInput = Omit<
|
||||
InspectIl2cppMetadataInput,
|
||||
"detail"
|
||||
>;
|
||||
export type Il2cppMetadataSection = z.infer<typeof il2cppMetadataSectionSchema>;
|
||||
export type Il2cppMetadata = z.infer<typeof il2cppMetadataSchema>;
|
||||
@@ -1,7 +1,10 @@
|
||||
/** Exact UTF-8 bytes of a JSON string literal, counted without serializing it. */
|
||||
export const jsonStringByteLength = (value: string): number => {
|
||||
/** UTF-8 JSON string bytes without serialization; above budget only establishes overflow. */
|
||||
export const jsonStringByteLength = (
|
||||
value: string,
|
||||
budget = Number.POSITIVE_INFINITY,
|
||||
): number => {
|
||||
let bytes = 2;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
for (let index = 0; index < value.length && bytes <= budget; index++) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit === 0x22 || unit === 0x5c) bytes += 2;
|
||||
else if (unit < 0x20)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { jsonValueByteLength } from "./jsonValueByteLength.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
it("matches native UTF-8 serialization for keys, repeated values and nested JSON", () => {
|
||||
const text = '雪🧪"\\\n\u0001\ud800';
|
||||
const values: JsonValue[] = [
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
-0,
|
||||
1e25,
|
||||
Number.NaN,
|
||||
{ [text]: [text, text, {}, [], { count: 12345, absent: null }] },
|
||||
];
|
||||
for (const value of values)
|
||||
expect(jsonValueByteLength(value)).toBe(
|
||||
Buffer.byteLength(JSON.stringify(value)),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves exact admission and stops at a proven byte-budget overflow", () => {
|
||||
const value = {
|
||||
names: ["雪".repeat(1000), "\u0001".repeat(1000)],
|
||||
rows: [1, 2, 3],
|
||||
};
|
||||
const exact = Buffer.byteLength(JSON.stringify(value));
|
||||
expect(jsonValueByteLength(value, exact)).toBe(exact);
|
||||
for (const budget of [-1, 0, 1, 64, exact - 1]) {
|
||||
const measured = jsonValueByteLength(value, budget);
|
||||
expect(measured).toBeGreaterThan(budget);
|
||||
expect(measured).toBeLessThanOrEqual(exact);
|
||||
}
|
||||
const visit = () => {
|
||||
throw new Error("Budget overflow must stop later traversal");
|
||||
};
|
||||
const owned = {
|
||||
first: "\u0001".repeat(1000),
|
||||
get later() {
|
||||
return visit();
|
||||
},
|
||||
};
|
||||
expect(jsonValueByteLength(owned, 32)).toBeGreaterThan(32);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import { jsonStringByteLength } from "./jsonStringByteLength.js";
|
||||
|
||||
/**
|
||||
* UTF-8 JSON size of producer-owned JSON, without allocating its serialization.
|
||||
* Above budget, the result only establishes that the value exceeds that budget.
|
||||
*/
|
||||
export const jsonValueByteLength = (
|
||||
value: JsonValue,
|
||||
budget = Number.POSITIVE_INFINITY,
|
||||
): number => {
|
||||
if (typeof value === "string") return jsonStringByteLength(value, budget);
|
||||
if (Array.isArray(value)) {
|
||||
let bytes = 2 + Math.max(0, value.length - 1);
|
||||
for (const child of value) {
|
||||
if (bytes > budget) break;
|
||||
bytes += jsonValueByteLength(child, budget - bytes);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const keys = Object.keys(value);
|
||||
let bytes = 2 + Math.max(0, keys.length - 1);
|
||||
for (const key of keys) {
|
||||
if (bytes > budget) break;
|
||||
bytes += jsonStringByteLength(key, budget - bytes) + 1;
|
||||
if (bytes <= budget)
|
||||
bytes += jsonValueByteLength(value[key]!, budget - bytes);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return typeof value === "number" && !Number.isFinite(value)
|
||||
? 4
|
||||
: String(value).length;
|
||||
};
|
||||
@@ -0,0 +1,718 @@
|
||||
import {
|
||||
IL2CPP_METADATA_MAGIC,
|
||||
IL2CPP_METADATA_SECTION_NAMES,
|
||||
IL2CPP_METADATA_VERSIONS,
|
||||
type Il2cppMetadata,
|
||||
type Il2cppMetadataSection,
|
||||
} from "../domain/il2cpp/il2cppMetadata.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 IndexWidth = 1 | 2 | 4;
|
||||
export type Il2cppMetadataVersion = (typeof IL2CPP_METADATA_VERSIONS)[number];
|
||||
type RecordSection = "typeDefinitions" | "images" | "methods";
|
||||
export type Il2cppMetadataDecoded = Omit<
|
||||
Il2cppMetadata,
|
||||
"artifact" | "limitations"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Version-specific header and record shape. "fixed" headers hold
|
||||
* (offset, size) pairs and every index is a 4-byte int32; "counted" headers
|
||||
* hold (offset, size, count) triples whose counts select variable index widths.
|
||||
*/
|
||||
interface VersionLayout {
|
||||
readonly indexing: "fixed" | "counted";
|
||||
/** Version 29 and 31 type records also carry elementTypeIndex. */
|
||||
readonly typeDefinitionTypeIndices: 3 | 4;
|
||||
/** Version 31 added returnParameterToken to method records. */
|
||||
readonly methodReturnParameterToken: boolean;
|
||||
}
|
||||
|
||||
const VERSION_LAYOUTS: Readonly<Record<Il2cppMetadataVersion, VersionLayout>> =
|
||||
{
|
||||
29: {
|
||||
indexing: "fixed",
|
||||
typeDefinitionTypeIndices: 4,
|
||||
methodReturnParameterToken: false,
|
||||
},
|
||||
31: {
|
||||
indexing: "fixed",
|
||||
typeDefinitionTypeIndices: 4,
|
||||
methodReturnParameterToken: true,
|
||||
},
|
||||
39: {
|
||||
indexing: "counted",
|
||||
typeDefinitionTypeIndices: 3,
|
||||
methodReturnParameterToken: true,
|
||||
},
|
||||
};
|
||||
|
||||
const sectionEntryBytes = (layout: VersionLayout) =>
|
||||
layout.indexing === "counted" ? 12 : 8;
|
||||
|
||||
/** Magic, version and one entry per section. */
|
||||
export const il2cppMetadataHeaderBytes = (version: Il2cppMetadataVersion) =>
|
||||
8 +
|
||||
IL2CPP_METADATA_SECTION_NAMES.length *
|
||||
sectionEntryBytes(VERSION_LAYOUTS[version]);
|
||||
|
||||
const isSupportedVersion = (value: number): value is Il2cppMetadataVersion =>
|
||||
(IL2CPP_METADATA_VERSIONS as readonly number[]).includes(value);
|
||||
|
||||
/** Width the runtime selects for an index into a collection of this many entries. */
|
||||
export const il2cppIndexWidth = (entries: number): IndexWidth =>
|
||||
entries <= 0xff ? 1 : entries <= 0xffff ? 2 : 4;
|
||||
|
||||
/** Record sizes implied by the index widths of one metadata version. */
|
||||
export const il2cppMetadataRecordBytes = (
|
||||
widths: {
|
||||
readonly type_definition: IndexWidth;
|
||||
readonly generic_container: IndexWidth;
|
||||
readonly parameter: IndexWidth;
|
||||
readonly type: IndexWidth;
|
||||
},
|
||||
version: Il2cppMetadataVersion,
|
||||
): Readonly<Record<RecordSection, number>> => {
|
||||
const layout = VERSION_LAYOUTS[version];
|
||||
return {
|
||||
// Two string indices, the type indices, a generic container index,
|
||||
// flags, eight int32 starts, eight uint16 counts, bitfield and token.
|
||||
typeDefinitions:
|
||||
68 +
|
||||
layout.typeDefinitionTypeIndices * widths.type +
|
||||
widths.generic_container,
|
||||
// Name, assembly, two type-definition starts with uint32 counts, entry
|
||||
// point, token, custom-attribute start and count.
|
||||
images: 32 + 2 * widths.type_definition,
|
||||
// Name, declaring type, return type, optional return token, parameter
|
||||
// start, generic container, token and four uint16 fields.
|
||||
methods:
|
||||
16 +
|
||||
(layout.methodReturnParameterToken ? 4 : 0) +
|
||||
widths.type_definition +
|
||||
widths.type +
|
||||
widths.parameter +
|
||||
widths.generic_container,
|
||||
};
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (width === 1) {
|
||||
const value = bytes.readUInt8(offset);
|
||||
return value === 0xff ? -1 : value;
|
||||
}
|
||||
if (width === 2) {
|
||||
const value = bytes.readUInt16LE(offset);
|
||||
return value === 0xffff ? -1 : value;
|
||||
}
|
||||
return bytes.readInt32LE(offset);
|
||||
};
|
||||
|
||||
const readHeader = (bytes: Buffer) => {
|
||||
const magic = bytes.length < 4 ? null : bytes.readUInt32LE(0);
|
||||
if (bytes.length < 8 || magic !== IL2CPP_METADATA_MAGIC)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unrecognized-magic",
|
||||
"the file does not start with the IL2CPP global-metadata magic 0xFAB11BAF; encrypted or non-metadata inputs are not decoded",
|
||||
);
|
||||
const version = bytes.readInt32LE(4);
|
||||
if (!isSupportedVersion(version))
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unsupported-version",
|
||||
`metadata version ${version} is not supported; versions ${IL2CPP_METADATA_VERSIONS.join(", ")} are decoded`,
|
||||
);
|
||||
const headerBytes = il2cppMetadataHeaderBytes(version);
|
||||
if (bytes.length < headerBytes)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"truncated-header",
|
||||
`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,
|
||||
};
|
||||
},
|
||||
);
|
||||
return { magic, version, headerBytes, sections } as const;
|
||||
};
|
||||
|
||||
const checkSections = (
|
||||
bytes: Buffer,
|
||||
headerBytes: number,
|
||||
sections: readonly Il2cppMetadataSection[],
|
||||
) => {
|
||||
const first = sections[0];
|
||||
if (first?.offset !== headerBytes)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"unexpected-header-size",
|
||||
`stringLiterals starts at ${String(first?.offset)}, not immediately after the ${headerBytes}-byte header`,
|
||||
);
|
||||
for (const section of sections)
|
||||
if (
|
||||
section.offset < 0 ||
|
||||
section.size < 0 ||
|
||||
(section.count ?? 0) < 0 ||
|
||||
section.offset + section.size > bytes.length
|
||||
)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"section-out-of-bounds",
|
||||
`section ${section.name} (offset ${section.offset}, size ${section.size}, count ${String(section.count)}) is negative or extends past the ${bytes.length}-byte file`,
|
||||
);
|
||||
else if (section.size > 0 && section.offset < headerBytes)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"section-out-of-bounds",
|
||||
`section ${section.name} starts at ${section.offset}, inside the ${headerBytes}-byte header`,
|
||||
);
|
||||
// Empty sections may share an offset with any neighbor; gaps are padding.
|
||||
const occupied = sections
|
||||
.filter((section) => section.size > 0)
|
||||
.sort((left, right) => left.offset - right.offset);
|
||||
for (let index = 1; index < occupied.length; index += 1) {
|
||||
const previous = occupied[index - 1]!;
|
||||
const current = occupied[index]!;
|
||||
if (previous.offset + previous.size > current.offset)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"section-overlap",
|
||||
`section ${previous.name} [${previous.offset}, ${previous.offset + previous.size}) overlaps ${current.name} starting at ${current.offset}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** A counted header's entry count; fixed headers record none. */
|
||||
const headerCount = (section: Il2cppMetadataSection) => {
|
||||
if (section.count === null)
|
||||
throw new TypeError(`section ${section.name} has no header count`);
|
||||
return section.count;
|
||||
};
|
||||
|
||||
/** Record size from a counted header; null for an empty section. */
|
||||
const recordBytes = (section: Il2cppMetadataSection): number | null => {
|
||||
const count = headerCount(section);
|
||||
if (count === 0) {
|
||||
if (section.size !== 0)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"record-size-mismatch",
|
||||
`section ${section.name} declares ${section.size} bytes for zero records`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (section.size % count !== 0)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"record-size-mismatch",
|
||||
`section ${section.name} size ${section.size} is not a whole multiple of its ${count} records`,
|
||||
);
|
||||
return section.size / count;
|
||||
};
|
||||
|
||||
/** Entry count of a section whose header records only its size. */
|
||||
const entriesIn = (section: Il2cppMetadataSection, entryBytes: number) => {
|
||||
if (section.size % entryBytes !== 0)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"record-size-mismatch",
|
||||
`section ${section.name} size ${section.size} is not a whole multiple of its ${entryBytes}-byte records`,
|
||||
);
|
||||
return section.size / entryBytes;
|
||||
};
|
||||
|
||||
const FIXED_WIDTHS: Il2cppMetadataDecoded["index_widths"] = {
|
||||
type_definition: 4,
|
||||
generic_container: 4,
|
||||
parameter: 4,
|
||||
type: 4,
|
||||
type_width_source: "fixed-width-version",
|
||||
};
|
||||
|
||||
const resolveWidths = (
|
||||
section: (name: SectionName) => Il2cppMetadataSection,
|
||||
): Il2cppMetadataDecoded["index_widths"] => {
|
||||
const type_definition = il2cppIndexWidth(
|
||||
headerCount(section("typeDefinitions")),
|
||||
);
|
||||
const generic_container = il2cppIndexWidth(
|
||||
headerCount(section("genericContainers")),
|
||||
);
|
||||
const parameter = il2cppIndexWidth(headerCount(section("parameters")));
|
||||
// TypeIndex counts native types, so its width is recovered from records.
|
||||
const interfaceOffset = recordBytes(section("interfaceOffsets"));
|
||||
let type: number;
|
||||
let type_width_source: Il2cppMetadataDecoded["index_widths"]["type_width_source"];
|
||||
if (interfaceOffset !== null) {
|
||||
type = interfaceOffset - 4;
|
||||
type_width_source = "interface-offsets-record";
|
||||
} else {
|
||||
const typeDefinition = recordBytes(section("typeDefinitions"));
|
||||
type =
|
||||
typeDefinition === null
|
||||
? Number.NaN
|
||||
: (typeDefinition - 68 - generic_container) / 3;
|
||||
type_width_source = "type-definition-record";
|
||||
}
|
||||
if (!isIndexWidth(type))
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"type-index-width",
|
||||
`the ${type_width_source} does not imply a 1-, 2- or 4-byte TypeIndex`,
|
||||
);
|
||||
return {
|
||||
type_definition,
|
||||
generic_container,
|
||||
parameter,
|
||||
type,
|
||||
type_width_source,
|
||||
};
|
||||
};
|
||||
|
||||
/** Record counts checked against the record sizes the widths imply. */
|
||||
const recordCounts = (
|
||||
version: Il2cppMetadataVersion,
|
||||
section: (name: SectionName) => Il2cppMetadataSection,
|
||||
expected: Readonly<Record<RecordSection, number>>,
|
||||
): Readonly<Record<RecordSection | "nestedTypes", number>> => {
|
||||
const names = ["typeDefinitions", "images", "methods"] as const;
|
||||
if (VERSION_LAYOUTS[version].indexing === "fixed") {
|
||||
const sized = Object.fromEntries(
|
||||
names.map((name) => [name, entriesIn(section(name), expected[name])]),
|
||||
) as Record<RecordSection, number>;
|
||||
return {
|
||||
...sized,
|
||||
nestedTypes: entriesIn(section("nestedTypes"), NESTED_TYPE_ENTRY_BYTES),
|
||||
};
|
||||
}
|
||||
for (const name of names) {
|
||||
const actual = recordBytes(section(name));
|
||||
if (actual !== null && actual !== expected[name])
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"record-size-mismatch",
|
||||
`section ${name} records are ${actual} bytes, but the index widths imply ${expected[name]}`,
|
||||
);
|
||||
}
|
||||
const nestedTypes = section("nestedTypes");
|
||||
if (nestedTypes.size !== headerCount(nestedTypes) * NESTED_TYPE_ENTRY_BYTES)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"record-size-mismatch",
|
||||
`section nestedTypes size ${nestedTypes.size} does not hold ${String(nestedTypes.count)} 4-byte entries`,
|
||||
);
|
||||
return {
|
||||
typeDefinitions: headerCount(section("typeDefinitions")),
|
||||
images: headerCount(section("images")),
|
||||
methods: headerCount(section("methods")),
|
||||
nestedTypes: headerCount(nestedTypes),
|
||||
};
|
||||
};
|
||||
|
||||
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 stringEnd = strings.offset + strings.size;
|
||||
const pool = bytes.subarray(strings.offset, stringEnd);
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
const cache = new Map<number, string>();
|
||||
return (index: number, label: string) => {
|
||||
const cached = cache.get(index);
|
||||
if (cached !== undefined) return cached;
|
||||
const end = index < 0 || index >= pool.length ? -1 : pool.indexOf(0, index);
|
||||
if (end === -1)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"invalid-string",
|
||||
`${label} string index ${index} has no terminated entry inside the ${strings.size}-byte string section`,
|
||||
);
|
||||
if (index > 0 && pool[index - 1] !== 0)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"invalid-string",
|
||||
`${label} string index ${index} starts inside another string`,
|
||||
);
|
||||
let value: string;
|
||||
try {
|
||||
value = decoder.decode(pool.subarray(index, end));
|
||||
} catch (cause: unknown) {
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"invalid-string",
|
||||
`${label} string index ${index} is not valid UTF-8`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
cache.set(index, value);
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
/** Checked header facts shared by the record readers. */
|
||||
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 readString: ReturnType<typeof createStringReader>;
|
||||
}
|
||||
|
||||
function* readImages({
|
||||
bytes,
|
||||
section,
|
||||
widths,
|
||||
recordBytes: sizes,
|
||||
counts,
|
||||
readString,
|
||||
}: MetadataTables): Generator<void, Il2cppMetadataDecoded["images"]> {
|
||||
const images = section("images");
|
||||
const typeTotal = counts.typeDefinitions;
|
||||
const width = widths.type_definition;
|
||||
const result: Il2cppMetadataDecoded["images"] = [];
|
||||
for (let index = 0; index < counts.images; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const at = images.offset + index * sizes.images;
|
||||
const image = {
|
||||
name: readString(bytes.readInt32LE(at), `image ${index} name`),
|
||||
type_start: readIndex(bytes, at + 8, width),
|
||||
type_count: bytes.readUInt32LE(at + 8 + width),
|
||||
};
|
||||
checkRange(
|
||||
`image ${index} types`,
|
||||
image.type_start,
|
||||
image.type_count,
|
||||
typeTotal,
|
||||
);
|
||||
result.push(image);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Record each type's enclosing type from its declaring type's nestedTypes range. */
|
||||
function* assignNesting(
|
||||
{ bytes, section, counts }: MetadataTables,
|
||||
types: Il2cppMetadataDecoded["types"],
|
||||
) {
|
||||
let work = 0;
|
||||
const nestedTypes = section("nestedTypes");
|
||||
for (const [index, type] of types.entries()) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
checkRange(
|
||||
`type ${index} nested types`,
|
||||
type.nested_type_start,
|
||||
type.nested_type_count,
|
||||
counts.nestedTypes,
|
||||
);
|
||||
for (let offset = 0; offset < type.nested_type_count; offset += 1) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
const nested = bytes.readInt32LE(
|
||||
nestedTypes.offset +
|
||||
(type.nested_type_start + offset) * NESTED_TYPE_ENTRY_BYTES,
|
||||
);
|
||||
const target = types[nested];
|
||||
if (target === undefined)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"index-out-of-range",
|
||||
`type ${index} lists nested type ${nested} outside the ${types.length} type definitions`,
|
||||
);
|
||||
if (nested === index)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"nested-type-conflict",
|
||||
`type ${index} lists itself as a nested type`,
|
||||
);
|
||||
if (target.declaring_type !== -1)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"nested-type-conflict",
|
||||
`type ${nested} is listed as nested in type ${index} and type ${target.declaring_type}`,
|
||||
);
|
||||
target.declaring_type = index;
|
||||
}
|
||||
}
|
||||
yield* rejectNestingCycles(types);
|
||||
}
|
||||
|
||||
/** Each enclosing chain must reach a top-level type. */
|
||||
function* rejectNestingCycles(types: Il2cppMetadataDecoded["types"]) {
|
||||
// 0 unvisited, 1 on the current chain, 2 reaches a top-level type.
|
||||
let work = 0;
|
||||
const state = new Uint8Array(types.length);
|
||||
for (let first = 0; first < types.length; first += 1) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
const chain: number[] = [];
|
||||
let current = first;
|
||||
while (current !== -1 && state[current] === 0) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
state[current] = 1;
|
||||
chain.push(current);
|
||||
current = types[current]!.declaring_type;
|
||||
}
|
||||
if (current !== -1 && state[current] === 1)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"nested-type-conflict",
|
||||
`type ${current} is nested inside itself through its declaring types`,
|
||||
);
|
||||
for (const index of chain) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
state[index] = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* readTypes(
|
||||
tables: MetadataTables,
|
||||
): Generator<void, Il2cppMetadataDecoded["types"]> {
|
||||
const { bytes, section, widths, recordBytes: sizes, counts } = tables;
|
||||
const { readString, typeDefinitionTypeIndices } = tables;
|
||||
const typeDefinitions = section("typeDefinitions");
|
||||
const methodTotal = counts.methods;
|
||||
const types: Il2cppMetadataDecoded["types"] = [];
|
||||
for (let index = 0; index < counts.typeDefinitions; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const at = typeDefinitions.offset + index * sizes.typeDefinitions;
|
||||
// int32 starts follow both string indices, the type indices, the
|
||||
// generic container index and flags.
|
||||
const starts =
|
||||
at +
|
||||
12 +
|
||||
typeDefinitionTypeIndices * widths.type +
|
||||
widths.generic_container;
|
||||
const countsAt = starts + 32;
|
||||
const type = {
|
||||
name: readString(bytes.readInt32LE(at), `type ${index} name`),
|
||||
namespace: readString(
|
||||
bytes.readInt32LE(at + 4),
|
||||
`type ${index} namespace`,
|
||||
),
|
||||
method_start: bytes.readInt32LE(starts + 4),
|
||||
method_count: bytes.readUInt16LE(countsAt),
|
||||
nested_type_start: bytes.readInt32LE(starts + 16),
|
||||
nested_type_count: bytes.readUInt16LE(countsAt + 8),
|
||||
declaring_type: -1,
|
||||
};
|
||||
checkRange(
|
||||
`type ${index} methods`,
|
||||
type.method_start,
|
||||
type.method_count,
|
||||
methodTotal,
|
||||
);
|
||||
types.push(type);
|
||||
}
|
||||
yield* assignNesting(tables, types);
|
||||
return types;
|
||||
}
|
||||
|
||||
function* readMethods({
|
||||
bytes,
|
||||
section,
|
||||
widths,
|
||||
recordBytes: sizes,
|
||||
counts,
|
||||
readString,
|
||||
}: MetadataTables): Generator<void, Il2cppMetadataDecoded["methods"]> {
|
||||
const methods = section("methods");
|
||||
const typeTotal = counts.typeDefinitions;
|
||||
const result: Il2cppMetadataDecoded["methods"] = [];
|
||||
for (let index = 0; index < counts.methods; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const at = methods.offset + index * sizes.methods;
|
||||
const method = {
|
||||
name: readString(bytes.readInt32LE(at), `method ${index} name`),
|
||||
declaring_type: readIndex(bytes, at + 4, widths.type_definition),
|
||||
};
|
||||
if (method.declaring_type < -1 || method.declaring_type >= typeTotal)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"index-out-of-range",
|
||||
`method ${index} declares type ${method.declaring_type} outside the ${typeTotal} type definitions`,
|
||||
);
|
||||
result.push(method);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type method ranges and method declaring types must agree in both
|
||||
* directions, so a damaged range cannot strand or misattribute a method.
|
||||
*/
|
||||
function* checkMethodOwners(
|
||||
types: Il2cppMetadataDecoded["types"],
|
||||
methods: Il2cppMetadataDecoded["methods"],
|
||||
) {
|
||||
let work = 0;
|
||||
for (const [index, type] of types.entries()) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
for (let offset = 0; offset < type.method_count; offset += 1) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
const method = type.method_start + offset;
|
||||
const declaring = methods[method]!.declaring_type;
|
||||
if (declaring !== index)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"declaring-type-mismatch",
|
||||
`method ${method} is in type ${index}'s range but declares type ${declaring}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [index, method] of methods.entries()) {
|
||||
if (work++ % 256 === 0) yield;
|
||||
const owner = types[method.declaring_type];
|
||||
if (
|
||||
owner !== undefined &&
|
||||
(index < owner.method_start ||
|
||||
index >= owner.method_start + owner.method_count)
|
||||
)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"declaring-type-mismatch",
|
||||
`method ${index} declares type ${method.declaring_type} but is outside its method range`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Each type belongs to at most one image. */
|
||||
function* checkImageRanges(images: Il2cppMetadataDecoded["images"]) {
|
||||
const ranges: { start: number; end: number; index: number }[] = [];
|
||||
for (let index = 0; index < images.length; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const image = images[index]!;
|
||||
if (image.type_count > 0)
|
||||
ranges.push({
|
||||
start: image.type_start,
|
||||
end: image.type_start + image.type_count,
|
||||
index,
|
||||
});
|
||||
}
|
||||
ranges.sort((left, right) => left.start - right.start);
|
||||
for (let at = 1; at < ranges.length; at++) {
|
||||
if (at % 256 === 0) yield;
|
||||
const previous = ranges[at - 1]!;
|
||||
const current = ranges[at]!;
|
||||
if (previous.end > current.start)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"image-range-overlap",
|
||||
`image ${previous.index} types [${previous.start}, ${previous.end}) overlap image ${current.index} starting at type ${current.start}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Row counts known from the header and record sizes, before rows are built. */
|
||||
export interface Il2cppMetadataRowCounts {
|
||||
readonly images: number;
|
||||
readonly types: number;
|
||||
readonly methods: number;
|
||||
}
|
||||
|
||||
/** Decode image, type and method names 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;
|
||||
} = {},
|
||||
): Generator<void, Il2cppMetadataDecoded> {
|
||||
const { magic, version, headerBytes, sections } = readHeader(bytes);
|
||||
checkSections(bytes, headerBytes, sections);
|
||||
const byName = new Map(sections.map((entry) => [entry.name, entry]));
|
||||
const section = (name: SectionName) => byName.get(name)!;
|
||||
const layout = VERSION_LAYOUTS[version];
|
||||
const widths =
|
||||
layout.indexing === "counted" ? resolveWidths(section) : FIXED_WIDTHS;
|
||||
const expected = il2cppMetadataRecordBytes(widths, version);
|
||||
const tables: MetadataTables = {
|
||||
bytes,
|
||||
section,
|
||||
widths,
|
||||
recordBytes: expected,
|
||||
counts: recordCounts(version, section, expected),
|
||||
typeDefinitionTypeIndices: layout.typeDefinitionTypeIndices,
|
||||
readString: createStringReader(bytes, section("strings")),
|
||||
};
|
||||
options.beforeRows?.({
|
||||
images: tables.counts.images,
|
||||
types: tables.counts.typeDefinitions,
|
||||
methods: tables.counts.methods,
|
||||
});
|
||||
yield;
|
||||
const images = yield* readImages(tables);
|
||||
yield* checkImageRanges(images);
|
||||
const types = yield* readTypes(tables);
|
||||
const methods = yield* readMethods(tables);
|
||||
yield* checkMethodOwners(types, methods);
|
||||
return {
|
||||
magic,
|
||||
metadata_version: version,
|
||||
index_widths: widths,
|
||||
sections,
|
||||
images,
|
||||
types,
|
||||
methods,
|
||||
};
|
||||
}
|
||||
|
||||
/** Synchronous consumption of the same decoder for byte-level callers. */
|
||||
export const readIl2cppMetadata = (
|
||||
bytes: Buffer,
|
||||
options: Parameters<typeof readIl2cppMetadataSteps>[1] = {},
|
||||
): Il2cppMetadataDecoded => {
|
||||
const steps = readIl2cppMetadataSteps(bytes, options);
|
||||
for (;;) {
|
||||
const step = steps.next();
|
||||
if (step.done) return step.value;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import {
|
||||
readStableArtifact,
|
||||
type StableArtifactSnapshot,
|
||||
} from "../artifacts/readStableArtifact.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
ArtifactFileCleanupFailure,
|
||||
ArtifactFileCancelled,
|
||||
bindArtifactFileCleanup,
|
||||
artifactFileCancellationCause,
|
||||
assertArtifactReadActive,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
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 { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
il2cppMetadataSchema,
|
||||
type Il2cppMetadata,
|
||||
type Il2cppMetadataProviderInput,
|
||||
} from "../domain/il2cpp/il2cppMetadata.js";
|
||||
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";
|
||||
|
||||
/** Bundled format reader identity; the metadata version is a separate observed fact. */
|
||||
export const IL2CPP_METADATA_PROVIDER_IDENTITY = {
|
||||
id: "il2cpp-metadata",
|
||||
name: "REA IL2CPP global-metadata reader",
|
||||
version: "1",
|
||||
} as const;
|
||||
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")
|
||||
return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
});
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(OPERATION, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
if (cause instanceof Il2cppMetadataFormatFailure)
|
||||
return cause.kind === "unsupported"
|
||||
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
})
|
||||
: new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: `Selected artifact ${path} is corrupt: ${cause.message}`,
|
||||
},
|
||||
]);
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
if (cause.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"file-size",
|
||||
cause.message,
|
||||
{ maximum_input_bytes: INPUT_BYTES },
|
||||
{ cause },
|
||||
);
|
||||
if (cause.reason === "path")
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{ path: ["path"], reason: "invalid_format", message: cause.message },
|
||||
]);
|
||||
}
|
||||
if (cause instanceof Error && "code" in cause) {
|
||||
if (cause.code === "EACCES" || cause.code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
|
||||
cause,
|
||||
});
|
||||
if (
|
||||
["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes(
|
||||
String(cause.code),
|
||||
)
|
||||
)
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return new ProviderAdapterError(
|
||||
IL2CPP_METADATA_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
{
|
||||
cause,
|
||||
diagnostics: {
|
||||
path,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const failureFor = (cause: unknown, path: string): AnalysisError => {
|
||||
const primary =
|
||||
cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "failed"
|
||||
? failureForPrimary(cause.fileCleanupFailure.outcome.cause, path)
|
||||
: failureForPrimary(cause, path);
|
||||
if (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
cause.partialObservation !== undefined &&
|
||||
primary.partialObservation === undefined
|
||||
)
|
||||
primary.retainPartialObservation(cause.partialObservation);
|
||||
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
|
||||
return primary;
|
||||
const cleanup = new ProviderCleanupError(
|
||||
IL2CPP_METADATA_PROVIDER_IDENTITY.id,
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ operation: OPERATION, cause },
|
||||
);
|
||||
const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
const reportLimit = (path: string, maximumBytes: number) =>
|
||||
new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"memory",
|
||||
`Complete IL2CPP metadata for ${path} exceeds the retained report budget; no complete inventory is retained.`,
|
||||
{ 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,
|
||||
bytes: snapshot.bytes.length,
|
||||
});
|
||||
|
||||
/** Source identity survives a failed inventory without claiming decoded coverage. */
|
||||
const metadataSourceEvidence = (
|
||||
artifact: Il2cppMetadata["artifact"],
|
||||
counts: Il2cppMetadataRowCounts | undefined,
|
||||
) =>
|
||||
createEvidence(
|
||||
{
|
||||
path: artifact.path,
|
||||
sha256: artifact.sha256,
|
||||
format: "file",
|
||||
},
|
||||
IL2CPP_METADATA_PROVIDER_IDENTITY,
|
||||
{
|
||||
operation: "observe_il2cpp_metadata_source",
|
||||
predicateType: "rea.il2cpp-metadata-source",
|
||||
parameters: { path: artifact.path },
|
||||
result: {
|
||||
artifact,
|
||||
inventory_status: "not-returned-as-complete",
|
||||
...(counts === undefined
|
||||
? {}
|
||||
: { validated_section_row_counts: { ...counts } }),
|
||||
},
|
||||
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.",
|
||||
],
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: artifact.path },
|
||||
{
|
||||
kind: "file-offset-range",
|
||||
start: 0,
|
||||
end: artifact.bytes,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
/** Stable in-process decoding without subprocesses, temporary files, native binaries or decryption. */
|
||||
export class Il2cppMetadataProvider implements Il2cppMetadataPort {
|
||||
readonly identity = IL2CPP_METADATA_PROVIDER_IDENTITY;
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
#inspecting = false;
|
||||
|
||||
/** `outputBytes` exists so tests can reach the guard with small files. */
|
||||
constructor(
|
||||
private readonly outputBytes: number = OUTPUT_BYTES,
|
||||
private readonly yieldControl: () => Promise<void> = setImmediate,
|
||||
) {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#resources.close();
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof ArtifactReaderFailure) ||
|
||||
cause.cleanup === undefined
|
||||
)
|
||||
throw cause;
|
||||
const error = new ProviderCleanupError(
|
||||
IL2CPP_METADATA_PROVIDER_IDENTITY.id,
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ cause, operation: OPERATION },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read every image, type and method name from one stable file snapshot. */
|
||||
async inspect(
|
||||
input: Il2cppMetadataProviderInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Il2cppMetadata, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
if (this.#inspecting)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"memory",
|
||||
"Only one IL2CPP metadata inspection is admitted per reader.",
|
||||
{ maximum_concurrent_inspections: 1 },
|
||||
),
|
||||
);
|
||||
this.#inspecting = true;
|
||||
const observed: {
|
||||
artifact?: Il2cppMetadata["artifact"];
|
||||
counts?: Il2cppMetadataRowCounts;
|
||||
} = {};
|
||||
try {
|
||||
return await this.#resources.run(async () => {
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
INPUT_BYTES,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
).catch((cause: unknown) => {
|
||||
// Only this awaited stable read owns these typed physical outcomes.
|
||||
const outcome =
|
||||
cause instanceof ArtifactFileCleanupFailure
|
||||
? (cause as ArtifactFileCleanupFailure<StableArtifactSnapshot>)
|
||||
.fileCleanupFailure.outcome
|
||||
: cause instanceof ArtifactFileCancelled
|
||||
? (cause as ArtifactFileCancelled<StableArtifactSnapshot>)
|
||||
.fileCancellation.outcome
|
||||
: undefined;
|
||||
if (outcome?.kind === "completed")
|
||||
observed.artifact = sourceArtifact(input.path, outcome.value);
|
||||
throw cause;
|
||||
});
|
||||
const artifact = sourceArtifact(input.path, snapshot);
|
||||
observed.artifact = artifact;
|
||||
assertArtifactReadActive(options?.signal);
|
||||
const limit = this.outputBytes;
|
||||
const steps = readIl2cppMetadataSteps(snapshot.bytes, {
|
||||
beforeRows: (counts) => {
|
||||
observed.counts = counts;
|
||||
if (countLowerBound(counts) > limit)
|
||||
throw reportLimit(input.path, limit);
|
||||
},
|
||||
});
|
||||
let step = steps.next();
|
||||
while (!step.done) {
|
||||
await this.yieldControl();
|
||||
assertArtifactReadActive(options?.signal);
|
||||
step = steps.next();
|
||||
}
|
||||
const decoded = step.value;
|
||||
const report = {
|
||||
...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.",
|
||||
],
|
||||
};
|
||||
if (jsonValueByteLength(report, limit) > limit)
|
||||
throw reportLimit(input.path, limit);
|
||||
const parsed = il2cppMetadataSchema.safeParse(report);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"IL2CPP metadata reader produced invalid section ranges or report values.",
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
return ok(parsed.data);
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
const failure = failureFor(cause, input.path);
|
||||
if (
|
||||
observed.artifact !== undefined &&
|
||||
failure.partialObservation === undefined
|
||||
)
|
||||
failure.retainPartialObservation(
|
||||
metadataSourceEvidence(observed.artifact, observed.counts),
|
||||
);
|
||||
return err(failure);
|
||||
} finally {
|
||||
this.#inspecting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,13 @@ import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"
|
||||
import { createDesktopApplicationService } from "../composition/desktop.js";
|
||||
import { registerDesktopApplicationTool } from "./registerDesktopApplicationTool.js";
|
||||
import { createGoBinaryService } from "../composition/go.js";
|
||||
import { createIl2cppMetadataService } from "../composition/il2cpp.js";
|
||||
import type { Il2cppMetadataService } from "../application/il2cpp/Il2cppMetadataService.js";
|
||||
import type { GoBinaryService } from "../application/go/GoBinaryService.js";
|
||||
import { createElectronFuseService } from "../composition/electronFuses.js";
|
||||
import type { ElectronFuseService } from "../application/electron/ElectronFuseService.js";
|
||||
import { registerGoTools } from "./registerGoTools.js";
|
||||
import { registerIl2cppTools } from "./registerIl2cppTools.js";
|
||||
import { createRustBinaryService } from "../composition/rust.js";
|
||||
import type { RustBinaryService } from "../application/rust/RustBinaryService.js";
|
||||
import { registerRustTools } from "./registerRustTools.js";
|
||||
@@ -157,6 +160,7 @@ export interface CreateServerOptions {
|
||||
readonly logger?: Logger;
|
||||
readonly binaryLayout?: BinaryLayoutService;
|
||||
readonly goBinary?: GoBinaryService;
|
||||
readonly il2cppMetadata?: Il2cppMetadataService;
|
||||
readonly rustBinary?: RustBinaryService;
|
||||
readonly electronFuses?: ElectronFuseService;
|
||||
readonly recordedCrash?: RecordedCrashService;
|
||||
@@ -311,6 +315,8 @@ export const createServer = (
|
||||
const desktopApplication = createDesktopApplicationService();
|
||||
const goBinary = options.goBinary ?? createGoBinaryService();
|
||||
const rustBinary = options.rustBinary ?? createRustBinaryService();
|
||||
const il2cppMetadata =
|
||||
options.il2cppMetadata ?? createIl2cppMetadataService();
|
||||
const peResources = createPeResourcesService();
|
||||
const electronFuses = options.electronFuses ?? createElectronFuseService();
|
||||
const webNetworkCapture =
|
||||
@@ -395,6 +401,7 @@ export const createServer = (
|
||||
desktopApplication.close(),
|
||||
goBinary.close(),
|
||||
rustBinary.close(),
|
||||
il2cppMetadata.close(),
|
||||
peResources.close(),
|
||||
electronFuses.close(),
|
||||
webNetworkCapture.close(),
|
||||
@@ -457,6 +464,7 @@ export const createServer = (
|
||||
artifactResources,
|
||||
goBinary,
|
||||
rustBinary,
|
||||
il2cppMetadata,
|
||||
peResources,
|
||||
electronFuses,
|
||||
webNetworkCapture,
|
||||
@@ -665,6 +673,7 @@ const registerConfiguredAnalysisTools = (
|
||||
withAdmittedAnalysis,
|
||||
goBinary,
|
||||
rustBinary,
|
||||
il2cppMetadata,
|
||||
peResources,
|
||||
webNetworkCapture,
|
||||
}: ServerToolContext,
|
||||
@@ -719,6 +728,14 @@ const registerConfiguredAnalysisTools = (
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
registerIl2cppTools(
|
||||
server,
|
||||
il2cppMetadata,
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
evidenceById,
|
||||
);
|
||||
registerRustTools(
|
||||
server,
|
||||
rustBinary,
|
||||
@@ -833,6 +850,7 @@ const createSessionRecorders = (
|
||||
interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
|
||||
readonly goBinary: GoBinaryService;
|
||||
readonly rustBinary: RustBinaryService;
|
||||
readonly il2cppMetadata: Il2cppMetadataService;
|
||||
readonly peResources: PeResourcesService;
|
||||
readonly electronFuses: ElectronFuseService;
|
||||
readonly webNetworkCapture: WebNetworkCaptureService;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import type { Il2cppMetadataService } from "../application/il2cpp/Il2cppMetadataService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { createEvidenceToolHandler } from "./contractToolHandler.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
|
||||
/** Bind the exact IL2CPP contract to its shared workflow and owning session Evidence ledger. */
|
||||
export const registerIl2cppTools = (
|
||||
server: EvidenceMcpServer,
|
||||
service: Il2cppMetadataService,
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
evidenceById?: EvidenceLookup,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_il2cpp_metadata");
|
||||
const handler = createEvidenceToolHandler(
|
||||
server,
|
||||
(input, options) =>
|
||||
service.inspect(input, options, { recordEvidence, evidenceById }),
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,429 @@
|
||||
import { desktopSummarySchema } from "../../src/domain/analysisView/desktopApplicationView.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { readEvidenceBundle } from "../../src/application/EvidenceBundleFiles.js";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
il2cppMetadataSummaryViewResultSchema,
|
||||
} from "../../src/domain/analysisView/analysisView.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { il2cppMetadataSchema } from "../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
import {
|
||||
createIl2cppMetadataFixture,
|
||||
FIXTURE_IMAGES,
|
||||
FIXTURE_METHODS,
|
||||
FIXTURE_TYPES,
|
||||
} from "../fixtures/il2cpp/metadata.js";
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"returns identical complete inventories through CLI and MCP and pages a retained summary after removal",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-metadata-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const { bytes } = createIl2cppMetadataFixture({ typeIndexWidth: 2 });
|
||||
await writeFile(path, bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-il2cpp-metadata", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_il2cpp_metadata").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const report = il2cppMetadataSchema.parse(evidence.normalized_result);
|
||||
expect(report.magic).toBe(bytes.readUInt32LE(0));
|
||||
expect(report.artifact).toEqual({
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
});
|
||||
expect(report.images).toEqual(FIXTURE_IMAGES);
|
||||
expect(report.types).toEqual(FIXTURE_TYPES);
|
||||
expect(report.methods).toEqual(FIXTURE_METHODS);
|
||||
expect(evidence.parameters).toEqual({ path });
|
||||
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const complete = await call("inspect_il2cpp_metadata", {
|
||||
path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(complete.isError, JSON.stringify(complete)).not.toBe(true);
|
||||
expect(parseEvidence(complete.structuredContent).normalized_result).toEqual(
|
||||
report,
|
||||
);
|
||||
|
||||
// The default keeps complete Evidence while returning a compact receipt.
|
||||
const omitted = await call("inspect_il2cpp_metadata", { path });
|
||||
expect(omitted.isError, JSON.stringify(omitted)).not.toBe(true);
|
||||
expect(
|
||||
il2cppMetadataSummaryViewResultSchema.parse(
|
||||
parseEvidence(omitted.structuredContent).normalized_result,
|
||||
).summary.counts.methods,
|
||||
).toBe(FIXTURE_METHODS.length);
|
||||
const summarized = await call("inspect_il2cpp_metadata", {
|
||||
path,
|
||||
detail: "summary",
|
||||
});
|
||||
expect(summarized.isError, JSON.stringify(summarized)).not.toBe(true);
|
||||
const view = il2cppMetadataSummaryViewResultSchema.parse(
|
||||
parseEvidence(summarized.structuredContent).normalized_result,
|
||||
);
|
||||
expect(view.summary.magic).toBe(report.magic);
|
||||
expect(view.summary.counts).toEqual({
|
||||
sections: 31,
|
||||
images: 2,
|
||||
types: 4,
|
||||
methods: 5,
|
||||
nested_types: 1,
|
||||
});
|
||||
expect(view.summary).not.toHaveProperty("sections");
|
||||
expect(view.summary.index_widths).toEqual(report.index_widths);
|
||||
|
||||
// Pages come from retained Evidence, not the file.
|
||||
await unlink(path);
|
||||
for (const collection of [
|
||||
"sections",
|
||||
"images",
|
||||
"types",
|
||||
"methods",
|
||||
] as const) {
|
||||
const rows: unknown[] = [];
|
||||
let offset = 0;
|
||||
do {
|
||||
const page = await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: view.parent_evidence_id,
|
||||
},
|
||||
view: { kind: "il2cpp-metadata", collection, offset, limit: 2 },
|
||||
});
|
||||
expect(page.isError, JSON.stringify(page)).not.toBe(true);
|
||||
const projected = analysisViewResultSchema.parse(
|
||||
parseEvidence(page.structuredContent).normalized_result,
|
||||
);
|
||||
if (projected.kind !== "il2cpp-metadata")
|
||||
throw new Error("Wrong IL2CPP page kind");
|
||||
expect(projected.counts).toEqual(view.summary.counts);
|
||||
rows.push(...projected.items);
|
||||
offset = projected.coverage.next_offset ?? report[collection].length;
|
||||
} while (offset < report[collection].length);
|
||||
expect(rows).toEqual(
|
||||
report[collection].map((row, index) => ({ index, ...row })),
|
||||
);
|
||||
}
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"distinguishes unsupported versions from corrupt metadata through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-metadata-fail-");
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const fixture = createIl2cppMetadataFixture();
|
||||
const unsupported = Buffer.from(fixture.bytes);
|
||||
unsupported.writeInt32LE(27, 4);
|
||||
const corrupt = Buffer.from(fixture.bytes);
|
||||
corrupt.writeInt32LE(
|
||||
fixture.section("typeDefinitions").offset + 4,
|
||||
fixture.header("images", "offset"),
|
||||
);
|
||||
for (const [name, bytes, expected] of [
|
||||
[
|
||||
"v27.dat",
|
||||
unsupported,
|
||||
{
|
||||
code: "unsupported_target",
|
||||
details: { operation: "inspect_il2cpp_metadata" },
|
||||
message: expect.stringContaining("unsupported-version"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"overlap.dat",
|
||||
corrupt,
|
||||
{
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
issues: [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: expect.stringContaining("section-overlap"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const) {
|
||||
const path = join(root, name);
|
||||
await writeFile(path, bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-il2cpp-metadata", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject(expected);
|
||||
const response = await call("inspect_il2cpp_metadata", { path });
|
||||
expect(parseMcpToolError(response).error).toMatchObject(expected);
|
||||
}
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
it("queries compact retained metadata through the real SDK with exact row identities and fields", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-sdk-selection-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const fixture = createIl2cppMetadataFixture({ version: 31 });
|
||||
await writeFile(path, fixture.bytes);
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const directory = await call("inspect_desktop_application", { path: root });
|
||||
const directoryView = analysisViewResultSchema.parse(
|
||||
parseEvidence(directory.structuredContent).normalized_result,
|
||||
);
|
||||
if (directoryView.kind !== "summary")
|
||||
throw new Error("Expected a directory summary");
|
||||
const probe = desktopSummarySchema
|
||||
.parse(directoryView.summary)
|
||||
.next_probe_groups.find(
|
||||
(group) => group.operation === "inspect_il2cpp_metadata",
|
||||
)?.example;
|
||||
if (probe === undefined)
|
||||
throw new Error("Missing IL2CPP metadata next operation");
|
||||
expect(probe.path).toBe(await realpath(path));
|
||||
const response = await call(probe.operation, { path: probe.path });
|
||||
expect(response.isError).not.toBe(true);
|
||||
const overview = il2cppMetadataSummaryViewResultSchema.parse(
|
||||
parseEvidence(response.structuredContent).normalized_result,
|
||||
);
|
||||
expect(overview.summary.counts).toEqual({
|
||||
sections: 31,
|
||||
images: 2,
|
||||
types: 4,
|
||||
methods: 5,
|
||||
nested_types: 1,
|
||||
});
|
||||
expect(overview.summary).not.toHaveProperty("sections");
|
||||
expect(overview.summary).not.toHaveProperty("methods");
|
||||
await unlink(path);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: overview.parent_evidence_id,
|
||||
};
|
||||
const selected = async (view: Record<string, unknown>) => {
|
||||
const result = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "il2cpp-metadata", ...view },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(result.structuredContent);
|
||||
expect(evidence.evidence_links).toContain(overview.parent_evidence_id);
|
||||
const payload = analysisViewResultSchema.parse(evidence.normalized_result);
|
||||
if (payload.kind !== "il2cpp-metadata")
|
||||
throw new Error("Wrong collection response");
|
||||
expect(payload.artifact.sha256).toBe(
|
||||
createHash("sha256").update(fixture.bytes).digest("hex"),
|
||||
);
|
||||
return payload;
|
||||
};
|
||||
expect(
|
||||
(
|
||||
await selected({
|
||||
collection: "sections",
|
||||
names: ["methods"],
|
||||
fields: ["count"],
|
||||
})
|
||||
).items,
|
||||
).toEqual([{ index: 5, count: null }]);
|
||||
expect(
|
||||
(
|
||||
await selected({
|
||||
collection: "types",
|
||||
indices: [3, 0, 3, 999],
|
||||
fields: ["namespace"],
|
||||
})
|
||||
).items,
|
||||
).toEqual([
|
||||
{ index: 0, namespace: "Game" },
|
||||
{ index: 3, namespace: "System" },
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await selected({
|
||||
collection: "types",
|
||||
image_indices: [0],
|
||||
namespaces: ["Game"],
|
||||
names: ["Player"],
|
||||
fields: ["name"],
|
||||
})
|
||||
).items,
|
||||
).toEqual([{ index: 0, name: "Player" }]);
|
||||
const methods = {
|
||||
collection: "methods",
|
||||
declaring_types: [0, 1],
|
||||
names: [".ctor", "Add"],
|
||||
fields: ["name", "declaring_type"],
|
||||
};
|
||||
expect((await selected(methods)).items).toEqual([
|
||||
{ index: 0, name: ".ctor", declaring_type: 0 },
|
||||
{ index: 2, name: "Add", declaring_type: 1 },
|
||||
]);
|
||||
expect(await selected({ ...methods, offset: 1, limit: 1 })).toMatchObject({
|
||||
items: [{ index: 2, name: "Add", declaring_type: 1 }],
|
||||
coverage: { total: 2, exhausted: true, next_offset: null },
|
||||
});
|
||||
expect(
|
||||
(await selected({ collection: "methods", indices: [2, 0], fields: [] }))
|
||||
.items,
|
||||
).toEqual([{ index: 0 }, { index: 2 }]);
|
||||
expect(await selected({ collection: "types", indices: [] })).toMatchObject({
|
||||
items: [],
|
||||
coverage: { total: 0, exhausted: true },
|
||||
});
|
||||
const invalid = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "methods",
|
||||
fields: ["namespace"],
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(invalid).error.code).toBe("invalid_request");
|
||||
await client.ping();
|
||||
}, 30000);
|
||||
|
||||
cliTest(
|
||||
"exports complete metadata Evidence for compact CLI delivery and portable selected reads",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-cli-export-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const bundle = join(root, "evidence.json");
|
||||
const refused = await cli.run({
|
||||
arguments: [
|
||||
"inspect-il2cpp-metadata",
|
||||
join(root, "must-not-be-read"),
|
||||
"--detail",
|
||||
"summary",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(refused.exitCode).toBe(1);
|
||||
expect(refused.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { issues: [{ path: ["evidence_output"] }] },
|
||||
});
|
||||
await writeFile(path, createIl2cppMetadataFixture().bytes);
|
||||
const args = [
|
||||
"inspect-il2cpp-metadata",
|
||||
path,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence_output",
|
||||
bundle,
|
||||
"--json",
|
||||
];
|
||||
const result = await cli.run({ arguments: args });
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
evidence_bundle: { path: bundle, bytes: expect.any(Number) },
|
||||
summary: { normalized_result: { kind: "summary" } },
|
||||
});
|
||||
const before = await readFile(bundle);
|
||||
const overwrite = await cli.run({ arguments: args });
|
||||
expect(overwrite.exitCode).toBe(1);
|
||||
expect(await readFile(bundle)).toEqual(before);
|
||||
const saved = await readEvidenceBundle(bundle);
|
||||
if (!saved.ok) throw saved.error;
|
||||
const evidence = saved.value.records[0];
|
||||
if (evidence === undefined)
|
||||
throw new Error("Missing exported source Evidence");
|
||||
expect(JSON.stringify(overwrite.json)).toContain(evidence.evidence_id);
|
||||
expect(
|
||||
il2cppMetadataSchema.parse(evidence.normalized_result).methods,
|
||||
).toEqual(FIXTURE_METHODS);
|
||||
await unlink(path);
|
||||
const query = join(root, "query.json");
|
||||
await writeFile(
|
||||
query,
|
||||
JSON.stringify({
|
||||
source: { kind: "inline", evidence },
|
||||
view: {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "methods",
|
||||
indices: [2, 0],
|
||||
fields: ["name"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const selected = await cli.run({
|
||||
arguments: ["inspect-analysis-view", query, "--json"],
|
||||
});
|
||||
expect(selected.exitCode, selected.stderr).toBe(0);
|
||||
expect(parseEvidence(selected.json).normalized_result).toMatchObject({
|
||||
items: [
|
||||
{ index: 0, name: ".ctor" },
|
||||
{ index: 2, name: "Add" },
|
||||
],
|
||||
parent_evidence_id: evidence.evidence_id,
|
||||
});
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"exports and reads Evidence at the filesystem-selected symlink-parent path",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-export-path-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, createIl2cppMetadataFixture().bytes);
|
||||
const actual = join(root, "actual");
|
||||
await mkdir(join(actual, "nested"), { recursive: true });
|
||||
await symlink(join(actual, "nested"), join(root, "link"));
|
||||
const decoy = join(root, "evidence.json");
|
||||
await writeFile(decoy, "unrelated caller data");
|
||||
const selected = `${root}/link/../evidence.json`;
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect-il2cpp-metadata",
|
||||
path,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence_output",
|
||||
selected,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({ evidence_bundle: { path: selected } });
|
||||
expect(await readFile(decoy, "utf8")).toBe("unrelated caller data");
|
||||
const saved = await readEvidenceBundle(selected);
|
||||
if (!saved.ok) throw saved.error;
|
||||
expect(
|
||||
JSON.parse(await readFile(join(actual, "evidence.json"), "utf8")),
|
||||
).toEqual(saved.value);
|
||||
const read = await cli.run({
|
||||
arguments: [
|
||||
"inspect-evidence-bundle",
|
||||
JSON.stringify({ path: selected, detail: "summary" }),
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(read.exitCode, read.stderr).toBe(0);
|
||||
expect(read.stdout).toContain(saved.value.records[0]?.evidence_id);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
@@ -959,6 +959,16 @@ it.each([
|
||||
"unity-il2cpp",
|
||||
]);
|
||||
expect(report.next_probes).toEqual([
|
||||
expect.objectContaining({
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
path: join(
|
||||
await realpath(root),
|
||||
"Game_Data/il2cpp_data/Metadata/global-metadata.dat",
|
||||
),
|
||||
file_id: report.files.find((file) =>
|
||||
file.path.endsWith("global-metadata.dat"),
|
||||
)?.file_id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
operation: "open_binary",
|
||||
path: join(await realpath(root), path),
|
||||
@@ -1170,3 +1180,32 @@ it("preserves admitted Unicode and escaped AssemblyRef names in complete Evidenc
|
||||
"unity-mono",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([27, 31, 106])(
|
||||
"suggests metadata-only inspection for supported raw version %s",
|
||||
async (version) => {
|
||||
const root = await createTestTempDirectory("rea-desktop-metadata-probe-");
|
||||
const bytes = Buffer.alloc(8);
|
||||
bytes.writeUInt32LE(0xfab11baf);
|
||||
bytes.writeUInt32LE(version, 4);
|
||||
await put(root, "global-metadata.dat", bytes);
|
||||
const { report } = await inspect(root);
|
||||
expect(report.candidates).toEqual([]);
|
||||
expect(report.files[0]?.facts).toContainEqual({
|
||||
kind: "il2cpp-metadata-header",
|
||||
offset: 0,
|
||||
value: { magic: "0xfab11baf", raw_version: version },
|
||||
});
|
||||
expect(report.next_probes).toEqual(
|
||||
version === 31
|
||||
? [
|
||||
expect.objectContaining({
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
path: join(await realpath(root), "global-metadata.dat"),
|
||||
file_id: report.files[0]?.file_id,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -324,6 +324,21 @@ it("reuses authenticated file snapshots while isolating mutable public reads", a
|
||||
});
|
||||
|
||||
describe("evidence bundle filesystem adapter", () => {
|
||||
it.skipIf(process.platform === "win32").each(["/", "/."])(
|
||||
"preserves the directory requirement of a missing output ending in %s",
|
||||
async (suffix) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-evidence-directory-",
|
||||
);
|
||||
const selected = `${join(directory, "report.json")}${suffix}`;
|
||||
expect(await writeBundle(bundle(), selected, false)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceFileError", reason: "not-file" },
|
||||
});
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("round trips canonical bytes and requires explicit overwrite", async () => {
|
||||
const directory = await createTestTempDirectory("rea-evidence-");
|
||||
const path = join(directory, "bundle.json");
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
il2cppIndexWidth,
|
||||
il2cppMetadataRecordBytes,
|
||||
Il2cppMetadataFormatFailure,
|
||||
readIl2cppMetadata,
|
||||
type Il2cppMetadataFailureReason,
|
||||
} from "../../../src/il2cpp/Il2cppMetadataFile.js";
|
||||
import {
|
||||
createIl2cppMetadataFixture,
|
||||
FIXTURE_IMAGES,
|
||||
fixtureImages,
|
||||
FIXTURE_METHODS,
|
||||
FIXTURE_TYPES,
|
||||
fixtureRows,
|
||||
type Il2cppMetadataFixture,
|
||||
} from "../../fixtures/il2cpp/metadata.js";
|
||||
|
||||
const failure = (bytes: Buffer) => {
|
||||
try {
|
||||
readIl2cppMetadata(bytes);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Il2cppMetadataFormatFailure) return cause;
|
||||
throw cause;
|
||||
}
|
||||
throw new Error("Expected the metadata to be rejected");
|
||||
};
|
||||
|
||||
const rejects = (
|
||||
bytes: Buffer,
|
||||
reason: Il2cppMetadataFailureReason,
|
||||
kind: "unsupported" | "corrupt" = "corrupt",
|
||||
) => {
|
||||
const rejected = failure(bytes);
|
||||
expect(rejected.reason).toBe(reason);
|
||||
expect(rejected.kind).toBe(kind);
|
||||
expect(rejected.message.startsWith(`${reason}: `)).toBe(true);
|
||||
};
|
||||
|
||||
const mutated = (
|
||||
edit: (fixture: Il2cppMetadataFixture, bytes: Buffer) => void,
|
||||
fixture = createIl2cppMetadataFixture(),
|
||||
) => {
|
||||
const bytes = Buffer.from(fixture.bytes);
|
||||
edit(fixture, bytes);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe("IL2CPP global-metadata version 39", () => {
|
||||
it.each([1, 2, 4] as const)(
|
||||
"decodes names, ranges and nesting with a %i-byte TypeIndex under 1 KiB",
|
||||
(typeIndexWidth) => {
|
||||
const fixture = createIl2cppMetadataFixture({ typeIndexWidth });
|
||||
expect(fixture.bytes.length).toBeLessThan(1024);
|
||||
const decoded = readIl2cppMetadata(fixture.bytes);
|
||||
expect(decoded.magic).toBe(fixture.bytes.readUInt32LE(0));
|
||||
expect(decoded.metadata_version).toBe(39);
|
||||
expect(decoded.index_widths).toEqual({
|
||||
type_definition: 1,
|
||||
generic_container: 1,
|
||||
parameter: 1,
|
||||
type: typeIndexWidth,
|
||||
type_width_source: "interface-offsets-record",
|
||||
});
|
||||
expect(decoded.images).toEqual(FIXTURE_IMAGES);
|
||||
expect(decoded.types).toEqual(FIXTURE_TYPES);
|
||||
expect(decoded.methods).toEqual(FIXTURE_METHODS);
|
||||
expect(decoded.sections).toHaveLength(31);
|
||||
expect(decoded.sections[0]).toEqual({
|
||||
name: "stringLiterals",
|
||||
offset: 380,
|
||||
size: 0,
|
||||
count: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("recovers the TypeIndex width from type records without interfaceOffsets", () => {
|
||||
const fixture = createIl2cppMetadataFixture({
|
||||
typeIndexWidth: 2,
|
||||
interfaceOffsets: false,
|
||||
});
|
||||
const decoded = readIl2cppMetadata(fixture.bytes);
|
||||
expect(decoded.index_widths.type).toBe(2);
|
||||
expect(decoded.index_widths.type_width_source).toBe(
|
||||
"type-definition-record",
|
||||
);
|
||||
expect(decoded.methods).toEqual(FIXTURE_METHODS);
|
||||
});
|
||||
|
||||
it("accepts empty sections that share an offset with occupied sections and padding gaps", () => {
|
||||
const bytes = mutated((fixture, target) => {
|
||||
const methods = fixture.section("methods");
|
||||
target.writeInt32LE(
|
||||
methods.offset + 3,
|
||||
fixture.header("windowsRuntimeStrings", "offset"),
|
||||
);
|
||||
// Grow strings into trailing padding that no other section occupies.
|
||||
target.writeInt32LE(
|
||||
fixture.section("strings").size,
|
||||
fixture.header("strings", "size"),
|
||||
);
|
||||
});
|
||||
expect(readIl2cppMetadata(bytes).types).toEqual(FIXTURE_TYPES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejected IL2CPP global-metadata", () => {
|
||||
it("rejects an unrecognized magic and other versions as unsupported", () => {
|
||||
rejects(
|
||||
mutated((_, bytes) => bytes.writeUInt32LE(0x12345678, 0)),
|
||||
"unrecognized-magic",
|
||||
"unsupported",
|
||||
);
|
||||
rejects(Buffer.alloc(4), "unrecognized-magic", "unsupported");
|
||||
rejects(
|
||||
mutated((_, bytes) => bytes.writeInt32LE(30, 4)),
|
||||
"unsupported-version",
|
||||
"unsupported",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a truncated header and an unexpected header size", () => {
|
||||
rejects(
|
||||
createIl2cppMetadataFixture().bytes.subarray(0, 200),
|
||||
"truncated-header",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(384, fixture.header("stringLiterals", "offset")),
|
||||
),
|
||||
"unexpected-header-size",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects sections outside the file or with negative fields", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(bytes.length, fixture.header("methods", "size")),
|
||||
),
|
||||
"section-out-of-bounds",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(-1, fixture.header("events", "count")),
|
||||
),
|
||||
"section-out-of-bounds",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects overlapping occupied sections", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(
|
||||
fixture.section("typeDefinitions").offset + 4,
|
||||
fixture.header("images", "offset"),
|
||||
),
|
||||
),
|
||||
"section-overlap",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects record sizes that disagree with counts or index widths", () => {
|
||||
// Not a whole multiple of the record count.
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(
|
||||
fixture.section("methods").count! + 1,
|
||||
fixture.header("methods", "count"),
|
||||
),
|
||||
),
|
||||
"record-size-mismatch",
|
||||
);
|
||||
// 300 generic containers widen GenericContainerIndex to two bytes.
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(300, fixture.header("genericContainers", "count")),
|
||||
),
|
||||
"record-size-mismatch",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(2, fixture.header("nestedTypes", "count")),
|
||||
),
|
||||
"record-size-mismatch",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(0, fixture.header("methods", "count")),
|
||||
),
|
||||
"record-size-mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an interfaceOffsets record that implies no valid TypeIndex width", () => {
|
||||
rejects(
|
||||
createIl2cppMetadataFixture({ interfaceOffsetRecordBytes: 7 }).bytes,
|
||||
"type-index-width",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejected IL2CPP metadata references", () => {
|
||||
it("rejects image, type, method and nested-type indices outside their tables", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeUInt32LE(9, fixture.record("images", 1) + 9),
|
||||
),
|
||||
"index-out-of-range",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) => {
|
||||
// methodStart follows fieldStart in the int32 start block.
|
||||
const at = fixture.record("typeDefinitions", 0) + 8 + 3 + 1 + 4 + 4;
|
||||
bytes.writeInt32LE(4, at);
|
||||
}),
|
||||
"index-out-of-range",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeUInt8(4, fixture.record("methods", 2) + 4),
|
||||
),
|
||||
"index-out-of-range",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(4, fixture.section("nestedTypes").offset),
|
||||
),
|
||||
"index-out-of-range",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a type nested in two declaring types", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) => {
|
||||
// Two string indices, three 1-byte type indices, generic container, flags.
|
||||
const starts = fixture.record("typeDefinitions", 3) + 8 + 3 + 1 + 4;
|
||||
bytes.writeInt32LE(0, starts + 16);
|
||||
bytes.writeUInt16LE(1, starts + 32 + 8);
|
||||
}),
|
||||
"nested-type-conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unterminated, out-of-range and non-UTF-8 strings", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(
|
||||
fixture.section("strings").size,
|
||||
fixture.record("methods", 0),
|
||||
),
|
||||
),
|
||||
"invalid-string",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(
|
||||
fixture.section("strings").size - 1,
|
||||
fixture.header("strings", "size"),
|
||||
),
|
||||
),
|
||||
"invalid-string",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeUInt8(0xff, fixture.stringOffset("Update")),
|
||||
),
|
||||
"invalid-string",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a shifted string section and names that start inside another string", () => {
|
||||
// Pulled back, the fixture's strings would start inside the header.
|
||||
for (const [delta, reason] of [
|
||||
[-1, "section-out-of-bounds"],
|
||||
[1, "invalid-string"],
|
||||
] as const)
|
||||
rejects(
|
||||
mutated((fixture, bytes) => {
|
||||
const strings = fixture.section("strings");
|
||||
bytes.writeInt32LE(
|
||||
strings.offset + delta,
|
||||
fixture.header("strings", "offset"),
|
||||
);
|
||||
// Shrink a forward shift so it stays clear of the next section.
|
||||
bytes.writeInt32LE(
|
||||
strings.size - Math.max(delta, 0),
|
||||
fixture.header("strings", "size"),
|
||||
);
|
||||
}),
|
||||
reason,
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(
|
||||
fixture.stringOffset("Update") +
|
||||
1 -
|
||||
fixture.section("strings").offset,
|
||||
fixture.record("methods", 0),
|
||||
),
|
||||
),
|
||||
"invalid-string",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects nesting cycles and self-nesting", () => {
|
||||
// Type 0 owns nestedTypes entry 0; listing itself there is a self-cycle.
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(0, fixture.section("nestedTypes").offset),
|
||||
),
|
||||
"nested-type-conflict",
|
||||
);
|
||||
rejects(
|
||||
createIl2cppMetadataFixture({ nestingCycle: true }).bytes,
|
||||
"nested-type-conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects methods whose declaring type disagrees with the owning type range", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeUInt8(3, fixture.record("methods", 1) + 4),
|
||||
),
|
||||
"declaring-type-mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IL2CPP index widths", () => {
|
||||
it("switches widths at the runtime's 255 and 65535 entry boundaries", () => {
|
||||
expect(
|
||||
[0, 255, 256, 65535, 65536].map((entries) => il2cppIndexWidth(entries)),
|
||||
).toEqual([1, 1, 2, 2, 4]);
|
||||
expect(
|
||||
il2cppMetadataRecordBytes(
|
||||
{
|
||||
type_definition: 2,
|
||||
generic_container: 2,
|
||||
parameter: 4,
|
||||
type: 2,
|
||||
},
|
||||
39,
|
||||
),
|
||||
).toEqual({ typeDefinitions: 76, images: 36, methods: 30 });
|
||||
const fixed = {
|
||||
type_definition: 4,
|
||||
generic_container: 4,
|
||||
parameter: 4,
|
||||
type: 4,
|
||||
} as const;
|
||||
expect(il2cppMetadataRecordBytes(fixed, 29)).toEqual({
|
||||
typeDefinitions: 88,
|
||||
images: 40,
|
||||
methods: 32,
|
||||
});
|
||||
expect(il2cppMetadataRecordBytes(fixed, 31).methods).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IL2CPP global-metadata versions 29 and 31", () => {
|
||||
it.each([29, 31] as const)(
|
||||
"decodes version %i with fixed 4-byte indices and size-only headers",
|
||||
(version) => {
|
||||
const fixture = createIl2cppMetadataFixture({ version });
|
||||
const decoded = readIl2cppMetadata(fixture.bytes);
|
||||
expect(decoded.metadata_version).toBe(version);
|
||||
expect(decoded.magic).toBe(fixture.bytes.readUInt32LE(0));
|
||||
expect(decoded.index_widths).toEqual({
|
||||
type_definition: 4,
|
||||
generic_container: 4,
|
||||
parameter: 4,
|
||||
type: 4,
|
||||
type_width_source: "fixed-width-version",
|
||||
});
|
||||
expect(decoded.sections[0]).toEqual({
|
||||
name: "stringLiterals",
|
||||
offset: 256,
|
||||
size: 0,
|
||||
count: null,
|
||||
});
|
||||
expect(decoded.sections.every((section) => section.count === null)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(decoded.images).toEqual(FIXTURE_IMAGES);
|
||||
expect(decoded.types).toEqual(FIXTURE_TYPES);
|
||||
expect(decoded.methods).toEqual(FIXTURE_METHODS);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects method records sized for the other version", () => {
|
||||
for (const [built, labeled] of [
|
||||
[29, 31],
|
||||
[31, 29],
|
||||
] as const)
|
||||
rejects(
|
||||
mutated(
|
||||
(_, bytes) => bytes.writeInt32LE(labeled, 4),
|
||||
createIl2cppMetadataFixture({ version: built }),
|
||||
),
|
||||
"record-size-mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a version 29 header that does not end at the first section", () => {
|
||||
rejects(
|
||||
createIl2cppMetadataFixture({ version: 29 }).bytes.subarray(0, 200),
|
||||
"truncated-header",
|
||||
);
|
||||
rejects(
|
||||
mutated(
|
||||
(fixture, bytes) =>
|
||||
bytes.writeInt32LE(380, fixture.header("stringLiterals", "offset")),
|
||||
createIl2cppMetadataFixture({ version: 29 }),
|
||||
),
|
||||
"unexpected-header-size",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects size-only sections that do not hold whole records", () => {
|
||||
rejects(
|
||||
mutated(
|
||||
(fixture, bytes) =>
|
||||
bytes.writeInt32LE(
|
||||
fixture.section("typeDefinitions").size - 1,
|
||||
fixture.header("typeDefinitions", "size"),
|
||||
),
|
||||
createIl2cppMetadataFixture({ version: 31 }),
|
||||
),
|
||||
"record-size-mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the shared string and ownership checks", () => {
|
||||
const fixture = createIl2cppMetadataFixture({ version: 31 });
|
||||
rejects(
|
||||
mutated(
|
||||
(target, bytes) =>
|
||||
bytes.writeInt32LE(
|
||||
target.stringOffset("Update") +
|
||||
1 -
|
||||
target.section("strings").offset,
|
||||
target.record("methods", 0),
|
||||
),
|
||||
fixture,
|
||||
),
|
||||
"invalid-string",
|
||||
);
|
||||
rejects(
|
||||
mutated(
|
||||
(target, bytes) =>
|
||||
bytes.writeInt32LE(3, target.record("methods", 1) + 4),
|
||||
fixture,
|
||||
),
|
||||
"declaring-type-mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IL2CPP metadata coverage checks", () => {
|
||||
it("rejects occupied sections that overlap the header", () => {
|
||||
rejects(
|
||||
mutated(
|
||||
(fixture, bytes) =>
|
||||
bytes.writeInt32LE(27, fixture.header("strings", "offset")),
|
||||
createIl2cppMetadataFixture({ version: 29 }),
|
||||
),
|
||||
"section-out-of-bounds",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a method outside its declaring type's range", () => {
|
||||
// Type 3 owns methods [3, 5); shrinking it strands method 4.
|
||||
rejects(
|
||||
mutated((fixture, bytes) => {
|
||||
const starts = fixture.record("typeDefinitions", 3) + 8 + 3 + 1 + 4;
|
||||
bytes.writeUInt16LE(1, starts + 32);
|
||||
}),
|
||||
"declaring-type-mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects images whose type ranges overlap", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeUInt8(2, fixture.record("images", 1) + 8),
|
||||
),
|
||||
"image-range-overlap",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a leading byte order mark in names", () => {
|
||||
const fixture = createIl2cppMetadataFixture();
|
||||
const bytes = Buffer.from(fixture.bytes);
|
||||
// Replace the three bytes of "Add" with a lone UTF-8 byte order mark.
|
||||
const at = fixture.stringOffset("Add");
|
||||
bytes.set([0xef, 0xbb, 0xbf], at);
|
||||
expect(readIl2cppMetadata(bytes).methods[2]?.name).toBe("\ufeff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IL2CPP version 39 wide indices", () => {
|
||||
it.each([
|
||||
[2, { fillerTypes: 300, genericContainers: 300, parameters: 300 }],
|
||||
[4, { fillerTypes: 65_540, genericContainers: 65_540, parameters: 65_540 }],
|
||||
] as const)(
|
||||
"decodes %i-byte type-definition, generic-container and parameter indices",
|
||||
(width, wide) => {
|
||||
const decoded = readIl2cppMetadata(
|
||||
createIl2cppMetadataFixture({ wide }).bytes,
|
||||
);
|
||||
expect(decoded.index_widths).toMatchObject({
|
||||
type_definition: width,
|
||||
generic_container: width,
|
||||
parameter: width,
|
||||
});
|
||||
const rows = fixtureRows({ wide });
|
||||
expect(decoded.types).toEqual(rows.types);
|
||||
expect(decoded.methods).toEqual(rows.methods);
|
||||
// The last method declares a type index beyond the narrower widths.
|
||||
expect(decoded.methods.at(-1)?.declaring_type).toBe(
|
||||
rows.types.length - 1,
|
||||
);
|
||||
expect(decoded.images).toEqual(FIXTURE_IMAGES);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("IL2CPP narrow index sentinels", () => {
|
||||
it.each([
|
||||
[1, undefined],
|
||||
[2, { fillerTypes: 300, genericContainers: 0, parameters: 0 }],
|
||||
] as const)(
|
||||
"reads an empty image's all-ones %i-byte type start as -1",
|
||||
(width, wide) => {
|
||||
const options = {
|
||||
emptyImage: true,
|
||||
...(wide === undefined ? {} : { wide }),
|
||||
};
|
||||
const decoded = readIl2cppMetadata(
|
||||
createIl2cppMetadataFixture(options).bytes,
|
||||
);
|
||||
expect(decoded.index_widths.type_definition).toBe(width);
|
||||
expect(decoded.images).toEqual(fixtureImages(options));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { Il2cppMetadataProvider } from "../../../src/il2cpp/Il2cppMetadataProvider.js";
|
||||
import { createIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Version 29 file whose methods all share one long name. */
|
||||
const sharedNameMetadata = (
|
||||
nameBytes: number | Buffer,
|
||||
methods: number,
|
||||
fill = 0x41,
|
||||
) => {
|
||||
const encoded = Buffer.isBuffer(nameBytes)
|
||||
? nameBytes
|
||||
: Buffer.alloc(nameBytes, fill);
|
||||
const name = Buffer.concat([encoded, Buffer.from([0])]);
|
||||
const records = Buffer.alloc(methods * 32);
|
||||
for (let index = 0; index < methods; index += 1)
|
||||
records.writeInt32LE(-1, index * 32 + 4);
|
||||
const header = Buffer.alloc(256);
|
||||
header.writeUInt32LE(0xfab11baf, 0);
|
||||
header.writeInt32LE(29, 4);
|
||||
let cursor = 256;
|
||||
for (let section = 0; section < 31; section += 1) {
|
||||
// strings is section 2 and methods is section 5.
|
||||
const content = section === 2 ? name : section === 5 ? records : undefined;
|
||||
header.writeInt32LE(cursor, 8 + section * 8);
|
||||
header.writeInt32LE(content?.length ?? 0, 12 + section * 8);
|
||||
cursor += content?.length ?? 0;
|
||||
}
|
||||
return Buffer.concat([header, name, records]);
|
||||
};
|
||||
|
||||
it("refuses an oversized report from its row lower bound before serializing it", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-budget-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
// Shared names make about 100 KiB of input expand beyond 64 MiB of JSON.
|
||||
await writeFile(path, sharedNameMetadata(65_536, 1_100));
|
||||
const stringify = vi.spyOn(JSON, "stringify");
|
||||
const provider = new Il2cppMetadataProvider();
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(
|
||||
stringify.mock.calls.some(
|
||||
([value]) =>
|
||||
typeof value === "object" && value !== null && "methods" in value,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts escaped name characters toward the budget without escaping the name", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-escape-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
// 4 KiB of U+0001 is 4 KiB raw but 24 KiB once escaped as .
|
||||
await writeFile(path, sharedNameMetadata(4096, 1, 0x01));
|
||||
const stringify = vi.spyOn(JSON, "stringify");
|
||||
const provider = new Il2cppMetadataProvider(8192);
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(
|
||||
stringify.mock.calls.some(
|
||||
([value]) => typeof value === "string" && value.length >= 4096,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("admits a report whose escaped names fit the budget exactly", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-fit-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, sharedNameMetadata(4096, 1, 0x01));
|
||||
const unbounded = new Il2cppMetadataProvider();
|
||||
let bytes: number;
|
||||
try {
|
||||
const result = await unbounded.inspect({ path });
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.methods[0]?.name).toBe("".repeat(4096));
|
||||
bytes = Buffer.byteLength(JSON.stringify(result.value));
|
||||
} finally {
|
||||
await unbounded.close();
|
||||
}
|
||||
const exact = new Il2cppMetadataProvider(bytes);
|
||||
try {
|
||||
expect((await exact.inspect({ path })).ok).toBe(true);
|
||||
} finally {
|
||||
await exact.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses from row counts before decoding any row", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-counts-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
// A broken method name would fail as invalid-string if rows were decoded.
|
||||
const fixture = createIl2cppMetadataFixture();
|
||||
const bytes = Buffer.from(fixture.bytes);
|
||||
bytes.writeInt32LE(0x7fff, fixture.record("methods", 0));
|
||||
await writeFile(path, bytes);
|
||||
const provider = new Il2cppMetadataProvider(256);
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ requested: "cancel during metadata decoding" },
|
||||
new AnalysisResourceConstraintError(
|
||||
"caller-selected-reason",
|
||||
"memory",
|
||||
"Caller cancellation reason",
|
||||
{},
|
||||
),
|
||||
])(
|
||||
"accepts cancellation during decoded batches with exact reason %j",
|
||||
async (reason) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-cancel-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const bytes = sharedNameMetadata(1, 100_000);
|
||||
await writeFile(path, bytes);
|
||||
const controller = new AbortController();
|
||||
let batches = 0;
|
||||
const provider = new Il2cppMetadataProvider(undefined, async () => {
|
||||
await setImmediate();
|
||||
if (++batches === 3) controller.abort(reason);
|
||||
});
|
||||
try {
|
||||
const result = await provider.inspect(
|
||||
{ path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(batches).toBe(3);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "observe_il2cpp_metadata_source",
|
||||
normalized_result: {
|
||||
artifact: {
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
},
|
||||
inventory_status: "not-returned-as-complete",
|
||||
validated_section_row_counts: {
|
||||
images: 0,
|
||||
types: 0,
|
||||
methods: 100_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const replacement = createIl2cppMetadataFixture();
|
||||
await writeFile(path, replacement.bytes);
|
||||
const next = await provider.inspect({ path });
|
||||
if (!next.ok) throw next.error;
|
||||
expect(next.value.artifact.sha256).toBe(
|
||||
createHash("sha256").update(replacement.bytes).digest("hex"),
|
||||
);
|
||||
expect(next.value.methods).toHaveLength(5);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("bounds concurrent snapshots and retains active decoding until close completes", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-close-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, createIl2cppMetadataFixture().bytes);
|
||||
let markEntered!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
markEntered = resolve;
|
||||
});
|
||||
let resume!: () => void;
|
||||
const release = new Promise<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const provider = new Il2cppMetadataProvider(undefined, () => {
|
||||
markEntered();
|
||||
return release;
|
||||
});
|
||||
const active = provider.inspect({ path });
|
||||
await entered;
|
||||
let closed = false;
|
||||
const closing = provider.close().then(() => {
|
||||
closed = true;
|
||||
});
|
||||
try {
|
||||
const refused = await provider.inspect({ path });
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok)
|
||||
expect(refused.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
await setImmediate();
|
||||
expect(closed).toBe(false);
|
||||
} finally {
|
||||
resume();
|
||||
await closing;
|
||||
}
|
||||
expect((await active).ok).toBe(true);
|
||||
expect(closed).toBe(true);
|
||||
expect((await provider.inspect({ path })).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses UTF-8 expansion before serializing the complete report", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-unicode-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, sharedNameMetadata(Buffer.from("雪".repeat(4096)), 1));
|
||||
const stringify = vi.spyOn(JSON, "stringify");
|
||||
const provider = new Il2cppMetadataProvider(8192);
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(
|
||||
stringify.mock.calls.some(
|
||||
([value]) =>
|
||||
typeof value === "object" && value !== null && "methods" in value,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps an independent failure with shared cancellation reason: %s",
|
||||
async (sharedReason) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-failure-cancel-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, createIl2cppMetadataFixture().bytes);
|
||||
const controller = new AbortController();
|
||||
const failure = Object.assign(
|
||||
new Error("independent scheduler I/O failure"),
|
||||
{ code: "EIO" },
|
||||
);
|
||||
const provider = new Il2cppMetadataProvider(undefined, () => {
|
||||
controller.abort(sharedReason ? failure : "separate caller cancellation");
|
||||
return Promise.reject(failure);
|
||||
});
|
||||
try {
|
||||
const result = await provider.inspect(
|
||||
{ path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected the independent failure");
|
||||
expect(result.error).not.toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(failure);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the exact empty source observation without claiming a decoded inventory", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-empty-");
|
||||
const path = join(root, "empty.dat");
|
||||
await writeFile(path, Buffer.alloc(0));
|
||||
const provider = new Il2cppMetadataProvider();
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected unsupported empty metadata");
|
||||
expect(result.error.message).toContain("unrecognized-magic");
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "observe_il2cpp_metadata_source",
|
||||
normalized_result: {
|
||||
artifact: {
|
||||
path,
|
||||
bytes: 0,
|
||||
sha256: createHash("sha256").digest("hex"),
|
||||
},
|
||||
inventory_status: "not-returned-as-complete",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { z } from "zod";
|
||||
import { createDeferred } from "../../fixtures/binarySession.js";
|
||||
import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js";
|
||||
import { Il2cppMetadataService } from "../../../src/application/il2cpp/Il2cppMetadataService.js";
|
||||
import { WebNetworkCaptureService } from "../../../src/application/WebNetworkCaptureService.js";
|
||||
import { AnalysisUnsupportedTargetError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
@@ -339,9 +340,10 @@ it("awaits SQLite cleanup and retries its retained owner after failed server shu
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("closes the bound Go and web-capture services and retries failed shutdown", async () => {
|
||||
it("closes the bound Go, IL2CPP and web-capture services and retries failed shutdown", async () => {
|
||||
let goCalls = 0;
|
||||
let goCloseAttempts = 0;
|
||||
let il2cppCloseAttempts = 0;
|
||||
let captureCloseAttempts = 0;
|
||||
let scenarioCloseAttempts = 0;
|
||||
let cleanupAllowed = false;
|
||||
@@ -362,6 +364,16 @@ it("closes the bound Go and web-capture services and retries failed shutdown", a
|
||||
if (!cleanupAllowed) throw new Error("Go cleanup refused");
|
||||
},
|
||||
});
|
||||
const il2cppMetadata = new Il2cppMetadataService({
|
||||
identity: { id: "il2cpp-fixture", name: "IL2CPP fixture", version: "1" },
|
||||
inspect: async () => {
|
||||
throw new Error("No metadata inspection requested");
|
||||
},
|
||||
close: async () => {
|
||||
il2cppCloseAttempts += 1;
|
||||
if (!cleanupAllowed) throw new Error("IL2CPP cleanup refused");
|
||||
},
|
||||
});
|
||||
const webNetworkCapture = new WebNetworkCaptureService({
|
||||
inspect: async () => {
|
||||
throw new Error("No capture inspection requested");
|
||||
@@ -375,6 +387,7 @@ it("closes the bound Go and web-capture services and retries failed shutdown", a
|
||||
{ kind: "fixed", analysis: { execute: async () => ok(null) } },
|
||||
{
|
||||
goBinary,
|
||||
il2cppMetadata,
|
||||
webNetworkCapture,
|
||||
browserScenarioCapture: {
|
||||
identity: () => ({
|
||||
@@ -412,12 +425,14 @@ it("closes the bound Go and web-capture services and retries failed shutdown", a
|
||||
]),
|
||||
});
|
||||
expect(goCloseAttempts).toBe(1);
|
||||
expect(il2cppCloseAttempts).toBe(1);
|
||||
expect(captureCloseAttempts).toBe(1);
|
||||
expect(scenarioCloseAttempts).toBe(1);
|
||||
|
||||
cleanupAllowed = true;
|
||||
await server.close();
|
||||
expect(goCloseAttempts).toBe(2);
|
||||
expect(il2cppCloseAttempts).toBe(2);
|
||||
expect(captureCloseAttempts).toBe(2);
|
||||
expect(scenarioCloseAttempts).toBe(2);
|
||||
} finally {
|
||||
|
||||
Vendored
+360
@@ -0,0 +1,360 @@
|
||||
import { IL2CPP_METADATA_SECTION_NAMES } from "../../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
|
||||
type SectionName = (typeof IL2CPP_METADATA_SECTION_NAMES)[number];
|
||||
type Width = 1 | 2 | 4;
|
||||
|
||||
/** Synthetic version 39 metadata small enough to read in a hex dump. */
|
||||
export interface Il2cppMetadataFixtureOptions {
|
||||
/** Serialized TypeIndex width. */
|
||||
readonly typeIndexWidth?: Width;
|
||||
/** Omit interfaceOffsets so the TypeIndex width comes from type records. */
|
||||
readonly interfaceOffsets?: boolean;
|
||||
/** Raw interfaceOffsets record size; defaults to 4 + typeIndexWidth. */
|
||||
readonly interfaceOffsetRecordBytes?: number;
|
||||
/** Make type 1 also list type 0, so 0 and 1 nest in each other. */
|
||||
readonly nestingCycle?: boolean;
|
||||
/** Append an image with no types, whose start is the -1 sentinel at the narrow width. */
|
||||
readonly emptyImage?: boolean;
|
||||
/** Metadata version; 29 and 31 use fixed 4-byte indices and no header counts. */
|
||||
readonly version?: 29 | 31 | 39;
|
||||
/**
|
||||
* Version 39 entry counts that widen the serialized indices: filler type
|
||||
* definitions (the last owns one extra method), and generic container and
|
||||
* parameter sections whose records are zero-filled and never decoded.
|
||||
*/
|
||||
readonly wide?: {
|
||||
readonly fillerTypes: number;
|
||||
readonly genericContainers: number;
|
||||
readonly parameters: number;
|
||||
};
|
||||
}
|
||||
|
||||
const widthFor = (entries: number): Width =>
|
||||
entries <= 0xff ? 1 : entries <= 0xffff ? 2 : 4;
|
||||
|
||||
/** Type and method rows a fixture holds, including any wide-index filler. */
|
||||
export const fixtureRows = (options: Il2cppMetadataFixtureOptions = {}) => {
|
||||
const fillers = options.wide?.fillerTypes ?? 0;
|
||||
const last = FIXTURE_TYPES.length + fillers - 1;
|
||||
const types = [
|
||||
...FIXTURE_TYPES,
|
||||
...Array.from({ length: fillers }, (_, index) => ({
|
||||
name: "Filler",
|
||||
namespace: "",
|
||||
method_start: index === fillers - 1 ? FIXTURE_METHODS.length : -1,
|
||||
method_count: index === fillers - 1 ? 1 : 0,
|
||||
nested_type_start: -1,
|
||||
nested_type_count: 0,
|
||||
declaring_type: -1,
|
||||
})),
|
||||
];
|
||||
const methods = [
|
||||
...FIXTURE_METHODS,
|
||||
...(fillers === 0 ? [] : [{ name: "Fill", declaring_type: last }]),
|
||||
];
|
||||
return { types, methods };
|
||||
};
|
||||
|
||||
export const FIXTURE_IMAGES = [
|
||||
{ name: "Assembly-CSharp.dll", type_start: 0, type_count: 3 },
|
||||
{ name: "mscorlib.dll", type_start: 3, type_count: 1 },
|
||||
] as const;
|
||||
|
||||
export const FIXTURE_TYPES = [
|
||||
{
|
||||
name: "Player",
|
||||
namespace: "Game",
|
||||
method_start: 0,
|
||||
method_count: 2,
|
||||
nested_type_start: 0,
|
||||
nested_type_count: 1,
|
||||
declaring_type: -1,
|
||||
},
|
||||
{
|
||||
name: "Slot",
|
||||
namespace: "",
|
||||
method_start: 2,
|
||||
method_count: 1,
|
||||
nested_type_start: -1,
|
||||
nested_type_count: 0,
|
||||
declaring_type: 0,
|
||||
},
|
||||
{
|
||||
name: "<Module>",
|
||||
namespace: "",
|
||||
method_start: -1,
|
||||
method_count: 0,
|
||||
nested_type_start: -1,
|
||||
nested_type_count: 0,
|
||||
declaring_type: -1,
|
||||
},
|
||||
{
|
||||
name: "Größe",
|
||||
namespace: "System",
|
||||
method_start: 3,
|
||||
method_count: 2,
|
||||
nested_type_start: -1,
|
||||
nested_type_count: 0,
|
||||
declaring_type: -1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const FIXTURE_METHODS = [
|
||||
{ name: ".ctor", declaring_type: 0 },
|
||||
{ name: "Update", declaring_type: 0 },
|
||||
{ name: "Add", declaring_type: 1 },
|
||||
{ name: ".ctor", declaring_type: 3 },
|
||||
{ name: "ToString", declaring_type: 3 },
|
||||
] as const;
|
||||
|
||||
const writeIndex = (
|
||||
target: Buffer,
|
||||
offset: number,
|
||||
width: Width,
|
||||
value: number,
|
||||
) => {
|
||||
if (width === 1) target.writeUInt8(value === -1 ? 0xff : value, offset);
|
||||
else if (width === 2)
|
||||
target.writeUInt16LE(value === -1 ? 0xffff : value, offset);
|
||||
else target.writeInt32LE(value, offset);
|
||||
};
|
||||
|
||||
/** Built bytes with helpers that address header fields and records by name. */
|
||||
export interface Il2cppMetadataFixture {
|
||||
readonly bytes: Buffer;
|
||||
readonly typeIndexWidth: Width;
|
||||
readonly recordBytes: {
|
||||
readonly typeDefinitions: number;
|
||||
readonly images: number;
|
||||
readonly methods: number;
|
||||
};
|
||||
/** Byte offset of a header entry field; only version 39 records counts. */
|
||||
header(name: SectionName, field: "offset" | "size" | "count"): number;
|
||||
section(name: SectionName): {
|
||||
offset: number;
|
||||
size: number;
|
||||
count: number | null;
|
||||
};
|
||||
record(name: "typeDefinitions" | "images" | "methods", index: number): number;
|
||||
/** Absolute offset of a string's first byte in the strings section. */
|
||||
stringOffset(value: string): number;
|
||||
}
|
||||
|
||||
type SectionContent = { bytes: Buffer; count: number };
|
||||
|
||||
/** Write the header and lay out non-empty sections in header order. */
|
||||
const assembleFile = (
|
||||
version: 29 | 31 | 39,
|
||||
contents: ReadonlyMap<SectionName, SectionContent>,
|
||||
) => {
|
||||
const fixed = version !== 39;
|
||||
const entryBytes = fixed ? 8 : 12;
|
||||
const headerBytes = 8 + IL2CPP_METADATA_SECTION_NAMES.length * entryBytes;
|
||||
const header = Buffer.alloc(headerBytes);
|
||||
header.writeUInt32LE(0xfab11baf, 0);
|
||||
header.writeInt32LE(version, 4);
|
||||
const body: Buffer[] = [];
|
||||
let cursor = headerBytes;
|
||||
IL2CPP_METADATA_SECTION_NAMES.forEach((name, index) => {
|
||||
const content = contents.get(name);
|
||||
const at = 8 + index * entryBytes;
|
||||
header.writeInt32LE(cursor, at);
|
||||
header.writeInt32LE(content?.bytes.length ?? 0, at + 4);
|
||||
if (!fixed) header.writeInt32LE(content?.count ?? 0, at + 8);
|
||||
if (content !== undefined) {
|
||||
body.push(content.bytes);
|
||||
cursor += content.bytes.length;
|
||||
}
|
||||
});
|
||||
const bytes = Buffer.concat([header, ...body]);
|
||||
const headerField = (
|
||||
name: SectionName,
|
||||
field: "offset" | "size" | "count",
|
||||
) => {
|
||||
if (fixed && field === "count")
|
||||
throw new Error(`Version ${version} headers record no counts`);
|
||||
return (
|
||||
8 +
|
||||
IL2CPP_METADATA_SECTION_NAMES.indexOf(name) * entryBytes +
|
||||
{ offset: 0, size: 4, count: 8 }[field]
|
||||
);
|
||||
};
|
||||
const section = (name: SectionName) => ({
|
||||
offset: bytes.readInt32LE(headerField(name, "offset")),
|
||||
size: bytes.readInt32LE(headerField(name, "size")),
|
||||
count: fixed ? null : bytes.readInt32LE(headerField(name, "count")),
|
||||
});
|
||||
return { bytes, headerField, section };
|
||||
};
|
||||
|
||||
/** Image rows a fixture holds, including the optional empty image. */
|
||||
export const fixtureImages = (options: Il2cppMetadataFixtureOptions = {}) => [
|
||||
...FIXTURE_IMAGES,
|
||||
...(options.emptyImage === true
|
||||
? [{ name: "Empty.dll", type_start: -1, type_count: 0 }]
|
||||
: []),
|
||||
];
|
||||
|
||||
/** Lay sections out in header order; empty sections share the next offset. */
|
||||
export const createIl2cppMetadataFixture = (
|
||||
options: Il2cppMetadataFixtureOptions = {},
|
||||
): Il2cppMetadataFixture => {
|
||||
const version = options.version ?? 39;
|
||||
const fixed = version !== 39;
|
||||
const T: Width = fixed ? 4 : (options.typeIndexWidth ?? 1);
|
||||
const rows = fixtureRows(options);
|
||||
const genericContainers = options.wide?.genericContainers ?? 0;
|
||||
const parameterCount = options.wide?.parameters ?? 0;
|
||||
const D: Width = fixed ? 4 : widthFor(rows.types.length);
|
||||
const G: Width = fixed ? 4 : widthFor(genericContainers);
|
||||
const P: Width = fixed ? 4 : widthFor(parameterCount);
|
||||
// Versions 29 and 31 also store elementTypeIndex in type records.
|
||||
const typeIndexFields = fixed ? 4 : 3;
|
||||
// Version 31 added returnParameterToken to method records.
|
||||
const returnToken = version === 29 ? 0 : 4;
|
||||
const nestedTypeEntries = options.nestingCycle === true ? [1, 0] : [1];
|
||||
const typeRows = rows.types.map((type, index) =>
|
||||
options.nestingCycle === true && index === 1
|
||||
? { ...type, nested_type_start: 1, nested_type_count: 1 }
|
||||
: type,
|
||||
);
|
||||
const recordBytes = {
|
||||
typeDefinitions: 68 + typeIndexFields * T + G,
|
||||
images: 32 + 2 * D,
|
||||
methods: 16 + returnToken + D + T + P + G,
|
||||
};
|
||||
|
||||
const stringParts: Buffer[] = [];
|
||||
const stringIndex = new Map<string, number>();
|
||||
let stringBytes = 0;
|
||||
const intern = (value: string) => {
|
||||
const known = stringIndex.get(value);
|
||||
if (known !== undefined) return known;
|
||||
const encoded = Buffer.from(`${value}\0`, "utf8");
|
||||
stringIndex.set(value, stringBytes);
|
||||
stringParts.push(encoded);
|
||||
stringBytes += encoded.length;
|
||||
return stringIndex.get(value)!;
|
||||
};
|
||||
const imageRows = fixtureImages(options);
|
||||
for (const image of imageRows) intern(image.name);
|
||||
for (const type of FIXTURE_TYPES) {
|
||||
intern(type.name);
|
||||
intern(type.namespace);
|
||||
}
|
||||
for (const row of [...rows.types, ...rows.methods]) intern(row.name);
|
||||
const strings = Buffer.concat(stringParts);
|
||||
|
||||
const types = Buffer.alloc(typeRows.length * recordBytes.typeDefinitions);
|
||||
typeRows.forEach((type, index) => {
|
||||
let at = index * recordBytes.typeDefinitions;
|
||||
types.writeInt32LE(intern(type.name), at);
|
||||
types.writeInt32LE(intern(type.namespace), at + 4);
|
||||
at += 8;
|
||||
// byval, native declaring and parent TypeIndex values are deliberately
|
||||
// unrelated to nesting; the reader must use nestedTypes instead.
|
||||
for (let field = 0; field < typeIndexFields; field += 1, at += T)
|
||||
writeIndex(types, at, T, 0x2a);
|
||||
writeIndex(types, at, G, -1);
|
||||
at += G + 4;
|
||||
const starts = [-1, type.method_start, -1, -1, type.nested_type_start];
|
||||
starts.forEach((value, field) => types.writeInt32LE(value, at + field * 4));
|
||||
types.writeInt32LE(-1, at + 20);
|
||||
types.writeInt32LE(-1, at + 24);
|
||||
types.writeInt32LE(-1, at + 28);
|
||||
at += 32;
|
||||
types.writeUInt16LE(type.method_count, at);
|
||||
types.writeUInt16LE(type.nested_type_count, at + 8);
|
||||
at += 16;
|
||||
types.writeUInt32LE(0, at);
|
||||
types.writeUInt32LE(0x02000001 + index, at + 4);
|
||||
});
|
||||
|
||||
const images = Buffer.alloc(imageRows.length * recordBytes.images);
|
||||
imageRows.forEach((image, index) => {
|
||||
const at = index * recordBytes.images;
|
||||
images.writeInt32LE(intern(image.name), at);
|
||||
images.writeInt32LE(index, at + 4);
|
||||
writeIndex(images, at + 8, D, image.type_start);
|
||||
images.writeUInt32LE(image.type_count, at + 8 + D);
|
||||
writeIndex(images, at + 12 + D, D, -1);
|
||||
images.writeUInt32LE(0, at + 12 + 2 * D);
|
||||
images.writeInt32LE(-1, at + 16 + 2 * D);
|
||||
});
|
||||
|
||||
const methods = Buffer.alloc(rows.methods.length * recordBytes.methods);
|
||||
rows.methods.forEach((method, index) => {
|
||||
const at = index * recordBytes.methods;
|
||||
methods.writeInt32LE(intern(method.name), at);
|
||||
writeIndex(methods, at + 4, D, method.declaring_type);
|
||||
writeIndex(methods, at + 4 + D, T, 0);
|
||||
const parameterStart = at + 4 + D + T + returnToken;
|
||||
writeIndex(methods, parameterStart, P, -1);
|
||||
writeIndex(methods, parameterStart + P, G, -1);
|
||||
methods.writeUInt32LE(0x06000001 + index, parameterStart + P + G);
|
||||
});
|
||||
|
||||
const nested = Buffer.alloc(nestedTypeEntries.length * 4);
|
||||
nestedTypeEntries.forEach((value, index) =>
|
||||
nested.writeInt32LE(value, index * 4),
|
||||
);
|
||||
|
||||
const interfaceOffsetBytes = options.interfaceOffsetRecordBytes ?? 4 + T;
|
||||
const interfaceOffsets =
|
||||
options.interfaceOffsets === false
|
||||
? null
|
||||
: Buffer.alloc(interfaceOffsetBytes);
|
||||
|
||||
const contents = new Map<SectionName, SectionContent>([
|
||||
["strings", { bytes: strings, count: stringIndex.size }],
|
||||
["methods", { bytes: methods, count: rows.methods.length }],
|
||||
...(genericContainers === 0
|
||||
? []
|
||||
: [
|
||||
[
|
||||
"genericContainers",
|
||||
{
|
||||
bytes: Buffer.alloc(genericContainers * 16),
|
||||
count: genericContainers,
|
||||
},
|
||||
] as [SectionName, SectionContent],
|
||||
]),
|
||||
...(parameterCount === 0
|
||||
? []
|
||||
: [
|
||||
[
|
||||
"parameters",
|
||||
{
|
||||
bytes: Buffer.alloc(parameterCount * (8 + T)),
|
||||
count: parameterCount,
|
||||
},
|
||||
] as [SectionName, SectionContent],
|
||||
]),
|
||||
["nestedTypes", { bytes: nested, count: nestedTypeEntries.length }],
|
||||
["typeDefinitions", { bytes: types, count: typeRows.length }],
|
||||
["images", { bytes: images, count: imageRows.length }],
|
||||
...(interfaceOffsets === null
|
||||
? []
|
||||
: [
|
||||
["interfaceOffsets", { bytes: interfaceOffsets, count: 1 }] as [
|
||||
SectionName,
|
||||
SectionContent,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
const { bytes, headerField, section } = assembleFile(version, contents);
|
||||
return {
|
||||
bytes,
|
||||
typeIndexWidth: T,
|
||||
recordBytes,
|
||||
header: headerField,
|
||||
section,
|
||||
record: (name, index) => section(name).offset + index * recordBytes[name],
|
||||
stringOffset: (value) => {
|
||||
const index = stringIndex.get(value);
|
||||
if (index === undefined) throw new Error(`Unknown string ${value}`);
|
||||
return section("strings").offset + index;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -41,6 +42,8 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
import { RustBinaryProvider } from "../../src/rust/RustBinaryProvider.js";
|
||||
import { RustBinaryService } from "../../src/application/rust/RustBinaryService.js";
|
||||
import { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisArtifactChangedError,
|
||||
@@ -55,11 +58,13 @@ import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope
|
||||
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
|
||||
import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js";
|
||||
import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js";
|
||||
import { Il2cppMetadataProvider } from "../../src/il2cpp/Il2cppMetadataProvider.js";
|
||||
import { inspectPeResourcesInputSchema } from "../../src/domain/native/peResources.js";
|
||||
import { peResourceFixture } from "../../src/native/pe/PeResources.fixture.js";
|
||||
import { createServer } from "../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../fixtures/binarySession.js";
|
||||
import { createRustImage } from "../fixtures/rust/image.js";
|
||||
import { createIl2cppMetadataFixture } from "../fixtures/il2cpp/metadata.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { mcpTest } from "../support/mcp/mcpFixture.js";
|
||||
@@ -77,8 +82,27 @@ const staticProviders = [
|
||||
["Rust", () => new RustBinaryProvider()],
|
||||
["Go", () => new GoBinaryProvider()],
|
||||
["PE", () => new PeResourcesProvider()],
|
||||
["IL2CPP metadata", () => new Il2cppMetadataProvider()],
|
||||
] as const;
|
||||
|
||||
const expectIl2cppSource = (
|
||||
error: AnalysisError,
|
||||
path: string,
|
||||
bytes: Buffer,
|
||||
) => {
|
||||
const source = parseEvidence(error.partialObservation);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
expect(source.operation).toBe("observe_il2cpp_metadata_source");
|
||||
expect(source.subject).toMatchObject({
|
||||
local_path: path,
|
||||
digest: { sha256 },
|
||||
});
|
||||
expect(source.normalized_result).toEqual({
|
||||
artifact: { path, sha256, bytes: bytes.length },
|
||||
inventory_status: "not-returned-as-complete",
|
||||
});
|
||||
};
|
||||
|
||||
it.each(staticProviders)(
|
||||
"preserves the exact late-close cancellation cause through %s inspection",
|
||||
async (_name, makeProvider) => {
|
||||
@@ -86,6 +110,8 @@ it.each(staticProviders)(
|
||||
const provider = makeProvider();
|
||||
if (provider instanceof PeResourcesProvider)
|
||||
await writeFile(path, peResourceFixture().bytes);
|
||||
if (provider instanceof Il2cppMetadataProvider)
|
||||
await writeFile(path, createIl2cppMetadataFixture().bytes);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel during confirmed descriptor close");
|
||||
fault.blockNew = false;
|
||||
@@ -101,6 +127,12 @@ it.each(staticProviders)(
|
||||
if (result.ok) throw new Error("Expected late cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
if (provider instanceof Il2cppMetadataProvider)
|
||||
expectIl2cppSource(
|
||||
result.error,
|
||||
path,
|
||||
createIl2cppMetadataFixture().bytes,
|
||||
);
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
} finally {
|
||||
fault.blocked.clear();
|
||||
@@ -109,6 +141,46 @@ it.each(staticProviders)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"retains completed IL2CPP source facts through failed close (cancel: %s)",
|
||||
async (cancel) => {
|
||||
const path = await selectedImage();
|
||||
const { bytes } = createIl2cppMetadataFixture();
|
||||
await writeFile(path, bytes);
|
||||
const provider = new Il2cppMetadataProvider();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel during failed close");
|
||||
if (cancel) fault.onClose = () => controller.abort(reason);
|
||||
try {
|
||||
const result = await provider.inspect(
|
||||
{ path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected failed close");
|
||||
if (cancel) {
|
||||
expect(result.error.cleanupPrimary).toBeInstanceOf(
|
||||
AnalysisCancelledError,
|
||||
);
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||
}
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expectIl2cppSource(result.error, path, bytes);
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
expect(fault.handles[0]?.fd).toBeGreaterThanOrEqual(0);
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(fault.handles[0]?.fd).toBe(-1);
|
||||
expect(
|
||||
projectAnalysisError(result.error).details?.partial_observation,
|
||||
).toEqual(result.error.partialObservation);
|
||||
} finally {
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
staticProviders.flatMap(([name, makeProvider]) => [
|
||||
[
|
||||
@@ -150,6 +222,8 @@ it.each(
|
||||
if (result.ok) throw new Error("Expected primary read failure");
|
||||
expect(result.error).toBeInstanceOf(expected);
|
||||
expect(result.error.cause).toBe(primary);
|
||||
if (provider instanceof Il2cppMetadataProvider)
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
} finally {
|
||||
await provider.close();
|
||||
@@ -186,6 +260,8 @@ it.each(staticProviders)(
|
||||
const [original] = aggregate.errors;
|
||||
expect(original).toBeInstanceOf(AnalysisArtifactChangedError);
|
||||
expect((original as AnalysisArtifactChangedError).cause).toBe(primary);
|
||||
if (provider instanceof Il2cppMetadataProvider)
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
|
||||
Reference in new issue
Block a user