merge: consolidate upstream ownership and coverage fixes

This commit is contained in:
morluto committed 2026-10-24 04:43:33 +08:00
commit 7356434202
99 files changed
+8308 -928

No files matched your search

+12 -4
View File
@@ -10,10 +10,18 @@ and maintenance cost. Keep cosmetic polish with useful fixes; confusing or
incorrect user instructions warrant standalone corrections. Collect other
cosmetic-only suggestions in an issue rather than separate PRs.
For capability organization and provider composition, use the
[architecture map](docs/architecture.mermaid). Production factories in
`src/composition/` construct fresh providers without starting engines or acquiring
targets; optional adapter failures must preserve successful peers. Run
For selected architectural owners and dependency paths, use the
[architecture map](docs/architecture.mermaid). It is not a complete directory or
import inventory. For current provider wiring, start with
[`src/composition/binary.ts`](src/composition/binary.ts) for deep candidates and
the other `src/composition/` factories for tool services, including
[`gpu.ts`](src/composition/gpu.ts) and [`model.ts`](src/composition/model.ts).
Live Frida and Cutter adapters are constructed directly in
[`src/cli/fridaCommands.ts`](src/cli/fridaCommands.ts),
[`src/cli/cutterCommands.ts`](src/cli/cutterCommands.ts), and
[`src/server/createServer.ts`](src/server/createServer.ts).
Production factories construct fresh providers without starting engines or
acquiring targets; optional adapter failures must preserve successful peers. Run
`npm run verify:test-discovery` after adding or moving tests. Resolved import
boundaries are checked by `npm run verify:module-boundaries`, included in lint.
+1 -1
View File
@@ -399,7 +399,7 @@ to improve the code or docs.
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and checks,
[testing](docs/testing.md) for verification lanes, and the
[architecture map](docs/architecture.mermaid) for the project structure.
[architecture map](docs/architecture.mermaid) for selected layers and dependency paths.
## License
+7 -1
View File
@@ -1,4 +1,6 @@
graph TD
%% Selected layer owners and dependency/wiring paths, not a directory or import inventory.
%% Current deep-provider candidates are wired in src/composition/binary.ts.
subgraph "Entry Points"
CLI["src/cli.ts + src/cli/<br/>One-shot CLI adapter<br/>setup, diagnostics, analysis, decompilation"]
MAIN["src/main.ts + src/main/<br/>MCP server adapter: stdio default, streamable HTTP opt-in<br/>config, session, process lifetime"]
@@ -25,7 +27,7 @@ graph TD
JS_RECON["src/application/javascript/ + src/artifacts/javascript/<br/>JavaScript artifact reconstruction<br/>directory/ASAR reads<br/>AST-only bundle projection"]
end
subgraph "Provider Layers"
subgraph "Provider Layers (selected adapters)"
PROVIDER_OWNERS["src/providers/<br/>shared deferred client and inspection owners<br/>exact cleanup retry and attributed failures"]
PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/<br/>BYO offline ELF/core adapters<br/>owned snapshots/Python and optional core-only GDB<br/>unchanged upstream parsers, ABI structures and mapping API"]
WASM["src/wasm/<br/>caller-supplied WABT; immutable artifact inspection<br/>owned process supervision and retryable workspace cleanup"]
@@ -34,6 +36,7 @@ graph TD
HOPPER["src/hopper/<br/>Hopper launch & Unix-socket protocol<br/>BridgeLauncher, HopperClient, protocol"]
GHIDRA["src/ghidra/<br/>Ghidra discovery, profile & function analysis<br/>snapshot, Unix/Windows transport, bounded lifecycle"]
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>live GUI binding or owned headless database"]
KUNA["src/kuna/<br/>BYO Kuna deep analysis candidate<br/>discovery reads metadata; version probe on target open"]
IDA_MCP["External ida-pro-mcp<br/>GUI proxy or database supervisor<br/>upstream installation and engine lifecycle"]
AUX["src/artifacts/ + src/native/<br/>artifact inventory, bounded directory triage and extraction<br/>format readers and native capabilities"]
BROWSER["src/browser/<br/>CDP / Playwright capture<br/>captured scripts and native module resolution<br/>execution/: coverage, listener and source attribution"]
@@ -112,6 +115,7 @@ graph TD
COMPOSITION --> HOPPER
COMPOSITION --> GHIDRA
COMPOSITION --> IDA
COMPOSITION --> KUNA
COMPOSITION --> AUX
IDA --> IDA_MCP
IDA --> CONTRACTS
@@ -142,6 +146,7 @@ graph TD
GHIDRA --> GHIDRA_BRIDGE
HOPPER --> PROCESS
GHIDRA --> PROCESS
KUNA --> PROCESS
GHIDRA --> PROVIDER_OWNERS
APP --> PROVIDER_OWNERS
PROVIDER_OWNERS --> PROCESS
@@ -159,6 +164,7 @@ graph TD
CONTRACTS --> DOMAIN
HOPPER --> DOMAIN
GHIDRA --> DOMAIN
KUNA --> DOMAIN
AUX --> DOMAIN
BROWSER --> DOMAIN
PROCESS --> DOMAIN
+8 -4
View File
@@ -381,10 +381,14 @@ unambiguous. Gaps, zero-fill and conflicting mappings remain unreadable names.
Unresolved symbol versions keep their rows: the version name is null when no
definition or requirement names the index, and `version` is
`"unknown"` when the version table cannot be read; coverage is likewise
`partial`. A dynamic symbol table without its null entry is malformed. An ELF without an available `SHT_DYNSYM` section is `unsupported`,
including files with no section headers or only an inactive null section.
Missing section declarations do not establish that the loader has no exports;
enumerating dynamic symbols from the dynamic segment is not implemented. Mach-O exports are reported by `inspect_macho`.
`partial`. A dynamic symbol table without its null entry is malformed. An ELF
without section headers is `unsupported`. With section headers, a missing
`SHT_DYNSYM` section is also `unsupported` when a `PT_DYNAMIC` segment is
observed, including a directory containing only the inactive null section.
Enumerating dynamic symbols from that segment is not implemented. A present
section directory with neither a dynamic symbol table nor a dynamic segment
reports a complete empty inventory. Mach-O exports are reported by
`inspect_macho`.
`max_file_bytes` defaults to 256 MiB (maximum 512 MiB; CLI `--max-file-bytes`).
Export directories in PE headers are read like those in sections. An export or
+12
View File
@@ -129,6 +129,18 @@ contract. Legacy pointer-backed fields are decoded at
the width declared by Go's build-info record, independently of the image's ELF
class. It does not scan arbitrary byte strings as compiler metadata.
Legacy string descriptors and contents may use the declared file-backed PE
headers. A descriptor or string may also cross adjacent loader mappings when
their virtual ranges cover every requested byte and identify one contiguous
file range. Gaps, conflicting file aliases and nonempty zero-fill overlap remain
invalid. Discovery policy and the aggregate 1 MiB string-content limit are
unchanged. These are static reads over REA's native mapping facts; they do not
establish Windows/Linux loading or parity with Go's standard reader, whose
[Go 1.24.0 ELF/PE readers](https://github.com/golang/go/blob/go1.24.0/src/debug/buildinfo/buildinfo.go#L445)
read within individual segments or sections. The historical two-word string
descriptor follows the
[Go 1.17.13 reader](https://github.com/golang/go/blob/go1.17.13/src/cmd/go/internal/version/version.go#L212).
A universal (FAT) Mach-O needs `architecture` (`x86`, `x86_64`, `arm`
or `arm64`; CLI `--architecture`). REA reads its own FAT table, without `lipo`,
and validates the selected slice's range, alignment and Mach-O header CPU
+2
View File
@@ -320,6 +320,8 @@ Scans yield cooperatively in bounded batches. Name-address retention and reverse
ELF RELATIVE writes into zero-fill are kept as a private virtual-pointer overlay. Unresolved supported-width symbol relocations, including RELA PLT entries, remain unknown only for the bytes their ELF ABI relocation type covers; an adjacent 32-bit relocation does not make a separate 64-bit method slot unknown. Stored zeros never establish no-body. COPY, TLS descriptor, REL, packed APS2 and other unsupported relocation encodings fail closed. Input bytes are unchanged.
ELF relocation tables and file-backed destinations may span adjacent load ranges when every range agrees on the file translation. Any overlapping zero-fill declaration makes those bytes ambiguous; REA refuses the table or relocation destination instead of choosing the stored file bytes. Missing file bytes remain distinct from conflicting owners: supported unresolved relocations outside file-backed memory retain their unknown overlay, while ambiguous destinations refuse linking. Registration coverage uses the same file/zero-fill ownership checks as pointer reads.
Native address queries batch exact 64-bit hexadecimal `addresses` and `states` (`code`, `no-body`, `unknown`), intersecting indices and pagination. Numerically equal address spellings match; original row indices and every shared-body alternative survive:
```json
+40
View File
@@ -191,6 +191,46 @@ state. Select small explicit batches when using this workflow. A general batch
admission/output policy, including completed per-item facts when capacity is
exhausted, remains unresolved.
`decompile_all` (`rea decompile-all <target> --output <directory>`) writes one
pseudocode file per analyzed function and returns only a manifest: counts, the
output directory, capped failure reasons, provider identity, and the
analysis-profile digest. The manifest and `decompile-index.json` do not contain
pseudocode. Each file records the provider name, canonical address, inclusive
body ranges when `procedure_info` supplies them, and the `procedure_info` and
pseudocode limitations for that function. Header values are abbreviated to
2,000 characters per value and the joined limitation line to 4,000 characters;
the index retains the complete selected names, addresses, provider facts and
caveats. Failure details retain at most 32 rows within 2,048 JSON-encoded
UTF-8 bytes; names may be omitted to keep complete addresses and reasons. The
index retains every failure and its complete observed name.
Each function's `decompile-index.json` record, whatever its status, keeps the
complete limitations of the observations it used. The returned Evidence keeps
at most eight first-observed distinct per-function provider limitations within an 8,000-byte JSON-encoded UTF-8 text budget and counts omitted observations. Repeated omitted text is counted per observation, not as a distinct caveat. This summary does not retain every omitted string in memory. A fresh output directory must not already exist. `--resume`
continues an export for the same target SHA-256, provider, analysis profile,
and layout; it requires the directory to exist, and an export without a known
target or profile digest cannot be resumed. Resume skips files whose bytes still match the last write the journal
recorded, even after a later read failure, tries
functions it has not attempted before retrying recorded failures, ignores a
journal line torn by an interrupted write, and never overwrites a conflicting
file. File reservations compare ASCII paths without case, so distinct address
spaces remain distinct on case-insensitive filesystems. A resumed journal with
conflicting reservations is refused. One function's decompiler error or deadline is counted and does not stop
the others. A function that `procedure_info` classifies as external, such as an
import, is counted as `external` and recorded with that status and the body
fact as observed. It gets no file, no pseudocode is requested, and a resume
skips it. A function whose classification is unknown is decompiled as usual;
a provider that returns null pseudocode for it is reported as `no_pseudocode`. Inventory rows without an address are counted in the limitations. `--layout dwarf`
reports that compilation-unit paths are unavailable and still names files by
address. Filters are a case-sensitive name substring, a case-sensitive name
prefix, an inclusive address range (including uppercase hexadecimal input), and a positive safe-integer attempt limit. The limit does not allocate space in proportion to its value. Cancellation leaves
files already written in place. One run at a time holds an output directory,
identified by its device and inode, through export finalization and any retained cleanup retry; another
run on the same directory is refused with the holder's process ID, and a
crashed run can be recovered by the next run. Failed descriptor or staging cleanup remains owned by the session; close the target to retry it. Initialization cleanup preserves unexpected entries rather than recursively removing the directory. Resume hashes existing files in bounded chunks, and index generation reuses committed journal facts already held by the export owner. After five consecutive functions whose files
could not be written or read, for example on a full disk, the export stops with
an error instead of decompiling the rest. Files and records already committed
stay, `decompile-index.json` is still written, and a later resume continues.
Type inspection selects one exact database pathname or typed data address.
Struct/union fields, enums, pointers, arrays, typedefs, size, alignment, packing
and bitfields retain database authority. Child types use exact IDs for further
+18 -13
View File
@@ -73,20 +73,25 @@ Stage `capability_issue` and `next_action` fields map unsupported or failing
work to the responsible capability and remediation without claiming that the
requested analysis ran.
Operation parity groups observations by operation, call kind and expected
success. Every retained CLI/MCP observation in that group must agree on its
structured error code, including repeats; findings cite the complete group and
its sequence numbers. Equivalent repeats are retained. A later recovery success
is assessed with its corresponding success observations instead of replacing a
recorded failure.
Closure history describes one fixed required-obligation universe from the
supplied ledger. Its first entry establishes the starting open count and cannot
claim a new transition. Each later count decrease must equal the number of
unique newly verified required obligations. An ID cannot be credited twice
within an entry or across entries, and no historical open count can exceed the
ledger's required-obligation count. Original history entries remain in the
report snapshot when these checks fail.
supplied final ledger. Its first entry establishes the starting open count and
cannot claim a new transition. Every observation marked `ready` must report
zero required obligations open. Observations marked `open` or `failed` must
report a positive required-open count. `unknown` may have zero open obligations
because limitations can prevent a ready verdict independently of that count.
Every decrease must name that many distinct, verified required obligations;
an obligation cannot count again within an entry or in a later observation.
No historical open count can exceed the ledger's required-obligation count.
The retained history is checked for consistency with that ledger, not
authenticated as a series of complete historical ledgers. Original entries
remain in the report snapshot when these checks fail.
For each operation and call kind, every retained success/error-code variant
must occur on both CLI and MCP. Repeated observations and their Evidence stay
in the report snapshot; drift findings cite the complete group and its sequence
numbers. Equal repeat counts and one-to-one run pairing are not claimed: the
input has no identity linking an individual CLI attempt to an MCP attempt.
Differing transport status is checked on each observation separately.
## Fixtures
@@ -0,0 +1,223 @@
# Native directory decompilation verification — 2026-10-23
The completed analyst task is to export the bound provider's function inventory
as one local pseudocode file per non-external function, with names, body ranges,
provider/profile identity and the limitations of each used observation. The
agent receives a manifest rather than the pseudocode. A function decompilation
failure remains a recorded partial outcome and does not end the export.
## Artifact, provider and correctness
The real target was the installed ELF x86-64 `/usr/bin/sha256sum` (60,368 bytes),
SHA-256 `6cd7c6bfc81d645ba13b927e31651a1466092a28ed0bd2632e82f8b27882b25e`.
It was read and analyzed statically, never executed. Both variants used the
existing Ghidra 12.1.4 installation and JDK 21.0.12.1+1, Node 24.22.1, the locked
npm dependencies, `GHIDRA_HEADLESS_MAXMEM=1G` and
`GHIDRA_HEADLESS_JAVA_OPTIONS=-XX:ActiveProcessorCount=2`.
The upstream analysis-profile digest was
`ae80ece8c83379eddabbdf379bbffca5da84c7df7fa7c159f45fa810468f85d9`.
Baseline main was `5d2c8c2b78d2552da0e13a11d3e2678efb69810d`.
The feature branch integrates original PR head
`d3a207038cfc00d58a97b86b06af1368839bec99` on that base, with the maintenance
changes described below. The frozen integration is `c566cace04d24f8ccb3d54353ababf95b96c8830`,
including refreshed main `bded9c628de23643aab81f98959b6a473318a240`. The review fixes were verified on `d3e0e96821a09a447785d100f3d85139b6e77e96`. Each variant ran through the public production
`scripts/rea.mjs mcp` entrypoint and the installed MCP SDK client.
The baseline called `list_procedures`, then `procedure_info` for each addressed
entry and `procedure_pseudo_code` for each non-external entry. A local verifier
used the returned Evidence to materialize the same function files and retain an
index. It reused the feature's pure file-header renderer to keep file encoding
equal; baseline analysis itself used only the existing public primitive tools.
The existing `batch_decompile` item format does not retain per-function caveat
associations; its workflow limitations aggregate across items. It would not
provide equivalent per-function caveat provenance here, so the baseline uses
primitive observations. The feature called `decompile_all` once per output directory. Both retained the
complete MCP responses locally. A second export used a new output directory in
the same session. The feature additionally resumed the verified warm export.
Both cold and warm exports examined the same 298 provider inventory entries:
236 written, 61 external, one failed. Addresses, statuses, body ranges, function
names, pseudocode SHA-256 and complete emitted-file SHA-256 matched for every
entry in both rounds. The target's bytes stayed unchanged. This proves export
preservation of the provider's observed results; it does not establish that the
provider found every actual function or that its pseudocode is original source.
Ghidra failed at `0x10a510` with `Overlapping input varnodes`; the failure code
was `execution_failure`. The export still wrote 68 functions at later numeric
addresses. Resume skipped every successful/external entry and retried the one
failed entry without rewriting the successful files. Complete per-function
caveats stayed in the index, and no pseudocode was returned in the manifest.
## Measurements
| Completed workflow | Before: public primitives + local files | After: directory workflow |
| ---------------------------------------------- | --------------------------------------: | ------------------------: |
| Cold export: MCP calls | 536 | 1 |
| Cold export: request + response token estimate | 1,375,776 | 3,800 |
| Cold export: elapsed | 50.940 s | 39.155 s |
| Cold export: sampled family RSS peak | 1754.5 MiB | 1709.3 MiB |
| Warm export: MCP calls | 536 | 1 |
| Warm export: request + response token estimate | 1,377,486 | 3,800 |
| Warm export: elapsed | 8.566 s | 8.058 s |
| Warm export: sampled family RSS peak | 1779.4 MiB | 1710.6 MiB |
The identical two-export task, including the full catalog discovery, open and
close, used **3,723,191 → 981,913 estimated tokens (73.63% fewer)**.
The extra verified-resume call is excluded from that comparison. The catalog
itself grew from 969,665 to
974,049 estimated tokens. All 220 existing tool definitions
were identical; only `decompile_all` was added. The per-export request/reply
reduction is about 99.72%; applying that percentage to the entire task would
overstate the benefit. A client which injects the entire catalog still pays its
large fixed context cost.
The full verifier process family took 85.697 → 62.034 s elapsed,
111.166 → 68.185 s waited CPU, and
1779.4 → 1713.7 MiB sampled peak RSS. The after run additionally includes
verified resume (0.266 s). These compare the retained baseline with the final review-fix run;
**no general CPU or elapsed-time speedup is claimed**. Earlier maintenance
trials ranged 50–83 s elapsed and 65–118 s CPU; they used earlier source
revisions and are retained separately. Full-run figures include SDK, verifier
formatting/hash checks and token estimation as well as REA and its provider.
Per-phase sampled CPU was 70.19 → 43.16 s cold and
6.15 → 4.42 s warm, subject to delayed child accounting. The
integration decision rests on equivalent results with far fewer agent-facing
calls and estimated context, plus bounded auxiliary accumulators.
Cold/warm here describe primitive analysis reuse inside one provider session.
Host filesystem caches were not flushed, and the baseline ran first. This is a
paired functional measurement, not a statistical speedup claim. Provider startup
and host load varied: the independent CLI runs below took longer than the MCP
runs. No claim is made about cold OS caches or other binaries.
Token counts use `tokenx` 1.3.0 estimates of actual tool arguments and complete
MCP call results, including both textual and structured projections. They are
not model-specific BPE counts or actual model usage. The completed-task total
also includes the actual `tools/list` response, open and close calls, and both
exports. MCP handshake framing, orchestrator prompts, reasoning, subsequent
agent reading of files and unknown model tokenizer costs are unmeasured.
Process-family RSS was sampled from `/proc` every 50 ms. Shared pages can be
counted more than once, and short peaks can be missed. Full-run CPU uses waited
child usage; per-phase CPU is a sampled `/proc` delta and can include delayed
accounting of exited descendants. Limits were two CPU cores, a 2 GiB V8 heap per
Node process, 1 GiB configured Java heap, 6 GiB sampled family RSS, 300 sampled
family CPU seconds and 600 elapsed seconds. These are verifier controls, not a
kernel memory limit. All reported completed runs exited zero without a resource
stop. No reduction in Java's analysis memory is claimed.
The first baseline verifier attempt stopped at the real per-function Ghidra
error because its harness initially required every primitive call to succeed.
It took 74.325 s elapsed and 101.569 s waited CPU, with no resource stop. The
completed baseline was corrected to retain that same failure and continue,
matching the requested partial-result semantics. The failed trial is retained
separately and is excluded from the paired completed-export table; no provider
fix or disappearance of that failure is claimed.
## Ownership, reuse and resource limits
The export owner parses a resumed journal once and updates its latest records
only after append and sync complete. The shared function-record schema owns
path/status validation for both journal and index; the append retains the
parsed snapshot, including nested fields, rather than the caller's mutable input. Index generation consumes those committed
facts instead of reading and parsing the whole journal again. Resume identity
covers target SHA-256, provider identity/version, analysis-profile digest and
layout. Unknown target/profile digests prevent resume. Files are checked afresh
with the shared stable-file hash owner and bounded reads, rather than trusting
an old checksum as current. `decompile_all` remains ineligible for snapshot
replay: filesystem publication must run for the actual request.
Journal descriptors, verified staging areas and the directory lease have one
cleanup owner. Failed close/removal retains that owner under the session; the
lease is released after child cleanup and initialization cleanup finish.
Initialization removes verified owned entries rather than recursively deleting
unexpected files. Function path reservations use case-folded ASCII lookup
keys while preserving the original address identity and chosen filename;
collisions receive a stable address digest suffix. Resume refuses conflicting
legacy reservations. These cases are exercised with the real filesystem on
Linux; a real Windows/macOS filesystem was not exercised.
A failed lease acquisition preserves even an empty directory
and reports its created path, because another holder may be preparing it.
The inline failure accumulator retains at most 32 rows within 2,048
JSON-encoded UTF-8 bytes. It omits display names before omitting complete
address/code/reason rows. Recent output-I/O diagnostics retain at most five
rows within the same byte budget. The index retains all complete function
records. Per-item caveat collection retains at most eight first-observed
provider/text groups within an 8,000-byte JSON-encoded UTF-8 text budget,
instead of retaining all distinct caveats to rank them.
Omitted observations are counted without retaining their text; this count is
not a distinct-caveat count. Complete caveats remain in the owning index.
Inventory and latest journal/index state still scale with the number of
functions and their retained metadata. Larger exports, long-running journal
histories and worst-case metadata expansion were not capacity-benchmarked;
this report makes no 100,000-function memory guarantee. The attempt limit is a
positive safe integer and does not allocate according to its numeric value.
## Validation
`npm run check` passed after the review fixes on `d3e0e9682`: type checking,
lint, formatting, dependency/metadata checks, module boundaries and architecture
guards. `npm run docs:check` also passed, including nine README conformance cases. After the main refresh, 226 tests passed across ten files, including the
four Ghidra profile suites and the six CLI/MCP/workflow/schema files. Earlier
Hopper lease and session-cleanup coverage below is additional; repeated tests
are not counted as new cases.
- The focused integration run passed 112 tests across 11 files; four
macOS-specific real-Hopper startup cases were skipped on Linux. It includes
public CLI/MCP behavior, actual SDK schema conversion/dialect/client checks,
the shared lease extraction, session cleanup retry and descriptor ownership.
- After the final caveat/cleanup changes, 75 tests passed across the four
affected files: 48 export cases, 20 workflow-Evidence cases, six filesystem
ownership cases and one MCP caveat case. This adds five distinct cases to
the earlier integration coverage; overlapping executions are not summed.
- Follow-up review coverage passed 72 tests across the workflow and MCP
files, plus 22 filesystem/CLI/schema cases. This includes portable filename
reservations, conflicting legacy
journals, encoded caveat budgets and long failure names. Against the
published pre-fix implementation, all three new regression cases failed;
the complete SDK result for the long-name case was 485,937 bytes, above
the 51,200-byte tool window. The repaired case retains useful failures
inline and verifies complete names and caveats in the index.
- Original-PR controls reproduce the uppercase-input refusal, valid-journal
initialization cleanup race, lost failed-close ownership, unexpected-file
deletion, and the empty-root lease-acquisition race. These controls use the
original production implementation, not the repaired one. An additional
production selection control returns zero matching aliases before the fix
and the selected alias after it. A separate negative control of the
maintenance index-reuse change reproduced journal/index disagreement after
mutation of the append input; retaining the parsed snapshot resolves it.
- Three real Ghidra CLI calls passed: uppercase address range plus limit 1
wrote one of two functions; resume with limit 100001 skipped the verified
file and wrote the other; an unmatched name selected zero and reported 298
excluded inventory entries. Both output files matched the full MCP export.
The CLI group took 166.128 s elapsed, 120.848 s waited CPU and 1257.0 MiB
sampled peak family RSS, without a resource stop.
Raw measurements and verifier scripts remain in the maintainer workspace at
`/tmp/rea-maintenance-20261023/6170/`, including completed and failed attempts,
full MCP exchanges, all function outputs and equivalence checks. They are local
verification artifacts, not shipped provider projects or committed binaries.
Real-provider coverage here is Linux x86-64 Ghidra only. Hopper, IDA, Windows and macOS
were not exercised with real providers by this verification.
## CI attribution
The first maintenance CI run used synthetic merge
`798091d99f5c9f434d832981e0c8d28a5ec4aabc` and exact base
`9fd78a74f84a8e924bd58e7deaf5934bfb24f53c`. All 54 observed failing
assertions reproduced on both revisions with identical failure diagnostics
after normalizing only their checkout paths: 11 source cases and 43 built
cases. Forty built failures came from cancelled-shard logs and were absent
from the aggregate report; cancelled coverage is not claimed as executed.
The real-browser job also failed its stable-capture checkpoint. A bounded
checkpoint-only execution of the production verifier reproduced the same
`dom_structure: unchanged`, `scripts: unknown` result on exact base and
merge, with cleanup retained. Local Chromium was 155.0.8059.79 on Debian 12;
the CI Chrome version was not measured. This does not establish a passing
complete browser workflow. [The public CI attribution](https://github.com/morluto/rea/pull/6170#issuecomment-6373669748)
records the controls and separates these baseline failures from the three
actionable feature review findings fixed above.
+11 -6
View File
@@ -75,12 +75,17 @@ selected byte identity and tool provenance.
Core validation remains authoritative when an optional custom section cannot
be interpreted. WABT warning output is preserved in `custom_section_warnings`;
`debug_name_metadata` reports whether the optional name section was absent,
fully interpreted, or uninterpretable. Older portable reports retain their
original strict command profile; missing metadata coverage is `unknown` and
missing warning history is `null`. The raw custom section remains in the
decoded WAT. Module names decoded before a later malformed subsection remain
available for framing the exact objdump preamble; `uninterpretable` still marks
the overall optional metadata coverage.
fully interpreted, uninterpretable, or unknown. WABT custom-section warnings
whose section attribution is unknown prevent a complete debug-name claim, even
when module names were decoded. They do not establish that the name section is
malformed. Exact warnings retain their producing tool and text. Older portable
reports retain their original strict command profile; missing metadata coverage
is `unknown` and missing warning history is `null`. The raw custom section
remains in the decoded WAT. An uninterpretable name section leaves complete
debug-name coverage unknown. Module names decoded before a later metadata
failure remain available for exact objdump framing, including names containing
newlines or NUL; those partial observations do not establish complete optional
metadata.
The tool profile preserves upstream release/source identity, resolved executable
paths, executable digests, exact banners and arguments. Executable digests are
+112
View File
@@ -431,6 +431,118 @@ try {
cases += 2;
}
}
// Source-owned inert core and raw optional payloads reproduce #6885 without
// asking wat2wasm to repair or regenerate the malformed name metadata.
const core = Buffer.from(
"0061736d0100000001040160000003020100070501016600000a040102000b",
"hex",
);
for (const [label, payload, metadata, observedName] of [
["valid-name", Buffer.from("00060568656c6c6f", "hex"), "complete", true],
[
"valid-name-map",
Buffer.from("00060568656c6c6f010100", "hex"),
"complete",
true,
],
[
"inner-name-error",
Buffer.from("00060568656c6c6f010101", "hex"),
"unknown",
true,
],
[
"partial-name",
Buffer.from("00060568656c6c6f010201", "hex"),
"uninterpretable",
true,
],
[
"initial-name-error",
Buffer.from("0006056e616d65", "hex"),
"uninterpretable",
false,
],
]) {
const fixtureRoot = join(root.path, label);
await mkdir(fixtureRoot);
const path = join(fixtureRoot, "module.wasm"),
custom = Buffer.concat([Buffer.from([4]), Buffer.from("name"), payload]),
original = Buffer.concat([core, Buffer.from([0, custom.length]), custom]);
await writeFile(path, original);
const validation = await tool(
"wasm-validate",
["--enable-all", "--ignore-custom-section-errors", "module.wasm"],
fixtureRoot,
),
dump = await tool(
"wasm-objdump",
["-h", "-x", "module.wasm"],
fixtureRoot,
),
decoded = await tool(
"wasm2wat",
["--enable-all", "--ignore-custom-section-errors", "module.wasm"],
fixtureRoot,
);
const input = { path, glue_paths: [], candidate_paths: [] };
const cli = parseEvidence(await inspectCli(input));
const reply = await callTool({
name: "inspect_wasm_artifact",
arguments: input,
});
assert.notEqual(reply.isError, true, mcpTextValue(reply));
assert.ok(
ajv.validate(advertised.outputSchema, reply.structuredContent),
JSON.stringify(ajv.errors),
);
const mcp = parseEvidence(reply.structuredContent);
assert.deepEqual(cli, mcp);
const report = mcp.normalized_result;
assert.equal(report.validation, "valid");
assert.equal(report.artifact.sha256, sha256(original));
assert.equal(report.artifact.bytes, original.length);
assert.equal(report.debug_name_metadata, metadata);
assert.equal(report.interface.state, "complete");
assert.deepEqual(report.exports, ['(export "f" (func 0))']);
assert.deepEqual(
report.interface.entries.map(({ direction, name, signature }) => ({
direction,
name,
signature,
})),
[
{
direction: "export",
name: "f",
signature: { kind: "func", params: [], results: [] },
},
],
);
assert.equal(
report.headers.includes("module name: <hello>\n"),
observedName,
);
assert.equal(report.sections.at(-1).end, original.length);
assert.equal(report.wat.text, decoded.stdout);
assert.equal(report.wat.sha256, sha256(decoded.stdout));
assert.ok(report.wat.text.includes('(@custom "name"'));
const warnings = [
{ tool: "wasm-validate", text: validation.stderr },
{ tool: "wasm-objdump", text: dump.stderr },
{ tool: "wasm2wat", text: decoded.stderr },
].filter(({ text }) => text !== "");
assert.equal(warnings.length, metadata === "complete" ? 0 : 3);
assert.deepEqual(report.custom_section_warnings, warnings);
if (metadata !== "complete")
assert.ok(
report.limitations.some((text) =>
text.includes("complete debug-name coverage remains unknown"),
),
);
assert.deepEqual(await readFile(path), original);
cases += 2;
}
cases += await verifyWabtCleanup(entrypoint, environment, childTempRoot);
} catch (cause) {
failures.push(cause);
+40
View File
@@ -2057,6 +2057,46 @@ ${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat
]);
});
it.each([
{ value: "10", operand: "10" },
{ value: "010", operand: "10" },
{ value: "-010", operand: "-10" },
{ value: "08", operand: "8" },
{ value: "000", operand: "0" },
])(
"passes int extra $value to am as decimal $operand",
async ({ value, operand }) => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")}
${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW }\n" })(root)}`,
);
await evidenceResult(providerOver(binary), {
operation: "start_adb_activity",
input: {
serial: "emulator-5554",
action: "android.intent.action.VIEW",
extras: [
{ key: "count", type: "int", value },
// `--el` is read with Long.valueOf, which is always decimal.
{ key: "total", type: "long", value },
],
},
});
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
// am reads `--ei` with Integer.decode, where a leading zero means octal.
expect(logged.slice(-6)).toEqual([
"--ei",
"count",
operand,
"--el",
"total",
value,
]);
},
);
it("collects a device bugreport archive with its digest", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
@@ -195,6 +195,99 @@ describe("analysis context queries: navigation", () => {
});
});
describe("navigation procedure admission", () => {
it.each([
{ condition: "missing result", value: null },
{ condition: "missing discriminator", value: {} },
{
condition: "missing query identity",
value: { found: false, procedure: null, reason: "not_in_procedure" },
},
{
condition: "missing procedure facts",
value: {
query_address: "0x401000",
found: true,
procedure: { address: "0x401000", name: "main" },
},
},
])(
"rejects $condition instead of reporting no procedure",
async ({ value }) => {
const result = await getNavigationContext(
{
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "current_address" ? "0x401000" : value,
provider,
),
),
},
{ document: "fixture" },
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
operation: "resolve_containing_procedure",
},
});
},
);
it.each(["no procedure exists", "not in a procedure"])(
"preserves a provider failure containing %s",
async (message) => {
const failure = new AnalysisProtocolError(`Provider failed: ${message}`);
const result = await getNavigationContext(
{
execute: async (operation) =>
operation === "current_address"
? ok(createAnalysisExecution("0x401000", provider))
: err(failure),
},
{ document: "fixture" },
);
expect(result).toEqual(err(failure));
if (result.ok) throw new Error("expected original provider failure");
expect(result.error).toBe(failure);
},
);
it("returns a validated containing procedure name", async () => {
const result = await getNavigationContext(
{
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "current_address"
? "0x401000"
: {
query_address: "0x401000",
found: true,
procedure: {
address: "0x401000",
name: "main",
classification: null,
body: {
available: false,
reason: "Fixture has no body ranges",
},
},
},
provider,
),
),
},
{ document: "fixture" },
);
expect(result).toEqual(
ok({ document: "fixture", address: "0x401000", procedure: "main" }),
);
});
});
describe("analysis context queries: address facets", () => {
it("keeps unsupported address facets local and filters bookmarks", async () => {
const bookmarkInputs: Readonly<Record<string, unknown>>[] = [];
+2 -1
View File
@@ -168,7 +168,8 @@ const readContainingProcedure = (value: JsonValue) => {
: err(
new AnalysisOutputError(
"resolve_containing_procedure",
"expected a valid found or absent containing-procedure result",
"expected a containing-procedure resolution",
{ cause: parsed.error },
),
);
};
+2
View File
@@ -126,6 +126,8 @@ export function createAnalysisExecution(
/** Provider-neutral application capability for one analysis operation. */
export interface AnalysisOperationPort {
/** The session retains workflow resources whose final cleanup failed. */
readonly resourceScope?: RetainedResourceScope | undefined;
execute(
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
+1
View File
@@ -201,6 +201,7 @@ const ENHANCED_REQUIREMENTS: ReadonlyMap<string, ProviderComposition> = new Map(
get_objc_classes: ["list_names"],
get_objc_protocols: ["list_names"],
batch_decompile: ["procedure_pseudo_code"],
decompile_all: ["list_procedures", "procedure_pseudo_code"],
get_call_graph: CALL_RELATIONSHIP_COMPOSITION,
trace_call_path: CALL_RELATIONSHIP_COMPOSITION,
analyze_swift_types: ["list_procedures"],
+19 -7
View File
@@ -47,6 +47,7 @@ import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.j
import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js";
import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js";
import {
composedLimitationBudget,
executeComposedWorkflow,
createWorkflowEvidence,
workflowSnapshotRecord,
@@ -89,7 +90,8 @@ type DirectAnalysisTool =
| "xrefs"
| "trace_feature"
| "trace_native_ui_action"
| "trace_native_values";
| "trace_native_values"
| "decompile_all";
// These one-shot CLI questions require native utilities, not a deep engine.
// Runtime observation retains the ordinary session and authority checks.
@@ -464,7 +466,11 @@ const runAnalysis = async (
return err(recorded.error.retainPartialObservation(evidence));
}
}
if (tool !== "trace_native_ui_action" && snapshotPath !== undefined) {
if (
tool !== "trace_native_ui_action" &&
tool !== "decompile_all" &&
snapshotPath !== undefined
) {
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok)
return err(snapshot.error.retainPartialObservation(evidence));
@@ -553,11 +559,15 @@ const executeAnalysisTool = async (input: {
tool === "inspect_native_dispatch_metadata" ||
tool === "trace_feature" ||
tool === "trace_native_values" ||
tool === "trace_native_ui_action"
tool === "trace_native_ui_action" ||
tool === "decompile_all"
) {
const { EnhancedTools } = await import("./EnhancedTools.js");
const result = await executeComposedWorkflow(session, (observed) =>
new EnhancedTools(observed).execute(tool, input.arguments, signal),
const result = await executeComposedWorkflow(
session,
(observed) =>
new EnhancedTools(observed).execute(tool, input.arguments, signal),
composedLimitationBudget(tool),
);
if (!result.ok) return result;
const evidence = createWorkflowEvidence({
@@ -722,7 +732,8 @@ const allowsSnapshotReplay = (
| ManagedToolName
| DirectAnalysisTool,
): boolean => {
if (tool === "trace_native_ui_action") return false;
if (tool === "trace_native_ui_action" || tool === "decompile_all")
return false;
const descriptor = route.capabilities?.get(tool);
return descriptor === undefined
? ![...(route.capabilities?.values() ?? [])].some(
@@ -795,7 +806,8 @@ const isWorkflowEvidenceTool = (
tool === "inspect_native_dispatch_metadata" ||
tool === "trace_feature" ||
tool === "trace_native_values" ||
tool === "trace_native_ui_action";
tool === "trace_native_ui_action" ||
tool === "decompile_all";
const fileExists = async (path: string): Promise<boolean> => {
try {
+9
View File
@@ -37,6 +37,7 @@ import {
import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection.js";
import { traceNativeValues } from "./native/NativeValueTrace.js";
import { traceNativeUiAction } from "./native/NativeUiActionTrace.js";
import { decompileAll } from "./decompileDirectory/decompileAll.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { addressedValueSchema } from "../domain/native/nativeInventory.js";
import {
@@ -164,6 +165,12 @@ export class EnhancedTools {
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidEnhancedInput(name, parsed.error);
}
case "decompile_all": {
const parsed = enhancedInputSchemas.decompile_all.safeParse(input);
return parsed.success
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidEnhancedInput(name, parsed.error);
}
case "get_call_graph": {
const parsed = enhancedInputSchemas.get_call_graph.safeParse(input);
return parsed.success
@@ -212,6 +219,8 @@ export class EnhancedTools {
return this.#objcProtocols(signal);
case "batch_decompile":
return this.#batchDecompile(call.input.addresses, signal);
case "decompile_all":
return decompileAll(this.analysis, call.input, signal);
case "get_call_graph":
return this.#callGraph(call.input, signal);
case "analyze_swift_types":
+153
View File
@@ -213,6 +213,159 @@ it("deduplicates exact source caveats without merging providers or operations",
);
});
it("bounds per-item caveat retention while preserving inventory caveats", async () => {
const result = await executeComposedWorkflow(
{
execute: async (operation, parameters) =>
ok(
createAnalysisExecution(null, provider, {
limitations:
operation === "list_procedures"
? ["inventory caveat"]
: [
"shared caveat",
`caveat at ${String(parameters.procedure)}`,
],
}),
),
},
async (observed) => {
await observed.execute("list_procedures", {});
for (const procedure of ["0x1", "0x2", "0x3"])
await observed.execute("procedure_pseudo_code", { procedure });
return ok({ count: 3 });
},
{
perItemOperations: new Set(["procedure_pseudo_code"]),
maxDistinct: 2,
retainedIn: "the files keep them",
},
);
expect(result).toEqual(
ok({
result: { count: 3 },
limitations: [
"Fixture (fixture 1) list_procedures: inventory caveat",
"Fixture (fixture 1) procedure_pseudo_code: caveat at 0x1",
"Fixture (fixture 1) procedure_pseudo_code: shared caveat",
"2 per-item provider limitation observations were omitted from this answer; the files keep them.",
],
}),
);
});
it("spends one budget slot on a caveat that two per-item operations share", async () => {
const result = await executeComposedWorkflow(
{
execute: async (operation, parameters) =>
ok(
createAnalysisExecution(null, provider, {
limitations: [
"shared caveat",
`${operation} at ${String(parameters.procedure)}`,
],
}),
),
},
async (observed) => {
await observed.execute("procedure_info", { procedure: "0x1" });
await observed.execute("procedure_pseudo_code", { procedure: "0x1" });
return ok(null);
},
{
perItemOperations: new Set(["procedure_info", "procedure_pseudo_code"]),
maxDistinct: 2,
retainedIn: "the files keep them",
},
);
expect(result).toEqual(
ok({
result: null,
limitations: [
"Fixture (fixture 1) procedure_info, procedure_pseudo_code: shared caveat",
"Fixture (fixture 1) procedure_info: procedure_info at 0x1",
"1 per-item provider limitation observation was omitted from this answer; the files keep them.",
],
}),
);
});
it("counts repeated omitted observations without retaining their text for ranking", async () => {
const result = await executeComposedWorkflow(
{
execute: async (_operation, parameters) =>
ok(
createAnalysisExecution(null, provider, {
limitations: [
parameters.procedure === "first"
? "first caveat"
: "later caveat",
],
}),
),
},
async (observed) => {
await observed.execute("procedure_pseudo_code", { procedure: "first" });
for (let index = 0; index < 30; index += 1)
await observed.execute("procedure_pseudo_code", { procedure: "later" });
return ok(null);
},
{
perItemOperations: new Set(["procedure_pseudo_code"]),
maxDistinct: 1,
retainedIn: "the index keeps them",
},
);
expect(result).toEqual(
ok({
result: null,
limitations: [
"Fixture (fixture 1) procedure_pseudo_code: first caveat",
"30 per-item provider limitation observations were omitted from this answer; the index keeps them.",
],
}),
);
});
it("bounds retained caveat text while preserving smaller later observations", async () => {
const result = await executeComposedWorkflow(
{
execute: async () =>
ok(
createAnalysisExecution(null, provider, {
limitations: [
"x".repeat(9_000),
"first",
"second",
"x".repeat(9_000),
"ok",
],
}),
),
},
async (observed) => {
await observed.execute("procedure_pseudo_code", {});
return ok(null);
},
{
perItemOperations: new Set(["procedure_pseudo_code"]),
maxDistinct: 8,
maxBytes: 12,
retainedIn: "the index keeps them",
},
);
expect(result).toEqual(
ok({
result: null,
limitations: [
"Fixture (fixture 1) procedure_pseudo_code: first",
"Fixture (fixture 1) procedure_pseudo_code: ok",
"3 per-item provider limitation observations were omitted from this answer; the index keeps them.",
],
}),
);
});
it("preserves a failed composed operation without an extra provider read", async () => {
const failure = err(new AnalysisCancelledError("list_strings"));
let calls = 0;
+73 -1
View File
@@ -1,3 +1,4 @@
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js";
import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js";
import type {
@@ -28,12 +29,45 @@ import {
type SourceLimitation,
} from "./SourceLimitations.js";
/**
* Bound for caveats a workflow observes once per item. Provider caveats can
* name per-item addresses, so they do not deduplicate and would otherwise grow
* with the number of items.
*/
export interface ComposedLimitationBudget {
/** Operations the workflow calls once per item. */
readonly perItemOperations: ReadonlySet<AnalysisOperation>;
/**
* First distinct per-item caveats retained, grouped by provider and text.
* Kept lines are rendered in the usual sorted order.
*/
readonly maxDistinct: number;
/** Retained caveat text as JSON-encoded UTF-8 bytes; defaults to 8,000. */
readonly maxBytes?: number;
/** Where the omitted per-item caveats remain, as a clause. */
readonly retainedIn: string;
}
const DECOMPILE_ALL_LIMITATIONS: ComposedLimitationBudget = {
perItemOperations: new Set(["procedure_info", "procedure_pseudo_code"]),
maxDistinct: 8,
retainedIn:
"each function's record in decompile-index.json keeps the caveats of the observations it used",
};
/** The caveat budget for a composed workflow, when it has one. */
export const composedLimitationBudget = (
operation: string,
): ComposedLimitationBudget | undefined =>
operation === "decompile_all" ? DECOMPILE_ALL_LIMITATIONS : undefined;
/** Retain the limits of exactly the observations used by one composed call. */
export const executeComposedWorkflow = async (
analysis: AnalysisOperationPort,
execute: (
observed: AnalysisOperationPort,
) => Promise<Result<JsonValue, AnalysisError>>,
budget?: ComposedLimitationBudget,
): Promise<
Result<
{
@@ -45,8 +79,12 @@ export const executeComposedWorkflow = async (
>
> => {
const limitations = new Map<string, SourceLimitation>();
const perItemGroups = new Set<string>();
let perItemBytes = 0;
let omittedObservations = 0;
let cachePolicy: "live" | undefined;
const result = await execute({
resourceScope: analysis.resourceScope,
execute: async (operation, parameters, options) => {
const execution = await analysis.execute(operation, parameters, options);
if (execution.ok && execution.value.cachePolicy === "live")
@@ -58,6 +96,33 @@ export const executeComposedWorkflow = async (
operation,
limitation,
} satisfies SourceLimitation;
if (budget?.perItemOperations.has(operation) === true) {
// Reject oversized text before allocating a grouping key. Complete
// caveats are already retained by the export's per-function owner.
const maxBytes = budget.maxBytes ?? 8_000;
const bytes = jsonStringByteLength(limitation, maxBytes);
if (bytes > maxBytes) {
omittedObservations += 1;
continue;
}
const group = JSON.stringify([
fact.provider.id,
fact.provider.name,
fact.provider.version,
limitation,
]);
if (!perItemGroups.has(group)) {
if (
perItemGroups.size >= budget.maxDistinct ||
perItemBytes + bytes > maxBytes
) {
omittedObservations += 1;
continue;
}
perItemGroups.add(group);
perItemBytes += bytes;
}
}
limitations.set(sourceLimitationKey(fact), fact);
}
return execution;
@@ -66,7 +131,14 @@ export const executeComposedWorkflow = async (
if (!result.ok) return result;
return ok({
result: result.value,
limitations: renderSourceLimitations([...limitations.values()]),
limitations: [
...renderSourceLimitations([...limitations.values()]),
...(omittedObservations === 0
? []
: [
`${omittedObservations} per-item provider limitation observation${omittedObservations === 1 ? " was" : "s were"} omitted from this answer; ${budget?.retainedIn}.`,
]),
],
...(cachePolicy === undefined ? {} : { cachePolicy }),
});
};
+2
View File
@@ -388,6 +388,7 @@ export class BinarySession
/** External live state cannot be replayed by a direct or composed CLI workflow. */
allowsSnapshotReplay(operation: AnalysisOperation): boolean {
if (operation === "decompile_all") return false;
if (this.#activeBundleResources !== undefined) return false;
const capabilities = this.#active?.route.capabilities;
const descriptor = capabilities?.get(operation);
@@ -1142,6 +1143,7 @@ export class BinarySession
if (!admission.ok) return admission;
return ok(
await operation({
resourceScope: this.resourceScope,
execute: (name, arguments_, options) =>
this.#execute(name, arguments_, options, Promise.resolve()),
}),
@@ -28,6 +28,7 @@ import { err, ok, type Result } from "../../domain/result.js";
import type {
AnalysisExecution,
AnalysisOperation,
RetainedResourceScope,
} from "../AnalysisProvider.js";
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
@@ -70,6 +71,16 @@ export abstract class BinarySessionRecords {
readonly #records: InvestigationRecords;
protected readonly fileResources = new ArtifactResourceScope();
readonly resourceScope: RetainedResourceScope = {
retain: (owner, resource) =>
this.fileResources.retain({
kind: "lease",
resource,
lease: { release: () => owner.close() },
}),
run: (operation) => this.fileResources.run(operation),
};
protected get hasPendingFileResources(): boolean {
return this.fileResources.hasPending;
}
@@ -36,6 +36,7 @@ import {
} from "../../artifacts/ArtifactFileOperation.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { sameRegularFileState } from "../../filesystem/RegularFile.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
/** Bound retained result rows before constructing the Evidence representation. */
@@ -201,14 +202,8 @@ export class ByteEntropyService {
input.path,
"File identity, size or timestamps changed during entropy inspection.",
);
const same = (value: typeof initial) =>
value.isFile() &&
value.dev === initial.dev &&
value.ino === initial.ino &&
value.size === initial.size &&
value.mtimeNs === initial.mtimeNs &&
value.ctimeNs === initial.ctimeNs;
if (!same(before)) throw changed();
if (!initial.isFile() || !sameRegularFileState(initial, before))
throw changed();
const size = Number(initial.size);
if (!Number.isSafeInteger(size))
throw invalid(
@@ -280,8 +275,11 @@ export class ByteEntropyService {
position += filled;
}
if (
!same(await file.stat({ bigint: true })) ||
!same(await this.files.stat(input.path, { bigint: true }))
!sameRegularFileState(initial, await file.stat({ bigint: true })) ||
!sameRegularFileState(
initial,
await this.files.stat(input.path, { bigint: true }),
)
)
throw changed();
assertArtifactReadActive(options?.signal);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,994 @@
import { join } from "node:path";
import { z } from "zod";
import {
DECOMPILE_DIRECTORY_INDEX_NAME,
DECOMPILE_DIRECTORY_INLINE_FAILURE_LIMIT,
DECOMPILE_DIRECTORY_INLINE_FAILURE_BYTES,
decompileDirectoryManifestSchema,
type DecompileDirectoryFunctionRecord,
type DecompileDirectoryInput,
type DecompileDirectoryInlineFailure,
} from "../../contracts/decompileDirectory.js";
import { jsonValueByteLength } from "../../domain/jsonValueByteLength.js";
import { digestSchema } from "../../domain/digests.js";
import type {
AnalysisExecution,
AnalysisOperationPort,
} from "../AnalysisProvider.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
AnalysisTimeoutError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { procedureInfoSchema } from "../../domain/native/procedureInfo.js";
import { addressedValueSchema } from "../../domain/native/nativeInventory.js";
import { addressCoordinate } from "../../domain/native/addressCoordinate.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
classifyOutputPath,
DecompileExport,
type ExportIdentity,
} from "./exportTree.js";
import {
bodyRecord,
functionFileText,
type FunctionBody,
} from "./functionFile.js";
import {
selectProcedures,
validateDecompileBounds,
type SelectedProcedure,
} from "./selection.js";
const DWARF_LAYOUT_UNAVAILABLE =
"Debug compilation-unit layout is unavailable. Files are named by canonical address.";
const BODY_UNAVAILABLE =
"procedure_info is unavailable, so function body ranges are unknown.";
const TARGET_DIGEST_UNAVAILABLE =
"Target SHA-256 was not observed, so this export cannot be resumed.";
const PROFILE_UNAVAILABLE =
"The provider did not commit an analysis profile, so this export cannot be resumed.";
const DUPLICATE_ADDRESSES =
"Duplicate procedure addresses were coalesced to the first matching inventory row.";
/** Consecutive output_io failures after which an export stops. */
export const OUTPUT_IO_STREAK_LIMIT = 5;
const unaddressedRows = (count: number): string =>
`${count.toString()} inventory row(s) had no address and were not exported.`;
interface FailureReason {
readonly code: string;
readonly message: string;
}
type InlineFailure = DecompileDirectoryInlineFailure;
type NamedFailure = Extract<InlineFailure, { readonly name: string | null }>;
interface PublishState {
/** Functions this call took on after skipping verified files; bounded by limit. */
attempted: number;
written: number;
failed: number;
/** Functions procedure_info classifies as external: not decompiled, no file. */
external: number;
skipped: number;
readonly failures: InlineFailure[];
inlineFailureBytes: number;
readonly recentFailures: PublishState["failures"];
bodyUnavailable: boolean;
/** Failure code of the function being attempted, if it failed. */
lastFailureCode: string | undefined;
/**
* Consecutive output_io failures. A verified file, a write, or any other
* outcome ends the run of failures; a recorded external function does not.
*/
outputIoStreak: number;
}
/**
* Write one pseudocode file per selected function and return only a manifest.
* Files already committed stay in place when a later function fails or the
* caller cancels.
*/
export const decompileAll = async (
analysis: AnalysisOperationPort,
input: DecompileDirectoryInput,
signal?: AbortSignal,
): Promise<Result<JsonValue, AnalysisError>> => {
const bounds = validateDecompileBounds(input);
if (!bounds.ok) return bounds;
const placement = await classifyOutputPath(
input.output_directory,
input.resume,
);
if (!placement.ok) return placement;
if (isAborted(signal)) return err(cancelled(signal));
const loaded = await loadProcedures(analysis, input.layout, signal);
if (!loaded.ok) return loaded;
if (isAborted(signal)) return err(cancelled(signal));
const selection = selectProcedures(
loaded.value.procedures,
input,
bounds.value,
);
const opened = await DecompileExport.open({
placement: placement.value,
identity: loaded.value.identity,
resourceScope: analysis.resourceScope,
});
if (!opened.ok) return opened;
const session = opened.value;
const state: PublishState = {
attempted: 0,
written: 0,
failed: 0,
external: 0,
skipped: 0,
failures: [],
inlineFailureBytes: 2,
recentFailures: [],
bodyUnavailable: false,
lastFailureCode: undefined,
outputIoStreak: 0,
};
let published: Result<void, AnalysisError> | undefined;
let indexed: Result<void, AnalysisError> | undefined;
let limitations: readonly string[] = [];
let closed: Result<void, AnalysisError> | undefined;
// The journal handle is closed on every path, including an unexpected throw.
try {
published = await publishSelected({
analysis,
session,
procedures: attemptOrder(session, selection.procedures),
identity: loaded.value.identity,
signal,
limit: input.limit,
state,
});
limitations = exportLimitations(
input.layout,
loaded.value.identity,
selection.collapsedDuplicates,
state.bodyUnavailable,
loaded.value.unaddressed,
);
indexed = await session.writeIndex({
layoutStatus: layoutStatus(input.layout),
limitations,
});
} finally {
closed = await session.close();
}
// A publish, index or close failure stays primary: the journal or index
// may be incomplete and the caller needs that reason. Cancellation only
// replaces success, so an index or close failure after the caller's abort
// stopped the publish loop is still reported. A provider's own
// cancellation, without a caller abort, is a publish failure like any other.
const callerStoppedPublish =
!published.ok &&
published.error instanceof AnalysisCancelledError &&
isAborted(signal);
const failures = [
...(!published.ok && !callerStoppedPublish ? [published.error] : []),
...(indexed.ok ? [] : [indexed.error]),
...(closed.ok ? [] : [closed.error]),
];
const primary = failures[0];
if (primary !== undefined) {
for (const failure of failures.slice(1))
primary.retainSecondaryFailure(failure);
return !closed.ok &&
primary !== closed.error &&
closed.error.cleanupIncomplete
? err(
analysisErrorWithCleanupFailure(
primary,
closed.error,
"decompile_all",
).bindCleanupDependencies([closed.error]),
)
: err(primary);
}
// Finalization succeeded, so committed files and the index are usable on
// resume, but a caller abort that stopped the loop or arrived meanwhile is
// not a success. Report it with the caller's own reason.
if (isAborted(signal)) return err(cancelled(signal));
const manifest = decompileDirectoryManifestSchema.safeParse(
manifestOf({
root: session.root,
input,
identity: loaded.value.identity,
selected: selection.procedures.length,
excluded: selection.excluded,
state,
limitations,
}),
);
return manifest.success
? ok(manifest.data)
: err(
new AnalysisOutputError(
"decompile_all",
"decompile manifest could not be built",
),
);
};
/**
* Functions with no recorded failure go first, so a limited resume reaches
* new work instead of retrying the same failures every call.
*/
const attemptOrder = (
session: DecompileExport,
procedures: readonly SelectedProcedure[],
): readonly SelectedProcedure[] => {
const failedBefore = (procedure: SelectedProcedure): boolean =>
session.record(procedure.address)?.status === "failed";
return [
...procedures.filter((procedure) => !failedBefore(procedure)),
...procedures.filter(failedBefore),
];
};
const loadProcedures = async (
analysis: AnalysisOperationPort,
layout: DecompileDirectoryInput["layout"],
signal: AbortSignal | undefined,
): Promise<
Result<
{
readonly procedures: readonly SelectedProcedure[];
readonly identity: ExportIdentity;
readonly unaddressed: number;
},
AnalysisError
>
> => {
const observed = await analysis.execute(
"list_procedures",
{},
executionOptions(signal),
);
if (!observed.ok) return observed;
const parsed = z.array(addressedValueSchema).safeParse(observed.value.result);
if (!parsed.success)
return err(
new AnalysisOutputError(
"list_procedures",
"provider returned an invalid procedure inventory",
),
);
const identity = identityFrom(observed.value, layout);
if (!identity.ok) return identity;
// An empty address cannot name a file or a journal record.
const addressed = parsed.data.filter((row) => row.address.length > 0);
return ok({
procedures: addressed.map((row) => ({
address: row.address,
name: row.value.length === 0 ? null : row.value,
})),
identity: identity.value,
unaddressed: parsed.data.length - addressed.length,
});
};
const identityFrom = (
execution: AnalysisExecution,
layout: DecompileDirectoryInput["layout"],
): Result<ExportIdentity, AnalysisError> => {
const sha = execution.subject?.sha256 ?? null;
if (sha !== null && !digestSchema.safeParse(sha).success)
return err(
new AnalysisOutputError(
"list_procedures",
"provider returned a target digest that is not a SHA-256",
),
);
const profile = execution.analysisProfile?.digest ?? null;
if (profile !== null && !digestSchema.safeParse(profile).success)
return err(
new AnalysisOutputError(
"list_procedures",
"provider returned an analysis profile digest that is not a SHA-256",
),
);
const provider = execution.provider;
if (provider.id.length === 0 || provider.name.length === 0)
return err(
new AnalysisOutputError(
"list_procedures",
"provider identity is incomplete",
),
);
if (provider.version !== null && provider.version.length === 0)
return err(
new AnalysisOutputError(
"list_procedures",
"provider identity is incomplete",
),
);
return ok({
targetSha256: sha,
provider: {
id: provider.id,
name: provider.name,
version: provider.version,
},
analysisProfileDigest: profile,
layout,
});
};
interface PublishInput {
readonly analysis: AnalysisOperationPort;
readonly session: DecompileExport;
readonly procedures: readonly SelectedProcedure[];
readonly identity: ExportIdentity;
readonly signal: AbortSignal | undefined;
readonly limit: number | undefined;
readonly state: PublishState;
}
const publishSelected = async (
input: PublishInput,
): Promise<Result<void, AnalysisError>> => {
let bodySupport: "unknown" | "unavailable" = "unknown";
for (const procedure of input.procedures) {
if (isAborted(input.signal)) return err(cancelled(input.signal));
const existing = await existingFile(
input.session,
procedure.address,
input.signal,
);
if (isAborted(input.signal)) return err(cancelled(input.signal));
if (existing === "skip") {
input.state.skipped += 1;
// A file that reads back and verifies shows the directory works.
input.state.outputIoStreak = 0;
// The file verified again after a later failure, so the journal and
// index report it as written instead of keeping the stale failure.
const written = input.session.lastWritten(procedure.address);
if (
written !== undefined &&
input.session.record(procedure.address) !== written
) {
const appended = await input.session.append(written);
if (!appended.ok) return appended;
}
continue;
}
// The journal header pins the target, provider and profile, so a
// recorded external classification still holds. Asking again would only
// spend the limit, and a limit below the external count would leave
// functions remaining on every resume.
// A conflicting or unreadable file at its reserved path is still reported
// below rather than hidden by the skip.
if (
existing === "write" &&
input.session.record(procedure.address)?.status === "external"
) {
input.state.skipped += 1;
continue;
}
if (input.limit !== undefined && input.state.attempted >= input.limit)
continue;
input.state.attempted += 1;
input.state.lastFailureCode = undefined;
const outcome = await attemptOne(input, procedure, existing, bodySupport);
bodySupport = outcome.bodySupport;
if (!outcome.result.ok) return outcome.result;
input.state.outputIoStreak =
input.state.lastFailureCode === "output_io"
? input.state.outputIoStreak + 1
: 0;
// Repeated write failures mean the directory has stopped accepting files,
// for example a full disk. Stop instead of decompiling every remaining
// function only to fail its write. Committed files and records are kept.
if (input.state.outputIoStreak >= OUTPUT_IO_STREAK_LIMIT)
return err(outputStoppedAccepting(input.state));
}
return ok(undefined);
};
/** One function this call takes on: report a file problem or decompile it. */
const attemptOne = async (
input: PublishInput,
procedure: SelectedProcedure,
existing: "write" | "conflict" | "io",
bodySupport: "unknown" | "unavailable",
): Promise<{
readonly result: Result<void, AnalysisError>;
readonly bodySupport: "unknown" | "unavailable";
}> => {
if (existing === "conflict") {
const reason = {
code: "output_exists",
message:
"The function file already exists and does not match this export.",
};
recordFailure(input.state, procedure, reason);
const appended = await input.session.append(
failureRecord(
procedure,
retainedBody(input.session, procedure.address),
reason,
{ relativePath: input.session.pathFor(procedure.address) },
),
);
return { result: appended, bodySupport };
}
if (existing === "io") {
const reason = {
code: "output_io",
message: "The existing function file could not be read.",
};
recordFailure(input.state, procedure, reason);
const appended = await input.session.append(
failureRecord(
procedure,
retainedBody(input.session, procedure.address),
reason,
{ relativePath: input.session.pathFor(procedure.address) },
),
);
return { result: appended, bodySupport };
}
return publishOne({
...input,
procedure,
bodySupport,
});
};
const publishOne = async (input: {
readonly analysis: AnalysisOperationPort;
readonly session: DecompileExport;
readonly procedure: SelectedProcedure;
readonly identity: ExportIdentity;
readonly signal: AbortSignal | undefined;
readonly state: PublishState;
readonly bodySupport: "unknown" | "unavailable";
}): Promise<{
readonly result: Result<void, AnalysisError>;
readonly bodySupport: "unknown" | "unavailable";
}> => {
const body = await readBody(
input.analysis,
input.procedure.address,
input.identity,
input.signal,
input.bodySupport,
);
if (body.kind === "cancelled")
return { result: err(body.error), bodySupport: input.bodySupport };
if (body.kind === "unavailable") input.state.bodyUnavailable = true;
// Caveats of the observations this attempt used. The journal record keeps
// the complete list, written or failed, so the bounded Evidence summary and
// the length-capped file header never hold the only copy.
const infoCaveats = body.kind === "body" ? (body.limitations ?? []) : [];
// procedure_info classifies the function as external, so this target holds
// no code of its own to decompile. The record keeps the provider's body
// fact as observed; it is reported as external, not as a failure.
if (body.kind === "body" && body.external === true) {
input.state.external += 1;
return {
result: await input.session.append(
externalRecord(input.procedure, body.fact, infoCaveats),
),
bodySupport: body.support,
};
}
const decompiled = await input.analysis.execute(
"procedure_pseudo_code",
{ procedure: input.procedure.address },
executionOptions(input.signal),
);
if (isAborted(input.signal))
return { result: err(cancelled(input.signal)), bodySupport: body.support };
if (!decompiled.ok) {
if (decompiled.error instanceof AnalysisCancelledError)
return { result: decompiled, bodySupport: body.support };
const reason = failureReason(decompiled.error);
recordFailure(input.state, input.procedure, reason);
return {
result: await input.session.append(
failureRecord(input.procedure, body.fact, reason, {
limitations: infoCaveats,
}),
),
bodySupport: body.support,
};
}
const mismatch = observationMismatch(input.identity, decompiled.value);
if (mismatch !== undefined) {
const reason = mismatch;
recordFailure(input.state, input.procedure, reason);
return {
result: await input.session.append(
failureRecord(input.procedure, body.fact, reason, {
limitations: infoCaveats,
}),
),
bodySupport: body.support,
};
}
const caveats = [
...new Set([...infoCaveats, ...decompiled.value.limitations]),
];
const pseudocode = decompiled.value.result;
if (typeof pseudocode !== "string" || pseudocode.length === 0) {
const reason = pseudocodeFailure(pseudocode);
recordFailure(input.state, input.procedure, reason);
return {
result: await input.session.append(
failureRecord(input.procedure, body.fact, reason, {
limitations: caveats,
}),
),
bodySupport: body.support,
};
}
const relativePath = input.session.pathFor(input.procedure.address);
const bytes = Buffer.from(
functionFileText({
procedure: input.procedure,
body: body.fact,
identity: input.identity,
limitations: caveats,
pseudocode,
}),
"utf8",
);
const committed = await input.session.commitFunctionFile(relativePath, bytes);
if (committed.status === "conflict" || committed.status === "io") {
const reason =
committed.status === "conflict"
? {
code: "output_exists",
message:
"The function file already exists and does not match this export.",
}
: {
code: "output_io",
message: "The function file could not be written.",
};
recordFailure(input.state, input.procedure, reason);
return {
result: await input.session.append(
failureRecord(input.procedure, body.fact, reason, {
relativePath,
limitations: caveats,
}),
),
bodySupport: body.support,
};
}
const appended = await input.session.append({
address: input.procedure.address,
name: input.procedure.name,
status: "written",
relative_path: relativePath,
sha256: committed.sha256,
body: bodyRecord(body.fact),
// The header may truncate; the journal keeps the complete list.
...(caveats.length === 0 ? {} : { limitations: caveats }),
});
if (!appended.ok) return { result: appended, bodySupport: body.support };
input.state.written += committed.status === "written" ? 1 : 0;
input.state.skipped += committed.status === "matched" ? 1 : 0;
return { result: ok(undefined), bodySupport: body.support };
};
const readBody = async (
analysis: AnalysisOperationPort,
address: string,
identity: ExportIdentity,
signal: AbortSignal | undefined,
support: "unknown" | "unavailable",
): Promise<
| {
readonly kind: "body";
readonly fact: FunctionBody;
readonly support: "unknown" | "unavailable";
/**
* Caveats of a procedure_info observation for this export's target and
* profile. An observation for another target or profile is foreign and
* contributes none.
*/
readonly limitations?: readonly string[];
/**
* procedure_info for this export classifies the function as external.
* Absent when the classification is unknown or not external.
*/
readonly external?: true;
}
| {
readonly kind: "unavailable";
readonly fact: FunctionBody;
readonly support: "unavailable";
}
| { readonly kind: "cancelled"; readonly error: AnalysisCancelledError }
> => {
if (support === "unavailable")
return {
kind: "unavailable",
fact: unavailableBody(BODY_UNAVAILABLE),
support,
};
const observed = await analysis.execute(
"procedure_info",
{ procedure: address },
executionOptions(signal),
);
if (!observed.ok) {
if (observed.error instanceof AnalysisCancelledError)
return { kind: "cancelled", error: observed.error };
if (observed.error instanceof AnalysisCapabilityUnavailableError)
return {
kind: "unavailable",
fact: unavailableBody(BODY_UNAVAILABLE),
support: "unavailable",
};
return {
kind: "body",
fact: unavailableBody(failureReason(observed.error).message),
support,
};
}
const mismatch = observationMismatch(identity, observed.value);
if (mismatch !== undefined)
return {
kind: "body",
fact: unavailableBody(`procedure_info: ${mismatch.message}`),
support,
};
const parsed = procedureInfoSchema.safeParse(observed.value.result);
if (!parsed.success)
return {
kind: "body",
fact: unavailableBody("procedure_info returned an invalid body."),
support,
limitations: observed.value.limitations,
};
if (!sameAddress(parsed.data.entrypoint, address))
return {
kind: "body",
fact: unavailableBody(
"procedure_info described a different entry point than this function.",
),
support,
limitations: observed.value.limitations,
};
const external =
parsed.data.classification?.external === true
? { external: true as const }
: {};
if (!parsed.data.body.available)
return {
kind: "body",
fact: unavailableBody(parsed.data.body.reason),
support,
limitations: observed.value.limitations,
...external,
};
return {
kind: "body",
fact: {
available: true,
ranges: parsed.data.body.ranges,
non_contiguous: parsed.data.body.non_contiguous,
},
support,
limitations: observed.value.limitations,
...external,
};
};
type ExistingFile = "skip" | "write" | "conflict" | "io";
const existingFile = async (
session: DecompileExport,
address: string,
signal: AbortSignal | undefined,
): Promise<ExistingFile> => {
const record = session.lastWritten(address);
if (
record === undefined ||
record.sha256 === undefined ||
record.relative_path === undefined
)
return "write";
const digest = await session
.hashFile(record.relative_path, signal)
.catch((cause: unknown) => cause);
if (typeof digest === "string")
return digest === record.sha256 ? "skip" : "conflict";
return nodeAbsent(digest) ? "write" : "io";
};
const PROFILE_CHANGED: FailureReason = {
code: "profile_changed",
message:
"The decompiler observation does not match this export's provider or analysis profile.",
};
const TARGET_CHANGED: FailureReason = {
code: "target_changed",
message:
"The decompiler observation names a different target than this export.",
};
/** Why a per-function observation cannot join this export, if it cannot. */
const observationMismatch = (
identity: ExportIdentity,
execution: AnalysisExecution,
): FailureReason | undefined => {
const provider = execution.provider;
if (
provider.id !== identity.provider.id ||
provider.name !== identity.provider.name ||
provider.version !== identity.provider.version
)
return PROFILE_CHANGED;
// A missing profile is unknown, not a match for a committed one.
if (
(execution.analysisProfile?.digest ?? null) !==
identity.analysisProfileDigest
)
return PROFILE_CHANGED;
// A per-function observation may omit its subject; one that names a
// different target is not this export's target.
const target = execution.subject?.sha256;
return target === undefined || target === identity.targetSha256
? undefined
: TARGET_CHANGED;
};
/** Same canonical address; numeric when both parse, otherwise exact text. */
const sameAddress = (left: string, right: string): boolean => {
const a = addressCoordinate(left);
const b = addressCoordinate(right);
return a === null || b === null
? left === right
: a.space === b.space && a.offset === b.offset;
};
const failureReason = (error: AnalysisError): FailureReason => {
if (error instanceof AnalysisTimeoutError)
return { code: "deadline", message: "The decompiler deadline elapsed." };
const projected = projectAnalysisError(error);
const message = projected.message.replace(/\s+/gu, " ").trim().slice(0, 300);
return {
code: projected.code.slice(0, 80),
message: message.length === 0 ? projected.code : message,
};
};
const outputStoppedAccepting = (state: PublishState): AnalysisOutputError =>
new AnalysisOutputError(
"decompile_all",
"output directory stopped accepting function files",
{
userMessage: `Stopped after ${OUTPUT_IO_STREAK_LIMIT.toString()} function files in a row could not be written or read, for example because the disk is full or permissions changed. Files already written are kept and recorded in ${DECOMPILE_DIRECTORY_INDEX_NAME}. Fix the output directory, then resume.`,
remediationAction:
"Free space or restore write access in the output directory, then rerun with resume.",
diagnostics: {
attempted: state.attempted,
written: state.written,
failed: state.failed,
external: state.external,
skipped: state.skipped,
consecutive_output_io: state.outputIoStreak,
last_failures: state.recentFailures.map((failure) => ({ ...failure })),
last_failures_omitted:
Math.min(state.failed, OUTPUT_IO_STREAK_LIMIT) -
state.recentFailures.length,
},
},
);
const recordFailure = (
state: PublishState,
procedure: SelectedProcedure,
reason: FailureReason,
): void => {
state.failed += 1;
state.lastFailureCode = reason.code;
const failure = {
address: procedure.address,
name: procedure.name,
code: reason.code,
message: reason.message,
};
if (state.failures.length < DECOMPILE_DIRECTORY_INLINE_FAILURE_LIMIT) {
const comma = state.failures.length === 0 ? 0 : 1;
const fitted = fitInlineFailure(
failure,
DECOMPILE_DIRECTORY_INLINE_FAILURE_BYTES -
state.inlineFailureBytes -
comma,
);
if (fitted !== undefined) {
state.failures.push(fitted.row);
state.inlineFailureBytes += comma + fitted.bytes;
}
}
const recent = fitInlineFailure(
failure,
DECOMPILE_DIRECTORY_INLINE_FAILURE_BYTES - 2,
);
// An oversized latest row cannot leave an older example labelled as recent.
if (recent === undefined) state.recentFailures.length = 0;
else state.recentFailures.push(recent.row);
while (
state.recentFailures.length > OUTPUT_IO_STREAK_LIMIT ||
jsonValueByteLength(
state.recentFailures,
DECOMPILE_DIRECTORY_INLINE_FAILURE_BYTES,
) > DECOMPILE_DIRECTORY_INLINE_FAILURE_BYTES
)
state.recentFailures.shift();
};
const fitInlineFailure = (
failure: NamedFailure,
budget: number,
): { readonly row: InlineFailure; readonly bytes: number } | undefined => {
const bytes = jsonValueByteLength(failure, budget);
if (bytes <= budget) return { row: failure, bytes };
// Name is display context; address remains the complete lookup identity.
const { name: _name, ...withoutName } = failure;
const compactBytes = jsonValueByteLength(withoutName, budget);
return compactBytes <= budget
? { row: withoutName, bytes: compactBytes }
: undefined;
};
const failureRecord = (
procedure: SelectedProcedure,
body: FunctionBody,
reason: FailureReason,
options: {
readonly relativePath?: string;
/** Caveats of the observations this attempt used. */
readonly limitations?: readonly string[];
} = {},
): DecompileDirectoryFunctionRecord => ({
address: procedure.address,
name: procedure.name,
status: "failed",
body: bodyRecord(body),
failure: reason,
...(options.relativePath === undefined
? {}
: { relative_path: options.relativePath }),
...(options.limitations === undefined || options.limitations.length === 0
? {}
: { limitations: [...options.limitations] }),
});
/** Why a successful pseudocode observation still produced no file. */
const pseudocodeFailure = (pseudocode: unknown): FailureReason => {
if (pseudocode === null)
return {
code: "no_pseudocode",
message: "The provider returned null pseudocode for this function.",
};
return typeof pseudocode === "string"
? {
code: "empty_pseudocode",
message: "The provider returned no pseudocode.",
}
: {
code: "invalid_pseudocode",
message: "The provider returned pseudocode that is not text.",
};
};
const externalRecord = (
procedure: SelectedProcedure,
body: FunctionBody,
limitations: readonly string[],
): DecompileDirectoryFunctionRecord => ({
address: procedure.address,
name: procedure.name,
status: "external",
body: bodyRecord(body),
...(limitations.length === 0 ? {} : { limitations: [...limitations] }),
});
const unavailableBody = (reason: string): FunctionBody => ({
available: false,
reason: reason.length === 0 ? "unavailable" : reason,
});
/** Keep the journal's body fact when a conflicting file is left unchanged. */
const retainedBody = (
session: DecompileExport,
address: string,
): FunctionBody => {
return session.record(address)?.body ?? unavailableBody("not rewritten");
};
const exportLimitations = (
layout: DecompileDirectoryInput["layout"],
identity: ExportIdentity,
collapsedDuplicates: boolean,
bodyUnavailable: boolean,
unaddressed: number,
): readonly string[] => [
...(layout === "dwarf" ? [DWARF_LAYOUT_UNAVAILABLE] : []),
...(identity.targetSha256 === null ? [TARGET_DIGEST_UNAVAILABLE] : []),
...(identity.analysisProfileDigest === null ? [PROFILE_UNAVAILABLE] : []),
...(collapsedDuplicates ? [DUPLICATE_ADDRESSES] : []),
...(unaddressed > 0 ? [unaddressedRows(unaddressed)] : []),
...(bodyUnavailable ? [BODY_UNAVAILABLE] : []),
];
const layoutStatus = (
layout: DecompileDirectoryInput["layout"],
): "address" | "debug_layout_unavailable" =>
layout === "dwarf" ? "debug_layout_unavailable" : "address";
const manifestOf = (input: {
readonly root: string;
readonly input: DecompileDirectoryInput;
readonly identity: ExportIdentity;
readonly selected: number;
readonly excluded: number;
readonly state: PublishState;
readonly limitations: readonly string[];
}) => {
const requested =
input.state.written + input.state.failed + input.state.external;
const shown = input.state.failures.slice(
0,
DECOMPILE_DIRECTORY_INLINE_FAILURE_LIMIT,
);
return {
output_directory: input.root,
index_path: join(input.root, DECOMPILE_DIRECTORY_INDEX_NAME),
layout: input.input.layout,
layout_status: layoutStatus(input.input.layout),
counts: {
selected: input.selected,
excluded: input.excluded,
requested,
written: input.state.written,
failed: input.state.failed,
external: input.state.external,
skipped: input.state.skipped,
remaining: input.selected - input.state.skipped - requested,
},
failures: shown,
failures_omitted: input.state.failed - shown.length,
provider: input.identity.provider,
analysis_profile_digest: input.identity.analysisProfileDigest,
target_sha256: input.identity.targetSha256,
limitations: [...input.limitations],
resume: input.input.resume,
};
};
const executionOptions = (signal: AbortSignal | undefined) => ({
...(signal === undefined ? {} : { signal }),
selectorOrigin: "provider" as const,
});
const cancelled = (signal: AbortSignal | undefined): AnalysisCancelledError =>
new AnalysisCancelledError("decompile_all", { cause: signal?.reason });
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
const nodeAbsent = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";
@@ -0,0 +1,262 @@
import {
link,
lstat,
mkdtemp,
open,
rename,
rmdir,
unlink,
type FileHandle,
} from "node:fs/promises";
import { join } from "node:path";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { DECOMPILE_DIRECTORY_JOURNAL_NAME } from "../../contracts/decompileDirectory.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../../domain/result.js";
import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../../filesystem/OwnedStagedOutput.js";
import {
hashStableFile,
stableFileHashCause,
} from "../../filesystem/StableFileHash.js";
import type { OwnedLease } from "../../process/OwnedLease.js";
import type { RetainedResourceScope } from "../AnalysisProvider.js";
/** Own every descriptor and staging area until cleanup, then release the directory lease. */
export class ExportResources {
readonly #files = new ArtifactResourceScope();
readonly #cleanup: AnalysisCleanupLease;
#removeRoot = false;
#journalIdentity: FileIdentity | undefined;
#rootRemoved = false;
#retained = false;
#functionsRemoved = false;
#journalRemoved = false;
#leaseReleased = false;
#functionsIdentity: FileIdentity | undefined;
constructor(
readonly root: string,
private readonly identity: FileIdentity,
private readonly lease: OwnedLease,
private readonly scope: RetainedResourceScope | undefined,
) {
const resources = this;
this.#cleanup = AnalysisCleanupLease.forCloseOwner(
{
close: async () => {
await this.#files.retryCleanup();
if (this.#removeRoot && !this.#rootRemoved)
await this.#removeCreatedRoot();
await this.lease.release();
this.#leaseReleased = true;
},
get cleanupResources() {
return resources.cleanupResources;
},
},
root,
);
}
get cleanupResources(): readonly string[] {
return [
...this.#files.cleanupResources,
...(this.#removeRoot && !this.#rootRemoved ? [this.root] : []),
...(this.#leaseReleased ? [] : [this.lease.socketPath ?? this.root]),
];
}
ownFunctionsDirectory(identity: FileIdentity): void {
this.#functionsIdentity = identity;
}
ownFile(handle: FileHandle, path: string, journal = false) {
const file = new OwnedFileHandle(handle);
const owner = {
kind: "lease" as const,
resource: path,
lease: {
release: async () => {
if (journal && handle.fd >= 0)
this.#journalIdentity = await handle
.stat({ bigint: true })
.catch(() => undefined);
await file.close();
},
},
};
this.#files.retain(owner);
return async (): Promise<boolean> =>
(await this.#files.release(owner)).kind === "released";
}
/** Check existing output with bounded memory and the shared file identity owner. */
async hashFile(path: string, signal?: AbortSignal): Promise<string> {
try {
const hash = await hashStableFile(path, {
...(signal === undefined ? {} : { signal }),
retainFailedClose: (handle) =>
this.#files.retain({ kind: "file-handle", resource: path, handle }),
});
return hash.sha256;
} catch (cause: unknown) {
throw stableFileHashCause(cause);
}
}
/** Publish only a verified staged file; failed close or removal stays owned. */
async publish(
bytes: string | Buffer,
destination: string,
mode: "link" | "rename",
): Promise<void> {
const directory = await mkdtemp(join(this.root, ".decompile-stage-"));
const staging = await OwnedStagedOutput.create(directory);
const owner = {
kind: "staged-output" as const,
resource: directory,
output: staging,
};
this.#files.retain(owner);
try {
const path = join(directory, "content");
const handle = await open(path, "wx", 0o600);
staging.setFile(new OwnedFileHandle(handle));
await handle.writeFile(bytes);
await handle.sync();
await staging.closeFile();
await staging.assertReadyForPublication(path);
if (mode === "link") await link(path, destination);
else {
await rename(path, destination);
staging.recordPublishedRename();
}
} finally {
const released = await this.#files.release(owner);
if (released.kind === "failed") throw released.cause;
}
}
/** Keep the lease through all initialization cleanup, including any retry. */
async failedInitialization(
error: AnalysisError,
): Promise<Result<never, AnalysisError>> {
this.#removeRoot = true;
const closed = await this.close();
return closed.ok
? err(error)
: err(
analysisErrorWithCleanupFailure(
error,
closed.error,
"decompile_all",
).bindCleanupDependencies([closed.error]),
);
}
async close(): Promise<Result<void, AnalysisError>> {
try {
await this.#cleanup.release();
return ok(undefined);
} catch (cause: unknown) {
const error = new AnalysisOutputError(
"decompile_all",
"decompile export cleanup failed",
{
cause,
cleanup: {
reason: "Owned export resources could not be released.",
resources: [this.root],
},
},
);
error.bindCleanupResourceOwner(this.#cleanup);
this.#cleanup.bindCleanupError(error);
if (!this.#retained && this.scope !== undefined) {
this.#retained = true;
const cleanup = this.#cleanup;
this.scope.retain(
{
close: () => cleanup.release(),
get cleanupResources() {
return cleanup.cleanupResources;
},
},
this.root,
);
}
return err(error);
}
}
async #removeCreatedRoot(): Promise<void> {
const root = await lstat(this.root, { bigint: true });
if (
!sameIdentity(root, this.identity) ||
!root.isDirectory() ||
root.isSymbolicLink()
)
throw new Error("Export directory identity changed during cleanup");
// Initialization has not exported functions. Refuse unexpected entries rather
// than recursively deleting work introduced by another owner.
if (!this.#journalRemoved) {
const journal = join(this.root, DECOMPILE_DIRECTORY_JOURNAL_NAME);
const stat = await lstat(journal, { bigint: true }).catch(
(cause: unknown) => {
if (isMissing(cause)) return undefined;
throw cause;
},
);
if (stat !== undefined) {
if (
this.#journalIdentity === undefined ||
!sameIdentity(stat, this.#journalIdentity)
)
throw new Error(
"Cannot establish ownership of the journal during cleanup",
);
await unlink(journal);
}
this.#journalRemoved = true;
}
if (!this.#functionsRemoved) {
const path = join(this.root, "functions");
const stat = await lstat(path, { bigint: true }).catch(
(cause: unknown) => {
if (isMissing(cause) && this.#functionsIdentity === undefined)
return undefined;
throw cause;
},
);
if (stat !== undefined) {
if (
this.#functionsIdentity === undefined ||
!sameIdentity(stat, this.#functionsIdentity)
)
throw new Error(
"Functions directory identity changed during cleanup",
);
await rmdir(path);
}
this.#functionsRemoved = true;
}
await rmdir(this.root);
this.#rootRemoved = true;
}
}
interface FileIdentity {
readonly dev: bigint;
readonly ino: bigint;
}
const sameIdentity = (left: FileIdentity, right: FileIdentity): boolean =>
left.dev === right.dev && left.ino === right.ino;
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
@@ -0,0 +1,970 @@
import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import {
chmod,
lstat,
mkdir,
open,
realpath,
rmdir,
type FileHandle,
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import {
DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY,
DECOMPILE_DIRECTORY_INDEX_NAME,
DECOMPILE_DIRECTORY_JOURNAL_NAME,
decompileDirectoryIndexSchema,
decompileJournalHeaderSchema,
decompileJournalLineSchema,
isDecompileFunctionRelativePath,
type DecompileDirectoryFunctionRecord,
type DecompileJournalHeader,
} from "../../contracts/decompileDirectory.js";
import {
AnalysisOutputError,
AnalysisResourceConstraintError,
type AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { isPathWithinRoot } from "../../domain/localPath.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
acquireOwnedLease,
type LeaseFailure,
type OwnedLease,
} from "../../process/OwnedLease.js";
import type { RetainedResourceScope } from "../AnalysisProvider.js";
import { ExportResources } from "./exportResources.js";
import { decompileInputError } from "./inputError.js";
import { compareProcedureAddresses } from "./selection.js";
export interface ExportIdentity {
readonly targetSha256: string | null;
readonly provider: DecompileJournalHeader["provider"];
readonly analysisProfileDigest: string | null;
readonly layout: DecompileJournalHeader["layout"];
}
export type OutputPlacement =
| { readonly mode: "create"; readonly root: string }
| { readonly mode: "resume"; readonly root: string };
/** Refuse a bad path before provider work. This does not create a directory. */
export const classifyOutputPath = async (
requested: string,
resume: boolean,
): Promise<Result<OutputPlacement, AnalysisError>> => {
const stripped = requested.replace(/\/+$/u, "");
const name = basename(stripped);
if (stripped.length === 0 || name === "" || name === "." || name === "..")
return err(
decompileInputError(
"output_directory",
"The output directory name is invalid.",
),
);
const listed = await lstat(stripped).then(
(stat) => stat,
(cause: unknown) => cause,
);
if (!isDirectoryStat(listed)) {
if (resume && nodeCode(listed) === "ENOENT")
return err(
decompileInputError(
"output_directory",
"Resume requires an existing export directory. Omit resume to start a new export.",
),
);
return missingOrRejected(stripped, listed);
}
if (!resume)
return err(
decompileInputError(
"output_directory",
"Output directory already exists. Pass resume to continue an export that matches this target and analysis profile.",
),
);
const root = await realpath(stripped).catch((cause: unknown) => cause);
return typeof root === "string"
? ok({ mode: "resume", root })
: err(
decompileInputError(
"output_directory",
"The output directory could not be resolved.",
),
);
};
/** Owns one export directory and its append-only journal. */
export class DecompileExport {
private constructor(
readonly root: string,
private readonly journal: FileHandle,
private readonly records: Map<string, DecompileDirectoryFunctionRecord>,
private readonly written: Map<string, DecompileDirectoryFunctionRecord>,
private readonly paths: PathReservations,
private readonly identity: ExportIdentity,
private readonly resources: ExportResources,
) {}
static open(input: {
readonly placement: OutputPlacement;
readonly identity: ExportIdentity;
readonly resourceScope?: RetainedResourceScope | undefined;
}): Promise<Result<DecompileExport, AnalysisError>> {
return input.placement.mode === "create"
? DecompileExport.#create(
input.placement.root,
input.identity,
input.resourceScope,
)
: DecompileExport.#resume(
input.placement.root,
input.identity,
input.resourceScope,
);
}
/** The latest journal outcome for an address. */
record(address: string): DecompileDirectoryFunctionRecord | undefined {
return this.records.get(address);
}
/**
* The last successful write for an address. A later failure, such as a
* transient read error, does not erase the checksum that verifies the file.
*/
lastWritten(address: string): DecompileDirectoryFunctionRecord | undefined {
return this.written.get(address);
}
/**
* Stable relative path for an address. The first path the journal gave this
* address wins, even when a later failure record names no path.
*/
pathFor(address: string): string {
const owned = this.paths.byAddress.get(address);
if (owned !== undefined) return owned;
const relativePath = allocateFunctionPath(
address,
this.paths.byPortablePath,
);
reservePath(this.paths, relativePath, address);
return relativePath;
}
async append(
record: DecompileDirectoryFunctionRecord,
): Promise<Result<void, AnalysisError>> {
const line = decompileJournalLineSchema.safeParse({
kind: "function",
...record,
});
if (!line.success || line.data.kind !== "function")
return err(
new AnalysisOutputError(
"decompile_all",
"decompile journal record is invalid",
),
);
const written = await this.journal
.writeFile(`${JSON.stringify(line.data)}\n`)
.then(() => this.journal.sync())
.then(
() => true,
() => false,
);
if (!written)
return err(
new AnalysisOutputError(
"decompile_all",
"decompile journal could not be updated",
),
);
// The journal schema owns the parsed snapshot, including nested fields;
// the caller may have changed its input while the append was pending.
const { kind: _kind, ...committed } = line.data;
this.records.set(committed.address, committed);
if (committed.status === "written")
this.written.set(committed.address, committed);
if (committed.relative_path !== undefined)
reservePath(this.paths, committed.relative_path, committed.address);
return ok(undefined);
}
async writeIndex(input: {
readonly layoutStatus: "address" | "debug_layout_unavailable";
readonly limitations: readonly string[];
}): Promise<Result<void, AnalysisError>> {
// Appends update this owner only after journal sync; resume parses once.
// Reuse those committed facts instead of re-reading and reinterpreting them.
const index = decompileDirectoryIndexSchema.safeParse({
version: 1,
target_sha256: this.identity.targetSha256,
provider: this.identity.provider,
analysis_profile_digest: this.identity.analysisProfileDigest,
layout: this.identity.layout,
layout_status: input.layoutStatus,
limitations: [...input.limitations],
functions: [...this.records.values()].sort((left, right) =>
compareProcedureAddresses(left.address, right.address),
),
});
if (!index.success)
return err(
new AnalysisOutputError(
"decompile_all",
"decompile index could not be built",
),
);
return writeOwnedIndex(
this.root,
`${JSON.stringify(index.data, null, 2)}\n`,
this.resources,
);
}
commitFunctionFile(relativePath: string, bytes: Buffer): Promise<FileCommit> {
return commitFunctionFile(this.root, relativePath, bytes, this.resources);
}
hashFile(relativePath: string, signal?: AbortSignal): Promise<string> {
return this.resources.hashFile(join(this.root, relativePath), signal);
}
/** Failed cleanup retains the same files and lease for a later session retry. */
close(): Promise<Result<void, AnalysisError>> {
return this.resources.close();
}
static async #create(
root: string,
identity: ExportIdentity,
scope: RetainedResourceScope | undefined,
): Promise<Result<DecompileExport, AnalysisError>> {
const created = await createPrivateDirectory(root);
if (!created.ok) return created;
// The directory exists before its journal, so a resume that starts in
// between takes the same lease and cannot write alongside this export.
const lease = await acquireExportLease(root);
// Even an empty directory may now have another lease holder preparing its
// journal. Without the lease, preserve it rather than disrupting that run.
if (!lease.ok) {
lease.error.retainDiagnostics({ created_output_directory: root });
return lease;
}
const resources = new ExportResources(
root,
lease.value.identity,
lease.value.lease,
scope,
);
const functions = await prepareFunctionsDirectory(root, resources);
if (!functions.ok) return resources.failedInitialization(functions.error);
const opened = await seedJournal(root, identity, resources);
if (!opened.ok) return resources.failedInitialization(opened.error);
return ok(
new DecompileExport(
root,
opened.value,
new Map(),
new Map(),
{ byPortablePath: new Map(), byAddress: new Map() },
identity,
resources,
),
);
}
static async #resume(
root: string,
identity: ExportIdentity,
scope: RetainedResourceScope | undefined,
): Promise<Result<DecompileExport, AnalysisError>> {
// The lease is held before the journal is read, so no other run appends
// between this read and this run's own appends, repair or index.
const lease = await acquireExportLease(root);
if (!lease.ok) return lease;
const resources = new ExportResources(
root,
lease.value.identity,
lease.value.lease,
scope,
);
const opened = await openResumedJournal(root, identity, resources);
if (!opened.ok) {
const closed = await resources.close();
return closed.ok
? opened
: err(
analysisErrorWithCleanupFailure(
opened.error,
closed.error,
"decompile_all",
).bindCleanupDependencies([closed.error]),
);
}
return ok(
new DecompileExport(
root,
opened.value.handle,
opened.value.journal.records,
opened.value.journal.written,
opened.value.journal.paths,
identity,
resources,
),
);
}
}
const openResumedJournal = async (
root: string,
identity: ExportIdentity,
resources: ExportResources,
): Promise<
Result<
{ readonly handle: FileHandle; readonly journal: OpenedJournal },
AnalysisError
>
> => {
const opened = await readJournal(root, identity, resources);
if (!opened.ok) return opened;
const functions = await prepareFunctionsDirectory(root, resources);
if (!functions.ok) return functions;
const handle = await openJournal(root, "a");
if (!handle.ok) return handle;
resources.ownFile(
handle.value,
join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME),
true,
);
// Drop a torn final line left by a crash so the next append starts a line.
const repaired =
opened.value.committedBytes === opened.value.journalBytes ||
(await handle.value.truncate(opened.value.committedBytes).then(
() => true,
() => false,
));
if (!repaired) {
return err(
decompileInputError(
"output_directory",
"The decompile journal could not be repaired after an interrupted write.",
),
);
}
return ok({ handle: handle.value, journal: opened.value });
};
export type FileCommit =
| { readonly status: "written"; readonly sha256: string }
| { readonly status: "matched"; readonly sha256: string }
| { readonly status: "conflict" }
| { readonly status: "io" };
/**
* Publish bytes with `link`, which fails when the final name exists.
* A matching existing file is adopted. A different file is left unchanged.
*/
const commitFunctionFile = async (
root: string,
relativePath: string,
bytes: Buffer,
resources: ExportResources,
): Promise<FileCommit> => {
if (!isDecompileFunctionRelativePath(relativePath)) return { status: "io" };
const finalPath = join(root, relativePath);
if (!isPathWithinRoot(root, finalPath)) return { status: "io" };
const digest = sha256(bytes);
const linked = await resources.publish(bytes, finalPath, "link").then(
() => true,
(cause: unknown) => cause,
);
if (linked === true) return { status: "written", sha256: digest };
if (nodeCode(linked) !== "EEXIST") return { status: "io" };
const existing = await resources
.hashFile(finalPath)
.catch((cause: unknown) => cause);
if (typeof existing !== "string") return { status: "io" };
return existing === digest
? { status: "matched", sha256: digest }
: { status: "conflict" };
};
const missingOrRejected = async (
stripped: string,
listed: unknown,
): Promise<Result<OutputPlacement, AnalysisError>> => {
if (isSymlinkStat(listed))
return err(
decompileInputError(
"output_directory",
"The output directory must be a real directory, not a symlink.",
),
);
if (isStat(listed))
return err(
decompileInputError(
"output_directory",
"The output path exists and is not a directory.",
),
);
if (nodeCode(listed) !== "ENOENT")
return err(
decompileInputError(
"output_directory",
"The output directory could not be inspected.",
),
);
const parent = await realpath(dirname(stripped)).catch(
(cause: unknown) => cause,
);
if (typeof parent !== "string")
return err(
decompileInputError(
"output_directory",
"The output directory's parent does not exist.",
),
);
return ok({ mode: "create", root: join(parent, basename(stripped)) });
};
const createPrivateDirectory = async (
root: string,
): Promise<Result<void, AnalysisError>> => {
const created = await mkdir(root, { mode: 0o700 }).catch(
(cause: unknown) => cause,
);
if (created !== undefined)
return err(
nodeCode(created) === "EEXIST"
? decompileInputError(
"output_directory",
"Output directory already exists. Pass resume to continue an export that matches this target and analysis profile.",
)
: decompileInputError(
"output_directory",
"The output directory could not be created.",
),
);
// This call created the directory, so it owns removing it when the
// directory cannot be made private. Leaving it would block a fresh export
// and a resume alike, because no journal exists yet.
const secured = await chmod(root, 0o700).catch((cause: unknown) => cause);
if (secured === undefined) return ok(undefined);
// The directory is still empty, so a non-recursive removal fails closed on
// a path that another process filled meanwhile or replaced with a file or
// symlink.
const removed = await rmdir(root).then(
() => true,
() => false,
);
return err(
decompileInputError(
"output_directory",
removed
? "The output directory could not be secured."
: "The output directory could not be secured, and the directory this call created could not be removed. Remove it before retrying.",
),
);
};
const prepareFunctionsDirectory = async (
root: string,
resources: ExportResources,
): Promise<Result<void, AnalysisError>> => {
const directory = join(root, DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY);
const created = await mkdir(directory, { mode: 0o700 }).catch(
(cause: unknown) => cause,
);
if (created !== undefined && nodeCode(created) !== "EEXIST")
return err(
decompileInputError(
"output_directory",
"The functions directory could not be created.",
),
);
const stat = await lstat(directory, { bigint: true }).catch(
(cause: unknown) => cause,
);
if (!isDirectoryStat(stat) || !hasFileIdentity(stat))
return err(
decompileInputError(
"output_directory",
"The functions directory is not a real directory.",
),
);
resources.ownFunctionsDirectory(stat);
const secured = await chmod(directory, 0o700).catch(
(cause: unknown) => cause,
);
return secured === undefined
? ok(undefined)
: err(
decompileInputError(
"output_directory",
"The functions directory could not be secured.",
),
);
};
const seedJournal = async (
root: string,
identity: ExportIdentity,
resources: ExportResources,
): Promise<Result<FileHandle, AnalysisError>> => {
const header = decompileJournalHeaderSchema.safeParse({
kind: "header",
version: 1,
target_sha256: identity.targetSha256,
provider: identity.provider,
analysis_profile_digest: identity.analysisProfileDigest,
layout: identity.layout,
});
if (!header.success)
return err(
new AnalysisOutputError(
"decompile_all",
"decompile journal header is invalid",
),
);
const opened = await openJournal(root, "wx");
if (!opened.ok) return opened;
resources.ownFile(
opened.value,
join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME),
true,
);
const seeded = await opened.value
.writeFile(`${JSON.stringify(header.data)}\n`)
.then(() => opened.value.sync())
.then(() => chmod(join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME), 0o600))
.then(
() => true,
() => false,
);
if (seeded) return opened;
return err(
decompileInputError(
"output_directory",
"The decompile journal could not be created.",
),
);
};
/** Absent on Windows, where the journal's lstat check is the only guard. */
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
const JOURNAL_FLAGS = {
wx: constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW,
a: constants.O_WRONLY | constants.O_APPEND | NO_FOLLOW,
} as const;
/** The journal is never opened through a symlink: repair truncates it. */
const openJournal = async (
root: string,
flag: "wx" | "a",
): Promise<Result<FileHandle, AnalysisError>> => {
const handle = await open(
join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME),
JOURNAL_FLAGS[flag],
0o600,
).catch((cause: unknown) => cause);
return isFileHandle(handle)
? ok(handle)
: err(
decompileInputError(
"output_directory",
"The decompile journal could not be opened.",
),
);
};
interface OpenedJournal {
readonly records: Map<string, DecompileDirectoryFunctionRecord>;
readonly written: Map<string, DecompileDirectoryFunctionRecord>;
readonly paths: PathReservations;
/** Byte length of the newline-terminated lines. */
readonly committedBytes: number;
/** Byte length of the journal file as read. */
readonly journalBytes: number;
}
/** Read the journal only when it is a regular file, not a symlink. */
const readJournalBytes = async (
root: string,
resources: ExportResources,
): Promise<unknown> => {
const path = join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME);
const stat = await lstat(path).catch((cause: unknown) => cause);
if (!isStat(stat)) return stat;
if (!stat.isFile()) return new Error("journal is not a regular file");
const handle = await open(path, constants.O_RDONLY | NO_FOLLOW).catch(
(cause: unknown) => cause,
);
if (!isFileHandle(handle)) return handle;
const close = resources.ownFile(handle, path);
const bytes = await handle.readFile().catch((cause: unknown) => cause);
return (await close())
? bytes
: new Error("decompile journal read handle could not be closed");
};
const readJournal = async (
root: string,
identity: ExportIdentity,
resources: ExportResources,
): Promise<Result<OpenedJournal, AnalysisError>> => {
const bytes = await readJournalBytes(root, resources);
if (!(bytes instanceof Buffer))
return err(
nodeCode(bytes) === "ENOENT"
? decompileInputError(
"output_directory",
"Resume requires decompile-journal.jsonl in the output directory. An export stopped before its journal was written has no functions to keep; choose a new output directory, or remove this one if it is yours.",
)
: decompileInputError(
"output_directory",
"The decompile journal could not be read.",
),
);
const parsed = parseJournal(bytes);
if (!parsed.ok) return parsed;
const unknown = unknownIdentity(parsed.value.header, identity);
if (unknown !== undefined) return err(unknown);
return sameIdentity(parsed.value.header, identity)
? ok({
records: parsed.value.records,
written: parsed.value.written,
paths: parsed.value.paths,
committedBytes: parsed.value.committedBytes,
journalBytes: parsed.value.journalBytes,
})
: err(
decompileInputError(
"output_directory",
"The decompile journal does not match this target, provider, analysis profile, or layout.",
),
);
};
const parseJournal = (
bytes: Buffer,
): Result<
OpenedJournal & { readonly header: DecompileJournalHeader },
AnalysisError
> => {
// Every committed line ends with a newline. Bytes after the last newline are
// an interrupted append and are not part of the journal. Offsets are bytes,
// so damaged UTF-8 cannot move the truncation point.
const committedBytes = bytes.lastIndexOf(0x0a) + 1;
const lines = bytes.subarray(0, committedBytes).toString("utf8").split("\n");
lines.pop();
const header = decompileJournalHeaderSchema.safeParse(parseJson(lines[0]));
if (!header.success) return err(incompatibleJournal());
const records = new Map<string, DecompileDirectoryFunctionRecord>();
const written = new Map<string, DecompileDirectoryFunctionRecord>();
// A path stays reserved by the first address that used it, even when that
// address's latest record no longer names it.
const paths: PathReservations = {
byPortablePath: new Map(),
byAddress: new Map(),
};
for (const line of lines.slice(1)) {
const parsed = decompileJournalLineSchema.safeParse(parseJson(line));
if (!parsed.success || parsed.data.kind !== "function")
return err(incompatibleJournal());
const { kind: _kind, ...record } = parsed.data;
const path = record.relative_path;
if (path !== undefined) {
const owner = paths.byPortablePath.get(portablePathKey(path));
if (owner !== undefined && owner !== record.address)
return err(incompatibleJournal());
reservePath(paths, path, record.address);
}
records.set(record.address, record);
if (record.status === "written") written.set(record.address, record);
}
return ok({
header: header.data,
records,
written,
paths,
committedBytes,
journalBytes: bytes.length,
});
};
/** Resume needs both identities known. Two unknown digests are not a match. */
const unknownIdentity = (
header: DecompileJournalHeader,
identity: ExportIdentity,
): AnalysisInputError | undefined => {
if (header.target_sha256 === null || identity.targetSha256 === null)
return decompileInputError(
"output_directory",
"Resume requires an observed target SHA-256 for both the journal and this target.",
);
if (
header.analysis_profile_digest === null ||
identity.analysisProfileDigest === null
)
return decompileInputError(
"output_directory",
"Resume requires an analysis profile digest for both the journal and this provider.",
);
return undefined;
};
const sameIdentity = (
header: DecompileJournalHeader,
identity: ExportIdentity,
): boolean =>
header.target_sha256 === identity.targetSha256 &&
header.analysis_profile_digest === identity.analysisProfileDigest &&
header.layout === identity.layout &&
header.provider.id === identity.provider.id &&
header.provider.name === identity.provider.name &&
header.provider.version === identity.provider.version;
const incompatibleJournal = (): AnalysisInputError =>
decompileInputError(
"output_directory",
"The decompile journal is not a compatible export.",
);
const parseJson = (line: string | undefined): unknown => {
if (line === undefined) return undefined;
try {
return JSON.parse(line) as unknown;
} catch {
return undefined;
}
};
/** Function file paths: each path has one owning address, each address keeps its first path. */
interface PathReservations {
readonly byPortablePath: Map<string, string>;
readonly byAddress: Map<string, string>;
}
/** Generated and admitted function paths are ASCII; fold only their lookup key. */
const portablePathKey = (path: string): string => path.toLowerCase();
const reservePath = (
paths: PathReservations,
path: string,
address: string,
): void => {
const key = portablePathKey(path);
if (!paths.byPortablePath.has(key)) paths.byPortablePath.set(key, address);
if (!paths.byAddress.has(address)) paths.byAddress.set(address, path);
};
const allocateFunctionPath = (
address: string,
used: ReadonlyMap<string, string>,
): string => {
const base = sanitizeAddress(address);
const directory = DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY;
const plain = `${directory}/${base}.c`;
if (!used.has(portablePathKey(plain))) return plain;
const suffix = createHash("sha256").update(address).digest("hex").slice(0, 8);
let candidate = `${directory}/${base}-${suffix}.c`;
let discriminator = 0;
// Terminates: `used` is finite.
while (used.has(portablePathKey(candidate))) {
discriminator += 1;
candidate = `${directory}/${base}-${suffix}-${discriminator.toString(16)}.c`;
}
return candidate;
};
const sanitizeAddress = (address: string): string => {
const cleaned = address
.replace(/[^A-Za-z0-9._-]+/gu, "_")
.replace(/^\.+/u, "")
.slice(0, 120);
return cleaned.length === 0 ? "function" : cleaned;
};
/** Private per-user parent of export lease sockets; it outlives individual runs. */
const DECOMPILE_EXPORT_LEASE_DIRECTORY = join(
"/tmp",
`rea-decompile-${(process.getuid?.() ?? 0).toString()}`,
);
/**
* Hold the output directory for this run. The lease names the directory by
* device and inode, so two spellings of one directory share it, and it ends
* when this process exits, so a crashed run leaves no lock behind.
*/
const acquireExportLease = async (
root: string,
): Promise<
Result<
{
readonly lease: OwnedLease;
readonly identity: { readonly dev: bigint; readonly ino: bigint };
},
AnalysisError
>
> => {
const stat = await lstat(root, { bigint: true }).catch(
(cause: unknown) => cause,
);
if (!isDirectoryStat(stat) || !hasFileIdentity(stat))
return err(
decompileInputError(
"output_directory",
"The output directory could not be inspected.",
),
);
try {
const acquisition = await acquireOwnedLease({
identity: {
operation: "decompile_all",
device: stat.dev.toString(),
inode: stat.ino.toString(),
},
runId: randomUUID(),
directory: DECOMPILE_EXPORT_LEASE_DIRECTORY,
failure: exportLeaseFailure,
});
return acquisition.acquired
? ok({ lease: acquisition.lease, identity: stat })
: err(
new AnalysisResourceConstraintError(
"decompile_all",
"capacity",
`another decompile_all run (process ${acquisition.owner.processId.toString()}) is using this output directory`,
{ holder_process_id: acquisition.owner.processId },
{
retryable: true,
remediationAction:
"Wait for the other decompile_all run on this output directory to finish, then retry with resume.",
},
),
);
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new AnalysisOutputError(
"decompile_all",
"output directory lease could not be acquired",
{ cause },
),
);
}
};
const exportLeaseFailure = (
failure: LeaseFailure,
cause?: unknown,
): AnalysisOutputError =>
new AnalysisOutputError(
"decompile_all",
"output directory lease could not be acquired",
{
...(cause === undefined ? {} : { cause }),
userMessage: exportLeaseMessage(failure),
},
);
const exportLeaseMessage = (failure: LeaseFailure): string => {
switch (failure.kind) {
case "owner_unverified":
return `Another process holds this output directory's lease but did not identify itself (${failure.reason}). Retry after it finishes.`;
case "recovery_busy":
return `Another run is recovering this output directory's lease at ${JSON.stringify(failure.path)}. Retry shortly. If no REA process is running, remove that directory.`;
case "recovery_remove":
return `REA could not remove its lease recovery directory at ${JSON.stringify(failure.path)}. Check its write access before retrying.`;
case "unrecoverable":
return "REA could not take this output directory's lease. Retry after other decompile_all runs finish.";
case "directory_access_denied":
return `REA could not access its lease directory at ${JSON.stringify(failure.directory)}. Restore owner-only permissions (0700) on a directory you own, then retry.`;
case "directory_create":
case "directory_not_private":
case "directory_permissions":
case "directory_inspect":
case "recovery_create":
return `REA could not use its private lease directory ${JSON.stringify(DECOMPILE_EXPORT_LEASE_DIRECTORY)}. Check that it is a directory owned by this user with permissions 0700.`;
}
};
const hasFileIdentity = (
value: unknown,
): value is { readonly dev: bigint; readonly ino: bigint } =>
typeof value === "object" &&
value !== null &&
"dev" in value &&
"ino" in value &&
typeof value.dev === "bigint" &&
typeof value.ino === "bigint";
const writeOwnedIndex = async (
root: string,
text: string,
resources: ExportResources,
): Promise<Result<void, AnalysisError>> => {
try {
await resources.publish(
text,
join(root, DECOMPILE_DIRECTORY_INDEX_NAME),
"rename",
);
return ok(undefined);
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
"decompile_all",
"decompile index could not be written",
{ cause },
),
);
}
};
export const sha256 = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
const isFileHandle = (value: unknown): value is FileHandle =>
typeof value === "object" &&
value !== null &&
"write" in value &&
"sync" in value &&
"close" in value;
const isDirectoryStat = (value: unknown): boolean =>
isStat(value) && value.isDirectory() && !value.isSymbolicLink();
const isSymlinkStat = (value: unknown): boolean =>
isStat(value) && value.isSymbolicLink();
const isStat = (
value: unknown,
): value is {
isDirectory: () => boolean;
isFile: () => boolean;
isSymbolicLink: () => boolean;
} =>
typeof value === "object" &&
value !== null &&
"isDirectory" in value &&
"isFile" in value &&
"isSymbolicLink" in value &&
typeof value.isDirectory === "function" &&
typeof value.isSymbolicLink === "function";
const nodeCode = (cause: unknown): string | undefined =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
typeof cause.code === "string"
? cause.code
: undefined;
@@ -0,0 +1,85 @@
import type { DecompileDirectoryFunctionRecord } from "../../contracts/decompileDirectory.js";
import type { ExportIdentity } from "./exportTree.js";
export interface FunctionBodyFact {
readonly available: false;
readonly reason: string;
}
export interface FunctionBodyRanges {
readonly available: true;
readonly ranges: readonly { readonly start: string; readonly end: string }[];
readonly non_contiguous: boolean;
}
export type FunctionBody = FunctionBodyFact | FunctionBodyRanges;
/** Line-comment header plus the provider text. A block comment would close early inside pseudocode. */
export const functionFileText = (input: {
readonly procedure: {
readonly address: string;
readonly name: string | null;
};
readonly body: FunctionBody;
readonly identity: ExportIdentity;
readonly limitations: readonly string[];
readonly pseudocode: string;
}): string => {
const lines = [
"// rea decompile_all",
`// name: ${headerValue(input.procedure.name ?? "")}`,
`// address: ${headerValue(input.procedure.address)}`,
...bodyLines(input.body),
`// provider: ${headerValue(providerLine(input.identity))}`,
`// analysis_profile: ${input.identity.analysisProfileDigest ?? "unavailable"}`,
...limitationLines(input.limitations),
"// pseudocode: provider-specific; not original source",
];
return `${lines.join("\n")}\n\n${input.pseudocode}`;
};
export const bodyRecord = (
body: FunctionBody,
): DecompileDirectoryFunctionRecord["body"] =>
body.available
? {
available: true,
ranges: body.ranges.map((range) => ({
start: range.start,
end: range.end,
})),
non_contiguous: body.non_contiguous,
}
: { available: false, reason: body.reason };
const bodyLines = (body: FunctionBody): readonly string[] =>
body.available
? [
"// body: available",
...body.ranges.map(
(range) =>
`// range: ${headerValue(range.start)}-${headerValue(range.end)} inclusive`,
),
`// non_contiguous: ${body.non_contiguous ? "true" : "false"}`,
]
: ["// body: unavailable", `// body_reason: ${headerValue(body.reason)}`];
const limitationLines = (limitations: readonly string[]): readonly string[] => {
const joined = limitations
.map((limitation) => headerValue(limitation))
.filter((limitation) => limitation.length > 0)
.join("; ");
if (joined.length <= 4_000)
return [`// limitations: ${joined.length === 0 ? "none" : joined}`];
return [
`// limitations: ${joined.slice(0, 4_000)}`,
"// limitations_truncated: true; decompile-index.json keeps the complete list",
];
};
const providerLine = (identity: ExportIdentity): string =>
`${identity.provider.name} ${identity.provider.version ?? "unknown"} (${identity.provider.id})`;
/** Keep a provider string on one header line without control characters. */
const headerValue = (value: string): string =>
value.replace(/[\u0000-\u001f\u007f]/gu, " ").slice(0, 2_000);
@@ -0,0 +1,10 @@
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
/** Caller-facing rejection for one decompile-directory argument. */
export const decompileInputError = (
path: string,
message: string,
): AnalysisInputError =>
new AnalysisInputError("decompile_all", { userMessage: message }, [
{ path: [path], reason: "invalid_value", message },
]);
@@ -0,0 +1,169 @@
import type { DecompileDirectoryInput } from "../../contracts/decompileDirectory.js";
import { addressCoordinate } from "../../domain/native/addressCoordinate.js";
import { nativeAddressCoordinateInputSchema } from "../../domain/native/nativeAddress.js";
import { err, ok, type Result } from "../../domain/result.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { decompileInputError } from "./inputError.js";
export interface SelectedProcedure {
readonly address: string;
readonly name: string | null;
}
interface AddressBound {
readonly space: string | null;
readonly offset: bigint;
}
export interface DecompileBounds {
readonly start: AddressBound | null;
readonly end: AddressBound | null;
}
/** Reject address bounds that cannot be compared inside one address space. */
export const validateDecompileBounds = (
input: DecompileDirectoryInput,
): Result<DecompileBounds, AnalysisError> => {
const start = parseBound(input.address_start, "address_start");
if (!start.ok) return start;
const end = parseBound(input.address_end, "address_end");
if (!end.ok) return end;
if (
start.value !== null &&
end.value !== null &&
start.value.space !== end.value.space
)
return err(
decompileInputError(
"address_end",
"Address bounds must use the same address space.",
),
);
if (
start.value !== null &&
end.value !== null &&
end.value.offset < start.value.offset
)
return err(
decompileInputError(
"address_end",
"Address end is before address start.",
),
);
return ok({ start: start.value, end: end.value });
};
export interface ProcedureSelection {
readonly procedures: readonly SelectedProcedure[];
readonly excluded: number;
readonly collapsedDuplicates: boolean;
}
/** Filter and stably order unique inventory rows. Duplicate addresses collapse. */
export const selectProcedures = (
rows: readonly SelectedProcedure[],
input: DecompileDirectoryInput,
bounds: DecompileBounds,
): ProcedureSelection => {
const seen = new Set<string>();
const selected = new Map<string, SelectedProcedure>();
let collapsedDuplicates = false;
for (const row of rows) {
if (seen.has(row.address)) collapsedDuplicates = true;
seen.add(row.address);
// Every observed label participates in caller filtering before one output
// is chosen for the address. An earlier non-match cannot hide an alias.
if (!selected.has(row.address) && matches(row, input, bounds))
selected.set(row.address, row);
}
const procedures = [...selected.values()].sort(compareProcedures);
return {
procedures,
excluded: seen.size - procedures.length,
collapsedDuplicates,
};
};
const parseBound = (
value: string | undefined,
path: "address_start" | "address_end",
): Result<AddressBound | null, AnalysisError> => {
if (value === undefined) return ok(null);
const coordinate = nativeAddressCoordinateInputSchema.safeParse(value);
return !coordinate.success
? err(
decompileInputError(
path,
"Address bounds must be 0xoffset or space:0xoffset.",
),
)
: ok(coordinate.data);
};
const matches = (
procedure: SelectedProcedure,
input: DecompileDirectoryInput,
bounds: DecompileBounds,
): boolean =>
matchesName(procedure.name, input.name) &&
matchesNamespace(procedure.name, input.namespace) &&
matchesAddress(procedure.address, bounds);
const matchesName = (
name: string | null,
filter: string | undefined,
): boolean => filter === undefined || (name !== null && name.includes(filter));
const matchesNamespace = (
name: string | null,
filter: string | undefined,
): boolean =>
filter === undefined || (name !== null && name.startsWith(filter));
const matchesAddress = (address: string, bounds: DecompileBounds): boolean => {
if (bounds.start === null && bounds.end === null) return true;
const coordinate = addressCoordinate(address);
if (coordinate === null) return false;
if (
bounds.start !== null &&
(coordinate.space !== bounds.start.space ||
coordinate.offset < bounds.start.offset)
)
return false;
return (
bounds.end === null ||
(coordinate.space === bounds.end.space &&
coordinate.offset <= bounds.end.offset)
);
};
const compareProcedures = (
left: SelectedProcedure,
right: SelectedProcedure,
): number => {
const byAddress = compareProcedureAddresses(left.address, right.address);
if (byAddress !== 0) return byAddress;
const byName = compareUnicodeCodePoints(left.name ?? "", right.name ?? "");
return byName === 0
? compareUnicodeCodePoints(left.address, right.address)
: byName;
};
/** Numeric order within an address space; unparsed addresses sort last. */
export const compareProcedureAddresses = (
left: string,
right: string,
): number => {
const a = addressCoordinate(left);
const b = addressCoordinate(right);
if (a === null || b === null) {
if (a === null && b === null) return compareUnicodeCodePoints(left, right);
return a === null ? 1 : -1;
}
const space = compareUnicodeCodePoints(a.space ?? "", b.space ?? "");
if (space !== 0) return space;
if (a.offset < b.offset) return -1;
if (a.offset > b.offset) return 1;
return 0;
};
@@ -8,6 +8,11 @@ import {
environmentStateServeFixture,
environmentStateServeInputs,
} from "../../../tests/fixtures/environmentServe.js";
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
import {
book,
bookClock,
} from "../../../tests/fixtures/environmentStateWrites.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import {
REPLAY_BODY_LIMIT_BYTES,
@@ -21,6 +26,8 @@ import { EnvironmentStateStore } from "./environmentStateStore.js";
const grafana = environmentStateServeInputs();
const bk = (id: number, name: string) => book(id, name, bookClock(id));
const servingOf = (
inputs: typeof grafana = grafana,
): StateServing & { readonly store: EnvironmentStateStore } => ({
@@ -184,6 +191,61 @@ describe("item answers", () => {
});
});
it("answers a state item whose route has a repeated path separator", () => {
const serving = servingFrom([
["GET", "/api/books/1", { id: 1, title: "Single separator" }],
["GET", "/api//books/1", { id: 1, title: "Repeated separator" }],
]);
const itemAnswer = (template: string) => {
const tool = serving.source.manifest.tools.find(
(candidate) => candidate.path_template === template,
);
if (tool === undefined) throw new Error(`Fixture lacks ${template}`);
const result = answerFromState(serving, tool, { id: "1" });
if (!result.ok) throw result.error;
return result.value;
};
// The repeated separator route is answered from the collection it was
// extracted under, instead of reporting its own entity as missing.
expect(itemAnswer("/api//books/{numeric}")).toMatchObject({
body: { title: "Repeated separator" },
state: {
source: "state",
view: "item",
collection: "/api//books",
id: "1",
},
});
// The single separator route stays distinct.
expect(itemAnswer("/api/books/{numeric}")).toMatchObject({
body: { title: "Single separator" },
state: { source: "state", collection: "/api/books", id: "1" },
});
});
it("creates into a collection whose route has a repeated path separator", () => {
const serving = servedFrom([
["GET", "/api//books", [bk(1, "One"), bk(2, "Two")]],
["GET", "/api//books/1", bk(1, "One")],
["GET", "/api//books/2", bk(2, "Two")],
["POST", "/api//books", bk(3, "Recorded"), 201, { name: "Recorded" }],
["GET", "/api//books/3", bk(3, "Recorded")],
["GET", "/api//books", [bk(1, "One"), bk(2, "Two"), bk(3, "Recorded")]],
]);
const post = serving.source.manifest.tools.find(
(tool) => tool.method === "POST",
);
if (post === undefined) throw new Error("Fixture lacks a create tool");
// A generated create (an unrecorded body) builds from state and must not be
// refused for the empty literal segment in /api//books.
const result = serving.call(post.name, { body: { name: "New" } });
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
status: 201,
state: { source: "state", view: "create", collection: "/api//books" },
});
});
it("refuses an entity the state does not hold or one missing a field the view requires", () => {
const serving = servingOf();
expect(refusal(serving, "get_users_by_id", { id: "999" })).toMatchObject({
@@ -28,8 +28,8 @@ export const callSegments = (
const names = environmentToolArguments(tool).path;
const params = new Map(tool.params.map((param) => [param.index, param]));
const segments: SuppliedSegment[] = [];
const template = segmentsOf(tool.path_template);
for (const [index, text] of template.entries()) {
// Use the collection's path parser so repeated separators remain meaningful.
for (const [index, text] of segmentsOf(tool.path_template).entries()) {
const param = params.get(index);
if (param === undefined) {
segments.push({ text, supplied: false });
@@ -31,7 +31,12 @@ export const createdCollection = (
type: string,
): string | undefined => {
const segments = callSegments(tool, args);
if (segments === undefined || segments.some(({ text }) => text.length === 0))
// A supplied path argument may not be empty; an empty literal segment, from a
// repeated separator in the route, is part of the collection's spelling.
if (
segments === undefined ||
segments.some(({ text, supplied }) => supplied && text.length === 0)
)
return undefined;
// The spelling the call supplies verbatim wins, as item calls choose their entity, then the first known: held ones lead.
return selectedCollection(segments, creatableCollectionsOf(serving, type));
+2 -9
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { constants, type BigIntStats } from "node:fs";
import { lstat, open, type FileHandle } from "node:fs/promises";
import { sameRegularFileState } from "../filesystem/RegularFile.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import type { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
@@ -136,15 +137,7 @@ export const readStableArtifact = async (
fileSystem.lstat(path),
);
if (
[after, current].some(
(stat) =>
!stat.isFile() ||
stat.dev !== initial.dev ||
stat.ino !== initial.ino ||
stat.size !== initial.size ||
stat.mtimeNs !== initial.mtimeNs ||
stat.ctimeNs !== initial.ctimeNs,
) ||
[after, current].some((stat) => !sameRegularFileState(initial, stat)) ||
BigInt(count) !== initial.size
)
throw new ArtifactReaderFailure(
@@ -10,6 +10,7 @@ import type {
} from "../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { browserCleanupObservation } from "../domain/browserObservationError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
@@ -105,20 +106,6 @@ export const browserScenarioCaptureData = (input: {
};
};
/** Describe the cleanup failure without discarding its resource evidence. */
export const browserScenarioCleanupObservation = (
cause: unknown,
): { readonly reason: string; readonly resources: readonly string[] } =>
cause instanceof AnalysisError && cause.cleanup !== undefined
? cause.cleanup
: {
reason: cause instanceof Error ? cause.message : String(cause),
resources:
cause instanceof AnalysisError && cause.cleanupResources.length > 0
? cause.cleanupResources
: ["browser_transport"],
};
/** Retain operation diagnostics and capture data after cleanup settles. */
export const browserScenarioOperationFailure = (
cause: unknown,
@@ -135,7 +122,7 @@ export const browserScenarioOperationFailure = (
);
primary.retainPartialObservation(partialObservation);
if (cleanupFailure === undefined) return primary;
const observation = browserScenarioCleanupObservation(cleanupFailure.cause);
const observation = browserCleanupObservation(cleanupFailure.cause);
const cleanup =
cleanupFailure.cause instanceof AnalysisError &&
cleanupFailure.cause.cleanupIncomplete
@@ -35,7 +35,10 @@ import {
} from "../domain/browserScenarioCaptureValues.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import {
BrowserObservationError,
browserCleanupObservation,
} from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import type {
@@ -47,7 +50,6 @@ import {
type ScenarioActionStop,
} from "./ScenarioActionSequence.js";
import {
browserScenarioCleanupObservation,
browserScenarioOperationFailure,
browserScenarioCaptureData,
} from "./BrowserScenarioPartialObservation.js";
@@ -303,7 +305,7 @@ const runScenario = async (
try {
cleanupOutcome = { ok: true, cleanup: await session.close() };
} catch (cause: unknown) {
const cleanup = browserScenarioCleanupObservation(cause);
const cleanup = browserCleanupObservation(cause);
const failure = new BrowserObservationError(OPERATION, "cleanup_failed", {
cause,
cleanup,
@@ -320,7 +322,7 @@ const runScenario = async (
cause: options.signal.reason,
}),
};
const cleanup = browserScenarioCleanupObservation(cleanupOutcome.cause);
const cleanup = browserCleanupObservation(cleanupOutcome.cause);
const partialObservation: BrowserScenarioPartialObservation = {
kind: "browser-scenario-observation",
capture: browserScenarioCaptureData({
+2
View File
@@ -18,6 +18,7 @@ import { withCommandCancellation } from "./commandCancellation.js";
import { javascriptApplicationOptions } from "../cliObservationOptions.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { registerDecompileDirectoryCommand } from "./decompileDirectoryCommand.js";
/** Register provider-neutral binary overview and procedure CLI commands. */
export const registerCoreBinaryCommands = (
@@ -28,6 +29,7 @@ export const registerCoreBinaryCommands = (
): void => {
registerOverviewCommands(cli, logger, runDirectAnalysis, environment);
registerDecompileCommand(cli, logger, runDirectAnalysis);
registerDecompileDirectoryCommand(cli, logger, runDirectAnalysis);
registerFunctionCommand(cli, logger, runDirectAnalysis);
registerInstructionsCommand(cli, logger, runDirectAnalysis);
registerSearchCommand(cli, logger, runDirectAnalysis);
+119
View File
@@ -0,0 +1,119 @@
import { z } from "incur";
import type { DirectAnalysis } from "../composition/directAnalysis.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import {
directAnalysisOptionsFromCli,
nativeOpenOptionFields,
} from "./options.js";
import type { CliInstance } from "./types.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Register `rea decompile-all`, the directory export of provider pseudocode. */
export const registerDecompileDirectoryCommand = (
cli: CliInstance,
logger: Logger,
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.decompileAll, {
description:
"Write every function's pseudocode into a directory and return a manifest",
args: z.object({
path: z.string().describe("App or program path"),
}),
options: z.object({
output: z
.string()
.min(1)
.describe(
"Output directory. A relative path is resolved from the working directory. The parent must exist.",
),
layout: z
.enum(["address", "dwarf"])
.default("address")
.describe(
"address names files by function address. dwarf reports that compilation-unit paths are unavailable.",
),
resume: z
.boolean()
.default(false)
.describe(
"Continue a matching export. Never overwrites a function file.",
),
name: z
.string()
.min(1)
.optional()
.describe("Case-sensitive literal substring of the function name"),
namespace: z
.string()
.min(1)
.optional()
.describe(
"Case-sensitive function-name prefix, including any separator",
),
addressStart: z
.string()
.min(1)
.optional()
.describe("Inclusive lower bound, 0xoffset or space:0xoffset"),
addressEnd: z
.string()
.min(1)
.optional()
.describe("Inclusive upper bound, 0xoffset or space:0xoffset"),
limit: z
.number()
.int()
.min(1)
.optional()
.describe(
"Maximum functions to attempt after skipping verified files and recorded external functions",
),
snapshot: z
.string()
.min(1)
.optional()
.describe(
"Load a local analysis snapshot. decompile-all does not update it.",
),
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "decompile-all", () =>
runDirectAnalysis(
args.path,
"decompile_all",
decompileAllArguments(options),
directAnalysisOptionsFromCli(logger, options),
),
),
});
};
const decompileAllArguments = (options: {
readonly output: string;
readonly layout: "address" | "dwarf";
readonly resume: boolean;
readonly name?: string | undefined;
readonly namespace?: string | undefined;
readonly addressStart?: string | undefined;
readonly addressEnd?: string | undefined;
readonly limit?: number | undefined;
}): Readonly<Record<string, JsonValue>> => ({
output_directory: absoluteCallerPath(options.output),
layout: options.layout,
resume: options.resume,
...(options.name === undefined ? {} : { name: options.name }),
...(options.namespace === undefined ? {} : { namespace: options.namespace }),
...(options.addressStart === undefined
? {}
: { address_start: options.addressStart }),
...(options.addressEnd === undefined
? {}
: { address_end: options.addressEnd }),
...(options.limit === undefined ? {} : { limit: options.limit }),
});
+5 -17
View File
@@ -6,9 +6,11 @@ import { withCommandCancellation } from "./cli/commandCancellation.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js";
import { logCliCommand } from "./cliLogging.js";
import { AnalysisError } from "./domain/analysisErrorBase.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
import { BrowserObservationError } from "./domain/browserObservationError.js";
import {
BrowserObservationError,
browserCleanupObservation,
} from "./domain/browserObservationError.js";
import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js";
import { browserScenarioSchema } from "./domain/browserScenario.js";
import type { BrowserScenarioCapturePort } from "./application/BrowserScenarioCapturePort.js";
@@ -78,21 +80,7 @@ export const registerBrowserScenarioCommands = (
if (result?.ok)
throw new BrowserObservationError(OPERATION, "cleanup_failed", {
cause,
cleanup:
cause instanceof AnalysisError &&
cause.cleanup !== undefined
? cause.cleanup
: {
reason:
cause instanceof Error
? cause.message
: String(cause),
resources:
cause instanceof AnalysisError &&
cause.cleanupResources.length > 0
? cause.cleanupResources
: ["browser_transport"],
},
cleanup: browserCleanupObservation(cause),
partialObservation: result.value,
});
}
+1
View File
@@ -18,6 +18,7 @@ export const CLI_COMMANDS = Object.freeze({
analyze: "analyze",
inspect: "inspect",
decompile: "decompile",
decompileAll: "decompile-all",
setup: "setup",
doctor: "doctor",
uninstall: "uninstall",
+329
View File
@@ -0,0 +1,329 @@
import { z } from "zod";
import { digestSchema } from "../domain/digests.js";
import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
/** On-disk journal. Append-only so a crash keeps committed function files. */
export const DECOMPILE_DIRECTORY_JOURNAL_NAME = "decompile-journal.jsonl";
/** Derived catalog of the journal. It never contains pseudocode. */
export const DECOMPILE_DIRECTORY_INDEX_NAME = "decompile-index.json";
/** Directory of one `.c` file per function, named from the canonical address. */
export const DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY = "functions";
/** First supported journal version. */
export const DECOMPILE_DIRECTORY_VERSION = 1;
/** Failure reasons returned inline. The index keeps every reason. */
export const DECOMPILE_DIRECTORY_INLINE_FAILURE_LIMIT = 32;
/** JSON-encoded UTF-8 bytes for one inline failure-detail array. */
export const DECOMPILE_DIRECTORY_INLINE_FAILURE_BYTES = 2_048;
const layoutSchema = z.enum(["address", "dwarf"]);
const exportProviderSchema = z.strictObject({
id: z.string().min(1),
name: z.string().min(1),
version: z.string().min(1).nullable(),
});
const bodySchema = z.discriminatedUnion("available", [
z.strictObject({
available: z.literal(false),
reason: z.string().min(1),
}),
z.strictObject({
available: z.literal(true),
ranges: z.array(
z.strictObject({
start: z.string().min(1),
end: z
.string()
.min(1)
.describe("Inclusive last address of this body range."),
}),
),
non_contiguous: z.boolean(),
}),
]);
/**
* written: a file holds the pseudocode. failed: no file, with a reason.
* external: procedure_info classifies the function as external. No file is
* written and no pseudocode is requested; the body fact is kept as observed.
*/
const functionStatusSchema = z.enum(["written", "failed", "external"]);
const failureFields = {
address: z.string().min(1),
code: z.string().min(1),
message: z.string().min(1),
};
const failureSchema = z.union([
z.strictObject({
...failureFields,
name: z
.string()
.nullable()
.describe("Observed name, or null when unknown."),
}),
z
.strictObject(failureFields)
.describe(
"Name omitted to fit the inline text budget; the index retains the full observed name.",
),
]);
export type DecompileDirectoryInlineFailure = z.infer<typeof failureSchema>;
const countSchema = (description: string) =>
z.number().int().min(0).describe(description);
/**
* Caller input for writing pseudocode to a directory.
* The workflow parses this once at the CLI and MCP boundaries.
*/
export const decompileDirectoryInputSchema = z.strictObject({
output_directory: absoluteLocalPathStringSchema.describe(
"Absolute output directory. The parent must exist. The directory itself must be absent unless resume is true. One run at a time may use a directory; a second run is refused while the first holds it.",
),
layout: layoutSchema
.default("address")
.describe(
"address names files by canonical address. dwarf asks for compilation-unit paths and is reported unavailable when the provider has no such fact.",
),
resume: z
.boolean()
.default(false)
.describe(
"Continue a journal for the same target SHA-256, provider, analysis profile, and layout. Both digests must be known. Verified files and functions recorded as external are skipped, conflicting files are left unchanged, and functions not yet tried run before retries of recorded failures.",
),
name: z
.string()
.min(1)
.optional()
.describe(
"Case-sensitive literal substring of the provider function name.",
),
namespace: z
.string()
.min(1)
.optional()
.describe(
"Case-sensitive prefix of the provider function name, including any separator such as Foo:: or Class.",
),
address_start: z
.string()
.min(1)
.optional()
.describe(
"Inclusive lower bound as 0xoffset or space:0xoffset. Must use the same address space as address_end.",
),
address_end: z
.string()
.min(1)
.optional()
.describe(
"Inclusive upper bound as 0xoffset or space:0xoffset. Must use the same address space as address_start.",
),
limit: z
.number()
.int()
.min(1)
.optional()
.describe(
"Maximum functions to attempt on this call after skipping verified files and functions recorded as external. Further matches remain for a later resume.",
),
});
export type DecompileDirectoryInput = z.output<
typeof decompileDirectoryInputSchema
>;
/** Small tool result. Pseudocode stays in the output files. */
export const decompileDirectoryManifestSchema = z
.strictObject({
output_directory: z
.string()
.min(1)
.describe("Absolute directory containing the function files."),
index_path: z
.string()
.min(1)
.describe(
"Absolute path of the index. The index lists functions and does not contain pseudocode.",
),
layout: layoutSchema,
layout_status: z
.enum(["address", "debug_layout_unavailable"])
.describe(
"address when files are named by address on request. debug_layout_unavailable when dwarf was requested and no compilation-unit fact exists.",
),
counts: z.strictObject({
selected: countSchema("Functions matching the filter."),
excluded: countSchema("Inventory functions excluded by the filter."),
requested: countSchema("Functions this call attempted."),
written: countSchema("Function files created by this call."),
failed: countSchema("Functions this call could not write."),
external: countSchema(
"Functions this call found procedure_info classifies as external. No file is written and no pseudocode is requested. A function without a matching procedure_info classification is decompiled as usual.",
),
skipped: countSchema(
"Selected functions already settled: existing files that still hold the same bytes, whether recorded in the journal or found on disk, and functions the journal records as external.",
),
remaining: countSchema(
"Selected functions not attempted because the limit was reached.",
),
}),
failures: z
.array(failureSchema)
.max(DECOMPILE_DIRECTORY_INLINE_FAILURE_LIMIT)
.describe(
"At most 32 failure details within 2,048 JSON-encoded UTF-8 bytes. Names may be omitted to preserve addresses and reasons; the index retains complete records.",
),
failures_omitted: countSchema(
"Failure records omitted from failures because of the inline count or encoded-text budget.",
),
provider: exportProviderSchema.describe(
"Provider that produced the pseudocode.",
),
analysis_profile_digest: digestSchema
.nullable()
.describe(
"Provider analysis-profile digest, or null when the provider did not commit one.",
),
target_sha256: digestSchema
.nullable()
.describe(
"SHA-256 of the analyzed target, or null when the observation has no file subject.",
),
limitations: z
.array(z.string())
.describe("Limits of this export. Pseudocode is not included."),
resume: z.boolean().describe("Whether this call continued a journal."),
})
.superRefine((manifest, context) => {
const { counts } = manifest;
if (counts.written + counts.failed + counts.external !== counts.requested)
context.addIssue({
code: "custom",
path: ["counts", "requested"],
message: "requested must equal written plus failed plus external",
});
if (
counts.requested + counts.skipped + counts.remaining !==
counts.selected
)
context.addIssue({
code: "custom",
path: ["counts", "selected"],
message: "selected must equal requested plus skipped plus remaining",
});
if (manifest.failures.length + manifest.failures_omitted !== counts.failed)
context.addIssue({
code: "custom",
path: ["failures_omitted"],
message: "failures plus failures_omitted must equal failed",
});
});
/** One latest journal outcome, without pseudocode. */
export const decompileDirectoryFunctionSchema = z
.strictObject({
address: z.string().min(1),
name: z.string().nullable(),
status: functionStatusSchema,
relative_path: z
.string()
.min(1)
.refine(
(path) => isDecompileFunctionRelativePath(path),
"Function paths must remain inside functions/.",
)
.optional(),
sha256: digestSchema.optional(),
body: bodySchema,
failure: z
.strictObject({
code: z.string().min(1),
message: z.string().min(1),
})
.optional(),
/** Complete provider caveats of the observations this attempt used. */
limitations: z.array(z.string().min(1)).min(1).optional(),
})
.superRefine((record, context) => {
const valid =
record.status === "written"
? record.relative_path !== undefined && record.sha256 !== undefined
: record.status === "failed"
? record.failure !== undefined
: record.relative_path === undefined &&
record.sha256 === undefined &&
record.failure === undefined;
if (!valid)
context.addIssue({
code: "custom",
path: ["status"],
message: "Function record fields do not match its status.",
});
});
export type DecompileDirectoryFunctionRecord = z.infer<
typeof decompileDirectoryFunctionSchema
>;
/** Header of a decompile journal. Identity for resume. */
export const decompileJournalHeaderSchema = z.strictObject({
kind: z.literal("header"),
version: z.literal(DECOMPILE_DIRECTORY_VERSION),
target_sha256: digestSchema.nullable(),
provider: exportProviderSchema,
analysis_profile_digest: digestSchema.nullable(),
layout: layoutSchema,
});
export type DecompileJournalHeader = z.infer<
typeof decompileJournalHeaderSchema
>;
/** One appended function outcome. */
export const decompileJournalFunctionSchema =
decompileDirectoryFunctionSchema.safeExtend({
kind: z.literal("function"),
});
export const decompileJournalLineSchema = z.discriminatedUnion("kind", [
decompileJournalHeaderSchema,
decompileJournalFunctionSchema,
]);
/** Index rewritten from the journal at the end of a call. */
export const decompileDirectoryIndexSchema = z.strictObject({
version: z.literal(DECOMPILE_DIRECTORY_VERSION),
target_sha256: digestSchema.nullable(),
provider: exportProviderSchema,
analysis_profile_digest: digestSchema.nullable(),
layout: layoutSchema,
layout_status: z.enum(["address", "debug_layout_unavailable"]),
limitations: z.array(z.string()),
functions: z.array(decompileDirectoryFunctionSchema),
});
/**
* A function file path owned by this export: `functions/<safe>.c` with one
* path segment and no traversal.
*/
export const isDecompileFunctionRelativePath = (value: string): boolean => {
if (value.includes("\\") || value.includes("\0")) return false;
const [directory, name, extra] = value.split("/");
return (
extra === undefined &&
directory === DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY &&
name !== undefined &&
/^[A-Za-z0-9._-]+\.c$/u.test(name)
);
};
+2
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js";
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
import { decompileDirectoryInputSchema } from "./decompileDirectory.js";
import { nativeValueTraceInputSchema } from "../domain/native/nativeValueTrace.js";
const traceLiteralInputSchema = z.strictObject({
query: z.string().min(1),
@@ -26,6 +27,7 @@ export const enhancedInputSchemas = {
.array(z.string().describe("A provider-normalized procedure address"))
.default([]),
}),
decompile_all: decompileDirectoryInputSchema,
get_call_graph: z.strictObject({
address: z
.string()
+5
View File
@@ -46,6 +46,11 @@ export const ENHANCED_TOOL_CONTRACTS = [
"Decompile each explicit procedure symbol or address, with one provider read active per batch. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item success or error results and aggregate counts. The complete reply retains every pseudocode body; use selected small batches to bound retained output.",
enhancedInputSchemas.batch_decompile,
),
enhanced(
"decompile_all",
"Write provider pseudocode for every analyzed function, or a filtered subset, into a caller-selected directory and return only a manifest. Each file records the provider function name, canonical address, inclusive body ranges when procedure_info supplies them, and that function's procedure_info and pseudocode limitations. Each function's index record keeps its complete limitations. Evidence keeps at most eight first-observed distinct per-function limitations within an encoded-text budget; the omitted count measures observations, not distinct warnings. The manifest reports counts, the output directory, the index path, at most 32 failure details within 2,048 JSON-encoded UTF-8 bytes (names can be omitted; full names remain in the index), provider identity, and the analysis-profile digest. Pseudocode stays in the files. An existing directory is refused unless resume is set and the journal matches the target SHA-256, provider, and analysis profile; verified files are skipped and conflicting files are left unchanged. layout dwarf reports that compilation-unit paths are unavailable and names files by address. A failure of one function does not stop the others. Functions procedure_info classifies as external are counted as external, get no file, and are not decompiled; a resume skips them. Cancellation leaves files already written.",
enhancedInputSchemas.decompile_all,
),
enhanced(
"get_call_graph",
"Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG.",
+1
View File
@@ -53,6 +53,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
trace_feature: { query: "license" },
trace_call_path: { start: "0x1000", goal: "0x1100" },
trace_native_ui_action: { action: "buildTapped:" },
decompile_all: { output_directory: "/tmp/decompile-out" },
open_binary: { path: "/tmp/fixture" },
open_bundle_resources: { path: "/tmp/Fixture.app" },
export_evidence_bundle: { path: "/tmp/evidence.json" },
+5
View File
@@ -564,6 +564,11 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
get_objc_classes: evidence,
get_objc_protocols: evidence,
batch_decompile: evidence,
decompile_all: effects({
mutatesSession: true,
writesFilesystem: true,
idempotent: false,
}),
get_call_graph: evidence,
analyze_swift_types: evidence,
find_xrefs_to_name: evidence,
+7 -3
View File
@@ -47,6 +47,7 @@ import {
referenceEdgeSchema,
unresolvedCallSchema,
procedureIdentitySchema,
containingProcedureResolutionSchema,
} from "../domain/hopperValues.js";
import { nativeApiInspectionResultSchema } from "../domain/native/nativeApiBoundary.js";
import {
@@ -78,6 +79,7 @@ import { changedBehaviorResultSchema } from "../domain/changedBehavior.js";
import { callPathResultSchema } from "../domain/callPathSchemas.js";
import { staticRuntimeCorrelationResultSchema } from "../domain/staticRuntimeCorrelation.js";
import { reconstructionVerificationResultSchema } from "../domain/reconstructionVerificationSchemas.js";
import { decompileDirectoryManifestSchema } from "./decompileDirectory.js";
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
@@ -85,7 +87,6 @@ import { nativeInvestigationTraceSchema } from "../domain/native/nativeInvestiga
import {
addressList,
addressedEntry,
containingProcedureResolution,
functionDossierOutput,
graphNode,
lifecycleResultOf,
@@ -205,7 +206,9 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
}),
),
procedure_pseudo_code: evidenceResultOf(nullableText),
resolve_containing_procedure: evidenceResultOf(containingProcedureResolution),
resolve_containing_procedure: evidenceResultOf(
containingProcedureResolutionSchema,
),
search_procedures: analysisDetailOutputSchemaOf(
z.array(z.object({ address: z.string(), value: z.string() })),
nativeInventorySummaryViewResultSchema.extend({
@@ -237,7 +240,7 @@ const literalTraceOutput = evidenceResultOf(
z.object({
target_address: z.string(),
source_address: z.string(),
containing_procedure: containingProcedureResolution,
containing_procedure: containingProcedureResolutionSchema,
}),
),
truncated: z.boolean(),
@@ -307,6 +310,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
trace_native_ui_action: evidenceResultOf(nativeInvestigationTraceSchema),
get_objc_classes: objcClassDiscoveryOutput,
get_objc_protocols: symbolDiscoveryOutput("protocols"),
decompile_all: evidenceResultOf(decompileDirectoryManifestSchema),
batch_decompile: evidenceResultOf(
z.object({
items: z.array(
@@ -551,8 +551,6 @@ export const addressedEntry = z.object({
address: z.string(),
name: z.string(),
});
export { containingProcedureResolutionSchema as containingProcedureResolution } from "../domain/hopperValues.js";
const unavailable = z.object({
available: z.literal(false),
reason: z.string(),
+6 -15
View File
@@ -86,25 +86,16 @@ export const artifactManifestId = (
)}`;
/** Identify one integrity disagreement at its observed logical path. */
export const artifactContradictionId = (
input: {
readonly rootArtifactId: string;
readonly logicalPath: string;
} & (
| { readonly declaredSha256: string; readonly observedSha256: string }
| { readonly integrity: ArtifactIntegrityPair }
),
): string =>
export const artifactContradictionId = (input: {
readonly rootArtifactId: string;
readonly logicalPath: string;
readonly integrity: ArtifactIntegrityPair;
}): string =>
`ic_${digestCanonicalValue(
{
root_artifact_id: input.rootArtifactId,
logical_path: input.logicalPath,
...("integrity" in input
? artifactIntegrityPairOf(input.integrity)
: {
declared_sha256: input.declaredSha256,
observed_sha256: input.observedSha256,
}),
...artifactIntegrityPairOf(input.integrity),
},
"Artifact",
)}`;
+15
View File
@@ -4,10 +4,25 @@ import type {
} from "./browserObservationErrors.js";
import {
AnalysisError,
type AnalysisCleanupObservation,
type AnalysisErrorOptions,
} from "./analysisErrorBase.js";
import { AnalysisCancelledError } from "./analysisErrorCore.js";
/** Describe browser cleanup failure without discarding its resource evidence. */
export const browserCleanupObservation = (
cause: unknown,
): AnalysisCleanupObservation =>
cause instanceof AnalysisError && cause.cleanup !== undefined
? cause.cleanup
: {
reason: cause instanceof Error ? cause.message : String(cause),
resources:
cause instanceof AnalysisError && cause.cleanupResources.length > 0
? cause.cleanupResources
: ["browser_transport"],
};
/** A bounded passive browser observation failed at its CDP boundary. */
export class BrowserObservationError extends AnalysisError {
readonly _tag = "BrowserObservationError";
+274
View File
@@ -5,6 +5,7 @@ import { createReconstructionReadinessReport } from "./reconstructionReadiness.j
import {
reconstructionObligationLedgerClosureDigest,
reconstructionObligationLedgerStatus,
reconstructionObligationOwnershipGraph,
summarizeReconstructionObligations,
} from "./reconstructionObligationLedgerIntegrity.js";
import { reconstructionObligationLedgerSchema } from "./reconstructionObligationLedgerSchemas.js";
@@ -207,6 +208,279 @@ describe("readiness stage identity", () => {
);
});
describe("readiness closure history", () => {
it("rejects an earlier ready observation that still has required obligations open", () => {
const value = input();
value.closure_history[0]!.status = "ready";
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("fail");
expect(report.summary.passed_required_stages).toBe(8);
expect(report.findings).toEqual([
expect.objectContaining({
code: "invalid-obligation-closure-transition",
stage_id: "verify-reconstruction-closure",
status: "fail",
evidence_ids: value.closure_history[0]!.evidence_ids,
}),
]);
expect(report.snapshot.closure_history).toEqual(value.closure_history);
});
it.each(["open", "failed", "unknown"] as const)(
"checks a zero-open middle observation marked %s without treating unknown as ready",
(status) => {
const value = input();
const [initial, final] = value.closure_history;
value.closure_history = [
initial!,
{ ...final!, status, ledger_digest: "c".repeat(64) },
{ ...final!, sequence: 2, newly_verified_obligation_ids: [] },
];
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe(status === "unknown" ? "pass" : "fail");
expect(report.findings).toEqual(
status === "unknown"
? []
: [
expect.objectContaining({
code: "invalid-obligation-closure-transition",
stage_id: "verify-reconstruction-closure",
status: "fail",
evidence_ids: value.closure_history[1]!.evidence_ids,
}),
],
);
expect(report.snapshot.closure_history).toEqual(value.closure_history);
},
);
it("does not count one obligation twice in a closure observation", () => {
const value = input();
value.closure_history[0]!.required_open = 2;
value.closure_history[1]!.newly_verified_obligation_ids = [
"obl.readiness",
"obl.readiness",
];
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("fail");
expect(report.summary.passed_required_stages).toBe(8);
expect(report.findings).toContainEqual(
expect.objectContaining({
code: "invalid-obligation-closure-transition",
stage_id: "verify-reconstruction-closure",
status: "fail",
evidence_ids: value.closure_history[1]!.evidence_ids,
}),
);
expect(report.snapshot.closure_history).toEqual(value.closure_history);
});
it.each([false, true])(
"checks distinct obligations across closure observations (repeated: %s)",
(repeated) => {
const value = input();
const first = value.obligation_ledger.obligations[0]!;
const second = structuredClone(first);
second.obligation_id = "obl.second";
second.binding!.obligation_id = "obl.second";
second.binding!.verifier!.enumerated_obligation_ids = ["obl.second"];
const obligations = [first, second];
const ledger = {
...value.obligation_ledger,
obligations,
summary: summarizeReconstructionObligations(obligations),
ownership_graph: reconstructionObligationOwnershipGraph(obligations),
};
value.obligation_ledger = {
...ledger,
closure_digest: reconstructionObligationLedgerClosureDigest(ledger),
};
const [initial, final] = value.closure_history;
value.closure_history = [
{ ...initial!, required_open: 2 },
{
...initial!,
sequence: 1,
required_open: 1,
newly_verified_obligation_ids: ["obl.readiness"],
},
{
...final!,
sequence: 2,
ledger_digest: value.obligation_ledger.closure_digest,
newly_verified_obligation_ids: [
repeated ? "obl.readiness" : "obl.second",
],
},
];
// History order is determined by sequence, rather than caller array order.
value.closure_history.reverse();
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe(repeated ? "fail" : "pass");
expect(report.findings.map(({ code }) => code)).toEqual(
repeated ? ["invalid-obligation-closure-transition"] : [],
);
expect(report.snapshot.closure_history).toEqual(value.closure_history);
},
);
it("does not close a required obligation with an optional obligation's verified proof", () => {
const value = input();
const first = value.obligation_ledger.obligations[0]!;
const optional = structuredClone(first);
optional.obligation_id = "obl.optional";
optional.required = false;
optional.binding!.obligation_id = "obl.optional";
optional.binding!.verifier!.enumerated_obligation_ids = ["obl.optional"];
const obligations = [first, optional];
const ledger = {
...value.obligation_ledger,
obligations,
summary: summarizeReconstructionObligations(obligations),
ownership_graph: reconstructionObligationOwnershipGraph(obligations),
};
value.obligation_ledger = {
...ledger,
closure_digest: reconstructionObligationLedgerClosureDigest(ledger),
};
value.closure_history[1]!.ledger_digest =
value.obligation_ledger.closure_digest;
value.closure_history[1]!.newly_verified_obligation_ids = ["obl.optional"];
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("fail");
expect(report.findings.map(({ code }) => code)).toEqual([
"closure-obligation-proof-missing",
]);
});
});
describe("readiness repeated operation outcomes", () => {
it.each(["cli", "mcp"] as const)(
"checks a later conflicting %s error and preserves its Evidence",
(surface) => {
const value = input();
const original = value.operation_outcomes.find(
(outcome) =>
outcome.surface === surface && outcome.call_kind === "permission",
)!;
const later = {
...original,
sequence: 4,
error_code: "different-error",
evidence_ids: [value.evidence_bundle.records[1]!.evidence_id],
};
value.operation_outcomes.push(later);
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("fail");
expect(report.summary.passed_required_stages).toBe(8);
expect(report.findings).toEqual([
expect.objectContaining({
code: "surface-semantic-drift",
stage_id: "export-replay",
status: "fail",
evidence_ids: [
...new Set([...original.evidence_ids, ...later.evidence_ids]),
].sort(),
}),
]);
expect(report.snapshot.operation_outcomes).toEqual(
value.operation_outcomes,
);
value.operation_outcomes.reverse();
expect(createReconstructionReadinessReport(value).findings).toEqual(
report.findings,
);
},
);
it("checks later success/failure drift even when each transport status is consistent", () => {
const value = input();
value.operation_outcomes.push({
...value.operation_outcomes[2]!,
sequence: 4,
expected_success: true,
cli_exit_code: 0,
error_code: null,
});
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("fail");
expect(report.findings.map(({ code }) => code)).toEqual([
"surface-semantic-drift",
]);
});
it("accepts matched semantic variants without dropping or pairing repeated attempts", () => {
const value = input();
value.operation_outcomes.push(
{
...value.operation_outcomes[2]!,
sequence: 4,
error_code: "different-error",
},
{
...value.operation_outcomes[3]!,
sequence: 5,
error_code: "different-error",
},
{ ...value.operation_outcomes[2]!, sequence: 6 },
);
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("pass");
expect(report.findings).toEqual([]);
expect(report.snapshot.operation_outcomes).toEqual(
value.operation_outcomes,
);
});
it("keeps missing Evidence on a repeated matching outcome unknown", () => {
const value = input();
const missing = `ev_${"0".repeat(64)}`;
value.operation_outcomes.push({
...value.operation_outcomes[2]!,
sequence: 4,
evidence_ids: [missing],
});
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("unknown");
expect(report.findings).toEqual([
expect.objectContaining({
code: "referenced-evidence-missing",
stage_id: "export-replay",
status: "unknown",
}),
]);
expect(report.snapshot.operation_outcomes.at(-1)?.evidence_ids).toEqual([
missing,
]);
});
it("reports the retained Evidence for an operation missing the other surface", () => {
const value = input();
value.operation_outcomes = value.operation_outcomes.filter(
(outcome) =>
!(outcome.call_kind === "permission" && outcome.surface === "mcp"),
);
const report = createReconstructionReadinessReport(value);
expect(report.status).toBe("fail");
expect(report.findings).toEqual([
expect.objectContaining({
code: "surface-parity-missing",
evidence_ids: value.operation_outcomes[2]!.evidence_ids,
}),
]);
});
});
describe("readiness missing Evidence references", () => {
it("reports each absent Evidence ID once per stage across stage and check references", () => {
const value = input();
@@ -226,42 +226,39 @@ const evaluateClosureHistory = (
"Closure history must begin partial and end ready with zero required obligations open.",
),
);
const requiredObligations = input.obligation_ledger.obligations.filter(
(obligation) => obligation.required,
);
const verifiedRequiredIds = new Set(
requiredObligations
.filter((obligation) => obligation.status === "verified")
.map((obligation) => obligation.obligation_id),
input.obligation_ledger.obligations
.filter(({ required, status }) => required && status === "verified")
.map(({ obligation_id }) => obligation_id),
);
const newlyVerifiedIds = new Set<string>();
const previouslyVerifiedIds = new Set<string>();
for (const [index, observation] of history.entries()) {
const previous = history[index - 1];
const stepIds = new Set(observation.newly_verified_obligation_ids);
const newlyVerifiedIds = new Set(observation.newly_verified_obligation_ids);
if (
observation.required_open > requiredObligations.length ||
stepIds.size !== observation.newly_verified_obligation_ids.length ||
[...stepIds].some((id) => newlyVerifiedIds.has(id)) ||
(observation.status === "ready" && observation.required_open !== 0) ||
((observation.status === "open" || observation.status === "failed") &&
observation.required_open === 0) ||
observation.required_open > input.obligation_ledger.summary.required ||
newlyVerifiedIds.size !==
observation.newly_verified_obligation_ids.length ||
[...newlyVerifiedIds].some((id) => previouslyVerifiedIds.has(id)) ||
(previous === undefined
? stepIds.size !== 0
? newlyVerifiedIds.size !== 0
: observation.required_open > previous.required_open ||
stepIds.size !== previous.required_open - observation.required_open)
newlyVerifiedIds.size !==
previous.required_open - observation.required_open)
)
findings.push(
finding(
"invalid-obligation-closure-transition",
"verify-reconstruction-closure",
"fail",
`Closure observation ${String(observation.sequence)} must count unique newly verified required obligations within the supplied ledger.`,
`Closure observation ${String(observation.sequence)} must establish the starting count without claiming a transition, report zero required obligations open when ready, a positive open count when open or failed, and count only distinct, previously uncounted required obligations within the ledger's required count.`,
observation.evidence_ids,
),
);
for (const id of stepIds) newlyVerifiedIds.add(id);
if (
observation.newly_verified_obligation_ids.some(
(obligationId) => !verifiedRequiredIds.has(obligationId),
)
)
if ([...newlyVerifiedIds].some((id) => !verifiedRequiredIds.has(id)))
findings.push(
finding(
"closure-obligation-proof-missing",
@@ -271,6 +268,7 @@ const evaluateClosureHistory = (
observation.evidence_ids,
),
);
for (const id of newlyVerifiedIds) previouslyVerifiedIds.add(id);
addMissingEvidenceFindings(
findings,
"verify-reconstruction-closure",
@@ -352,26 +352,46 @@ const outcomeFindings = (
}
for (const outcomes of groupedOutcomes(input).values()) {
const first = outcomes[0]!;
const label = `${first.operation}:${first.call_kind}:${String(first.expected_success)}`;
const citations = outcomes.flatMap((outcome) => outcome.evidence_ids);
if (new Set(outcomes.map((outcome) => outcome.surface)).size !== 2)
const label = `${first.operation}:${first.call_kind}`;
const surfaces = new Set(outcomes.map(({ surface }) => surface));
const outcomeEvidence = outcomes.flatMap(
({ evidence_ids }) => evidence_ids,
);
if (surfaces.size !== 2) {
findings.push(
finding(
"surface-parity-missing",
"export-replay",
"fail",
`CLI/MCP outcome pair is incomplete: ${label}.`,
citations,
outcomeEvidence,
),
);
if (outcomes.some((outcome) => outcome.error_code !== first.error_code))
continue;
}
// The contract has no run-pair identity. Compare every retained semantic variant,
// allowing repeated observations without inventing a pairing from input order.
const variants = new Map<string, Set<"cli" | "mcp">>();
for (const outcome of outcomes) {
const semantic = JSON.stringify([
outcome.expected_success,
outcome.error_code,
]);
let observed = variants.get(semantic);
if (observed === undefined) {
observed = new Set();
variants.set(semantic, observed);
}
observed.add(outcome.surface);
}
if ([...variants.values()].some((observed) => observed.size !== 2))
findings.push(
finding(
"surface-semantic-drift",
"export-replay",
"fail",
`CLI/MCP structured outcomes disagree: ${label}; sequences ${outcomes.map((outcome) => outcome.sequence).join(", ")}.`,
citations,
`CLI/MCP structured outcomes disagree: ${label}; sequences ${outcomes.map(({ sequence }) => sequence).sort((left, right) => left - right).join(", ")}.`,
outcomeEvidence,
),
);
}
@@ -389,14 +409,10 @@ const groupedOutcomes = (
ReconstructionReadinessInput["operation_outcomes"]
>();
for (const outcome of input.operation_outcomes) {
const key = compositeKey([
outcome.operation,
outcome.call_kind,
String(outcome.expected_success),
]);
const group = grouped.get(key);
if (group === undefined) grouped.set(key, [outcome]);
else group.push(outcome);
const key = compositeKey([outcome.operation, outcome.call_kind]);
const outcomes = grouped.get(key);
if (outcomes === undefined) grouped.set(key, [outcome]);
else outcomes.push(outcome);
}
return grouped;
};
+69 -1
View File
@@ -21,6 +21,8 @@ import {
type ManagedMethod,
} from "./ManagedMemberInspectorCore.js";
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import { managedMemberInspectionSchema } from "../domain/managed/managedArtifact.js";
import { comparisonLimitations } from "../domain/managed/managedMemberComparisonCoverage.js";
import { buildManagedPeFixture as buildMultiMethodManagedPeFixture } from "../../scripts/fixtures/managed/pe.mjs";
@@ -553,7 +555,7 @@ const genericRef = Buffer.from([0x30, 0x01, 0x00, 0x01]);
// GENERIC, one generic parameter, no parameters, void.
const genericDef = Buffer.from([0x10, 0x01, 0x00, 0x01]);
describe("managed edge target declaring types", () => {
describe("managed TypeSpec declaring names", () => {
it("names a generic TypeSpec parent by its generic type and leaves other TypeSpecs unknown", () => {
// GENERICINST CLASS TypeRef(1) <string>
const generic = memberRefOnTypeSpec(
@@ -568,6 +570,19 @@ describe("managed edge target declaring types", () => {
expect(array.coverage).toEqual({ state: "complete", issues: [] });
});
it.each([{ prefix: [0x0f] }, { prefix: [0x1d] }, { prefix: [0x1f, 0x05] }])(
"leaves a wrapped generic TypeSpec unnamed with prefix $prefix",
({ prefix }) => {
const result = memberRefOnTypeSpec(
Buffer.from([...prefix, 0x15, 0x12, 0x05, 0x01, 0x0e]),
);
expect(result.member_refs[0]?.parent_name).toBeNull();
expect(result.call_edges[0]?.target_declaring_type).toBeNull();
expect(result.coverage).toEqual({ state: "complete", issues: [] });
},
);
it.each([
[
"names a TypeSpec as its generic type",
@@ -612,6 +627,27 @@ describe("managed edge target declaring types", () => {
},
);
it("validates late generic arguments before exposing a TypeSpec's nominal head", () => {
const result = memberRefOnTypeSpec(
Buffer.from([0x15, 0x12, 0x05, 0x02, 0x0e, 0x12, 0x25]),
);
expect(result.member_refs[0]?.parent_name).toBeNull();
expect(result.coverage).toMatchObject({
state: "partial",
issues: [
{
code: "invalid-row",
scope: "metadata.TypeSpec:0x1b000001",
detail:
"TypeDefOrRefOrSpec operand 0x25 is invalid: coded index selects row 9 in table 1, which has 1 rows",
},
],
});
});
});
describe("managed edge target declaring types", () => {
it("reports unsupported TypeSpec and MethodSpec forms instead of treating them as unknown", () => {
const typeSpec = memberRefOnTypeSpec(Buffer.from([0xff]));
const methodSpec = inspect({
@@ -701,6 +737,38 @@ describe("managed edge target declaring types", () => {
});
describe("managed TypeRef resolution scopes", () => {
it.each([0, 9])(
"retains invalid enclosing TypeRef row %s without granting scope validity",
(enclosingRow) => {
const bytes = buildManagedPeFixture({
typeRefScopeRaw: enclosingRow * 4 + 3,
});
const pe = readManagedPeLayout(bytes);
if (pe.cli === null) throw new Error("fixture has no CLI header");
const { rva, size } = pe.cli.metadata;
const layout = readManagedMetadataLayout(
bytes,
pe.rvaToOffset(rva, size, "metadata"),
size,
);
const issues: ManagedParseIssue[] = [];
const reference = managedTypeReferences(bytes, layout, issues)(1);
expect(reference).toMatchObject({ enclosingRow, scopeValid: false });
expect(issues).toMatchObject([
{
code: "invalid-row",
scope: "metadata.TypeRef:0x01000001",
detail: expect.stringContaining(
enclosingRow === 0
? "coded index selects row zero in table 1"
: "coded index selects row 9 in table 1, which has 1 rows",
),
},
]);
},
);
it.each([
// AssemblyRef 9 does not exist.
["an invalid", (9 << 2) | 2, "is invalid"],
+14 -17
View File
@@ -207,12 +207,8 @@ export const inspectManagedMembersBytes = (
typeNames === undefined ? 0 : ranges.length - parsedTypes.types.length;
appendManagedParseIssues(issues, parsedTypes.issues);
const typeReferences = managedTypeReferences(bytes, layout, issues);
const signatureTypeName = managedTypeNameDigests(
layout,
issues,
parsedTypes,
typeReferences,
);
const { operand: signatureTypeName, declaration: declarationTypeName } =
managedTypeNameDigests(layout, issues, parsedTypes, typeReferences);
const types = parsedTypes.types
.filter((type) => selected === null || selected.has(type.token))
.map((type) => {
@@ -220,8 +216,9 @@ export const inspectManagedMembersBytes = (
return {
...type,
name_sha256:
signatureTypeName(
Number.parseInt(type.token.slice(4), 16) * 4,
declarationTypeName(
2,
Number.parseInt(type.token.slice(4), 16),
)?.toString("hex") ?? null,
};
});
@@ -285,6 +282,7 @@ export const inspectManagedMembersBytes = (
appendManagedParseIssues(issues, exportedTypes.issues);
const fields = parseFields(bytes, layout, ranges, memberLists, {
signatureTypeName,
declarationTypeName,
selected,
});
const resolveTypeName = typeNameLookup(
@@ -294,12 +292,11 @@ export const inspectManagedMembersBytes = (
issues,
typeReferences,
);
const memberRefs = parseMemberRefs(
bytes,
layout,
resolveTypeName,
const memberRefs = parseMemberRefs(bytes, layout, {
typeName: resolveTypeName,
signatureTypeName,
);
declarationTypeName,
});
appendManagedParseIssues(issues, memberRefs.issues);
const methods = parseMethods({
bytes,
@@ -307,6 +304,7 @@ export const inspectManagedMembersBytes = (
pe,
ranges,
signatureTypeName,
declarationTypeName,
memberRefKinds: new Map(
memberRefs.refs.flatMap((memberRef) =>
memberRef.signature.kind === "field" ||
@@ -339,7 +337,7 @@ export const inspectManagedMembersBytes = (
region.class_token === null
? null
: classType !== null && tokenTable === 2
? signatureTypeName(tokenRow * 4) !== null
? declarationTypeName(2, tokenRow) !== null
: false;
return {
...region,
@@ -347,9 +345,8 @@ export const inspectManagedMembersBytes = (
class_type_name_sha256:
region.class_token !== null &&
(tokenTable === 1 || tokenTable === 2)
? (signatureTypeName(
tokenRow * 4 + Number(tokenTable === 1),
)?.toString("hex") ?? null)
? (declarationTypeName(tokenTable, tokenRow)?.toString("hex") ??
null)
: null,
class_type_identity_resolved: identityResolved,
};
+52 -32
View File
@@ -24,8 +24,9 @@ import type {
} from "./ManagedMetadataLayout.js";
import {
metadataRowCursor,
metadataCodedToken,
metadataCodedTokenInvalidReason,
decodeMetadataCodedIndex,
metadataCodedIndexCoordinate,
type MetadataCodedIndexCoordinate,
metadataToken,
readMetadataString,
sha256Bytes,
@@ -92,7 +93,7 @@ export const memberTargetIdentity = (
name: string,
declaringToken: string | null,
signature: ManagedSignature,
typeName: SignatureTypeName,
typeName: DeclarationTypeName,
): string | null => {
if (
declaringToken === null ||
@@ -102,9 +103,7 @@ export const memberTargetIdentity = (
return null;
const table = Number.parseInt(declaringToken.slice(2, 4), 16);
if (table !== 1 && table !== 2) return null;
const owner = typeName(
Number.parseInt(declaringToken.slice(4), 16) * 4 + Number(table === 1),
);
const owner = typeName(table, Number.parseInt(declaringToken.slice(4), 16));
return owner === null
? null
: createHash("sha256")
@@ -243,18 +242,18 @@ export const parseTypes = (
definition.methodStart,
definition.methodEnd,
);
const extendsReason = metadataCodedTokenInvalidReason(
const extendsIndex = decodeMetadataCodedIndex(
extendsRaw,
2,
[2, 1, 27],
layout.rowCounts,
);
if (extendsReason !== null)
if (extendsIndex.kind === "invalid")
issues.push({
code: "invalid-row",
scope: `metadata.TypeDef:${token}`,
offset: extendsOffset,
detail: `TypeDef Extends coded index 0x${extendsRaw.toString(16)} is invalid: ${extendsReason}`,
detail: `TypeDef Extends coded index 0x${extendsRaw.toString(16)} is invalid: ${extendsIndex.reason}`,
});
const enclosingRow = nesting.enclosing.get(row);
items.push({
@@ -264,12 +263,7 @@ export const parseTypes = (
name,
full_name: definition.fullName,
flags,
extends_token: metadataCodedToken(
extendsRaw,
2,
[2, 1, 27],
layout.rowCounts,
),
extends_token: extendsIndex.kind === "valid" ? extendsIndex.token : null,
enclosing_type_token:
enclosingRow === undefined ? null : metadataToken(2, enclosingRow),
name_sha256: null,
@@ -526,27 +520,26 @@ const readTypeOperand = (
offset: number,
layout?: ManagedMetadataLayout,
tables: readonly number[] = [2, 1, 27],
): ReturnType<typeof readCompressed> => {
): Omit<TypeOperand, "offset"> => {
const token = readCompressed(blob, offset);
if (layout !== undefined) {
const reason =
token.value === 0
? "type operand is null"
: metadataCodedTokenInvalidReason(
token.value,
2,
tables,
layout.rowCounts,
);
if (reason !== null)
const decoded = decodeMetadataCodedIndex(
token.value,
2,
tables,
layout.rowCounts,
);
if (decoded.kind !== "valid")
throw managedFailure(
"invalid-row",
"signature",
`TypeDefOrRefOrSpec operand 0x${token.value.toString(16)} is invalid: ${reason}`,
`TypeDefOrRefOrSpec operand 0x${token.value.toString(16)} is invalid: ${decoded.kind === "null" ? "type operand is null" : decoded.reason}`,
offset,
);
return { ...token, coordinate: { table: decoded.table, row: decoded.row } };
}
return token;
const { table, row } = metadataCodedIndexCoordinate(token.value, 2, tables);
return { ...token, coordinate: { table, row } };
};
/** A TypeDefOrRefOrSpec coded index embedded in a signature blob. */
@@ -554,6 +547,12 @@ interface TypeOperand {
readonly offset: number;
readonly next: number;
readonly value: number;
readonly coordinate: MetadataCodedIndexCoordinate;
}
interface NominalGenericType {
readonly table: 1 | 2;
readonly row: number;
}
const readTypeSignature = (
@@ -569,9 +568,14 @@ const readTypeSignature = (
/** Collect each type operand, to resolve it by name. */
readonly operands?: TypeOperand[];
} = {},
): { readonly value: string; readonly next: number } => {
): {
readonly value: string;
readonly next: number;
readonly nominalGenericType: NominalGenericType | null;
} => {
const stack: TypeContinuation[] = [];
let cursor = offset;
let nominalGenericType: NominalGenericType | null = null;
let pinned = false;
for (;;) {
let value: string | undefined;
@@ -688,6 +692,14 @@ const readTypeSignature = (
cursor + 1,
);
const token = readTypeOperand(blob, cursor + 2, layout, [2, 1]);
if (
cursor === offset &&
(token.coordinate.table === 1 || token.coordinate.table === 2)
)
nominalGenericType = {
table: token.coordinate.table,
row: token.coordinate.row,
};
operands?.push({ offset: cursor + 2, ...token });
const argumentCount = readCompressed(blob, token.next);
if (argumentCount.value === 0)
@@ -736,7 +748,7 @@ const readTypeSignature = (
}
while (value !== undefined) {
const top = stack.pop();
if (top === undefined) return { value, next: cursor };
if (top === undefined) return { value, next: cursor, nominalGenericType };
if (top.kind === "suffix") value += top.suffix;
else if (top.kind === "modifiers")
value = `${value} ${top.modifiers.join(" ")}`;
@@ -844,6 +856,7 @@ export const specSignatureFacts = (
): {
readonly types: readonly string[];
readonly resolvedSha256: string | null;
readonly nominalGenericType: NominalGenericType | null;
} => {
const budget = managedDecodeBudget(layout);
// Rendering is transient, so charge it as member signatures are charged.
@@ -862,6 +875,7 @@ export const specSignatureFacts = (
}
const types: string[] = [];
const operands: TypeOperand[] = [];
let nominalGenericType: NominalGenericType | null = null;
// Each Type consumes at least one byte, so a false count fails on truncation.
for (let index = 0; index < count; index += 1) {
const type = readTypeSignature(blob, cursor, "type", {
@@ -869,6 +883,7 @@ export const specSignatureFacts = (
operands,
});
types.push(type.value);
if (owner.form === "type") nominalGenericType = type.nominalGenericType;
cursor = type.next;
}
if (cursor !== blob.length)
@@ -879,6 +894,7 @@ export const specSignatureFacts = (
typeName === undefined
? null
: resolvedSha256(blob, operands, typeName),
nominalGenericType,
};
} catch (cause: unknown) {
if (
@@ -1043,7 +1059,11 @@ export const signatureGenericArity = (
* SHA-256 of the namespace-qualified name of a TypeDefOrRefOrSpec coded index,
* or null when it has no name.
*/
export type SignatureTypeName = (codedIndex: number) => Buffer | null;
export type SignatureTypeName = (
operand: Pick<TypeOperand, "value" | "coordinate">,
) => Buffer | null;
export type DeclarationTypeName = (table: 1 | 2, row: number) => Buffer | null;
/**
* Each type operand is replaced by 0xff, which never starts a compressed
@@ -1066,7 +1086,7 @@ const resolvedSha256 = (
const hash = createHash("sha256");
let copied = 0;
for (const operand of operands) {
const name = typeName(operand.value);
const name = typeName(operand);
if (name === null) return null;
hash.update(blob.subarray(copied, operand.offset));
hash.update(OPERAND_MARKER);
+29 -27
View File
@@ -20,13 +20,13 @@ import {
type ManagedMethod,
type MemberCore,
type SignatureTypeName,
type DeclarationTypeName,
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
import {
metadataToken,
metadataRowCursor,
metadataCodedToken,
metadataCodedTokenInvalidReason,
decodeMetadataCodedIndex,
readMetadataBlob,
readMetadataString,
} from "./ManagedMetadataHeaps.js";
@@ -57,9 +57,11 @@ export const parseFields = (
memberLists: TypeMemberListFacts,
{
signatureTypeName,
declarationTypeName,
selected = null,
}: {
readonly signatureTypeName: SignatureTypeName;
readonly declarationTypeName: DeclarationTypeName;
readonly selected?: SelectedTypeTokens;
},
): {
@@ -120,7 +122,7 @@ export const parseFields = (
name,
declared?.token ?? null,
decoded,
signatureTypeName,
declarationTypeName,
),
});
fields.push({
@@ -172,21 +174,21 @@ export const parseExportedTypes = (
layout.codedIndexSize("Implementation"),
);
const token = metadataToken(39, row);
const implementationReason = metadataCodedTokenInvalidReason(
const implementation = decodeMetadataCodedIndex(
implementationRaw,
2,
[38, 35, 39],
layout.rowCounts,
);
if (implementationReason !== null || implementationRaw === 0)
if (implementation.kind !== "valid")
issues.push({
code: "invalid-row",
scope: `metadata.ExportedType:${token}`,
offset: implementationOffset,
detail:
implementationReason === null
implementation.kind === "null"
? "ExportedType implementation coded index 0x0 is null, but the Implementation column must reference a row"
: `ExportedType implementation coded index 0x${implementationRaw.toString(16)} is invalid: ${implementationReason}`,
: `ExportedType implementation coded index 0x${implementationRaw.toString(16)} is invalid: ${implementation.reason}`,
});
exportedTypes.push({
token,
@@ -196,12 +198,8 @@ export const parseExportedTypes = (
flags,
forwarder: (flags & TYPE_FORWARDER_FLAG) !== 0,
type_def_id: typeDefId,
implementation_token: metadataCodedToken(
implementationRaw,
2,
[38, 35, 39],
layout.rowCounts,
),
implementation_token:
implementation.kind === "valid" ? implementation.token : null,
});
}, issues);
}
@@ -211,8 +209,15 @@ export const parseExportedTypes = (
export const parseMemberRefs = (
bytes: Buffer,
layout: ManagedMetadataLayout,
typeName: (token: string | null) => string | null,
signatureTypeName: SignatureTypeName,
{
typeName,
signatureTypeName,
declarationTypeName,
}: {
readonly typeName: (token: string | null) => string | null;
readonly signatureTypeName: SignatureTypeName;
readonly declarationTypeName: DeclarationTypeName;
},
): {
readonly refs: readonly ManagedMemberRef[];
readonly core: ReadonlyMap<string, MemberCore>;
@@ -241,28 +246,23 @@ export const parseMemberRefs = (
layout.blob?.size ?? 0,
);
const token = metadataToken(10, row);
const parentReason = metadataCodedTokenInvalidReason(
const parent = decodeMetadataCodedIndex(
parentRaw,
3,
[2, 1, 26, 6, 27],
layout.rowCounts,
);
if (parentReason !== null || parentRaw === 0)
if (parent.kind !== "valid")
issues.push({
code: "invalid-row",
scope: `metadata.MemberRef:${token}`,
offset: cursor.start,
detail:
parentReason === null
parent.kind === "null"
? "MemberRef parent coded index 0x0 is null, but the Class column must reference a row"
: `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parentReason}`,
: `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parent.reason}`,
});
const parentToken = metadataCodedToken(
parentRaw,
3,
[2, 1, 26, 6, 27],
layout.rowCounts,
);
const parentToken = parent.kind === "valid" ? parent.token : null;
const parentName = typeName(parentToken);
const decoded = budgetedSignature(
sig,
@@ -289,7 +289,7 @@ export const parseMemberRefs = (
name,
parentToken,
decoded,
signatureTypeName,
declarationTypeName,
),
});
}, issues);
@@ -303,6 +303,7 @@ interface ParseMethodsInput {
readonly pe: ManagedPeLayout;
readonly ranges: readonly TypeRange[];
readonly signatureTypeName: SignatureTypeName;
readonly declarationTypeName: DeclarationTypeName;
readonly memberRefKinds: ReadonlyMap<string, "field" | "method">;
readonly memberLists: TypeMemberListFacts;
readonly selected?: SelectedTypeTokens;
@@ -316,6 +317,7 @@ export const parseMethods = ({
pe,
ranges,
signatureTypeName,
declarationTypeName,
memberRefKinds,
memberLists,
selected = null,
@@ -400,7 +402,7 @@ export const parseMethods = ({
name,
declared?.token ?? null,
decoded,
signatureTypeName,
declarationTypeName,
),
});
const read = methodBody(bytes, pe, rva, {
+51 -22
View File
@@ -392,37 +392,66 @@ export const metadataRowCursor = (
);
};
/** Decode a metadata coded index without signed 32-bit coercion. */
export const metadataCodedToken = (
/** Format coordinates do not establish that the selected metadata row exists. */
export interface MetadataCodedIndexCoordinate {
readonly table: number | undefined;
readonly row: number;
}
export const metadataCodedIndexCoordinate = (
raw: number,
bits: number,
tables: readonly (number | undefined)[],
rowCounts: readonly number[],
): string | null => {
if (raw === 0) return null;
): MetadataCodedIndexCoordinate & { readonly tag: number } => {
const tag = raw & (2 ** bits - 1);
const row = Math.floor(raw / 2 ** bits);
const table = tables[tag];
return table === undefined || row === 0 || row > (rowCounts[table] ?? 0)
? null
: metadataToken(table, row);
return { tag, table: tables[tag], row: Math.floor(raw / 2 ** bits) };
};
/** Explain why a non-null coded index cannot name an admitted metadata row. */
export const metadataCodedTokenInvalidReason = (
export type ManagedCodedIndex =
| { readonly kind: "null" }
| {
readonly kind: "valid";
readonly token: string;
readonly table: number;
readonly row: number;
}
| {
readonly kind: "invalid";
readonly reason: string;
readonly table: number | undefined;
readonly row: number;
};
/** Decode and validate a metadata coded index without signed row coercion. */
export const decodeMetadataCodedIndex = (
raw: number,
bits: number,
tables: readonly (number | undefined)[],
rowCounts: readonly number[],
): string | null => {
if (raw === 0) return null;
const tag = raw & (2 ** bits - 1);
const row = Math.floor(raw / 2 ** bits);
const table = tables[tag];
if (table === undefined) return `coded index tag ${tag} is not defined`;
if (row === 0) return `coded index selects row zero in table ${table}`;
): ManagedCodedIndex => {
if (raw === 0) return { kind: "null" };
const { tag, table, row } = metadataCodedIndexCoordinate(raw, bits, tables);
if (table === undefined)
return {
kind: "invalid",
reason: `coded index tag ${tag} is not defined`,
table,
row,
};
if (row === 0)
return {
kind: "invalid",
reason: `coded index selects row zero in table ${table}`,
table,
row,
};
const count = rowCounts[table] ?? 0;
return row > count
? `coded index selects row ${row} in table ${table}, which has ${count} rows`
: null;
if (row > count)
return {
kind: "invalid",
reason: `coded index selects row ${row} in table ${table}, which has ${count} rows`,
table,
row,
};
return { kind: "valid", token: metadataToken(table, row), table, row };
};
+35 -52
View File
@@ -16,8 +16,8 @@ import {
} from "./ManagedMetadataLayout.js";
import {
metadataRowCursor,
metadataCodedToken,
metadataCodedTokenInvalidReason,
decodeMetadataCodedIndex,
type ManagedCodedIndex,
metadataToken,
readMetadataBlob,
readMetadataGuid,
@@ -404,29 +404,28 @@ const declaringTypeForMethod = (
const attributeTypeName = (
bytes: Buffer,
layout: ManagedMetadataLayout,
rawType: number,
constructor: Extract<ManagedCodedIndex, { readonly kind: "valid" }>,
memberLists: TypeMemberListFacts,
typeName: ManagedMetadataTypeNameLookup,
): string | null => {
const tag = rawType & 7;
const row = Math.floor(rawType / 8);
if (row === 0) return null;
if (tag === 2)
const { table, row } = constructor;
if (table === 6)
return declaringTypeForMethod(bytes, layout, row, memberLists, typeName);
if (tag !== 3) return null;
const memberRefs = layout.table(10);
if (memberRefs === undefined || row > memberRefs.rowCount) return null;
const cursor = metadataRowCursor(bytes, layout, 10, row);
const parent = cursor.readIndex(layout.codedIndexSize("MemberRefParent"));
const parentTag = parent & 7;
const parentRow = Math.floor(parent / 8);
if (parentTag === 0) return typeName(2, parentRow);
if (parentTag === 1) return typeName(1, parentRow);
if (parentTag === 3)
const parent = decodeMetadataCodedIndex(
cursor.readIndex(layout.codedIndexSize("MemberRefParent")),
3,
[2, 1, 26, 6, 27],
layout.rowCounts,
);
if (parent.kind === "null") return null;
if (parent.table === 2 || parent.table === 1)
return typeName(parent.table, parent.row);
if (parent.table === 6)
return declaringTypeForMethod(
bytes,
layout,
parentRow,
parent.row,
memberLists,
typeName,
);
@@ -510,59 +509,48 @@ export const readCustomAttribute = (
cursor.readIndex(layout.blobIndexSize),
heapExtent,
);
const parent = metadataCodedToken(
const parent = decodeMetadataCodedIndex(
parentRaw,
5,
HAS_CUSTOM_ATTRIBUTE_TABLES,
layout.rowCounts,
);
if (parent === null) {
const parentReason = metadataCodedTokenInvalidReason(
parentRaw,
5,
HAS_CUSTOM_ATTRIBUTE_TABLES,
layout.rowCounts,
);
if (parent.kind !== "valid") {
throw managedFailure(
"invalid-row",
`metadata.CustomAttribute:${metadataToken(12, row)}`,
`CustomAttribute parent coded index 0x${parentRaw.toString(16)} is invalid${parentReason === null ? " because the required parent is null" : `: ${parentReason}`}`,
`CustomAttribute parent coded index 0x${parentRaw.toString(16)} is invalid${parent.kind === "null" ? " because the required parent is null" : `: ${parent.reason}`}`,
cursor.start,
);
}
const constructor = metadataCodedToken(
const constructor = decodeMetadataCodedIndex(
typeRaw,
3,
CUSTOM_ATTRIBUTE_TYPE_TABLES,
layout.rowCounts,
);
if (constructor === null) {
const typeReason = metadataCodedTokenInvalidReason(
typeRaw,
3,
CUSTOM_ATTRIBUTE_TYPE_TABLES,
layout.rowCounts,
);
if (constructor.kind !== "valid") {
issues.push({
code: "invalid-row",
scope: `metadata.CustomAttribute:${metadataToken(12, row)}`,
offset: typeOffset,
detail:
typeReason === null
constructor.kind === "null"
? "CustomAttribute constructor coded index 0x0 is null, but Type must reference a MethodDef or MemberRef row"
: `CustomAttribute constructor coded index 0x${typeRaw.toString(16)} is invalid: ${typeReason}`,
: `CustomAttribute constructor coded index 0x${typeRaw.toString(16)} is invalid: ${constructor.reason}`,
});
}
let typeName: string | null = null;
if (constructor !== null)
if (constructor.kind === "valid")
typeName =
readManagedValue(
() => attributeTypeName(bytes, layout, typeRaw, memberLists, typeNames),
() =>
attributeTypeName(bytes, layout, constructor, memberLists, typeNames),
issues,
) ?? null;
return {
parent_token: parent,
constructor_token: constructor,
parent_token: parent.token,
constructor_token: constructor.kind === "valid" ? constructor.token : null,
type_name: typeName,
value_length: value.length,
value_sha256: managedDecodeBudget(layout).digest(value, "sha256"),
@@ -603,28 +591,22 @@ export const readResource = ({
const implementationRaw = cursor.readIndex(
layout.codedIndexSize("Implementation"),
);
const implementationToken = metadataCodedToken(
const implementation = decodeMetadataCodedIndex(
implementationRaw,
2,
[38, 35, 39],
layout.rowCounts,
);
const implementationReason = metadataCodedTokenInvalidReason(
implementationRaw,
2,
[38, 35, 39],
layout.rowCounts,
);
if (implementationReason !== null)
if (implementation.kind === "invalid")
issues.push({
code: "invalid-row",
scope: `metadata.ManifestResource:${metadataToken(40, row)}`,
offset: implementationOffset,
detail: `ManifestResource implementation coded index 0x${implementationRaw.toString(16)} is invalid: ${implementationReason}`,
detail: `ManifestResource implementation coded index 0x${implementationRaw.toString(16)} is invalid: ${implementation.reason}`,
});
let dataLength: number | null = null;
let dataSha256: string | null = null;
if (implementationRaw === 0) {
if (implementation.kind === "null") {
if (directory === null || declaredOffset > directory.size - 4) {
issues.push({
code: "invalid-resource",
@@ -655,8 +637,9 @@ export const readResource = ({
flags,
visibility:
(flags & 7) === 1 ? "public" : (flags & 7) === 2 ? "private" : "unknown",
implementation_token: implementationToken,
embedded: implementationRaw === 0,
implementation_token:
implementation.kind === "valid" ? implementation.token : null,
embedded: implementation.kind === "null",
declared_offset: declaredOffset,
data_length: dataLength,
data_sha256: dataSha256,
+6 -7
View File
@@ -9,8 +9,7 @@ import {
} from "./ManagedMemberInspectorCore.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
metadataCodedToken,
metadataCodedTokenInvalidReason,
decodeMetadataCodedIndex,
metadataRowCursor,
readMetadataBlob,
} from "./ManagedMetadataHeaps.js";
@@ -84,23 +83,23 @@ const readMethodSpec = (
);
const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef"));
const instantiation = cursor.readIndex(layout.blobIndexSize);
const methodReason = metadataCodedTokenInvalidReason(
const method = decodeMetadataCodedIndex(
methodRaw,
1,
[6, 10],
layout.rowCounts,
);
if (methodReason !== null || methodRaw === 0)
if (method.kind !== "valid")
issues.push({
code: "invalid-row",
scope,
offset: cursor.start,
detail:
methodReason === null
method.kind === "null"
? "MethodSpec method coded index 0x0 is null, but the Method column must reference a row"
: `MethodSpec method coded index 0x${methodRaw.toString(16)} is invalid: ${methodReason}`,
: `MethodSpec method coded index 0x${methodRaw.toString(16)} is invalid: ${method.reason}`,
});
const generic = metadataCodedToken(methodRaw, 1, [6, 10], layout.rowCounts);
const generic = method.kind === "valid" ? method.token : null;
const target = generic === null ? undefined : members.get(generic);
// A MethodSpec instantiates a method, so a field target never lends it a
// name, even to a field operand.
+7 -12
View File
@@ -11,8 +11,7 @@ import {
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
metadataRowCursor,
metadataCodedToken,
metadataCodedTokenInvalidReason,
decodeMetadataCodedIndex,
metadataToken,
readMetadataString,
} from "./ManagedMetadataHeaps.js";
@@ -169,28 +168,24 @@ export const parseImplMaps = ({
const memberForwardedRaw = cursor.readIndex(
layout.codedIndexSize("MemberForwarded"),
);
const memberForwardedReason = metadataCodedTokenInvalidReason(
const memberForwarded = decodeMetadataCodedIndex(
memberForwardedRaw,
1,
[4, 6],
layout.rowCounts,
);
if (memberForwardedReason !== null || memberForwardedRaw === 0)
if (memberForwarded.kind !== "valid")
issues.push({
code: "invalid-row",
scope: `metadata.ImplMap:${metadataToken(28, row)}`,
offset: memberForwardedOffset,
detail:
memberForwardedReason === null
memberForwarded.kind === "null"
? "ImplMap MemberForwarded coded index 0x0 is null, but the column must reference a Field or MethodDef row"
: `ImplMap MemberForwarded coded index 0x${memberForwardedRaw.toString(16)} is invalid: ${memberForwardedReason}`,
: `ImplMap MemberForwarded coded index 0x${memberForwardedRaw.toString(16)} is invalid: ${memberForwarded.reason}`,
});
const memberToken = metadataCodedToken(
memberForwardedRaw,
1,
[4, 6],
layout.rowCounts,
);
const memberToken =
memberForwarded.kind === "valid" ? memberForwarded.token : null;
const importName = readMetadataString(
bytes,
layout,
+77 -7
View File
@@ -6,11 +6,13 @@ import {
attachManagedDecodeBudget,
ManagedDecodeBudget,
} from "./ManagedDecodeBudget.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
import {
parseTypes,
signature,
readTypeDefinitions,
type SignatureTypeName,
} from "./ManagedMemberInspectorCore.js";
import {
metadataRowOffset,
@@ -22,6 +24,7 @@ import {
} from "./ManagedPe.fixture.js";
import { readManagedPeLayout } from "./ManagedPeReader.js";
import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js";
import { metadataCodedIndexCoordinate } from "./ManagedMetadataHeaps.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js";
@@ -48,11 +51,36 @@ const metadataLayout = (bytes: Buffer) => {
const named =
(names: Readonly<Record<number, string>>) =>
(codedIndex: number): Buffer | null => {
const name = names[codedIndex];
({ value }: Parameters<SignatureTypeName>[0]): Buffer | null => {
const name = names[value];
return name === undefined ? null : nameDigest(name);
};
describe("layoutless signature coordinates", () => {
it.each([
[0, 2, 0],
[0x25, 1, 9],
[0x07, undefined, 1],
] as const)(
"keeps layoutless operand %s decoded without admitting a metadata row",
(value, table, row) => {
const name = vi.fn<SignatureTypeName>(() => Buffer.alloc(32));
const result = signature(
Buffer.from([0x06, 0x12, value]),
undefined,
name,
);
expect(result.parse_status).toBe("decoded");
expect(result.resolved_sha256).toMatch(/^[0-9a-f]{64}$/u);
expect(name).toHaveBeenCalledTimes(1);
expect(name).toHaveBeenCalledWith(
expect.objectContaining({ value, coordinate: { table, row } }),
);
},
);
});
describe("resolved member signature digests", () => {
it("names modifier, class, and generic type operands instead of their rows", () => {
// modreq(M) G<P> on the left and the same types at other rows on the right.
@@ -114,6 +142,43 @@ describe("resolved member signature digests", () => {
expect(field?.signature.parse_status).toBe("decoded");
expect(field?.signature.resolved_sha256).toBeNull();
});
});
describe("metadata type-name digest ownership", () => {
it("shares declaration and operand identity while preserving invalid TypeDef diagnostics", () => {
const bytes = buildManagedPeFixture();
const layout = metadataLayout(bytes);
const issues: ManagedParseIssue[] = [];
const names = managedTypeNameDigests(
layout,
issues,
{ types: [], nesting: { enclosing: new Map(), complete: true } },
managedTypeReferences(bytes, layout, issues),
);
for (const row of [0, 9]) {
expect(names.declaration(2, row)).toBeNull();
const value = row * 4;
expect(
names.operand({
value,
coordinate: metadataCodedIndexCoordinate(value, 2, [2, 1, 27]),
}),
).toBeNull();
}
expect(issues).toMatchObject([
{
code: "invalid-row",
scope: "metadata.signature.type:2:0",
detail: "Signature references an unavailable TypeDef row 0",
},
{
code: "invalid-row",
scope: "metadata.signature.type:2:9",
detail: "Signature references an unavailable TypeDef row 9",
},
]);
});
it("names each type operand once, so repeated operands are not charged again", () => {
const bytes = buildManagedPeFixture({
@@ -122,7 +187,7 @@ describe("resolved member signature digests", () => {
const layout = metadataLayout(bytes);
const budget = new ManagedDecodeBudget();
attachManagedDecodeBudget(layout, budget);
const typeName = managedTypeNameDigests(
const typeNames = managedTypeNameDigests(
layout,
[],
{
@@ -133,9 +198,14 @@ describe("resolved member signature digests", () => {
);
const uri = (2 << 2) | 1;
expect(typeName(uri)).toEqual(nameDigest("System.Uri"));
const operand = {
value: uri,
coordinate: metadataCodedIndexCoordinate(uri, 2, [2, 1, 27]),
};
expect(typeNames.operand(operand)).toEqual(nameDigest("System.Uri"));
const reserve = vi.spyOn(budget, "reserve");
expect(typeName(uri)).toEqual(nameDigest("System.Uri"));
expect(typeNames.operand(operand)).toEqual(nameDigest("System.Uri"));
expect(typeNames.declaration(1, 2)).toEqual(nameDigest("System.Uri"));
expect(reserve).not.toHaveBeenCalled();
});
@@ -182,8 +252,8 @@ describe("resolved member signature digests", () => {
parsedTypes,
managedTypeReferences(bytes, layout, issues),
);
expect(() => resolve((2 << 2) | 1)).not.toThrow();
expect(resolve((2 << 2) | 1)).toBeNull();
expect(() => resolve.declaration(1, 2)).not.toThrow();
expect(resolve.declaration(1, 2)).toBeNull();
expect(issues).toEqual([]);
const inspection = inspectManagedMembersBytes(
bytes,
+23 -7
View File
@@ -5,6 +5,7 @@ import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import type {
parseTypes,
SignatureTypeName,
DeclarationTypeName,
} from "./ManagedMemberInspectorCore.js";
import { metadataToken } from "./ManagedMetadataHeaps.js";
import type { ManagedTypeReferenceLookup } from "./ManagedTypeReferences.js";
@@ -146,7 +147,10 @@ export const managedTypeNameDigests = (
issues: ManagedParseIssue[],
parsedTypes: Pick<ReturnType<typeof parseTypes>, "types" | "nesting">,
reference: ManagedTypeReferenceLookup,
): SignatureTypeName => {
): {
readonly operand: SignatureTypeName;
readonly declaration: DeclarationTypeName;
} => {
const retain = () => managedDecodeBudget(layout).reserve(128, null);
managedDecodeBudget(layout).reserve(parsedTypes.types.length * 128, null);
const types = new Map<string, (typeof parsedTypes.types)[number]>();
@@ -196,16 +200,28 @@ export const managedTypeNameDigests = (
return value?.scopeValid === true ? value : null;
}, retain);
const digests = new Map<number, Buffer | null>();
return (codedIndex) => {
let digest = digests.get(codedIndex);
const digestFor = (
key: number,
table: number | undefined,
row: number,
): Buffer | null => {
let digest = digests.get(key);
if (digest === undefined) {
managedDecodeBudget(layout).reserve(256, null);
const row = codedIndex >>> 2;
const table = codedIndex & 3;
digest =
table === 0 ? definitions(row) : table === 1 ? references(row) : null;
digests.set(codedIndex, digest);
table === TYPE_DEF
? definitions(row)
: table === TYPE_REF
? references(row)
: null;
digests.set(key, digest);
}
return digest;
};
return {
operand: ({ value, coordinate }) =>
digestFor(value, coordinate.table, coordinate.row),
declaration: (table, row) =>
digestFor(row * 4 + Number(table === TYPE_REF), table, row),
};
};
+5 -16
View File
@@ -2,7 +2,6 @@ import type { ManagedTypeReferenceLookup } from "./ManagedTypeReferences.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import {
fullName,
readCompressed,
specSignatureFacts,
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
@@ -15,7 +14,7 @@ import {
metadataToken,
readMetadataBlob,
} from "./ManagedMetadataHeaps.js";
import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js";
import { readManagedValue } from "./ManagedReaderFailure.js";
/**
* Name the type a TypeDef, TypeRef, or generic-instantiation TypeSpec token
@@ -55,20 +54,10 @@ export const typeNameLookup = (
scope: `metadata.TypeSpec:${metadataToken(27, row)}`,
offset: cursor.start,
} as const;
specSignatureFacts(layout, spec, owner);
if (spec[0] !== 0x15) return null;
// GENERICINST (CLASS | VALUETYPE) TypeDefOrRefEncoded: never a TypeSpec,
// so naming never recurses.
const encoded = readCompressed(spec, 2).value;
const table = ([2, 1] as const)[encoded & 3];
if (table === undefined)
throw managedFailure(
"invalid-blob",
owner.scope,
"Generic instantiation does not name a TypeDef or TypeRef",
owner.offset,
);
return definitionName(table, Math.floor(encoded / 4));
const { nominalGenericType } = specSignatureFacts(layout, spec, owner);
return nominalGenericType === null
? null
: definitionName(nominalGenericType.table, nominalGenericType.row);
};
return (token) => {
if (token === null) return null;
+6 -6
View File
@@ -1,7 +1,7 @@
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
metadataCodedTokenInvalidReason,
decodeMetadataCodedIndex,
metadataRowCursor,
metadataToken,
readMetadataString,
@@ -46,25 +46,25 @@ export const managedTypeReferences = (
readManagedValue(() => {
const cursor = metadataRowCursor(bytes, layout, 1, row);
const raw = cursor.readIndex(layout.codedIndexSize("ResolutionScope"));
const reason = metadataCodedTokenInvalidReason(
const scope = decodeMetadataCodedIndex(
raw,
2,
[0, 26, 35, 1],
layout.rowCounts,
);
if (reason !== null)
if (scope.kind === "invalid")
issues.push({
code: "invalid-row",
scope: `metadata.TypeRef:${metadataToken(1, row)}`,
offset: cursor.start,
detail: `TypeRef ResolutionScope coded index 0x${raw.toString(16)} is invalid: ${reason}`,
detail: `TypeRef ResolutionScope coded index 0x${raw.toString(16)} is invalid: ${scope.reason}`,
});
return {
nameIndex: cursor.readIndex(layout.stringIndexSize),
namespaceIndex: cursor.readIndex(layout.stringIndexSize),
enclosingRow:
raw !== 0 && (raw & 3) === 3 ? Math.floor(raw / 4) : null,
scopeValid: reason === null,
scope.kind !== "null" && scope.table === 1 ? scope.row : null,
scopeValid: scope.kind !== "invalid",
};
}, issues) ?? null;
rows.set(row, value);
+14 -20
View File
@@ -3,10 +3,11 @@ import {
BinaryResourceFailure,
BinaryReader,
mappedFileOffset,
fileBackedMappings,
imageFileMappings,
isZeroFilledSpan,
type BinaryContainer,
type FileMapping,
type ZeroFillMapping,
} from "../native/metadata/BinaryContainer.js";
import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js";
import {
@@ -89,15 +90,11 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
: { section_names_limitation: sectionNamesLimitation }),
};
const { search, header } = selected;
const mappings = imageFileMappings(container);
const verifyMapping = (size: number): void => {
const address = search.address + BigInt(header - search.offset);
if (
mappedFileOffset(
container.mappings,
address,
size,
container.zero_fills,
) !== header
mappedFileOffset(mappings, address, size, container.zero_fills) !== header
)
throw new BinaryFormatFailure(
"malformed",
@@ -160,14 +157,16 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
const pointerBits = ptrSize === 4 ? 32 : 64;
version = readPointerString(
reader,
container,
mappings,
container.zero_fills,
reader.word(header + 16, pointerBits),
pointerBits,
);
const moduleField = header + 16 + ptrSize;
module = readOptionalModuleString(
reader,
container,
mappings,
container.zero_fills,
reader.word(moduleField, pointerBits),
moduleField,
pointerBits,
@@ -347,12 +346,11 @@ const readInlineExtent = (
const readPointerString = (
reader: BinaryReader,
container: BinaryContainer,
mappings: readonly FileMapping[],
zeroFills: readonly ZeroFillMapping[],
address: bigint,
pointerBits: 32 | 64,
): LocatedString => {
const mappings = fileBackedMappings(container);
const zeroFills = container.zero_fills;
const width = pointerBits / 8;
const header = mappedFileOffset(mappings, address, width * 2, zeroFills);
const valueAddress = reader.word(header, pointerBits);
@@ -377,19 +375,15 @@ const readPointerString = (
const readOptionalModuleString = (
reader: BinaryReader,
container: BinaryContainer,
mappings: readonly FileMapping[],
zeroFills: readonly ZeroFillMapping[],
address: bigint,
pointerField: number,
pointerBits: 32 | 64,
): LocatedString =>
isZeroFilledSpan(
fileBackedMappings(container),
container.zero_fills,
address,
(pointerBits / 8) * 2,
)
isZeroFilledSpan(mappings, zeroFills, address, (pointerBits / 8) * 2)
? { bytes: Buffer.alloc(0), location: { offset: pointerField, bytes: 0 } }
: readPointerString(reader, container, address, pointerBits);
: readPointerString(reader, mappings, zeroFills, address, pointerBits);
/** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */
export const observedUtf8 = (bytes: Buffer, label: string): string => {
+16 -10
View File
@@ -1,12 +1,12 @@
const processor = String.raw`gfx[0-9a-z]+(?:-[0-9a-z]+)*`;
const targetFeaturesSource = String.raw`(?:(?::[a-z0-9_-]+[+-])|(?:\+[a-z0-9_-]+))*`;
const targetIdSource = `${processor}${targetFeaturesSource}`;
const targetId = new RegExp(`^${targetIdSource}$`, "iu");
const targetId = new RegExp(`^(${processor})${targetFeaturesSource}$`, "iu");
const targetTripleSource = String.raw`(?:amdgcn|amdgpu(?:[0-9]+(?:\.[0-9a-f]+)?)?)-amd-amdhsa`;
const bundleTriple = new RegExp(`^(?:hip|hipv4)-${targetTripleSource}-`, "iu");
const bundleTarget = new RegExp(`(?:--|-)(${targetIdSource})$`, "iu");
const noteTarget = new RegExp(
`^${targetTripleSource}(?:--|-unknown-)((${processor})${targetFeaturesSource})$`,
`^${targetTripleSource}(?:--|-unknown-)(${targetIdSource})$`,
"iu",
);
@@ -78,9 +78,15 @@ const ELF_PROCESSORS: Readonly<Record<number, string>> = {
0xeb: "gfx1250-strict",
};
/** Target IDs retain the processor spelling and any declared feature modifiers. */
export const parseAmdGpuTargetId = (value: string): string | null =>
targetId.test(value) ? value : null;
/** Retain exact target spelling separately from its canonical processor identity. */
export const parseAmdGpuTargetId = (
value: string,
): { readonly key: string; readonly processor: string } | null => {
const match = targetId.exec(value);
return match?.[1] === undefined
? null
: { key: value, processor: match[1].toLowerCase() };
};
/** Clang HIP bundle IDs carry an AMD triple followed by an optional target ID. */
export const isAmdGpuBundleId = (name: string): boolean =>
@@ -89,17 +95,17 @@ export const isAmdGpuBundleId = (name: string): boolean =>
export const readAmdGpuBundleTarget = (name: string): string | null => {
if (!isAmdGpuBundleId(name)) return null;
const target = bundleTarget.exec(name)?.[1];
return target === undefined ? null : parseAmdGpuTargetId(target);
return target === undefined
? null
: (parseAmdGpuTargetId(target)?.key ?? null);
};
/** Preserve the full target ID separately from its processor identity. */
export const readAmdGpuNoteTarget = (
target: string,
): { readonly key: string; readonly processor: string } | null => {
const match = noteTarget.exec(target);
return match?.[1] === undefined || match[2] === undefined
? null
: { key: match[1], processor: match[2] };
const value = noteTarget.exec(target)?.[1];
return value === undefined ? null : parseAmdGpuTargetId(value);
};
/** Decode the processor field from AMDGPU ELF e_flags (EF_AMDGPU_MACH). */
+1 -1
View File
@@ -392,7 +392,7 @@ export const readAmdElfMetadata = async (
const processorConflict =
processor !== null &&
noteIdentity !== null &&
processor !== noteIdentity.processor.toLowerCase();
processor !== noteIdentity.processor;
const conflict = noteConflict || processorConflict;
const limitation = noteConflict
? "AMDGPU notes declare conflicting processor targets; processor identity is unknown"
+11 -6
View File
@@ -52,6 +52,7 @@ import {
readPtxKernels,
readPtxTarget,
} from "./PtxKernels.js";
import { parseAmdGpuTargetId } from "./AmdGpuTarget.js";
/** File declarations only. Every child range stays in its named parent coordinate space. */
export class GpuInventory {
@@ -428,12 +429,16 @@ export class GpuInventory {
state = base === inner ? "base-only" : "conflict";
} else if (ptxTargetIdentity(outer) === ptxTargetIdentity(payload.key))
state = "consistent";
else if (
row.kind === "amdgpu" &&
outer.split(":")[0] === payload.key.split(":")[0]
)
state = "unknown";
else state = "conflict";
else if (row.kind === "amdgpu") {
const outerTarget = parseAmdGpuTargetId(outer);
const payloadTarget = parseAmdGpuTargetId(payload.key);
state =
outerTarget !== null &&
payloadTarget !== null &&
outerTarget.processor === payloadTarget.processor
? "unknown"
: "conflict";
} else state = "conflict";
}
this.#retain({
payload_target: payload.value,
+16 -24
View File
@@ -26,6 +26,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js";
import {
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
type FatSliceDeclaration,
validateFatSlice,
} from "../domain/apple/machoContainer.js";
@@ -428,18 +429,12 @@ function originalOffset(
observation.provider_image_header_hex === null
? null
: Buffer.from(observation.provider_image_header_hex, "hex");
const loadedMagic = header?.readUInt32BE(0);
if (
header === null ||
loadedMagic === undefined ||
![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(loadedMagic)
)
const loadedIdentity =
header === null ? null : readMachoHeaderPrefixFacts(header).identity;
if (header === null || loadedIdentity === null)
return err(
"Hopper did not expose the loaded Mach-O header needed to identify its FAT slice",
);
const little = loadedMagic === 0xcefaedfe || loadedMagic === 0xcffaedfe;
const cpu = little ? header.readUInt32LE(4) : header.readUInt32BE(4);
const subtype = little ? header.readUInt32LE(8) : header.readUInt32BE(8);
const matches = [];
const ranges = [...layout.value.slices].sort((a, b) => a.offset - b.offset);
for (let index = 1; index < ranges.length; index++) {
@@ -460,25 +455,22 @@ function originalOffset(
return err(
`FAT architecture ${slice.index} has a truncated Mach-O header`,
);
const embeddedMagic = embedded.readUInt32BE(0);
if (
![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(embeddedMagic)
)
const embeddedIdentity = readMachoHeaderPrefixFacts(embedded).identity;
if (embeddedIdentity === null)
return err(
`FAT architecture ${slice.index} does not contain a Mach-O image`,
);
const embeddedLittle =
embeddedMagic === 0xcefaedfe || embeddedMagic === 0xcffaedfe;
const identityError = validateFatSlice(slice, size, layout.value.tableEnd, {
cpuType: embeddedLittle
? embedded.readUInt32LE(4)
: embedded.readUInt32BE(4),
cpuSubtype: embeddedLittle
? embedded.readUInt32LE(8)
: embedded.readUInt32BE(8),
});
const identityError = validateFatSlice(
slice,
size,
layout.value.tableEnd,
embeddedIdentity,
);
if (identityError !== null) return err(identityError);
if (slice.cpuType === cpu && slice.cpuSubtype === subtype)
if (
slice.cpuType === loadedIdentity.cpuType &&
slice.cpuSubtype === loadedIdentity.cpuSubtype
)
matches.push(slice);
}
const selected = matches.length === 1 ? matches[0] : undefined;
+17 -26
View File
@@ -15,6 +15,7 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
validateFatSlice,
type FatSliceDeclaration,
} from "../domain/apple/machoContainer.js";
@@ -321,15 +322,14 @@ const inspectHopperMachOImage = async (
return invalid(
`Thin Mach-O source changed after target admission: expected SHA-256 ${target.sha256}, observed ${observedSha256}`,
);
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const headerMagic = prefix.readUInt32BE(0);
if (headerMagic !== magic)
const facts = readMachoHeaderPrefixFacts(prefix);
if (facts.magic !== magic || facts.identity === null)
return invalid("Thin Mach-O header changed during profile resolution");
return ok({
method: "thin_verified_copy",
byte_length: size,
cpu_type: little ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4),
cpu_subtype: little ? prefix.readUInt32LE(8) : prefix.readUInt32BE(8),
cpu_type: facts.identity.cpuType,
cpu_subtype: facts.identity.cpuSubtype,
});
}
const isFatMagic = [0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(
@@ -390,16 +390,11 @@ const inspectHopperMachOImage = async (
const embedded = await read(slice.offset, 12);
if (embedded.length !== 12)
return invalid(`FAT architecture ${slice.index} became truncated`);
const thinMagic = embedded.readUInt32BE(0);
if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(thinMagic))
const identity = readMachoHeaderPrefixFacts(embedded).identity;
if (identity === null)
return invalid(
`FAT architecture ${slice.index} does not contain a Mach-O image`,
);
const little = thinMagic === 0xcefaedfe || thinMagic === 0xcffaedfe;
const identity = {
cpuType: little ? embedded.readUInt32LE(4) : embedded.readUInt32BE(4),
cpuSubtype: little ? embedded.readUInt32LE(8) : embedded.readUInt32BE(8),
};
const identityError = validateFatSlice(slice, size, tableEnd, identity);
if (identityError !== null) return invalid(identityError);
ranges.push(slice);
@@ -519,15 +514,12 @@ export const prepareHopperMachOImage = async (
if (!copiedResult.ok) return copiedResult;
const observed = hash.digest("hex");
const header = Buffer.concat(thinHeader);
const magic = header.length === 12 ? header.readUInt32BE(0) : 0;
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const identity = readMachoHeaderPrefixFacts(header).identity;
const headerMatches =
header.length === 12 &&
[0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic) &&
(little ? header.readUInt32LE(4) : header.readUInt32BE(4)) ===
image.cpu_type &&
(little ? header.readUInt32LE(8) : header.readUInt32BE(8)) ===
image.cpu_subtype;
identity !== null &&
identity.cpuType === image.cpu_type &&
identity.cpuSubtype === image.cpu_subtype;
if (
tableHash.digest("hex") !== image.container_table_sha256 ||
!headerMatches
@@ -591,15 +583,14 @@ const prepareThinHopperMachOImage = async (
}),
);
const observed = hash.digest("hex");
const magic = prefixLength === prefix.length ? prefix.readUInt32BE(0) : 0;
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const identity = readMachoHeaderPrefixFacts(
prefix.subarray(0, prefixLength),
).identity;
const headerMatches =
prefixLength === prefix.length &&
[0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic) &&
(little ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4)) ===
image.cpu_type &&
(little ? prefix.readUInt32LE(8) : prefix.readUInt32BE(8)) ===
image.cpu_subtype;
identity !== null &&
identity.cpuType === image.cpu_type &&
identity.cpuSubtype === image.cpu_subtype;
if (
observed !== sourceSha256 ||
copied !== image.byte_length ||
+8 -7
View File
@@ -1,5 +1,5 @@
import {
BinaryFormatFailure,
BinaryMappingFailure,
mappedFileOffset,
isZeroFilledSpan,
type FileMapping,
@@ -124,14 +124,15 @@ function* dynamicTags(image: Buffer): Generator<void, Map<bigint, bigint>> {
}
const fileOffset = (
mappings: readonly FileMapping[],
source: RelocationSource,
address: bigint,
size: number,
): number | null => {
try {
return mappedFileOffset(mappings, address, size);
return mappedFileOffset(source.mappings, address, size, source.zeroFills);
} catch (cause) {
if (cause instanceof BinaryFormatFailure) return null;
if (cause instanceof BinaryMappingFailure && cause.reason === "unmapped")
return null;
throw cause;
}
};
@@ -163,7 +164,7 @@ function* applyRelaTable(
size > BigInt(source.image.length)
)
throw malformed("invalid DT_RELASZ or DT_RELAENT");
const start = fileOffset(source.mappings, address, Number(size));
const start = fileOffset(source, address, Number(size));
if (start === null) throw malformed("RELA table is not file-backed");
for (let at = start; at < start + Number(size); at += 24) {
if ((at - start) % (24 * 4096) === 0) yield;
@@ -180,11 +181,11 @@ function* applyRelaTable(
output.unresolved.set(destination, Math.max(priorWidth ?? 0, width));
// Registration scans read this private image directly. Clear unknown
// fields so their stored bytes cannot masquerade as resolved pointers.
const target = fileOffset(source.mappings, destination, width);
const target = fileOffset(source, destination, width);
if (target !== null) output.bytes.fill(0, target, target + width);
continue;
}
const target = fileOffset(source.mappings, destination, 8);
const target = fileOffset(source, destination, 8);
const value = source.image.readBigInt64LE(at + 16) & 0xffffffffffffffffn;
if (target !== null) output.bytes.writeBigUInt64LE(value, target);
else if (
+6 -1
View File
@@ -234,7 +234,12 @@ export const readUInt32 = (
export const isMapped = (image: NativeImage, address: bigint, size: number) => {
try {
mappedFileOffset(image.container.mappings, address, size);
mappedFileOffset(
image.container.mappings,
address,
size,
image.container.zero_fills,
);
return true;
} catch (cause) {
if (cause instanceof BinaryFormatFailure) return false;
+2 -6
View File
@@ -24,6 +24,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { err, ok, type Result } from "../domain/result.js";
import { sameRegularFileState } from "../filesystem/RegularFile.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
ArtifactFileCleanupFailure,
@@ -345,12 +346,7 @@ export const readNativePlistBytes = (
{ boundary: "file-size" },
);
if (
!after.isFile() ||
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeNs !== after.mtimeNs ||
before.ctimeNs !== after.ctimeNs ||
!sameRegularFileState(before, after) ||
BigInt(position) !== before.size
)
throw new ArtifactReaderFailure(
+9 -17
View File
@@ -395,34 +395,26 @@ describe("ELF export version and name coverage", () => {
exports: [],
coverage: { status: "complete" },
});
expect(
parse(elfExportImage({ symbols: [], dynamicSymbols: false })),
).toMatchObject({ exports: [], coverage: { status: "complete" } });
for (const bytes of [
elfExportImage({ symbols: [], dynamicSymbols: false }),
elfExportImage({ symbols: [], sectionHeaders: false }),
elfExportImage({
symbols: [],
dynamicSymbols: false,
dynamicSegment: true,
}),
])
expect(parse(bytes)).toMatchObject({
exports: [],
coverage: {
status: "unsupported",
reason: expect.stringContaining("No SHT_DYNSYM"),
reason: expect.stringContaining("dynamic segment"),
examined_entries: 0,
},
});
});
it("does not turn a null-only section directory into exhaustive export absence", () => {
const bytes = elfExportImage({ symbols: [{ name: "exported_answer" }] });
expect(parse(bytes)).toMatchObject({
exports: [{ name: "exported_answer" }],
coverage: { status: "complete" },
});
// Keep every symbol byte and the existing inactive section-zero header.
bytes.writeUInt16LE(1, 60);
bytes.writeUInt16LE(0, 62);
expect(parse(bytes)).toMatchObject({
exports: [],
coverage: { status: "unsupported", examined_entries: 0 },
});
});
});
describe("ELF version requirements", () => {
+20 -4
View File
@@ -3,7 +3,7 @@ import {
BinaryReader,
BinaryResourceFailure,
mappedFileOffset,
fileBackedMappings,
imageFileMappings,
readMappedCString,
} from "../metadata/BinaryContainer.js";
import { architectureForElfMachine } from "../metadata/ElfImage.js";
@@ -111,7 +111,7 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
],
};
const reader = new BinaryReader(bytes, true);
const mappings = fileBackedMappings(image);
const mappings = imageFileMappings(image);
const map = (rva: number, size: number): number =>
mappedFileOffset(mappings, base + BigInt(rva), size, image.zero_fills);
const cString = (rva: number): string => {
@@ -460,20 +460,36 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
};
const { sections } = directory;
const dynsym = sections.find(({ type }) => type === SHT_DYNSYM);
if (dynsym === undefined)
if (
sections.length === 0 ||
(dynsym === undefined && directory.has_dynamic_segment)
)
return {
...result,
exports: [],
coverage: {
status: "unsupported",
reason:
"No SHT_DYNSYM section is available; enumerating dynamic symbols from the dynamic segment is not implemented",
sections.length === 0
? "The ELF has no section headers; enumerating dynamic symbols from the dynamic segment is not implemented"
: "The ELF declares a dynamic segment but has no dynamic symbol section; enumerating dynamic symbols from the dynamic segment is not implemented",
examined_entries: 0,
exports: 0,
},
issues: [],
limitations: ELF_LIMITATIONS,
};
if (dynsym === undefined)
return {
...result,
exports: [],
coverage: coverage([], 0, 0),
issues: [],
limitations: [
"The ELF has no dynamic symbol table, so it exports no dynamic symbols.",
...ELF_LIMITATIONS,
],
};
const width = directory.bits === 64 ? 24 : 16;
const strings = sections[dynsym.link];
if (
+33 -23
View File
@@ -9,6 +9,16 @@ export class BinaryFormatFailure extends Error {
}
}
/** A missing file span is a candidate miss; conflicting owners are malformed. */
export class BinaryMappingFailure extends BinaryFormatFailure {
constructor(
readonly reason: "unmapped" | "ambiguous",
message: string,
) {
super("malformed", message);
}
}
/** Resource boundary exceeded while decoding metadata or native container structure. */
export class BinaryResourceFailure extends Error {
constructor(
@@ -121,6 +131,14 @@ export interface BinaryContainer {
readonly sections: readonly ImageSection[];
}
/** All declared file-backed image memory, including PE headers at the image base. */
export const imageFileMappings = (
container: BinaryContainer,
): readonly FileMapping[] =>
container.pe_header_mapping === undefined
? container.mappings
: [container.pe_header_mapping, ...container.mappings];
/** Reader that checks every structural range before decoding numeric fields. */
export class BinaryReader {
constructor(
@@ -212,14 +230,6 @@ export const declaredFileRange = (
};
};
/** All file-backed image ranges, including the separately observed PE header. */
export const fileBackedMappings = (
image: BinaryContainer,
): readonly FileMapping[] =>
image.pe_header_mapping === undefined
? image.mappings
: [image.pe_header_mapping, ...image.mappings];
/** Map a physically contiguous virtual span across consistent adjacent ranges. */
export const mappedFileOffset = (
mappings: readonly FileMapping[],
@@ -245,8 +255,8 @@ export const mappedFileOffset = (
continue;
const origin = mapping.offset + Number(address - mapping.address);
if (found !== undefined && found !== origin)
throw new BinaryFormatFailure(
"malformed",
throw new BinaryMappingFailure(
"ambiguous",
"Virtual span has ambiguous or noncontiguous file mappings",
);
found = origin;
@@ -254,8 +264,8 @@ export const mappedFileOffset = (
else if (!whollyCovered) covered.push({ start, end: overlapEnd });
}
if (found === undefined)
throw new BinaryFormatFailure(
"malformed",
throw new BinaryMappingFailure(
"unmapped",
"Virtual address or string range is not file mapped",
);
if (!whollyCovered) {
@@ -265,22 +275,22 @@ export const mappedFileOffset = (
let cursor = address;
for (const span of covered) {
if (span.start > cursor)
throw new BinaryFormatFailure(
"malformed",
throw new BinaryMappingFailure(
"unmapped",
"Virtual address or string range is not file mapped",
);
if (span.end > cursor) cursor = span.end;
}
if (cursor < end)
throw new BinaryFormatFailure(
"malformed",
throw new BinaryMappingFailure(
"unmapped",
"Virtual address or string range is not file mapped",
);
}
for (const zeroFill of zeroFills) {
if (spansOverlap(address, BigInt(size), zeroFill.address, zeroFill.size))
throw new BinaryFormatFailure(
"malformed",
throw new BinaryMappingFailure(
"ambiguous",
"Virtual address has ambiguous file and zero-fill mappings",
);
}
@@ -294,7 +304,7 @@ export const readMappedCString = (
address: bigint,
maximumBytes: number,
): Buffer => {
const mappings = fileBackedMappings(image);
const mappings = imageFileMappings(image);
const chunks: Buffer[] = [];
let length = 0;
while (length <= maximumBytes) {
@@ -305,8 +315,8 @@ export const readMappedCString = (
);
if (mapping === undefined) {
mappedFileOffset(mappings, cursor, 1, image.zero_fills);
throw new BinaryFormatFailure(
"malformed",
throw new BinaryMappingFailure(
"unmapped",
"String range is not file mapped",
);
}
@@ -352,8 +362,8 @@ export const isZeroFilledSpan = (
return false;
for (const mapping of mappings) {
if (spansOverlap(address, bytes, mapping.address, BigInt(mapping.size)))
throw new BinaryFormatFailure(
"malformed",
throw new BinaryMappingFailure(
"ambiguous",
"Virtual address has ambiguous file and zero-fill mappings",
);
}
+17 -4
View File
@@ -38,6 +38,7 @@ export interface NativeSectionDirectory {
readonly program_offset: bigint;
readonly program_count: number;
readonly program_width: number;
readonly has_dynamic_segment: boolean;
readonly sections: readonly NativeSection[];
}
const STRUCTURE_BYTES = 16 * 1024 * 1024;
@@ -130,14 +131,24 @@ export function* readElfSectionDirectory(
reject("ELF extended program count is below its reserved range");
}
}
let has_dynamic_segment = false;
if (program_count !== 0) {
if (program_width < (wide ? 56 : 32))
reject("ELF program header width is invalid");
request(
fileSize,
program_offset,
BigInt(program_count) * BigInt(program_width),
const programs = new BinaryReader(
yield request(
fileSize,
program_offset,
BigInt(program_count) * BigInt(program_width),
),
little,
);
for (let index = 0; index < program_count; index++) {
if (programs.u32(index * program_width) === 2) {
has_dynamic_segment = true;
break;
}
}
}
const sections: NativeSection[] = [];
let section_names_limitation: string | undefined;
@@ -259,6 +270,7 @@ export function* readElfSectionDirectory(
program_offset,
program_count,
program_width,
has_dynamic_segment,
sections,
};
}
@@ -346,6 +358,7 @@ export function* readPeSectionDirectory(
program_offset: 0n,
program_count: 0,
program_width: 0,
has_dynamic_segment: false,
sections,
};
}
+9
View File
@@ -507,10 +507,19 @@ const TEXT_FIELDS = /^(?:segname|sectname|string #\d+)$/u;
*/
const OFFSET_STRING_FIELD = /^(name|path) (.* \(offset \d+\))$/u;
/**
* otool prints segment and section names as `<key> %.16s`: one separator
* space, then the fixed-width name, whose own leading and trailing whitespace
* belongs to the Mach-O value.
*/
const MACHO_NAME_FIELD = /^(segname|sectname)(?: (.*))?$/u;
const parseField = (rawLine: string): readonly [string, string] | undefined => {
const offsetString = OFFSET_STRING_FIELD.exec(rawLine.trimStart());
if (offsetString?.[1] !== undefined && offsetString[2] !== undefined)
return [offsetString[1], offsetString[2]];
const name = MACHO_NAME_FIELD.exec(rawLine.trimStart());
if (name?.[1] !== undefined) return [name[1], name[2] ?? ""];
// otool prints each linker option as `string #n <option>`; keep the option
// exactly, including surrounding spaces, under otool's ordinal label.
const option = /^\s*(string #[1-9]\d*)(?: (.*))?$/u.exec(rawLine);
+47 -275
View File
@@ -1,30 +1,16 @@
import { createHash } from "node:crypto";
import { chmod, mkdir, open, realpath, rm, rmdir } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import {
createConnection,
createServer,
type Server,
type Socket,
} from "node:net";
import { realpath } from "node:fs/promises";
import { join } from "node:path";
import { HopperStartError } from "../domain/hopperErrors.js";
import { safeParseJson } from "../domain/safeJson.js";
import {
acquireOwnedLease,
type LeaseAcquisition,
type LeaseFailure,
type OwnedLease,
} from "./OwnedLease.js";
interface HopperTargetLeaseOwner {
readonly runId: string;
readonly processId: number;
}
export type HopperTargetLease = OwnedLease;
export interface HopperTargetLease {
/** Bound socket identity for cleanup diagnostics; omission remains unknown. */
readonly socketPath?: string;
readonly release: () => Promise<void>;
}
export type HopperTargetLeaseAcquisition =
| { readonly acquired: true; readonly lease: HopperTargetLease }
| { readonly acquired: false; readonly owner: HopperTargetLeaseOwner };
export type HopperTargetLeaseAcquisition = LeaseAcquisition;
/** Shared private parent of Hopper lease sockets; it outlives individual sessions. */
export const HOPPER_TARGET_LEASE_DIRECTORY = join(
@@ -87,258 +73,44 @@ const acquireLease = async (input: {
readonly identity: Readonly<Record<string, unknown>>;
readonly runId: string;
readonly directory?: string;
}): Promise<HopperTargetLeaseAcquisition> => {
const directory = input.directory ?? HOPPER_TARGET_LEASE_DIRECTORY;
await ensureLeaseDirectory(directory);
const key = createHash("sha256")
.update(JSON.stringify(input.identity))
.digest("hex")
.slice(0, 32);
const socketPath = join(directory, `${key}.sock`);
const owner = { runId: input.runId, processId: process.pid };
const acquisition = await tryAcquireLease(socketPath, owner);
if (typeof acquisition !== "string") return acquisition;
// Serialize stale cleanup through the replacement bind: otherwise two
// recoverers can unlink the socket that one of them has just acquired.
const recoveryPath = `${socketPath}.recovery`;
await reserveRecovery(recoveryPath);
let recovered: HopperTargetLeaseAcquisition | "missing" | "stale" | undefined;
try {
recovered = await tryAcquireLease(socketPath, owner);
if (recovered === "stale") await rm(socketPath, { force: true });
if (typeof recovered === "string")
recovered = await tryAcquireLease(socketPath, owner);
if (typeof recovered !== "string") return recovered;
throw new HopperStartError({
userMessage:
"REA could not reserve Hopper for this request. Close competing REA sessions and retry.",
});
} finally {
try {
await rmdir(recoveryPath);
} catch (cause: unknown) {
if (typeof recovered !== "string" && recovered?.acquired)
await recovered.lease.release();
throw new HopperStartError({
cause,
userMessage: `REA could not remove its Hopper recovery directory at ${JSON.stringify(recoveryPath)}. Check this directory's write access before retrying.`,
});
}
}
};
const reserveRecovery = async (recoveryPath: string): Promise<void> => {
try {
await mkdir(recoveryPath, { mode: 0o700 });
} catch (cause: unknown) {
const busy =
typeof cause === "object" &&
cause !== null &&
Reflect.get(cause, "code") === "EEXIST";
throw new HopperStartError({
cause,
userMessage: busy
? `REA could not reserve Hopper lease recovery at ${JSON.stringify(recoveryPath)}; the existing socket was left unchanged. Retry after concurrent startup finishes. If recovery was interrupted, stop REA sessions and remove this recovery directory before retrying.`
: `REA could not create its Hopper recovery directory at ${JSON.stringify(recoveryPath)}; the existing socket was left unchanged. Check the parent directory's write access before retrying.`,
});
}
};
const tryAcquireLease = async (
socketPath: string,
owner: HopperTargetLeaseOwner,
): Promise<HopperTargetLeaseAcquisition | "missing" | "stale"> => {
const server = createServer((socket) => sendOwner(socket, owner));
// The lease lives exactly as long as its owning process. A lease retained
// after unconfirmed cleanup must not keep a completed one-shot command
// alive; process exit leaves a stale socket that later acquirers recover.
server.unref();
if (!(await bind(server, socketPath))) {
const existingOwner = await readOwner(socketPath);
return typeof existingOwner === "string"
? existingOwner
: { acquired: false, owner: existingOwner };
}
try {
await chmod(socketPath, 0o600);
} catch (cause: unknown) {
await closeServer(server);
throw cause;
}
let released = false;
return {
acquired: true,
lease: {
socketPath,
release: async () => {
if (released) return;
// Node removes a bound Unix socket on close. A second unlink here
// could remove a new owner's socket bound after that close.
await closeServer(server);
released = true;
},
},
};
};
const ensureLeaseDirectory = async (directory: string): Promise<void> => {
try {
await mkdir(directory, { recursive: true, mode: 0o700 });
} catch (cause: unknown) {
throw new HopperStartError({
cause,
userMessage: "REA could not create its private Hopper lease directory.",
});
}
// Use O_DIRECTORY|O_NOFOLLOW to open the directory without following symlinks,
// then inspect and secure that descriptor. Later socket operations still use
// the path and are not made atomic by this directory validation.
let handle;
try {
handle = await open(
directory,
fsConstants.O_RDONLY |
(fsConstants.O_DIRECTORY ?? 0) |
(fsConstants.O_NOFOLLOW ?? 0),
);
const directoryStat = await handle.stat();
if (
directoryStat.isSymbolicLink() ||
directoryStat.isDirectory() === false ||
(process.getuid !== undefined && directoryStat.uid !== process.getuid())
)
throw new HopperStartError({
userMessage:
"REA's Hopper lease directory is not a private directory owned by this user.",
});
// POSIX permission hardening: chmod on the already-opened descriptor avoids
// a second TOCTOU window where the directory could be swapped between check
// and permission fix. Windows DACL-based security is handled separately.
const posixPermissionsApply = process.getuid !== undefined;
if (posixPermissionsApply && (directoryStat.mode & 0o077) !== 0) {
await handle.chmod(0o700);
// Re-verify permissions on the same opened directory.
const afterChmod = await handle.stat();
if (
afterChmod.isSymbolicLink() ||
afterChmod.isDirectory() === false ||
(afterChmod.mode & 0o077) !== 0
)
throw new HopperStartError({
userMessage:
"REA's Hopper lease directory permissions could not be secured.",
});
}
} catch (cause: unknown) {
if (cause instanceof HopperStartError) throw cause;
const accessDenied =
process.getuid !== undefined &&
cause instanceof Error &&
"code" in cause &&
cause.code === "EACCES";
throw new HopperStartError({
cause,
userMessage: accessDenied
? `REA could not access its Hopper lease directory at ${JSON.stringify(directory)}. Check its ownership and parent-directory access. For a directory you own, restore owner-only read, write, and search permissions (0700), then retry.`
: "REA could not inspect its private Hopper lease directory.",
});
} finally {
await handle?.close();
}
};
const bind = (server: Server, socketPath: string): Promise<boolean> =>
new Promise((resolveBind, reject) => {
const onError = (error: NodeJS.ErrnoException): void => {
server.removeListener("listening", onListening);
if (error.code === "EADDRINUSE") resolveBind(false);
else reject(error);
};
const onListening = (): void => {
server.removeListener("error", onError);
resolveBind(true);
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(socketPath);
}): Promise<HopperTargetLeaseAcquisition> =>
acquireOwnedLease({
identity: input.identity,
runId: input.runId,
directory: input.directory ?? HOPPER_TARGET_LEASE_DIRECTORY,
failure: hopperLeaseFailure,
});
const sendOwner = (socket: Socket, owner: HopperTargetLeaseOwner): void => {
socket.end(`${JSON.stringify(owner)}\n`);
const hopperLeaseFailure = (
failure: LeaseFailure,
cause?: unknown,
): HopperStartError =>
new HopperStartError({
...(cause === undefined ? {} : { cause }),
userMessage: hopperLeaseMessage(failure),
});
const hopperLeaseMessage = (failure: LeaseFailure): string => {
switch (failure.kind) {
case "directory_create":
return "REA could not create its private Hopper lease directory.";
case "directory_not_private":
return "REA's Hopper lease directory is not a private directory owned by this user.";
case "directory_permissions":
return "REA's Hopper lease directory permissions could not be secured.";
case "directory_access_denied":
return `REA could not access its Hopper lease directory at ${JSON.stringify(failure.directory)}. Check its ownership and parent-directory access. For a directory you own, restore owner-only read, write, and search permissions (0700), then retry.`;
case "directory_inspect":
return "REA could not inspect its private Hopper lease directory.";
case "recovery_busy":
return `REA could not reserve Hopper lease recovery at ${JSON.stringify(failure.path)}; the existing socket was left unchanged. Retry after concurrent startup finishes. If recovery was interrupted, stop REA sessions and remove this recovery directory before retrying.`;
case "recovery_create":
return `REA could not create its Hopper recovery directory at ${JSON.stringify(failure.path)}; the existing socket was left unchanged. Check the parent directory's write access before retrying.`;
case "recovery_remove":
return `REA could not remove its Hopper recovery directory at ${JSON.stringify(failure.path)}. Check this directory's write access before retrying.`;
case "owner_unverified":
return `REA could not verify the existing Hopper lease (${failure.reason}); it was left unchanged. Retry when the owning REA session is responsive.`;
case "unrecoverable":
return "REA could not reserve Hopper for this request. Close competing REA sessions and retry.";
}
};
const readOwner = (
socketPath: string,
): Promise<HopperTargetLeaseOwner | "missing" | "stale"> =>
new Promise((resolveOwner, rejectOwner) => {
const socket = createConnection(socketPath);
socket.setEncoding("utf8");
let response = "";
let settled = false;
const fail = (reason: string, cause?: unknown): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
rejectOwner(
new HopperStartError({
cause,
userMessage: `REA could not verify the existing Hopper lease (${reason}); it was left unchanged. Retry when the owning REA session is responsive.`,
}),
);
};
const timer = setTimeout(() => fail("owner response timed out"), 250);
const finish = (
owner: HopperTargetLeaseOwner | "missing" | "stale",
): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
resolveOwner(owner);
};
socket.on("data", (chunk: string) => {
response += chunk;
const newline = response.indexOf("\n");
if (newline < 0) return;
const parsed = safeParseJson(response.slice(0, newline));
if (!parsed.ok) {
fail("malformed owner response");
return;
}
const value: unknown = parsed.value;
const runId =
typeof value === "object" && value !== null
? Reflect.get(value, "runId")
: undefined;
const processId =
typeof value === "object" && value !== null
? Reflect.get(value, "processId")
: undefined;
if (
typeof runId === "string" &&
runId.length > 0 &&
typeof processId === "number" &&
Number.isSafeInteger(processId) &&
processId > 0
)
finish({ runId, processId });
else fail("invalid owner identity");
});
socket.on("error", (cause: NodeJS.ErrnoException) => {
if (cause.code === "ENOENT") finish("missing");
else if (cause.code === "ECONNREFUSED") finish("stale");
else fail(cause.code ?? "owner connection failed", cause);
});
socket.on("end", () => fail("owner closed without a complete response"));
});
const closeServer = (server: Server): Promise<void> =>
new Promise((resolveClose, reject) => {
if (!server.listening) {
resolveClose();
return;
}
server.close((error) => (error ? reject(error) : resolveClose()));
});
+320
View File
@@ -0,0 +1,320 @@
import { createHash } from "node:crypto";
import { chmod, mkdir, open, rm, rmdir } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import {
createConnection,
createServer,
type Server,
type Socket,
} from "node:net";
import { join } from "node:path";
import { safeParseJson } from "../domain/safeJson.js";
/** The process that holds a lease, as that process reports itself. */
export interface LeaseOwner {
readonly runId: string;
readonly processId: number;
}
export interface OwnedLease {
/** Bound socket identity for cleanup diagnostics; omission remains unknown. */
readonly socketPath?: string;
readonly release: () => Promise<void>;
}
export type LeaseAcquisition =
| { readonly acquired: true; readonly lease: OwnedLease }
| { readonly acquired: false; readonly owner: LeaseOwner };
/** Why a lease could be neither acquired nor attributed to its owner. */
export type LeaseFailure =
| { readonly kind: "directory_create" }
| { readonly kind: "directory_not_private" }
| { readonly kind: "directory_permissions" }
| { readonly kind: "directory_access_denied"; readonly directory: string }
| { readonly kind: "directory_inspect" }
| { readonly kind: "recovery_busy"; readonly path: string }
| { readonly kind: "recovery_create"; readonly path: string }
| { readonly kind: "recovery_remove"; readonly path: string }
| { readonly kind: "owner_unverified"; readonly reason: string }
| { readonly kind: "unrecoverable" };
export type LeaseFailureFactory = (
failure: LeaseFailure,
cause?: unknown,
) => Error;
/**
* Hold one named lease for as long as this process keeps it.
*
* The lease is a listening local socket, so the operating system ends it when
* the owning process exits. A crashed owner leaves a socket that refuses
* connections; recovery replaces only that stale socket. A live owner answers
* with its own identity, which is reported instead of a PID guess.
*/
export const acquireOwnedLease = async (input: {
readonly identity: Readonly<Record<string, unknown>>;
readonly runId: string;
/** Private per-user directory for POSIX sockets. Unused on Windows. */
readonly directory: string;
readonly failure: LeaseFailureFactory;
}): Promise<LeaseAcquisition> => {
const produced = new WeakSet<Error>();
const fail = (failure: LeaseFailure, cause?: unknown): Error => {
const error = input.failure(failure, cause);
produced.add(error);
return error;
};
const key = createHash("sha256")
.update(JSON.stringify(input.identity))
.digest("hex")
.slice(0, 32);
const owner = { runId: input.runId, processId: process.pid };
if (process.platform === "win32") {
// A named pipe disappears with its last handle, so no stale pipe needs
// recovery. A pipe that vanished between bind and owner lookup is retried.
const pipePath = `\\\\.\\pipe\\rea-lease-${key}`;
for (let attempt = 0; attempt < 2; attempt += 1) {
const acquisition = await tryAcquireLease(pipePath, owner, fail, false);
if (typeof acquisition !== "string") return acquisition;
}
throw fail({ kind: "unrecoverable" });
}
await ensureLeaseDirectory(input.directory, fail, produced);
const socketPath = join(input.directory, `${key}.sock`);
const acquisition = await tryAcquireLease(socketPath, owner, fail, true);
if (typeof acquisition !== "string") return acquisition;
// Serialize stale cleanup through the replacement bind: otherwise two
// recoverers can unlink the socket that one of them has just acquired.
const recoveryPath = `${socketPath}.recovery`;
await reserveRecovery(recoveryPath, fail);
let recovered: LeaseAcquisition | "missing" | "stale" | undefined;
try {
recovered = await tryAcquireLease(socketPath, owner, fail, true);
if (recovered === "stale") await rm(socketPath, { force: true });
if (typeof recovered === "string")
recovered = await tryAcquireLease(socketPath, owner, fail, true);
if (typeof recovered !== "string") return recovered;
throw fail({ kind: "unrecoverable" });
} finally {
try {
await rmdir(recoveryPath);
} catch (cause: unknown) {
if (typeof recovered !== "string" && recovered?.acquired)
await recovered.lease.release();
throw fail({ kind: "recovery_remove", path: recoveryPath }, cause);
}
}
};
const reserveRecovery = async (
recoveryPath: string,
fail: (failure: LeaseFailure, cause?: unknown) => Error,
): Promise<void> => {
try {
await mkdir(recoveryPath, { mode: 0o700 });
} catch (cause: unknown) {
const busy =
typeof cause === "object" &&
cause !== null &&
Reflect.get(cause, "code") === "EEXIST";
throw fail(
busy
? { kind: "recovery_busy", path: recoveryPath }
: { kind: "recovery_create", path: recoveryPath },
cause,
);
}
};
const tryAcquireLease = async (
socketPath: string,
owner: LeaseOwner,
fail: (failure: LeaseFailure, cause?: unknown) => Error,
securePath: boolean,
): Promise<LeaseAcquisition | "missing" | "stale"> => {
const server = createServer((socket) => sendOwner(socket, owner));
// The lease lives exactly as long as its owning process. A lease retained
// after unconfirmed cleanup must not keep a completed one-shot command
// alive; process exit leaves a stale socket that later acquirers recover.
server.unref();
if (!(await bind(server, socketPath))) {
const existingOwner = await readOwner(socketPath, fail);
return typeof existingOwner === "string"
? existingOwner
: { acquired: false, owner: existingOwner };
}
if (securePath)
try {
await chmod(socketPath, 0o600);
} catch (cause: unknown) {
await closeServer(server);
throw cause;
}
let released = false;
return {
acquired: true,
lease: {
socketPath,
release: async () => {
if (released) return;
// Node removes a bound Unix socket on close. A second unlink here
// could remove a new owner's socket bound after that close.
await closeServer(server);
released = true;
},
},
};
};
const ensureLeaseDirectory = async (
directory: string,
fail: (failure: LeaseFailure, cause?: unknown) => Error,
produced: WeakSet<Error>,
): Promise<void> => {
try {
await mkdir(directory, { recursive: true, mode: 0o700 });
} catch (cause: unknown) {
throw fail({ kind: "directory_create" }, cause);
}
// Use O_DIRECTORY|O_NOFOLLOW to open the directory without following symlinks,
// then inspect and secure that descriptor. Later socket operations still use
// the path and are not made atomic by this directory validation.
let handle;
try {
handle = await open(
directory,
fsConstants.O_RDONLY |
(fsConstants.O_DIRECTORY ?? 0) |
(fsConstants.O_NOFOLLOW ?? 0),
);
const directoryStat = await handle.stat();
if (
directoryStat.isSymbolicLink() ||
directoryStat.isDirectory() === false ||
(process.getuid !== undefined && directoryStat.uid !== process.getuid())
)
throw fail({ kind: "directory_not_private" });
// POSIX permission hardening: chmod on the already-opened descriptor avoids
// a second TOCTOU window where the directory could be swapped between check
// and permission fix.
const posixPermissionsApply = process.getuid !== undefined;
if (posixPermissionsApply && (directoryStat.mode & 0o077) !== 0) {
await handle.chmod(0o700);
// Re-verify permissions on the same opened directory.
const afterChmod = await handle.stat();
if (
afterChmod.isSymbolicLink() ||
afterChmod.isDirectory() === false ||
(afterChmod.mode & 0o077) !== 0
)
throw fail({ kind: "directory_permissions" });
}
} catch (cause: unknown) {
if (cause instanceof Error && produced.has(cause)) throw cause;
const accessDenied =
process.getuid !== undefined &&
cause instanceof Error &&
"code" in cause &&
cause.code === "EACCES";
throw fail(
accessDenied
? { kind: "directory_access_denied", directory }
: { kind: "directory_inspect" },
cause,
);
} finally {
await handle?.close();
}
};
const bind = (server: Server, socketPath: string): Promise<boolean> =>
new Promise((resolveBind, reject) => {
const onError = (error: NodeJS.ErrnoException): void => {
server.removeListener("listening", onListening);
if (error.code === "EADDRINUSE") resolveBind(false);
else reject(error);
};
const onListening = (): void => {
server.removeListener("error", onError);
resolveBind(true);
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(socketPath);
});
const sendOwner = (socket: Socket, owner: LeaseOwner): void => {
socket.end(`${JSON.stringify(owner)}\n`);
};
const readOwner = (
socketPath: string,
fail: (failure: LeaseFailure, cause?: unknown) => Error,
): Promise<LeaseOwner | "missing" | "stale"> =>
new Promise((resolveOwner, rejectOwner) => {
const socket = createConnection(socketPath);
socket.setEncoding("utf8");
let response = "";
let settled = false;
const reject = (reason: string, cause?: unknown): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
rejectOwner(fail({ kind: "owner_unverified", reason }, cause));
};
const timer = setTimeout(() => reject("owner response timed out"), 250);
const finish = (owner: LeaseOwner | "missing" | "stale"): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
resolveOwner(owner);
};
socket.on("data", (chunk: string) => {
response += chunk;
const newline = response.indexOf("\n");
if (newline < 0) return;
const parsed = safeParseJson(response.slice(0, newline));
if (!parsed.ok) {
reject("malformed owner response");
return;
}
const value: unknown = parsed.value;
const runId =
typeof value === "object" && value !== null
? Reflect.get(value, "runId")
: undefined;
const processId =
typeof value === "object" && value !== null
? Reflect.get(value, "processId")
: undefined;
if (
typeof runId === "string" &&
runId.length > 0 &&
typeof processId === "number" &&
Number.isSafeInteger(processId) &&
processId > 0
)
finish({ runId, processId });
else reject("invalid owner identity");
});
socket.on("error", (cause: NodeJS.ErrnoException) => {
if (cause.code === "ENOENT") finish("missing");
else if (cause.code === "ECONNREFUSED") finish("stale");
else reject(cause.code ?? "owner connection failed", cause);
});
socket.on("end", () => reject("owner closed without a complete response"));
});
const closeServer = (server: Server): Promise<void> =>
new Promise((resolveClose, reject) => {
if (!server.listening) {
resolveClose();
return;
}
server.close((error) => (error ? reject(error) : resolveClose()));
});
+20 -5
View File
@@ -15,6 +15,7 @@ import type { ProgressReporter } from "../application/ProgressReporter.js";
import { EnhancedTools } from "../application/EnhancedTools.js";
import type { ValidatedEnhancedCall } from "../application/EnhancedToolTypes.js";
import {
composedLimitationBudget,
executeComposedWorkflow,
createWorkflowEvidence,
workflowSnapshotRecord,
@@ -71,6 +72,7 @@ export const registerEnhancedTools = (
const objcClasses = toolContract("get_objc_classes");
const objcProtocols = toolContract("get_objc_protocols");
const batchDecompile = toolContract("batch_decompile");
const decompileAll = toolContract("decompile_all");
const callGraph = toolContract("get_call_graph");
const swiftTypes = toolContract("analyze_swift_types");
const xrefsToName = toolContract("find_xrefs_to_name");
@@ -126,6 +128,15 @@ export const registerEnhancedTools = (
context,
}),
);
server.registerTool(
decompileAll.name,
toolRegistrationOptions(decompileAll),
(input, context) =>
executeEnhancedTool(registration, decompileAll, {
validatedCall: { name: "decompile_all", input },
context,
}),
);
server.registerTool(
callGraph.name,
toolRegistrationOptions(callGraph),
@@ -236,6 +247,7 @@ const executeEnhancedTool = async (
progress,
});
const analysis: AnalysisOperationPort = {
resourceScope: admittedAnalysis.resourceScope,
execute: (operation, operationParameters, executionOptions) =>
admittedAnalysis.execute(operation, operationParameters, {
...executionOptions,
@@ -243,11 +255,14 @@ const executeEnhancedTool = async (
}),
};
const result = await logToolExecution(registration.logger, name, () =>
executeComposedWorkflow(analysis, (observed) =>
new EnhancedTools(observed).executeValidated(
validatedCall,
context.mcpReq.signal,
),
executeComposedWorkflow(
analysis,
(observed) =>
new EnhancedTools(observed).executeValidated(
validatedCall,
context.mcpReq.signal,
),
composedLimitationBudget(name),
),
);
await reportOperationEnd(progress, name, result.ok);
+129 -69
View File
@@ -207,76 +207,136 @@ const createWabtArtifactFixture = async () => {
return { selectedRoot, path, bytes, dump };
};
it("keeps core WASM usable when optional name-section metadata is malformed", async () => {
const fixture = await createWabtArtifactFixture();
const diagnostic =
"0000011: warning: invalid sub-section size: extends past end\n";
const dump = [
"",
"module.wasm:\tfile format wasm 0x1",
"",
"Sections:",
"",
' Custom start=0x0000000a end=0x0000004c (size=0x00000042) "name"',
"",
"Section Details:",
"",
].join("\n");
const provider = new WabtArtifactProvider(
{ REA_WABT_BIN_DIRECTORY: fixture.selectedRoot },
(input) => {
const version = input.arguments[0] === "--version";
const isObjdump = input.command.includes("wasm-objdump");
const isDecoder = input.command.includes("wasm2wat");
const stdout = version
? "1.0.42\n"
: isObjdump
? dump
: isDecoder
? '(module (@custom "name" "abc"))\n'
: "";
const stderr = version ? "" : diagnostic;
return spawnOwnedProviderProcess({
...input,
hostEnvironment: nodeHostEnvironment(input.hostEnvironment),
command: process.execPath,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)});`,
],
expectedCommand: null,
it.each([
{
label: "initially malformed",
preamble: "",
payload: "abc",
diagnostic:
"0000011: warning: invalid sub-section size: extends past end\n",
metadata: "uninterpretable",
},
{
label: "partially decoded",
preamble: "module name: <hello>\n",
payload: String.raw`\00\06\05hello\01\02\01`,
diagnostic:
"0000011: warning: invalid sub-section size: extends past end\n",
metadata: "uninterpretable",
},
{
label: "an inner map rejected by WABT",
preamble: "module name: <hello>\n",
payload: String.raw`\00\06\05hello\01\01\01`,
diagnostic:
"000002f: warning: invalid name count 1, only 0 bytes left in section\n",
metadata: "unknown",
},
{
label: "a valid empty function-name map",
preamble: "module name: <hello>\n",
payload: String.raw`\00\06\05hello\01\01\00`,
diagnostic: "",
metadata: "complete",
},
{
label: "a valid name with an unattributed custom-section warning",
preamble: "module name: <hello>\n",
payload: String.raw`\00\06\05hello\01\01\00`,
diagnostic:
"0000030: warning: unable to read u32 leb128: linking metadata version\n",
metadata: "unknown",
},
])(
"keeps core WASM usable when optional name metadata is $label",
async ({ preamble, payload, diagnostic, metadata }) => {
const fixture = await createWabtArtifactFixture();
const dump = [
"",
`module.wasm:\tfile format wasm 0x1\n${preamble}`.trimEnd(),
"",
"Sections:",
"",
' Custom start=0x0000000a end=0x0000004c (size=0x00000042) "name"',
"",
"Section Details:",
"",
].join("\n");
const provider = new WabtArtifactProvider(
{ REA_WABT_BIN_DIRECTORY: fixture.selectedRoot },
(input) => {
const version = input.arguments[0] === "--version";
const isObjdump = input.command.includes("wasm-objdump");
const isDecoder = input.command.includes("wasm2wat");
const stdout = version
? "1.0.42\n"
: isObjdump
? dump
: isDecoder
? `(module (@custom "name" "${payload}"))\n`
: "";
const stderr = version ? "" : diagnostic;
return spawnOwnedProviderProcess({
...input,
hostEnvironment: nodeHostEnvironment(input.hostEnvironment),
command: process.execPath,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)});`,
],
expectedCommand: null,
});
},
);
try {
const result = await provider.inspect({
path: fixture.path,
glue_paths: [],
candidate_paths: [],
});
},
);
try {
const result = await provider.inspect({
path: fixture.path,
glue_paths: [],
candidate_paths: [],
});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
validation: "valid",
debug_name_metadata: "uninterpretable",
custom_section_warnings: [
{ tool: "wasm-validate", text: diagnostic },
{ tool: "wasm-objdump", text: diagnostic },
{ tool: "wasm2wat", text: diagnostic },
],
sections: [{ kind: "Custom", start: 10, end: 76, description: '"name"' }],
limitations: expect.arrayContaining([
expect.stringContaining("optional WebAssembly name custom section"),
]),
});
expect(
wasmArtifactSchema.parse(result.value.result).tool_profile.commands[0]
?.arguments,
).toContain("--ignore-custom-section-errors");
expect(await readFile(fixture.path)).toEqual(fixture.bytes);
} finally {
await provider.close();
}
});
if (!result.ok) throw result.error;
const report = wasmArtifactSchema.parse(result.value.result);
expect(report).toMatchObject({
validation: "valid",
debug_name_metadata: metadata,
custom_section_warnings:
diagnostic === ""
? []
: [
{ tool: "wasm-validate", text: diagnostic },
{ tool: "wasm-objdump", text: diagnostic },
{ tool: "wasm2wat", text: diagnostic },
],
sections: [
{ kind: "Custom", start: 10, end: 76, description: '"name"' },
],
});
if (metadata !== "complete")
expect(report.limitations).toContainEqual(
expect.stringContaining(
"complete debug-name coverage remains unknown",
),
);
else
expect(report.limitations.join("\n")).not.toContain(
"complete debug-name coverage remains unknown",
);
expect(report.artifact.sha256).toBe(
createHash("sha256").update(fixture.bytes).digest("hex"),
);
expect(report.tool_profile.commands[0]?.arguments).toContain(
"--ignore-custom-section-errors",
);
expect(await readFile(fixture.path)).toEqual(fixture.bytes);
expect(report.headers).toBe(
dump.slice(0, dump.indexOf("Section Details:\n")),
);
expect(report.wat.text).toBe(`(module (@custom "name" "${payload}"))\n`);
} finally {
await provider.close();
}
},
);
const assertModeOutcome = (
mode: OutcomeMode,
+13 -2
View File
@@ -444,12 +444,23 @@ export class WabtArtifactProvider implements WasmArtifactPort {
exports,
customSectionNames,
debugModuleNames,
debugNameMetadata,
debugNameMetadata: decodedNameMetadata,
interface: interfaceFacts,
} = parseWabtWat(wat.stdout.text);
// WAT framing reads module names, not every inner name map. A producer
// warning may concern a skipped body or another custom section; without
// section attribution it cannot establish complete debug-name coverage.
const debugNameMetadata =
decodedNameMetadata === "complete" && customSectionWarnings.length > 0
? "unknown"
: decodedNameMetadata;
if (debugNameMetadata === "uninterpretable")
optionalLimitations.push(
"The optional WebAssembly name custom section could not be interpreted; its original custom-section payload remains in decoded WAT, while debug module names are unavailable.",
"The optional WebAssembly name custom section could not be fully interpreted; its original payload remains in decoded WAT. Observed module-name displays are retained for producer framing; complete debug-name coverage remains unknown.",
);
else if (debugNameMetadata === "unknown")
optionalLimitations.push(
"WABT custom-section warning attribution is unknown, so complete debug-name coverage remains unknown. Observed module-name displays are retained for producer framing.",
);
if (customSectionWarnings.length > 0)
optionalLimitations.push(
+35
View File
@@ -203,6 +203,41 @@ it.each([
debugNameMetadata: "uninterpretable",
});
});
it.each([
String.raw`\00\06\05hello\01\02\01`,
String.raw`\00\07\05hello\00`,
String.raw`\00\06\05hello\00\06\05later`,
])(
"preserves a module name already observed before malformed metadata: %s",
(payload) => {
expect(
parseWabtWat(`(module (@custom "name" "${payload}"))`),
).toMatchObject({
debugModuleNames: ["hello"],
debugNameMetadata: "uninterpretable",
});
},
);
it("stops before a module name that WABT rejects as out of order", () => {
expect(
parseWabtWat(String.raw`(module (@custom "name" "\01\00\00\06\05hello"))`),
).toMatchObject({
debugModuleNames: [],
debugNameMetadata: "uninterpretable",
});
});
it("keeps observed names across custom sections while coverage stays uninterpretable", () => {
expect(
parseWabtWat(String.raw`(module
(@custom "name" "\00\06\05hello")
(@custom "name" "\00\06\05short\01\02\01")
(@custom "name" "\00\06\05later"))`),
).toMatchObject({
customSectionNames: ["name", "name", "name"],
debugModuleNames: ["hello", "short", "later"],
debugNameMetadata: "uninterpretable",
});
});
it("frames custom section names at their NUL-terminated objdump display", async () => {
const text = await golden();
expect(parseWabtOutput(text, 76, ["test\0hidden"]).sections.at(-1)).toEqual({
+36 -10
View File
@@ -1,10 +1,18 @@
import { parseWabtInterface } from "./WabtInterface.js";
import { watString, watStringEnd } from "./WabtString.js";
import { err, ok, type Result } from "../domain/result.js";
interface WabtNameMetadataFailure {
readonly _tag: "WabtNameMetadataFailure";
readonly reason: string;
/** Names WABT already observed before rejecting the remaining metadata. */
readonly partial: readonly string[];
}
/** Read only module-name metadata from WABT's preserved name-section payload. */
const moduleNames = (
bytes: Uint8Array,
): { names: string[]; complete: boolean } => {
): Result<readonly string[], WabtNameMetadataFailure> => {
const names: string[] = [];
let offset = 0;
const uint32 = (end: number): number => {
@@ -19,28 +27,41 @@ const moduleNames = (
throw new Error("Incomplete WABT name-section metadata.");
};
try {
let previousType = -1;
while (offset < bytes.length) {
const type = uint32(bytes.length);
if (type <= previousType)
throw new Error("Duplicate or out-of-order WABT name subsection.");
previousType = type;
const size = uint32(bytes.length);
const end = offset + size;
if (end > bytes.length)
throw new Error("Incomplete WABT name-section metadata.");
if (type === 0 && size !== 0) {
const length = uint32(end);
if (offset + length !== end)
const nameEnd = offset + length;
if (nameEnd > end)
throw new Error("Incomplete WABT module-name metadata.");
names.push(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(offset, end),
bytes.subarray(offset, nameEnd),
),
);
// WABT calls OnModuleName before rejecting trailing subsection bytes.
if (nameEnd !== end)
throw new Error("Unfinished WABT module-name subsection.");
}
offset = end;
}
} catch {
return { names, complete: false };
} catch (cause) {
if (!(cause instanceof Error)) throw cause;
return err({
_tag: "WabtNameMetadataFailure",
reason: cause.message,
partial: names,
});
}
return { names, complete: true };
return ok(names);
};
/** Extract exact top-level WABT WAT forms; no raw WASM decoding or import/export name unescaping. */
@@ -135,11 +156,16 @@ export const parseWabtWat = (text: string) => {
nameString.end + payloadPrefix[0].length,
);
const names = moduleNames(payload.bytes);
// Framing uses every name decoded before an optional subsection failure.
debugModuleNames.push(...names.names);
if (!names.complete) debugNameMetadata = "uninterpretable";
else if (debugNameMetadata !== "uninterpretable")
// Framing needs every observed display, even when optional
// metadata coverage is incomplete in this or an earlier section.
debugModuleNames.push(
...(names.ok ? names.value : names.error.partial),
);
if (!names.ok) {
debugNameMetadata = "uninterpretable";
} else if (debugNameMetadata !== "uninterpretable") {
debugNameMetadata = "complete";
}
}
}
}
@@ -0,0 +1,69 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { nativeExportsSchema } from "../../../src/domain/native/nativeExports.js";
import { elfExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"keeps unenumerated ELF dynamic exports unsupported through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-elf-dynamic-exports-");
const mcp =
surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined;
for (const encoding of [
{ bits: 64, little: true },
{ bits: 32, little: false },
] as const) {
for (const sectionHeaders of ["null-only", false, true] as const) {
const path = join(
root,
`exports-${encoding.bits}-${sectionHeaders}.so`,
);
const bytes = elfExportImage({
...encoding,
symbols: [{ name: "exported_answer" }],
dynamicSegment: true,
sectionHeaders,
});
await writeFile(path, bytes);
let document: unknown;
if (surface === "CLI") {
const response = await cli.run({
arguments: ["inspect-native-exports", path, "--json"],
});
expect(response.exitCode, response.stderr).toBe(0);
document = response.json;
} else {
const response = await mcp!.call("inspect_native_exports", { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
document = response.structuredContent;
}
const evidence = toolContract(
"inspect_native_exports",
).outputSchema.parse(document);
const result = nativeExportsSchema.parse(evidence.normalized_result);
expect(result).toMatchObject({
format: "elf",
coverage: {
status: sectionHeaders === true ? "complete" : "unsupported",
exports: sectionHeaders === true ? 1 : 0,
},
issues: [],
});
if (sectionHeaders === true) {
expect(result.exports).toMatchObject([{ name: "exported_answer" }]);
} else {
expect(result.exports).toEqual([]);
expect(result.coverage.reason).toContain("dynamic segment");
expect(result.limitations.join(" ")).not.toContain(
"so it exports no dynamic symbols",
);
}
}
}
},
);
@@ -0,0 +1,79 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { evidenceResultOf } from "../../src/contracts/toolOutputSchemaPrimitives.js";
import { goBinarySchema } from "../../src/domain/go/goBinary.js";
import {
createGoCrossLoadPointerFixture,
createGoPeHeaderPointerFixture,
} from "../fixtures/go/image.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest, runFileCli } from "../support/cli/cliFixture.js";
cliTest(
"retains header-backed PE and cross-load ELF pointer metadata through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-go-pointer-mappings-");
const { client, call } = await connectLocalToolsMcp();
const cases = [
...([32, 64] as const).flatMap((bits) =>
(["contents", "descriptors"] as const).map((placement) => ({
name: `pe${bits}-header-${placement}`,
fixture: createGoPeHeaderPointerFixture(bits, placement),
})),
),
...(["contents", "descriptor"] as const).map((placement) => ({
name: `elf-cross-load-${placement}`,
fixture: createGoCrossLoadPointerFixture(placement),
})),
];
for (const { name, fixture } of cases) {
const path = join(root, name);
await writeFile(path, fixture.bytes);
const result = await runFileCli(cli, {
arguments: ["inspect-go-binary", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
expect(evidence.normalized_result.build_info).toMatchObject({
encoding: "pointer",
go_version: "go1.26.0",
go_version_bytes_base64: Buffer.from("go1.26.0").toString("base64"),
module_bytes_base64: fixture.moduleBytes.toString("base64"),
module: { path: "example.com/tool/cmd/tool" },
version_location: { offset: fixture.versionOffset, bytes: 8 },
module_location: {
offset: fixture.moduleOffset,
bytes: fixture.moduleBytes.length,
},
});
expect(evidence.subject?.digest?.sha256).toBe(
createHash("sha256").update(fixture.bytes).digest("hex"),
);
const response = await call("inspect_go_binary", { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(response.structuredContent).toEqual(evidence);
expect(await readFile(path)).toEqual(fixture.bytes);
}
const damaged = createGoCrossLoadPointerFixture("contents");
damaged.bytes.writeBigUInt64LE(0x11001n, 200); // A one-byte virtual gap.
const path = join(root, "elf-cross-load-gap");
await writeFile(path, damaged.bytes);
const rejected = await runFileCli(cli, {
arguments: ["inspect-go-binary", path, "--json"],
});
expect(rejected.exitCode).toBe(1);
expect(rejected.json).toMatchObject({
code: "invalid_request",
details: { issues: [{ path: ["path"], reason: "invalid_format" }] },
});
const response = await call("inspect_go_binary", { path });
expect(parseMcpToolError(response)).toEqual({ error: rejected.json });
await client.ping();
},
60000,
);
+102
View File
@@ -0,0 +1,102 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { writeFakeKuna } from "../providers/kuna/fakeKuna.js";
const FUNCTIONS = [
{ name: "main", addr: "0x1000" },
{ name: "parse", addr: "0x1040" },
{ name: "late", addr: "0x1080" },
] as const;
describe("decompile-all CLI", () => {
cliTest(
"maps filters, limit and a relative output directory to the export",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-decompile-all-cli-");
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS });
const target = join(root, "target.elf");
await writeFile(target, elf(2, 1, 62));
const environment = {
REA_LOG_LEVEL: "silent",
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: join(root, "cache"),
};
const result = await cli.run({
arguments: [
"decompile-all",
target,
"--provider",
"kuna",
"--output",
"out",
"--address-start",
"0X1000",
"--address-end",
"0X1040",
"--limit",
"1",
"--format",
"json",
],
cwd: root,
environment,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
expect(result.json).toMatchObject({
operation: "decompile_all",
parameters: {
output_directory: join(root, "out"),
address_start: "0X1000",
address_end: "0X1040",
limit: 1,
layout: "address",
resume: false,
},
normalized_result: {
output_directory: join(root, "out"),
// The fake inventory also lists the import puts at 0x2000.
counts: { selected: 2, excluded: 2, written: 1, remaining: 1 },
},
});
expect(await readdir(join(root, "out", "functions"))).toEqual([
"0x1000.c",
]);
expect(JSON.stringify(result.json)).not.toContain(
await readFile(join(root, "out", "functions", "0x1000.c"), "utf8"),
);
const resumed = await cli.run({
arguments: [
"decompile-all",
target,
"--provider",
"kuna",
"--output",
"out",
"--resume",
"--address-start",
"0X1000",
"--address-end",
"0X1040",
"--limit",
"100001",
"--format",
"json",
],
cwd: root,
environment,
});
expect(resumed.exitCode, JSON.stringify(resumed.json)).toBe(0);
expect(resumed.json).toMatchObject({
parameters: { limit: 100001, resume: true },
normalized_result: {
counts: { selected: 2, written: 1, skipped: 1, remaining: 0 },
},
});
},
);
});
@@ -0,0 +1,300 @@
import { lstat, readFile, stat, writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// Host filesystems cannot fail chmod on one new directory on demand; every
// other creation step runs on the real filesystem.
const failChmodFor = vi.hoisted(() => ({
path: undefined as string | undefined,
journalClose: undefined as string | undefined,
beforeCleanup: undefined as (() => Promise<void>) | undefined,
cleanupPath: undefined as string | undefined,
beforeLeasePath: undefined as string | undefined,
beforeLease: undefined as (() => Promise<void>) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (args[0] === failChmodFor.journalClose) {
failChmodFor.journalClose = undefined;
const close = handle.close.bind(handle);
vi.spyOn(handle, "close")
.mockImplementationOnce(async () => {
throw new Error("injected close failure");
})
.mockImplementation(close);
}
return handle;
},
rm: async (...args: Parameters<typeof actual.rm>) => {
if (
args[0] === failChmodFor.cleanupPath ||
`${String(args[0])}/decompile-journal.jsonl` ===
failChmodFor.cleanupPath
) {
const hook = failChmodFor.beforeCleanup;
failChmodFor.beforeCleanup = undefined;
await hook?.();
}
return actual.rm(...args);
},
unlink: async (...args: Parameters<typeof actual.unlink>) => {
if (args[0] === failChmodFor.cleanupPath) {
const hook = failChmodFor.beforeCleanup;
failChmodFor.beforeCleanup = undefined;
await hook?.();
}
return actual.unlink(...args);
},
chmod: async (path: Parameters<typeof actual.chmod>[0], mode: number) => {
if (path === failChmodFor.path)
throw Object.assign(new Error("denied"), { code: "EPERM" });
await actual.chmod(path, mode);
if (path === failChmodFor.beforeLeasePath) {
const hook = failChmodFor.beforeLease;
failChmodFor.beforeLease = undefined;
await hook?.();
}
},
};
});
const { DecompileExport } =
await import("../../../src/application/decompileDirectory/exportTree.js");
const { BinarySessionRecords } =
await import("../../../src/application/binary/BinarySessionRecords.js");
class SessionResources extends BinarySessionRecords {
protected activeAnalysisBinding() {
return undefined;
}
}
const { acquireOwnedLease } =
await import("../../../src/process/OwnedLease.js");
const identity = {
targetSha256: "ab".repeat(32),
provider: { id: "fixture", name: "Fixture", version: "1" },
analysisProfileDigest: "cd".repeat(32),
layout: "address" as const,
};
afterEach(() => {
failChmodFor.path = undefined;
failChmodFor.journalClose = undefined;
failChmodFor.beforeCleanup = undefined;
failChmodFor.cleanupPath = undefined;
failChmodFor.beforeLeasePath = undefined;
failChmodFor.beforeLease = undefined;
vi.restoreAllMocks();
});
describe("decompile export creation", () => {
it("removes the output directory it created when it cannot be made private", async () => {
const parent = await createTestTempDirectory("rea-decompile-create-");
const root = join(parent, "out");
failChmodFor.path = root;
const opened = await DecompileExport.open({
placement: { mode: "create", root },
identity,
});
expect(opened.ok).toBe(false);
if (opened.ok) return;
expect(opened.error.userMessage).toContain("could not be secured");
// A fresh export can be retried at the same path.
await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" });
failChmodFor.path = undefined;
const retried = await DecompileExport.open({
placement: { mode: "create", root },
identity,
});
expect(retried.ok).toBe(true);
if (retried.ok) expect((await retried.value.close()).ok).toBe(true);
});
it("holds the lease while removing a failed initialization with a valid journal", async () => {
const parent = await createTestTempDirectory("rea-decompile-create-");
const root = join(parent, "out");
const journal = join(root, "decompile-journal.jsonl");
failChmodFor.path = journal;
failChmodFor.cleanupPath = journal;
let resumedTag: string | undefined;
failChmodFor.beforeCleanup = async () => {
// The original header is valid when the competing run tries to resume.
expect(
JSON.parse((await readFile(journal, "utf8")).trim()),
).toMatchObject({ kind: "header" });
const resumed = await DecompileExport.open({
placement: { mode: "resume", root },
identity,
});
if (resumed.ok) await resumed.value.close();
resumedTag = resumed.ok ? "acquired" : resumed.error._tag;
};
const opened = await DecompileExport.open({
placement: { mode: "create", root },
identity,
});
expect(opened.ok).toBe(false);
expect(resumedTag).toBe("AnalysisResourceConstraintError");
await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains an export after a journal close failure until session cleanup retries it", async () => {
const parent = await createTestTempDirectory("rea-decompile-close-");
const root = join(parent, "out");
const resources = new SessionResources();
failChmodFor.journalClose = join(root, "decompile-journal.jsonl");
const opened = await DecompileExport.open({
placement: { mode: "create", root },
identity,
resourceScope: resources.resourceScope,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
const closed = await opened.value.close();
expect(closed.ok).toBe(false);
if (closed.ok) return;
expect(closed.error.cleanupIncomplete).toBe(true);
const refused = await DecompileExport.open({
placement: { mode: "resume", root },
identity,
});
expect(refused.ok).toBe(false);
if (!refused.ok)
expect(refused.error._tag).toBe("AnalysisResourceConstraintError");
expect((await resources.closeFileResources()).ok).toBe(true);
expect(closed.error.cleanupIncomplete).toBe(false);
const resumed = await DecompileExport.open({
placement: { mode: "resume", root },
identity,
});
expect(resumed.ok).toBe(true);
if (resumed.ok) expect((await resumed.value.close()).ok).toBe(true);
});
it("preserves unexpected files and keeps initialization cleanup owned until a retry", async () => {
const parent = await createTestTempDirectory("rea-decompile-cleanup-");
const root = join(parent, "out");
const journal = join(root, "decompile-journal.jsonl");
const resources = new SessionResources();
failChmodFor.path = journal;
failChmodFor.cleanupPath = journal;
failChmodFor.beforeCleanup = () =>
writeFile(join(root, "caller.txt"), "keep me");
const opened = await DecompileExport.open({
placement: { mode: "create", root },
identity,
resourceScope: resources.resourceScope,
});
expect(opened.ok).toBe(false);
if (opened.ok) return;
expect(opened.error.cleanupIncomplete).toBe(true);
expect(await readFile(join(root, "caller.txt"), "utf8")).toBe("keep me");
expect((await resources.closeFileResources()).ok).toBe(false);
await unlink(join(root, "caller.txt"));
expect((await resources.closeFileResources()).ok).toBe(true);
expect(opened.error.cleanupIncomplete).toBe(false);
await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("decompile export lease acquisition", () => {
it("preserves an empty new root when another run acquires its lease first", async () => {
const parent = await createTestTempDirectory("rea-decompile-lease-race-");
const root = join(parent, "out");
let release: (() => Promise<void>) | undefined;
failChmodFor.beforeLeasePath = root;
failChmodFor.beforeLease = async () => {
const stat = await lstat(root, { bigint: true });
const acquired = await acquireOwnedLease({
identity: {
operation: "decompile_all",
device: stat.dev.toString(),
inode: stat.ino.toString(),
},
runId: "competing-export",
directory: join("/tmp", `rea-decompile-${process.getuid?.() ?? 0}`),
failure: () => new Error("competing lease could not be acquired"),
});
expect(acquired.acquired).toBe(true);
if (acquired.acquired) release = acquired.lease.release;
};
try {
const opened = await DecompileExport.open({
placement: { mode: "create", root },
identity,
});
expect(opened.ok).toBe(false);
expect((await stat(root)).isDirectory()).toBe(true);
if (!opened.ok) {
expect(opened.error._tag).toBe("AnalysisResourceConstraintError");
expect(opened.error.diagnostics).toMatchObject({
created_output_directory: root,
});
}
} finally {
await release?.();
}
});
});
describe("decompile journal ownership", () => {
it("keeps the parsed committed record when a caller mutates its input during append", async () => {
const parent = await createTestTempDirectory("rea-decompile-record-");
const root = join(parent, "out");
const opened = await DecompileExport.open({
placement: { mode: "create", root },
identity,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
try {
const record = {
address: "0x1000",
name: "original",
status: "failed" as const,
body: { available: false as const, reason: "original body" },
failure: { code: "fixture", message: "original failure" },
limitations: ["original caveat"],
};
const appended = opened.value.append(record);
record.address = "0x2000";
record.body.reason = "changed body";
record.failure.message = "changed failure";
record.limitations[0] = "changed caveat";
expect((await appended).ok).toBe(true);
expect(
(
await opened.value.writeIndex({
layoutStatus: "address",
limitations: [],
})
).ok,
).toBe(true);
const index = JSON.parse(
await readFile(join(root, "decompile-index.json"), "utf8"),
);
expect(index.functions).toEqual([
{
address: "0x1000",
name: "original",
status: "failed",
body: { available: false, reason: "original body" },
failure: { code: "fixture", message: "original failure" },
limitations: ["original caveat"],
},
]);
} finally {
expect((await opened.value.close()).ok).toBe(true);
}
});
});
+18
View File
@@ -12,6 +12,7 @@ import { readMachoImage } from "../../../src/native/metadata/MachoImage.js";
import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js";
import {
createGoBinaryFixture,
createGoPeHeaderPointerFixture,
createGoSplitElfFixture,
createGoOverlappingElfFixture,
GO_MODULE_TEXT,
@@ -264,6 +265,23 @@ it("refuses mapped pointer lengths outside the file instead of rounding 64-bit a
expect(() => readGoBinaryImage(length.bytes)).toThrowError(/limit|length/i);
});
it.each(["file bytes", "zero-fill"])(
"refuses a header-backed PE module descriptor contradicted by %s",
(alias) => {
const { bytes } = createGoPeHeaderPointerFixture(64, "descriptors");
const section = 152 + bytes.readUInt16LE(148) + 40;
bytes.writeUInt16LE(2, 134);
bytes.write(".alias", section, "ascii");
bytes.writeUInt32LE(16, section + 8);
bytes.writeUInt32LE(528, section + 12);
if (alias === "file bytes") {
bytes.writeUInt32LE(16, section + 16);
bytes.writeUInt32LE(1536, section + 20);
}
expect(() => readGoBinaryImage(bytes)).toThrowError(/ambiguous/i);
},
);
it("refuses overlong or truncated varints before decoding or allocating strings", () => {
const fixture = createGoBinaryFixture();
fixture.bytes.fill(
@@ -31,6 +31,58 @@ it("does not confuse adjacent mappings with overlapping byte spans", () => {
expect(mappedFileOffset([selected, adjacent], 164n, 0)).toBe(80);
});
it("maps a split span across unsorted adjacent and agreeing overlapping ranges", () => {
expect(
mappedFileOffset(
[
{ address: 164n, offset: 80, size: 32 },
{ address: 156n, offset: 72, size: 12 },
selected,
],
156n,
24,
),
).toBe(72);
});
it.each([
{ address: 165n, offset: 81, size: 32 }, // One virtual byte is unbacked.
{ address: 164n, offset: 96, size: 32 }, // Virtual adjacency, different file translation.
])(
"refuses a split span with a gap or discontinuous file bytes",
(adjacent) => {
expect(() => mappedFileOffset([selected, adjacent], 156n, 24)).toThrowError(
/mapped|ambiguous/i,
);
},
);
it("checks aliases and zero-fill inside the later part of a split span", () => {
const adjacent = { address: 164n, offset: 80, size: 32 };
expect(() =>
mappedFileOffset(
[selected, adjacent, { address: 170n, offset: 4, size: 4 }],
156n,
24,
),
).toThrowError(/ambiguous/i);
expect(() =>
mappedFileOffset([selected, adjacent], 156n, 24, [
{ address: 170n, size: 4n },
]),
).toThrowError(/ambiguous/i);
});
it("keeps ambiguous zero-length endpoints invalid", () => {
expect(() =>
mappedFileOffset(
[selected, { address: 164n, offset: 96, size: 32 }],
164n,
0,
),
).toThrowError(/ambiguous/i);
});
it.each([
{ address: 132n, size: 4n },
{ address: 112n, size: 12n },
+53 -41
View File
@@ -363,48 +363,60 @@ it.each(["amdgcn", "amdgpu9.00"])(
expect(result.value.kernels).toHaveLength(1);
},
);
it.each([
"amdgcn-amd-amdhsa-unknown-",
"amdgpu9.00-amd-amdhsa--",
"amdgpu9.00-amd-amdhsa-unknown-",
])(
"compares AMDGPU note processor and feature declarations with %s",
async (prefix) => {
for (const comparison of ["match", "conflict", "feature-unknown"]) {
const payloadTarget = `${prefix}gfx900${comparison === "feature-unknown" ? ":xnack+" : ""}`;
const inventoryTarget = comparison === "conflict" ? "gfx1100" : "gfx900";
const bytes = hipFixture(
amdFixture(false, undefined, payloadTarget),
`hipv4-amdgcn-amd-amdhsa--${inventoryTarget}`,
it.each(
[
"amdgcn-amd-amdhsa-unknown-",
"amdgpu9.00-amd-amdhsa--",
"amdgpu9.00-amd-amdhsa-unknown-",
].flatMap((prefix) =>
(
[
["gfx900", "gfx900", "consistent"],
["gfx1100", "gfx900", "conflict"],
["gfx900", "gfx900:xnack+", "unknown"],
["gfx900", "gfx900+xnack", "unknown"],
["gfx900+xnack", "gfx900", "unknown"],
["GFX900", "gfx900", "unknown"],
["gfx900", "GFX900", "unknown"],
] as const
).map(([inventoryTarget, payloadTargetId, consistency]) => ({
inventoryTarget,
payloadTarget: `${prefix}${payloadTargetId}`,
consistency,
})),
),
)(
"compares AMDGPU inventory $inventoryTarget with note $payloadTarget as $consistency",
async ({ inventoryTarget, payloadTarget, consistency }) => {
const bytes = hipFixture(
amdFixture(false, undefined, payloadTarget),
`hipv4-amdgcn-amd-amdhsa--${inventoryTarget}`,
);
const result = await inspect(bytes, { targets: [inventoryTarget] });
if (!result.ok) throw result.error;
const row = result.value.objects.find((value) => value.kind === "amdgpu")!;
expect(row.payload_target).toBe(payloadTarget);
expect(result.value.kernels).toHaveLength(1);
if (consistency === "conflict") {
expect(row).toMatchObject({
target: inventoryTarget,
target_consistency: "conflict",
inspection: "partial",
});
expect(result.value.coverage.payloads).toBe("partial");
expect(result.value.kernels[0]!.limitations.join(" ")).toContain(
"Conflicting target declarations",
);
} else {
expect(row).toMatchObject({
target: inventoryTarget,
target_consistency: consistency,
inspection: "complete",
});
expect(result.value.coverage.payloads).toBe("complete");
expect(result.value.kernels[0]!.limitations.join(" ")).not.toContain(
"Conflicting target declarations",
);
const result = await inspect(bytes);
if (!result.ok) throw result.error;
const row = result.value.objects.find(
(value) => value.kind === "amdgpu",
)!;
expect(row.payload_target).toBe(payloadTarget);
expect(result.value.kernels).toHaveLength(1);
if (comparison === "conflict") {
expect(row).toMatchObject({
target: "gfx1100",
target_consistency: "conflict",
inspection: "partial",
});
expect(result.value.coverage.payloads).toBe("partial");
expect(result.value.kernels[0]!.limitations.join(" ")).toContain(
"Conflicting target declarations",
);
} else {
expect(row).toMatchObject({
target: "gfx900",
target_consistency: comparison === "match" ? "consistent" : "unknown",
inspection: "complete",
});
expect(result.value.coverage.payloads).toBe("complete");
expect(result.value.kernels[0]!.limitations.join(" ")).not.toContain(
"Conflicting target declarations",
);
}
}
},
);
@@ -0,0 +1,187 @@
import { expect, it } from "vitest";
import { linkIl2cppBinary } from "../../../src/il2cpp/Il2cppBinaryFile.js";
import {
isMapped,
openImageSteps,
readPointer,
readSlot,
} from "../../../src/il2cpp/Il2cppBinaryImage.js";
import { readIl2cppMetadataForLinking } from "../../../src/il2cpp/Il2cppMetadataFile.js";
import {
createElfFixture,
createIl2cppBinaryFixture,
FIXTURE_BINARY_BASE,
} from "../../fixtures/il2cpp/binary.js";
import { createLinkedIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
const { metadata, link: facts } = readIl2cppMetadataForLinking(
createLinkedIl2cppMetadataFixture().bytes,
);
const link = (bytes: Buffer) => linkIl2cppBinary(bytes, metadata, facts);
const open = (bytes: Buffer) => {
const steps = openImageSteps(bytes, undefined);
let step = steps.next();
while (!step.done) step = steps.next();
return step.value;
};
/** Replace PT_LOAD declarations, keeping the original PT_DYNAMIC and payload. */
const withLoadRanges = (
bytes: Buffer,
ranges: readonly {
readonly offset: number;
readonly size: number;
readonly zeroFill?: number;
}[],
): Buffer => {
const headers: Buffer[] = [];
const start = Number(bytes.readBigUInt64LE(32));
const width = bytes.readUInt16LE(54);
for (let index = 0; index < bytes.readUInt16LE(56); index++) {
const at = start + index * width;
if (bytes.readUInt32LE(at) !== 1)
headers.push(bytes.subarray(at, at + width));
}
for (const range of ranges) {
const header = Buffer.alloc(width);
header.writeUInt32LE(1);
header.writeUInt32LE(7, 4);
header.writeBigUInt64LE(BigInt(range.offset), 8);
const address = FIXTURE_BINARY_BASE + BigInt(range.offset);
header.writeBigUInt64LE(address, 16);
header.writeBigUInt64LE(address, 24);
header.writeBigUInt64LE(BigInt(range.size), 32);
header.writeBigUInt64LE(BigInt(range.size + (range.zeroFill ?? 0)), 40);
header.writeBigUInt64LE(1n, 48);
headers.push(header);
}
const result = Buffer.concat([bytes, ...headers]);
result.writeBigUInt64LE(BigInt(bytes.length), 32);
result.writeUInt16LE(headers.length, 56);
return result;
};
it.each([183, 62])(
"links agreeing split ELF RELA spans without changing input bytes (machine %s)",
(machine) => {
const original = createElfFixture(createIl2cppBinaryFixture(), { machine });
// Split the first Elf64_Rela after its destination and half its info word.
const split = withLoadRanges(original, [
{ offset: 0, size: 0x100c },
{ offset: 0x100c, size: original.length - 0x100c },
]);
const snapshot = Buffer.from(split);
expect(link(split)).toEqual(link(original));
expect(split).toEqual(snapshot);
},
);
it.each([false, true])(
"refuses a RELA addend claimed by file bytes and zero-fill (split %s)",
(split) => {
const original = createElfFixture(createIl2cppBinaryFixture());
const bytes = withLoadRanges(original, [
...(split
? [
{ offset: 0, size: 0x100c },
{ offset: 0x100c, size: original.length - 0x100c },
]
: [{ offset: 0, size: original.length }]),
{ offset: 0x1010, size: 0, zeroFill: 8 },
]);
const snapshot = Buffer.from(bytes);
expect(() => link(bytes)).toThrow(
expect.objectContaining({
reason: "malformed-container",
message: expect.stringContaining(
"ambiguous file and zero-fill mappings",
),
}),
);
expect(bytes).toEqual(snapshot);
},
);
it("refuses a RELATIVE destination claimed by both file bytes and zero-fill", () => {
const original = createElfFixture(createIl2cppBinaryFixture());
const bytes = withLoadRanges(original, [
{ offset: 0, size: original.length },
{ offset: 0x304, size: 0, zeroFill: 4 },
]);
expect(() => link(bytes)).toThrow(
expect.objectContaining({
reason: "malformed-container",
message: expect.stringContaining("ambiguous file and zero-fill mappings"),
}),
);
});
it("refuses an unresolved count relocation with conflicting file/zero-fill owners", () => {
const original = createElfFixture(createIl2cppBinaryFixture(), {
unresolvedRelocations: [{ offset: 0x600, kind: 258 }], // R_AARCH64_ABS32
});
const bytes = withLoadRanges(original, [
{ offset: 0, size: original.length },
{ offset: 0x600, size: 0, zeroFill: 4 },
]);
const snapshot = Buffer.from(bytes);
expect(() => link(bytes)).toThrow(
expect.objectContaining({
reason: "malformed-container",
message: expect.stringContaining("ambiguous file and zero-fill mappings"),
}),
);
expect(bytes).toEqual(snapshot);
});
it("clears a file-backed unresolved count before scanning registration candidates", () => {
const control = createElfFixture(createIl2cppBinaryFixture());
const bytes = createElfFixture(createIl2cppBinaryFixture(), {
unresolvedRelocations: [{ offset: 0x600, kind: 258 }],
});
const snapshot = Buffer.from(bytes);
expect(link(control).code_gen_modules_field).toBe(
FIXTURE_BINARY_BASE + 0x600n,
);
expect(() => link(bytes)).toThrow(
expect.objectContaining({ reason: "code-registration-not-found" }),
);
expect(bytes).toEqual(snapshot);
});
it.each([false, true])(
"retains unrelated unresolved targets without file bytes (zero-fill %s)",
(zeroFill) => {
const control = createElfFixture(createIl2cppBinaryFixture());
const original = createElfFixture(createIl2cppBinaryFixture(), {
unresolvedRelocations: [{ offset: 0x2000, kind: 258 }],
});
const bytes = withLoadRanges(original, [
{ offset: 0, size: original.length },
...(zeroFill ? [{ offset: 0x2000, size: 0, zeroFill: 4 }] : []),
]);
expect(link(bytes)).toEqual(link(control));
expect(readSlot(open(bytes), FIXTURE_BINARY_BASE + 0x2000n)).toBe(
"unknown",
);
},
);
it("keeps mapped-span admission consistent with pointer reads across zero-fill aliases", () => {
const original = createElfFixture(createIl2cppBinaryFixture());
const ranges = [
{ offset: 0, size: 0xc08 },
{ offset: 0xc08, size: original.length - 0xc08 },
];
for (const conflict of [false, true]) {
const bytes = withLoadRanges(original, [
...ranges,
...(conflict ? [{ offset: 0xc08, size: 0, zeroFill: 4 }] : []),
]);
const image = open(bytes);
const address = FIXTURE_BINARY_BASE + 0xc04n;
expect(isMapped(image, address, 8)).toBe(!conflict);
expect(readPointer(image, address)).toBe(conflict ? null : 0n);
expect(isMapped(image, FIXTURE_BINARY_BASE + 0xc00n, 4)).toBe(true);
}
});
@@ -0,0 +1,146 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "../../../src/application/AnalysisProvider.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { err, ok } from "../../../src/domain/result.js";
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import {
decompileDirectoryIndexSchema,
decompileDirectoryManifestSchema,
} from "../../../src/contracts/decompileDirectory.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
const profile = createAnalysisProfile(provider, { decompiler: "fixture" });
const addresses = Array.from(
{ length: 20 },
(_, index) => `0x${(0x1000 + index * 0x10).toString(16)}`,
);
const longCaveat = "long provider caveat ".repeat(600);
/** A provider that, like Ghidra, names each function's terminal call address. */
const addressCaveats: AnalysisOperationPort = {
execute: (operation, parameters) => {
const observe = (value: unknown, limitations: readonly string[] = []) =>
Promise.resolve(
ok(
createAnalysisExecution(value, provider, {
analysisProfile: profile,
limitations,
}),
),
);
const address = String(parameters.procedure);
if (operation === "list_procedures")
return observe(
addresses.map((entry) => ({ address: entry, value: entry })),
);
if (operation === "procedure_info")
return observe(null, [`procedure_info caveat at ${address}`]);
return observe(`int f(void) { return 0; }`, [
`terminal call at ${address}`,
...(address === addresses.at(-1) ? [longCaveat] : []),
]);
},
};
afterEach(closeEnhancedToolResources);
describe("decompile_all MCP caveats", () => {
it("returns a bounded caveat summary and keeps each function's caveats in its file", async () => {
const parent = await createTestTempDirectory("rea-decompile-caveats-");
const client = await connect(addressCaveats);
const result = await client.callTool({
name: "decompile_all",
arguments: { output_directory: join(parent, "out") },
});
expect(result.isError).not.toBe(true);
const evidence = parseEvidence(result.structuredContent);
expect(evidence.normalized_result).toMatchObject({
counts: { written: 20, failed: 0 },
});
// 40 address-specific caveats plus one oversized caveat: 8 kept and 33 counted, beside the workflow
// line and this target-less fixture's identity caveat.
expect(evidence.limitations).toHaveLength(11);
expect(evidence.limitations.at(-1)).toMatch(
/^33 per-item provider limitation observations were omitted/u,
);
const last = await readFile(
join(parent, "out", "functions", `${addresses.at(-1)}.c`),
"utf8",
);
expect(last).toContain(`procedure_info caveat at ${addresses.at(-1)}`);
expect(last).toContain(`terminal call at ${addresses.at(-1)}`);
expect(JSON.stringify(result)).not.toContain(longCaveat);
const index = JSON.parse(
await readFile(join(parent, "out", "decompile-index.json"), "utf8"),
);
expect(index.functions.at(-1).limitations).toContain(longCaveat);
});
});
it("keeps long-name failures inside the tool window and complete in the index", async () => {
const parent = await createTestTempDirectory("rea-decompile-long-failures-");
const output = join(parent, "out");
const names = Array.from(
{ length: 32 },
(_, index) => `${'a"\\\0😀'.repeat(400)}-${index}`,
);
const caveat = "\0".repeat(2_000);
const port: AnalysisOperationPort = {
execute: async (operation) =>
operation === "procedure_pseudo_code"
? err(new AnalysisInputError(operation))
: ok(
createAnalysisExecution(
operation === "list_procedures"
? names.map((name, index) => ({
address: `0x${(0x1000 + index).toString(16)}`,
value: name,
}))
: null,
provider,
{
analysisProfile: profile,
...(operation === "procedure_info"
? { limitations: [caveat] }
: {}),
},
),
),
};
const client = await connect(port);
const result = await client.callTool({
name: "decompile_all",
arguments: { output_directory: output },
});
expect(result.isError).not.toBe(true);
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(50 * 1_024);
const evidence = parseEvidence(result.structuredContent);
const manifest = decompileDirectoryManifestSchema.parse(
evidence.normalized_result,
);
expect(manifest.counts.failed).toBe(32);
expect(manifest.failures.length).toBeGreaterThan(0);
expect(manifest.failures_omitted + manifest.failures.length).toBe(32);
expect(manifest.failures.every((failure) => !("name" in failure))).toBe(true);
expect(manifest.failures.every((failure) => failure.code.length > 0)).toBe(
true,
);
expect(JSON.stringify(result)).not.toContain(caveat);
const index = decompileDirectoryIndexSchema.parse(
JSON.parse(await readFile(join(output, "decompile-index.json"), "utf8")),
);
expect(index.functions.map((record) => record.name)).toEqual(names);
expect(
index.functions.every((record) => record.limitations?.includes(caveat)),
).toBe(true);
});
@@ -1,3 +1,6 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it } from "vitest";
import { PROCEDURES, inventory, jsonResult } from "./enhancedToolsHarness.js";
@@ -63,29 +66,24 @@ const functionDossier = {
limitations: [],
};
const capabilityUnavailable = (operation: string, message: string) =>
Promise.resolve(
err(new AnalysisCapabilityUnavailableError("fixture", operation, message)),
);
const fixturePort = (): AnalysisOperationPort => ({
// oxlint-disable-next-line complexity -- this fixture exhaustively serves the registered MCP surface.
execute: (name, arguments_) => {
switch (name) {
case "read_binary_inventory_summary":
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
name,
"This fixture exercises composed overview",
),
),
return capabilityUnavailable(
name,
"This fixture exercises composed overview",
);
case "inspect_native_dispatch_metadata":
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
name,
"No byte reader or exact native seed in this symbol-only fixture",
),
),
return capabilityUnavailable(
name,
"No byte reader or exact native seed in this symbol-only fixture",
);
case "procedure_address": {
const procedure = inventory(PROCEDURES).find(
@@ -153,6 +151,11 @@ const fixturePort = (): AnalysisOperationPort => ({
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
);
}
case "procedure_info":
return capabilityUnavailable(
name,
"This fixture does not report function bodies",
);
case "procedure_callees": {
const procedure = arguments_.procedure;
return Promise.resolve(
@@ -215,11 +218,16 @@ const fixturePort = (): AnalysisOperationPort => ({
});
const resources: Array<{ close(): Promise<void> }> = [];
let decompileOutput: string | undefined;
afterEach(async () => {
await Promise.all(
resources.splice(0).map(async (resource) => resource.close()),
);
if (decompileOutput !== undefined) {
await rm(decompileOutput, { recursive: true, force: true });
decompileOutput = undefined;
}
});
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
@@ -257,6 +265,8 @@ describe("enhanced MCP tools", () => {
// oxlint-disable-next-line max-lines-per-function -- one exhaustive registration test guards the public tool catalog.
it("executes all enhanced tools through production registration", async () => {
const client = await connect();
const outputParent = await mkdtemp(join(tmpdir(), "rea-decompile-all-"));
decompileOutput = outputParent;
const calls = [
["get_objc_classes", { pattern: "Fixture" }],
["get_objc_protocols", {}],
@@ -272,6 +282,7 @@ describe("enhanced MCP tools", () => {
["trace_native_ui_action", { action: "missing-selector" }],
["inspect_native_dispatch_metadata", { max_records: 100 }],
["trace_native_values", { procedure: "0x1" }],
["decompile_all", { output_directory: join(outputParent, "out") }],
] as const;
const results = await Promise.all(
calls.map(async ([name, arguments_]) =>
@@ -438,6 +449,11 @@ describe("enhanced MCP tools", () => {
}),
],
});
expect(results[14]).toMatchObject({
counts: { selected: 6, written: 6, failed: 0, excluded: 0 },
layout_status: "address",
});
expect(JSON.stringify(results[14])).not.toContain("pseudo:");
});
});
@@ -45,8 +45,16 @@ it.each([
selection: { current_document: "fixture", current_address: null },
operation: "current_address",
},
{
selection: {
current_document: "fixture",
current_address: "0x401000",
resolve_containing_procedure: null,
},
operation: "resolve_containing_procedure",
},
])(
"delivers a typed MCP failure when $operation is not a string",
"delivers a typed MCP failure when $operation returns malformed output",
async ({ selection, operation }) => {
const result = await navigate(selection);
expect(JSON.stringify(result.content)).not.toContain(
@@ -207,6 +207,50 @@ it.each([
},
);
it("keeps the surrounding spaces of segment and section names", () => {
// Captured from `otool -l` on an object whose section is " sec " in segment
// " seg ": otool prints one space after the label, then the stored name.
const output = [
"Load command 0",
" cmd LC_SEGMENT_64",
" cmdsize 152",
" segname ",
" vmaddr 0x0000000000000000",
" vmsize 0x0000000000000008",
" fileoff 184",
" filesize 8",
" maxprot 0x00000007",
" initprot 0x00000007",
" nsects 1",
" flags 0x0",
"Section",
" sectname sec ",
" segname seg ",
" addr 0x0000000000000000",
" size 0x0000000000000008",
" offset 184",
" align 2^0 (1)",
" reloff 0",
" nreloc 0",
" flags 0x00000000",
" reserved1 0",
" reserved2 0",
"",
].join("\n");
expect(
parseOtoolLoadCommands(withOtoolMachHeader(output)).segments,
).toMatchObject([
{ name: "", sections: [{ segment: " seg ", name: " sec " }] },
]);
const named = parseOtoolLoadCommands(
withOtoolMachHeader(output.replace(" segname \n", " segname seg \n")),
);
expect(named.commands[0]?.fields.segname).toBe(" seg ");
expect(named.segments[0]?.name).toBe(" seg ");
});
it("preserves dependency install names that begin with the word stamp", () => {
const load = parseOtoolLoadCommands(
withOtoolMachHeader(
+23 -3
View File
@@ -248,7 +248,8 @@ export const elfExportImage = (options: {
readonly versionsOutsideFile?: boolean;
/** Declare a zero-byte `.dynsym`, without its null entry. */
readonly emptyDynamicSymbols?: boolean;
readonly sectionHeaders?: boolean;
readonly sectionHeaders?: boolean | "null-only";
readonly dynamicSegment?: boolean;
readonly dynamicSymbols?: boolean;
}): Buffer => {
const bits = options.bits ?? 64;
@@ -317,6 +318,8 @@ export const elfExportImage = (options: {
u16(wide ? 52 : 40, wide ? 64 : 52);
u16(wide ? 54 : 42, wide ? 56 : 32);
u16(wide ? 58 : 46, sectionWidth);
if (options.dynamicSegment === true)
writeElfDynamicSegment(bytes, bits, little);
if (options.sectionHeaders === false) return bytes;
symbols.forEach((symbol, index) => {
@@ -381,8 +384,9 @@ export const elfExportImage = (options: {
word(at + (wide ? 56 : 36), BigInt(entsize));
});
word(wide ? 40 : 32, BigInt(sectionAt));
u16(wide ? 60 : 48, sections.length);
u16(wide ? 62 : 50, sections.length - 1);
const nullOnly = options.sectionHeaders === "null-only";
u16(wide ? 60 : 48, nullOnly ? 1 : sections.length);
u16(wide ? 62 : 50, nullOnly ? 0 : sections.length - 1);
return bytes;
};
@@ -463,3 +467,19 @@ const elfSectionRows = (
] satisfies ElfSectionRow[])),
[60, 3, layout.shstrtabAt, layout.shstrtab, 0, 0, 0],
];
/** Two program headers: an empty load segment followed by a dynamic segment. */
const writeElfDynamicSegment = (
bytes: Buffer,
bits: 32 | 64,
little: boolean,
): void => {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const wide = bits === 64;
const programAt = wide ? 64 : 52;
if (wide) view.setBigUint64(32, BigInt(programAt), little);
else view.setUint32(28, programAt, little);
view.setUint16(wide ? 56 : 44, 2, little);
view.setUint32(programAt, 1, little);
view.setUint32(programAt + (wide ? 56 : 32), 2, little);
};
+86
View File
@@ -141,6 +141,92 @@ export const createGoBinaryFixture = (
return { bytes, headerOffset, versionOffset, moduleOffset, moduleBytes };
};
/** Legacy strings in declared PE header memory, with the record still in .data. */
export const createGoPeHeaderPointerFixture = (
bits: 32 | 64,
placement: "contents" | "descriptors",
) => {
const fixture = createGoBinaryFixture({
format: "pe",
bits,
encoding: "pointer",
});
const { bytes, headerOffset } = fixture;
const width = bits / 8;
const word = (offset: number, value: number) => {
if (bits === 64) bytes.writeBigUInt64LE(BigInt(value), offset);
else bytes.writeUInt32LE(value, offset);
};
const section = 152 + bytes.readUInt16LE(148);
bytes.writeUInt16LE(0x22, 150);
bytes.writeUInt32LE(4096, 184); // SectionAlignment.
bytes.writeUInt32LE(512, 188); // FileAlignment.
bytes.writeUInt32LE(8192, 208); // SizeOfImage.
bytes.writeUInt32LE(1024, 212); // SizeOfHeaders.
bytes.writeUInt32LE(4096, section + 12);
const sectionAddress = (offset: number) => 0x11000 + offset - 1024;
word(headerOffset + 16, sectionAddress(1536));
word(headerOffset + 16 + width, sectionAddress(1536 + width * 2));
word(1536, sectionAddress(fixture.versionOffset));
word(1536 + width * 2, sectionAddress(fixture.moduleOffset));
if (placement === "descriptors") {
bytes.copy(bytes, 512, 1536, 1536 + width * 4);
word(headerOffset + 16, 0x10200);
word(headerOffset + 16 + width, 0x10200 + width * 2);
return fixture;
}
const versionOffset = 512;
const moduleOffset = 528;
bytes.copy(
bytes,
versionOffset,
fixture.versionOffset,
fixture.versionOffset + 8,
);
fixture.moduleBytes.copy(bytes, moduleOffset);
word(1536, 0x10000 + versionOffset);
word(1536 + width * 2, 0x10000 + moduleOffset);
return { ...fixture, versionOffset, moduleOffset };
};
/** A pointer string or its two-word descriptor crossing adjacent ELF PT_LOADs. */
export const createGoCrossLoadPointerFixture = (
placement: "contents" | "descriptor",
) => {
const fixture = createGoBinaryFixture({ encoding: "pointer" });
const bytes = Buffer.alloc(8192);
fixture.bytes.copy(bytes);
bytes.writeUInt16LE(2, 56);
bytes.writeBigUInt64LE(4096n, 160);
bytes.writeBigUInt64LE(4096n, 168);
bytes.writeBigUInt64LE(4096n, 176);
bytes.writeUInt32LE(1, 184);
bytes.writeUInt32LE(6, 188);
bytes.writeBigUInt64LE(4096n, 192);
bytes.writeBigUInt64LE(0x11000n, 200);
bytes.writeBigUInt64LE(4096n, 216);
bytes.writeBigUInt64LE(4096n, 224);
bytes.writeBigUInt64LE(4096n, 232);
bytes.writeBigUInt64LE(32n, 416); // Named discovery section contains only the record.
const moduleOffset = 5000;
fixture.moduleBytes.copy(bytes, moduleOffset);
bytes.writeBigUInt64LE(0x10000n + BigInt(moduleOffset), 1552);
if (placement === "descriptor") {
bytes.copy(bytes, 4088, 1536, 1552);
bytes.writeBigUInt64LE(0x10ff8n, fixture.headerOffset + 16);
return { ...fixture, bytes, moduleOffset };
}
const versionOffset = 4092;
bytes.copy(
bytes,
versionOffset,
fixture.versionOffset,
fixture.versionOffset + 8,
);
bytes.writeBigUInt64LE(0x10ffcn, 1536);
return { ...fixture, bytes, versionOffset, moduleOffset };
};
/** Build an ELF inline record with a selected aggregate string-content size. */
export const createGoInlineAggregateFixture = (contentBytes: number) => {
const fixture = createGoBinaryFixture({ moduleText: "" });