diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfd27028b9..19f87f919b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index e6e718e3ab..83ac32e50f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 962bb0f1ba..ddb1d785b4 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -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/
One-shot CLI adapter
setup, diagnostics, analysis, decompilation"] MAIN["src/main.ts + src/main/
MCP server adapter: stdio default, streamable HTTP opt-in
config, session, process lifetime"] @@ -25,7 +27,7 @@ graph TD JS_RECON["src/application/javascript/ + src/artifacts/javascript/
JavaScript artifact reconstruction
directory/ASAR reads
AST-only bundle projection"] end - subgraph "Provider Layers" + subgraph "Provider Layers (selected adapters)" PROVIDER_OWNERS["src/providers/
shared deferred client and inspection owners
exact cleanup retry and attributed failures"] PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/
BYO offline ELF/core adapters
owned snapshots/Python and optional core-only GDB
unchanged upstream parsers, ABI structures and mapping API"] WASM["src/wasm/
caller-supplied WABT; immutable artifact inspection
owned process supervision and retryable workspace cleanup"] @@ -34,6 +36,7 @@ graph TD HOPPER["src/hopper/
Hopper launch & Unix-socket protocol
BridgeLauncher, HopperClient, protocol"] GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"] IDA["src/ida/
Upstream MCP contract adaptation
live GUI binding or owned headless database"] + KUNA["src/kuna/
BYO Kuna deep analysis candidate
discovery reads metadata; version probe on target open"] IDA_MCP["External ida-pro-mcp
GUI proxy or database supervisor
upstream installation and engine lifecycle"] AUX["src/artifacts/ + src/native/
artifact inventory, bounded directory triage and extraction
format readers and native capabilities"] BROWSER["src/browser/
CDP / Playwright capture
captured scripts and native module resolution
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 diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 599044c638..857cd53830 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -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 diff --git a/docs/go-binaries.md b/docs/go-binaries.md index 7509d1a24a..be315599f6 100644 --- a/docs/go-binaries.md +++ b/docs/go-binaries.md @@ -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 diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 825da69624..5b9f667282 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -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 diff --git a/docs/native-investigation.md b/docs/native-investigation.md index df190bfafe..b27c5ec2ba 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 --output `) 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 diff --git a/docs/reconstruction-readiness.md b/docs/reconstruction-readiness.md index 8173238be4..eec1d33dff 100644 --- a/docs/reconstruction-readiness.md +++ b/docs/reconstruction-readiness.md @@ -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 diff --git a/docs/verification/native-decompile-directory-2026-10-23.md b/docs/verification/native-decompile-directory-2026-10-23.md new file mode 100644 index 0000000000..9f9ff5d7fe --- /dev/null +++ b/docs/verification/native-decompile-directory-2026-10-23.md @@ -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. diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 3dff7ffb2b..ff58d373d4 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -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 diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 9a2ef5b724..0277e495fc 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -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: \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); diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index a59ca7ec08..7542dc8357 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -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 diff --git a/src/application/AnalysisContextQueries.test.ts b/src/application/AnalysisContextQueries.test.ts index cfc9dde921..e9453d0e48 100644 --- a/src/application/AnalysisContextQueries.test.ts +++ b/src/application/AnalysisContextQueries.test.ts @@ -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>[] = []; diff --git a/src/application/AnalysisContextQueries.ts b/src/application/AnalysisContextQueries.ts index e3f108931b..dc8d1a0d81 100644 --- a/src/application/AnalysisContextQueries.ts +++ b/src/application/AnalysisContextQueries.ts @@ -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 }, ), ); }; diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index e0a311bc44..717e65957f 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -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>, diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 81795cbe21..31abe683e3 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -201,6 +201,7 @@ const ENHANCED_REQUIREMENTS: ReadonlyMap = 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"], diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 2d19350540..e3a2045aae 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -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 => { try { diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 77737ac0ed..9956403f1c 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -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": diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index 333fa33d32..ee3629d433 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -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; diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index 25b1631fa2..1f105066d2 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -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; + /** + * 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>, + budget?: ComposedLimitationBudget, ): Promise< Result< { @@ -45,8 +79,12 @@ export const executeComposedWorkflow = async ( > > => { const limitations = new Map(); + const perItemGroups = new Set(); + 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 }), }); }; diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 8f220bfa91..e933451bec 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -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()), }), diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 5942a164f1..7f68d2513c 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -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; } diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts index 0df278a667..08dd3c853e 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.ts @@ -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); diff --git a/src/application/decompileDirectory/decompileAll.test.ts b/src/application/decompileDirectory/decompileAll.test.ts new file mode 100644 index 0000000000..4e7e3da2f6 --- /dev/null +++ b/src/application/decompileDirectory/decompileAll.test.ts @@ -0,0 +1,1706 @@ +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +import { + createAnalysisExecution, + type AnalysisOperation, + type AnalysisOperationPort, +} from "../AnalysisProvider.js"; +import { EnhancedTools } from "../EnhancedTools.js"; +import { DecompileExport } from "./exportTree.js"; +import { + composedLimitationBudget, + createWorkflowEvidence, + executeComposedWorkflow, +} from "../WorkflowEvidence.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { + DECOMPILE_DIRECTORY_INDEX_NAME, + DECOMPILE_DIRECTORY_JOURNAL_NAME, + decompileDirectoryIndexSchema, + decompileJournalLineSchema, +} from "../../contracts/decompileDirectory.js"; +import { createAnalysisProfile } from "../../domain/analysisProfile.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../../domain/analysisErrorCore.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; +import { err, ok } from "../../domain/result.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; +const profile = createAnalysisProfile(provider, { decompiler: "fixture" }); +const otherProfile = createAnalysisProfile(provider, { decompiler: "other" }); +const targetSha = "ab".repeat(32); +const sentinel = "UNIQUE_PSEUDO_SENTINEL */"; + +interface Procedure { + readonly address: string; + readonly name: string; +} + +interface RecordedCall { + readonly operation: AnalysisOperation; + readonly procedure: unknown; + readonly origin: unknown; +} + +const subject = { + path: "/tmp/fixture", + sha256: targetSha, + format: "elf" as const, + architecture: "x86_64" as const, +}; + +const infoFor = ( + address: string, + ranges: readonly { readonly start: string; readonly end: string }[] = [ + { start: address, end: address }, + ], +) => { + const nonContiguous = ranges.length > 1; + const total = ranges.reduce((sum, range) => { + const start = BigInt(range.start); + const end = BigInt(range.end); + return sum + Number(end - start + 1n); + }, 0); + const span = nonContiguous + ? Number( + BigInt(ranges.at(-1)?.end ?? address) - + BigInt(ranges[0]?.start ?? address) + + 1n, + ) + : total; + return { + name: "fixture", + entrypoint: address, + basicblock_count: 1, + length: total, + signature: null, + locals: [], + classification: null, + body: { + available: true as const, + provenance: "fixture", + ranges, + total_bytes: total, + span_bytes: span, + non_contiguous: nonContiguous, + contains_entry: true, + }, + }; +}; + +/** procedure_info for an import: Ghidra reports an observed body with no ranges. */ +const externalInfoFor = (address: string) => ({ + ...infoFor(address), + basicblock_count: 0, + length: 0, + classification: { + external: true, + thunk: false, + thunk_target: null, + provenance: "fixture", + }, + body: { + available: true as const, + provenance: "fixture", + ranges: [], + total_bytes: 0, + span_bytes: 0, + non_contiguous: false, + contains_entry: false, + }, +}); + +const harness = (options?: { + readonly procedures?: readonly Procedure[]; + readonly fail?: Readonly< + Record + >; + readonly info?: "ok" | "unavailable" | "error"; + readonly subject?: boolean; + readonly mismatch?: string; + readonly unprofiled?: string; + readonly foreignTarget?: string; + /** Per-address caveats, as Ghidra reports terminal calls. */ + readonly addressCaveats?: boolean; + /** Addresses procedure_info classifies as external, as Ghidra does for imports. */ + readonly external?: readonly string[]; +}) => { + const procedures = options?.procedures ?? [ + { address: "0x3000", name: "Bar::late" }, + { address: "0x1000", name: "Foo::main" }, + { address: "0x2000", name: "Foo::helper" }, + ]; + const calls: RecordedCall[] = []; + const port: AnalysisOperationPort = { + execute: (operation, parameters, execution) => { + calls.push({ + operation, + procedure: parameters.procedure, + origin: execution?.selectorOrigin, + }); + if (operation === "list_procedures") + return Promise.resolve( + ok( + createAnalysisExecution( + procedures.map((procedure) => ({ + address: procedure.address, + value: procedure.name, + })), + provider, + { + analysisProfile: profile, + ...(options?.subject === false ? {} : { subject }), + }, + ), + ), + ); + if (operation === "procedure_info") { + if (options?.info === "unavailable") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + provider.id, + operation, + "no body", + ), + ), + ); + if (options?.info === "error") + return Promise.resolve(err(new AnalysisInputError(operation))); + const address = String(parameters.procedure); + const info = + options?.external?.includes(address) === true + ? externalInfoFor(address) + : infoFor(address); + return Promise.resolve( + ok( + createAnalysisExecution(info, provider, { + analysisProfile: profile, + ...(options?.addressCaveats === true + ? { limitations: [`info caveat at ${address}`] } + : {}), + }), + ), + ); + } + if (operation === "procedure_pseudo_code") { + const address = String(parameters.procedure); + const failure = options?.fail?.[address]; + if (failure === "timeout") + return Promise.resolve(err(new AnalysisTimeoutError(operation, 1))); + if (failure === "error") + return Promise.resolve(err(new AnalysisInputError(operation))); + if (failure === "empty") + return Promise.resolve( + ok( + createAnalysisExecution("", provider, { + analysisProfile: profile, + }), + ), + ); + if (failure === "null") + return Promise.resolve( + ok( + createAnalysisExecution(null, provider, { + analysisProfile: profile, + }), + ), + ); + const selected = options?.mismatch === address ? otherProfile : profile; + return Promise.resolve( + ok( + createAnalysisExecution(`int fn(void) { ${sentinel} }`, provider, { + ...(options?.unprofiled === address + ? {} + : { analysisProfile: selected }), + ...(options?.foreignTarget === address + ? { subject: { ...subject, sha256: "cd".repeat(32) } } + : {}), + limitations: [ + "terminal call may cut the body", + ...(options?.addressCaveats === true + ? [`terminal call at ${address}`] + : []), + ], + }), + ), + ); + } + return Promise.resolve(err(new AnalysisInputError(operation))); + }, + }; + return { calls, port, tools: new EnhancedTools(port), procedures }; +}; + +const run = ( + tools: EnhancedTools, + directory: string, + input: Readonly> = {}, + signal?: AbortSignal, +) => + tools.execute( + "decompile_all", + { output_directory: directory, ...input }, + signal, + ); + +const of = (calls: readonly RecordedCall[], operation: AnalysisOperation) => + calls.filter((call) => call.operation === operation); + +const useOutputDirectory = (): (() => string) => { + let parent = ""; + beforeEach(async () => { + parent = await createTestTempDirectory("rea-decompile-all-"); + }); + return () => join(parent, "out"); +}; + +describe("decompile_all manifest", () => { + const directory = useOutputDirectory(); + + it("writes every function, keeps pseudocode out of the manifest, and records identity", async () => { + const { calls, tools } = harness(); + const result = await run(tools, directory()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { + selected: 3, + excluded: 0, + requested: 3, + written: 3, + failed: 0, + skipped: 0, + remaining: 0, + }, + target_sha256: targetSha, + analysis_profile_digest: profile.digest, + provider, + layout: "address", + layout_status: "address", + failures: [], + }); + expect(JSON.stringify(result.value)).not.toContain(sentinel); + const text = await readFile( + join(directory(), "functions/0x1000.c"), + "utf8", + ); + expect( + text.startsWith( + "// rea decompile_all\n// name: Foo::main\n// address: 0x1000\n", + ), + ).toBe(true); + expect(text).toContain("// range: 0x1000-0x1000 inclusive"); + expect(text).toContain("// limitations: terminal call may cut the body"); + expect(text.slice(text.indexOf("\n\n") + 2)).toBe( + `int fn(void) { ${sentinel} }`, + ); + const index = await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ); + const journal = await readFile( + join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME), + "utf8", + ); + expect(index).not.toContain(sentinel); + expect(journal).not.toContain(sentinel); + expect( + decompileDirectoryIndexSchema.safeParse(JSON.parse(index)).success, + ).toBe(true); + expect( + of(calls, "procedure_pseudo_code").map((call) => call.procedure), + ).toEqual(["0x1000", "0x2000", "0x3000"]); + expect( + of(calls, "list_procedures").every((call) => call.origin === "provider"), + ).toBe(true); + const evidence = createWorkflowEvidence({ + target: undefined, + operation: "decompile_all", + parameters: { output_directory: directory() }, + result: result.value, + upstreamProfile: profile, + upstreamLimitations: [], + }); + expect(evidence.evidence_id.length).toBeGreaterThan(0); + expect( + toolContract("decompile_all").outputSchema.safeParse(evidence).success, + ).toBe(true); + expect(JSON.stringify(evidence)).not.toContain(sentinel); + }); + + it("records one decompiler failure without aborting or writing that file", async () => { + const { tools } = harness({ + fail: { "0x2000": "error", "0x3000": "timeout" }, + }); + const result = await run(tools, directory()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { written: 1, failed: 2, requested: 3 }, + }); + const failures = ( + result.value as { failures: { address: string; code: string }[] } + ).failures; + expect(failures.map((failure) => failure.code).sort()).toEqual( + ["deadline", "invalid_request"].sort(), + ); + await expect( + readFile(join(directory(), "functions/0x2000.c")), + ).rejects.toThrow(); + await expect( + readFile(join(directory(), "functions/0x1000.c"), "utf8"), + ).resolves.toContain(sentinel); + }); +}); + +describe("decompile_all external functions", () => { + const directory = useOutputDirectory(); + + it("reports an external function as external without requesting its pseudocode", async () => { + const { calls, tools } = harness({ external: ["0x2000"] }); + const result = await run(tools, directory()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { requested: 3, written: 2, failed: 0, external: 1 }, + failures: [], + }); + expect( + of(calls, "procedure_pseudo_code").map((call) => call.procedure), + ).not.toContain("0x2000"); + await expect( + readFile(join(directory(), "functions/0x2000.c")), + ).rejects.toThrow(); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect(index.functions.find((entry) => entry.address === "0x2000")).toEqual( + { + address: "0x2000", + name: "Foo::helper", + status: "external", + body: { available: true, ranges: [], non_contiguous: false }, + }, + ); + }); + + it("skips a recorded external function on resume without asking the provider again", async () => { + const { calls, tools } = harness({ external: ["0x1000", "0x2000"] }); + expect((await run(tools, directory())).ok).toBe(true); + calls.length = 0; + const resumed = await run(tools, directory(), { resume: true, limit: 1 }); + + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + // A limit below the external count still finishes: nothing remains. + expect(resumed.value).toMatchObject({ + counts: { requested: 0, skipped: 3, remaining: 0, external: 0 }, + }); + expect(of(calls, "procedure_info")).toEqual([]); + expect(of(calls, "procedure_pseudo_code")).toEqual([]); + }); + + it("reports a conflicting file at a written function's path when a resume finds it external", async () => { + expect((await run(harness().tools, directory())).ok).toBe(true); + await writeFile(join(directory(), "functions/0x2000.c"), "replaced\n"); + + const resumed = await run( + harness({ external: ["0x2000"] }).tools, + directory(), + { resume: true }, + ); + + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ + counts: { requested: 1, skipped: 2, failed: 1, external: 0 }, + failures: [{ address: "0x2000", code: "output_exists" }], + }); + }); + + it("resumes an import recorded as invalid pseudocode by an earlier version as external", async () => { + const before = harness({ fail: { "0x2000": "null" } }); + expect((await run(before.tools, directory())).ok).toBe(true); + const journal = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME); + const text = await readFile(journal, "utf8"); + expect(text).toContain('"no_pseudocode"'); + // The journal an earlier version wrote for the same import. + await writeFile( + journal, + text.replace( + /"code":"no_pseudocode","message":"[^"]*"/u, + '"code":"invalid_pseudocode","message":"The provider returned pseudocode that is not text."', + ), + ); + + const after = harness({ external: ["0x2000"] }); + const resumed = await run(after.tools, directory(), { resume: true }); + + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ + counts: { requested: 1, skipped: 2, failed: 0, external: 1 }, + failures: [], + }); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect( + index.functions.find((entry) => entry.address === "0x2000")?.status, + ).toBe("external"); + }); + + it.each([ + ["a failure", '"failure":{"code":"x","message":"x"},'], + ["a file path", '"relative_path":"functions/0x2000.c",'], + ["a checksum", `"sha256":"${"cd".repeat(32)}",`], + ])( + "refuses to resume a journal whose external record carries %s", + async (_, field) => { + const { tools } = harness({ external: ["0x2000"] }); + expect((await run(tools, directory())).ok).toBe(true); + const journal = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME); + const text = await readFile(journal, "utf8"); + await writeFile( + journal, + text.replace('"status":"external",', `"status":"external",${field}`), + ); + + const resumed = await run(tools, directory(), { resume: true }); + + expect(resumed.ok).toBe(false); + if (resumed.ok) return; + expect(resumed.error).toBeInstanceOf(AnalysisInputError); + if (resumed.error instanceof AnalysisInputError) + expect(resumed.error.userMessage).toContain("not a compatible export"); + }, + ); + + it("reports a null pseudocode result as no pseudocode, not as text of the wrong type", async () => { + const { tools } = harness({ fail: { "0x2000": "null" } }); + const result = await run(tools, directory()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { written: 2, failed: 1, external: 0 }, + failures: [{ address: "0x2000", code: "no_pseudocode" }], + }); + }); +}); + +describe("decompile_all resume", () => { + const directory = useOutputDirectory(); + + it("refuses an existing directory until resume matches, then skips verified files", async () => { + const first = harness(); + const created = await run(first.tools, directory()); + expect(created.ok).toBe(true); + const again = harness(); + const refused = await run(again.tools, directory()); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(refused.error).toBeInstanceOf(AnalysisInputError); + expect(refused.error.userMessage).toContain("already exists"); + expect(of(again.calls, "list_procedures")).toHaveLength(0); + + const resumed = harness(); + const second = await run(resumed.tools, directory(), { resume: true }); + expect(second.ok).toBe(true); + if (!second.ok) return; + expect(second.value).toMatchObject({ + counts: { written: 0, skipped: 3, failed: 0, requested: 0 }, + resume: true, + }); + expect(of(resumed.calls, "procedure_pseudo_code")).toHaveLength(0); + }); + + it("does not rewrite a conflicting file and retries a missing one", async () => { + const { tools } = harness(); + expect((await run(tools, directory())).ok).toBe(true); + const file = join(directory(), "functions/0x1000.c"); + await writeFile(file, "tampered"); + await rm(join(directory(), "functions/0x2000.c")); + const next = harness(); + const resumed = await run(next.tools, directory(), { resume: true }); + + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ + counts: { written: 1, failed: 1, skipped: 1 }, + }); + expect(await readFile(file, "utf8")).toBe("tampered"); + expect( + of(next.calls, "procedure_pseudo_code").map((call) => call.procedure), + ).toEqual(["0x2000"]); + const failures = ( + resumed.value as { failures: { address: string; code: string }[] } + ).failures; + expect(failures).toEqual([ + expect.objectContaining({ address: "0x1000", code: "output_exists" }), + ]); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect( + index.functions.find((record) => record.address === "0x1000")?.body, + ).toEqual({ + available: true, + ranges: [{ start: "0x1000", end: "0x1000" }], + non_contiguous: false, + }); + }); + + it("refuses resume for a different target digest or a damaged journal", async () => { + expect((await run(harness().tools, directory())).ok).toBe(true); + const journalPath = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME); + const original = await readFile(journalPath, "utf8"); + const damaged = original.replace(targetSha, "cd".repeat(32)); + await writeFile(journalPath, damaged); + const mismatched = await run(harness().tools, directory(), { + resume: true, + }); + expect(mismatched.ok).toBe(false); + if (!mismatched.ok) + expect(mismatched.error.userMessage).toContain("does not match"); + + await writeFile(journalPath, `${original}not-json\n`); + const broken = await run(harness().tools, directory(), { resume: true }); + expect(broken.ok).toBe(false); + if (!broken.ok) + expect(broken.error.userMessage).toContain("not a compatible export"); + expect( + await readFile(join(directory(), "functions/0x1000.c"), "utf8"), + ).toContain(sentinel); + }); +}); + +describe("decompile_all filters and layout", () => { + const directory = useOutputDirectory(); + + it("filters by name, namespace, and address and reports the excluded count", async () => { + const { tools } = harness(); + const result = await run(tools, directory(), { + name: "helper", + namespace: "Foo::", + address_start: "0x1500", + address_end: "0x2500", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { selected: 1, excluded: 2, written: 1 }, + }); + expect( + await readFile(join(directory(), "functions/0x2000.c"), "utf8"), + ).toContain("Foo::helper"); + }); + + it("stops at a limit and continues in address order on resume", async () => { + const first = harness(); + const limited = await run(first.tools, directory(), { limit: 1 }); + expect(limited.ok).toBe(true); + if (!limited.ok) return; + expect(limited.value).toMatchObject({ + counts: { written: 1, remaining: 2, skipped: 0 }, + }); + expect( + of(first.calls, "procedure_pseudo_code").map((call) => call.procedure), + ).toEqual(["0x1000"]); + + const second = harness(); + const resumed = await run(second.tools, directory(), { + resume: true, + limit: 1, + }); + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ + counts: { written: 1, skipped: 1, remaining: 1 }, + }); + expect( + of(second.calls, "procedure_pseudo_code").map((call) => call.procedure), + ).toEqual(["0x2000"]); + }); + + it("reports dwarf layout unavailable and still uses address file names", async () => { + const { tools } = harness(); + const result = await run(tools, directory(), { layout: "dwarf" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + layout: "dwarf", + layout_status: "debug_layout_unavailable", + }); + const limitations = ( + result.value as { limitations: string[] } + ).limitations.join("\n"); + expect(limitations).toContain("unavailable"); + expect( + await readFile(join(directory(), "functions/0x1000.c"), "utf8"), + ).toContain(sentinel); + const names = await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ); + expect(names).not.toContain("Foo/"); + }); + + it("keeps a non-contiguous procedure_info body in the file header", async () => { + const procedures = [{ address: "0x1000", name: "main" }]; + const calls: RecordedCall[] = []; + const port: AnalysisOperationPort = { + execute: (operation, parameters) => { + calls.push({ + operation, + procedure: parameters.procedure, + origin: undefined, + }); + if (operation === "list_procedures") + return Promise.resolve( + ok( + createAnalysisExecution( + procedures.map((procedure) => ({ + address: procedure.address, + value: procedure.name, + })), + provider, + { analysisProfile: profile, subject }, + ), + ), + ); + if (operation === "procedure_info") + return Promise.resolve( + ok( + createAnalysisExecution( + infoFor("0x1000", [ + { start: "0x1000", end: "0x1007" }, + { start: "0x1010", end: "0x1017" }, + ]), + provider, + { analysisProfile: profile }, + ), + ), + ); + return Promise.resolve( + ok( + createAnalysisExecution( + `int main(void) { ${sentinel} }`, + provider, + { analysisProfile: profile }, + ), + ), + ); + }, + }; + const result = await new EnhancedTools(port).execute("decompile_all", { + output_directory: directory(), + }); + expect(result.ok).toBe(true); + const text = await readFile( + join(directory(), "functions/0x1000.c"), + "utf8", + ); + expect(text).toContain("// range: 0x1000-0x1007 inclusive"); + expect(text).toContain("// range: 0x1010-0x1017 inclusive"); + expect(text).toContain("// non_contiguous: true"); + }); + + it("still writes when procedure_info is unavailable and does not ask again", async () => { + const { calls, tools } = harness({ info: "unavailable" }); + const result = await run(tools, directory()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ counts: { written: 3, failed: 0 } }); + expect( + (result.value as { limitations: string[] }).limitations.join("\n"), + ).toContain("procedure_info"); + expect(of(calls, "procedure_info")).toHaveLength(1); + expect( + await readFile(join(directory(), "functions/0x1000.c"), "utf8"), + ).toContain("// body: unavailable"); + }); +}); + +describe("decompile_all partial results", () => { + const directory = useOutputDirectory(); + + it("caps inline failures and keeps the rest in the index", async () => { + const procedures = Array.from({ length: 33 }, (_, index) => ({ + address: `0x${(index + 1).toString(16)}`, + name: `fn${index.toString(16)}`, + })); + const { tools } = harness({ + procedures, + fail: Object.fromEntries( + procedures.map((procedure) => [procedure.address, "error" as const]), + ), + }); + const result = await run(tools, directory()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { failed: 33, written: 0 }, + }); + const shown = (result.value as { failures: unknown[] }).failures; + expect(shown.length).toBeGreaterThan(0); + expect(shown.length).toBeLessThanOrEqual(32); + expect(result.value).toMatchObject({ failures_omitted: 33 - shown.length }); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect( + index.functions.filter((record) => record.status === "failed"), + ).toHaveLength(33); + expect(JSON.stringify(index)).not.toContain(sentinel); + }); + + it("leaves committed files in place when the caller cancels", async () => { + const controller = new AbortController(); + const procedures = [ + { address: "0x1000", name: "first" }, + { address: "0x2000", name: "second" }, + ]; + const port: AnalysisOperationPort = { + execute: (operation, parameters) => { + if ( + operation === "procedure_pseudo_code" && + parameters.procedure === "0x2000" + ) + controller.abort(); + if (operation === "list_procedures") + return Promise.resolve( + ok( + createAnalysisExecution( + procedures.map((procedure) => ({ + address: procedure.address, + value: procedure.name, + })), + provider, + { analysisProfile: profile, subject }, + ), + ), + ); + if (operation === "procedure_info") + return Promise.resolve( + ok( + createAnalysisExecution( + infoFor(String(parameters.procedure)), + provider, + { analysisProfile: profile }, + ), + ), + ); + return Promise.resolve( + ok( + createAnalysisExecution( + "int cancelled(void) { return 1; }", + provider, + { analysisProfile: profile, limitations: [] }, + ), + ), + ); + }, + }; + const result = await new EnhancedTools(port).execute( + "decompile_all", + { output_directory: directory() }, + controller.signal, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + await expect( + readFile(join(directory(), "functions/0x1000.c"), "utf8"), + ).resolves.toContain("int cancelled(void)"); + await expect( + readFile(join(directory(), "functions/0x2000.c")), + ).rejects.toThrow(); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect(index.functions.map((record) => record.address)).toEqual(["0x1000"]); + const next = harness({ procedures }); + const resumed = await run(next.tools, directory(), { resume: true }); + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ counts: { written: 1, skipped: 1 } }); + expect( + of(next.calls, "procedure_pseudo_code").map((call) => call.procedure), + ).toEqual(["0x2000"]); + }); + + it("rejects an inverted address range before creating a directory or calling a provider", async () => { + const { calls, tools } = harness(); + const result = await run(tools, directory(), { + address_start: "ram:0x20", + address_end: "ram:0x10", + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.userMessage).toContain("before address start"); + expect(calls).toHaveLength(0); + await expect(readFile(directory())).rejects.toThrow(); + }); +}); + +describe("decompile_all caller coordinates", () => { + const directory = useOutputDirectory(); + it("accepts uppercase caller coordinates and retains address-space identity", async () => { + const { tools } = harness({ + info: "unavailable", + procedures: [ + { address: "RAM%3Aoverlay:0xabcd", name: "in_range" }, + { address: "ram%3Aoverlay:0xabcd", name: "other_space" }, + { address: "RAM%3Aoverlay:0xabce", name: "past_end" }, + ], + }); + const result = await run(tools, directory(), { + address_start: "RAM%3aoverlay:0X00ABCD", + address_end: "RAM%3Aoverlay:0xABCD", + }); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value).toMatchObject({ + counts: { selected: 1, excluded: 2, written: 1 }, + }); + }); +}); + +describe("decompile_all duplicate-address labels", () => { + const directory = useOutputDirectory(); + it("checks later labels before coalescing a selected address", async () => { + const { tools } = harness({ + procedures: [ + { address: "0x1000", name: "Other::original" }, + { address: "0x1000", name: "Chosen::alias" }, + { address: "0x1000", name: "Chosen::alias_again" }, + { address: "0x2000", name: "Other::helper" }, + ], + }); + const result = await run(tools, directory(), { + name: "alias", + namespace: "Chosen::", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { selected: 1, excluded: 1, written: 1 }, + }); + expect( + await readFile(join(directory(), "functions/0x1000.c"), "utf8"), + ).toContain("// name: Chosen::alias\n"); + }); +}); + +describe("decompile_all portable function paths", () => { + const directory = useOutputDirectory(); + const procedures = [ + { address: "RAM%3Aoverlay:0xabcd", name: "upper_space" }, + { address: "ram%3Aoverlay:0xabcd", name: "lower_space" }, + ]; + it("exports distinct case-sensitive address spaces using case-insensitive-unique filenames", async () => { + const { tools } = harness({ procedures, info: "unavailable" }); + const result = await run(tools, directory()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { selected: 2, written: 2, failed: 0 }, + }); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + const names = index.functions.map((record) => + record.relative_path!.toLowerCase(), + ); + expect(new Set(names).size).toBe(2); + expect(index.functions.map((record) => record.address)).toEqual( + procedures.map((row) => row.address), + ); + const resumed = await run(tools, directory(), { resume: true }); + expect(resumed.ok).toBe(true); + if (resumed.ok) + expect(resumed.value).toMatchObject({ + counts: { skipped: 2, written: 0, failed: 0 }, + }); + }); + it("refuses legacy journal paths that alias under portable filename rules", async () => { + const { tools } = harness({ procedures, info: "unavailable" }); + expect((await run(tools, directory())).ok).toBe(true); + const journalPath = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME); + const lines = (await readFile(journalPath, "utf8")) + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)); + lines[2].relative_path = lines[1].relative_path.toLowerCase(); + await writeFile( + journalPath, + lines.map((line) => JSON.stringify(line)).join("\n") + "\n", + ); + const refused = await run(tools, directory(), { resume: true }); + expect(refused.ok).toBe(false); + if (!refused.ok) + expect(refused.error.userMessage).toContain("compatible export"); + }); +}); + +describe("decompile_all identity and recovery", () => { + const directory = useOutputDirectory(); + + it("records an empty target digest and refuses to resume an unknown target", async () => { + const { tools } = harness({ subject: false }); + const result = await run(tools, directory()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ target_sha256: null }); + expect( + (result.value as { limitations: string[] }).limitations.join("\n"), + ).toContain("SHA-256"); + const other = harness({ subject: false }); + const resumed = await run(other.tools, directory(), { resume: true }); + expect(resumed.ok).toBe(false); + if (!resumed.ok) + expect(resumed.error.userMessage).toContain("target SHA-256"); + expect(of(other.calls, "procedure_pseudo_code")).toHaveLength(0); + }); + + it("does not accept a decompiler observation without the export's profile or for another target", async () => { + const { tools } = harness({ + unprofiled: "0x2000", + foreignTarget: "0x3000", + }); + const result = await run(tools, directory()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ counts: { written: 1, failed: 2 } }); + expect((result.value as { failures: unknown[] }).failures).toEqual([ + expect.objectContaining({ address: "0x2000", code: "profile_changed" }), + expect.objectContaining({ address: "0x3000", code: "target_changed" }), + ]); + }); + + it("refuses to resume an export whose analysis profile is unknown", async () => { + expect((await run(harness().tools, directory())).ok).toBe(true); + const journalPath = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME); + const original = await readFile(journalPath, "utf8"); + await writeFile( + journalPath, + original.replace( + `"analysis_profile_digest":"${profile.digest}"`, + `"analysis_profile_digest":null`, + ), + ); + const next = harness(); + const resumed = await run(next.tools, directory(), { resume: true }); + expect(resumed.ok).toBe(false); + if (!resumed.ok) + expect(resumed.error.userMessage).toContain("analysis profile digest"); + expect(of(next.calls, "procedure_pseudo_code")).toHaveLength(0); + }); + + it("refuses resume when the output directory does not exist", async () => { + const { calls, tools } = harness(); + const result = await run(tools, directory(), { resume: true }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.userMessage).toContain("existing export directory"); + expect(calls).toHaveLength(0); + await expect( + readFile(join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME)), + ).rejects.toThrow(); + }); + + it("exports addressed rows and counts inventory rows without an address", async () => { + const { tools } = harness({ + procedures: [ + { address: "", name: "ghost" }, + { address: "0x1000", name: "main" }, + ], + }); + const result = await run(tools, directory()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + counts: { selected: 1, written: 1, failed: 0 }, + }); + expect( + (result.value as { limitations: string[] }).limitations.join("\n"), + ).toContain("1 inventory row(s) had no address"); + }); + + it("resumes after a torn final journal line and keeps the journal parseable", async () => { + const first = harness(); + expect((await run(first.tools, directory(), { limit: 1 })).ok).toBe(true); + const journalPath = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME); + await writeFile( + journalPath, + `${await readFile(journalPath, "utf8")}{"kind":"function","name":"é\u00e9`, + ); + const stray = join(directory(), ".tmp-0123456789abcdef.c"); + await writeFile(stray, "not owned by this run"); + const resumed = await run(harness().tools, directory(), { resume: true }); + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ + counts: { written: 2, skipped: 1, failed: 0 }, + }); + const text = await readFile(journalPath, "utf8"); + expect(text.endsWith("\n")).toBe(true); + for (const line of text.trimEnd().split("\n")) + expect( + decompileJournalLineSchema.safeParse(JSON.parse(line)).success, + ).toBe(true); + // A file is not this export's just because its name looks like a temporary. + await expect(readFile(stray, "utf8")).resolves.toBe( + "not owned by this run", + ); + }); +}); + +describe("decompile_all untrusted directory and provider facts", () => { + const directory = useOutputDirectory(); + + it("refuses a journal that is a symlink and leaves its target unchanged", async () => { + expect((await run(harness().tools, directory(), { limit: 1 })).ok).toBe( + true, + ); + const journalPath = join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME); + const outside = join(directory(), "..", "outside.jsonl"); + const torn = `${await readFile(journalPath, "utf8")}{"kind":"func`; + await writeFile(outside, torn); + await rm(journalPath); + await symlink(outside, journalPath); + const next = harness(); + const resumed = await run(next.tools, directory(), { resume: true }); + expect(resumed.ok).toBe(false); + expect(of(next.calls, "procedure_pseudo_code")).toHaveLength(0); + expect(await readFile(outside, "utf8")).toBe(torn); + }); + + it("does not take body ranges from procedure_info for another entry point", async () => { + const port: AnalysisOperationPort = { + execute: (operation) => { + if (operation === "list_procedures") + return Promise.resolve( + ok( + createAnalysisExecution( + [{ address: "0x1000", value: "main" }], + provider, + { analysisProfile: profile, subject }, + ), + ), + ); + if (operation === "procedure_info") + return Promise.resolve( + ok( + createAnalysisExecution(infoFor("0x9000"), provider, { + analysisProfile: profile, + }), + ), + ); + return Promise.resolve( + ok( + createAnalysisExecution( + `int main(void) { ${sentinel} }`, + provider, + { + analysisProfile: profile, + }, + ), + ), + ); + }, + }; + const result = await new EnhancedTools(port).execute("decompile_all", { + output_directory: directory(), + }); + expect(result.ok).toBe(true); + const text = await readFile( + join(directory(), "functions/0x1000.c"), + "utf8", + ); + expect(text).toContain("// body: unavailable"); + expect(text).not.toContain("0x9000"); + }); + + it("keeps a function's file name after a failed retry that recorded no path", async () => { + expect((await run(harness().tools, directory())).ok).toBe(true); + await rm(join(directory(), "functions/0x1000.c")); + const failing = harness({ fail: { "0x1000": "timeout" } }); + expect((await run(failing.tools, directory(), { resume: true })).ok).toBe( + true, + ); + const retried = await run(harness().tools, directory(), { resume: true }); + expect(retried.ok).toBe(true); + if (!retried.ok) return; + expect(retried.value).toMatchObject({ counts: { written: 1, skipped: 2 } }); + await expect( + readFile(join(directory(), "functions/0x1000.c"), "utf8"), + ).resolves.toContain(sentinel); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect( + index.functions.find((record) => record.address === "0x1000") + ?.relative_path, + ).toBe("functions/0x1000.c"); + }); + + it("tries functions not yet attempted before retrying recorded failures", async () => { + const failing = { fail: { "0x1000": "timeout" as const } }; + expect( + (await run(harness(failing).tools, directory(), { limit: 1 })).ok, + ).toBe(true); + const next = harness(failing); + const resumed = await run(next.tools, directory(), { + resume: true, + limit: 1, + }); + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect( + of(next.calls, "procedure_pseudo_code").map((call) => call.procedure), + ).toEqual(["0x2000"]); + expect(resumed.value).toMatchObject({ + counts: { written: 1, failed: 0, remaining: 2 }, + }); + }); +}); + +describe("decompile_all journal recovery", () => { + const directory = useOutputDirectory(); + + const indexRecord = async (address: string) => + decompileDirectoryIndexSchema + .parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ) + .functions.find((record) => record.address === address); + + it("keeps a written file's checksum through a read error and skips it once readable", async () => { + expect((await run(harness().tools, directory())).ok).toBe(true); + const file = join(directory(), "functions/0x1000.c"); + const original = await readFile(file); + const written = await indexRecord("0x1000"); + // A directory in the file's place makes the read fail without being absent. + await rm(file); + await mkdir(file); + const blocked = await run(harness().tools, directory(), { resume: true }); + expect(blocked.ok).toBe(true); + if (!blocked.ok) return; + expect(blocked.value).toMatchObject({ + counts: { failed: 1, skipped: 2, written: 0 }, + failures: [ + expect.objectContaining({ address: "0x1000", code: "output_io" }), + ], + }); + expect((await indexRecord("0x1000"))?.status).toBe("failed"); + + await rm(file, { recursive: true }); + await writeFile(file, original); + const next = harness(); + const recovered = await run(next.tools, directory(), { resume: true }); + expect(recovered.ok).toBe(true); + if (!recovered.ok) return; + expect(recovered.value).toMatchObject({ + counts: { failed: 0, skipped: 3, written: 0 }, + }); + expect(of(next.calls, "procedure_pseudo_code")).toHaveLength(0); + expect(await indexRecord("0x1000")).toEqual(written); + }); + + it("bounds per-function provider caveats in the answer and keeps each in its file", async () => { + const procedures = Array.from({ length: 20 }, (_, index) => ({ + address: `0x${(0x1000 + index * 0x10).toString(16)}`, + name: `fn_${index}`, + })); + const { port } = harness({ procedures, addressCaveats: true }); + const composed = await executeComposedWorkflow( + port, + (observed) => + new EnhancedTools(observed).execute("decompile_all", { + output_directory: directory(), + }), + composedLimitationBudget("decompile_all"), + ); + expect(composed.ok).toBe(true); + if (!composed.ok) return; + const { limitations } = composed.value; + // The shared caveat is kept; 39 per-address caveats collapse to a count. + expect(limitations).toHaveLength(9); + expect(limitations).toContainEqual( + expect.stringContaining("terminal call may cut the body"), + ); + expect(limitations.at(-1)).toBe( + "33 per-item provider limitation observations were omitted from this answer; each function's record in decompile-index.json keeps the caveats of the observations it used.", + ); + const last = await readFile( + join(directory(), "functions/0x1130.c"), + "utf8", + ); + expect(last).toContain("info caveat at 0x1130"); + expect(last).toContain("terminal call at 0x1130"); + }); + + it("keeps the caveats each attempt used in its index record", async () => { + const { tools } = harness({ + addressCaveats: true, + fail: { "0x1000": "timeout", "0x2000": "empty" }, + }); + expect((await run(tools, directory())).ok).toBe(true); + expect((await indexRecord("0x1000"))?.limitations).toEqual([ + "info caveat at 0x1000", + ]); + // The fixture's empty pseudocode observation reports no caveats. + expect((await indexRecord("0x2000"))?.limitations).toEqual([ + "info caveat at 0x2000", + ]); + expect((await indexRecord("0x3000"))?.limitations).toEqual([ + "info caveat at 0x3000", + "terminal call may cut the body", + "terminal call at 0x3000", + ]); + }); + + it("keeps caveats beyond the header's length cap in the index", async () => { + const caveats = Array.from( + { length: 40 }, + (_, index) => + `terminal call at 0x${(0x1000 + index).toString(16)} ${"x".repeat(150)}`, + ); + const port: AnalysisOperationPort = { + execute: (operation) => + Promise.resolve( + ok( + operation === "list_procedures" + ? createAnalysisExecution( + [{ address: "0x1000", value: "large" }], + provider, + { analysisProfile: profile, subject }, + ) + : operation === "procedure_info" + ? createAnalysisExecution(infoFor("0x1000"), provider, { + analysisProfile: profile, + }) + : createAnalysisExecution( + `int large(void) { ${sentinel} }`, + provider, + { + analysisProfile: profile, + limitations: caveats, + }, + ), + ), + ), + }; + const result = await new EnhancedTools(port).execute("decompile_all", { + output_directory: directory(), + }); + expect(result.ok).toBe(true); + const header = await readFile( + join(directory(), "functions/0x1000.c"), + "utf8", + ); + expect(header).toContain("// limitations_truncated: true"); + expect(header).not.toContain(caveats.at(-1)); + expect((await indexRecord("0x1000"))?.limitations).toEqual(caveats); + }); +}); + +describe("decompile_all late cancellation", () => { + const directory = useOutputDirectory(); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports cancellation when the caller aborts while the index is written", async () => { + const controller = new AbortController(); + const reason = new Error("caller stopped"); + const writeIndex = DecompileExport.prototype.writeIndex; + vi.spyOn(DecompileExport.prototype, "writeIndex").mockImplementation( + function (this: DecompileExport, input) { + controller.abort(reason); + return writeIndex.call(this, input); + }, + ); + const result = await run( + harness().tools, + directory(), + {}, + controller.signal, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + // Finalization still completed, so the export resumes without new work. + vi.restoreAllMocks(); + const next = harness(); + const resumed = await run(next.tools, directory(), { resume: true }); + expect(resumed.ok).toBe(true); + expect(of(next.calls, "procedure_pseudo_code")).toHaveLength(0); + }); + + it("keeps an index failure primary when the caller also aborts", async () => { + const controller = new AbortController(); + const failure = new AnalysisOutputError("decompile_all", "index failed"); + vi.spyOn(DecompileExport.prototype, "writeIndex").mockImplementation(() => { + controller.abort(new Error("caller stopped")); + return Promise.resolve(err(failure)); + }); + const result = await run( + harness().tools, + directory(), + {}, + controller.signal, + ); + expect(result).toEqual(err(failure)); + }); +}); + +describe("decompile_all cancelled publish loop", () => { + const directory = useOutputDirectory(); + afterEach(() => { + vi.restoreAllMocks(); + }); + + const stopReason = new Error("caller stopped"); + + /** A port that aborts the caller's signal once the first function is decompiled. */ + const abortAfterFirst = (controller: AbortController) => { + const { port } = harness(); + const stopping: AnalysisOperationPort = { + execute: async (operation, parameters, execution) => { + const result = await port.execute(operation, parameters, execution); + if (operation === "procedure_pseudo_code") controller.abort(stopReason); + return result; + }, + }; + return new EnhancedTools(stopping); + }; + + it("keeps an index failure primary when cancellation stops the publish loop", async () => { + const controller = new AbortController(); + const failure = new AnalysisOutputError("decompile_all", "index failed"); + vi.spyOn(DecompileExport.prototype, "writeIndex").mockResolvedValue( + err(failure), + ); + const result = await run( + abortAfterFirst(controller), + directory(), + {}, + controller.signal, + ); + expect(result).toEqual(err(failure)); + }); + + it("keeps a journal close failure primary when cancellation stops the publish loop", async () => { + const controller = new AbortController(); + const failure = new AnalysisOutputError("decompile_all", "close failed"); + const close = DecompileExport.prototype.close; + vi.spyOn(DecompileExport.prototype, "close").mockImplementation( + async function (this: DecompileExport) { + await close.call(this); + return err(failure); + }, + ); + const result = await run( + abortAfterFirst(controller), + directory(), + {}, + controller.signal, + ); + expect(result).toEqual(err(failure)); + }); + + it("reports the caller's reason when cancellation stops the publish loop and finalization succeeds", async () => { + const controller = new AbortController(); + const result = await run( + abortAfterFirst(controller), + directory(), + {}, + controller.signal, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(stopReason); + }); + + it("reports the caller's reason when procedure_info returns its own cancellation as the caller aborts", async () => { + const controller = new AbortController(); + const { port } = harness(); + const cancelling: AnalysisOperationPort = { + execute: (operation, parameters, execution) => { + if (operation !== "procedure_info") + return port.execute(operation, parameters, execution); + controller.abort(stopReason); + return Promise.resolve( + err( + new AnalysisCancelledError("procedure_info", { + cause: new Error("provider session stopped"), + }), + ), + ); + }, + }; + const result = await run( + new EnhancedTools(cancelling), + directory(), + {}, + controller.signal, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(stopReason); + }); + + it("keeps a provider's own cancellation primary when the caller did not abort", async () => { + const { port } = harness(); + const providerCancelled = new AnalysisCancelledError( + "procedure_pseudo_code", + { cause: new Error("provider session stopped") }, + ); + const cancelling: AnalysisOperationPort = { + execute: (operation, parameters, execution) => + operation === "procedure_pseudo_code" + ? Promise.resolve(err(providerCancelled)) + : port.execute(operation, parameters, execution), + }; + vi.spyOn(DecompileExport.prototype, "writeIndex").mockResolvedValue( + err(new AnalysisOutputError("decompile_all", "index failed")), + ); + const result = await run( + new EnhancedTools(cancelling), + directory(), + {}, + new AbortController().signal, + ); + expect(result).toEqual(err(providerCancelled)); + }); +}); + +describe("decompile_all output directory lease", () => { + const directory = useOutputDirectory(); + + /** A port that holds the first decompile until the test lets it finish. */ + const heldAtFirstDecompile = () => { + const { port } = harness(); + let release = (): void => undefined; + const released = new Promise((resolve) => { + release = resolve; + }); + let reached = (): void => undefined; + const reachedFirst = new Promise((resolve) => { + reached = resolve; + }); + let held = false; + const holding: AnalysisOperationPort = { + execute: async (operation, parameters, execution) => { + if (operation === "procedure_pseudo_code" && !held) { + held = true; + reached(); + await released; + } + return port.execute(operation, parameters, execution); + }, + }; + return { tools: new EnhancedTools(holding), reachedFirst, release }; + }; + + it("refuses a second run on a directory another run is exporting, then resumes after it", async () => { + const first = heldAtFirstDecompile(); + const exporting = run(first.tools, directory()); + await first.reachedFirst; + + const competing = harness(); + const refused = await run(competing.tools, directory(), { resume: true }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + // Busy is retryable once the holder finishes, unlike invalid input. + expect(refused.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(refused.error).toMatchObject({ + resource: "capacity", + retryable: true, + reportedLimits: { holder_process_id: process.pid }, + }); + // The refused run neither decompiled nor appended to the journal. + expect(of(competing.calls, "procedure_pseudo_code")).toHaveLength(0); + + first.release(); + const exported = await exporting; + expect(exported.ok).toBe(true); + const journal = ( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_JOURNAL_NAME), + "utf8", + ) + ) + .trimEnd() + .split("\n"); + expect(journal).toHaveLength(4); + + // The lease ends with the run, and the next run sees every record. + const next = harness(); + const resumed = await run(next.tools, directory(), { resume: true }); + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ counts: { skipped: 3, written: 0 } }); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect(index.functions.map((record) => record.status)).toEqual([ + "written", + "written", + "written", + ]); + }); + + it("releases the lease when a resume is refused before any work", async () => { + expect((await run(harness().tools, directory())).ok).toBe(true); + const foreign = harness({ subject: false }); + const refused = await run(foreign.tools, directory(), { resume: true }); + expect(refused.ok).toBe(false); + const resumed = await run(harness().tools, directory(), { resume: true }); + expect(resumed.ok).toBe(true); + }); +}); + +describe("decompile_all repeated output failures", () => { + const directory = useOutputDirectory(); + + const procedures = Array.from({ length: 8 }, (_, index) => ({ + address: `0x${(0x1000 + index * 0x10).toString(16)}`, + name: `${"long-name".repeat(400)}_${index.toString()}`, + })); + + /** Put a directory where each listed function's file was, so reading it fails. */ + const blockFiles = async (addresses: readonly string[]) => { + for (const address of addresses) { + const file = join(directory(), `functions/${address}.c`); + await rm(file); + await mkdir(file); + } + }; + + it("stops after five consecutive output_io failures and keeps what it recorded", async () => { + expect((await run(harness({ procedures }).tools, directory())).ok).toBe( + true, + ); + await blockFiles(procedures.slice(0, 7).map(({ address }) => address)); + const next = harness({ procedures }); + const stopped = await run(next.tools, directory(), { resume: true }); + expect(stopped.ok).toBe(false); + if (stopped.ok) return; + expect(stopped.error).toBeInstanceOf(AnalysisOutputError); + expect(stopped.error.userMessage).toContain( + "Stopped after 5 function files in a row could not be written or read", + ); + expect(stopped.error.diagnostics).toMatchObject({ + attempted: 5, + failed: 5, + consecutive_output_io: 5, + }); + expect( + Buffer.byteLength(JSON.stringify(stopped.error.diagnostics)), + ).toBeLessThan(4_096); + // The index was still written and records the five failures; the sixth + // and seventh blocked functions were not attempted. + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect( + index.functions + .filter((record) => record.failure?.code === "output_io") + .map((record) => record.address), + ).toEqual(procedures.slice(0, 5).map(({ address }) => address)); + expect( + index.functions.find( + (record) => record.address === procedures[5]?.address, + )?.status, + ).toBe("written"); + }); + + it("does not stop when a verified file interrupts the failures", async () => { + expect((await run(harness({ procedures }).tools, directory())).ok).toBe( + true, + ); + // Four failures, one verified file, then three more failures. + await blockFiles( + [0, 1, 2, 3, 5, 6, 7].map((index) => procedures[index]?.address ?? ""), + ); + const resumed = await run(harness({ procedures }).tools, directory(), { + resume: true, + }); + expect(resumed.ok).toBe(true); + if (!resumed.ok) return; + expect(resumed.value).toMatchObject({ + counts: { failed: 7, skipped: 1, remaining: 0 }, + }); + }); +}); diff --git a/src/application/decompileDirectory/decompileAll.ts b/src/application/decompileDirectory/decompileAll.ts new file mode 100644 index 0000000000..4bf91ba4b2 --- /dev/null +++ b/src/application/decompileDirectory/decompileAll.ts @@ -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; + +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> => { + 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 | undefined; + let indexed: Result | undefined; + let limitations: readonly string[] = []; + let closed: Result | 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 => { + 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> => { + 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; + 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; + 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 => { + 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"; diff --git a/src/application/decompileDirectory/exportResources.ts b/src/application/decompileDirectory/exportResources.ts new file mode 100644 index 0000000000..2d83f38548 --- /dev/null +++ b/src/application/decompileDirectory/exportResources.ts @@ -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 => + (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 { + 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 { + 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> { + 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> { + 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 { + 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"; diff --git a/src/application/decompileDirectory/exportTree.ts b/src/application/decompileDirectory/exportTree.ts new file mode 100644 index 0000000000..d6e2b6a259 --- /dev/null +++ b/src/application/decompileDirectory/exportTree.ts @@ -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> => { + 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, + private readonly written: Map, + 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> { + 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> { + 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> { + // 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 { + return commitFunctionFile(this.root, relativePath, bytes, this.resources); + } + + hashFile(relativePath: string, signal?: AbortSignal): Promise { + return this.resources.hashFile(join(this.root, relativePath), signal); + } + + /** Failed cleanup retains the same files and lease for a later session retry. */ + close(): Promise> { + return this.resources.close(); + } + + static async #create( + root: string, + identity: ExportIdentity, + scope: RetainedResourceScope | undefined, + ): Promise> { + 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> { + // 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 => { + 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> => { + 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> => { + 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> => { + 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> => { + 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> => { + 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; + readonly written: Map; + 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 => { + 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> => { + 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(); + const written = new Map(); + // 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; + readonly byAddress: Map; +} + +/** 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 => { + 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> => { + 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; diff --git a/src/application/decompileDirectory/functionFile.ts b/src/application/decompileDirectory/functionFile.ts new file mode 100644 index 0000000000..94896f18e5 --- /dev/null +++ b/src/application/decompileDirectory/functionFile.ts @@ -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); diff --git a/src/application/decompileDirectory/inputError.ts b/src/application/decompileDirectory/inputError.ts new file mode 100644 index 0000000000..88fb1436ef --- /dev/null +++ b/src/application/decompileDirectory/inputError.ts @@ -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 }, + ]); diff --git a/src/application/decompileDirectory/selection.ts b/src/application/decompileDirectory/selection.ts new file mode 100644 index 0000000000..7e6aa74ff9 --- /dev/null +++ b/src/application/decompileDirectory/selection.ts @@ -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 => { + 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(); + const selected = new Map(); + 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 => { + 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; +}; diff --git a/src/application/environment/environmentStateAnswer.test.ts b/src/application/environment/environmentStateAnswer.test.ts index df70fc3471..1642bd0154 100644 --- a/src/application/environment/environmentStateAnswer.test.ts +++ b/src/application/environment/environmentStateAnswer.test.ts @@ -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({ diff --git a/src/application/environment/environmentStateCallLines.ts b/src/application/environment/environmentStateCallLines.ts index b2a0d3c9e7..b661031076 100644 --- a/src/application/environment/environmentStateCallLines.ts +++ b/src/application/environment/environmentStateCallLines.ts @@ -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 }); diff --git a/src/application/environment/environmentStateCollections.ts b/src/application/environment/environmentStateCollections.ts index 5fb3067205..190b95bce9 100644 --- a/src/application/environment/environmentStateCollections.ts +++ b/src/application/environment/environmentStateCollections.ts @@ -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)); diff --git a/src/artifacts/readStableArtifact.ts b/src/artifacts/readStableArtifact.ts index 3fcd79bba4..183e5162b8 100644 --- a/src/artifacts/readStableArtifact.ts +++ b/src/artifacts/readStableArtifact.ts @@ -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( diff --git a/src/browser/BrowserScenarioPartialObservation.ts b/src/browser/BrowserScenarioPartialObservation.ts index 1e3db475d1..858d2c524d 100644 --- a/src/browser/BrowserScenarioPartialObservation.ts +++ b/src/browser/BrowserScenarioPartialObservation.ts @@ -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 diff --git a/src/browser/PlaywrightBrowserScenarioProvider.ts b/src/browser/PlaywrightBrowserScenarioProvider.ts index 951feafdc8..b40c4925d3 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.ts @@ -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({ diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 4e0ddcce79..3ebd9b6e0e 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -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); diff --git a/src/cli/decompileDirectoryCommand.ts b/src/cli/decompileDirectoryCommand.ts new file mode 100644 index 0000000000..2cf651f1cf --- /dev/null +++ b/src/cli/decompileDirectoryCommand.ts @@ -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> => ({ + 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 }), +}); diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index 737c621d86..59aede588a 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -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, }); } diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 3a47cfd3de..1a450389c0 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -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", diff --git a/src/contracts/decompileDirectory.ts b/src/contracts/decompileDirectory.ts new file mode 100644 index 0000000000..bb584f8437 --- /dev/null +++ b/src/contracts/decompileDirectory.ts @@ -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; + +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/.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) + ); +}; diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index 1d715e90ee..4c85e2937e 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -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() diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 9d12219cfa..4d835b1061 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -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.", diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 5cb0e3f25f..a50af46de2 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -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" }, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index c956bc487a..4ae4149a96 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -564,6 +564,11 @@ export const TOOL_EFFECTS: Readonly> = { 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, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 4279d1a283..ed95184272 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -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> = { }), ), 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> = { 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( diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index d7113d36f5..8dd970667d 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -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(), diff --git a/src/domain/artifactIdentity.ts b/src/domain/artifactIdentity.ts index 34c3cc4a4e..a10fd5df79 100644 --- a/src/domain/artifactIdentity.ts +++ b/src/domain/artifactIdentity.ts @@ -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", )}`; diff --git a/src/domain/browserObservationError.ts b/src/domain/browserObservationError.ts index b53c1be57f..b6f76c26af 100644 --- a/src/domain/browserObservationError.ts +++ b/src/domain/browserObservationError.ts @@ -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"; diff --git a/src/domain/reconstructionReadiness.test.ts b/src/domain/reconstructionReadiness.test.ts index 22ac894857..64b98968b7 100644 --- a/src/domain/reconstructionReadiness.test.ts +++ b/src/domain/reconstructionReadiness.test.ts @@ -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(); diff --git a/src/domain/reconstructionReadinessIntegrityFindings.ts b/src/domain/reconstructionReadinessIntegrityFindings.ts index c994a6933a..92e475c8ff 100644 --- a/src/domain/reconstructionReadinessIntegrityFindings.ts +++ b/src/domain/reconstructionReadinessIntegrityFindings.ts @@ -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(); + const previouslyVerifiedIds = new Set(); 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", diff --git a/src/domain/reconstructionReadinessJourneyFindings.ts b/src/domain/reconstructionReadinessJourneyFindings.ts index 513ec34f7a..e4d7d3afe7 100644 --- a/src/domain/reconstructionReadinessJourneyFindings.ts +++ b/src/domain/reconstructionReadinessJourneyFindings.ts @@ -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>(); + 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; }; diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 031205ae46..f1b89468ec 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -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) 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"], diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 2437ca9557..c846a55239 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -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, }; diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index a536010032..052809d8d9 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -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 => { +): Omit => { 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, +) => 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); diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index b9ba377643..0b8a3cca39 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -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; @@ -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; 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, { diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index 50e5d56a83..ce1b2f8960 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -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 }; }; diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 79563afb62..6bf3ccbb0b 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -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, 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, diff --git a/src/dotnet/ManagedMethodSpecs.ts b/src/dotnet/ManagedMethodSpecs.ts index fd97737dc3..37d9917d5b 100644 --- a/src/dotnet/ManagedMethodSpecs.ts +++ b/src/dotnet/ManagedMethodSpecs.ts @@ -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. diff --git a/src/dotnet/ManagedNativeBoundaryHelpers.ts b/src/dotnet/ManagedNativeBoundaryHelpers.ts index 12bc15c492..495dc4089b 100644 --- a/src/dotnet/ManagedNativeBoundaryHelpers.ts +++ b/src/dotnet/ManagedNativeBoundaryHelpers.ts @@ -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, diff --git a/src/dotnet/ManagedSignatureResolution.test.ts b/src/dotnet/ManagedSignatureResolution.test.ts index 89a4d8b07b..1a2e605fa5 100644 --- a/src/dotnet/ManagedSignatureResolution.test.ts +++ b/src/dotnet/ManagedSignatureResolution.test.ts @@ -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>) => - (codedIndex: number): Buffer | null => { - const name = names[codedIndex]; + ({ value }: Parameters[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(() => 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

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, diff --git a/src/dotnet/ManagedTypeNameDigests.ts b/src/dotnet/ManagedTypeNameDigests.ts index dff47b5f4d..c470a5a86f 100644 --- a/src/dotnet/ManagedTypeNameDigests.ts +++ b/src/dotnet/ManagedTypeNameDigests.ts @@ -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, "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(); @@ -196,16 +200,28 @@ export const managedTypeNameDigests = ( return value?.scopeValid === true ? value : null; }, retain); const digests = new Map(); - 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), + }; }; diff --git a/src/dotnet/ManagedTypeNames.ts b/src/dotnet/ManagedTypeNames.ts index 845ad145e8..bf52cea625 100644 --- a/src/dotnet/ManagedTypeNames.ts +++ b/src/dotnet/ManagedTypeNames.ts @@ -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; diff --git a/src/dotnet/ManagedTypeReferences.ts b/src/dotnet/ManagedTypeReferences.ts index 6b436a410c..340a0bc681 100644 --- a/src/dotnet/ManagedTypeReferences.ts +++ b/src/dotnet/ManagedTypeReferences.ts @@ -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); diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 55bc6e4ec8..781c43c69b 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -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 => { diff --git a/src/gpu/AmdGpuTarget.ts b/src/gpu/AmdGpuTarget.ts index 1b289aa3cd..903e7afdd0 100644 --- a/src/gpu/AmdGpuTarget.ts +++ b/src/gpu/AmdGpuTarget.ts @@ -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> = { 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). */ diff --git a/src/gpu/GpuElfKernels.ts b/src/gpu/GpuElfKernels.ts index c944a08f0d..09f6107ae2 100644 --- a/src/gpu/GpuElfKernels.ts +++ b/src/gpu/GpuElfKernels.ts @@ -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" diff --git a/src/gpu/GpuInventory.ts b/src/gpu/GpuInventory.ts index 3b1c310459..db40b49a0c 100644 --- a/src/gpu/GpuInventory.ts +++ b/src/gpu/GpuInventory.ts @@ -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, diff --git a/src/hopper/HopperFileOffset.ts b/src/hopper/HopperFileOffset.ts index fbb6cf7f2d..99cc014974 100644 --- a/src/hopper/HopperFileOffset.ts +++ b/src/hopper/HopperFileOffset.ts @@ -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; diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index ab2f948802..ad2c9e3bf4 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -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 || diff --git a/src/il2cpp/ElfRelocations.ts b/src/il2cpp/ElfRelocations.ts index a3ea8465a4..041d403ef5 100644 --- a/src/il2cpp/ElfRelocations.ts +++ b/src/il2cpp/ElfRelocations.ts @@ -1,5 +1,5 @@ import { - BinaryFormatFailure, + BinaryMappingFailure, mappedFileOffset, isZeroFilledSpan, type FileMapping, @@ -124,14 +124,15 @@ function* dynamicTags(image: Buffer): Generator> { } 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 ( diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index 6ad4bedb9f..d443e4503a 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -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; diff --git a/src/native/NativePlistSource.ts b/src/native/NativePlistSource.ts index 368bd05447..dd112a6272 100644 --- a/src/native/NativePlistSource.ts +++ b/src/native/NativePlistSource.ts @@ -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( diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index 838241cd2e..79f189c037 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -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", () => { diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index ea19806c9a..e32eb74f4e 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -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 ( diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index 7f993c9df0..0e52965016 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -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", ); } diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 538689e32b..323b79f444 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -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, }; } diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index 7b5b755da7..4923a01d64 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -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 ` %.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