Files
rea/docs/managed-code-analysis.md
T
记得吃中饭andgensokyo-dev 22cab8215a feat(artifacts): inspect and extract .NET single-file bundles (#3787)
* feat(artifacts): inventory and extract .NET single-file bundles

* fix(artifacts): retain bundle source I/O failure reasons

* docs(artifacts): record Linux bundle regression verification

* fix(artifacts): preserve bundle source failures and cancellation

* docs(artifacts): record synchronized bundle verification

* fix(artifacts): bind bundle extraction to inventoried host

* docs(artifacts): record bundle verification outcome

* fix(artifacts): bound bundle paths and suggest extraction

* fix(artifacts): preserve nested executable container boundaries

* test(artifacts): verify projected .NET members and simplify extraction admission

* fix(artifacts): explain fixed bundle limits at their producing boundary

---------

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-29 20:06:58 +08:00

54 KiB

Managed-code analysis

REA inspects .NET PE/CLI artifacts without loading or executing their code. It can identify an assembly, inspect metadata and CIL, compare members across builds, and connect declared native calls to supplied native-analysis evidence. CIL is Common Intermediate Language, the instruction format used by managed assemblies.

The seven shipped tools are:

Task MCP tool
Identify an artifact and its managed deployment form inspect_managed_artifact
Inspect types, signatures, method bodies, calls, and field access inspect_managed_members
List declared native calls and native implementation indicators inspect_managed_native_boundaries
Compare members across builds and map build-local tokens compare_managed_members
Check native call declarations against supplied export or function evidence verify_managed_native_boundaries
Import decompiled code against verified static member identities import_managed_reconstruction
Add managed findings to an application graph project_managed_application_graph

Each has a matching CLI command: replace underscores with hyphens and prefix the name with rea, for example rea inspect-managed-artifact. Native-body bridge mapping and managed runtime execution are outside the current tool set.

The canonical tool inventory is the build-generated catalog.

Inspect an assembly

rea inspect-managed-artifact /absolute/path/Assembly.dll --json
rea inspect-managed-members /absolute/path/Assembly.dll --json

Use the artifact result to identify the assembly and its metadata coverage. Member results retain build-local tokens, signatures, CIL bodies, and decoded relationships. Use an exact reported full_name with --type when a focused inspection is needed; see selected member inspection. Compare separate builds with member comparison, preserving their paths, digests, and selection scope.

Metadata overview and retained rows

The existing CLI and omitted/complete MCP detail return complete metadata inventories. For identity and classification questions, select inspect_managed_artifact with {"path":"/absolute/path/Assembly.dll","detail":"summary"} on the first call. This keeps the complete source in the session and returns a distinct linked inspect_analysis_view overview with complete PE/CLI, classification and its evidence, metadata status, module/MVID, assembly, frameworks, returned-row counts, coverage/issues and limitations. No assembly is loaded or executed.

ECMA-335 permits empty metadata streams to be omitted. An absent #Blob stream therefore does not prevent module or assembly identity from being read when all blob indexes are zero; a nonzero reference to that missing heap is reported as a missing-stream issue.

Use normalized_result.parent_evidence_id to page original rows:

{
  "source": {
    "kind": "retained-evidence",
    "evidence_id": "<parent_evidence_id>"
  },
  "view": {
    "kind": "managed",
    "collection": "attributes",
    "offset": 0,
    "limit": 16
  }
}

Collections are references, resources and attributes; rows retain their original values, nulls, order, metadata tokens and offsets. Pages retain source metadata status and coverage. Exhaustion covers the returned parent collection, not all possible metadata, and empty rows under partial/unavailable metadata do not establish absence. Tokens are build-local and require the recorded artifact SHA-256 and known module MVID for cross-observation matching.

Retained views do not repeat the parser or require the source file to remain available. Portable views use the authenticated original Evidence inline, including the existing inspect-analysis-view CLI. Session record lifetime and export/import semantics remain unchanged. A server without Evidence retention rejects selected summary before analysis; complete delivery remains available.

Member summary and retained member rows

Complete inspect_managed_members delivery always includes the assembly-wide member_refs and exported_types, which type_names does not narrow, so even one selected or unmatched type can return megabytes. In MCP, select {"path":"/absolute/path/Assembly.dll","type_names":["Namespace.Type"],"detail":"summary"} to keep the complete source in the session and receive identity, metadata status, the selection with its unmatched names, coverage/issues, the separate user-string coverage, limitations and per-collection counts. Then page original rows with view kind managed-members:

{
  "source": {
    "kind": "retained-evidence",
    "evidence_id": "<parent_evidence_id>"
  },
  "view": {
    "kind": "managed-members",
    "collection": "call_edges",
    "offset": 0,
    "limit": 64
  }
}

Collections are types, exported_types, fields, methods, member_refs, call_edges, field_accesses and user_strings (the literals). Each page names its collection, retains original rows, order, tokens, signatures and body facts, and carries the source selection and coverage; user_strings pages carry the literal pool's own coverage. Older Evidence that never examined exported types or user strings reports a null count and refuses those pages rather than returning an empty one.

To find specific retained members without returning their IL anchors, select exact names and defining types, then project columns:

{
  "source": {
    "kind": "retained-evidence",
    "evidence_id": "<parent_evidence_id>"
  },
  "view": {
    "kind": "managed-members",
    "collection": "methods",
    "names": ["ParseNumber", "ParseReadNumber"],
    "declaring_types": ["Newtonsoft.Json.JsonTextReader"],
    "fields": ["name", "declaring_type", "signature"],
    "offset": 0,
    "limit": 64
  }
}

Filters are exact and case-sensitive; lists are alternatives and filters intersect. An empty list matches nothing. Names select name in types, exported types, fields, methods and member references; in call edges they select target_name, and in field accesses field_name. Declaring-type filters select declaring_type in fields/methods, parent_name in member references, target_declaring_type in call edges and field_declaring_type in field accesses. Types, exported types and user strings do not accept a declaring-type filter; user strings do not accept a name filter. No display strings are reparsed.

fields accepts only columns of that collection. Omitting it preserves full rows, including method bodies. Selecting body explicitly returns the full recorded body and anchors; selecting no columns ([]) returns identity fields. Projected or filtered rows add their original source_index; tokens and source byte offsets remain, with defining/parent tokens or IL-site endpoints where applicable. The page retains module/MVID, artifact digest, parent Evidence, producer selection and source coverage. Full facts remain in the parent.

Paging offsets and totals count known matches in original order. source_count counts the producer-returned collection; unresolved_filter_rows counts rows that could match but lack a requested name/type. Those candidates remain unknown and are excluded from the matched total. Exhausting the known matches does not prove absence among unresolved or unexamined source rows.

CLI summary and saved member rows

The CLI accepts the same complete and summary detail choices. Omitted or --detail complete returns complete Evidence. To receive a summary, choose a new Evidence bundle destination so its original complete parent remains available after the command exits:

rea inspect-managed-members /absolute/path/Assembly.dll --type Namespace.Type \
  --detail summary --evidence-bundle /absolute/path/evidence/members.json --json

--evidence-bundle writes the complete original Evidence in REA's canonical bundle format. Its parent directory must exist, and it never replaces an existing path. Relative paths resolve against the command's working directory. The option also works with complete delivery when a saved bundle is useful. Summary without a destination is rejected before analysis. A publication failure returns a typed error with the completed Evidence under details.partial_observation; it does not report successful summary delivery. Cancellation before publication prevents the new output. Once atomic publication has started, it can complete despite cancellation; a cancelled delivery then retains the committed path and byte count under details.diagnostics.evidence_bundle.

Copy normalized_result.parent_evidence_id from the summary into the source.evidence_id field of the managed-members request above, then save that small request as members-page.json and run:

rea inspect-analysis-view /absolute/path/evidence/members-page.json \
  --evidence-bundle /absolute/path/evidence/members.json --json

The view command authenticates the selected bundle and resolves that exact parent ID. It does not reopen the assembly, so the original target may have been moved or removed. Subsequent calls can select another collection or use the returned coverage.next_offset to continue the same collection. An inline source already includes its parent and cannot also select --evidence-bundle.

Summary changes delivery, while type selection still controls decoding and the same inspection resource limits apply. Each separate CLI page reads and authenticates the saved bundle; the full source is retained on disk.

Shipped scope

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 itself unpack .NET single-file hosts or infer a NativeAOT identity from an ordinary native PE. It does not decode IL2CPP; use IL2CPP metadata 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.

For ordinary .NET single-file hosts, use the existing artifact tools to inventory and extract bundle members, then inspect the extracted assembly explicitly with the managed tools.

A valid implementation marker establishes a candidate native boundary, not recovered native semantics or runtime behavior.

An assembly targeting .NET Standard declares API compatibility across multiple .NET implementations. REA preserves that target in target_frameworks and leaves runtime_family as unknown unless independent markers identify a runtime family, such as a ReadyToRun signature. When ReadyToRun is observed, runtime_family remains modern-dotnet even if Unity or target-framework hints are also present; the separate hint evidence is retained in the classification evidence list.

Resource admission

The static provider admits regular input files up to 128 MiB before retaining their bytes. Reads remain bounded if a file grows after admission. Metadata, CIL decoding, and projected inspection records have a separate conservative 64 MiB representation budget; shared heap strings are cached, but repeated output occurrences still consume that budget. These are inspection resource policies, not PE format limits or a hard bound on total V8 heap use.

Path-based member comparison uses the same per-input snapshot policy and inspects its two selected paths sequentially, retaining each inspection rather than both full byte snapshots. The limit therefore remains a per-file resource admission rule, not an aggregate comparison limit. Comparison Evidence keeps the caller-selected paths as its parameters and artifact locations.

Input overflow returns a resource constraint. Decode or projection overflow reports resource-limit with unavailable or partial coverage; omitted facts do not become empty observations. Ambiguous PE section mappings are rejected rather than interpreted according to section order.

Minimal Edit-and-Continue metadata marked by #JTD is not a standalone assembly inventory. The pinned .NET 9 reader uses different reference widths for this mode, and delta handles require the baseline and prior generations. The released metadata serializer emits this marker with #-; the PE writer can embed that metadata root. REA reports unsupported-metadata at the marker header before interpreting table rows, preserving the selected path, artifact digest, parsed metadata version and stream names, and admitted PE/CLI facts. Metadata status is partial; member and native declaration inventories are unavailable. Empty collections do not establish absence, and a requested type name is not declared unmatched. Ordinary standalone #~ and #- metadata keep their existing interpretation. This does not add raw .dmeta input, baseline attachment or delta reconstruction. Regression coverage uses authored metadata in a generated PE shell; it does not establish naturally emitted application PEs, CLR loadability or a real Edit-and-Continue execution.

Selected member inspection

Member inspection admits type names, tokens, list ranges/counts, nesting facts, module identity and their output representation before decoding members. If later decoding or projection reaches the 64 MiB representation budget, the result retains this type inventory with coverage.state: "partial" and the original resource-limit coordinate. It does not return partially admitted member collections: empty fields, methods, references and edges are unexamined, not proof of absence; exported types, user strings and signature-resolution attestation remain null. Summary and retained pages keep this partial coverage. The capacity limit is unchanged. If metadata or the type inventory itself cannot be admitted, the result remains unavailable.

Use the returned types[].full_name values to select declaring types before their signatures and CIL bodies are decoded:

rea inspect-managed-members /absolute/path/Assembly.dll --type Example.Parser

MCP uses inspect_managed_members with {"path":"/absolute/path/Assembly.dll","type_names":["Example.Parser"]}. Repeat --type or supply multiple unique type_names for several types. Names match the reported full_name exactly. Top-level names require that namespace-qualified spelling. Nested declarations also accept their bare name, including when their own namespace is nonempty; this can select several types with that name. Selection uses the parsed TypeDef facts and comparison uses the returned declaring types, rather than reconstructing scope from the caller's spelling. Each type's enclosing_type_token records its enclosing TypeDef. nesting_read distinguishes complete source rows (true), incomplete current rows (false), and historical Evidence (null). A null enclosing token alone does not establish that a type is top-level. Each selected type also retains name_sha256, a framed digest of its namespace, name and enclosing names. It uses the same bounded, cached owner as signature type operands and remains available when selection omits the enclosing types. It adds one fixed-size digest per returned type rather than repeated name paths.

The result preserves the requested names in selection.type_names and reports unmatched names separately. If metadata names are unreadable, limitations retain that uncertainty. With no selection, selection is null and whole-assembly inspection keeps its existing resource policy. Selection restricts types, fields, methods, call edges, field accesses and user strings; member references remain an assembly-wide inventory, so output size is not guaranteed to be small.

Coverage describes the selected members. Comparisons leave members outside the opposite observation's selection unknown, and application graphs report their selected scope as partial. Neither makes an absence claim about the whole assembly. Older member Evidence without selection remains readable. The comparison resolves a member's declaring TypeDef token in its own source, then checks that nominal name_sha256 against the opposite selected type set. Pairing, absence and aggregate selected-scope agreement share these retained facts, including enclosing names, rather than requested spellings or display labels. Equivalent requests can cover the same types, while the same display name can select different nested owners. Such disjoint selected owners cannot establish a member pair or added/removed status even when both inventories are complete. Missing nominal identities cannot establish selected membership, including historical selected Evidence that retains only display names; the explicitly limited historical whole-inventory comparison remains available. Incomplete selected inventories or missing identities leave aggregate agreement unknown and coverage partial; known observed member pairs remain available. Complete empty selected inventories agree even if their unmatched request names differ. Whole-assembly and selected inventories remain distinct scopes, and each source retains its exact request. The method_rows interval records the examined zero-based MethodDef offset, row count and full table total before type selection. A complete observation can cover only that interval. Comparison keeps omissions from a method page unknown: build-local row positions cannot prove member absence across builds. Historical Evidence without this interval also cannot establish method absence; observed pairs and field inventory comparisons remain available.

For MCP callers, request detail: "summary" and page the types collection using the returned parent Evidence ID. Select one or more returned full names in a subsequent inspect_managed_members request. Selection is a recovery path, not a guarantee: a single type or the assembly-wide member references can still exhaust the representation budget. A successful command exit means the Evidence was returned; inspect coverage.state and coverage.issues before treating any collection as complete.

User strings

ldstr anchors keep their build-local 0x70 tokens. user_strings.literals lists each distinct token loaded by an inspected method body once, in token order, with its #US file offset and decoded value. Values keep their stored UTF-16 code units, including unpaired surrogates. Token 0x70000000 names the heap's empty entry at offset 0 and reads as ""; any other item there is malformed. The terminal flag is checked against ECMA-335 II.24.2.4, and the length prefix against the width II.23.2 assigns. A difference is reported with its byte offset as a user-string issue while preserving the readable literal. Toolchain conventions can differ; this conformance observation does not establish runtime rejection. A missing #US stream is one stream-level issue; a malformed item is reported against its token. User strings carry their own coverage: any of these makes user_strings.state partial and lists the issue in user_strings.issues, while the member coverage and metadata.status are unaffected, and readable literals remain. The state is also partial when an inspected method's CIL was not fully decoded (a malformed exception section alone does not count) or a method that may belong to the inspected types was not inspected (its row could not be read, or its type's name could not be read under a type_names selection), because ldstr instructions there were not observed. Normalized CIL digests do not include decoded values. compare_managed_members does not decode or compare literals: an unchanged method does not establish unchanged strings, and the comparison says so in its limitations. Older member Evidence without user_strings reads as null: not examined.

Edge target declaring types

member_refs[].parent_name, call_edges[].target_declaring_type, and field_accesses[].field_declaring_type name the type that declares each target. Referenced spec signatures validate encoded type coordinates against the metadata tables, including nested type arguments and custom modifiers. TypeRef resolution scopes are checked transitively with cycle detection; readable names survive malformed scopes as partial evidence. TypeDef and TypeRef names use the namespace-qualified full_name form, and nested types keep bare names. A generic-instantiation TypeSpec parent names its generic type, such as System.Collections.Generic.List`1. Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay null. A method-spec call edge reports the name and declaring type of the generic method it instantiates. A field access is named only by a field or a field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the member-signature grammar, and a MethodSpec must instantiate a generic method with as many type arguments as it declares. Malformed or unsupported rows and blobs, and arity mismatches, make coverage partial. A readable MethodSpec method column that names a method, or a TypeRef whose resolution scope is invalid, keeps its name. Older member Evidence without these fields reads them as null: unknown.

Analysis objective

Start by identifying the artifact with inspect_managed_artifact, then inspect its types and CIL with inspect_managed_members. Use member comparison for another build, and managed/native boundary rules when matching declared native calls to supplied native Evidence. Static inspection does not load or execute the assembly. Reconstructed structure and unresolved native/runtime questions retain their own authority and coverage.

When the selected source changes during acquisition or no longer matches its admitted digest, inspection and comparison report retryable artifact_changed; any completed hash retains the observed digest and byte count. Retry against a stable source. This differs from corrupted retained Evidence, which remains an Evidence-integrity failure. Malformed selected PE input has the same input-format classification in inspection and comparison. Its issue identifies the selected filename and argument: path for inspection, or leftPath / rightPath for CLI comparison. A failed right-side comparison retains the completed left observation.

Member comparison

Call edges and field accesses retain il_offset as their instruction site within the caller's build-local method token and artifact identity. Their target_identity_sha256 combines the existing enclosing-type nominal digest, member name and resolved decoded signature. Comparison joins edges to their instruction sites rather than array positions and compares these identities; display labels alone cannot establish equal targets. Same-named nested types under different enclosing types and different overload signatures stay distinct. This is nominal metadata identity, not assembly resolution or runtime binding. Historical edges without a site or identity, unresolved/cyclic type scopes, TypeSpec owners and unresolved MethodSpec instantiations retain unknown target equivalence. Valid MethodSpecs combine the base member identity with the resolved instantiation signature, preserving generic argument distinctions.

Names and semantic labels do not establish durable member identity. Comparison preserves observed differences, structural inferences, and unknowns separately.

Cross-version matching first pairs members of the same declared type and name. A current declared type uses its observed name_sha256, so same-named nested types in different enclosing types stay distinct even in selected inventories. Malformed, missing, contradictory, cyclic or over-deep enclosing identity remains unknown; the comparison preserves display names without using them to repair that gap. Historical observations retain an explicitly limited bare-name mode when either side predates the identity facts. A known current identity gap still remains unknown beside a historical observation. Matching then pairs methods by exact CIL/signature identity, then by signature, then by structural body shape; fields by signature. Exact CIL/signature identity requires equal raw CIL digests and decoded fingerprints. Equal decoded fingerprints alone cannot establish an exact match: they omit switch targets and collapse distinct NaN encodings. Only then may a remap pair a renamed or moved member across declared types or names: a method by exact CIL/signature identity or structural body shape, a field by exact decoded signature. A remap never takes a member whose own declared type and name remain unpaired on the other side, so a member is never taken from its own changed counterpart. A member whose declared identity is unresolved could belong to any type. It never makes a declared-identity pair, and any remap group it joins stays ambiguous. Unresolved same-named counterparts prevent definitive added/removed claims, while observed names and tokens remain available in the result. The exact-signature key uses names only as part of that full tuple; names alone never select a pair. Duplicate tuples remain ambiguous rather than being paired by token order. Every round compares signatures by resolved_sha256: the raw signature with each TypeDef or TypeRef operand replaced by a digest of that type's namespace and name, hashed as separate values so A + B.C differs from A.B + C, after its enclosing types for a nested type (Outer/Inner). The defining assembly is not part of the name: a TypeDef and a TypeRef with the same namespace and name compare as the same nominal name, and a TypeRef's ResolutionScope assembly is ignored, so a type forwarded from one assembly to another still pairs. Two different types with the same full name in different assemblies therefore have equal name digests. Comparison reports this scope limitation inline; it does not establish CLR type identity or runtime binding equivalence. Nesting deeper than 64 levels is not named, and such a signature compares by its raw digest. Comparison keys keep resolved and raw digests apart, so a raw digest never pairs with an equal resolved one. Raw signature bytes name types by metadata row, and rows are build-local, so the same signature usually has different raw bytes in two builds. A signature that names no types resolves to its raw digest. When an operand cannot be resolved, such as a TypeSpec, or when REA could not parse the signature, the raw digest stands in. Missing or unreadable TypeDef and TypeRef operands are reported as coverage issues. Invalid NestedClass rows retain their failure reasons and withhold TypeDef signature identities; type names and nesting are parsed once by their shared owner. Each current inspection records signature_resolution: "type-names-v2", even when none of its operands can be resolved. Version 2 also appends readable names to signature operands, for example class:7772 (AnalysisJob). Coded indexes remain build-local references; names are display text and do not change raw or resolved signature digests. Nested names use Outer/Inner. Generic instantiations keep their decoded arguments, for example class:9 (System.Collec