Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225d664e49 | ||
|
|
f255899ee8 | ||
|
|
65726afaaa | ||
|
|
30ba5e99f9 | ||
|
|
b6529bf1af | ||
|
|
1b8a9c831b | ||
|
|
cb18d4c9c4 | ||
|
|
47b4628bac | ||
|
|
670ad6bd96 | ||
|
|
c61c108b52 | ||
|
|
d7651b6a0d | ||
|
|
e18fccc548 | ||
|
|
e45eb33f03 | ||
|
|
337d2e33c5 | ||
|
|
6688ed6db7 | ||
|
|
c1fd496e6a |
No files matched your search
@@ -59,7 +59,7 @@ availability have different remedies; avoid repeating setup for all of them.
|
||||
absent, inspect the client's MCP launch error rather than repeating setup.
|
||||
|
||||
REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra, IDA,
|
||||
JADX, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated
|
||||
JADX, Kuna, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated
|
||||
software to repair
|
||||
MCP registration. For an unsupported client, use manual stdio registration
|
||||
with a version-pinned `rea-agents` package or continue through the CLI.
|
||||
@@ -79,7 +79,7 @@ is within the authorized scope, use `npx -y rea-agents@latest` in its place.
|
||||
Configuration repair starts with a read-only setup plan; show paths, backups and
|
||||
skill replacement before applying authorized changes. Preserve existing approval
|
||||
and target-execution boundaries. REA setup never installs or upgrades unrelated
|
||||
software, including Node.js, npm, Homebrew, Java, Ghidra, IDA, JADX, Binwalk or Unblob.
|
||||
software, including Node.js, npm, Homebrew, Java, Ghidra, IDA, JADX, Kuna, Binwalk or Unblob.
|
||||
|
||||
## Route the target first
|
||||
|
||||
@@ -215,6 +215,15 @@ after opening; IDA does not supply `binary_overview`. Consult session availabili
|
||||
when composing broader workflows. Modern direct callers and unsupported dossier
|
||||
facets remain unknown; live IDA results are not replayed from snapshots.
|
||||
|
||||
With `REA_KUNA_COMMAND` configured, `provider_id: "kuna"` answers procedure
|
||||
lists, lookups, pseudocode, assembly and `analyze_function` from Kuna's cached
|
||||
whole-program export; it has no cross-references, callers or CFG. A cold first
|
||||
answer can come from a single-function run (its limitations say so); assembly
|
||||
waits for the export. For pseudocode and assembly, `raw_result.source` names the
|
||||
cached `.c`/`.asm` file and byte span, which you may search directly instead of
|
||||
calling per function. See the
|
||||
[Kuna provider guide](https://github.com/morluto/rea/blob/main/docs/kuna-provider.md).
|
||||
|
||||
If the user says only "my app" without a name, path, or target already selected
|
||||
for this task, ask which app before searching for artifacts. Paths in unrelated
|
||||
saved reports and nearby test fixtures do not select the user's target. Resolve
|
||||
|
||||
@@ -205,7 +205,8 @@ or update either provider.
|
||||
|
||||
Native formats and host support vary by provider. See
|
||||
[Hopper and Ghidra setup](docs/installation.md#hopper), the
|
||||
[IDA guide](docs/ida-provider.md) (including an optional native MCP bridge), and
|
||||
[IDA guide](docs/ida-provider.md) (including an optional native MCP bridge), the
|
||||
[Kuna decompiler guide](docs/kuna-provider.md), and
|
||||
[experimental Windows Ghidra support](docs/windows-ghidra-p0.md).
|
||||
Ghidra also supports [16-bit DOS analysis](docs/ghidra-dos.md).
|
||||
For large binaries, raise the provider's startup deadline with
|
||||
|
||||
@@ -58,6 +58,7 @@ pin inspection sources; they are not included in the npm package.
|
||||
| ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. |
|
||||
| JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. |
|
||||
| jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. |
|
||||
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.833/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.833/NOTICE) | Bring your own `kuna`; REA verified 1.833 and never downloads, installs or bundles it. REA invokes it as a separate process. |
|
||||
| Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. |
|
||||
| Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. |
|
||||
| Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. |
|
||||
|
||||
@@ -60,6 +60,7 @@ export default defineConfig({
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "IDA", link: "/ida-provider" },
|
||||
{ text: "Kuna", link: "/kuna-provider" },
|
||||
{ text: "Ghidra on Windows", link: "/windows-ghidra-p0" },
|
||||
{ text: "Ghidra DOS analysis", link: "/ghidra-dos" },
|
||||
{ text: "Ghidra raw binary", link: "/raw-binary" },
|
||||
|
||||
+3
-2
@@ -95,8 +95,9 @@ dependency edges, and the section's file range. A missing `.dep-v0` section is
|
||||
null metadata, not proof that the binary has no dependencies. See
|
||||
[Rust binaries](rust-binaries.md).
|
||||
|
||||
Configure [Hopper or Ghidra](installation.md#hopper), or the
|
||||
[IDA adapter](ida-provider.md), before analyzing a native target. Substitute
|
||||
Configure [Hopper or Ghidra](installation.md#hopper), the
|
||||
[IDA adapter](ida-provider.md), or [Kuna](kuna-provider.md) before analyzing a
|
||||
native target. Substitute
|
||||
your target, search text and function name or address in these examples:
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
|
||||
| Task | Guides |
|
||||
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [JEB](jeb-analysis.md) |
|
||||
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
|
||||
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
|
||||
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
|
||||
| Inspect assemblies, packages, or firmware | [.NET](managed-code-analysis.md), [Android](android-analysis.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) |
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
Run REA, connect your selected agents, and check the tools needed for your task.
|
||||
Setup previews configuration changes and asks for approval before applying them.
|
||||
|
||||
| Task | Section |
|
||||
| --------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Connect an agent | [Start setup](#start-setup), then [review changes](#review-setup-changes) |
|
||||
| Diagnose one task | [Scoped readiness checks](#check-readiness-for-your-task) |
|
||||
| Configure an analysis engine | [Hopper](#hopper), [Ghidra](#ghidra), or the [IDA guide](ida-provider.md) |
|
||||
| Update or remove REA | [Maintenance](#diagnose-update-and-remove) |
|
||||
| Resolve a client-specific problem | [Client configuration](client-configuration.md) |
|
||||
| Task | Section |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Connect an agent | [Start setup](#start-setup), then [review changes](#review-setup-changes) |
|
||||
| Diagnose one task | [Scoped readiness checks](#check-readiness-for-your-task) |
|
||||
| Configure an analysis engine | [Hopper](#hopper), [Ghidra](#ghidra), the [IDA guide](ida-provider.md), or [Kuna](kuna-provider.md) |
|
||||
| Update or remove REA | [Maintenance](#diagnose-update-and-remove) |
|
||||
| Resolve a client-specific problem | [Client configuration](client-configuration.md) |
|
||||
|
||||
## Start setup
|
||||
|
||||
|
||||
@@ -276,8 +276,13 @@ RPC client syntax also contributes network endpoint candidates, in both
|
||||
named operation found at the start of a string or cooked template literal —
|
||||
the name must be followed by variable definitions (`($`), a directive, or a
|
||||
selection set starting with a field name — or in a precompiled
|
||||
`OperationDefinition` document object. A graphql-tag `loc.source.body` copy is
|
||||
not reported twice. Anonymous operations are not reported.
|
||||
`OperationDefinition` document object. A standalone Source object's `body`
|
||||
remains an operation candidate even when it has `locationOffset` metadata.
|
||||
A graphql-tag `loc.source.body` copy is suppressed only when that source
|
||||
belongs to a recovered document with the same operation and its containing
|
||||
document, location and source have proven private metadata ownership. Writes,
|
||||
escapes and classic-script global bindings retain the source observation.
|
||||
Anonymous operations are not reported.
|
||||
|
||||
Literal link options and precompiled document fields follow object property
|
||||
order: the final definition owns the key. A later spread, dynamic computed key,
|
||||
|
||||
@@ -91,17 +91,20 @@ nodes. REA evaluates compatible static candidates conservatively:
|
||||
|
||||
1. A captured-source SHA-256 plus one matching mapped location yields
|
||||
a content-and-location match.
|
||||
2. One unique exact content digest can match even when no location mapping is
|
||||
available. Whole-asset digests and reconstructed module-factory source
|
||||
2. When no static candidate exists at a mapped location, one unique exact
|
||||
content digest can match. Whole-asset digests and reconstructed module-factory source
|
||||
digests remain distinguishable in the reported basis.
|
||||
3. Without captured bytes, one unique mapped artifact path can produce a
|
||||
medium-confidence location match.
|
||||
4. Multiple candidates remain `ambiguous`; no `observed_as` edge is emitted.
|
||||
With captured source and mapped candidates, only mapped content matches
|
||||
qualify; unrelated content matches elsewhere are excluded.
|
||||
`candidate_static_count` reports the full qualified layer/node count, and
|
||||
`candidate_static_nodes` includes every unique candidate reference.
|
||||
5. If captured bytes disagree with the static candidate at the mapped path,
|
||||
the result is an explicit mismatch. A path never overrides a digest
|
||||
disagreement.
|
||||
the result is an explicit mismatch. An equal digest elsewhere cannot hide
|
||||
that disagreement. If a mapped candidate's digest is unavailable and no
|
||||
mapped content match is known, the comparison remains `unknown`.
|
||||
6. Missing authorization mappings and incomplete or truncated input coverage
|
||||
remain unknown or truncated rather than becoming absence claims.
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Kuna provider
|
||||
|
||||
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. REA uses a `kuna` you supply. It does not download, install, update or bundle Kuna. REA verified this provider with Kuna 1.833; it accepts other releases and reports them as unverified in `rea doctor` and in each analysis profile.
|
||||
|
||||
Kuna is static: it reads the selected image and never executes it.
|
||||
|
||||
## Configure
|
||||
|
||||
```bash
|
||||
export REA_KUNA_COMMAND=/absolute/path/to/kuna
|
||||
rea doctor --provider kuna
|
||||
```
|
||||
|
||||
Select it explicitly with `--provider kuna`, MCP's `open_binary.provider_id: "kuna"`, or `REA_ANALYSIS_PROVIDER=kuna`. With Kuna and another provider configured for the same target, `auto` reports `ambiguous` instead of choosing one.
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REA_KUNA_COMMAND` | none (required) | Absolute path of the `kuna` executable. |
|
||||
| `REA_KUNA_CACHE_DIR` | `~/Library/Caches/rea/kuna` (macOS), `$XDG_CACHE_HOME/rea/kuna` (Linux; `~/.cache/rea/kuna` when `XDG_CACHE_HOME` is unset or relative) | Absolute directory REA owns for project exports. Kuna's own spec cache also lives here, not in your Kuna cache. |
|
||||
| `REA_KUNA_CACHE_MAX_BYTES` | 4 GiB | Size cap; least recently used exports are evicted above it. |
|
||||
| `REA_KUNA_PROJECT_TIMEOUT_MS` | 30 minutes | Deadline for one project export. |
|
||||
| `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile. |
|
||||
| `REA_KUNA_JOBS` | 1 | `--jobs` for `kuna functions` and `decompile-project`. The inventory is identical for any value. For the export, 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. |
|
||||
|
||||
Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason.
|
||||
|
||||
## What Kuna answers
|
||||
|
||||
| Tool | Source |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list_procedures`, `search_procedures`, `procedure_address` | Kuna's callable inventory (`kuna functions --json`), including import slots it does not decompile |
|
||||
| `address_name` | Function entries, then the data labels Kuna's export assigns |
|
||||
| `procedure_pseudo_code`, `batch_decompile` | The project export, or a single-function run while the export builds |
|
||||
| `procedure_assembly` | The project export's labeled listing |
|
||||
| `analyze_function` | Pseudocode and assembly from the export; callers, callees, references, comments, strings and CFG are declared unavailable |
|
||||
|
||||
Cross-references, callers, callees and basic blocks are not available from this provider. Use another provider when you need them.
|
||||
|
||||
## How answers are produced
|
||||
|
||||
The first analysis request in a session looks for this target's export in the cache. `list_documents` reads the selected path without initializing analysis. The export is keyed by the target's SHA-256, the Kuna executable's SHA-256, its version and `--jobs`.
|
||||
|
||||
- **Warm cache:** analysis reads reuse the verified export. A fresh server verifies its file digests once, even for concurrent first requests; that cost scales with export size.
|
||||
- **Cold cache:**
|
||||
- REA starts `kuna decompile-project` in the background on a private copy of the target.
|
||||
- Meanwhile, pseudocode comes from one `kuna decompile --addr` run, and listings from `kuna functions --json`.
|
||||
- Requests for the same function share one run; it stops when its last waiting request is cancelled. Each run analyzes the whole image again in its own process, so one REA process runs at most as many at once as the host has cores beyond `REA_KUNA_JOBS` (at least one). Further requests wait in order, and a waiting request is answered from the export if it finishes first. A run whose stop REA cannot prove keeps its slot until `close_binary` proves it stopped.
|
||||
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds.
|
||||
- A single-function answer says so in its limitations. It lacks the export's whole-program prototypes, so a later request can return different text for the same function.
|
||||
|
||||
Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry.
|
||||
|
||||
The background export belongs to the session. `close_binary`, or the end of a CLI command, cancels it unless another session still waits for it. A one-shot CLI decompile therefore answers from a single-function run; any request that needs the export, such as `procedure_assembly` or `analyze_function`, keeps it.
|
||||
|
||||
The assembly label index is scanned once per export reader. Cancelling a request stops its wait without interrupting other readers; closing the session aborts and drains the shared scan before releasing its files.
|
||||
|
||||
Regex procedure search uses ECMAScript Unicode matching in a cancellable worker with a five-second matching deadline. Literal matching remains the default. Cancellation or a matching deadline leaves the session available.
|
||||
|
||||
Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger text remains in the local export and the error identifies the file and range. `analyze_function` initially returns the complete dossier, which can still be large; later retained views do not remove that first transfer. Prefer the individual text tools for a focused question.
|
||||
|
||||
## Provenance and limitations
|
||||
|
||||
- Addresses and bytes are Kuna's observations of the image. Procedure entries and extents are its derivations: extents are upper bounds clipped at the next entry, and Kuna can split one function at an entry its own analysis infers. Pseudocode is its reconstruction, never original source.
|
||||
- Pseudocode and assembly are exact Kuna text; text from Kuna and from another provider is not comparable as equal or different.
|
||||
- Results are re-read rather than replayed from REA's analysis snapshots, because a cold answer can differ from the export's.
|
||||
|
||||
## Cache ownership
|
||||
|
||||
REA writes Kuna's exports only inside `REA_KUNA_CACHE_DIR`:
|
||||
|
||||
- `projects/<key>/` holds published, immutable exports with a manifest of file digests.
|
||||
- `staging/` holds builds whose processes may still be live.
|
||||
- `trash/` holds retired builds and evicted exports awaiting removal.
|
||||
- `home/` is Kuna's HOME.
|
||||
|
||||
Builds publish by one rename, so a reader sees a complete export or none. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it.
|
||||
|
||||
## Verify with a real Kuna
|
||||
|
||||
```bash
|
||||
REA_KUNA_COMMAND=/absolute/path/to/kuna npm run verify:kuna
|
||||
# Compare with Ghidra on caller-built binaries
|
||||
REA_KUNA_COMMAND=/absolute/path/to/kuna GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra \
|
||||
npm run verify:kuna -- --targets DIR --functions inflate,crc32 --compare-ghidra
|
||||
```
|
||||
|
||||
The lane compiles a small source-owned fixture with the host C compiler and verifies the MCP workflow end to end. `--targets DIR --functions a,b` adds caller-built binaries. `--compare-ghidra` (with `GHIDRA_INSTALL_DIR`) runs those same functions through Ghidra; it requires `--targets` and `--functions` and refuses empty matrices or requested function names missing from every target. See [testing](testing.md#kuna-lane) for coverage.
|
||||
|
||||
Failed runs also report process-lineage evidence and exit unsuccessfully. If provider or transport cleanup is unconfirmed, the report says `cleanup: "incomplete"` and records `retained_workspace`; that directory is preserved for investigation and cleanup retries.
|
||||
@@ -270,7 +270,9 @@ Mach-O image. Architecture
|
||||
`file_offset` values are container-relative. Entrypoint, segment and section
|
||||
`file_offset` values retain their source meaning and are relative to the
|
||||
selected Mach-O slice. Raw load-command fields retain their original values.
|
||||
Evidence locations map the LC_MAIN entrypoint and nonempty file-backed segment
|
||||
A field otool prints more than once, such as each `LC_BUILD_VERSION` tool, is
|
||||
keyed `name #n` in print order. Each `LC_LINKER_OPTION` string is keyed
|
||||
`string #n` and checked against the declared `count`. Evidence locations map the LC_MAIN entrypoint and nonempty file-backed segment
|
||||
ranges to container offsets by adding the observed slice offset. Zero-fill
|
||||
segments do not produce file ranges. When the selected slice's container offset
|
||||
is unknown, those mapped locations are omitted with an explicit limitation;
|
||||
|
||||
@@ -166,7 +166,7 @@ npx rea-agents@latest setup
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
تختلف صيغ ملفات الشيفرة الأصلية والأنظمة المضيفة المدعومة باختلاف الموفّر. راجع [إعداد Hopper وGhidra](../installation.md#hopper)، و[دليل IDA](../ida-provider.md)، و[دعم Ghidra التجريبي على Windows](../windows-ghidra-p0.md). يدعم Ghidra أيضًا [تحليل DOS ذي 16 بت](../ghidra-dos.md). لاختيار الموفّر، راجع [دليل CLI](../cli.md#choose-a-provider). تحقّق من [توفر الميزات في الإصدارات](../installation.md#released-package-and-main) للميزات المضافة بعد أحدث إصدار على npm.
|
||||
تختلف صيغ ملفات الشيفرة الأصلية والأنظمة المضيفة المدعومة باختلاف الموفّر. راجع [إعداد Hopper وGhidra](../installation.md#hopper)، و[دليل IDA](../ida-provider.md)، [Kuna](../kuna-provider.md)، و[دعم Ghidra التجريبي على Windows](../windows-ghidra-p0.md). يدعم Ghidra أيضًا [تحليل DOS ذي 16 بت](../ghidra-dos.md). لاختيار الموفّر، راجع [دليل CLI](../cli.md#choose-a-provider). تحقّق من [توفر الميزات في الإصدارات](../installation.md#released-package-and-main) للميزات المضافة بعد أحدث إصدار على npm.
|
||||
|
||||
## دراسات الحالة
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ Statische JavaScript- und .NET-Untersuchungen lesen die bereitgestellten Dateien
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Native Formate und unterstützte Hostsysteme unterscheiden sich je nach Provider. Siehe [Hopper- und Ghidra-Einrichtung](../installation.md#hopper), den [IDA-Leitfaden](../ida-provider.md) und die [experimentelle Ghidra-Unterstützung unter Windows](../windows-ghidra-p0.md). Ghidra unterstützt auch [16-Bit-DOS-Analyse](../ghidra-dos.md). Die Provider-Auswahl beschreibt der [CLI-Leitfaden](../cli.md#choose-a-provider). Prüfe die [Verfügbarkeit in Releases](../installation.md#released-package-and-main) für Funktionen, die seit der neuesten npm-Version hinzugekommen sind.
|
||||
Native Formate und unterstützte Hostsysteme unterscheiden sich je nach Provider. Siehe [Hopper- und Ghidra-Einrichtung](../installation.md#hopper), den [IDA-Leitfaden](../ida-provider.md), [Kuna](../kuna-provider.md) und die [experimentelle Ghidra-Unterstützung unter Windows](../windows-ghidra-p0.md). Ghidra unterstützt auch [16-Bit-DOS-Analyse](../ghidra-dos.md). Die Provider-Auswahl beschreibt der [CLI-Leitfaden](../cli.md#choose-a-provider). Prüfe die [Verfügbarkeit in Releases](../installation.md#released-package-and-main) für Funktionen, die seit der neuesten npm-Version hinzugekommen sind.
|
||||
|
||||
## Fallstudien
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ npx rea-agents@latest setup
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Οι μορφές κώδικα μηχανής και τα υποστηριζόμενα συστήματα διαφέρουν ανά πάροχο ανάλυσης. Δες τη [ρύθμιση Hopper και Ghidra](../installation.md#hopper), τον [οδηγό IDA](../ida-provider.md) (που περιλαμβάνει προαιρετική γέφυρα MCP για κώδικα μηχανής) και την [πειραματική υποστήριξη Ghidra στα Windows](../windows-ghidra-p0.md). Το Ghidra υποστηρίζει επίσης [ανάλυση DOS 16 bit](../ghidra-dos.md). Για μεγάλα δυαδικά αρχεία, αύξησε το χρονικό όριο εκκίνησης του παρόχου με `REA_GHIDRA_STARTUP_TIMEOUT_MS` ή `REA_HOPPER_STARTUP_TIMEOUT_MS`. Για την επιλογή παρόχου, δες τον [οδηγό CLI](../cli.md#choose-a-provider). Έλεγξε τη [διαθεσιμότητα ανά έκδοση](../installation.md#released-package-and-main) για λειτουργίες που προστέθηκαν μετά την τελευταία έκδοση στο npm.
|
||||
Οι μορφές κώδικα μηχανής και τα υποστηριζόμενα συστήματα διαφέρουν ανά πάροχο ανάλυσης. Δες τη [ρύθμιση Hopper και Ghidra](../installation.md#hopper), τον [οδηγό IDA](../ida-provider.md) (που περιλαμβάνει προαιρετική γέφυρα MCP για κώδικα μηχανής), [Kuna](../kuna-provider.md) και την [πειραματική υποστήριξη Ghidra στα Windows](../windows-ghidra-p0.md). Το Ghidra υποστηρίζει επίσης [ανάλυση DOS 16 bit](../ghidra-dos.md). Για μεγάλα δυαδικά αρχεία, αύξησε το χρονικό όριο εκκίνησης του παρόχου με `REA_GHIDRA_STARTUP_TIMEOUT_MS` ή `REA_HOPPER_STARTUP_TIMEOUT_MS`. Για την επιλογή παρόχου, δες τον [οδηγό CLI](../cli.md#choose-a-provider). Έλεγξε τη [διαθεσιμότητα ανά έκδοση](../installation.md#released-package-and-main) για λειτουργίες που προστέθηκαν μετά την τελευταία έκδοση στο npm.
|
||||
|
||||
## Μελέτες περίπτωσης
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ La inspección estática de JavaScript y .NET lee los archivos suministrados sin
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Los formatos nativos y los sistemas anfitriones compatibles varían según el proveedor. Consulta [configuración de Hopper y Ghidra](../installation.md#hopper), la [guía de IDA](../ida-provider.md) y la [compatibilidad experimental de Ghidra en Windows](../windows-ghidra-p0.md). Ghidra también admite [análisis de DOS de 16 bits](../ghidra-dos.md). Para elegir un proveedor, consulta la [guía de CLI](../cli.md#choose-a-provider). Comprueba la [disponibilidad por versión](../installation.md#released-package-and-main) de las funciones añadidas después de la última versión publicada en npm.
|
||||
Los formatos nativos y los sistemas anfitriones compatibles varían según el proveedor. Consulta [configuración de Hopper y Ghidra](../installation.md#hopper), la [guía de IDA](../ida-provider.md), [Kuna](../kuna-provider.md) y la [compatibilidad experimental de Ghidra en Windows](../windows-ghidra-p0.md). Ghidra también admite [análisis de DOS de 16 bits](../ghidra-dos.md). Para elegir un proveedor, consulta la [guía de CLI](../cli.md#choose-a-provider). Comprueba la [disponibilidad por versión](../installation.md#released-package-and-main) de las funciones añadidas después de la última versión publicada en npm.
|
||||
|
||||
## Casos prácticos
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
فرمتهای بومی و پشتیبانی سیستم میزبان بر اساس ارائهدهنده متفاوتاند. [راهاندازی Hopper و Ghidra](../installation.md#hopper)، [راهنمای IDA](../ida-provider.md) و [پشتیبانی آزمایشی Ghidra در ویندوز](../windows-ghidra-p0.md) را ببینید. Ghidra همچنین از [تحلیل DOS شانزدهبیتی](../ghidra-dos.md) پشتیبانی میکند. برای انتخاب ارائهدهنده، به [راهنمای CLI](../cli.md#choose-a-provider) مراجعه کنید. برای قابلیتهایی که پس از آخرین انتشار npm اضافه شدهاند، [وضعیت انتشار](../installation.md#released-package-and-main) را بررسی کنید.
|
||||
فرمتهای بومی و پشتیبانی سیستم میزبان بر اساس ارائهدهنده متفاوتاند. [راهاندازی Hopper و Ghidra](../installation.md#hopper)، [راهنمای IDA](../ida-provider.md)، [Kuna](../kuna-provider.md) و [پشتیبانی آزمایشی Ghidra در ویندوز](../windows-ghidra-p0.md) را ببینید. Ghidra همچنین از [تحلیل DOS شانزدهبیتی](../ghidra-dos.md) پشتیبانی میکند. برای انتخاب ارائهدهنده، به [راهنمای CLI](../cli.md#choose-a-provider) مراجعه کنید. برای قابلیتهایی که پس از آخرین انتشار npm اضافه شدهاند، [وضعیت انتشار](../installation.md#released-package-and-main) را بررسی کنید.
|
||||
|
||||
## نمونههای عملی
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ L'inspection statique JavaScript et .NET lit les fichiers fournis sans exécuter
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Les formats natifs et les hôtes pris en charge varient selon le fournisseur. Consultez [la configuration de Hopper et Ghidra](../installation.md#hopper), le [guide IDA](../ida-provider.md) et [la prise en charge expérimentale de Ghidra sous Windows](../windows-ghidra-p0.md). Ghidra prend aussi en charge [l'analyse DOS 16 bits](../ghidra-dos.md). Pour les gros binaires, augmentez son délai de démarrage avec `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Pour le choix du fournisseur, consultez le [guide CLI](../cli.md#choose-a-provider). Vérifiez la [disponibilité dans les versions publiées](../installation.md#released-package-and-main) pour les fonctionnalités ajoutées depuis la dernière version npm.
|
||||
Les formats natifs et les hôtes pris en charge varient selon le fournisseur. Consultez [la configuration de Hopper et Ghidra](../installation.md#hopper), le [guide IDA](../ida-provider.md), [Kuna](../kuna-provider.md) et [la prise en charge expérimentale de Ghidra sous Windows](../windows-ghidra-p0.md). Ghidra prend aussi en charge [l'analyse DOS 16 bits](../ghidra-dos.md). Pour les gros binaires, augmentez son délai de démarrage avec `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Pour le choix du fournisseur, consultez le [guide CLI](../cli.md#choose-a-provider). Vérifiez la [disponibilité dans les versions publiées](../installation.md#released-package-and-main) pour les fonctionnalités ajoutées depuis la dernière version npm.
|
||||
|
||||
## Études de cas
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ Pemeriksaan JavaScript dan .NET statis membaca berkas yang disediakan tanpa menj
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Format native dan dukungan sistem host berbeda-beda menurut penyedia. Lihat [penyiapan Hopper dan Ghidra](../installation.md#hopper), [panduan IDA](../ida-provider.md), dan [dukungan eksperimental Ghidra di Windows](../windows-ghidra-p0.md).
|
||||
Format native dan dukungan sistem host berbeda-beda menurut penyedia. Lihat [penyiapan Hopper dan Ghidra](../installation.md#hopper), [panduan IDA](../ida-provider.md), [Kuna](../kuna-provider.md), dan [dukungan eksperimental Ghidra di Windows](../windows-ghidra-p0.md).
|
||||
|
||||
Ghidra juga mendukung [analisis DOS 16-bit](../ghidra-dos.md).
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ L'ispezione statica di JavaScript e .NET legge i file forniti senza eseguire l'a
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
I formati nativi e il supporto host variano in base al provider. Vedi la [configurazione di Hopper e Ghidra](../installation.md#hopper), la [guida IDA](../ida-provider.md) e il [supporto sperimentale Windows Ghidra](../windows-ghidra-p0.md). Ghidra supporta anche l'[analisi DOS a 16 bit](../ghidra-dos.md). Per binari di grandi dimensioni, aumenta il timeout di avvio con `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Per la selezione del provider, vedi la [guida CLI](../cli.md#choose-a-provider). Controlla la [disponibilità delle release](../installation.md#released-package-and-main) per le funzionalità aggiunte dall'ultima release npm.
|
||||
I formati nativi e il supporto host variano in base al provider. Vedi la [configurazione di Hopper e Ghidra](../installation.md#hopper), la [guida IDA](../ida-provider.md), [Kuna](../kuna-provider.md) e il [supporto sperimentale Windows Ghidra](../windows-ghidra-p0.md). Ghidra supporta anche l'[analisi DOS a 16 bit](../ghidra-dos.md). Per binari di grandi dimensioni, aumenta il timeout di avvio con `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Per la selezione del provider, vedi la [guida CLI](../cli.md#choose-a-provider). Controlla la [disponibilità delle release](../installation.md#released-package-and-main) per le funzionalità aggiunte dall'ultima release npm.
|
||||
|
||||
## Showcase
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
対応するネイティブ形式とホストは、プロバイダーによって異なります。[Hopper と Ghidra のセットアップ](../installation.md#hopper)、[IDA ガイド](../ida-provider.md)、[Windows 向け Ghidra の実験的対応](../windows-ghidra-p0.md)を参照してください。Ghidra は [16 ビット DOS 分析](../ghidra-dos.md)にも対応しています。プロバイダーの選択は [CLI ガイド](../cli.md#choose-a-provider)を参照してください。最新の npm リリース以降に追加された機能については、[リリースでの利用可否](../installation.md#released-package-and-main)を確認してください。
|
||||
対応するネイティブ形式とホストは、プロバイダーによって異なります。[Hopper と Ghidra のセットアップ](../installation.md#hopper)、[IDA ガイド](../ida-provider.md)、[Kuna](../kuna-provider.md)、[Windows 向け Ghidra の実験的対応](../windows-ghidra-p0.md)を参照してください。Ghidra は [16 ビット DOS 分析](../ghidra-dos.md)にも対応しています。プロバイダーの選択は [CLI ガイド](../cli.md#choose-a-provider)を参照してください。最新の npm リリース以降に追加された機能については、[リリースでの利用可否](../installation.md#released-package-and-main)を確認してください。
|
||||
|
||||
## 事例
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md)(선택 사항인 네이티브 MCP 브리지 포함), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 큰 바이너리를 분석할 때는 `REA_GHIDRA_STARTUP_TIMEOUT_MS` 또는 `REA_HOPPER_STARTUP_TIMEOUT_MS`로 제공자의 시작 제한 시간을 늘리세요. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
|
||||
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md)(선택 사항인 네이티브 MCP 브리지 포함), [Kuna](../kuna-provider.md), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 큰 바이너리를 분석할 때는 `REA_GHIDRA_STARTUP_TIMEOUT_MS` 또는 `REA_HOPPER_STARTUP_TIMEOUT_MS`로 제공자의 시작 제한 시간을 늘리세요. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
|
||||
|
||||
## 사례
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ Statyczna analiza JavaScript i .NET odczytuje dostarczone pliki bez uruchamiania
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Obsługiwane formaty natywne i platformy zależą od dostawcy analizy. Zobacz [konfigurację Hoppera i Ghidry](../installation.md#hopper), [przewodnik IDA](../ida-provider.md) oraz [eksperymentalną obsługę Ghidry w systemie Windows](../windows-ghidra-p0.md). Ghidra obsługuje też [analizę 16-bitowego DOS-a](../ghidra-dos.md). Wybór dostawcy opisuje [przewodnik CLI](../cli.md#choose-a-provider). Sprawdź [dostępność w wydaniach](../installation.md#released-package-and-main) dla funkcji dodanych po najnowszym wydaniu npm.
|
||||
Obsługiwane formaty natywne i platformy zależą od dostawcy analizy. Zobacz [konfigurację Hoppera i Ghidry](../installation.md#hopper), [przewodnik IDA](../ida-provider.md), [Kuna](../kuna-provider.md) oraz [eksperymentalną obsługę Ghidry w systemie Windows](../windows-ghidra-p0.md). Ghidra obsługuje też [analizę 16-bitowego DOS-a](../ghidra-dos.md). Wybór dostawcy opisuje [przewodnik CLI](../cli.md#choose-a-provider). Sprawdź [dostępność w wydaniach](../installation.md#released-package-and-main) dla funkcji dodanych po najnowszym wydaniu npm.
|
||||
|
||||
## Przykłady zastosowań
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ A inspeção estática de JavaScript e .NET lê os arquivos fornecidos sem execu
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Os formatos nativos e os sistemas anfitriões compatíveis variam por provedor. Consulte [configuração do Hopper e Ghidra](../installation.md#hopper), o [guia do IDA](../ida-provider.md) e o [suporte experimental ao Ghidra no Windows](../windows-ghidra-p0.md). O Ghidra também oferece [análise de DOS de 16 bits](../ghidra-dos.md). Para selecionar um provedor, consulte o [guia da CLI](../cli.md#choose-a-provider). Verifique a [disponibilidade nas versões](../installation.md#released-package-and-main) das funcionalidades adicionadas após a versão mais recente no npm.
|
||||
Os formatos nativos e os sistemas anfitriões compatíveis variam por provedor. Consulte [configuração do Hopper e Ghidra](../installation.md#hopper), o [guia do IDA](../ida-provider.md), [Kuna](../kuna-provider.md) e o [suporte experimental ao Ghidra no Windows](../windows-ghidra-p0.md). O Ghidra também oferece [análise de DOS de 16 bits](../ghidra-dos.md). Para selecionar um provedor, consulte o [guia da CLI](../cli.md#choose-a-provider). Verifique a [disponibilidade nas versões](../installation.md#released-package-and-main) das funcionalidades adicionadas após a versão mais recente no npm.
|
||||
|
||||
## Casos de uso
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Нативные форматы и поддерживаемые системы различаются у разных провайдеров. См. [настройку Hopper и Ghidra](../installation.md#hopper), [руководство по IDA](../ida-provider.md) и [экспериментальную поддержку Ghidra в Windows](../windows-ghidra-p0.md). Ghidra также поддерживает [анализ 16-битного DOS](../ghidra-dos.md). Выбор провайдера описан в [руководстве по CLI](../cli.md#choose-a-provider). Для функций, добавленных после последней версии в npm, проверьте [доступность в релизах](../installation.md#released-package-and-main).
|
||||
Нативные форматы и поддерживаемые системы различаются у разных провайдеров. См. [настройку Hopper и Ghidra](../installation.md#hopper), [руководство по IDA](../ida-provider.md), [Kuna](../kuna-provider.md) и [экспериментальную поддержку Ghidra в Windows](../windows-ghidra-p0.md). Ghidra также поддерживает [анализ 16-битного DOS](../ghidra-dos.md). Выбор провайдера описан в [руководстве по CLI](../cli.md#choose-a-provider). Для функций, добавленных после последней версии в npm, проверьте [доступность в релизах](../installation.md#released-package-and-main).
|
||||
|
||||
## Примеры
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
รูปแบบไบนารีเนทีฟและแพลตฟอร์มที่รองรับแตกต่างกันตามผู้ให้บริการวิเคราะห์ ดู[การตั้งค่า Hopper และ Ghidra](../installation.md#hopper), [คู่มือ IDA](../ida-provider.md) และ[การรองรับ Ghidra บน Windows แบบทดลอง](../windows-ghidra-p0.md) Ghidra ยังรองรับ[การวิเคราะห์ DOS แบบ 16 บิต](../ghidra-dos.md) สำหรับการเลือกผู้ให้บริการ ดู[คู่มือ CLI](../cli.md#choose-a-provider) ตรวจสอบ[ความพร้อมใช้งานในแต่ละรุ่น](../installation.md#released-package-and-main) สำหรับฟีเจอร์ที่เพิ่มหลังรุ่นล่าสุดบน npm
|
||||
รูปแบบไบนารีเนทีฟและแพลตฟอร์มที่รองรับแตกต่างกันตามผู้ให้บริการวิเคราะห์ ดู[การตั้งค่า Hopper และ Ghidra](../installation.md#hopper), [คู่มือ IDA](../ida-provider.md) [Kuna](../kuna-provider.md) และ[การรองรับ Ghidra บน Windows แบบทดลอง](../windows-ghidra-p0.md) Ghidra ยังรองรับ[การวิเคราะห์ DOS แบบ 16 บิต](../ghidra-dos.md) สำหรับการเลือกผู้ให้บริการ ดู[คู่มือ CLI](../cli.md#choose-a-provider) ตรวจสอบ[ความพร้อมใช้งานในแต่ละรุ่น](../installation.md#released-package-and-main) สำหรับฟีเจอร์ที่เพิ่มหลังรุ่นล่าสุดบน npm
|
||||
|
||||
## ตัวอย่างการใช้งาน
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ Statik JavaScript ve .NET incelemesi, uygulamayı çalıştırmadan sağlanan do
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Yerel makine kodu içeren ikili dosya biçimleri ve ana bilgisayar desteği sağlayıcıya göre değişir. [Hopper ve Ghidra kurulumu](../installation.md#hopper), isteğe bağlı native MCP bridge'i de açıklayan [IDA rehberi](../ida-provider.md) ve [Windows için deneysel Ghidra desteği](../windows-ghidra-p0.md) belgelerine bakın. Ghidra ayrıca [16 bit DOS analizini](../ghidra-dos.md) destekler. Büyük ikili dosyalar için sağlayıcının başlatma zaman aşımını `REA_GHIDRA_STARTUP_TIMEOUT_MS` veya `REA_HOPPER_STARTUP_TIMEOUT_MS` ile artırın. Sağlayıcı seçimi için [CLI rehberine](../cli.md#choose-a-provider) bakın. En son npm sürümünden sonra eklenen özellikler için [sürümlerde kullanılabilirliği](../installation.md#released-package-and-main) kontrol edin.
|
||||
Yerel makine kodu içeren ikili dosya biçimleri ve ana bilgisayar desteği sağlayıcıya göre değişir. [Hopper ve Ghidra kurulumu](../installation.md#hopper), isteğe bağlı native MCP bridge'i de açıklayan [IDA rehberi](../ida-provider.md), [Kuna](../kuna-provider.md) ve [Windows için deneysel Ghidra desteği](../windows-ghidra-p0.md) belgelerine bakın. Ghidra ayrıca [16 bit DOS analizini](../ghidra-dos.md) destekler. Büyük ikili dosyalar için sağlayıcının başlatma zaman aşımını `REA_GHIDRA_STARTUP_TIMEOUT_MS` veya `REA_HOPPER_STARTUP_TIMEOUT_MS` ile artırın. Sağlayıcı seçimi için [CLI rehberine](../cli.md#choose-a-provider) bakın. En son npm sürümünden sonra eklenen özellikler için [sürümlerde kullanılabilirliği](../installation.md#released-package-and-main) kontrol edin.
|
||||
|
||||
## Örnek çalışmalar
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Нативні формати й підтримка систем відрізняються залежно від провайдера. Дивіться [налаштування Hopper і Ghidra](../installation.md#hopper), [посібник IDA](../ida-provider.md) та [експериментальну підтримку Ghidra у Windows](../windows-ghidra-p0.md). Ghidra також підтримує [аналіз 16-бітного DOS](../ghidra-dos.md). Для вибору провайдера дивіться [посібник CLI](../cli.md#choose-a-provider). Для функцій, доданих після найновішої версії в npm, перевірте [доступність у випусках](../installation.md#released-package-and-main).
|
||||
Нативні формати й підтримка систем відрізняються залежно від провайдера. Дивіться [налаштування Hopper і Ghidra](../installation.md#hopper), [посібник IDA](../ida-provider.md), [Kuna](../kuna-provider.md) та [експериментальну підтримку Ghidra у Windows](../windows-ghidra-p0.md). Ghidra також підтримує [аналіз 16-бітного DOS](../ghidra-dos.md). Для вибору провайдера дивіться [посібник CLI](../cli.md#choose-a-provider). Для функцій, доданих після найновішої версії в npm, перевірте [доступність у випусках](../installation.md#released-package-and-main).
|
||||
|
||||
## Приклади
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ Kiểm tra tĩnh JavaScript và .NET đọc các tệp được cung cấp mà k
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Định dạng mã máy và nền tảng chạy REA được hỗ trợ khác nhau tùy trình phân tích. Xem [thiết lập Hopper và Ghidra](../installation.md#hopper), [hướng dẫn IDA](../ida-provider.md) và [hỗ trợ Ghidra thử nghiệm trên Windows](../windows-ghidra-p0.md). Ghidra cũng hỗ trợ [phân tích DOS 16 bit](../ghidra-dos.md). Để chọn trình phân tích, xem [hướng dẫn CLI](../cli.md#choose-a-provider). Kiểm tra [khả năng sử dụng theo bản phát hành](../installation.md#released-package-and-main) đối với tính năng được bổ sung sau phiên bản npm mới nhất.
|
||||
Định dạng mã máy và nền tảng chạy REA được hỗ trợ khác nhau tùy trình phân tích. Xem [thiết lập Hopper và Ghidra](../installation.md#hopper), [hướng dẫn IDA](../ida-provider.md), [Kuna](../kuna-provider.md) và [hỗ trợ Ghidra thử nghiệm trên Windows](../windows-ghidra-p0.md). Ghidra cũng hỗ trợ [phân tích DOS 16 bit](../ghidra-dos.md). Để chọn trình phân tích, xem [hướng dẫn CLI](../cli.md#choose-a-provider). Kiểm tra [khả năng sử dụng theo bản phát hành](../installation.md#released-package-and-main) đối với tính năng được bổ sung sau phiên bản npm mới nhất.
|
||||
|
||||
## Các nghiên cứu điển hình
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
不同提供者支援的原生格式和主機系統有所不同。請參閱 [Hopper 與 Ghidra 設定](../installation.md#hopper)、[IDA 指南](../ida-provider.md)和[實驗性 Windows Ghidra 支援](../windows-ghidra-p0.md)。Ghidra 也支援 [16 位 DOS 分析](../ghidra-dos.md)。提供者選擇見 [CLI 指南](../cli.md#choose-a-provider)。對於最新 npm 版本發佈後新增的功能,請查看[發佈可用性](../installation.md#released-package-and-main)。
|
||||
不同提供者支援的原生格式和主機系統有所不同。請參閱 [Hopper 與 Ghidra 設定](../installation.md#hopper)、[IDA 指南](../ida-provider.md)、[Kuna](../kuna-provider.md)和[實驗性 Windows Ghidra 支援](../windows-ghidra-p0.md)。Ghidra 也支援 [16 位 DOS 分析](../ghidra-dos.md)。提供者選擇見 [CLI 指南](../cli.md#choose-a-provider)。對於最新 npm 版本發佈後新增的功能,請查看[發佈可用性](../installation.md#released-package-and-main)。
|
||||
|
||||
## 案例
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
不同提供方支持的原生格式和主机系统有所不同。请参阅 [Hopper 与 Ghidra 设置](../installation.md#hopper)、[IDA 指南](../ida-provider.md)和[实验性 Windows Ghidra 支持](../windows-ghidra-p0.md)。Ghidra 也支持 [16 位 DOS 分析](../ghidra-dos.md)。提供方选择见 [CLI 指南](../cli.md#choose-a-provider)。对于最新 npm 版本发布后新增的功能,请查看[发布可用性](../installation.md#released-package-and-main)。
|
||||
不同提供方支持的原生格式和主机系统有所不同。请参阅 [Hopper 与 Ghidra 设置](../installation.md#hopper)、[IDA 指南](../ida-provider.md)、[Kuna](../kuna-provider.md)和[实验性 Windows Ghidra 支持](../windows-ghidra-p0.md)。Ghidra 也支持 [16 位 DOS 分析](../ghidra-dos.md)。提供方选择见 [CLI 指南](../cli.md#choose-a-provider)。对于最新 npm 版本发布后新增的功能,请查看[发布可用性](../installation.md#released-package-and-main)。
|
||||
|
||||
## 案例
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Reconstruction coverage
|
||||
|
||||
`rea evaluate-reconstruction-coverage coverage.json --json` evaluates an inline
|
||||
coverage workspace and its selected `boundary_id`. MCP exposes the same input
|
||||
through `evaluate_reconstruction_coverage`. Results contain a status, reasons,
|
||||
recommended probes and Evidence references; the supplied bundle retains the
|
||||
complete records.
|
||||
|
||||
Evidence IDs and bundle manifests must authenticate before evaluation. A valid
|
||||
record can still be insufficient proof: a passing verifier summary cannot
|
||||
promote inferred Evidence into a replay observation. That case returns
|
||||
`unknown`, while a missing or tampered record remains an input error.
|
||||
|
||||
## Comparable verifier Evidence
|
||||
|
||||
At least one record explicitly cited by the latest verifier observation must
|
||||
support the declared contract authority:
|
||||
|
||||
| Contract authority | Evidence source | Confidence |
|
||||
| ------------------- | ----------------------------------------- | ----------------------- |
|
||||
| `shipped-artifact` | `shipped-artifact` | `observed` or `derived` |
|
||||
| `controlled-replay` | `controlled-replay` | `observed` |
|
||||
| `live-observation` | `controlled-replay` or `external-service` | `observed` |
|
||||
| `external` | `external-service` | `observed` |
|
||||
|
||||
Contextual references can accompany qualifying proof. An unrelated observed
|
||||
record elsewhere in the bundle cannot repair a result that cites only
|
||||
inference. Older observed results cannot replace the newest weak result.
|
||||
Equivalent observations at the same instant can share qualifying proof from
|
||||
one of those observations; conflicting tied assessments remain unknown.
|
||||
|
||||
## Package and integration proof
|
||||
|
||||
Build, clean-install and runtime-routing claims require observed runtime
|
||||
Evidence (`controlled-replay` or `external-service`). Package-content and
|
||||
authority-independence audits can also use observed or derived
|
||||
`shipped-artifact` Evidence. Each proof must cite its qualifying records and
|
||||
cover the selected boundary's artifact digests. An inference alone establishes
|
||||
neither a passing nor a failed package workflow.
|
||||
|
||||
The evaluator preserves Evidence provenance and returns an actionable unknown
|
||||
when qualification is unavailable. It does not change the cited records to
|
||||
make their authority match a caller's declaration.
|
||||
@@ -323,3 +323,6 @@ disposition but does not silently count it as verified.
|
||||
The tool returns every obligation inline. Its `ledger_id` and `closure_digest`
|
||||
identify the complete result, including summaries, reports, ownership and
|
||||
dependency graphs, Evidence links, and limitations.
|
||||
|
||||
For named boundaries with explicit verifier contracts and package-proof kinds,
|
||||
see [reconstruction coverage](reconstruction-coverage.md).
|
||||
@@ -16,11 +16,12 @@ can run.
|
||||
|
||||
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported.
|
||||
|
||||
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI option is `--follow-debug-links true`. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
|
||||
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
|
||||
|
||||
```sh
|
||||
rea inspect-with-objdump ./program disassemble
|
||||
rea inspect-with-objdump ./program dwarf --follow-debug-links false
|
||||
rea inspect-with-objdump ./program dwarf
|
||||
rea inspect-with-objdump ./program dwarf --follow-debug-links
|
||||
```
|
||||
|
||||
Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit.
|
||||
|
||||
@@ -817,6 +817,34 @@ Native IDA MCP verification uses `"protocol": "native"` with an already-running
|
||||
|
||||
The initial Linux x64 IDA Free 9.4 run used native MCP 1.1.2 with a local navigation correction. Windows x64 verification uses **IDA Pro 9.3**, the two-function public fixture, and the shipped Windows `whoami.exe` application. The native plugin was built from upstream `c9d15d98e8a143fc74f6e14037c48f63889b6d6c`, including the merged navigation fix, with a local loopback-listener change. Production CLI/MCP parity, switching, identity and cleanup passed. Correctness and performance measurements are recorded in the [Windows native verification notes](https://github.com/morluto/rea/blob/main/experiments/analysis-efficiency/ida-native-windows.md). Stock 1.1.2 switching, native macOS, and Windows IDA Free remain unverified. See the [native bridge prerequisites](ida-provider.md#optional-native-ida-mcp-bridge).
|
||||
|
||||
## Kuna lane
|
||||
|
||||
`npm run verify:kuna` requires a caller-supplied `kuna` in `REA_KUNA_COMMAND`
|
||||
and a host C compiler (`REA_CC`, default `cc`) with `strip` and `nm`. It builds a
|
||||
source-owned fixture with symbols and stripped, then verifies through MCP that
|
||||
the first cold pseudocode answer comes from a single-function run or an export
|
||||
that already finished, assembly waits for and serves from that export, and
|
||||
pseudocode and both dossier facets match their recorded file spans and digests,
|
||||
a fresh server reuses the export without rebuilding it, staging is empty after
|
||||
a completed export, and no owned process survives. Pass
|
||||
`--targets DIR --functions a,b` to add caller-built binaries laid out as
|
||||
`DIR/<flavor>/<name>` beside `<name>.stripped`; addresses come from the
|
||||
symbol-bearing build. `--compare-ghidra` with `GHIDRA_INSTALL_DIR` runs those
|
||||
caller-built functions through Ghidra (it refuses to run without `--targets` and
|
||||
`--functions`, an empty matrix, or names missing from every supplied target) and
|
||||
reports latency, sampled process-tree CPU and peak memory, UTF-8 response size,
|
||||
and goto and warning-marker counts. Those counts are not a correctness judgement,
|
||||
and model tokens are not measured. `--report PATH` writes the JSON report.
|
||||
|
||||
The first matrix run used Kuna 1.833 and Ghidra 12.1.4 with JDK 21 on macOS
|
||||
arm64. It covered zlib 1.3.2 `minigzip`, coreutils 9.9 `sort` and the sqlite
|
||||
3.54.0 shell, built at `-O2` for ELF x86-64, ELF AArch64, Mach-O arm64 and
|
||||
Mach-O x86-64, each with symbols and stripped. A separate Linux x86-64 run verified
|
||||
the fixture and system zlib 1.2.13 / SQLite 3.40.1 libraries through MCP, including
|
||||
cached byte spans and a Ghidra comparison. See the [Linux report](https://github.com/morluto/rea/pull/3854#issuecomment-6185870391)
|
||||
for measured scope and limitations. Windows, PE and universal Mach-O remain
|
||||
unverified by this lane.
|
||||
|
||||
## DOS Ghidra analysis
|
||||
|
||||
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
|
||||
|
||||
+3
-2
@@ -194,6 +194,7 @@
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
|
||||
"verify:kuna": "npm run build:cached && node scripts/verify-real-kuna.mjs",
|
||||
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
|
||||
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
|
||||
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
|
||||
@@ -290,8 +291,8 @@
|
||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
||||
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/bundle-runtime.mjs",
|
||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/bundle-runtime.mjs",
|
||||
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
|
||||
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
||||
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
import { AnalysisCancelledError } from "../../dist/domain/analysisErrorCore.js";
|
||||
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
|
||||
import { requireMcpEvidenceResult } from "./mcp-verifier-results.mjs";
|
||||
import { closeVerifierResources } from "./verifier-cleanup.mjs";
|
||||
|
||||
const expected = {
|
||||
timezone: "Pacific/Honolulu",
|
||||
@@ -113,19 +114,21 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
|
||||
assert.equal(value.result.type, "string");
|
||||
return JSON.parse(value.result.value);
|
||||
};
|
||||
const baseline = await readEnvironment();
|
||||
const scenario = environmentScenario({
|
||||
endpoint,
|
||||
targetId,
|
||||
origin,
|
||||
values: expected,
|
||||
});
|
||||
const assertRestored = async () => {
|
||||
assert.deepEqual(await readEnvironment(), baseline);
|
||||
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
assert.ok(remaining.some(({ id }) => id === targetId));
|
||||
};
|
||||
const provider = createBrowserScenarioProvider(process.env);
|
||||
let verificationError;
|
||||
try {
|
||||
const baseline = await readEnvironment();
|
||||
const scenario = environmentScenario({
|
||||
endpoint,
|
||||
targetId,
|
||||
origin,
|
||||
values: expected,
|
||||
});
|
||||
const assertRestored = async () => {
|
||||
assert.deepEqual(await readEnvironment(), baseline);
|
||||
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
assert.ok(remaining.some(({ id }) => id === targetId));
|
||||
};
|
||||
assertCapture((await runScenarioCli(scenario)).normalized_result);
|
||||
await assertRestored();
|
||||
const transport = new StdioClientTransport({
|
||||
@@ -167,7 +170,6 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
|
||||
await transport.close();
|
||||
}
|
||||
await assertRestored();
|
||||
const provider = createBrowserScenarioProvider(process.env);
|
||||
const initializationFailure = await provider.captureScenario({
|
||||
...scenario,
|
||||
environment: { ...scenario.environment, timezone: "REA/Invalid" },
|
||||
@@ -204,8 +206,14 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
|
||||
cancellation_cleanup: true,
|
||||
external_target_preserved: true,
|
||||
};
|
||||
} catch (cause) {
|
||||
verificationError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await connection.close();
|
||||
await closeVerifierResources(
|
||||
[() => provider.close(), () => connection.close()],
|
||||
verificationError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +364,11 @@ const releaseScenario = async (connection) => {
|
||||
});
|
||||
};
|
||||
|
||||
async function verifyCancelledEnvironment(provider, connection, scenario) {
|
||||
export async function verifyCancelledEnvironment(
|
||||
provider,
|
||||
connection,
|
||||
scenario,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const priorSnapshot = await readScenarioSnapshot(connection);
|
||||
const pending = provider.captureScenario(
|
||||
@@ -378,9 +390,15 @@ async function verifyCancelledEnvironment(provider, connection, scenario) {
|
||||
await settlement;
|
||||
}
|
||||
const cancelled = await pending;
|
||||
assert.ok(
|
||||
!cancelled.ok ||
|
||||
if (!cancelled.ok) {
|
||||
if (!(cancelled.error instanceof AnalysisCancelledError))
|
||||
throw cancelled.error;
|
||||
assert.equal(cancelled.error.cleanupIncomplete, false);
|
||||
} else {
|
||||
assert.ok(
|
||||
cancelled.value.steps.some(({ status }) => status === "cancelled"),
|
||||
"Cancellation unexpectedly completed every action",
|
||||
);
|
||||
"Cancellation unexpectedly completed every action",
|
||||
);
|
||||
assert.equal(cancelled.value.browser.cleanup, "disconnected-external");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runScenarioCli,
|
||||
scenarioProfiles,
|
||||
} from "./browser-scenario-verifier.mjs";
|
||||
import { closeVerifierResources } from "./verifier-cleanup.mjs";
|
||||
|
||||
const scenarioFor = (browser, origin) =>
|
||||
browserScenarioSchema.parse({
|
||||
@@ -299,6 +300,7 @@ const verifyConnectedStorage = async (endpoint, targetId, origin) => {
|
||||
target.webSocketDebuggerUrl,
|
||||
"capture_browser_scenario",
|
||||
);
|
||||
let failure;
|
||||
try {
|
||||
await assertStorageKeyPartition(observer, origin);
|
||||
const result = await runScenarioCli(scenario);
|
||||
@@ -313,11 +315,14 @@ const verifyConnectedStorage = async (endpoint, targetId, origin) => {
|
||||
assert.equal(capture.browser.cleanup, "disconnected-external");
|
||||
await verifyCallerDebuggerPause(observer, origin, scenario);
|
||||
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await raceWithTimeout(restoreTarget(observer, target.url), 1_000).catch(
|
||||
() => undefined,
|
||||
await closeVerifierResources(
|
||||
[() => restoreTarget(observer, target.url), () => observer.close()],
|
||||
failure,
|
||||
);
|
||||
await raceWithTimeout(observer.close(), 1_000).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -503,7 +508,8 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
|
||||
],
|
||||
});
|
||||
let settled = false;
|
||||
const pending = createBrowserScenarioProvider(process.env)
|
||||
const provider = createBrowserScenarioProvider(process.env);
|
||||
const pending = provider
|
||||
.captureScenario(debuggerScenario, { signal: controller.signal })
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
@@ -586,9 +592,10 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await raceWithTimeout(caller.send("Debugger.disable"), 1_000).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await closeVerifierResources([
|
||||
() => caller.send("Debugger.disable"),
|
||||
() => provider.close(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -615,38 +622,43 @@ const verifyCancelledStorage = async (
|
||||
expression: "performance.timeOrigin",
|
||||
returnByValue: true,
|
||||
});
|
||||
const pending = createBrowserScenarioProvider(process.env).captureScenario(
|
||||
cancelledScenario,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await waitForNewDocument(
|
||||
observer,
|
||||
"/storage-main",
|
||||
priorDocument.result.value,
|
||||
);
|
||||
const state = await observer.send("Runtime.evaluate", {
|
||||
expression:
|
||||
"JSON.parse(document.querySelector('#main-report').textContent)",
|
||||
returnByValue: true,
|
||||
const provider = createBrowserScenarioProvider(process.env);
|
||||
const pending = provider.captureScenario(cancelledScenario, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
assert.deepEqual(state.result.value, {
|
||||
local: "main-initial",
|
||||
session: "main-session-initial",
|
||||
removed: "main-remove-seed",
|
||||
empty: "main-empty-seed",
|
||||
});
|
||||
controller.abort(new Error("cancel storage seed scenario"));
|
||||
const result = await pending;
|
||||
assert.ok(
|
||||
!result.ok ||
|
||||
result.value.steps.some(({ status }) => status === "cancelled"),
|
||||
"Cancellation unexpectedly completed every scenario step",
|
||||
);
|
||||
const targets = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
assert.ok(
|
||||
targets.some(({ id }) => id === targetId),
|
||||
"Cancelled connect-mode storage cleanup closed the external target",
|
||||
);
|
||||
try {
|
||||
await waitForNewDocument(
|
||||
observer,
|
||||
"/storage-main",
|
||||
priorDocument.result.value,
|
||||
);
|
||||
const state = await observer.send("Runtime.evaluate", {
|
||||
expression:
|
||||
"JSON.parse(document.querySelector('#main-report').textContent)",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.deepEqual(state.result.value, {
|
||||
local: "main-initial",
|
||||
session: "main-session-initial",
|
||||
removed: "main-remove-seed",
|
||||
empty: "main-empty-seed",
|
||||
});
|
||||
controller.abort(new Error("cancel storage seed scenario"));
|
||||
const result = await pending;
|
||||
assert.ok(
|
||||
!result.ok ||
|
||||
result.value.steps.some(({ status }) => status === "cancelled"),
|
||||
"Cancellation unexpectedly completed every scenario step",
|
||||
);
|
||||
const targets = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
assert.ok(
|
||||
targets.some(({ id }) => id === targetId),
|
||||
"Cancelled connect-mode storage cleanup closed the external target",
|
||||
);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await provider.close();
|
||||
}
|
||||
};
|
||||
|
||||
const waitForNewDocument = async (connection, path, priorTimeOrigin) => {
|
||||
@@ -669,15 +681,38 @@ const waitForNewDocument = async (connection, path, priorTimeOrigin) => {
|
||||
};
|
||||
|
||||
const restoreTarget = async (connection, url) => {
|
||||
await connection.send("Page.navigate", { url });
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await connection.send("Runtime.evaluate", {
|
||||
expression: "location.href",
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.result.value === url) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
const loaded = new Set();
|
||||
let loaderId;
|
||||
let resolveLoaded;
|
||||
const load = new Promise((resolve) => {
|
||||
resolveLoaded = resolve;
|
||||
});
|
||||
const unsubscribe = connection.onEvent((event) => {
|
||||
if (
|
||||
event.method !== "Page.lifecycleEvent" ||
|
||||
event.params?.name !== "load" ||
|
||||
typeof event.params.loaderId !== "string"
|
||||
)
|
||||
return;
|
||||
loaded.add(event.params.loaderId);
|
||||
if (event.params.loaderId === loaderId) resolveLoaded();
|
||||
});
|
||||
try {
|
||||
await connection.send("Page.setLifecycleEventsEnabled", { enabled: true });
|
||||
const navigation = await connection.send("Page.navigate", { url });
|
||||
assert.equal(navigation.errorText, undefined);
|
||||
assert.equal(typeof navigation.loaderId, "string");
|
||||
loaderId = navigation.loaderId;
|
||||
// A URL changes at commit, before old child frames have been retired.
|
||||
// Do not hand the shared page to the next verifier until this load ends.
|
||||
if (!loaded.has(loaderId))
|
||||
await raceWithTimeout(load, 10_000, () => {
|
||||
throw new Error(
|
||||
"External browser did not finish restoring its document",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await connection.send("Page.setLifecycleEventsEnabled", { enabled: false });
|
||||
}
|
||||
throw new Error("External browser did not return to its original URL");
|
||||
};
|
||||
@@ -218,6 +218,13 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.idaProvider.IDA_PROVIDER_IDENTITY,
|
||||
contracts: declaredContracts("ida", sources.idaProvider.IDA_OPERATIONS),
|
||||
},
|
||||
{
|
||||
identity: sources.kunaProvider.KUNA_PROVIDER_IDENTITY,
|
||||
contracts: declaredContracts(
|
||||
"kuna",
|
||||
sources.kunaProvider.KUNA_OPERATIONS,
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.nativeProvider.NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
contracts: sources.nativeContracts.NATIVE_TOOL_CONTRACTS,
|
||||
|
||||
@@ -109,6 +109,7 @@ const SOURCE_PATHS = {
|
||||
hopperProvider: "dist/hopper/HopperProviderCapabilities.js",
|
||||
ghidraProvider: "dist/ghidra/GhidraProviderCapabilities.js",
|
||||
idaProvider: "dist/ida/IdaProviderCapabilities.js",
|
||||
kunaProvider: "dist/kuna/KunaProviderCapabilities.js",
|
||||
nativeProvider: "dist/native/NativeMacOSProviderMetadata.js",
|
||||
artifactProviders: "dist/application/InvestigationProviders.js",
|
||||
browserIdentities: "dist/browser/providerIdentities.js",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
// Record the default catalog digests into the compiled runtime. Run after
|
||||
// `tsc` and before bundling, so the digests come from the same compiled
|
||||
// contracts and pinned schema converter that the runtime serves.
|
||||
const compiledRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../dist",
|
||||
);
|
||||
const { createCatalogDigests } = await import(
|
||||
pathToFileURL(join(compiledRoot, "catalogIdentity.js")).href
|
||||
);
|
||||
await writeFile(
|
||||
join(compiledRoot, "recordedCatalogDigests.js"),
|
||||
`/** Recorded from the compiled default catalog by scripts/record-catalog-digests.mjs; do not edit. */\nexport const RECORDED_CATALOG_DIGESTS = Object.freeze(${JSON.stringify(createCatalogDigests())});\n`,
|
||||
"utf8",
|
||||
);
|
||||
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env node
|
||||
// Real Kuna provider lane. Requires a caller-supplied kuna (REA_KUNA_COMMAND)
|
||||
// and a host C compiler; REA installs neither. The default run builds a
|
||||
// source-owned fixture, with symbols and stripped, and verifies the public MCP
|
||||
// workflow: cold single-function pseudocode, the project export, warm reuse
|
||||
// from a fresh server, and cleanup. `--targets DIR --functions a,b` adds
|
||||
// caller-built binaries (DIR/<flavor>/<name> plus <name>.stripped).
|
||||
// `--compare-ghidra` runs the same functions through a bring-your-own Ghidra
|
||||
// and reports latency, CPU, memory, output size and simple quality signals.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { execFileOutput } from "../dist/process/ExecFileOutput.js";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
assertVerifierCleanupComplete,
|
||||
completeVerifierRun,
|
||||
createVerifierRun,
|
||||
} from "./lib/verifier-run.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
targets: { type: "string" },
|
||||
functions: { type: "string" },
|
||||
"compare-ghidra": { type: "boolean", default: false },
|
||||
report: { type: "string" },
|
||||
entrypoint: { type: "string" },
|
||||
},
|
||||
});
|
||||
const entrypoint =
|
||||
values.entrypoint === undefined
|
||||
? fileURLToPath(new URL("./rea.mjs", import.meta.url))
|
||||
: resolve(values.entrypoint);
|
||||
const kuna = process.env.REA_KUNA_COMMAND;
|
||||
assert.ok(
|
||||
kuna !== undefined && kuna.startsWith("/"),
|
||||
"Kuna lane prerequisite missing: REA_KUNA_COMMAND must name a caller-supplied kuna by absolute path (bring your own)",
|
||||
);
|
||||
if (values["compare-ghidra"]) {
|
||||
// The comparison runs on caller-built targets only; the fixture has none.
|
||||
assert.ok(
|
||||
values.targets !== undefined && values.functions !== undefined,
|
||||
"--compare-ghidra compares caller-built targets: pass --targets DIR --functions a,b as well",
|
||||
);
|
||||
assert.ok(
|
||||
process.env.GHIDRA_INSTALL_DIR,
|
||||
"Ghidra comparison prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra 12.1.4 and JDK 21)",
|
||||
);
|
||||
}
|
||||
const run = createVerifierRun();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-kuna-lane-"));
|
||||
const cacheRoot = join(workspace, "cache");
|
||||
// A server retains its workspace until both provider and transport cleanup
|
||||
// have positively completed, even if later lineage observation is empty.
|
||||
const pendingServers = new Set();
|
||||
let kunaVersion;
|
||||
|
||||
const FIXTURE_SOURCE = String.raw`
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
unsigned rea_checksum(const unsigned char *p, size_t n) {
|
||||
unsigned h = 2166136261u;
|
||||
for (size_t i = 0; i < n; i++) h = (h ^ p[i]) * 16777619u;
|
||||
return h;
|
||||
}
|
||||
int rea_classify(int c) {
|
||||
switch (c) {
|
||||
case 'a': return 1; case 'e': return 2; case 'i': return 3;
|
||||
case 'o': return 4; case 'u': return 5; default: return c > 'z' ? -1 : 0;
|
||||
}
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 2) return 2;
|
||||
printf("%u %d\n", rea_checksum((const unsigned char *)argv[1], strlen(argv[1])),
|
||||
rea_classify(argv[1][0]));
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Resolve function entry addresses from a symbol-bearing build. */
|
||||
const symbolAddresses = async (binary, names) => {
|
||||
const { stdout } = await exec(process.env.REA_NM ?? "nm", [binary]);
|
||||
const found = new Map();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const [address, type, symbol] = line.trim().split(/\s+/u);
|
||||
if (symbol === undefined || !/^[tT]$/u.test(type ?? "")) continue;
|
||||
const plain = symbol.startsWith("_") ? symbol.slice(1) : symbol;
|
||||
for (const name of names)
|
||||
if ((symbol === name || plain === name) && !found.has(name))
|
||||
found.set(name, `0x${BigInt(`0x${address}`).toString(16)}`);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Sum the resident memory and CPU time of a process and its descendants. */
|
||||
const sampleTree = async (root) => {
|
||||
const { stdout } = await exec("ps", ["-A", "-o", "pid=,ppid=,rss=,time="]);
|
||||
const rows = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(/\s+/u))
|
||||
.map(([pid, ppid, rss, time]) => ({
|
||||
pid: Number(pid),
|
||||
ppid: Number(ppid),
|
||||
rssKiB: Number(rss),
|
||||
cpuSeconds: (time ?? "0:0")
|
||||
.split(/[:-]/u)
|
||||
.map(Number)
|
||||
.reduce((total, part) => total * 60 + part, 0),
|
||||
}));
|
||||
const members = new Set([root]);
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
for (const row of rows)
|
||||
if (!members.has(row.pid) && members.has(row.ppid)) {
|
||||
members.add(row.pid);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
const tree = rows.filter(({ pid }) => members.has(pid));
|
||||
return {
|
||||
rssKiB: tree.reduce((total, row) => total + row.rssKiB, 0),
|
||||
cpu: new Map(tree.map((row) => [row.pid, row.cpuSeconds])),
|
||||
};
|
||||
};
|
||||
|
||||
/** One MCP server with a selected provider, sampled while it runs. */
|
||||
const openServer = async (provider) => {
|
||||
const owner = Symbol(provider.id);
|
||||
pendingServers.add(owner);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "--mcp"],
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
HOME: process.env.HOME ?? workspace,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: cacheRoot,
|
||||
...(process.env.GHIDRA_INSTALL_DIR === undefined
|
||||
? {}
|
||||
: { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }),
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { JAVA_HOME: process.env.JAVA_HOME }),
|
||||
},
|
||||
stderr: "ignore",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-kuna", version: "1" });
|
||||
let peakKiB = 0;
|
||||
const cpu = new Map();
|
||||
const sampleOnce = async () => {
|
||||
const pid = transport.pid;
|
||||
if (pid === null) return;
|
||||
const observed = await sampleTree(pid).catch(() => undefined);
|
||||
if (observed === undefined) return;
|
||||
peakKiB = Math.max(peakKiB, observed.rssKiB);
|
||||
for (const [member, seconds] of observed.cpu)
|
||||
cpu.set(member, Math.max(cpu.get(member) ?? 0, seconds));
|
||||
};
|
||||
let sampling;
|
||||
const sample = () => {
|
||||
sampling ??= sampleOnce().finally(() => {
|
||||
sampling = undefined;
|
||||
});
|
||||
return sampling;
|
||||
};
|
||||
let timer;
|
||||
const call = async (name, arguments_ = {}) => {
|
||||
const started = performance.now();
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{ timeout: 3_600_000 },
|
||||
);
|
||||
const ms = Math.round(performance.now() - started);
|
||||
const bytes = Buffer.byteLength(JSON.stringify(result));
|
||||
return {
|
||||
ms,
|
||||
bytes,
|
||||
value: requireMcpOperationResult(result, name),
|
||||
structured: result.structuredContent,
|
||||
};
|
||||
};
|
||||
const close = async (priorFailure) => {
|
||||
clearInterval(timer);
|
||||
await sample();
|
||||
const failures = priorFailure === undefined ? [] : [priorFailure];
|
||||
// Attempt each cleanup independently. Disposing a transport is not proof
|
||||
// that close_binary released retained provider processes or files.
|
||||
for (const cleanup of [
|
||||
() => call("close_binary"),
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
])
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
// A prior verification error must survive successful cleanup. Only the
|
||||
// cleanup failures retain this server's files.
|
||||
if (failures.length > (priorFailure === undefined ? 0 : 1))
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
"MCP server cleanup was not confirmed",
|
||||
);
|
||||
pendingServers.delete(owner);
|
||||
if (priorFailure !== undefined) throw priorFailure;
|
||||
return {
|
||||
peak_rss_mib: Math.round(peakKiB / 1024),
|
||||
cpu_seconds: [...cpu.values()].reduce((total, value) => total + value, 0),
|
||||
};
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
timer = setInterval(() => void sample(), 200);
|
||||
await call("open_binary", {
|
||||
path: provider.path,
|
||||
provider_id: provider.id,
|
||||
});
|
||||
} catch (cause) {
|
||||
// A rejected connection/target still owns its transport and workspace.
|
||||
await close(cause);
|
||||
}
|
||||
return { call, close };
|
||||
};
|
||||
|
||||
/** Count structuring and diagnostic markers in provider pseudocode. */
|
||||
const quality = (text) => ({
|
||||
bytes: text === null ? 0 : Buffer.byteLength(text),
|
||||
gotos: text === null ? 0 : (text.match(/\bgoto\b/gu) ?? []).length,
|
||||
warnings:
|
||||
text === null ? 0 : (text.match(/WARNING|\/\/ warn:/gu) ?? []).length,
|
||||
});
|
||||
|
||||
/** Verify Kuna on one binary and measure its cold and warm answers. */
|
||||
const verifyKuna = async (path, functions) => {
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex");
|
||||
const cold = await openServer({ path, id: "kuna" });
|
||||
const answers = [];
|
||||
let coldFailure;
|
||||
try {
|
||||
const listed = await cold.call("list_procedures");
|
||||
for (const [name, address] of functions) {
|
||||
assert.ok(
|
||||
listed.value.some((entry) => entry.address === address),
|
||||
`${name} at ${address} is missing from Kuna's procedure list for ${path}`,
|
||||
);
|
||||
const pseudocode = await cold.call("procedure_pseudo_code", {
|
||||
procedure: address,
|
||||
});
|
||||
assert.equal(pseudocode.structured.subject.digest.sha256, sha256);
|
||||
assert.equal(pseudocode.structured.provider.id, "kuna");
|
||||
const source = pseudocode.structured.raw_result?.source?.kind;
|
||||
// A fast export can finish before the first request reaches the
|
||||
// decompile path; both sources are valid answers. The single-function
|
||||
// fallback itself is covered by the provider's boundary tests.
|
||||
assert.ok(
|
||||
source === "single-function" || source === "project-export",
|
||||
`Unexpected pseudocode source ${String(source)}`,
|
||||
);
|
||||
const assembly = await cold.call("procedure_assembly", {
|
||||
procedure: address,
|
||||
});
|
||||
assert.equal(
|
||||
assembly.structured.raw_result.source.kind,
|
||||
"project-export",
|
||||
);
|
||||
assert.match(assembly.value, new RegExp(`: ; ${address}\\n`, "u"));
|
||||
const exported = await cold.call("procedure_pseudo_code", {
|
||||
procedure: address,
|
||||
});
|
||||
const recorded = exported.structured.raw_result.source;
|
||||
assert.equal(recorded.kind, "project-export");
|
||||
const block = (await readFile(join(recorded.directory, recorded.file)))
|
||||
.subarray(
|
||||
recorded.byte_offset,
|
||||
recorded.byte_offset + recorded.byte_length,
|
||||
)
|
||||
.toString("utf8");
|
||||
assert.ok(
|
||||
exported.value === null || block.includes(exported.value),
|
||||
"Served pseudocode differs from the recorded export span",
|
||||
);
|
||||
const dossier = await cold.call("analyze_function", {
|
||||
procedure: address,
|
||||
});
|
||||
const combined = dossier.structured.raw_result.source;
|
||||
assert.equal(combined.kind, "project-export");
|
||||
assert.deepEqual(
|
||||
combined.files.map(({ facet }) => facet),
|
||||
["pseudocode", "assembly"],
|
||||
);
|
||||
for (const file of combined.files) {
|
||||
const bytes = await readFile(join(combined.directory, file.file));
|
||||
assert.equal(
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
file.file_sha256,
|
||||
);
|
||||
const span = bytes
|
||||
.subarray(file.byte_offset, file.byte_offset + file.byte_length)
|
||||
.toString("utf8");
|
||||
const facet = dossier.value[file.facet];
|
||||
assert.ok(
|
||||
span.includes(Array.isArray(facet) ? facet.join("\n") : facet),
|
||||
);
|
||||
}
|
||||
answers.push({
|
||||
name,
|
||||
address,
|
||||
cold_source: source,
|
||||
cold_ms: pseudocode.ms,
|
||||
export_wait_ms: assembly.ms,
|
||||
warm_same_session_ms: exported.ms,
|
||||
response_bytes: exported.bytes,
|
||||
dossier_response_bytes: dossier.bytes,
|
||||
...quality(exported.value),
|
||||
});
|
||||
}
|
||||
} catch (cause) {
|
||||
coldFailure = cause;
|
||||
} finally {
|
||||
answers.push({ cold_server: await cold.close(coldFailure) });
|
||||
}
|
||||
const staging = await readdir(join(cacheRoot, "staging"));
|
||||
assert.deepEqual(staging, [], "Kuna staging survived a completed export");
|
||||
// A fresh server reuses the export immediately.
|
||||
const warm = await openServer({ path, id: "kuna" });
|
||||
let warmFailure;
|
||||
try {
|
||||
for (const answer of answers)
|
||||
if (answer.address !== undefined) {
|
||||
const reread = await warm.call("procedure_pseudo_code", {
|
||||
procedure: answer.address,
|
||||
});
|
||||
assert.equal(
|
||||
reread.structured.raw_result.source.kind,
|
||||
"project-export",
|
||||
);
|
||||
answer.warm_fresh_server_ms = reread.ms;
|
||||
}
|
||||
} catch (cause) {
|
||||
warmFailure = cause;
|
||||
} finally {
|
||||
answers.push({ warm_server: await warm.close(warmFailure) });
|
||||
}
|
||||
return answers;
|
||||
};
|
||||
|
||||
/** Measure Ghidra on the same functions through the same public workflow. */
|
||||
const measureGhidra = async (path, functions) => {
|
||||
const server = await openServer({ path, id: "ghidra" });
|
||||
const answers = [];
|
||||
let failure;
|
||||
try {
|
||||
for (const [name, address] of functions) {
|
||||
const cold = await server.call("procedure_pseudo_code", {
|
||||
procedure: address,
|
||||
});
|
||||
const warm = await server.call("procedure_pseudo_code", {
|
||||
procedure: address,
|
||||
});
|
||||
answers.push({
|
||||
name,
|
||||
address,
|
||||
first_call_ms: cold.ms,
|
||||
repeat_ms: warm.ms,
|
||||
response_bytes: cold.bytes,
|
||||
...quality(cold.value),
|
||||
});
|
||||
}
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
} finally {
|
||||
answers.push({ server: await server.close(failure) });
|
||||
}
|
||||
return answers;
|
||||
};
|
||||
|
||||
/** Symbol-bearing caller binaries with the requested functions, beside their stripped copies. */
|
||||
const targetPairs = async (root, names) => {
|
||||
const pairs = [];
|
||||
for (const flavor of (await readdir(root)).sort()) {
|
||||
const directory = join(root, flavor);
|
||||
if (!(await stat(directory)).isDirectory()) continue;
|
||||
for (const file of (await readdir(directory)).sort()) {
|
||||
if (file.endsWith(".stripped")) continue;
|
||||
const symbols = await symbolAddresses(join(directory, file), names);
|
||||
if (symbols.size > 0) pairs.push({ flavor, directory, file, symbols });
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
};
|
||||
|
||||
/** Verify Kuna, and optionally measure Ghidra, on one binary of a pair. */
|
||||
const verifyVariant = async ({ flavor, directory, symbols }, variant) => {
|
||||
const path = join(directory, variant);
|
||||
const entry = { binary: `${flavor}/${variant}` };
|
||||
entry.kuna = await verifyKuna(path, [...symbols]);
|
||||
if (values["compare-ghidra"])
|
||||
entry.ghidra = await measureGhidra(path, [...symbols]);
|
||||
process.stderr.write(`verified ${entry.binary}\n`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const results = [];
|
||||
const failures = [];
|
||||
try {
|
||||
kunaVersion = (
|
||||
await execFileOutput(kuna, ["--version"], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 64 * 1024,
|
||||
stopSignal: "SIGTERM",
|
||||
})
|
||||
).stdout.trim();
|
||||
let callerPairs = [];
|
||||
if (values.targets !== undefined) {
|
||||
const names = (values.functions ?? "").split(",").filter(Boolean);
|
||||
assert.ok(names.length > 0, "--targets requires --functions");
|
||||
callerPairs = await targetPairs(values.targets, names);
|
||||
assert.ok(
|
||||
callerPairs.length > 0,
|
||||
`No caller-built targets contain the requested functions in ${values.targets}`,
|
||||
);
|
||||
const found = new Set(
|
||||
callerPairs.flatMap(({ symbols }) => [...symbols.keys()]),
|
||||
);
|
||||
const missing = names.filter((name) => !found.has(name));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
"Requested functions were not found in any caller-built target",
|
||||
);
|
||||
}
|
||||
|
||||
// Source-owned fixture: always built and verified.
|
||||
const fixtureDir = join(workspace, "fixture");
|
||||
await mkdir(fixtureDir);
|
||||
const source = join(fixtureDir, "fixture.c");
|
||||
await writeFile(source, FIXTURE_SOURCE);
|
||||
const built = join(fixtureDir, "rea-kuna-fixture");
|
||||
await exec(process.env.REA_CC ?? "cc", ["-O2", "-o", built, source]);
|
||||
const stripped = `${built}.stripped`;
|
||||
await writeFile(stripped, await readFile(built));
|
||||
await exec(process.env.REA_STRIP ?? "strip", [stripped]);
|
||||
const fixtureFunctions = await symbolAddresses(built, [
|
||||
"rea_checksum",
|
||||
"rea_classify",
|
||||
]);
|
||||
assert.equal(fixtureFunctions.size, 2, "Fixture symbols were not found");
|
||||
for (const binary of [built, stripped])
|
||||
results.push({
|
||||
binary: binary.slice(workspace.length + 1),
|
||||
kuna: await verifyKuna(binary, [...fixtureFunctions]),
|
||||
});
|
||||
|
||||
// Caller-built matrix.
|
||||
for (const pair of callerPairs)
|
||||
for (const variant of [pair.file, `${pair.file}.stripped`])
|
||||
results.push(await verifyVariant(pair, variant));
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
// Failure paths need the same process evidence as successful verification.
|
||||
const lifecycle = await completeVerifierRun(run);
|
||||
let cleanupComplete = pendingServers.size === 0;
|
||||
try {
|
||||
assertVerifierCleanupComplete(lifecycle.process_lineage);
|
||||
} catch (cause) {
|
||||
cleanupComplete = false;
|
||||
failures.push(cause);
|
||||
}
|
||||
if (cleanupComplete)
|
||||
try {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
} catch (cause) {
|
||||
cleanupComplete = false;
|
||||
failures.push(cause);
|
||||
}
|
||||
const describeFailure = (cause) => ({
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
...(cause instanceof AggregateError
|
||||
? { causes: cause.errors.map(describeFailure) }
|
||||
: {}),
|
||||
});
|
||||
const report = {
|
||||
lane: "kuna",
|
||||
status: failures.length === 0 ? "passed" : "failed",
|
||||
kuna_version: kunaVersion,
|
||||
platform: `${process.platform}/${process.arch}`,
|
||||
verifier_run: lifecycle,
|
||||
results,
|
||||
limitations: [
|
||||
"Targets are analyzed statically; they are never executed.",
|
||||
"Memory is the sampled peak resident set of the MCP server process tree; CPU is its sampled cumulative time. A short-lived peak between 200 ms samples is not observed.",
|
||||
"Quality signals count gotos and warning markers only; they are not a correctness judgement and not a DecBench reproduction.",
|
||||
"Response bytes are UTF-8 serialization sizes. Model token usage, tool schema costs and agent task completion were not measured.",
|
||||
],
|
||||
cleanup: cleanupComplete ? "complete" : "incomplete",
|
||||
...(cleanupComplete ? {} : { retained_workspace: workspace }),
|
||||
...(failures.length === 0 ? {} : { failures: failures.map(describeFailure) }),
|
||||
};
|
||||
if (values.report !== undefined)
|
||||
await writeFile(values.report, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
"Kuna verification failed; see the report for cleanup evidence",
|
||||
);
|
||||
+13
-2
@@ -17,6 +17,7 @@ import {
|
||||
type CompactInputSchemaPresentation,
|
||||
} from "./contracts/compactInputSchema.js";
|
||||
import type { McpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js";
|
||||
import { RECORDED_CATALOG_DIGESTS } from "./recordedCatalogDigests.js";
|
||||
|
||||
const sortByName = (
|
||||
contracts: readonly ToolContract[],
|
||||
@@ -102,6 +103,8 @@ export const createCatalogDigests = (
|
||||
} as const;
|
||||
};
|
||||
|
||||
export type CatalogDigests = ReturnType<typeof createCatalogDigests>;
|
||||
|
||||
export const createCatalogIdentity = (
|
||||
compactPresentation: CompactInputSchemaPresentation | undefined = undefined,
|
||||
outputSchemaProfile: McpOutputSchemaProfile = "full",
|
||||
@@ -110,7 +113,12 @@ export const createCatalogIdentity = (
|
||||
compactPresentation === undefined
|
||||
? undefined
|
||||
: { budgetBytes: compactPresentation.budgetBytes };
|
||||
let catalogDigests: ReturnType<typeof createCatalogDigests> | undefined;
|
||||
// Building and canonicalizing every advertised schema dominates doctor and
|
||||
// server startup, so the compiled runtime records the default digests once.
|
||||
let catalogDigests: CatalogDigests | undefined =
|
||||
selectedPresentation === undefined && outputSchemaProfile === "full"
|
||||
? RECORDED_CATALOG_DIGESTS
|
||||
: undefined;
|
||||
return {
|
||||
counts: {
|
||||
cli_commands: CLI_COMMAND_NAMES.length,
|
||||
@@ -128,5 +136,8 @@ export const createCatalogIdentity = (
|
||||
} as const;
|
||||
};
|
||||
|
||||
/** Stable, schema-sensitive identity; schema digests are computed on first use. */
|
||||
/**
|
||||
* Stable, schema-sensitive identity; schema digests are recorded at build time
|
||||
* or computed on first use.
|
||||
*/
|
||||
export const CATALOG_IDENTITY = createCatalogIdentity();
|
||||
@@ -4,6 +4,7 @@ import type { BinarySession } from "../application/binary/BinarySession.js";
|
||||
import { HopperProvider } from "../hopper/HopperProvider.js";
|
||||
import { GhidraProvider } from "../ghidra/GhidraProvider.js";
|
||||
import { IdaProvider } from "../ida/IdaProvider.js";
|
||||
import { KunaProvider } from "../kuna/KunaProvider.js";
|
||||
import type { Logger } from "pino";
|
||||
import { auxiliaryAnalysisProviderDeclarations } from "./auxiliaryAnalysisProviders.js";
|
||||
import { AnalysisProviderRegistry } from "../application/binary/AnalysisProviderRegistry.js";
|
||||
@@ -25,9 +26,10 @@ export const createBinarySession = (
|
||||
const hopper = new HopperProvider(config, logger, environment);
|
||||
const ghidra = new GhidraProvider(config, logger, environment);
|
||||
const ida = new IdaProvider(config, environment);
|
||||
const kuna = new KunaProvider(environment);
|
||||
return composeBinarySession(
|
||||
new AnalysisProviderRegistry(
|
||||
[hopper, ghidra, ida],
|
||||
[hopper, ghidra, ida, kuna],
|
||||
config.analysisProvider,
|
||||
),
|
||||
auxiliaryAnalysisProviderDeclarations(environment).map(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { analysisSearchInput } from "../contracts/analysisSearchContract.js";
|
||||
import { AnalysisInputError } from "./analysisErrorCore.js";
|
||||
|
||||
/** Parsed `search_procedures`/`search_strings` query shared by providers that match in REA. */
|
||||
export const analysisSearchQuerySchema = z.object(analysisSearchInput);
|
||||
|
||||
export type AnalysisSearchQuery = z.infer<typeof analysisSearchQuerySchema>;
|
||||
|
||||
/**
|
||||
* Match names the way the search contract describes: a literal substring by
|
||||
* default, or a Unicode regular expression, case-insensitive unless requested.
|
||||
* An invalid pattern is the caller's input error.
|
||||
*/
|
||||
export const analysisSearchPredicate = (
|
||||
query: AnalysisSearchQuery,
|
||||
): ((value: string) => boolean) => {
|
||||
if (query.mode === "regex") {
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(query.pattern, query.case_sensitive ? "u" : "iu");
|
||||
} catch (cause: unknown) {
|
||||
throw new AnalysisInputError("search", { cause });
|
||||
}
|
||||
return (value) => regex.test(value);
|
||||
}
|
||||
const needle = query.case_sensitive
|
||||
? query.pattern
|
||||
: query.pattern.toLowerCase();
|
||||
return (value) =>
|
||||
(query.case_sensitive ? value : value.toLowerCase()).includes(needle);
|
||||
};
|
||||
@@ -98,6 +98,30 @@ export const semanticStaticPropertyKey = (
|
||||
return !computed && t.isIdentifier(property) ? property.name : null;
|
||||
};
|
||||
|
||||
/** Later properties own a key; an intervening dynamic write leaves it unknown. */
|
||||
export const semanticFinalObjectProperty = (
|
||||
node: t.ObjectExpression,
|
||||
key: string,
|
||||
): t.Node | undefined => {
|
||||
for (let index = node.properties.length - 1; index >= 0; index -= 1) {
|
||||
const property = node.properties[index];
|
||||
if (property === undefined || t.isSpreadElement(property)) return undefined;
|
||||
const name = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (name === null) return undefined;
|
||||
if (name === key)
|
||||
return t.isObjectProperty(property) ? property.value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const semanticFinalObjectStringProperty = (
|
||||
node: t.ObjectExpression,
|
||||
key: string,
|
||||
): string | undefined => {
|
||||
const value = semanticFinalObjectProperty(node, key);
|
||||
return t.isStringLiteral(value) ? value.value : undefined;
|
||||
};
|
||||
|
||||
/** Read exact consumed object-pattern keys, leaving dynamic keys unknown. */
|
||||
export const semanticObjectPatternKeys = (
|
||||
pattern: t.ObjectPattern,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import * as t from "@babel/types";
|
||||
import {
|
||||
unwrapJavaScriptExpression,
|
||||
semanticStaticPropertyKey,
|
||||
semanticFinalObjectProperty,
|
||||
semanticFinalObjectStringProperty,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSemanticReference } from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptBindingIndex,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import type {
|
||||
JavaScriptRpcEndpoint,
|
||||
JavaScriptRpcScan,
|
||||
} from "./javascriptRpcEndpoints.js";
|
||||
|
||||
/**
|
||||
* Find source-body string values owned by a GraphQL Source-shaped literal.
|
||||
* A later duplicate or spread can make the effective `body` unknown, but the
|
||||
* earlier strings still belong to this object and must not become standalone
|
||||
* operation findings during the string scan.
|
||||
*/
|
||||
const graphqlSourceBodyLiterals = (
|
||||
node: t.ObjectExpression,
|
||||
): readonly t.StringLiteral[] | undefined => {
|
||||
const hasLocationOffset = node.properties.some(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) ===
|
||||
"locationOffset",
|
||||
);
|
||||
if (!hasLocationOffset) return undefined;
|
||||
|
||||
const bodyStrings = (object: t.ObjectExpression) =>
|
||||
object.properties.flatMap((property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) === "body" &&
|
||||
t.isStringLiteral(property.value)
|
||||
? [property.value]
|
||||
: [],
|
||||
);
|
||||
const bodies = bodyStrings(node);
|
||||
|
||||
// Inline object spreads can own additional body values which the surrounding
|
||||
// Source object may select. Include only body-key string literals from those
|
||||
// spread values; ordinary strings elsewhere in the object remain scannable.
|
||||
const visited = new WeakSet<t.ObjectExpression>();
|
||||
visited.add(node);
|
||||
const pending = node.properties.flatMap((property) => {
|
||||
if (!t.isSpreadElement(property)) return [];
|
||||
const spread = unwrapJavaScriptExpression(property.argument).node;
|
||||
return t.isObjectExpression(spread) ? [spread] : [];
|
||||
});
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined || visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
bodies.push(...bodyStrings(current));
|
||||
for (const property of current.properties) {
|
||||
if (t.isSpreadElement(property)) {
|
||||
const spread = unwrapJavaScriptExpression(property.argument).node;
|
||||
if (t.isObjectExpression(spread)) pending.push(spread);
|
||||
}
|
||||
}
|
||||
}
|
||||
return bodies.length === 0 ? undefined : bodies;
|
||||
};
|
||||
|
||||
const GRAPHQL_OPERATION_TYPES: ReadonlySet<string> = new Set([
|
||||
"query",
|
||||
"mutation",
|
||||
"subscription",
|
||||
]);
|
||||
|
||||
// GraphQL October 2021 §2.1.7: ignored BOM, tab/space, line endings, commas;
|
||||
// comments are consumed separately below.
|
||||
const isGraphqlIgnoredCharacter = (character: string): boolean =>
|
||||
character === "," ||
|
||||
character === "\uFEFF" ||
|
||||
character === "\t" ||
|
||||
character === " " ||
|
||||
character === "\n" ||
|
||||
character === "\r";
|
||||
|
||||
/** Return the first offset after GraphQL ignored tokens. */
|
||||
const skipGraphqlIgnored = (text: string, start: number): number => {
|
||||
const limit = text.length;
|
||||
let index = start;
|
||||
while (index < limit) {
|
||||
const character = text.charAt(index);
|
||||
if (isGraphqlIgnoredCharacter(character)) index += 1;
|
||||
else if (character === "#") {
|
||||
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
|
||||
} else break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const graphqlNameAt = (
|
||||
text: string,
|
||||
index: number,
|
||||
): { readonly value: string; readonly end: number } | undefined => {
|
||||
const name = /^[_A-Za-z][_0-9A-Za-z]*/u.exec(text.slice(index))?.[0];
|
||||
return name === undefined
|
||||
? undefined
|
||||
: { value: name, end: index + name.length };
|
||||
};
|
||||
|
||||
/** Recover a named operation from GraphQL document text. */
|
||||
const graphqlOperationFromText = (
|
||||
text: string,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
const type = graphqlNameAt(text, skipGraphqlIgnored(text, 0));
|
||||
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type.value))
|
||||
return undefined;
|
||||
const name = graphqlNameAt(text, skipGraphqlIgnored(text, type.end));
|
||||
if (name === undefined) return undefined;
|
||||
const index = skipGraphqlIgnored(text, name.end);
|
||||
const next = text.charAt(index);
|
||||
// Keep the existing bounded recognition scope: a named operation followed
|
||||
// by variable definitions, a directive, or a selection set with a field.
|
||||
const hasHeader =
|
||||
next === "("
|
||||
? text.charAt(skipGraphqlIgnored(text, index + 1)) === "$"
|
||||
: (next === "@" || next === "{") &&
|
||||
graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined;
|
||||
return hasHeader
|
||||
? { value: name.value, mechanism: `graphql:${type.value}` }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Recover a named operation from a precompiled GraphQL document object. */
|
||||
const graphqlOperationFromDocumentNode = (
|
||||
node: t.ObjectExpression,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
if (semanticFinalObjectStringProperty(node, "kind") !== "OperationDefinition")
|
||||
return undefined;
|
||||
const type = semanticFinalObjectStringProperty(node, "operation");
|
||||
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type))
|
||||
return undefined;
|
||||
const name = semanticFinalObjectProperty(node, "name");
|
||||
if (
|
||||
!t.isObjectExpression(name) ||
|
||||
semanticFinalObjectStringProperty(name, "kind") !== "Name"
|
||||
)
|
||||
return undefined;
|
||||
const value = semanticFinalObjectStringProperty(name, "value");
|
||||
return value === undefined
|
||||
? undefined
|
||||
: { value, mechanism: `graphql:${type}` };
|
||||
};
|
||||
|
||||
interface GraphqlSourceDependency {
|
||||
readonly bindingId: string;
|
||||
readonly name: string;
|
||||
readonly location?: JavaScriptSemanticReference["location"];
|
||||
}
|
||||
|
||||
interface GraphqlSourceCopy {
|
||||
readonly source: t.ObjectExpression;
|
||||
readonly dependencies: readonly GraphqlSourceDependency[];
|
||||
readonly operations: readonly JavaScriptRpcEndpoint[];
|
||||
}
|
||||
|
||||
/** One private constant owns its initializer; exported or projected values do not. */
|
||||
const graphqlPrivateBinding = (
|
||||
node: t.Identifier,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
sourceType: t.Program["sourceType"],
|
||||
) => {
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (binding === undefined || binding.mutable) return undefined;
|
||||
const scope = state.scopesById.get(binding.scopeId);
|
||||
if (
|
||||
scope === undefined ||
|
||||
(scope.kind === "program" && sourceType !== "module")
|
||||
)
|
||||
return undefined;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (initializer?.projection.length !== 0) return undefined;
|
||||
const declaration = state.parentsByNode.get(initializer.node);
|
||||
const statement = t.isVariableDeclarator(declaration)
|
||||
? state.parentsByNode.get(declaration)
|
||||
: undefined;
|
||||
return t.isVariableDeclaration(statement) &&
|
||||
!t.isExportNamedDeclaration(state.parentsByNode.get(statement))
|
||||
? { binding, initializer }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Resolve potential metadata ownership; references must still prove exclusivity. */
|
||||
const graphqlSourceObjectValue = (
|
||||
node: t.Node | undefined,
|
||||
index: JavaScriptBindingIndex,
|
||||
dependencies: GraphqlSourceDependency[],
|
||||
): t.ObjectExpression | undefined => {
|
||||
if (node === undefined) return undefined;
|
||||
const state = index.bindings;
|
||||
const visited = new Set<t.Node>();
|
||||
while (node !== undefined && !visited.has(node)) {
|
||||
visited.add(node);
|
||||
node = unwrapJavaScriptExpression(node).node;
|
||||
if (t.isObjectExpression(node)) return node;
|
||||
if (!t.isIdentifier(node)) return undefined;
|
||||
const owner = graphqlPrivateBinding(node, state, index.sourceType);
|
||||
if (owner === undefined) return undefined;
|
||||
const { binding, initializer } = owner;
|
||||
if (
|
||||
initializer.node.end == null ||
|
||||
node.start == null ||
|
||||
node.loc == null ||
|
||||
initializer.node.end > node.start
|
||||
)
|
||||
return undefined;
|
||||
dependencies.push({
|
||||
bindingId: binding.bindingId,
|
||||
name: binding.name,
|
||||
location: range(node),
|
||||
});
|
||||
node = initializer.node;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const graphqlDocumentSource = (
|
||||
node: t.ObjectExpression,
|
||||
index: JavaScriptBindingIndex,
|
||||
): Omit<GraphqlSourceCopy, "operations"> | undefined => {
|
||||
const dependencies: GraphqlSourceDependency[] = [];
|
||||
const loc = graphqlSourceObjectValue(
|
||||
semanticFinalObjectProperty(node, "loc"),
|
||||
index,
|
||||
dependencies,
|
||||
);
|
||||
const source =
|
||||
loc === undefined
|
||||
? undefined
|
||||
: graphqlSourceObjectValue(
|
||||
semanticFinalObjectProperty(loc, "source"),
|
||||
index,
|
||||
dependencies,
|
||||
);
|
||||
return source !== undefined &&
|
||||
graphqlContainerOwnership(node, index, dependencies)
|
||||
? { source, dependencies }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Follow containment to the binding that can expose or mutate this document. */
|
||||
const graphqlContainerOwnership = (
|
||||
document: t.ObjectExpression,
|
||||
index: JavaScriptBindingIndex,
|
||||
dependencies: GraphqlSourceDependency[],
|
||||
): boolean => {
|
||||
const state = index.bindings;
|
||||
let node: t.Node = document;
|
||||
for (;;) {
|
||||
const parent = state.parentsByNode.get(node);
|
||||
if (parent === undefined) return false;
|
||||
if (t.isObjectProperty(parent) && parent.value === node) {
|
||||
const object = state.parentsByNode.get(parent);
|
||||
if (!t.isObjectExpression(object)) return false;
|
||||
node = object;
|
||||
} else if (
|
||||
t.isArrayExpression(parent) ||
|
||||
unwrapJavaScriptExpression(parent).node === node
|
||||
)
|
||||
node = parent;
|
||||
else if (t.isExpressionStatement(parent)) return true;
|
||||
else if (t.isVariableDeclarator(parent) && parent.init === node) {
|
||||
if (!t.isIdentifier(parent.id)) return false;
|
||||
const owner = graphqlPrivateBinding(parent.id, state, index.sourceType);
|
||||
if (owner?.initializer.node !== node) return false;
|
||||
const { binding } = owner;
|
||||
dependencies.push({ bindingId: binding.bindingId, name: binding.name });
|
||||
return true;
|
||||
} else return false;
|
||||
}
|
||||
};
|
||||
|
||||
const graphqlDocumentOperations = (
|
||||
node: t.ObjectExpression,
|
||||
operation: JavaScriptRpcEndpoint | undefined,
|
||||
): readonly JavaScriptRpcEndpoint[] => {
|
||||
if (operation !== undefined) return [operation];
|
||||
if (semanticFinalObjectStringProperty(node, "kind") !== "Document") return [];
|
||||
const definitions = semanticFinalObjectProperty(node, "definitions");
|
||||
return t.isArrayExpression(definitions)
|
||||
? definitions.elements.flatMap((definition) => {
|
||||
const recovered = t.isObjectExpression(definition)
|
||||
? graphqlOperationFromDocumentNode(definition)
|
||||
: undefined;
|
||||
return recovered === undefined ? [] : [recovered];
|
||||
})
|
||||
: [];
|
||||
};
|
||||
|
||||
const graphqlOperationKey = (operation: JavaScriptRpcEndpoint): string =>
|
||||
`${operation.mechanism}:${operation.value}`;
|
||||
|
||||
/** Any non-metadata use makes that connected alias component uncertain. */
|
||||
interface GraphqlSourceReferenceIndex {
|
||||
readonly allowed: Map<string, Set<string>>;
|
||||
readonly neighbors: Map<string, Set<string>>;
|
||||
readonly byName: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
function* graphqlSourceReferenceIndexSteps(
|
||||
copies: readonly GraphqlSourceCopy[],
|
||||
): Generator<void, GraphqlSourceReferenceIndex> {
|
||||
const allowed = new Map<string, Set<string>>();
|
||||
const neighbors = new Map<string, Set<string>>();
|
||||
const byName = new Map<string, Set<string>>();
|
||||
let examined = 0;
|
||||
for (const { dependencies } of copies) {
|
||||
const first = dependencies[0]?.bindingId;
|
||||
for (const { bindingId, name, location } of dependencies) {
|
||||
if (++examined % 1024 === 0) yield;
|
||||
const sites = allowed.get(bindingId) ?? new Set<string>();
|
||||
if (location !== undefined) sites.add(JSON.stringify(location));
|
||||
allowed.set(bindingId, sites);
|
||||
const names = byName.get(name) ?? new Set<string>();
|
||||
names.add(bindingId);
|
||||
byName.set(name, names);
|
||||
if (first === undefined || first === bindingId) continue;
|
||||
const left = neighbors.get(first) ?? new Set<string>();
|
||||
const right = neighbors.get(bindingId) ?? new Set<string>();
|
||||
left.add(bindingId);
|
||||
right.add(first);
|
||||
neighbors.set(first, left);
|
||||
neighbors.set(bindingId, right);
|
||||
}
|
||||
}
|
||||
return { allowed, neighbors, byName };
|
||||
}
|
||||
|
||||
function* uncertainGraphqlSourceBindingsSteps(
|
||||
index: GraphqlSourceReferenceIndex,
|
||||
references: readonly JavaScriptSemanticReference[],
|
||||
): Generator<void, ReadonlySet<string>> {
|
||||
const { allowed, neighbors, byName } = index;
|
||||
const uncertain = new Set<string>();
|
||||
let examined = 0;
|
||||
for (const reference of references) {
|
||||
if (++examined % 1024 === 0) yield;
|
||||
// Direct eval may reach private lexical objects. Indirect eval cannot;
|
||||
// classic-script globals are already rejected at private-binding admission.
|
||||
if (reference.name === "eval") return new Set(allowed.keys());
|
||||
if (reference.bindingId !== null) {
|
||||
const sites = allowed.get(reference.bindingId);
|
||||
if (
|
||||
sites !== undefined &&
|
||||
(reference.role !== "read" ||
|
||||
!sites.has(JSON.stringify(reference.location)))
|
||||
)
|
||||
uncertain.add(reference.bindingId);
|
||||
} else if (
|
||||
reference.resolution === "unknown" ||
|
||||
reference.resolution === "ambiguous"
|
||||
)
|
||||
for (const bindingId of byName.get(reference.name) ?? [])
|
||||
uncertain.add(bindingId);
|
||||
}
|
||||
const pending = [...uncertain];
|
||||
for (
|
||||
let bindingId = pending.pop();
|
||||
bindingId !== undefined;
|
||||
bindingId = pending.pop()
|
||||
)
|
||||
for (const neighbor of neighbors.get(bindingId) ?? []) {
|
||||
if (++examined % 1024 === 0) yield;
|
||||
if (!uncertain.has(neighbor)) {
|
||||
uncertain.add(neighbor);
|
||||
pending.push(neighbor);
|
||||
}
|
||||
}
|
||||
return uncertain;
|
||||
}
|
||||
|
||||
function* provenGraphqlSourceCopiesSteps(
|
||||
copies: readonly GraphqlSourceCopy[],
|
||||
references: readonly JavaScriptSemanticReference[],
|
||||
): Generator<GraphqlSourceCopy | void, void> {
|
||||
const index = yield* graphqlSourceReferenceIndexSteps(copies);
|
||||
const uncertain = yield* uncertainGraphqlSourceBindingsSteps(
|
||||
index,
|
||||
references,
|
||||
);
|
||||
for (const copy of copies)
|
||||
yield copy.dependencies.every(({ bindingId }) => !uncertain.has(bindingId))
|
||||
? copy
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const graphqlOperationFromTemplate = (
|
||||
node: t.TemplateLiteral,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
// Interpolations may contain fragment definitions. Continue only
|
||||
// across quasis that contain ignored tokens; prose or dynamic text
|
||||
// before an operation prevents claiming a later operation belongs to
|
||||
// this document prefix.
|
||||
for (const quasi of node.quasis) {
|
||||
const cooked = quasi.value.cooked;
|
||||
if (cooked === undefined || cooked === null) return undefined;
|
||||
if (skipGraphqlIgnored(cooked, 0) === cooked.length) continue;
|
||||
return graphqlOperationFromText(cooked);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const createJavaScriptGraphqlScan = (
|
||||
index: JavaScriptBindingIndex,
|
||||
): JavaScriptRpcScan => {
|
||||
const bodies = new WeakSet<t.Node>();
|
||||
const copied = new WeakMap<t.ObjectExpression, Set<string>>();
|
||||
const copies: GraphqlSourceCopy[] = [];
|
||||
const sources: {
|
||||
readonly source: t.ObjectExpression;
|
||||
readonly node: t.StringLiteral;
|
||||
readonly operation: JavaScriptRpcEndpoint;
|
||||
}[] = [];
|
||||
const retainCopy = ({ source, operations }: GraphqlSourceCopy) => {
|
||||
const keys = copied.get(source) ?? new Set<string>();
|
||||
for (const operation of operations)
|
||||
keys.add(graphqlOperationKey(operation));
|
||||
copied.set(source, keys);
|
||||
};
|
||||
return {
|
||||
inspect: (node: t.Node): JavaScriptRpcEndpoint | undefined => {
|
||||
if (t.isStringLiteral(node))
|
||||
return bodies.has(node)
|
||||
? undefined
|
||||
: graphqlOperationFromText(node.value);
|
||||
if (t.isTemplateLiteral(node)) return graphqlOperationFromTemplate(node);
|
||||
if (!t.isObjectExpression(node)) return undefined;
|
||||
const operation = graphqlOperationFromDocumentNode(node);
|
||||
if (semanticFinalObjectProperty(node, "loc") !== undefined) {
|
||||
const operations = graphqlDocumentOperations(node, operation);
|
||||
const source =
|
||||
operations.length === 0
|
||||
? undefined
|
||||
: graphqlDocumentSource(node, index);
|
||||
if (source !== undefined) {
|
||||
copies.push({ ...source, operations });
|
||||
}
|
||||
}
|
||||
const owned = graphqlSourceBodyLiterals(node);
|
||||
if (owned !== undefined) {
|
||||
for (const body of owned) bodies.add(body);
|
||||
const body = semanticFinalObjectProperty(node, "body");
|
||||
const recovered = t.isStringLiteral(body)
|
||||
? graphqlOperationFromText(body.value)
|
||||
: undefined;
|
||||
if (t.isStringLiteral(body) && recovered !== undefined)
|
||||
sources.push({ source: node, node: body, operation: recovered });
|
||||
}
|
||||
return operation;
|
||||
},
|
||||
*finish() {
|
||||
if (sources.length > 0 && copies.length > 0) {
|
||||
const references = yield* index.referencesSteps();
|
||||
for (const copy of provenGraphqlSourceCopiesSteps(copies, references)) {
|
||||
if (copy !== undefined) retainCopy(copy);
|
||||
yield;
|
||||
}
|
||||
}
|
||||
for (const { source, node, operation } of sources)
|
||||
yield copied.get(source)?.has(graphqlOperationKey(operation))
|
||||
? undefined
|
||||
: { node, ...operation };
|
||||
sources.length = 0;
|
||||
copies.length = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -401,6 +401,255 @@ describe("GraphQL operation recognition", () => {
|
||||
});
|
||||
|
||||
describe("GraphQL document ownership", () => {
|
||||
it("retains the only operation in a standalone Source object", () => {
|
||||
expect(
|
||||
rpcEndpoints(`const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not deduplicate an unrelated Source against the same operation name", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" },
|
||||
};
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["referenced", "inline"])(
|
||||
"deduplicates a proven %s source reference",
|
||||
(representation) => {
|
||||
const source = `{
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
}`;
|
||||
const document = `export {}; const doc = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" },
|
||||
}], loc: { source: ${representation === "referenced" ? "source" : source} } };`;
|
||||
expect(
|
||||
rpcEndpoints(
|
||||
representation === "referenced"
|
||||
? `const source = ${source};` + document
|
||||
: document,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains source text that differs from its document operation", () => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query SourceUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "mutation",
|
||||
name: { kind: "Name", value: "UpdateUser" }, loc: { source },
|
||||
};
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "network", value: "SourceUser", mechanism: "graphql:query" },
|
||||
{ kind: "network", value: "UpdateUser", mechanism: "graphql:mutation" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects only the final known standalone Source body", () => {
|
||||
expect(
|
||||
rpcEndpoints(`const source = {
|
||||
body: "query Old { user { id } }",
|
||||
body: "query Current { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "Current", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not prove source ownership from an uninitialized reference", () => {
|
||||
const actual = rpcEndpoints(`
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc: { source },
|
||||
};
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual.every(({ value }) => value === "GetUser")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GraphQL document container ownership", () => {
|
||||
it.each([
|
||||
["", "", 2],
|
||||
["export {};", "", 1],
|
||||
["function inspect() {", "}", 1],
|
||||
] as const)(
|
||||
"distinguishes global script bindings from private lexical objects: %s",
|
||||
(prefix, suffix, count) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
${prefix}
|
||||
const source={body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const doc={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source}};
|
||||
${suffix}
|
||||
`),
|
||||
).toHaveLength(count);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not prove a copy when a caller supplies direct eval", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
function inspect(eval) {
|
||||
const source={body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const doc={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source}};
|
||||
eval("doc.loc.source={}");
|
||||
}
|
||||
`),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["export {}; ", "", 1],
|
||||
["export {}; ", "holder.docs[0].loc.source = {};", 2],
|
||||
["export ", "", 2],
|
||||
] as const)(
|
||||
"checks containing object and array ownership: %s %s",
|
||||
(prefix, effect, count) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
${prefix}const holder = {docs:[{kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source:{body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}}}}]};
|
||||
${effect}
|
||||
`),
|
||||
).toHaveLength(count);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["", 'const indirect=globalThis; indirect.eval("loc.source={}");', 2],
|
||||
["export {};", 'globalThis.eval("loc.source={}");', 1],
|
||||
["export {};", 'eval("loc.source={}");', 2],
|
||||
] as const)(
|
||||
"respects lexical exposure for indirect evaluation: %s %s",
|
||||
(prefix, effect, count) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
${prefix}
|
||||
const source={body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const loc={source};
|
||||
const doc={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc};
|
||||
${effect}
|
||||
`),
|
||||
).toHaveLength(count);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"doc.loc.source = {};",
|
||||
"mutate(doc);",
|
||||
"export { doc };",
|
||||
'globalThis.eval("source.body = replacement");',
|
||||
])("retains sources exposed through their document: %s", (effect) => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const doc = {kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source}};
|
||||
${effect}
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["doc.loc.source = {};", "mutate(doc);", "export { doc };"])(
|
||||
"retains inline sources when the document escapes: %s",
|
||||
(effect) => {
|
||||
const actual = rpcEndpoints(`
|
||||
const doc = {kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source:{body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}}}};
|
||||
${effect}
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("GraphQL source alias effects", () => {
|
||||
it.each([
|
||||
'source.body = "query Changed { user { id } }";',
|
||||
"mutate(source);",
|
||||
'eval("source.body = replacement");',
|
||||
])(
|
||||
"retains Source observations after an unproven member effect: %s",
|
||||
(effect) => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
${effect}
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc: { source },
|
||||
};
|
||||
`);
|
||||
expect(actual.filter(({ value }) => value === "GetUser")).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not prove source ownership through a changed location alias", () => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const loc = { source };
|
||||
loc.source = {};
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc,
|
||||
};
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["inline", "specifier"])(
|
||||
"retains separately exported sources: %s",
|
||||
(form) => {
|
||||
const actual = rpcEndpoints(`
|
||||
${form === "inline" ? "export" : ""} const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
${form === "specifier" ? "export { source };" : ""}
|
||||
const doc = { kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc: { source } };
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("GraphQL document recognition", () => {
|
||||
it("recognizes precompiled GraphQL document objects", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
@@ -430,9 +679,10 @@ describe("GraphQL document ownership", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a precompiled graphql-tag document once, not again from loc.source.body", () => {
|
||||
it("reports a private precompiled document once, not again from loc.source.body", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
export {};
|
||||
const doc = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition",
|
||||
operation: "query",
|
||||
@@ -459,6 +709,7 @@ describe("GraphQL document ownership", () => {
|
||||
(sourceFields) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
export {};
|
||||
const source = { ${sourceFields} };
|
||||
const operation = {
|
||||
kind: "OperationDefinition",
|
||||
|
||||
@@ -3,8 +3,11 @@ import * as t from "@babel/types";
|
||||
import {
|
||||
unwrapJavaScriptExpression,
|
||||
semanticStaticPropertyKey,
|
||||
semanticFinalObjectStringProperty,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { calleeName } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptBindingIndex } from "./javascriptSemanticState.js";
|
||||
import { createJavaScriptGraphqlScan } from "./javascriptGraphqlOperations.js";
|
||||
|
||||
/** One RPC operation or transport URL recovered from client-side syntax. */
|
||||
export interface JavaScriptRpcEndpoint {
|
||||
@@ -12,9 +15,12 @@ export interface JavaScriptRpcEndpoint {
|
||||
readonly mechanism: string;
|
||||
}
|
||||
|
||||
/** Per-source RPC recognition state; feed it every AST node in pre-order. */
|
||||
/** Feed every AST node in pre-order, then drain finish() for Source findings. */
|
||||
export interface JavaScriptRpcScan {
|
||||
inspect(node: t.Node): JavaScriptRpcEndpoint | undefined;
|
||||
finish(): Iterable<
|
||||
(JavaScriptRpcEndpoint & { readonly node: t.Node }) | void
|
||||
>;
|
||||
}
|
||||
|
||||
// tRPC proxies preserve router and procedure names as property names, so they
|
||||
@@ -122,165 +128,6 @@ const objectKeys = (node: t.Node | undefined): readonly string[] =>
|
||||
})
|
||||
: [];
|
||||
|
||||
/** Later properties own a key; an intervening dynamic write leaves it unknown. */
|
||||
const objectProperty = (
|
||||
node: t.ObjectExpression,
|
||||
key: string,
|
||||
): t.Node | undefined => {
|
||||
for (let index = node.properties.length - 1; index >= 0; index -= 1) {
|
||||
const property = node.properties[index];
|
||||
if (property === undefined || t.isSpreadElement(property)) return undefined;
|
||||
const name = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (name === null) return undefined;
|
||||
if (name === key)
|
||||
return t.isObjectProperty(property) ? property.value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const objectStringProperty = (
|
||||
node: t.ObjectExpression,
|
||||
key: string,
|
||||
): string | undefined => {
|
||||
const value = objectProperty(node, key);
|
||||
return t.isStringLiteral(value) ? value.value : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find source-body string values owned by a GraphQL Source-shaped literal.
|
||||
* A later duplicate or spread can make the effective `body` unknown, but the
|
||||
* earlier strings still belong to this object and must not become standalone
|
||||
* operation findings during the string scan.
|
||||
*/
|
||||
const graphqlSourceBodyLiterals = (
|
||||
node: t.ObjectExpression,
|
||||
): readonly t.StringLiteral[] | undefined => {
|
||||
const bodyStrings = (object: t.ObjectExpression) =>
|
||||
object.properties.flatMap((property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) === "body" &&
|
||||
t.isStringLiteral(property.value)
|
||||
? [property.value]
|
||||
: [],
|
||||
);
|
||||
const bodies = bodyStrings(node);
|
||||
const hasLocationOffset = node.properties.some(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) ===
|
||||
"locationOffset",
|
||||
);
|
||||
if (!hasLocationOffset) return undefined;
|
||||
|
||||
// Inline object spreads can own additional body values which the surrounding
|
||||
// Source object may select. Include only body-key string literals from those
|
||||
// spread values; ordinary strings elsewhere in the object remain scannable.
|
||||
const visited = new WeakSet<t.ObjectExpression>();
|
||||
visited.add(node);
|
||||
const pending = node.properties.flatMap((property) => {
|
||||
if (!t.isSpreadElement(property)) return [];
|
||||
const spread = unwrapJavaScriptExpression(property.argument).node;
|
||||
return t.isObjectExpression(spread) ? [spread] : [];
|
||||
});
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined || visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
bodies.push(...bodyStrings(current));
|
||||
for (const property of current.properties) {
|
||||
if (t.isSpreadElement(property)) {
|
||||
const spread = unwrapJavaScriptExpression(property.argument).node;
|
||||
if (t.isObjectExpression(spread)) pending.push(spread);
|
||||
}
|
||||
}
|
||||
}
|
||||
return bodies.length === 0 ? undefined : bodies;
|
||||
};
|
||||
|
||||
const GRAPHQL_OPERATION_TYPES: ReadonlySet<string> = new Set([
|
||||
"query",
|
||||
"mutation",
|
||||
"subscription",
|
||||
]);
|
||||
|
||||
// GraphQL October 2021 §2.1.7: ignored BOM, tab/space, line endings, commas;
|
||||
// comments are consumed separately below.
|
||||
const isGraphqlIgnoredCharacter = (character: string): boolean =>
|
||||
character === "," ||
|
||||
character === "\uFEFF" ||
|
||||
character === "\t" ||
|
||||
character === " " ||
|
||||
character === "\n" ||
|
||||
character === "\r";
|
||||
|
||||
/** Return the first offset after GraphQL ignored tokens. */
|
||||
const skipGraphqlIgnored = (text: string, start: number): number => {
|
||||
const limit = text.length;
|
||||
let index = start;
|
||||
while (index < limit) {
|
||||
const character = text.charAt(index);
|
||||
if (isGraphqlIgnoredCharacter(character)) index += 1;
|
||||
else if (character === "#") {
|
||||
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
|
||||
} else break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const graphqlNameAt = (
|
||||
text: string,
|
||||
index: number,
|
||||
): { readonly value: string; readonly end: number } | undefined => {
|
||||
const name = /^[_A-Za-z][_0-9A-Za-z]*/u.exec(text.slice(index))?.[0];
|
||||
return name === undefined
|
||||
? undefined
|
||||
: { value: name, end: index + name.length };
|
||||
};
|
||||
|
||||
/** Recover a named operation from GraphQL document text. */
|
||||
export const graphqlOperationFromText = (
|
||||
text: string,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
const type = graphqlNameAt(text, skipGraphqlIgnored(text, 0));
|
||||
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type.value))
|
||||
return undefined;
|
||||
const name = graphqlNameAt(text, skipGraphqlIgnored(text, type.end));
|
||||
if (name === undefined) return undefined;
|
||||
const index = skipGraphqlIgnored(text, name.end);
|
||||
const next = text.charAt(index);
|
||||
// Keep the existing bounded recognition scope: a named operation followed
|
||||
// by variable definitions, a directive, or a selection set with a field.
|
||||
const hasHeader =
|
||||
next === "("
|
||||
? text.charAt(skipGraphqlIgnored(text, index + 1)) === "$"
|
||||
: (next === "@" || next === "{") &&
|
||||
graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined;
|
||||
return hasHeader
|
||||
? { value: name.value, mechanism: `graphql:${type.value}` }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Recover a named operation from a precompiled GraphQL document object. */
|
||||
export const graphqlOperationFromDocumentNode = (
|
||||
node: t.ObjectExpression,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
if (objectStringProperty(node, "kind") !== "OperationDefinition")
|
||||
return undefined;
|
||||
const type = objectStringProperty(node, "operation");
|
||||
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type))
|
||||
return undefined;
|
||||
const name = objectProperty(node, "name");
|
||||
if (
|
||||
!t.isObjectExpression(name) ||
|
||||
objectStringProperty(name, "kind") !== "Name"
|
||||
)
|
||||
return undefined;
|
||||
const value = objectStringProperty(name, "value");
|
||||
return value === undefined
|
||||
? undefined
|
||||
: { value, mechanism: `graphql:${type}` };
|
||||
};
|
||||
|
||||
/** Bundlers use `(0, imported.factory)` to call an import without a receiver. */
|
||||
const rpcFactoryName = (callee: t.Node): string => {
|
||||
let current = unwrapJavaScriptExpression(callee).node;
|
||||
@@ -312,7 +159,7 @@ const trpcLinkUrl = (
|
||||
if (!TRPC_URL_LINKS.has(factory)) return undefined;
|
||||
const options = node.arguments[0];
|
||||
if (!t.isObjectExpression(options)) return undefined;
|
||||
const url = objectStringProperty(options, "url");
|
||||
const url = semanticFinalObjectStringProperty(options, "url");
|
||||
return url === undefined
|
||||
? undefined
|
||||
: { value: url, mechanism: `trpc:link:${factory}` };
|
||||
@@ -328,14 +175,12 @@ const trpcLinkUrl = (
|
||||
export const createJavaScriptRpcScan = (
|
||||
source: string,
|
||||
clientRoots: ReadonlySet<t.Node>,
|
||||
index: JavaScriptBindingIndex,
|
||||
): JavaScriptRpcScan => {
|
||||
let marker: boolean | undefined;
|
||||
const hasMarker = (): boolean =>
|
||||
(marker ??= TRPC_SOURCE_MARKERS.some((value) => source.includes(value)));
|
||||
// graphql-tag documents retain source text in loc.source.body. Keep each
|
||||
// direct body literal owned by a Source-shaped object out of the independent
|
||||
// string scan, including duplicates that a later property or spread hides.
|
||||
const graphqlDocumentSourceBodies = new WeakSet<t.Node>();
|
||||
const graphql = createJavaScriptGraphqlScan(index);
|
||||
|
||||
const procedure = (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
@@ -369,32 +214,8 @@ export const createJavaScriptRpcScan = (
|
||||
inspect: (node) => {
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
|
||||
return trpcLinkUrl(node) ?? procedure(node);
|
||||
if (t.isObjectExpression(node)) {
|
||||
const bodies = graphqlSourceBodyLiterals(node);
|
||||
if (bodies !== undefined) {
|
||||
for (const body of bodies) graphqlDocumentSourceBodies.add(body);
|
||||
return undefined;
|
||||
}
|
||||
return graphqlOperationFromDocumentNode(node);
|
||||
}
|
||||
if (t.isStringLiteral(node))
|
||||
return graphqlDocumentSourceBodies.has(node)
|
||||
? undefined
|
||||
: graphqlOperationFromText(node.value);
|
||||
if (t.isTemplateLiteral(node)) {
|
||||
// Interpolations may contain fragment definitions. Continue only
|
||||
// across quasis that contain ignored tokens; prose or dynamic text
|
||||
// before an operation prevents claiming a later operation belongs to
|
||||
// this document prefix.
|
||||
for (const quasi of node.quasis) {
|
||||
const cooked = quasi.value.cooked;
|
||||
if (cooked === undefined || cooked === null) return undefined;
|
||||
if (skipGraphqlIgnored(cooked, 0) === cooked.length) continue;
|
||||
return graphqlOperationFromText(cooked);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
return graphql.inspect(node);
|
||||
},
|
||||
finish: graphql.finish,
|
||||
};
|
||||
};
|
||||
@@ -47,6 +47,7 @@ const classifyLayer = (
|
||||
const nodes = layer.graph.nodes.filter(isLoadStateNode);
|
||||
const direct = new Map<string, string[]>();
|
||||
const ambiguous = new Set<string>();
|
||||
const incomplete = new Set<string>();
|
||||
for (const match of matches)
|
||||
if (
|
||||
match.status === "matched" &&
|
||||
@@ -54,10 +55,12 @@ const classifyLayer = (
|
||||
match.static_node_id !== null
|
||||
)
|
||||
append(direct, match.static_node_id, match.runtime_node_id);
|
||||
else if (match.status === "ambiguous")
|
||||
else if (match.status === "ambiguous" || match.status === "unknown") {
|
||||
const uncertain = match.status === "ambiguous" ? ambiguous : incomplete;
|
||||
for (const candidate of match.candidate_static_nodes)
|
||||
if (candidate.static_layer_id === layer.layerId)
|
||||
ambiguous.add(candidate.static_node_id);
|
||||
uncertain.add(candidate.static_node_id);
|
||||
}
|
||||
const resident = residentNodes(layer, new Set(direct.keys()));
|
||||
const runtimeScope = createStaticRuntimeScope(layer, runtimeEntities);
|
||||
const scopedEntities = runtimeEntities.filter(
|
||||
@@ -82,11 +85,13 @@ const classifyLayer = (
|
||||
runtimeNodeIds: [],
|
||||
reason: "containing-asset-was-loaded",
|
||||
});
|
||||
if (ambiguous.has(node.node_id))
|
||||
if (incomplete.has(node.node_id) || ambiguous.has(node.node_id))
|
||||
return state(layer, node, {
|
||||
status: "unknown",
|
||||
runtimeNodeIds: [],
|
||||
reason: "ambiguous-runtime-candidate",
|
||||
reason: incomplete.has(node.node_id)
|
||||
? "static-or-runtime-coverage-incomplete"
|
||||
: "ambiguous-runtime-candidate",
|
||||
});
|
||||
const nodeWithinScope = staticNodeWithinRuntimeScope(node, runtimeScope);
|
||||
if (layerComplete && nodeWithinScope)
|
||||
|
||||
@@ -78,60 +78,13 @@ const reconcileEntity = (
|
||||
candidates: [],
|
||||
});
|
||||
const locations = candidatesWithLocation(entity, index, layers);
|
||||
if (entity.sourceSha256 !== null) {
|
||||
const content =
|
||||
index.byCategoryDigest.get(
|
||||
categoryDigestKey(entity.staticKind, entity.sourceSha256),
|
||||
) ?? [];
|
||||
const locatedKeys = new Set(
|
||||
locations.map(({ candidate }) => candidateKey(candidate)),
|
||||
if (entity.sourceSha256 !== null)
|
||||
return reconcileCapturedSource(
|
||||
entity,
|
||||
entity.sourceSha256,
|
||||
locations,
|
||||
index,
|
||||
);
|
||||
const combined = content.filter((candidate) =>
|
||||
locatedKeys.has(candidateKey(candidate)),
|
||||
);
|
||||
const combinedCandidate = combined.at(0);
|
||||
if (combined.length === 1 && combinedCandidate !== undefined)
|
||||
return matched(entity, combinedCandidate, {
|
||||
basis: "content-and-location",
|
||||
confidence: "high",
|
||||
reason: "content-and-location-match",
|
||||
});
|
||||
const contentCandidate = content.at(0);
|
||||
if (content.length === 1 && contentCandidate !== undefined) {
|
||||
const candidate = contentCandidate;
|
||||
const digestBasis = candidate.digests.find(
|
||||
({ sha256 }) => sha256 === entity.sourceSha256,
|
||||
)?.basis;
|
||||
return matched(entity, candidate, {
|
||||
basis: digestBasis ?? "content-sha256",
|
||||
confidence: "high",
|
||||
reason: "unique-content-match",
|
||||
});
|
||||
}
|
||||
if (content.length > 1)
|
||||
return unresolved(entity, {
|
||||
status: "ambiguous",
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
reason: "ambiguous-static-candidates",
|
||||
candidates: content,
|
||||
});
|
||||
if (locations.length > 0)
|
||||
return unresolved(entity, {
|
||||
status: "unmatched",
|
||||
basis: strongestLocationBasis(locations),
|
||||
confidence: "high",
|
||||
reason: "captured-content-disagrees-with-static-location",
|
||||
candidates: locations.map(({ candidate }) => candidate),
|
||||
});
|
||||
return unresolved(entity, {
|
||||
status: "unmatched",
|
||||
basis: "none",
|
||||
confidence: "high",
|
||||
reason: "no-static-content-match",
|
||||
candidates: [],
|
||||
});
|
||||
}
|
||||
|
||||
const location = locations.at(0);
|
||||
if (locations.length === 1 && location !== undefined) {
|
||||
@@ -169,6 +122,69 @@ interface LocatedCandidate {
|
||||
readonly mapping: MappedRuntimePath;
|
||||
}
|
||||
|
||||
const reconcileCapturedSource = (
|
||||
entity: RuntimeReconciliationEntity,
|
||||
sourceSha256: string,
|
||||
locations: readonly LocatedCandidate[],
|
||||
index: CandidateIndex,
|
||||
): EvaluatedMatch => {
|
||||
// A known mapped candidate owns the comparison. Unrelated equal bytes cannot
|
||||
// repair a disagreement or establish the content of a candidate without a digest.
|
||||
const content =
|
||||
locations.length > 0
|
||||
? locations
|
||||
.map(({ candidate }) => candidate)
|
||||
.filter(({ digests }) =>
|
||||
digests.some(({ sha256 }) => sha256 === sourceSha256),
|
||||
)
|
||||
: (index.byCategoryDigest.get(
|
||||
categoryDigestKey(entity.staticKind, sourceSha256),
|
||||
) ?? []);
|
||||
const candidate = content.at(0);
|
||||
if (content.length === 1 && candidate !== undefined)
|
||||
return matched(entity, candidate, {
|
||||
basis:
|
||||
locations.length > 0
|
||||
? "content-and-location"
|
||||
: (candidate.digests.find(({ sha256 }) => sha256 === sourceSha256)
|
||||
?.basis ?? "content-sha256"),
|
||||
confidence: "high",
|
||||
reason:
|
||||
locations.length > 0
|
||||
? "content-and-location-match"
|
||||
: "unique-content-match",
|
||||
});
|
||||
if (content.length > 1)
|
||||
return unresolved(entity, {
|
||||
status: "ambiguous",
|
||||
basis: locations.length > 0 ? "content-and-location" : "none",
|
||||
confidence: "unknown",
|
||||
reason: "ambiguous-static-candidates",
|
||||
candidates: content,
|
||||
});
|
||||
if (locations.length > 0) {
|
||||
const digestUnavailable = locations.some(
|
||||
({ candidate: { digests } }) => digests.length === 0,
|
||||
);
|
||||
return unresolved(entity, {
|
||||
status: digestUnavailable ? "unknown" : "unmatched",
|
||||
basis: strongestLocationBasis(locations),
|
||||
confidence: digestUnavailable ? "unknown" : "high",
|
||||
reason: digestUnavailable
|
||||
? "static-or-runtime-coverage-incomplete"
|
||||
: "captured-content-disagrees-with-static-location",
|
||||
candidates: locations.map(({ candidate: located }) => located),
|
||||
});
|
||||
}
|
||||
return unresolved(entity, {
|
||||
status: "unmatched",
|
||||
basis: "none",
|
||||
confidence: "high",
|
||||
reason: "no-static-content-match",
|
||||
candidates: [],
|
||||
});
|
||||
};
|
||||
|
||||
const candidatesWithLocation = (
|
||||
entity: RuntimeReconciliationEntity,
|
||||
index: CandidateIndex,
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
JavaScriptSemanticAnalysisState,
|
||||
JavaScriptSemanticBindingState,
|
||||
JavaScriptSemanticScopeState,
|
||||
JavaScriptBindingIndex,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
currentSemanticScope,
|
||||
@@ -243,46 +244,37 @@ export interface JavaScriptReceiverFacts {
|
||||
readonly rpcClientRoots: ReadonlySet<t.Node>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only the lexical receiver identities needed to distinguish browser
|
||||
* `window.open` from XHR and global storage `.open` calls, configuration
|
||||
* `.get` reads from HTTP client calls, and tRPC client roots (excluding
|
||||
* overwritten bindings). This deliberately builds definitions without running
|
||||
* semantic value or provenance analysis.
|
||||
*/
|
||||
export const classifyParsedJavaScriptReceivers = (
|
||||
file: ParsedJavaScriptSource,
|
||||
): JavaScriptReceiverFacts =>
|
||||
completeSemanticSteps(classifyParsedJavaScriptReceiversSteps(file));
|
||||
|
||||
/** Classify receivers while yielding during lexical and AST scans. */
|
||||
export function* classifyParsedJavaScriptReceiversSteps(
|
||||
file: ParsedJavaScriptSource,
|
||||
): Generator<void, JavaScriptReceiverFacts> {
|
||||
const state = createState(file.program);
|
||||
yield* collectDefinitionsSteps(file.program, state);
|
||||
return yield* classifyDefinitionReceiversSteps(file.program, state);
|
||||
export interface JavaScriptReceiverIndex extends JavaScriptBindingIndex {
|
||||
readonly receivers: JavaScriptReceiverFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index bindings and classify receivers from one definitions pass. Receiver
|
||||
* facts read the definitions-only phase, so they are classified before the
|
||||
* same state is extended with ambient writes.
|
||||
* facts read the definitions-only phase. Consumers request ambient writes and
|
||||
* lexical references only when needed; each optional index is built once.
|
||||
*/
|
||||
export function* indexJavaScriptBindingsWithReceiversSteps(
|
||||
program: t.Program,
|
||||
): Generator<
|
||||
void,
|
||||
{
|
||||
readonly bindings: JavaScriptSemanticAnalysisState;
|
||||
readonly receivers: JavaScriptReceiverFacts;
|
||||
}
|
||||
> {
|
||||
): Generator<void, JavaScriptReceiverIndex> {
|
||||
const state = createState(program);
|
||||
yield* collectDefinitionsSteps(program, state);
|
||||
const receivers = yield* classifyDefinitionReceiversSteps(program, state);
|
||||
yield* indexJavaScriptAmbientWritesSteps(program, state);
|
||||
return { bindings: state, receivers };
|
||||
let ambientIndexed = false;
|
||||
let references: readonly JavaScriptSemanticReference[] | undefined;
|
||||
return {
|
||||
sourceType: program.sourceType,
|
||||
bindings: state,
|
||||
receivers,
|
||||
*indexAmbientWritesSteps() {
|
||||
if (ambientIndexed) return;
|
||||
yield* indexJavaScriptAmbientWritesSteps(program, state);
|
||||
ambientIndexed = true;
|
||||
},
|
||||
*referencesSteps() {
|
||||
references ??= yield* collectSemanticReferencesSteps(program, state);
|
||||
return references;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function* classifyDefinitionReceiversSteps(
|
||||
|
||||
@@ -5,15 +5,47 @@ import {
|
||||
analyzeParsedJavaScriptSemanticsSteps,
|
||||
classifyParsedJavaScriptElectronBindings,
|
||||
classifyParsedJavaScriptElectronBindingsSteps,
|
||||
classifyParsedJavaScriptReceivers,
|
||||
classifyParsedJavaScriptReceiversSteps,
|
||||
indexJavaScriptBindingsWithReceiversSteps,
|
||||
} from "./javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import { createJavaScriptGraphqlScan } from "./javascriptGraphqlOperations.js";
|
||||
import {
|
||||
completeSemanticSteps,
|
||||
traverseJavaScriptAst,
|
||||
traverseJavaScriptAstSteps,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
|
||||
it.each([false, true])(
|
||||
"checkpoints suppressed GraphQL sources, including alias proof (aliases: %s)",
|
||||
(aliased) => {
|
||||
const count = 1100;
|
||||
const source = Array.from({ length: count }, (_, index) => {
|
||||
const value = `{body:"query Q${index} { id }",locationOffset:{line:1,column:1}}`;
|
||||
const declaration = aliased ? `const source${index}=${value};` : "";
|
||||
return `${declaration}const doc${index}={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"Q${index}"},loc:{source:${aliased ? `source${index}` : value}}};`;
|
||||
}).join("\n");
|
||||
const parsed = parseJavaScriptSource(source, "sources.mjs");
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const index = completeSemanticSteps(
|
||||
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
|
||||
);
|
||||
const scan = createJavaScriptGraphqlScan(index);
|
||||
let operations = 0;
|
||||
traverseJavaScriptAst(parsed.program, {
|
||||
enter: (node) => {
|
||||
if (scan.inspect(node) !== undefined) operations += 1;
|
||||
},
|
||||
});
|
||||
expect(operations).toBe(count);
|
||||
let checkpoints = 0;
|
||||
for (const finding of scan.finish()) {
|
||||
expect(finding).toBeUndefined();
|
||||
checkpoints += 1;
|
||||
}
|
||||
expect(checkpoints).toBeGreaterThanOrEqual(count);
|
||||
},
|
||||
);
|
||||
|
||||
const source = `
|
||||
import { readFile } from "node:fs";
|
||||
const shared = { token: "TOKEN" };
|
||||
@@ -110,15 +142,14 @@ new NativeWindow();
|
||||
`;
|
||||
const parsed = parseJavaScriptSource(text, "receivers.js");
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const openSteps = classifyParsedJavaScriptReceiversSteps(parsed);
|
||||
const openSteps = indexJavaScriptBindingsWithReceiversSteps(parsed.program);
|
||||
let openStep = openSteps.next();
|
||||
expect(openStep.done).toBe(false);
|
||||
while (openStep.done !== true) openStep = openSteps.next();
|
||||
expect([...openStep.value.openReceivers]).toEqual([
|
||||
expect([...openStep.value.receivers.openReceivers]).toEqual([
|
||||
[text.indexOf('ambient.open("/local")'), "local"],
|
||||
[text.indexOf('ambient.open("/global")'), "window"],
|
||||
]);
|
||||
expect(openStep.value).toEqual(classifyParsedJavaScriptReceivers(parsed));
|
||||
|
||||
const electronSteps = classifyParsedJavaScriptElectronBindingsSteps(parsed);
|
||||
let electronStep = electronSteps.next();
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
JavaScriptSemanticModuleLink,
|
||||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticScope,
|
||||
JavaScriptSemanticReference,
|
||||
} from "./javascriptSemanticIr.js";
|
||||
import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutionMode.js";
|
||||
|
||||
@@ -109,6 +110,14 @@ export interface JavaScriptSemanticAnalysisState {
|
||||
readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[];
|
||||
}
|
||||
|
||||
/** One parsed source owns its optional indexes and their per-source lifetime. */
|
||||
export interface JavaScriptBindingIndex {
|
||||
readonly sourceType: t.Program["sourceType"];
|
||||
readonly bindings: JavaScriptSemanticAnalysisState;
|
||||
indexAmbientWritesSteps(): Generator<void, void>;
|
||||
referencesSteps(): Generator<void, readonly JavaScriptSemanticReference[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one unconditional source initializer for a declared lexical binding.
|
||||
* Repeated variable declarations without another initializer do not write it.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
inspectRouteProperty,
|
||||
inspectRoleProperty,
|
||||
inspectRpcNode,
|
||||
finishRpcScanSteps,
|
||||
indexVitePreloadDependencyTable,
|
||||
} from "./javascriptStaticAnalysisCalls.js";
|
||||
import {
|
||||
@@ -81,8 +82,9 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
): Generator<void, JavaScriptStaticAnalysis> {
|
||||
const accumulator = createJavaScriptAnalysisAccumulator(source.length);
|
||||
// One definitions pass serves receiver classification and binding lookup.
|
||||
const { bindings, receivers } =
|
||||
yield* indexJavaScriptBindingsWithReceiversSteps(file.program);
|
||||
const index = yield* indexJavaScriptBindingsWithReceiversSteps(file.program);
|
||||
const { bindings, receivers } = index;
|
||||
yield* index.indexAmbientWritesSteps();
|
||||
const electronIdentity = yield* indexJavaScriptElectronIdentitySteps(
|
||||
file.program,
|
||||
bindings,
|
||||
@@ -95,7 +97,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program),
|
||||
openReceiverFacts: receivers.openReceivers,
|
||||
electronBindings: electronIdentity.bindings,
|
||||
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots),
|
||||
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index),
|
||||
},
|
||||
bindings,
|
||||
);
|
||||
@@ -122,6 +124,7 @@ function* traverseStaticSourceSteps(
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
yield* finishRpcScanSteps(findings);
|
||||
}
|
||||
|
||||
const finalizeStaticAnalysis = (
|
||||
|
||||
@@ -454,6 +454,16 @@ export const inspectRpcNode = (node: t.Node, context: FindingContext): void => {
|
||||
addEndpoint(context, { node, kind: "network", ...rpc });
|
||||
};
|
||||
|
||||
/** Resolve deferred Source bodies after their document owners have been seen. */
|
||||
export function* finishRpcScanSteps(
|
||||
context: FindingContext,
|
||||
): Generator<void, void> {
|
||||
for (const rpc of context.rpcScan.finish()) {
|
||||
if (rpc !== undefined) addEndpoint(context, { kind: "network", ...rpc });
|
||||
yield;
|
||||
}
|
||||
}
|
||||
|
||||
export const inspectRouteProperty = (
|
||||
node: t.ObjectProperty,
|
||||
context: FindingContext,
|
||||
|
||||
@@ -16,6 +16,11 @@ const EVIDENCE_RECORDS = ["1", "2", "3", "4", "5"].map((character) =>
|
||||
operation: `fixture-${character}`,
|
||||
parameters: {},
|
||||
result: { character },
|
||||
confidence: "observed",
|
||||
authority:
|
||||
character === "3" || character === "4"
|
||||
? "controlled-replay"
|
||||
: "shipped-artifact",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,29 @@ import {
|
||||
|
||||
const digest = (character: string): string => character.repeat(64);
|
||||
|
||||
it("preserves duplicate coverage owners instead of overwriting them in shared indexes", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const owner = workspace.owners[0];
|
||||
if (owner === undefined) throw new Error("Expected fixture owner");
|
||||
const result = evaluateReconstructionClosure(
|
||||
{
|
||||
...workspace,
|
||||
owners: [owner, owner],
|
||||
},
|
||||
"replacement.cli",
|
||||
RECONSTRUCTION_COVERAGE_NOW,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: "partial",
|
||||
reasons: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "owner-duplicate",
|
||||
subject_id: "cli.help",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconstruction coverage closure", () => {
|
||||
it("returns ready only when ownership, verification, package, and authority closure pass", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
compositeKey,
|
||||
} from "./unicodeCodePointOrder.js";
|
||||
|
||||
import { evidenceBundleSchema, parseEvidenceBundle } from "./evidenceBundle.js";
|
||||
import {
|
||||
authenticateParsedEvidenceBundle,
|
||||
evidenceBundleSchema,
|
||||
} from "./evidenceBundle.js";
|
||||
import type { Evidence } from "./evidence.js";
|
||||
import {
|
||||
evaluateReconstructionClaims,
|
||||
evaluateReconstructionPackageProofs,
|
||||
@@ -188,13 +192,37 @@ export type ReconstructionVerifierContract = z.infer<
|
||||
typeof verifierContractSchema
|
||||
>;
|
||||
|
||||
/** Owned coverage snapshot and its authenticated, unique lookup facts. */
|
||||
export interface ParsedReconstructionCoverage {
|
||||
readonly coverage: ReconstructionCoverageData;
|
||||
readonly evidenceById: ReadonlyMap<string, Evidence>;
|
||||
readonly artifacts: ReadonlyMap<
|
||||
string,
|
||||
ReconstructionCoverageData["artifacts"][number]
|
||||
>;
|
||||
readonly surfaces: ReadonlyMap<
|
||||
string,
|
||||
ReconstructionCoverageData["surfaces"][number]
|
||||
>;
|
||||
readonly claims: ReadonlyMap<
|
||||
string,
|
||||
ReconstructionCoverageData["claims"][number]
|
||||
>;
|
||||
readonly ownersBySurface: ReadonlyMap<
|
||||
string,
|
||||
readonly ReconstructionCoverageData["owners"][number][]
|
||||
>;
|
||||
}
|
||||
|
||||
/** Parse inline coverage data and validate every evidence and authority link. */
|
||||
export const createReconstructionCoverageData = (
|
||||
input: unknown,
|
||||
): ReconstructionCoverageData => {
|
||||
): ReconstructionCoverageData => parseCoverageData(input).coverage;
|
||||
|
||||
/** Retain authenticated Evidence facts alongside the validated coverage snapshot. */
|
||||
const parseCoverageData = (input: unknown): ParsedReconstructionCoverage => {
|
||||
const coverage = reconstructionCoverageDataSchema.parse(input);
|
||||
validateCoverageReferences(coverage);
|
||||
return coverage;
|
||||
return { coverage, ...validateCoverageReferences(coverage) };
|
||||
};
|
||||
|
||||
const closureReasonSchema = z.strictObject({
|
||||
@@ -252,7 +280,6 @@ export type ReconstructionClosureResult = z.infer<
|
||||
typeof reconstructionClosureResultSchema
|
||||
>;
|
||||
|
||||
type CoverageData = z.infer<typeof reconstructionCoverageDataSchema>;
|
||||
type ClosureReason = z.infer<typeof closureReasonSchema>;
|
||||
|
||||
/** Build a verifier contract whose digest prevents retrospective coverage edits. */
|
||||
@@ -272,15 +299,15 @@ export const evaluateReconstructionClosure = (
|
||||
boundaryId: string,
|
||||
nowEpochMs: number,
|
||||
): ReconstructionClosureResult => {
|
||||
const coverage = createReconstructionCoverageData(coverageInput);
|
||||
const boundary = coverage.boundaries.find(
|
||||
const parsed = parseCoverageData(coverageInput);
|
||||
const boundary = parsed.coverage.boundaries.find(
|
||||
({ boundary_id: id }) => id === boundaryId,
|
||||
);
|
||||
if (boundary === undefined)
|
||||
throw new TypeError(`Unknown reconstruction boundary: ${boundaryId}`);
|
||||
const nowInstant = offsetDateTimeInstantFromEpochMilliseconds(nowEpochMs);
|
||||
const context: ReconstructionEvaluationContext = {
|
||||
coverage,
|
||||
...parsed,
|
||||
boundary,
|
||||
nowEpochMs,
|
||||
nowInstant,
|
||||
@@ -306,21 +333,25 @@ export const evaluateReconstructionClosure = (
|
||||
});
|
||||
};
|
||||
|
||||
const validateCoverageReferences = (workspace: CoverageData): void => {
|
||||
const evidenceBundle = parseEvidenceBundle(workspace.evidence_bundle);
|
||||
const evidenceIds = new Set(
|
||||
evidenceBundle.records.map(({ evidence_id: id }) => id),
|
||||
const validateCoverageReferences = (
|
||||
workspace: ReconstructionCoverageData,
|
||||
): Omit<ParsedReconstructionCoverage, "coverage"> => {
|
||||
const evidenceBundle = authenticateParsedEvidenceBundle(
|
||||
workspace.evidence_bundle,
|
||||
);
|
||||
const evidenceById = new Map(
|
||||
evidenceBundle.records.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
const unknownIds = new Set(
|
||||
evidenceBundle.unknowns.map(({ unknown_id: id }) => id),
|
||||
);
|
||||
const artifacts = assertUnique(workspace.artifacts, "artifact_id");
|
||||
const surfaces = assertUnique(workspace.surfaces, "surface_id");
|
||||
const claims = assertUnique(workspace.claims, "claim_id");
|
||||
const contracts = assertUnique(workspace.verifier_contracts, "verifier_id");
|
||||
assertUnique(workspace.contradictions, "contradiction_id");
|
||||
assertUnique(workspace.package_proofs, "proof_id");
|
||||
assertUnique(workspace.boundaries, "boundary_id");
|
||||
const artifacts = indexUnique(workspace.artifacts, "artifact_id");
|
||||
const surfaces = indexUnique(workspace.surfaces, "surface_id");
|
||||
const claims = indexUnique(workspace.claims, "claim_id");
|
||||
const contracts = indexUnique(workspace.verifier_contracts, "verifier_id");
|
||||
indexUnique(workspace.contradictions, "contradiction_id");
|
||||
indexUnique(workspace.package_proofs, "proof_id");
|
||||
indexUnique(workspace.boundaries, "boundary_id");
|
||||
for (const contract of workspace.verifier_contracts) {
|
||||
const { contract_sha256: contractSha256, ...base } = contract;
|
||||
if (contractSha256 !== digest(verifierContractBaseSchema.parse(base)))
|
||||
@@ -331,9 +362,17 @@ const validateCoverageReferences = (workspace: CoverageData): void => {
|
||||
for (const surface of workspace.surfaces)
|
||||
if (!artifacts.has(surface.artifact_id))
|
||||
throw new TypeError(`Surface artifact is missing: ${surface.surface_id}`);
|
||||
for (const owner of workspace.owners)
|
||||
const ownersBySurface = new Map<
|
||||
string,
|
||||
ReconstructionCoverageData["owners"][number][]
|
||||
>();
|
||||
for (const owner of workspace.owners) {
|
||||
if (!surfaces.has(owner.surface_id))
|
||||
throw new TypeError(`Owner surface is missing: ${owner.surface_id}`);
|
||||
const owners = ownersBySurface.get(owner.surface_id);
|
||||
if (owners === undefined) ownersBySurface.set(owner.surface_id, [owner]);
|
||||
else owners.push(owner);
|
||||
}
|
||||
for (const claim of workspace.claims)
|
||||
if (claim.surface_ids.some((id) => !surfaces.has(id)))
|
||||
throw new TypeError(`Claim surface is missing: ${claim.claim_id}`);
|
||||
@@ -353,7 +392,7 @@ const validateCoverageReferences = (workspace: CoverageData): void => {
|
||||
...workspace.package_proofs.flatMap(({ evidence_ids: ids }) => ids),
|
||||
];
|
||||
const danglingEvidence = referencedEvidenceIds.find(
|
||||
(id) => !evidenceIds.has(id),
|
||||
(id) => !evidenceById.has(id),
|
||||
);
|
||||
if (danglingEvidence !== undefined)
|
||||
throw new TypeError(
|
||||
@@ -366,16 +405,20 @@ const validateCoverageReferences = (workspace: CoverageData): void => {
|
||||
throw new TypeError(
|
||||
`Coverage workspace residual unknown is missing: ${danglingUnknown}`,
|
||||
);
|
||||
return { evidenceById, artifacts, surfaces, claims, ownersBySurface };
|
||||
};
|
||||
|
||||
const assertUnique = <Item extends Record<Key, string>, Key extends keyof Item>(
|
||||
const indexUnique = <Item extends Record<Key, string>, Key extends keyof Item>(
|
||||
items: readonly Item[],
|
||||
key: Key,
|
||||
): Set<string> => {
|
||||
const values = items.map((item) => item[key]);
|
||||
if (new Set(values).size !== values.length)
|
||||
throw new TypeError(`Duplicate reconstruction coverage ${String(key)}`);
|
||||
return new Set(values);
|
||||
): ReadonlyMap<string, Item> => {
|
||||
const values = new Map<string, Item>();
|
||||
for (const item of items) {
|
||||
if (values.has(item[key]))
|
||||
throw new TypeError(`Duplicate reconstruction coverage ${String(key)}`);
|
||||
values.set(item[key], item);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const closureStatus = (
|
||||
|
||||
@@ -2,7 +2,12 @@ import type {
|
||||
ReconstructionClosureResult,
|
||||
ReconstructionCoverageData,
|
||||
ReconstructionVerifierContract,
|
||||
ParsedReconstructionCoverage,
|
||||
} from "./reconstructionCoverage.js";
|
||||
import {
|
||||
packageProofHasComparableEvidence,
|
||||
verifierHasComparableEvidence,
|
||||
} from "./reconstructionCoverageEvidence.js";
|
||||
import {
|
||||
compareOffsetDateTimeInstants,
|
||||
offsetDateTimeInstantFromEpochMilliseconds,
|
||||
@@ -35,8 +40,7 @@ const probeOperation = (code: ClosureReason["code"]): string => {
|
||||
|
||||
type Boundary = ReconstructionCoverageData["boundaries"][number];
|
||||
type ClosureReason = ReconstructionClosureResult["reasons"][number];
|
||||
export interface ReconstructionEvaluationContext {
|
||||
readonly coverage: ReconstructionCoverageData;
|
||||
export interface ReconstructionEvaluationContext extends ParsedReconstructionCoverage {
|
||||
readonly boundary: Boundary;
|
||||
readonly nowEpochMs: number;
|
||||
readonly nowInstant: OffsetDateTimeInstant;
|
||||
@@ -47,9 +51,8 @@ export interface ReconstructionEvaluationContext {
|
||||
export const evaluateReconstructionSurfaces = (
|
||||
context: ReconstructionEvaluationContext,
|
||||
): void => {
|
||||
const surfaces = indexUnique(context.coverage.surfaces, "surface_id");
|
||||
for (const surfaceId of context.boundary.required_surface_ids) {
|
||||
const surface = surfaces.get(surfaceId);
|
||||
const surface = context.surfaces.get(surfaceId);
|
||||
if (surface === undefined) {
|
||||
addReason(
|
||||
context,
|
||||
@@ -61,7 +64,7 @@ export const evaluateReconstructionSurfaces = (
|
||||
}
|
||||
addEvidence(context, surface.evidence_ids);
|
||||
for (const dependencyId of surface.dependency_surface_ids)
|
||||
if (!surfaces.has(dependencyId))
|
||||
if (!context.surfaces.has(dependencyId))
|
||||
addReason(
|
||||
context,
|
||||
"dependency-surface-missing",
|
||||
@@ -76,9 +79,7 @@ const evaluateOwner = (
|
||||
context: ReconstructionEvaluationContext,
|
||||
surfaceId: string,
|
||||
): void => {
|
||||
const owners = context.coverage.owners.filter(
|
||||
({ surface_id: id }) => id === surfaceId,
|
||||
);
|
||||
const owners = context.ownersBySurface.get(surfaceId) ?? [];
|
||||
if (owners.length === 0) {
|
||||
addReason(
|
||||
context,
|
||||
@@ -150,10 +151,9 @@ const evaluateOwner = (
|
||||
export const evaluateReconstructionClaims = (
|
||||
context: ReconstructionEvaluationContext,
|
||||
): void => {
|
||||
const claims = indexUnique(context.coverage.claims, "claim_id");
|
||||
const indexes = createVerifierIndexes(context.coverage);
|
||||
const indexes = createVerifierIndexes(context);
|
||||
for (const claimId of context.boundary.required_claim_ids) {
|
||||
const claim = claims.get(claimId);
|
||||
const claim = context.claims.get(claimId);
|
||||
if (claim === undefined) {
|
||||
addReason(
|
||||
context,
|
||||
@@ -214,8 +214,9 @@ interface VerifierIndexes {
|
||||
}
|
||||
|
||||
const createVerifierIndexes = (
|
||||
coverage: ReconstructionCoverageData,
|
||||
context: ReconstructionEvaluationContext,
|
||||
): VerifierIndexes => {
|
||||
const { coverage } = context;
|
||||
const contractsByClaim = new Map<string, ReconstructionVerifierContract[]>();
|
||||
for (const contract of coverage.verifier_contracts)
|
||||
for (const claimId of new Set(contract.claim_ids)) {
|
||||
@@ -224,16 +225,6 @@ const createVerifierIndexes = (
|
||||
contractsByClaim.set(claimId, contracts);
|
||||
}
|
||||
|
||||
const ownersBySurface = new Map<
|
||||
string,
|
||||
ReconstructionCoverageData["owners"][number][]
|
||||
>();
|
||||
for (const owner of coverage.owners) {
|
||||
const owners = ownersBySurface.get(owner.surface_id) ?? [];
|
||||
owners.push(owner);
|
||||
ownersBySurface.set(owner.surface_id, owners);
|
||||
}
|
||||
|
||||
return {
|
||||
contractsByClaim,
|
||||
latestResultsByVerifier: latestVerifierObservationsByVerifier(
|
||||
@@ -247,9 +238,9 @@ const createVerifierIndexes = (
|
||||
ownership.disposition === "implemented" ? [ownership.owner_sha256] : [],
|
||||
),
|
||||
),
|
||||
surfaces: indexUnique(coverage.surfaces, "surface_id"),
|
||||
artifacts: indexUnique(coverage.artifacts, "artifact_id"),
|
||||
ownersBySurface,
|
||||
surfaces: context.surfaces,
|
||||
artifacts: context.artifacts,
|
||||
ownersBySurface: context.ownersBySurface,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -277,10 +268,9 @@ const evaluateVerifierResult = (
|
||||
);
|
||||
const latest = latestResults[0];
|
||||
if (latest === undefined) return;
|
||||
const { result } = latest;
|
||||
if (
|
||||
!latestResults.every((candidate) =>
|
||||
sameVerifierAssessment(result, candidate.result),
|
||||
sameVerifierAssessment(latest.result, candidate.result),
|
||||
)
|
||||
) {
|
||||
addReason(
|
||||
@@ -291,7 +281,20 @@ const evaluateVerifierResult = (
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!verifierResultIsCompatible(claim, contract, result, indexes)) {
|
||||
const comparable = latestResults.find(({ result }) =>
|
||||
verifierHasComparableEvidence(result, contract, context.evidenceById),
|
||||
);
|
||||
if (comparable === undefined) {
|
||||
addReason(
|
||||
context,
|
||||
"verifier-result-incompatible",
|
||||
claim.claim_id,
|
||||
`Latest verifier observation has no cited Evidence with comparable ${contract.authority} authority and confidence.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { result } = comparable;
|
||||
if (!verifierResultMatchesContract(claim, contract, result, indexes)) {
|
||||
addReason(
|
||||
context,
|
||||
"verifier-result-incompatible",
|
||||
@@ -302,10 +305,10 @@ const evaluateVerifierResult = (
|
||||
}
|
||||
const cutoffEpochMs = context.nowEpochMs - contract.max_age_ms;
|
||||
if (
|
||||
compareOffsetDateTimeInstants(latest.instant, context.nowInstant) > 0 ||
|
||||
compareOffsetDateTimeInstants(comparable.instant, context.nowInstant) > 0 ||
|
||||
(cutoffEpochMs >= -Number.MAX_SAFE_INTEGER &&
|
||||
compareOffsetDateTimeInstants(
|
||||
latest.instant,
|
||||
comparable.instant,
|
||||
offsetDateTimeInstantFromEpochMilliseconds(cutoffEpochMs),
|
||||
) < 0)
|
||||
) {
|
||||
@@ -333,7 +336,7 @@ const evaluateVerifierResult = (
|
||||
);
|
||||
};
|
||||
|
||||
const verifierResultIsCompatible = (
|
||||
const verifierResultMatchesContract = (
|
||||
claim: ReconstructionCoverageData["claims"][number],
|
||||
contract: ReconstructionVerifierContract,
|
||||
result: ReconstructionCoverageData["verifier_results"][number],
|
||||
@@ -415,20 +418,13 @@ export const evaluateReconstructionCoverageRisks = (
|
||||
export const evaluateReconstructionPackageProofs = (
|
||||
context: ReconstructionEvaluationContext,
|
||||
): void => {
|
||||
const surfaces = new Map(
|
||||
context.coverage.surfaces.map((surface) => [surface.surface_id, surface]),
|
||||
);
|
||||
const artifacts = new Map(
|
||||
context.coverage.artifacts.map((artifact) => [
|
||||
artifact.artifact_id,
|
||||
artifact,
|
||||
]),
|
||||
);
|
||||
const requiredArtifactDigests = context.boundary.required_surface_ids.flatMap(
|
||||
(surfaceId) => {
|
||||
const surface = surfaces.get(surfaceId);
|
||||
const surface = context.surfaces.get(surfaceId);
|
||||
const artifact =
|
||||
surface === undefined ? undefined : artifacts.get(surface.artifact_id);
|
||||
surface === undefined
|
||||
? undefined
|
||||
: context.artifacts.get(surface.artifact_id);
|
||||
return artifact === undefined ? [] : [artifact.artifact_sha256];
|
||||
},
|
||||
);
|
||||
@@ -436,30 +432,42 @@ export const evaluateReconstructionPackageProofs = (
|
||||
const proofs = context.coverage.package_proofs.filter(
|
||||
(proof) => proof.kind === kind,
|
||||
);
|
||||
const applicable = (
|
||||
proof: ReconstructionCoverageData["package_proofs"][number],
|
||||
): boolean =>
|
||||
requiredArtifactDigests.every((digest) =>
|
||||
proof.artifact_sha256s.includes(digest),
|
||||
) && packageProofHasComparableEvidence(proof, context.evidenceById);
|
||||
const passing = proofs.find(
|
||||
({ artifact_sha256s: digests, status }) =>
|
||||
status === "pass" &&
|
||||
requiredArtifactDigests.every((digest) => digests.includes(digest)),
|
||||
(proof) => proof.status === "pass" && applicable(proof),
|
||||
);
|
||||
if (passing !== undefined) {
|
||||
addEvidence(context, passing.evidence_ids);
|
||||
continue;
|
||||
}
|
||||
if (proofs.some(({ status }) => status === "fail"))
|
||||
const failing = proofs.find(
|
||||
(proof) => proof.status === "fail" && applicable(proof),
|
||||
);
|
||||
if (failing !== undefined) {
|
||||
addEvidence(context, failing.evidence_ids);
|
||||
addReason(
|
||||
context,
|
||||
"package-proof-failed",
|
||||
kind,
|
||||
"Required package or integration proof failed.",
|
||||
);
|
||||
else if (proofs.length > 0)
|
||||
} else if (proofs.length > 0) {
|
||||
addEvidence(
|
||||
context,
|
||||
proofs.flatMap(({ evidence_ids }) => evidence_ids),
|
||||
);
|
||||
addReason(
|
||||
context,
|
||||
"package-proof-unknown",
|
||||
kind,
|
||||
"Required package or integration proof is not passing.",
|
||||
"Required package or integration proof has no qualifying passing Evidence.",
|
||||
);
|
||||
else
|
||||
} else
|
||||
addReason(
|
||||
context,
|
||||
"package-proof-missing",
|
||||
@@ -469,11 +477,6 @@ export const evaluateReconstructionPackageProofs = (
|
||||
}
|
||||
};
|
||||
|
||||
const indexUnique = <Item extends Record<Key, string>, Key extends keyof Item>(
|
||||
items: readonly Item[],
|
||||
key: Key,
|
||||
): ReadonlyMap<string, Item> => new Map(items.map((item) => [item[key], item]));
|
||||
|
||||
const addReason = (
|
||||
context: ReconstructionEvaluationContext,
|
||||
code: ClosureReason["code"],
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createEvidence, type Evidence } from "./evidence.js";
|
||||
import { createEvidenceBundle } from "./evidenceBundle.js";
|
||||
import {
|
||||
completeReconstructionCoverageFixture,
|
||||
RECONSTRUCTION_COVERAGE_NOW,
|
||||
} from "./reconstructionCoverage.fixture.js";
|
||||
import {
|
||||
createReconstructionCoverageData,
|
||||
createReconstructionVerifierContract,
|
||||
evaluateReconstructionClosure,
|
||||
type ReconstructionCoverageData,
|
||||
} from "./reconstructionCoverage.js";
|
||||
|
||||
const proof = (
|
||||
authority: Evidence["authority"],
|
||||
confidence: Evidence["confidence"],
|
||||
) =>
|
||||
createEvidence(
|
||||
undefined,
|
||||
{ id: "proof-test", name: "Proof fixture", version: "1" },
|
||||
{
|
||||
operation: "verify",
|
||||
parameters: {},
|
||||
authority,
|
||||
confidence,
|
||||
result:
|
||||
confidence === "observed" ||
|
||||
(authority === "shipped-artifact" && confidence === "derived")
|
||||
? { status: "pass" }
|
||||
: { status: "unknown", note: "No replay was performed" },
|
||||
},
|
||||
);
|
||||
|
||||
const withProofs = (records: readonly Evidence[]) => {
|
||||
const { workspace, result } = completeReconstructionCoverageFixture();
|
||||
return createReconstructionCoverageData({
|
||||
...workspace,
|
||||
evidence_bundle: createEvidenceBundle([
|
||||
...workspace.evidence_bundle.records,
|
||||
...records,
|
||||
]),
|
||||
verifier_results: [
|
||||
{
|
||||
...result,
|
||||
evidence_ids: records.map(({ evidence_id }) => evidence_id),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const withPackageProof = (
|
||||
kind: ReconstructionCoverageData["package_proofs"][number]["kind"],
|
||||
record: Evidence,
|
||||
status: ReconstructionCoverageData["package_proofs"][number]["status"] = "pass",
|
||||
) => {
|
||||
const { workspace } = completeReconstructionCoverageFixture();
|
||||
return createReconstructionCoverageData({
|
||||
...workspace,
|
||||
evidence_bundle: createEvidenceBundle([
|
||||
...workspace.evidence_bundle.records,
|
||||
record,
|
||||
]),
|
||||
package_proofs: [
|
||||
{
|
||||
proof_id: "selected",
|
||||
kind,
|
||||
status,
|
||||
artifact_sha256s: ["1".repeat(64)],
|
||||
evidence_ids: [record.evidence_id],
|
||||
},
|
||||
],
|
||||
boundaries: workspace.boundaries.map((boundary) => ({
|
||||
...boundary,
|
||||
required_package_proof_kinds: [kind],
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const evaluate = (coverage: ReconstructionCoverageData) =>
|
||||
evaluateReconstructionClosure(
|
||||
coverage,
|
||||
"replacement.cli",
|
||||
RECONSTRUCTION_COVERAGE_NOW,
|
||||
);
|
||||
|
||||
it.each([
|
||||
["analyst-inference", "inferred"],
|
||||
["analyst-inference", "derived"],
|
||||
["analyst-inference", "observed"],
|
||||
["controlled-replay", "inferred"],
|
||||
["controlled-replay", "derived"],
|
||||
["shipped-artifact", "observed"],
|
||||
["external-service", "observed"],
|
||||
["historical-reference", "observed"],
|
||||
] as const)(
|
||||
"does not promote %s/%s into observed controlled replay",
|
||||
(authority, confidence) => {
|
||||
const record = proof(authority, confidence);
|
||||
const coverage = withProofs([record]);
|
||||
const result = evaluate(coverage);
|
||||
expect(result).toMatchObject({
|
||||
status: "unknown",
|
||||
reasons: [
|
||||
expect.objectContaining({
|
||||
code: "verifier-result-incompatible",
|
||||
subject_id: "claim.cli.help",
|
||||
}),
|
||||
],
|
||||
evidence_ids: expect.arrayContaining([record.evidence_id]),
|
||||
recommended_probes: [
|
||||
expect.objectContaining({ operation: "run_reconstruction_verifier" }),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
coverage.evidence_bundle.records.find(
|
||||
({ evidence_id }) => evidence_id === record.evidence_id,
|
||||
),
|
||||
).toEqual(record);
|
||||
expect(result.reasons[0]?.detail).toContain(
|
||||
"controlled-replay authority and confidence",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts cited observed replay alongside contextual inference without borrowing uncited proof", () => {
|
||||
const weak = proof("analyst-inference", "inferred");
|
||||
const observed = proof("controlled-replay", "observed");
|
||||
expect(evaluate(withProofs([weak, observed]))).toMatchObject({
|
||||
status: "ready",
|
||||
evidence_ids: expect.arrayContaining([
|
||||
weak.evidence_id,
|
||||
observed.evidence_id,
|
||||
]),
|
||||
});
|
||||
expect(evaluate(withProofs([weak])).status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does not fall back to older observed proof when the latest result only cites inference", () => {
|
||||
const record = proof("analyst-inference", "inferred");
|
||||
const coverage = withProofs([record]);
|
||||
const { result: previous } = completeReconstructionCoverageFixture();
|
||||
const latest = coverage.verifier_results[0];
|
||||
if (latest === undefined) throw new Error("Expected verifier result");
|
||||
const result = evaluate(
|
||||
createReconstructionCoverageData({
|
||||
...coverage,
|
||||
verifier_results: [
|
||||
previous,
|
||||
{ ...latest, observed_at: "2026-07-16T11:30:00.000Z" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unknown");
|
||||
expect(result.evidence_ids).not.toContain(previous.evidence_ids[0]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["shipped-artifact", "shipped-artifact", "observed", "ready"],
|
||||
["shipped-artifact", "shipped-artifact", "derived", "ready"],
|
||||
["shipped-artifact", "shipped-artifact", "inferred", "unknown"],
|
||||
["live-observation", "external-service", "observed", "ready"],
|
||||
["live-observation", "controlled-replay", "observed", "ready"],
|
||||
["external", "external-service", "observed", "ready"],
|
||||
["external", "controlled-replay", "observed", "unknown"],
|
||||
] as const)(
|
||||
"checks %s authority against actual %s/%s Evidence",
|
||||
(required, authority, confidence, status) => {
|
||||
const coverage = withProofs([proof(authority, confidence)]);
|
||||
const original = coverage.verifier_contracts[0];
|
||||
const result = coverage.verifier_results[0];
|
||||
if (original === undefined || result === undefined)
|
||||
throw new Error("Expected verifier contract");
|
||||
const { contract_sha256: _digest, ...input } = original;
|
||||
const contract = createReconstructionVerifierContract({
|
||||
...input,
|
||||
authority: required,
|
||||
});
|
||||
const changed = createReconstructionCoverageData({
|
||||
...coverage,
|
||||
claims: coverage.claims.map((claim) => ({
|
||||
...claim,
|
||||
required_authority: required,
|
||||
})),
|
||||
verifier_contracts: [contract],
|
||||
verifier_results: [
|
||||
{ ...result, contract_sha256: contract.contract_sha256 },
|
||||
],
|
||||
});
|
||||
expect(evaluate(changed).status).toBe(status);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"build",
|
||||
"clean-install",
|
||||
"package-contents",
|
||||
"runtime-routing",
|
||||
"authority-independence",
|
||||
] as const)("keeps %s unknown when only inferred Evidence is cited", (kind) => {
|
||||
const record = proof("analyst-inference", "inferred");
|
||||
const coverage = withPackageProof(kind, record);
|
||||
expect(evaluate(coverage)).toMatchObject({
|
||||
status: "unknown",
|
||||
reasons: [
|
||||
expect.objectContaining({
|
||||
code: "package-proof-unknown",
|
||||
subject_id: kind,
|
||||
}),
|
||||
],
|
||||
evidence_ids: expect.arrayContaining([record.evidence_id]),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["build", "shipped-artifact", "observed", "unknown"],
|
||||
["build", "external-service", "observed", "ready"],
|
||||
["runtime-routing", "controlled-replay", "observed", "ready"],
|
||||
["clean-install", "controlled-replay", "derived", "unknown"],
|
||||
["package-contents", "shipped-artifact", "derived", "ready"],
|
||||
["authority-independence", "shipped-artifact", "derived", "ready"],
|
||||
] as const)(
|
||||
"checks %s proof at the actual %s/%s boundary",
|
||||
(kind, authority, confidence, status) => {
|
||||
expect(
|
||||
evaluate(withPackageProof(kind, proof(authority, confidence))).status,
|
||||
).toBe(status);
|
||||
},
|
||||
);
|
||||
|
||||
it("distinguishes observed package failure from an inferred failure claim", () => {
|
||||
const inference = proof("analyst-inference", "inferred");
|
||||
expect(evaluate(withPackageProof("build", inference, "fail"))).toMatchObject({
|
||||
status: "unknown",
|
||||
evidence_ids: expect.arrayContaining([inference.evidence_id]),
|
||||
});
|
||||
const observed = proof("controlled-replay", "observed");
|
||||
expect(evaluate(withPackageProof("build", observed, "fail"))).toMatchObject({
|
||||
status: "failed",
|
||||
reasons: [expect.objectContaining({ code: "package-proof-failed" })],
|
||||
evidence_ids: expect.arrayContaining([observed.evidence_id]),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Evidence } from "./evidence.js";
|
||||
import type {
|
||||
ReconstructionCoverageData,
|
||||
ReconstructionVerifierContract,
|
||||
} from "./reconstructionCoverage.js";
|
||||
|
||||
type Authority = ReconstructionVerifierContract["authority"];
|
||||
type PackageProof = ReconstructionCoverageData["package_proofs"][number];
|
||||
type VerifierResult = ReconstructionCoverageData["verifier_results"][number];
|
||||
|
||||
const evidenceAuthorities: Readonly<
|
||||
Record<Authority, readonly Evidence["authority"][]>
|
||||
> = {
|
||||
"shipped-artifact": ["shipped-artifact"],
|
||||
"controlled-replay": ["controlled-replay"],
|
||||
"live-observation": ["controlled-replay", "external-service"],
|
||||
external: ["external-service"],
|
||||
};
|
||||
|
||||
const packageAuthorities: Readonly<
|
||||
Record<PackageProof["kind"], readonly Authority[]>
|
||||
> = {
|
||||
build: ["live-observation"],
|
||||
"clean-install": ["live-observation"],
|
||||
"package-contents": ["shipped-artifact", "live-observation"],
|
||||
"runtime-routing": ["live-observation"],
|
||||
"authority-independence": ["shipped-artifact", "live-observation"],
|
||||
};
|
||||
|
||||
/** Qualify only the Evidence cited by this verifier observation. */
|
||||
export const verifierHasComparableEvidence = (
|
||||
result: VerifierResult,
|
||||
contract: ReconstructionVerifierContract,
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
): boolean =>
|
||||
hasComparableEvidence(result.evidence_ids, [contract.authority], records);
|
||||
|
||||
/** Runtime package claims need observations; static package audits can be derived. */
|
||||
export const packageProofHasComparableEvidence = (
|
||||
proof: PackageProof,
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
): boolean =>
|
||||
hasComparableEvidence(
|
||||
proof.evidence_ids,
|
||||
packageAuthorities[proof.kind],
|
||||
records,
|
||||
);
|
||||
|
||||
const hasComparableEvidence = (
|
||||
evidenceIds: readonly string[],
|
||||
requirements: readonly Authority[],
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
): boolean =>
|
||||
evidenceIds.some((id) => {
|
||||
const evidence = records.get(id);
|
||||
if (evidence === undefined) return false;
|
||||
return requirements.some(
|
||||
(required) =>
|
||||
evidenceAuthorities[required].includes(evidence.authority) &&
|
||||
(evidence.confidence === "observed" ||
|
||||
(required === "shipped-artifact" &&
|
||||
evidence.confidence === "derived")),
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,20 @@ import { createWebTextArtifact } from "./webContentArtifact.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
|
||||
it("retains standalone GraphQL Source operations in captured scripts", () => {
|
||||
const result = analyzeCapturedWebBundle(
|
||||
inspection(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
`),
|
||||
);
|
||||
expect(result.observations.endpoints).toEqual([
|
||||
expect.objectContaining({ value: "GetUser", mechanism: "graphql:query" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: "recovered",
|
||||
|
||||
@@ -5,7 +5,8 @@ import { compositeKey } from "./unicodeCodePointOrder.js";
|
||||
import type { WebPageInspection } from "./browserObservationSchemas.js";
|
||||
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
|
||||
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
|
||||
import { classifyParsedJavaScriptReceivers } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js";
|
||||
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
|
||||
import {
|
||||
@@ -61,11 +62,15 @@ export const analyzeScript = (
|
||||
if ((file.errors?.length ?? 0) > 0)
|
||||
accumulator.recoveredScripts.push(script.script_key);
|
||||
detectVendorFingerprints(script, accumulator);
|
||||
const receivers = classifyParsedJavaScriptReceivers(file);
|
||||
const index = completeSemanticSteps(
|
||||
indexJavaScriptBindingsWithReceiversSteps(file.program),
|
||||
);
|
||||
const { receivers } = index;
|
||||
const openReceiverFacts = receivers.openReceivers;
|
||||
const rpc = createJavaScriptRpcScan(
|
||||
script.source.artifact.text,
|
||||
receivers.rpcClientRoots,
|
||||
index,
|
||||
);
|
||||
traverseJavaScriptAst(file, {
|
||||
enter: (node) => {
|
||||
@@ -83,6 +88,16 @@ export const analyzeScript = (
|
||||
});
|
||||
},
|
||||
});
|
||||
for (const endpoint of rpc.finish())
|
||||
if (endpoint !== undefined)
|
||||
addFinding({
|
||||
collection: accumulator.endpoints,
|
||||
script,
|
||||
rawValue: endpoint.value,
|
||||
mechanism: endpoint.mechanism,
|
||||
node: endpoint.node,
|
||||
accumulator,
|
||||
});
|
||||
};
|
||||
|
||||
const inspectNode = (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { REGEX_MATCHING_LIMITATION } from "../process/RegexMatcher.js";
|
||||
import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
@@ -90,7 +91,7 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
||||
: []),
|
||||
...(operation === "search_strings" || operation === "search_procedures"
|
||||
? [
|
||||
"Regex mode uses ECMAScript Unicode semantics in a supervised worker with a five-second matching deadline. Cancellation or deadline failure stops matching without blocking Hopper; literal mode retains native casefold semantics.",
|
||||
`${REGEX_MATCHING_LIMITATION} Literal mode retains native casefold semantics.`,
|
||||
]
|
||||
: []),
|
||||
...(operation === "list_strings" ||
|
||||
|
||||
+16
-123
@@ -1,4 +1,3 @@
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
@@ -6,7 +5,6 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { analysisStringSchema } from "../domain/hopperValues.js";
|
||||
import { HopperRemoteError } from "../domain/hopperErrors.js";
|
||||
@@ -14,20 +12,12 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { analysisSearchInput } from "../contracts/analysisSearchContract.js";
|
||||
import type { HopperClient } from "./HopperClient.js";
|
||||
import { matchRegexValues } from "../process/RegexMatcher.js";
|
||||
|
||||
const MATCHING_DEADLINE_MS = 5_000;
|
||||
const inputSchema = z.strictObject({
|
||||
...analysisSearchInput,
|
||||
mode: z.literal("regex"),
|
||||
});
|
||||
const replySchema = z.discriminatedUnion("status", [
|
||||
z.strictObject({
|
||||
status: z.literal("matched"),
|
||||
indices: z.array(z.number().int().nonnegative()),
|
||||
}),
|
||||
z.strictObject({ status: z.literal("invalid_pattern"), reason: z.string() }),
|
||||
]);
|
||||
|
||||
/** Match collected native facts off Hopper's API thread, with cancellable lifetime. */
|
||||
export class HopperRegexSearch {
|
||||
readonly #active = new Map<
|
||||
@@ -35,7 +25,7 @@ export class HopperRegexSearch {
|
||||
Promise<Result<JsonValue, AnalysisError>>
|
||||
>();
|
||||
|
||||
constructor(private readonly client: HopperClient) {}
|
||||
constructor(private readonly client: Pick<HopperClient, "callTool">) {}
|
||||
|
||||
execute(
|
||||
operation: "search_strings" | "search_procedures",
|
||||
@@ -125,117 +115,20 @@ export class HopperRegexSearch {
|
||||
.catch(() => undefined);
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(new URL("./HopperRegexWorker.js", import.meta.url), {
|
||||
workerData: {
|
||||
pattern: input.data.pattern,
|
||||
caseSensitive: input.data.case_sensitive,
|
||||
values: parsed.data.map((item) => item.value),
|
||||
},
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
const matched = await matchRegexValues({
|
||||
operation,
|
||||
pattern: input.data.pattern,
|
||||
caseSensitive: input.data.case_sensitive,
|
||||
values: parsed.data.map((item) => item.value),
|
||||
signal: options.signal,
|
||||
invalidPattern: (reason) =>
|
||||
new HopperRemoteError(-32000, `Invalid regex pattern: ${reason}`, {
|
||||
diagnosticType: "invalid_request",
|
||||
operation,
|
||||
`Cannot start regex worker: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await new Promise<Result<JsonValue, AnalysisError>>((resolve) => {
|
||||
const finish = (result: Result<JsonValue, AnalysisError>): void => {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
const onAbort = (): void =>
|
||||
finish(err(new AnalysisCancelledError(operation)));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"cpu",
|
||||
"Regex matching exceeded its supervised deadline; the Hopper API thread remains available.",
|
||||
{ matching_deadline_ms: MATCHING_DEADLINE_MS },
|
||||
{
|
||||
remediationAction:
|
||||
"Simplify the ECMAScript Unicode regex or use literal mode, then retry in the same session.",
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
MATCHING_DEADLINE_MS,
|
||||
);
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (options.signal?.aborted) onAbort();
|
||||
worker.once("message", (message: unknown) => {
|
||||
const reply = replySchema.safeParse(message);
|
||||
if (!reply.success)
|
||||
return finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Regex worker reply is malformed",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (reply.data.status === "invalid_pattern")
|
||||
return finish(
|
||||
err(
|
||||
new HopperRemoteError(
|
||||
-32000,
|
||||
`Invalid regex pattern: ${reply.data.reason}`,
|
||||
{ diagnosticType: "invalid_request", operation },
|
||||
),
|
||||
),
|
||||
);
|
||||
const indices = reply.data.indices;
|
||||
if (
|
||||
indices.some(
|
||||
(index, position) =>
|
||||
index >= parsed.data.length ||
|
||||
(position > 0 && index <= (indices[position - 1] ?? -1)),
|
||||
)
|
||||
)
|
||||
return finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Regex worker returned invalid inventory indices",
|
||||
),
|
||||
),
|
||||
);
|
||||
const selected = new Set(indices);
|
||||
finish(ok(parsed.data.filter((_, index) => selected.has(index))));
|
||||
});
|
||||
worker.once("error", (cause: Error) =>
|
||||
finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
`Regex worker failed: ${cause.message}`,
|
||||
{ cause },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
worker.once("exit", (code) =>
|
||||
finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
`Regex worker exited without a result (${code})`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
}),
|
||||
});
|
||||
if (!matched.ok) return matched;
|
||||
const selected = new Set(matched.value);
|
||||
return ok(parsed.data.filter((_, index) => selected.has(index)));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { z } from "zod";
|
||||
|
||||
const input = z
|
||||
.strictObject({
|
||||
pattern: z.string().min(1),
|
||||
caseSensitive: z.boolean(),
|
||||
values: z.array(z.string()),
|
||||
})
|
||||
.parse(workerData);
|
||||
|
||||
let expression: RegExp | undefined;
|
||||
try {
|
||||
expression = new RegExp(input.pattern, input.caseSensitive ? "u" : "iu");
|
||||
} catch (cause: unknown) {
|
||||
parentPort?.postMessage({
|
||||
status: "invalid_pattern",
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
});
|
||||
}
|
||||
if (expression !== undefined) {
|
||||
const indices = [];
|
||||
for (const [index, value] of input.values.entries()) {
|
||||
if (expression.test(value)) indices.push(index);
|
||||
}
|
||||
parentPort?.postMessage({ status: "matched", indices });
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
nativeLiteralSearchPattern,
|
||||
nativeStringsSchema,
|
||||
} from "./IdaNativeProtocol.js";
|
||||
import { analysisSearchInput } from "../contracts/analysisSearchContract.js";
|
||||
import {
|
||||
analysisSearchPredicate,
|
||||
analysisSearchQuerySchema,
|
||||
} from "../domain/analysisSearch.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisCancelledError,
|
||||
@@ -82,7 +85,6 @@ const MISSING_PROCEDURE_ANSWERS: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
const lines = (asm: IdaDisassembly) =>
|
||||
asm.lines.map(({ address, instruction }) => `${address}: ${instruction}`);
|
||||
const searchQuerySchema = z.object(analysisSearchInput);
|
||||
|
||||
/** Translate explicit analyst questions into a closed set of read-only upstream requests. */
|
||||
export class IdaOperationRunner {
|
||||
@@ -581,10 +583,10 @@ export class IdaOperationRunner {
|
||||
): Promise<JsonValue> {
|
||||
const query =
|
||||
operation === "search_procedures" || operation === "search_strings"
|
||||
? searchQuerySchema.parse(parameters)
|
||||
? analysisSearchQuerySchema.parse(parameters)
|
||||
: undefined;
|
||||
const matches =
|
||||
query === undefined ? undefined : this.searchPredicate(query);
|
||||
query === undefined ? undefined : analysisSearchPredicate(query);
|
||||
if (operation === "list_procedures" || operation === "search_procedures") {
|
||||
const items = (await this.functions()).map((fn) => ({
|
||||
address: fn.address,
|
||||
@@ -660,30 +662,6 @@ export class IdaOperationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private searchPredicate(
|
||||
query: z.infer<typeof searchQuerySchema>,
|
||||
): (value: string) => boolean {
|
||||
let matches: (value: string) => boolean;
|
||||
if (query.mode === "regex") {
|
||||
try {
|
||||
const regex = new RegExp(
|
||||
query.pattern,
|
||||
query.case_sensitive ? "u" : "iu",
|
||||
);
|
||||
matches = (value) => regex.test(value);
|
||||
} catch (cause: unknown) {
|
||||
throw new AnalysisInputError("search", { cause });
|
||||
}
|
||||
} else {
|
||||
const needle = query.case_sensitive
|
||||
? query.pattern
|
||||
: query.pattern.toLowerCase();
|
||||
matches = (value) =>
|
||||
(query.case_sensitive ? value : value.toLowerCase()).includes(needle);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private async dossier(fn: IdaFunction): Promise<JsonValue> {
|
||||
const asm = await this.assembly(fn);
|
||||
const pseudocode = await this.pseudocode(fn);
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
ProviderProcessSnapshot,
|
||||
ProviderProcessSupervisor,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import {
|
||||
kunaChildEnvironment,
|
||||
type KunaInstallation,
|
||||
} from "./KunaInstallation.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
|
||||
/**
|
||||
* `kuna functions --json` costs ~210 bytes per function; this admits about
|
||||
* 600,000 functions, above the 392,814 of the largest image Kuna documents.
|
||||
*/
|
||||
export const KUNA_INVENTORY_OUTPUT_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The `kuna functions` inventory of one image. Its `--jobs` decode lanes
|
||||
* return the serial inventory byte for byte, so the configured jobs change only
|
||||
* how fast discovery runs (a 254 MB Mach-O: 66 s serially, 23 s with 8 lanes).
|
||||
*/
|
||||
export const kunaInventoryArguments = (
|
||||
input: string,
|
||||
jobs: number,
|
||||
): readonly string[] => ["functions", input, "--json", "--jobs", String(jobs)];
|
||||
|
||||
/** Test seam for the owned process launcher; production uses the default. */
|
||||
export type KunaProcessLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/** One bounded Kuna run inside a private workspace. */
|
||||
export interface KunaCommandRequest {
|
||||
readonly installation: KunaInstallation;
|
||||
readonly arguments: readonly string[];
|
||||
/** Private directory used as the working and temporary directory. */
|
||||
readonly workspace: string;
|
||||
/** Operation named by every failure this run reports. */
|
||||
readonly operation: string;
|
||||
readonly timeoutMs: number;
|
||||
/** The deadline callers configured, reported when the run times out. */
|
||||
readonly reportedTimeoutMs: number;
|
||||
readonly outputBytes: number;
|
||||
readonly signal: AbortSignal;
|
||||
readonly launcher?: KunaProcessLauncher | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a Kuna run ended. `retained` means the process tree could not be proven
|
||||
* stopped, so it may still write into the workspace, which therefore stays
|
||||
* with the caller's cleanup owner.
|
||||
*/
|
||||
export type KunaCommandOutcome =
|
||||
| { readonly kind: "ok"; readonly snapshot: ProviderProcessSnapshot }
|
||||
| { readonly kind: "failed"; readonly error: AnalysisError }
|
||||
| {
|
||||
readonly kind: "retained";
|
||||
readonly error: ProviderCleanupError;
|
||||
/** The supervisor that can retry stopping the process tree, when one exists. */
|
||||
readonly owner: ProviderProcessSupervisor | undefined;
|
||||
};
|
||||
|
||||
const message = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** File metadata that, unchanged, lets a verified digest stand without re-hashing. */
|
||||
const verifiedExecutables = new Map<
|
||||
string,
|
||||
{ readonly identity: string; readonly sha256: string }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Confirm the executable still has the digest its analysis profile committed,
|
||||
* so no answer names one Kuna build while another produced it.
|
||||
*/
|
||||
const verifyExecutable = async (
|
||||
installation: KunaInstallation,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> => {
|
||||
const status = await stat(installation.executable, { bigint: true });
|
||||
const identity = [
|
||||
status.dev,
|
||||
status.ino,
|
||||
status.size,
|
||||
status.mtimeNs,
|
||||
status.ctimeNs,
|
||||
].join(":");
|
||||
const known = verifiedExecutables.get(installation.executable);
|
||||
let sha256 = known?.identity === identity ? known.sha256 : undefined;
|
||||
if (sha256 === undefined) {
|
||||
sha256 = (await hashStableFile(installation.executable, signal)).sha256;
|
||||
verifiedExecutables.set(installation.executable, { identity, sha256 });
|
||||
}
|
||||
return sha256 === installation.sha256 ? undefined : sha256;
|
||||
};
|
||||
|
||||
/** Run Kuna under an owned process group with REA's environment and limits. */
|
||||
export const runKunaCommand = async (
|
||||
request: KunaCommandRequest,
|
||||
): Promise<KunaCommandOutcome> => {
|
||||
const { installation } = request;
|
||||
try {
|
||||
const changed = await verifyExecutable(installation, request.signal);
|
||||
if (changed !== undefined)
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
|
||||
userMessage: `${installation.executable} changed after this target was opened; reopen the target so REA probes the current Kuna executable.`,
|
||||
diagnostics: {
|
||||
expected_sha256: installation.sha256,
|
||||
observed_sha256: changed,
|
||||
},
|
||||
}),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
kind: "failed",
|
||||
error: request.signal.aborted
|
||||
? new AnalysisCancelledError(request.operation, { cause })
|
||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
|
||||
cause,
|
||||
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const snapshot = await runOwnedCommand(
|
||||
{
|
||||
command: installation.executable,
|
||||
arguments: request.arguments,
|
||||
runId: `rea-kuna-${randomUUID()}`,
|
||||
// A script's process reports its interpreter, not the configured path.
|
||||
...(installation.script ? { expectedCommand: null } : {}),
|
||||
cwd: request.workspace,
|
||||
hostEnvironment: kunaChildEnvironment(installation.configuration),
|
||||
env: { TMPDIR: request.workspace },
|
||||
},
|
||||
{
|
||||
timeoutMs: Math.max(1, request.timeoutMs),
|
||||
diagnosticBytes: request.outputBytes,
|
||||
},
|
||||
{
|
||||
signal: request.signal,
|
||||
...(request.launcher === undefined
|
||||
? {}
|
||||
: { launcher: request.launcher }),
|
||||
},
|
||||
);
|
||||
return { kind: "ok", snapshot };
|
||||
} catch (cause: unknown) {
|
||||
return commandFailure(cause, request);
|
||||
}
|
||||
};
|
||||
|
||||
/** Copy a file and return the SHA-256 of exactly the bytes copied. */
|
||||
export const copyAndHash = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
await pipeline(
|
||||
createReadStream(source),
|
||||
new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
}),
|
||||
createWriteStream(destination, { flags: "wx", mode: 0o600 }),
|
||||
{ signal },
|
||||
);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
/** Map an owned-command failure to its typed error, keeping unproven cleanup owned. */
|
||||
const commandFailure = (
|
||||
cause: unknown,
|
||||
request: KunaCommandRequest,
|
||||
): KunaCommandOutcome => {
|
||||
const { operation } = request;
|
||||
const subcommand = request.arguments[0] ?? "";
|
||||
if (!(cause instanceof OwnedCommandFailure))
|
||||
return {
|
||||
kind: "failed",
|
||||
error: request.signal.aborted
|
||||
? new AnalysisCancelledError(operation, { cause })
|
||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
cause,
|
||||
userMessage: `Cannot run ${request.installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
};
|
||||
const snapshot = cause.snapshot;
|
||||
const capturedOutput =
|
||||
snapshot === null
|
||||
? undefined
|
||||
: {
|
||||
// Output can be evidence-sized, not diagnostic-sized; keep only its tail.
|
||||
stdout: snapshot.stdout.text.slice(-4096),
|
||||
stderr: snapshot.stderr.text,
|
||||
truncated:
|
||||
snapshot.diagnosticTruncated === true ||
|
||||
snapshot.outputIncomplete === true ||
|
||||
snapshot.stdout.text.length > 4096,
|
||||
exit_code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal ?? null,
|
||||
};
|
||||
const details = {
|
||||
cause,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
};
|
||||
if (cause.cleanupFailure !== null)
|
||||
return {
|
||||
kind: "retained",
|
||||
error: new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
[...cause.resources, request.workspace],
|
||||
{ reason: cause.cleanupFailure },
|
||||
{ operation, cause },
|
||||
),
|
||||
owner: cause.cleanupOwner,
|
||||
};
|
||||
if (cause.reason === "cancelled")
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new AnalysisCancelledError(operation, details),
|
||||
};
|
||||
if (cause.reason === "timeout")
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new AnalysisTimeoutError(
|
||||
operation,
|
||||
request.reportedTimeoutMs,
|
||||
details,
|
||||
),
|
||||
};
|
||||
if (cause.reason === "output-limit")
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"capacity",
|
||||
`kuna ${subcommand} wrote more than ${String(request.outputBytes)} bytes of output.`,
|
||||
{ output_limit_bytes: request.outputBytes },
|
||||
details,
|
||||
),
|
||||
};
|
||||
const exitCode = capturedOutput?.exit_code;
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
...details,
|
||||
userMessage: `kuna ${subcommand} failed${exitCode === undefined || exitCode === null ? "" : ` with exit code ${String(exitCode)}`}: ${capturedOutput?.stderr.trim().split("\n").at(-1) ?? cause.message}`,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -18,6 +18,9 @@ const MAX_KUNA_JOBS = 32;
|
||||
*/
|
||||
const DEFAULT_PROJECT_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
/** One function decompiles in well under a second; sqlite3VdbeExec (42 KB) took 15-25 s. */
|
||||
const DEFAULT_FUNCTION_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* A serial (`--jobs 1`) export is Kuna's reference answer: it decompiles
|
||||
* callees before callers, which recovers prototypes that parallel workers
|
||||
@@ -39,6 +42,8 @@ export interface KunaConfiguration {
|
||||
readonly cacheRoot: string;
|
||||
readonly cacheMaxBytes: number;
|
||||
readonly projectTimeoutMs: number;
|
||||
/** Deadline for one single-function `kuna decompile` run. */
|
||||
readonly functionTimeoutMs: number;
|
||||
readonly jobs: number;
|
||||
}
|
||||
|
||||
@@ -125,6 +130,18 @@ export const readKunaConfiguration = (
|
||||
"REA_KUNA_PROJECT_TIMEOUT_MS",
|
||||
);
|
||||
|
||||
const rawFunctionTimeout = environment.REA_KUNA_TIMEOUT_MS;
|
||||
const functionTimeout =
|
||||
rawFunctionTimeout === undefined
|
||||
? undefined
|
||||
: providerStartupTimeoutSchema.safeParse(rawFunctionTimeout);
|
||||
if (functionTimeout !== undefined && !functionTimeout.success)
|
||||
return failure(
|
||||
"open_options_invalid",
|
||||
`REA_KUNA_TIMEOUT_MS must be a decimal millisecond count from 1 to 2147483647; received ${String(rawFunctionTimeout)}.`,
|
||||
"REA_KUNA_TIMEOUT_MS",
|
||||
);
|
||||
|
||||
const rawJobs = environment.REA_KUNA_JOBS;
|
||||
const jobs = rawJobs === undefined ? DEFAULT_JOBS : positiveInteger(rawJobs);
|
||||
if (jobs === undefined || jobs > MAX_KUNA_JOBS)
|
||||
@@ -139,6 +156,7 @@ export const readKunaConfiguration = (
|
||||
cacheRoot: cacheRoot ?? defaultCacheRoot(environment, platform),
|
||||
cacheMaxBytes,
|
||||
projectTimeoutMs: timeout?.data ?? DEFAULT_PROJECT_TIMEOUT_MS,
|
||||
functionTimeoutMs: functionTimeout?.data ?? DEFAULT_FUNCTION_TIMEOUT_MS,
|
||||
jobs,
|
||||
});
|
||||
};
|
||||
@@ -25,6 +25,7 @@ const REGISTRATION_SETTINGS = [
|
||||
"REA_KUNA_CACHE_DIR",
|
||||
"REA_KUNA_CACHE_MAX_BYTES",
|
||||
"REA_KUNA_PROJECT_TIMEOUT_MS",
|
||||
"REA_KUNA_TIMEOUT_MS",
|
||||
"REA_KUNA_JOBS",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Shared buffered-text capacity for a subprocess answer or one cached source facet. */
|
||||
export const KUNA_FUNCTION_TEXT_BYTES = 16 * 1024 * 1024;
|
||||
+129
-42
@@ -31,8 +31,9 @@ import {
|
||||
readKunaExport,
|
||||
type KunaExport,
|
||||
type KunaExportStatus,
|
||||
type KunaProcessLauncher,
|
||||
} from "./KunaProjectExport.js";
|
||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
import type { KunaProcessLauncher } from "./KunaCommand.js";
|
||||
import type {
|
||||
KunaInventoryFunction,
|
||||
KunaProjectFunction,
|
||||
@@ -129,6 +130,15 @@ export interface KunaProjectRequest {
|
||||
readonly onStatus?: (status: KunaExportStatus) => void;
|
||||
}
|
||||
|
||||
/** One build whose process tree REA could not prove stopped. */
|
||||
interface RetainedProcess {
|
||||
readonly key: string;
|
||||
readonly error: ProviderCleanupError;
|
||||
/** Retries stopping the process tree, when the launcher left one. */
|
||||
readonly owner: ProviderProcessSupervisor | undefined;
|
||||
readonly staging: string;
|
||||
}
|
||||
|
||||
interface StagedResult {
|
||||
readonly result: Result<KunaProject, AnalysisError>;
|
||||
/** Set when an owned process may still write into staging. */
|
||||
@@ -205,9 +215,13 @@ export class KunaProjectCache {
|
||||
#capacity: "within_limit" | "pinned" | "building" = "within_limit";
|
||||
#maintenance: Promise<void> | undefined;
|
||||
#maintenanceRequested = false;
|
||||
readonly #retainedProcesses: ProviderCleanupError[] = [];
|
||||
/** Builds whose process tree could not be proven stopped; their staging stays. */
|
||||
readonly #retainedProcesses: RetainedProcess[] = [];
|
||||
/** Only resources whose process cleanup is proven may be retried as files. */
|
||||
readonly #retainedFiles = new Map<string, ProviderCleanupError>();
|
||||
readonly #retainedFiles = new Map<
|
||||
string,
|
||||
{ readonly key: string | null; readonly error: ProviderCleanupError }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
@@ -341,33 +355,81 @@ export class KunaProjectCache {
|
||||
return this.#await(created, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a build that every waiter abandoned has stopped its Kuna
|
||||
* process and removed its staging. A build another request still waits for
|
||||
* keeps running and is not awaited.
|
||||
*/
|
||||
async settled(key: string): Promise<Result<null, ProviderCleanupError>> {
|
||||
await Promise.allSettled(
|
||||
[...this.#unfinished]
|
||||
.filter((entry) => entry.key === key && entry.controller.signal.aborted)
|
||||
.map((entry) => entry.promise),
|
||||
);
|
||||
await this.#retryProcesses((retained) => retained.key === key);
|
||||
await this.#retryFiles(key);
|
||||
return cleanupResult(
|
||||
[...this.#retainedProcesses, ...this.#retainedFiles.values()]
|
||||
.filter((retained) => retained.key === key)
|
||||
.map(({ error }) => error),
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait for builds, retry safe file removal, and report unresolved cleanup. */
|
||||
async close(): Promise<Result<null, ProviderCleanupError>> {
|
||||
await Promise.allSettled(
|
||||
[...this.#unfinished].map((entry) => entry.promise),
|
||||
);
|
||||
while (this.#maintenance !== undefined) await this.#maintenance;
|
||||
for (const path of [...this.#retainedFiles.keys()])
|
||||
await this.#removeRetired(path).catch(() => undefined);
|
||||
const retained = [
|
||||
...this.#retainedProcesses,
|
||||
...this.#retainedFiles.values(),
|
||||
];
|
||||
if (retained.length === 0) return ok(null);
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
retained.flatMap((entry) => entry.cleanupResources),
|
||||
{
|
||||
reason: retained
|
||||
.map((entry) =>
|
||||
String(entry.diagnostics?.["reason"] ?? entry.message),
|
||||
)
|
||||
.join("; "),
|
||||
},
|
||||
{ operation: KUNA_EXPORT_OPERATION },
|
||||
),
|
||||
);
|
||||
await this.#retryProcesses(() => true);
|
||||
await this.#retryFiles();
|
||||
return cleanupResult([
|
||||
...this.#retainedProcesses.map(({ error }) => error),
|
||||
...[...this.#retainedFiles.values()].map(({ error }) => error),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Retry stopping retained process trees; retire the staging of each one proven stopped. */
|
||||
async #retryProcesses(
|
||||
selected: (retained: RetainedProcess) => boolean,
|
||||
): Promise<void> {
|
||||
for (const retained of this.#retainedProcesses.filter(selected)) {
|
||||
const stopped = await retained.owner?.stop();
|
||||
if (stopped === undefined || stopped.status === "incomplete") continue;
|
||||
this.#retainedProcesses.splice(
|
||||
this.#retainedProcesses.indexOf(retained),
|
||||
1,
|
||||
);
|
||||
await this.#retireStaging(retained.staging, retained.key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry only stopped file resources belonging to the closing task, or all. */
|
||||
async #retryFiles(key?: string): Promise<void> {
|
||||
for (const [path, retained] of [...this.#retainedFiles])
|
||||
if (key === undefined || retained.key === key)
|
||||
await this.#removeRetired(path).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move proven-stopped staging into trash and remove it. A failed removal
|
||||
* stays owned as a file cleanup that close retries.
|
||||
*/
|
||||
async #retireStaging(
|
||||
staging: string,
|
||||
key: string,
|
||||
): Promise<ProviderCleanupError | undefined> {
|
||||
let cleanupPath = staging;
|
||||
try {
|
||||
const retired = join(this.#root, "trash", `build-${randomUUID()}`);
|
||||
await rename(staging, retired);
|
||||
cleanupPath = retired;
|
||||
await this.#removeRetired(retired, key);
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ProviderCleanupError) return cause;
|
||||
return this.#retainFileCleanup(cleanupPath, cause, key);
|
||||
}
|
||||
}
|
||||
|
||||
#await(
|
||||
@@ -437,18 +499,10 @@ export class KunaProjectCache {
|
||||
retainStaging: false,
|
||||
}));
|
||||
if (built.retainStaging) return built.result;
|
||||
let cleanupPath = staging;
|
||||
try {
|
||||
// Only a proven-stopped build may enter trash. A failed write, crash or
|
||||
// lost cleanup identity therefore never makes live staging reclaimable.
|
||||
const retired = join(this.#root, "trash", `build-${randomUUID()}`);
|
||||
await rename(staging, retired);
|
||||
cleanupPath = retired;
|
||||
await this.#removeRetired(retired);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ProviderCleanupError) return err(cause);
|
||||
return err(this.#retainFileCleanup(cleanupPath, cause));
|
||||
}
|
||||
// Only a proven-stopped build may enter trash. A failed write, crash or
|
||||
// lost cleanup identity therefore never makes live staging reclaimable.
|
||||
const retired = await this.#retireStaging(staging, key);
|
||||
if (retired !== undefined) return err(retired);
|
||||
if (built.result.ok)
|
||||
// The entry is published either way; a failed eviction retries on the next publish.
|
||||
await this.#evict(key).catch(() => undefined);
|
||||
@@ -473,7 +527,12 @@ export class KunaProjectCache {
|
||||
launcher: this.#launcher,
|
||||
});
|
||||
if (outcome.kind === "retained") {
|
||||
this.#retainedProcesses.push(outcome.error);
|
||||
this.#retainedProcesses.push({
|
||||
key,
|
||||
error: outcome.error,
|
||||
owner: outcome.owner,
|
||||
staging,
|
||||
});
|
||||
return { result: err(outcome.error), retainStaging: true };
|
||||
}
|
||||
return {
|
||||
@@ -643,16 +702,16 @@ export class KunaProjectCache {
|
||||
if (isMissing(cause)) return;
|
||||
throw cause;
|
||||
}
|
||||
await this.#removeRetired(trash);
|
||||
await this.#removeRetired(trash, key);
|
||||
}
|
||||
|
||||
/** Keep each failed removal owned until that path is successfully removed. */
|
||||
async #removeRetired(path: string): Promise<void> {
|
||||
async #removeRetired(path: string, key?: string): Promise<void> {
|
||||
try {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
this.#retainedFiles.delete(path);
|
||||
} catch (cause: unknown) {
|
||||
throw this.#retainFileCleanup(path, cause);
|
||||
throw this.#retainFileCleanup(path, cause, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,14 +736,42 @@ export class KunaProjectCache {
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
|
||||
#retainFileCleanup(path: string, cause: unknown): ProviderCleanupError {
|
||||
#retainFileCleanup(
|
||||
path: string,
|
||||
cause: unknown,
|
||||
key?: string,
|
||||
): ProviderCleanupError {
|
||||
const error = new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
[path],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{ operation: KUNA_EXPORT_OPERATION, cause },
|
||||
);
|
||||
this.#retainedFiles.set(path, error);
|
||||
this.#retainedFiles.set(path, {
|
||||
key: key ?? this.#retainedFiles.get(path)?.key ?? null,
|
||||
error,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
/** One cleanup error aggregating what remains owned, or success when nothing does. */
|
||||
const cleanupResult = (
|
||||
remaining: readonly ProviderCleanupError[],
|
||||
): Result<null, ProviderCleanupError> => {
|
||||
if (remaining.length === 0) return ok(null);
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
remaining.flatMap((entry) => entry.cleanupResources),
|
||||
{
|
||||
reason: remaining
|
||||
.map((entry) =>
|
||||
String(entry.diagnostics?.["reason"] ?? entry.message),
|
||||
)
|
||||
.join("; "),
|
||||
},
|
||||
{ operation: KUNA_EXPORT_OPERATION },
|
||||
),
|
||||
);
|
||||
};
|
||||
+45
-189
@@ -1,35 +1,26 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
ProviderProcessSnapshot,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
import {
|
||||
kunaChildEnvironment,
|
||||
type KunaInstallation,
|
||||
} from "./KunaInstallation.js";
|
||||
copyAndHash,
|
||||
KUNA_INVENTORY_OUTPUT_BYTES,
|
||||
kunaInventoryArguments,
|
||||
runKunaCommand,
|
||||
type KunaProcessLauncher,
|
||||
} from "./KunaCommand.js";
|
||||
import type { KunaInstallation } from "./KunaInstallation.js";
|
||||
import {
|
||||
parseKunaExportSummary,
|
||||
parseKunaInventory,
|
||||
@@ -46,12 +37,6 @@ export const KUNA_EXPORT_OPERATION = "kuna:decompile-project";
|
||||
/** Kuna prints one summary line per export; notes on a large image stay far below this. */
|
||||
const DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* `kuna functions --json` costs ~210 bytes per function; this admits about
|
||||
* 600,000 functions, above the 392,814 of the largest image Kuna documents.
|
||||
*/
|
||||
const INVENTORY_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
const INVENTORY_FILE = "functions.json";
|
||||
|
||||
/** Identity of one file an export consists of. */
|
||||
@@ -87,11 +72,6 @@ export interface KunaExportStatus {
|
||||
readonly inventoryFunctions: number | null;
|
||||
}
|
||||
|
||||
/** Test seam for the owned process launcher; production uses the default. */
|
||||
export type KunaProcessLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/**
|
||||
* How an export ended. `retained` means an owned Kuna process could not be
|
||||
* proven stopped, so it may still write into the workspace, which therefore
|
||||
@@ -102,7 +82,11 @@ export type KunaExportOutcome =
|
||||
readonly kind: "done";
|
||||
readonly result: Result<KunaExport, AnalysisError>;
|
||||
}
|
||||
| { readonly kind: "retained"; readonly error: ProviderCleanupError };
|
||||
| {
|
||||
readonly kind: "retained";
|
||||
readonly error: ProviderCleanupError;
|
||||
readonly owner: ProviderProcessSupervisor | undefined;
|
||||
};
|
||||
|
||||
/** One export of a target into a private workspace. */
|
||||
export interface KunaExportRequest {
|
||||
@@ -197,11 +181,38 @@ export const exportKunaProject = async (
|
||||
),
|
||||
};
|
||||
await prepareProcessOwnershipInspection(signal);
|
||||
const run = (arguments_: readonly string[], diagnosticBytes: number) =>
|
||||
runKuna(request, arguments_, { deadline, diagnosticBytes });
|
||||
const run = async (arguments_: readonly string[], outputBytes: number) => {
|
||||
const outcome = await runKunaCommand({
|
||||
installation,
|
||||
arguments: arguments_,
|
||||
workspace: request.workspace,
|
||||
operation: KUNA_EXPORT_OPERATION,
|
||||
timeoutMs: deadline - Date.now(),
|
||||
reportedTimeoutMs: configuration.projectTimeoutMs,
|
||||
outputBytes,
|
||||
signal,
|
||||
launcher: request.launcher,
|
||||
});
|
||||
if (outcome.kind === "ok")
|
||||
return { ok: true as const, snapshot: outcome.snapshot };
|
||||
return {
|
||||
ok: false as const,
|
||||
outcome:
|
||||
outcome.kind === "retained"
|
||||
? ({
|
||||
kind: "retained",
|
||||
error: outcome.error,
|
||||
owner: outcome.owner,
|
||||
} as const)
|
||||
: ({ kind: "done", result: err(outcome.error) } as const),
|
||||
};
|
||||
};
|
||||
|
||||
request.onStatus({ phase: "inventory", inventoryFunctions: null });
|
||||
const listed = await run(["functions", input, "--json"], INVENTORY_BYTES);
|
||||
const listed = await run(
|
||||
kunaInventoryArguments(input, configuration.jobs),
|
||||
KUNA_INVENTORY_OUTPUT_BYTES,
|
||||
);
|
||||
if (!listed.ok) return listed.outcome;
|
||||
const inventory = parseKunaInventory(listed.snapshot.stdout.text);
|
||||
if (!inventory.ok) return unusable(inventory.error);
|
||||
@@ -309,158 +320,3 @@ export const readKunaExport = async (
|
||||
if (!inventory.ok) return inventory;
|
||||
return ok({ functions: functions.value, inventory: inventory.value });
|
||||
};
|
||||
|
||||
const runKuna = async (
|
||||
request: KunaExportRequest,
|
||||
arguments_: readonly string[],
|
||||
limits: { readonly deadline: number; readonly diagnosticBytes: number },
|
||||
): Promise<
|
||||
| { readonly ok: true; readonly snapshot: ProviderProcessSnapshot }
|
||||
| { readonly ok: false; readonly outcome: KunaExportOutcome }
|
||||
> => {
|
||||
const { installation, signal } = request;
|
||||
try {
|
||||
const identity = await verifyKunaExecutable(installation, signal);
|
||||
if (!identity.ok)
|
||||
return { ok: false, outcome: { kind: "done", result: identity } };
|
||||
const snapshot = await runOwnedCommand(
|
||||
{
|
||||
command: installation.executable,
|
||||
arguments: arguments_,
|
||||
runId: `rea-kuna-${randomUUID()}`,
|
||||
// A script's process reports its interpreter, not the configured path.
|
||||
...(installation.script ? { expectedCommand: null } : {}),
|
||||
cwd: request.workspace,
|
||||
hostEnvironment: kunaChildEnvironment(installation.configuration),
|
||||
env: { TMPDIR: request.workspace },
|
||||
},
|
||||
{
|
||||
timeoutMs: Math.max(1, limits.deadline - Date.now()),
|
||||
diagnosticBytes: limits.diagnosticBytes,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
...(request.launcher === undefined
|
||||
? {}
|
||||
: { launcher: request.launcher }),
|
||||
},
|
||||
);
|
||||
return { ok: true, snapshot };
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
outcome: commandFailure(cause, request, arguments_[0] ?? "", limits),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** Copy the target into the workspace and return the SHA-256 of exactly the bytes copied. */
|
||||
const copyAndHash = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
await pipeline(
|
||||
createReadStream(source),
|
||||
new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
}),
|
||||
createWriteStream(destination, { flags: "wx", mode: 0o600 }),
|
||||
{ signal },
|
||||
);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
/** Map an owned-command failure to its typed error, keeping unproven cleanup owned. */
|
||||
const commandFailure = (
|
||||
cause: unknown,
|
||||
request: KunaExportRequest,
|
||||
subcommand: string,
|
||||
limits: { readonly diagnosticBytes: number },
|
||||
): KunaExportOutcome => {
|
||||
const { installation, signal } = request;
|
||||
if (!(cause instanceof OwnedCommandFailure))
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
signal.aborted
|
||||
? new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause })
|
||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
|
||||
cause,
|
||||
userMessage: `Cannot run ${installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
),
|
||||
};
|
||||
const snapshot = cause.snapshot;
|
||||
const capturedOutput =
|
||||
snapshot === null
|
||||
? undefined
|
||||
: {
|
||||
// An inventory is evidence-sized, not diagnostic-sized; keep only its tail.
|
||||
stdout: snapshot.stdout.text.slice(-4096),
|
||||
stderr: snapshot.stderr.text,
|
||||
truncated:
|
||||
snapshot.diagnosticTruncated === true ||
|
||||
snapshot.outputIncomplete === true ||
|
||||
snapshot.stdout.text.length > 4096,
|
||||
exit_code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal ?? null,
|
||||
};
|
||||
const details = {
|
||||
cause,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
};
|
||||
if (cause.cleanupFailure !== null)
|
||||
return {
|
||||
kind: "retained",
|
||||
error: new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
[...cause.resources, request.workspace],
|
||||
{ reason: cause.cleanupFailure },
|
||||
{ operation: KUNA_EXPORT_OPERATION, cause },
|
||||
),
|
||||
};
|
||||
if (cause.reason === "cancelled")
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION, details)),
|
||||
};
|
||||
if (cause.reason === "timeout")
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
new AnalysisTimeoutError(
|
||||
KUNA_EXPORT_OPERATION,
|
||||
installation.configuration.projectTimeoutMs,
|
||||
details,
|
||||
),
|
||||
),
|
||||
};
|
||||
if (cause.reason === "output-limit")
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
new AnalysisResourceConstraintError(
|
||||
KUNA_EXPORT_OPERATION,
|
||||
"capacity",
|
||||
`kuna ${subcommand} wrote more than ${String(limits.diagnosticBytes)} bytes of output; the export was not retained.`,
|
||||
{ output_limit_bytes: limits.diagnosticBytes },
|
||||
details,
|
||||
),
|
||||
),
|
||||
};
|
||||
const exitCode = capturedOutput?.exit_code;
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
|
||||
...details,
|
||||
userMessage: `kuna ${subcommand} failed${exitCode === undefined || exitCode === null ? "" : ` with exit code ${String(exitCode)}`}: ${capturedOutput?.stderr.trim().split("\n").at(-1) ?? cause.message}`,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
+141
-45
@@ -41,50 +41,37 @@ export const parseKunaExportSummary = (
|
||||
return { decompiled: Number(match[1]), failed: Number(match[2]) };
|
||||
};
|
||||
|
||||
/** A header line longer than this is not one Kuna wrote. */
|
||||
const MAX_HEADER_BYTES = 64 * 1024;
|
||||
const PREFIX_BYTES = Buffer.from(HEADER_PREFIX);
|
||||
/** A structural line longer than this is not one Kuna wrote. */
|
||||
const MAX_KEPT_LINE_BYTES = 64 * 1024;
|
||||
|
||||
interface Header {
|
||||
readonly name: string;
|
||||
readonly address: string;
|
||||
readonly error: string | null;
|
||||
/** One line a scan kept: where it starts in the file and its text. */
|
||||
interface KeptLine {
|
||||
readonly offset: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a finished export's `.c` into its function blocks, reading it as a
|
||||
* stream so a very large export is never held in memory.
|
||||
*
|
||||
* The `#include` preamble precedes the first header. The header count must
|
||||
* equal the functions Kuna reported, and every address must be distinct; an
|
||||
* export that fails either check is not a usable function inventory.
|
||||
* Stream a file's lines, keeping the text of only the lines whose first
|
||||
* `headBytes` bytes `keep` accepts. Every other line is skipped without being
|
||||
* buffered, so a large export is never held in memory.
|
||||
*/
|
||||
export const scanKunaProjectSource = async (
|
||||
const scanLines = async (
|
||||
chunks: AsyncIterable<Buffer>,
|
||||
summary: KunaExportSummary,
|
||||
): Promise<Result<readonly KunaProjectFunction[], string>> => {
|
||||
const headers: Header[] = [];
|
||||
headBytes: number,
|
||||
keep: (head: Buffer) => boolean,
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly lines: readonly KeptLine[]; readonly bytes: number },
|
||||
string
|
||||
>
|
||||
> => {
|
||||
const lines: KeptLine[] = [];
|
||||
let offset = 0;
|
||||
let lineStart = 0;
|
||||
// Bytes of the current line, kept only while it may still be a header.
|
||||
// The current line's bytes while it is undecided or kept; undefined once skipped.
|
||||
let line: Buffer[] | undefined = [];
|
||||
let lineBytes = 0;
|
||||
const endLine = (): string | undefined => {
|
||||
if (line === undefined) return undefined;
|
||||
const text = Buffer.concat(line).toString("utf8");
|
||||
if (!text.startsWith(HEADER_PREFIX)) return undefined;
|
||||
const match = HEADER.exec(text);
|
||||
if (match?.[1] === undefined || match[2] === undefined)
|
||||
return `unrecognized function header at byte ${String(lineStart)}`;
|
||||
headers.push({
|
||||
name: match[1],
|
||||
address: match[2],
|
||||
error: match[3] ?? null,
|
||||
offset: lineStart,
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
let decided = false;
|
||||
for await (const chunk of chunks) {
|
||||
let position = 0;
|
||||
while (position < chunk.length) {
|
||||
@@ -94,27 +81,71 @@ export const scanKunaProjectSource = async (
|
||||
const segment = chunk.subarray(position, end);
|
||||
line.push(segment);
|
||||
lineBytes += segment.length;
|
||||
const seen = Buffer.concat(line).subarray(0, PREFIX_BYTES.length);
|
||||
if (!PREFIX_BYTES.subarray(0, seen.length).equals(seen))
|
||||
line = undefined;
|
||||
else if (lineBytes > MAX_HEADER_BYTES)
|
||||
return err(
|
||||
`function header at byte ${String(lineStart)} is too long`,
|
||||
);
|
||||
if (!decided && (lineBytes >= headBytes || newline !== -1)) {
|
||||
decided = true;
|
||||
if (!keep(Buffer.concat(line).subarray(0, headBytes)))
|
||||
line = undefined;
|
||||
}
|
||||
if (line !== undefined && lineBytes > MAX_KEPT_LINE_BYTES)
|
||||
return err(`the line at byte ${String(lineStart)} is too long`);
|
||||
}
|
||||
if (newline === -1) break;
|
||||
const failure = endLine();
|
||||
if (failure !== undefined) return err(failure);
|
||||
if (line !== undefined)
|
||||
lines.push({
|
||||
offset: lineStart,
|
||||
text: Buffer.concat(line).toString("utf8"),
|
||||
});
|
||||
position = newline + 1;
|
||||
lineStart = offset + position;
|
||||
line = [];
|
||||
lineBytes = 0;
|
||||
decided = false;
|
||||
}
|
||||
offset += chunk.length;
|
||||
}
|
||||
const failure = endLine();
|
||||
if (failure !== undefined) return err(failure);
|
||||
const total = offset;
|
||||
if (line !== undefined && lineBytes > 0) {
|
||||
const head = Buffer.concat(line);
|
||||
if (decided || keep(head.subarray(0, headBytes)))
|
||||
lines.push({ offset: lineStart, text: head.toString("utf8") });
|
||||
}
|
||||
return ok({ lines, bytes: offset });
|
||||
};
|
||||
|
||||
const HEADER_PREFIX_BYTES = Buffer.from(HEADER_PREFIX);
|
||||
|
||||
/**
|
||||
* Split a finished export's `.c` into its function blocks.
|
||||
*
|
||||
* The `#include` preamble precedes the first header. The header count must
|
||||
* equal the functions Kuna reported, and every address must be distinct; an
|
||||
* export that fails either check is not a usable function inventory.
|
||||
*/
|
||||
export const scanKunaProjectSource = async (
|
||||
chunks: AsyncIterable<Buffer>,
|
||||
summary: KunaExportSummary,
|
||||
): Promise<Result<readonly KunaProjectFunction[], string>> => {
|
||||
const scanned = await scanLines(chunks, HEADER_PREFIX_BYTES.length, (head) =>
|
||||
head.equals(HEADER_PREFIX_BYTES),
|
||||
);
|
||||
if (!scanned.ok) return scanned;
|
||||
const headers: {
|
||||
readonly name: string;
|
||||
readonly address: string;
|
||||
readonly error: string | null;
|
||||
readonly offset: number;
|
||||
}[] = [];
|
||||
for (const { offset, text } of scanned.value.lines) {
|
||||
const match = HEADER.exec(text);
|
||||
if (match?.[1] === undefined || match[2] === undefined)
|
||||
return err(`unrecognized function header at byte ${String(offset)}`);
|
||||
headers.push({
|
||||
name: match[1],
|
||||
address: match[2],
|
||||
error: match[3] ?? null,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
const total = scanned.value.bytes;
|
||||
const expected = summary.decompiled + summary.failed;
|
||||
if (headers.length !== expected)
|
||||
return err(
|
||||
@@ -187,3 +218,68 @@ export const parseKunaInventory = (
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
/** One labeled span of an export's `.asm`. */
|
||||
export interface KunaAssemblySpan {
|
||||
readonly name: string;
|
||||
readonly address: string;
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
/** Function listings and data labels found in an export's `.asm`. */
|
||||
export interface KunaAssemblyIndex {
|
||||
/** Code labels, which name the same functions as the `.c`, by address. */
|
||||
readonly code: ReadonlyMap<string, KunaAssemblySpan>;
|
||||
/** Labels in the `; --- data ---` tail: named globals and Kuna's `dat_`/`s_` names. */
|
||||
readonly data: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
// Rendered by kuna-console: `{name}: ; 0x{addr:x}` at column 0, optionally
|
||||
// followed by ` = dat_{addr:x}` in the data tail. A code label's listing runs
|
||||
// to the next label, the next `; --- ` section marker, or the end of the file.
|
||||
const LABEL = /^(.+): ; (0x[0-9a-f]+)(?: = dat_[0-9a-f]+)?$/u;
|
||||
const SECTION_MARKER = "; --- ";
|
||||
const DATA_MARKER = "; --- data ---";
|
||||
const SPACE = 0x20;
|
||||
const TAB = 0x09;
|
||||
|
||||
/** Index the labels of an export's `.asm` without holding the file in memory. */
|
||||
export const scanKunaAssembly = async (
|
||||
chunks: AsyncIterable<Buffer>,
|
||||
): Promise<Result<KunaAssemblyIndex, string>> => {
|
||||
const scanned = await scanLines(
|
||||
chunks,
|
||||
1,
|
||||
(head) => head[0] !== SPACE && head[0] !== TAB && head.length > 0,
|
||||
);
|
||||
if (!scanned.ok) return scanned;
|
||||
const code = new Map<string, KunaAssemblySpan>();
|
||||
const data = new Map<string, string>();
|
||||
let inData = false;
|
||||
let open: { name: string; address: string; offset: number } | undefined;
|
||||
const close = (end: number) => {
|
||||
if (open === undefined) return;
|
||||
code.set(open.address, { ...open, length: end - open.offset });
|
||||
open = undefined;
|
||||
};
|
||||
for (const { offset, text } of scanned.value.lines) {
|
||||
if (text.startsWith(SECTION_MARKER)) {
|
||||
close(offset);
|
||||
if (text.startsWith(DATA_MARKER)) inData = true;
|
||||
continue;
|
||||
}
|
||||
const match = LABEL.exec(text);
|
||||
if (match?.[1] === undefined || match[2] === undefined) continue;
|
||||
if (inData) {
|
||||
if (!data.has(match[2])) data.set(match[2], match[1]);
|
||||
continue;
|
||||
}
|
||||
close(offset);
|
||||
if (code.has(match[2]))
|
||||
return err(`the .asm repeats code address ${match[2]}`);
|
||||
open = { name: match[1], address: match[2], offset };
|
||||
}
|
||||
close(scanned.value.bytes);
|
||||
return ok({ code, data });
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import type { KunaProject } from "./KunaProjectCache.js";
|
||||
import {
|
||||
scanKunaAssembly,
|
||||
type KunaAssemblyIndex,
|
||||
type KunaProjectFunction,
|
||||
} from "./KunaProjectFiles.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js";
|
||||
|
||||
/** The exact producing range for one facet, including a failed C block. */
|
||||
export interface KunaProjectFileSpan {
|
||||
readonly facet: "pseudocode" | "assembly";
|
||||
readonly path: string;
|
||||
readonly byteOffset: number;
|
||||
readonly byteLength: number;
|
||||
}
|
||||
|
||||
export interface KunaProjectText {
|
||||
readonly result: string | null;
|
||||
readonly files?: readonly KunaProjectFileSpan[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
const readSpan = async (
|
||||
operation: string,
|
||||
path: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
): Promise<Result<string, AnalysisError>> => {
|
||||
if (length > KUNA_FUNCTION_TEXT_BYTES)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"capacity",
|
||||
"Kuna's function text exceeds the buffered source-facet capacity; the complete export remains accessible locally.",
|
||||
{
|
||||
limit_bytes: KUNA_FUNCTION_TEXT_BYTES,
|
||||
requested_bytes: length,
|
||||
file: path,
|
||||
byte_offset: offset,
|
||||
},
|
||||
),
|
||||
);
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
||||
if (bytesRead !== length)
|
||||
throw new Error(`${path} is shorter than its recorded span`);
|
||||
return ok(buffer.toString("utf8"));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
/** One immutable export owns both its text ranges and its parsed label indexes. */
|
||||
export class KunaProjectReader {
|
||||
#blocks: ReadonlyMap<string, KunaProjectFunction> | undefined;
|
||||
#assembly: Promise<Result<KunaAssemblyIndex, string>> | undefined;
|
||||
|
||||
constructor(
|
||||
readonly project: KunaProject,
|
||||
readonly lifetime: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly own: <T>(work: Promise<T>) => Promise<T>;
|
||||
},
|
||||
) {}
|
||||
|
||||
async pseudocode(
|
||||
address: string,
|
||||
operation: string,
|
||||
): Promise<Result<KunaProjectText, AnalysisError>> {
|
||||
this.#blocks ??= new Map(
|
||||
this.project.functions.map((block) => [block.address, block]),
|
||||
);
|
||||
const block = this.#blocks.get(address);
|
||||
if (block === undefined)
|
||||
return ok({
|
||||
result: null,
|
||||
limitations: [
|
||||
"Kuna's export decompiles code entries only; this inventory entry, such as an import slot, has no body.",
|
||||
],
|
||||
});
|
||||
const files = [
|
||||
{
|
||||
facet: "pseudocode" as const,
|
||||
path: this.project.manifest.files.c.path,
|
||||
byteOffset: block.cOffset,
|
||||
byteLength: block.cLength,
|
||||
},
|
||||
];
|
||||
if (block.error !== null)
|
||||
return ok({
|
||||
result: null,
|
||||
files,
|
||||
limitations: [`Kuna could not decompile this function: ${block.error}`],
|
||||
});
|
||||
const text = await readSpan(
|
||||
operation,
|
||||
join(this.project.directory, this.project.manifest.files.c.path),
|
||||
block.cOffset,
|
||||
block.cLength,
|
||||
);
|
||||
if (!text.ok) return text;
|
||||
return ok({
|
||||
result: text.value
|
||||
.slice(text.value.indexOf("\n") + 1)
|
||||
.replace(/\n+$/u, ""),
|
||||
files,
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
|
||||
async assembly(
|
||||
address: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<KunaProjectText, AnalysisError>> {
|
||||
const index = await this.#index(operation, signal);
|
||||
if (!index.ok) return index;
|
||||
const span = index.value.code.get(address);
|
||||
if (span === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
operation,
|
||||
`Kuna's export lists no code at ${address}; import slots and data entries have no listing.`,
|
||||
),
|
||||
);
|
||||
const text = await readSpan(
|
||||
operation,
|
||||
join(this.project.directory, this.project.manifest.files.asm.path),
|
||||
span.offset,
|
||||
span.length,
|
||||
);
|
||||
if (!text.ok) return text;
|
||||
return ok({
|
||||
result: text.value.replace(/\n+$/u, ""),
|
||||
files: [
|
||||
{
|
||||
facet: "assembly",
|
||||
path: this.project.manifest.files.asm.path,
|
||||
byteOffset: span.offset,
|
||||
byteLength: span.length,
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
|
||||
async dataName(
|
||||
address: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<string | null, AnalysisError>> {
|
||||
const index = await this.#index(operation, signal);
|
||||
return index.ok ? ok(index.value.data.get(address) ?? null) : index;
|
||||
}
|
||||
|
||||
async #index(
|
||||
operation: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<KunaAssemblyIndex, AnalysisError>> {
|
||||
this.#assembly ??= this.lifetime.own(
|
||||
scanKunaAssembly(
|
||||
createReadStream(
|
||||
join(this.project.directory, this.project.manifest.files.asm.path),
|
||||
{ signal: this.lifetime.signal },
|
||||
),
|
||||
),
|
||||
);
|
||||
const index = await waitForAbortable(this.#assembly, signal);
|
||||
if (index === ABORTED) return err(new AnalysisCancelledError(operation));
|
||||
return index.ok
|
||||
? index
|
||||
: err(
|
||||
new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
userMessage: `Kuna's .asm cannot be indexed: ${index.error}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { accessSync, constants, statSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProviderCandidate,
|
||||
ProviderAvailability,
|
||||
ProviderTargetSupport,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
createAnalysisProfile,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import type { KunaProcessLauncher } from "./KunaCommand.js";
|
||||
import {
|
||||
readKunaConfiguration,
|
||||
type KunaConfiguration,
|
||||
} from "./KunaConfiguration.js";
|
||||
import { inspectKunaInstallation } from "./KunaInstallation.js";
|
||||
import { KunaProjectCache } from "./KunaProjectCache.js";
|
||||
import {
|
||||
KUNA_PROVIDER_IDENTITY,
|
||||
kunaCapabilities,
|
||||
} from "./KunaProviderCapabilities.js";
|
||||
import { KUNA_COMMAND_REMEDIATION, KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import { KunaRunSlots } from "./KunaRunSlots.js";
|
||||
import { KunaSessionClient } from "./KunaSessionClient.js";
|
||||
|
||||
/** Architectures REA verified Kuna on: x86-64 and AArch64 ELF and Mach-O, plus ARM Thumb. */
|
||||
const VERIFIED_ARCHITECTURES: ReadonlySet<string> = new Set([
|
||||
"x86_64",
|
||||
"arm64",
|
||||
"arm",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Bring-your-own Kuna candidate. Discovery reads configuration and the
|
||||
* executable's metadata only; the version probe runs when a target is opened.
|
||||
*/
|
||||
export class KunaProvider implements AnalysisProviderCandidate {
|
||||
readonly #environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly #platform: NodeJS.Platform;
|
||||
readonly #launcher: KunaProcessLauncher | undefined;
|
||||
readonly #singleFunctionRuns: number | undefined;
|
||||
#cache: KunaProjectCache | undefined;
|
||||
#runSlots: KunaRunSlots | undefined;
|
||||
|
||||
constructor(
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
options: {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly launcher?: KunaProcessLauncher;
|
||||
/** Test seam for concurrent single-function runs; production derives it from the host. */
|
||||
readonly singleFunctionRuns?: number;
|
||||
} = {},
|
||||
) {
|
||||
this.#platform = options.platform ?? process.platform;
|
||||
this.#environment = snapshotEnvironment(environment, this.#platform);
|
||||
this.#launcher = options.launcher;
|
||||
this.#singleFunctionRuns = options.singleFunctionRuns;
|
||||
}
|
||||
|
||||
identity() {
|
||||
return KUNA_PROVIDER_IDENTITY;
|
||||
}
|
||||
|
||||
capabilities() {
|
||||
const availability = this.inspectAvailability();
|
||||
return kunaCapabilities(
|
||||
availability.status === "available"
|
||||
? { available: true }
|
||||
: {
|
||||
available: false,
|
||||
reason: availability.reason,
|
||||
code: availability.code,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
inspectAvailability(): ProviderAvailability {
|
||||
const configuration = readKunaConfiguration(
|
||||
this.#environment,
|
||||
this.#platform,
|
||||
);
|
||||
if (!configuration.ok)
|
||||
return {
|
||||
status: "unavailable",
|
||||
code: configuration.error.code,
|
||||
reason: configuration.error.reason,
|
||||
diagnostics: {
|
||||
configured: configuration.error.code !== "not_configured",
|
||||
version_probed: false,
|
||||
},
|
||||
};
|
||||
const { command } = configuration.value;
|
||||
try {
|
||||
accessSync(command, constants.X_OK);
|
||||
if (!statSync(command).isFile()) throw new Error("not a regular file");
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
code: "executable_missing",
|
||||
reason: `Cannot execute REA_KUNA_COMMAND ${command}: ${cause instanceof Error ? cause.message : String(cause)}. ${KUNA_COMMAND_REMEDIATION}`,
|
||||
diagnostics: { configured: true, command, version_probed: false },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {
|
||||
configured: true,
|
||||
command,
|
||||
cache_root: configuration.value.cacheRoot,
|
||||
jobs: configuration.value.jobs,
|
||||
version_probed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport {
|
||||
const diagnostics = {
|
||||
target_kind: target.kind,
|
||||
target_format: target.format,
|
||||
};
|
||||
if (target.kind !== "executable")
|
||||
return {
|
||||
status: "unsupported",
|
||||
code: "target_kind_unsupported",
|
||||
reason: "Kuna decompiles executable images, not databases or archives.",
|
||||
diagnostics,
|
||||
};
|
||||
if (target.format !== "elf" && target.format !== "mach-o")
|
||||
return {
|
||||
status: "unsupported",
|
||||
code: "target_format_unsupported",
|
||||
reason:
|
||||
target.format === "mach-o-universal"
|
||||
? "REA's Kuna provider has no verified universal Mach-O slice selection yet; open a thin slice or use another provider."
|
||||
: `REA verified the Kuna provider on ELF and thin Mach-O images; ${target.format} is not admitted yet.`,
|
||||
diagnostics,
|
||||
};
|
||||
if (!VERIFIED_ARCHITECTURES.has(target.architecture))
|
||||
return {
|
||||
status: "unsupported",
|
||||
code: "architecture_unsupported",
|
||||
reason: `REA verified the Kuna provider on x86-64, AArch64 and ARM; ${target.architecture} is not admitted yet.`,
|
||||
diagnostics: { ...diagnostics, architecture: target.architecture },
|
||||
};
|
||||
return { status: "supported", code: null, reason: null, diagnostics };
|
||||
}
|
||||
|
||||
async resolveAnalysisProfile(
|
||||
_target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("kuna:profile"));
|
||||
const installation = await inspectKunaInstallation(this.#environment, {
|
||||
platform: this.#platform,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
if (!installation.ok)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
"open_binary",
|
||||
installation.error.reason,
|
||||
{ userMessage: installation.error.reason },
|
||||
),
|
||||
);
|
||||
const { value } = installation;
|
||||
return ok({
|
||||
profile: createAnalysisProfile(
|
||||
{ ...KUNA_PROVIDER_IDENTITY, version: value.version },
|
||||
{
|
||||
kuna_version: value.version,
|
||||
kuna_sha256: value.sha256,
|
||||
verified_release: value.verified,
|
||||
jobs: value.configuration.jobs,
|
||||
export: "decompile-project",
|
||||
cache_policy: "live",
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
): AnalysisClient {
|
||||
const configuration = readKunaConfiguration(
|
||||
this.#environment,
|
||||
this.#platform,
|
||||
);
|
||||
if (!configuration.ok)
|
||||
return {
|
||||
execute: async (operation) =>
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
operation,
|
||||
configuration.error.reason,
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
};
|
||||
return new KunaSessionClient(target, profile, {
|
||||
cache: this.#projectCache(configuration.value),
|
||||
runSlots: this.#singleFunctionSlots(configuration.value),
|
||||
environment: this.#environment,
|
||||
...(this.#launcher === undefined ? {} : { launcher: this.#launcher }),
|
||||
});
|
||||
}
|
||||
|
||||
/** One cache per provider, so sessions of one server share exports in flight. */
|
||||
#projectCache(configuration: KunaConfiguration): KunaProjectCache {
|
||||
this.#cache ??= new KunaProjectCache(configuration.cacheRoot, {
|
||||
maxBytes: configuration.cacheMaxBytes,
|
||||
...(this.#launcher === undefined ? {} : { launcher: this.#launcher }),
|
||||
});
|
||||
return this.#cache;
|
||||
}
|
||||
|
||||
/** One admission pool per provider, so concurrent sessions share the host's cores. */
|
||||
#singleFunctionSlots(configuration: KunaConfiguration): KunaRunSlots {
|
||||
this.#runSlots ??=
|
||||
this.#singleFunctionRuns === undefined
|
||||
? KunaRunSlots.forHost(configuration.jobs)
|
||||
: new KunaRunSlots(this.#singleFunctionRuns);
|
||||
return this.#runSlots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import { REGEX_MATCHING_LIMITATION } from "../process/RegexMatcher.js";
|
||||
import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js";
|
||||
|
||||
/** Static identity; the observed Kuna release is reported in each analysis profile. */
|
||||
export const KUNA_PROVIDER_IDENTITY = Object.freeze({
|
||||
id: KUNA_PROVIDER_ID,
|
||||
name: "Kuna",
|
||||
version: null,
|
||||
});
|
||||
|
||||
/** Analyst outcomes Kuna's inventory and project export can answer. */
|
||||
export const KUNA_OPERATIONS = Object.freeze([
|
||||
"list_documents",
|
||||
"list_procedures",
|
||||
"search_procedures",
|
||||
"procedure_address",
|
||||
"address_name",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_assembly",
|
||||
"analyze_function",
|
||||
] as const satisfies readonly Exclude<AnalysisOperation, "health">[]);
|
||||
export type KunaOperation = (typeof KUNA_OPERATIONS)[number];
|
||||
|
||||
/** Whether an operation is one Kuna answers. */
|
||||
export const isKunaOperation = (
|
||||
operation: AnalysisOperation,
|
||||
): operation is KunaOperation =>
|
||||
(KUNA_OPERATIONS as readonly string[]).includes(operation);
|
||||
|
||||
/** How to read every Kuna answer. */
|
||||
const KUNA_PROVENANCE =
|
||||
"Kuna static analysis: addresses and bytes are its observations, procedure entries and extents its derivations, and pseudocode its reconstruction, never original source.";
|
||||
|
||||
/** Entries come from Kuna's own discovery. */
|
||||
const KUNA_ENTRY_LIMITATION =
|
||||
"Kuna may split one function at an entry its own analysis infers; entry extents are upper bounds.";
|
||||
|
||||
/** Provider text is not comparable across providers. */
|
||||
const KUNA_TEXT_LIMITATION =
|
||||
"Pseudocode and assembly are exact Kuna text, not comparable as equal or different with another provider's text.";
|
||||
const KUNA_TEXT_CAPACITY_LIMITATION = `Each buffered source facet admits at most ${String(KUNA_FUNCTION_TEXT_BYTES)} bytes before allocation; larger text stays accessible in the local project export.`;
|
||||
|
||||
/** The limitations that apply to one operation's answer, and only those. */
|
||||
export const kunaLimitations = (
|
||||
operation: KunaOperation,
|
||||
): readonly string[] => {
|
||||
switch (operation) {
|
||||
case "list_documents":
|
||||
return [];
|
||||
case "list_procedures":
|
||||
case "procedure_address":
|
||||
case "address_name":
|
||||
return [KUNA_PROVENANCE, KUNA_ENTRY_LIMITATION];
|
||||
case "search_procedures":
|
||||
return [
|
||||
KUNA_PROVENANCE,
|
||||
KUNA_ENTRY_LIMITATION,
|
||||
REGEX_MATCHING_LIMITATION,
|
||||
];
|
||||
case "procedure_pseudo_code":
|
||||
case "procedure_assembly":
|
||||
case "analyze_function":
|
||||
return [
|
||||
KUNA_PROVENANCE,
|
||||
KUNA_TEXT_LIMITATION,
|
||||
KUNA_TEXT_CAPACITY_LIMITATION,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
/** Pseudocode answered before the project export finishes differs from the export's. */
|
||||
export const KUNA_SINGLE_FUNCTION_LIMITATION =
|
||||
"This pseudocode came from a single-function `kuna decompile` run while the project export was still building. It lacks the export's whole-program prototypes, and a later request for the same procedure can return the export's text.";
|
||||
|
||||
/** Advertise Kuna's static, process-launching operations truthfully. */
|
||||
export const kunaCapabilities = (
|
||||
availability:
|
||||
| { readonly available: true }
|
||||
| {
|
||||
readonly available: false;
|
||||
readonly reason: string;
|
||||
readonly code: ProviderRejectionCode;
|
||||
},
|
||||
): readonly CapabilityDescriptor[] =>
|
||||
KUNA_OPERATIONS.map((operation) => ({
|
||||
provider: KUNA_PROVIDER_IDENTITY,
|
||||
operation,
|
||||
...(availability.available
|
||||
? ({ available: true, reason: null } as const)
|
||||
: ({
|
||||
available: false,
|
||||
reason: availability.reason,
|
||||
availabilityCode: availability.code,
|
||||
} as const)),
|
||||
// A cold answer can come from the single-function path and differ from the
|
||||
// export's later answer, so results are re-read rather than replayed. The
|
||||
// project cache already serves warm reads in milliseconds.
|
||||
cachePolicy: "live",
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: true,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: true,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: kunaLimitations(operation),
|
||||
}));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { availableParallelism } from "node:os";
|
||||
|
||||
/** Releases one admitted run's slot; later calls do nothing. */
|
||||
export type KunaRunRelease = () => void;
|
||||
|
||||
/**
|
||||
* Process-wide admission for single-function `kuna decompile` runs.
|
||||
*
|
||||
* Each run analyzes the whole image again in its own process tree, which held
|
||||
* about 360 MiB resident and close to one core on a 200 KiB Mach-O. These runs only
|
||||
* happen while the project export builds, so they share the cores its
|
||||
* `--jobs` workers leave free, and requests beyond that wait in FIFO order.
|
||||
*/
|
||||
export class KunaRunSlots {
|
||||
readonly #capacity: number;
|
||||
#active = 0;
|
||||
readonly #waiting: (() => void)[] = [];
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.#capacity = Math.max(1, capacity);
|
||||
}
|
||||
|
||||
/** Slots for the cores an export with `jobs` workers leaves on this host. */
|
||||
static forHost(jobs: number): KunaRunSlots {
|
||||
return new KunaRunSlots(availableParallelism() - jobs);
|
||||
}
|
||||
|
||||
/** Wait for a slot; undefined when the signal aborts first. */
|
||||
acquire(signal: AbortSignal): Promise<KunaRunRelease | undefined> {
|
||||
if (signal.aborted) return Promise.resolve(undefined);
|
||||
if (this.#active < this.#capacity) {
|
||||
this.#active += 1;
|
||||
return Promise.resolve(this.#release());
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const grant = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
this.#active += 1;
|
||||
resolve(this.#release());
|
||||
};
|
||||
const onAbort = () => {
|
||||
const index = this.#waiting.indexOf(grant);
|
||||
if (index !== -1) this.#waiting.splice(index, 1);
|
||||
resolve(undefined);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.#waiting.push(grant);
|
||||
});
|
||||
}
|
||||
|
||||
#release(): KunaRunRelease {
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.#active -= 1;
|
||||
this.#waiting.shift()?.();
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+60
-17
@@ -280,6 +280,7 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => {
|
||||
if (kind === null) throw new TypeError("otool load command is missing cmd");
|
||||
for (const key of scalarFields(kind)) requireUniqueField(parsed, key);
|
||||
if (DYLIB_COMMANDS.has(kind)) validateDylibCommandLines(parsed);
|
||||
if (kind === "LC_LINKER_OPTION") validateLinkerOptionLines(parsed);
|
||||
state.commands.push({
|
||||
index,
|
||||
kind,
|
||||
@@ -294,7 +295,7 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => {
|
||||
|
||||
const validateDylibCommandLines = (parsed: ParsedFields): void => {
|
||||
const encodingFields = DYLIB_ENCODING_FIELDS.filter((key) =>
|
||||
Object.hasOwn(parsed.values, key),
|
||||
parsed.rows.some((field) => field?.[0] === key),
|
||||
);
|
||||
if (encodingFields.length !== 1)
|
||||
throw new TypeError(
|
||||
@@ -311,9 +312,24 @@ const validateDylibCommandLines = (parsed: ParsedFields): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Each declared option appears once, in order, as `string #1..count`. */
|
||||
const validateLinkerOptionLines = (parsed: ParsedFields): void => {
|
||||
const count = numberField(parsed.values, "count");
|
||||
const labels = parsed.rows
|
||||
.map((field) => field?.[0])
|
||||
.filter((key) => key !== "cmd" && key !== "cmdsize" && key !== "count");
|
||||
if (
|
||||
count === null ||
|
||||
labels.length !== count ||
|
||||
labels.some((key, index) => key !== `string #${String(index + 1)}`)
|
||||
)
|
||||
throw new TypeError("otool linker-option strings do not match count");
|
||||
};
|
||||
|
||||
const scalarFields = (kind: string): readonly string[] => {
|
||||
const common = ["cmd", "cmdsize"];
|
||||
if (DYLIB_COMMANDS.has(kind)) return DYLIB_FIELDS;
|
||||
if (kind === "LC_LINKER_OPTION") return [...common, "count"];
|
||||
if (kind === "LC_LOAD_DYLINKER" || kind === "LC_ID_DYLINKER")
|
||||
return [...common, "name"];
|
||||
if (kind === "LC_UUID") return [...common, "uuid"];
|
||||
@@ -391,7 +407,8 @@ const collectDependency = (
|
||||
fields: Readonly<Record<string, string | number | null>>,
|
||||
state: ParsedLoadCommands,
|
||||
): void => {
|
||||
if (!DYLIB_COMMANDS.has(kind)) return;
|
||||
// LC_ID_DYLIB names this image itself; it stays a raw load command only.
|
||||
if (!DYLIB_COMMANDS.has(kind) || kind === "LC_ID_DYLIB") return;
|
||||
state.dependencies.push({
|
||||
path: stripOffsetSuffix(stringField(fields, "name")),
|
||||
kind,
|
||||
@@ -420,24 +437,41 @@ interface ParsedFields {
|
||||
readonly rows: readonly (readonly [string, string] | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A key otool prints more than once, such as each LC_BUILD_VERSION tool, is
|
||||
* labeled `key #n` in print order so no row overwrites another. Linker-option
|
||||
* strings keep otool's own `string #n` label at every count.
|
||||
*/
|
||||
const parseFields = (block: string): ParsedFields => {
|
||||
const fields: Record<string, string | number | null> = {};
|
||||
const rows: Array<readonly [string, string] | undefined> = [];
|
||||
for (const rawLine of block.split(/\r?\n/u).slice(1)) {
|
||||
if (rawLine.trim().length === 0) continue;
|
||||
const field = parseField(rawLine);
|
||||
rows.push(field);
|
||||
for (const rawLine of block.split(/\r?\n/u).slice(1))
|
||||
if (rawLine.trim().length > 0) rows.push(parseField(rawLine));
|
||||
const occurrences = new Map<string, number>();
|
||||
for (const field of rows)
|
||||
if (field !== undefined)
|
||||
occurrences.set(field[0], (occurrences.get(field[0]) ?? 0) + 1);
|
||||
const fields: Record<string, string | number | null> = {};
|
||||
const ordinals = new Map<string, number>();
|
||||
for (const field of rows) {
|
||||
if (field === undefined) continue;
|
||||
const [key, value] = field;
|
||||
fields[key] =
|
||||
key === "segname" || key === "sectname"
|
||||
? value
|
||||
: (numeric(value) ?? value);
|
||||
const ordinal = (ordinals.get(key) ?? 0) + 1;
|
||||
ordinals.set(key, ordinal);
|
||||
const label =
|
||||
(occurrences.get(key) ?? 0) > 1 ? `${key} #${String(ordinal)}` : key;
|
||||
fields[label] = TEXT_FIELDS.test(key) ? value : (numeric(value) ?? value);
|
||||
}
|
||||
return { values: fields, rows };
|
||||
};
|
||||
|
||||
/** Names and linker options are text even when they look numeric. */
|
||||
const TEXT_FIELDS = /^(?:segname|sectname|string #\d+)$/u;
|
||||
|
||||
const parseField = (rawLine: string): readonly [string, string] | undefined => {
|
||||
// otool prints each linker option as `string #n <option>`; keep the option
|
||||
// exactly, including surrounding spaces, under otool's ordinal label.
|
||||
const option = /^\s*(string #[1-9]\d*)(?: (.*))?$/u.exec(rawLine);
|
||||
if (option?.[1] !== undefined) return [option[1], option[2] ?? ""];
|
||||
const line = rawLine.trim();
|
||||
// Match time stamp as a whole without swallowing `name stamp plugin.dylib`.
|
||||
const match = /^((?:time stamp)|\S+(?:\s+version)?)(?:\s+(.+))?$/u.exec(line);
|
||||
@@ -460,7 +494,7 @@ const parseSegment = (
|
||||
block: string,
|
||||
fields: Readonly<Record<string, string | number | null>>,
|
||||
): ParsedSegment => ({
|
||||
name: nonEmptyName(stringField(fields, "segname")),
|
||||
name: machoName(fields, "segname"),
|
||||
vm_address: hexField(fields, "vmaddr"),
|
||||
vm_size: numberField(fields, "vmsize"),
|
||||
file_offset: numberField(fields, "fileoff"),
|
||||
@@ -488,8 +522,8 @@ const parseSections = (block: string): ParsedSection[] =>
|
||||
])
|
||||
requireUniqueField(parsed, key);
|
||||
return {
|
||||
segment: nonEmptyName(stringField(fields, "segname")),
|
||||
name: nonEmptyName(stringField(fields, "sectname")),
|
||||
segment: machoName(fields, "segname"),
|
||||
name: machoName(fields, "sectname"),
|
||||
address: hexField(fields, "addr"),
|
||||
size: numberField(fields, "size"),
|
||||
file_offset: numberField(fields, "offset"),
|
||||
@@ -544,9 +578,18 @@ const numeric = (value: string): number | null => {
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
};
|
||||
|
||||
/** Project an absent or empty Mach-O name as before: the "unknown" label. */
|
||||
const nonEmptyName = (name: string | null): string =>
|
||||
name === null || name.length === 0 ? "unknown" : name;
|
||||
/**
|
||||
* A Mach-O segment or section name exactly as observed. Object files place
|
||||
* every section in one unnamed segment, so the empty name is a source value.
|
||||
*/
|
||||
const machoName = (
|
||||
fields: Readonly<Record<string, string | number | null>>,
|
||||
key: "segname" | "sectname",
|
||||
): string => {
|
||||
const name = stringField(fields, key);
|
||||
if (name === null) throw new TypeError(`otool field ${key} is missing`);
|
||||
return name;
|
||||
};
|
||||
|
||||
const stringField = (
|
||||
fields: Readonly<Record<string, string | number | null>>,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Worker } from "node:worker_threads";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const MATCHING_DEADLINE_MS = 5_000;
|
||||
const WORKER_HEAP_MIB = 128;
|
||||
/** Shared capability text for the supervised ECMAScript matching boundary. */
|
||||
export const REGEX_MATCHING_LIMITATION = `Regex mode uses ECMAScript Unicode semantics in a cancellable worker with a ${String(MATCHING_DEADLINE_MS / 1000)}-second matching deadline; matching failure leaves the service available.`;
|
||||
const replySchema = z.discriminatedUnion("status", [
|
||||
z.strictObject({
|
||||
status: z.literal("matched"),
|
||||
indices: z.array(z.number().int().nonnegative()),
|
||||
}),
|
||||
z.strictObject({ status: z.literal("invalid_pattern"), reason: z.string() }),
|
||||
]);
|
||||
|
||||
// Code is constant; caller patterns and target strings cross only workerData.
|
||||
const WORKER_SOURCE = String.raw`
|
||||
(async () => {
|
||||
const { parentPort, workerData } = await import("node:worker_threads");
|
||||
let expression;
|
||||
try {
|
||||
expression = new RegExp(workerData.pattern, workerData.caseSensitive ? "u" : "iu");
|
||||
} catch (cause) {
|
||||
parentPort.postMessage({ status: "invalid_pattern", reason: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
if (expression !== undefined) {
|
||||
const indices = [];
|
||||
for (const [index, value] of workerData.values.entries())
|
||||
if (expression.test(value)) indices.push(index);
|
||||
parentPort.postMessage({ status: "matched", indices });
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
/** Match an inventory off the service thread, and prove worker exit before returning. */
|
||||
export const matchRegexValues = async (input: {
|
||||
readonly operation: string;
|
||||
readonly pattern: string;
|
||||
readonly caseSensitive: boolean;
|
||||
readonly values: readonly string[];
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly invalidPattern?: (reason: string) => AnalysisError;
|
||||
}): Promise<Result<readonly number[], AnalysisError>> => {
|
||||
if (input.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(input.operation));
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(WORKER_SOURCE, {
|
||||
eval: true,
|
||||
workerData: {
|
||||
pattern: input.pattern,
|
||||
caseSensitive: input.caseSensitive,
|
||||
values: input.values,
|
||||
},
|
||||
resourceLimits: { maxOldGenerationSizeMb: WORKER_HEAP_MIB },
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new AnalysisOutputError(input.operation, "Cannot start regex worker", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: Result<readonly number[], AnalysisError>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
input.signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
const onAbort = () =>
|
||||
finish(err(new AnalysisCancelledError(input.operation)));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
input.operation,
|
||||
"cpu",
|
||||
"Regex matching exceeded its supervised deadline; the service remains available.",
|
||||
{ matching_deadline_ms: MATCHING_DEADLINE_MS },
|
||||
{
|
||||
remediationAction:
|
||||
"Simplify the ECMAScript Unicode regex or use literal mode, then retry in the same session.",
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
MATCHING_DEADLINE_MS,
|
||||
);
|
||||
input.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (input.signal?.aborted) onAbort();
|
||||
worker.once("message", (message: unknown) => {
|
||||
const reply = replySchema.safeParse(message);
|
||||
if (!reply.success)
|
||||
return finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
input.operation,
|
||||
"Regex worker reply is malformed",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (reply.data.status === "invalid_pattern")
|
||||
return finish(
|
||||
err(
|
||||
input.invalidPattern?.(reply.data.reason) ??
|
||||
new AnalysisInputError(input.operation, {
|
||||
userMessage: `Invalid regex pattern: ${reply.data.reason}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (
|
||||
reply.data.indices.some(
|
||||
(index, position, indices) =>
|
||||
index >= input.values.length ||
|
||||
(position > 0 && index <= (indices[position - 1] ?? -1)),
|
||||
)
|
||||
)
|
||||
return finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
input.operation,
|
||||
"Regex worker returned invalid inventory indices",
|
||||
),
|
||||
),
|
||||
);
|
||||
finish(ok(reply.data.indices));
|
||||
});
|
||||
worker.once("error", (cause: Error & { code?: string }) =>
|
||||
finish(
|
||||
err(
|
||||
cause.code === "ERR_WORKER_OUT_OF_MEMORY"
|
||||
? new AnalysisResourceConstraintError(
|
||||
input.operation,
|
||||
"memory",
|
||||
"Regex worker exhausted its heap budget",
|
||||
{ worker_heap_mib: WORKER_HEAP_MIB },
|
||||
{ cause },
|
||||
)
|
||||
: new AnalysisOutputError(
|
||||
input.operation,
|
||||
`Regex worker failed: ${cause.message}`,
|
||||
{ cause },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
worker.once("exit", (code) =>
|
||||
finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
input.operation,
|
||||
`Regex worker exited without a result (${String(code)})`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CatalogDigests } from "./catalogIdentity.js";
|
||||
|
||||
/**
|
||||
* Digests of the default catalog (full input and output schemas), recorded
|
||||
* from the compiled contracts by `scripts/record-catalog-digests.mjs` after
|
||||
* compilation. Source runs leave this undefined and compute them on first use.
|
||||
*/
|
||||
export const RECORDED_CATALOG_DIGESTS: CatalogDigests | undefined = undefined;
|
||||
@@ -8,6 +8,104 @@ import {
|
||||
} from "../../support/cli/javascriptApplication.js";
|
||||
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"retains standalone GraphQL sources and only suppresses owned copies through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-graphql-source-");
|
||||
await writeFixtureFiles(root, {
|
||||
"classic.js": `
|
||||
const document = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "ClassicSource" },
|
||||
loc: { source: {
|
||||
body: "query ClassicSource { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
} },
|
||||
};
|
||||
`,
|
||||
"entry.mjs": `
|
||||
const source = {
|
||||
body: "query Standalone { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const compiledSource = {
|
||||
body: "query Compiled { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const document = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "Compiled" },
|
||||
}], loc: { source: compiledSource } };
|
||||
const independentSource = {
|
||||
body: "query Independent { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const unrelated = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "Independent" },
|
||||
};
|
||||
const changedSource = {
|
||||
body: "query ChangedSource { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const location = { source: changedSource };
|
||||
location.source = {};
|
||||
const changedDocument = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "ChangedSource" }, loc: location,
|
||||
};
|
||||
const exposedSource = {
|
||||
body: "query ExposedSource { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const exposedDocument = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "ExposedSource" },
|
||||
loc: { source: exposedSource },
|
||||
};
|
||||
exposedDocument.loc.source = {};
|
||||
`,
|
||||
});
|
||||
const result = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
const operations = result.graph.nodes
|
||||
.filter((node) => node.kind === "endpoint")
|
||||
.flatMap((node) => node.observations)
|
||||
.filter(({ properties }) => properties.mechanism === "graphql:query");
|
||||
expect(operations.map(({ properties }) => properties.value).sort()).toEqual(
|
||||
[
|
||||
"ChangedSource",
|
||||
"ChangedSource",
|
||||
"ClassicSource",
|
||||
"ClassicSource",
|
||||
"Compiled",
|
||||
"ExposedSource",
|
||||
"ExposedSource",
|
||||
"Independent",
|
||||
"Independent",
|
||||
"Standalone",
|
||||
],
|
||||
);
|
||||
for (const observation of operations)
|
||||
expect(observation.evidence).toMatchObject({
|
||||
authority: "ast-static-analysis",
|
||||
state: "observed",
|
||||
artifact: { available: true },
|
||||
location: {
|
||||
available: true,
|
||||
value: {
|
||||
kind: "source-range",
|
||||
source:
|
||||
observation.properties.value === "ClassicSource"
|
||||
? "classic.js"
|
||||
: "entry.mjs",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
expect(result.graph.coverage.status).toBe("complete");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"retains only final known RPC object properties through %s",
|
||||
async (surface, { cli }) => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { join } from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const evidenceSchema = z.object({ normalized_result: inspectMachoSchema });
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native Mach-O CLI dylib identity",
|
||||
() => {
|
||||
cliTest(
|
||||
"keeps a dylib's own install name out of its dependencies",
|
||||
async ({ cli, processes }) => {
|
||||
const directory = await createTestTempDirectory("rea-dylib-identity-");
|
||||
const source = join(directory, "library.c");
|
||||
const library = join(directory, "libprobe.dylib");
|
||||
await writeFile(source, "int exported_value(void) { return 42; }\n");
|
||||
const compiled = await processes.run(
|
||||
"/usr/bin/xcrun",
|
||||
[
|
||||
"clang",
|
||||
"-dynamiclib",
|
||||
source,
|
||||
"-Wl,-install_name,@rpath/libprobe.dylib",
|
||||
"-o",
|
||||
library,
|
||||
],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
expect(compiled.exitCode, compiled.stderr).toBe(0);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-macho", library, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const inspected = evidenceSchema.parse(result.json).normalized_result;
|
||||
expect(inspected.dependencies).toMatchObject({
|
||||
items: [
|
||||
{ path: "/usr/lib/libSystem.B.dylib", kind: "LC_LOAD_DYLIB" },
|
||||
],
|
||||
total: 1,
|
||||
exhaustive: true,
|
||||
});
|
||||
expect(
|
||||
inspected.load_commands.items.find(
|
||||
({ kind }) => kind === "LC_ID_DYLIB",
|
||||
)?.fields.name,
|
||||
).toBe("@rpath/libprobe.dylib (offset 24)");
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { join } from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const evidenceSchema = z.object({ normalized_result: inspectMachoSchema });
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native Mach-O CLI linker options",
|
||||
() => {
|
||||
cliTest(
|
||||
"retains every compiler-recorded linker option string in order",
|
||||
async ({ cli, processes }) => {
|
||||
const directory = await createTestTempDirectory("rea-linker-option-");
|
||||
const source = join(directory, "options.c");
|
||||
const object = join(directory, "options.o");
|
||||
await writeFile(
|
||||
source,
|
||||
[
|
||||
'__asm__(".linker_option \\"-framework\\", \\"Foundation\\"");',
|
||||
'__asm__(".linker_option \\"-lz\\"");',
|
||||
"int sample(void) { return 7; }",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const compiled = await processes.run(
|
||||
"/usr/bin/xcrun",
|
||||
["clang", "-c", source, "-o", object],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
expect(compiled.exitCode, compiled.stderr).toBe(0);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-macho", object, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const { load_commands: loadCommands } = evidenceSchema.parse(
|
||||
result.json,
|
||||
).normalized_result;
|
||||
expect(loadCommands.exhaustive).toBe(true);
|
||||
expect(
|
||||
loadCommands.items
|
||||
.filter((command) => command.kind === "LC_LINKER_OPTION")
|
||||
.map(({ fields }) => fields),
|
||||
).toEqual([
|
||||
{
|
||||
cmd: "LC_LINKER_OPTION",
|
||||
cmdsize: 40,
|
||||
count: 2,
|
||||
"string #1": "-framework",
|
||||
"string #2": "Foundation",
|
||||
},
|
||||
{
|
||||
cmd: "LC_LINKER_OPTION",
|
||||
cmdsize: 16,
|
||||
count: 1,
|
||||
"string #1": "-lz",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { join } from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const evidenceSchema = z.object({ normalized_result: inspectMachoSchema });
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native Mach-O CLI object segment",
|
||||
() => {
|
||||
cliTest(
|
||||
"reports an object file's unnamed segment by its observed empty name",
|
||||
async ({ cli, processes }) => {
|
||||
const directory = await createTestTempDirectory("rea-object-segment-");
|
||||
const source = join(directory, "qa.c");
|
||||
const object = join(directory, "qa.o");
|
||||
await writeFile(source, "int qa(void) { return 7; }\n");
|
||||
const compiled = await processes.run(
|
||||
"/usr/bin/xcrun",
|
||||
["clang", "-c", source, "-o", object],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
expect(compiled.exitCode, compiled.stderr).toBe(0);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-macho", object, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const inspected = evidenceSchema.parse(result.json).normalized_result;
|
||||
expect(inspected.load_commands.items[0]?.fields.segname).toBe("");
|
||||
expect(inspected.segments).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
name: "",
|
||||
sections: {
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
segment: "__TEXT",
|
||||
name: "__text",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
exhaustive: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -50,7 +50,7 @@ describe("direct analysis snapshot files", () => {
|
||||
details: {
|
||||
selection_reason: "unknown_provider",
|
||||
requested_provider_id: "request-provider",
|
||||
candidate_ids: ["ghidra", "hopper", "ida"],
|
||||
candidate_ids: ["ghidra", "hopper", "ida", "kuna"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseRuntimeReconciliationInput,
|
||||
reconcileJavaScriptRuntime,
|
||||
} from "../../../../src/domain/javascript/javascriptRuntimeReconciliation.js";
|
||||
import { reconcileRuntimeEntities } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationMatching.js";
|
||||
import { classifyStaticLoadStates } from "../../../../src/domain/javascript/javascriptRuntimeLoadState.js";
|
||||
import { projectRuntimeCaptures } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationRuntime.js";
|
||||
import { reconcileJavaScriptRuntimeInputSchema } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js";
|
||||
import { collectStaticRuntimeCandidates } from "../../../../src/domain/javascript/javascriptRuntimeStaticCandidates.js";
|
||||
import {
|
||||
SOURCE,
|
||||
analyzeFixture,
|
||||
applicationFixture,
|
||||
electronRuntimeEvidence,
|
||||
} from "../../../fixtures/javascriptRuntimeReconciliation.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const CAPTURED = "export const observed = 'different';\n";
|
||||
|
||||
const collisionInput = async (
|
||||
alternatives: number,
|
||||
explicitMapping: boolean,
|
||||
) => {
|
||||
const root = await applicationFixture();
|
||||
if (alternatives > 0) await writeFile(join(root, "other.js"), CAPTURED);
|
||||
const live = explicitMapping
|
||||
? await createTestTempDirectory("rea-runtime-content-live-")
|
||||
: root;
|
||||
const extra = await createTestTempDirectory("rea-runtime-content-extra-");
|
||||
await writeFile(join(extra, "app.js"), CAPTURED);
|
||||
return parseRuntimeReconciliationInput(
|
||||
reconcileJavaScriptRuntimeInputSchema.parse({
|
||||
static_layers: [
|
||||
{
|
||||
role: "application",
|
||||
analysis: await analyzeFixture(root),
|
||||
runtime_mappings: explicitMapping
|
||||
? [{ kind: "file-root", root: live, artifact_prefix: "" }]
|
||||
: [],
|
||||
},
|
||||
...(alternatives > 1
|
||||
? [{ role: "assets", analysis: await analyzeFixture(extra) }]
|
||||
: []),
|
||||
],
|
||||
runtime_observations: [
|
||||
electronRuntimeEvidence(live, CAPTURED, { includeWorker: false }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
[0, false],
|
||||
[1, false],
|
||||
[2, false],
|
||||
[0, true],
|
||||
[1, true],
|
||||
[2, true],
|
||||
] as const)(
|
||||
"preserves mapped disagreement with %i other content matches (explicit mapping: %s)",
|
||||
async (alternatives, explicitMapping) => {
|
||||
const parsed = await collisionInput(alternatives, explicitMapping);
|
||||
const result = reconcileJavaScriptRuntime(parsed);
|
||||
const script = result.reconciliations.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
);
|
||||
expect(script).toMatchObject({
|
||||
status: "unmatched",
|
||||
basis: explicitMapping ? "operator-file-mapping" : "artifact-path",
|
||||
confidence: "high",
|
||||
reason: "captured-content-disagrees-with-static-location",
|
||||
static_layer_id: null,
|
||||
static_node_id: null,
|
||||
candidate_static_count: 1,
|
||||
});
|
||||
const candidate = script?.candidate_static_nodes[0];
|
||||
const selected = result.graph.nodes.find(
|
||||
({ node_id }) => node_id === candidate?.static_node_id,
|
||||
);
|
||||
expect(selected?.observations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({ path: "app.js" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result.graph.edges.some(
|
||||
(edge) =>
|
||||
edge.relation === "observed_as" &&
|
||||
edge.target_node_id === script?.runtime_node_id,
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("restricts content-and-location ambiguity to mapped candidates", async () => {
|
||||
const application = await applicationFixture();
|
||||
const cache = await applicationFixture();
|
||||
const other = await applicationFixture();
|
||||
const live = await createTestTempDirectory("rea-runtime-content-mapped-");
|
||||
const analysis = await analyzeFixture(application);
|
||||
const mapping = [{ kind: "file-root", root: live, artifact_prefix: "" }];
|
||||
const parsed = parseRuntimeReconciliationInput(
|
||||
reconcileJavaScriptRuntimeInputSchema.parse({
|
||||
static_layers: [
|
||||
{ role: "application", analysis, runtime_mappings: mapping },
|
||||
{
|
||||
role: "cache",
|
||||
analysis: await analyzeFixture(cache),
|
||||
runtime_mappings: mapping,
|
||||
},
|
||||
{ role: "assets", analysis: await analyzeFixture(other) },
|
||||
],
|
||||
runtime_observations: [
|
||||
electronRuntimeEvidence(live, SOURCE, { includeWorker: false }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = reconcileJavaScriptRuntime(parsed);
|
||||
const script = result.reconciliations.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
);
|
||||
expect(script).toMatchObject({
|
||||
status: "ambiguous",
|
||||
reason: "ambiguous-static-candidates",
|
||||
candidate_static_count: 2,
|
||||
});
|
||||
const unmapped = result.static_layers.find(({ role }) => role === "assets");
|
||||
expect(
|
||||
script?.candidate_static_nodes.map(
|
||||
({ static_layer_id }) => static_layer_id,
|
||||
),
|
||||
).not.toContain(unmapped?.layer_id);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains digest-only matching without a mapped candidate (mapping: %s)",
|
||||
async (mapped) => {
|
||||
const root = await applicationFixture();
|
||||
await writeFile(join(root, "other.js"), CAPTURED);
|
||||
const live = await createTestTempDirectory(
|
||||
"rea-runtime-content-unlocated-",
|
||||
);
|
||||
const result = reconcileJavaScriptRuntime(
|
||||
parseRuntimeReconciliationInput(
|
||||
reconcileJavaScriptRuntimeInputSchema.parse({
|
||||
static_layers: [
|
||||
{
|
||||
role: "application",
|
||||
analysis: await analyzeFixture(root),
|
||||
runtime_mappings: mapped
|
||||
? [{ kind: "file-root", root: live, artifact_prefix: "" }]
|
||||
: [],
|
||||
},
|
||||
],
|
||||
runtime_observations: [
|
||||
electronRuntimeEvidence(live, CAPTURED, {
|
||||
scriptFile: "absent.js",
|
||||
includeWorker: false,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.reconciliations.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "matched",
|
||||
basis: "content-sha256",
|
||||
confidence: "high",
|
||||
reason: "unique-content-match",
|
||||
candidate_static_count: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unavailable mapped digests unknown instead of borrowing unrelated matches", async () => {
|
||||
const parsed = await collisionInput(1, true);
|
||||
const candidates = collectStaticRuntimeCandidates(parsed.layers).map(
|
||||
(candidate) =>
|
||||
candidate.paths.includes("app.js") && candidate.category === "javascript"
|
||||
? { ...candidate, digests: [] }
|
||||
: candidate,
|
||||
);
|
||||
// Model the matcher's supported missing-digest boundary; do not falsify Evidence.
|
||||
const runtime = projectRuntimeCaptures(parsed.captures);
|
||||
const result = reconcileRuntimeEntities({
|
||||
entities: runtime.entities,
|
||||
candidates,
|
||||
layers: parsed.layers,
|
||||
});
|
||||
expect(
|
||||
result.items.find(({ entity_kind }) => entity_kind === "script"),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
basis: "operator-file-mapping",
|
||||
confidence: "unknown",
|
||||
reason: "static-or-runtime-coverage-incomplete",
|
||||
candidate_static_count: 1,
|
||||
});
|
||||
const script = result.items.find(
|
||||
({ entity_kind }) => entity_kind === "script",
|
||||
);
|
||||
const candidate = script?.candidate_static_nodes[0];
|
||||
const states = classifyStaticLoadStates(
|
||||
parsed.layers,
|
||||
runtime.entities,
|
||||
result.items,
|
||||
{
|
||||
reconciliationComplete: true,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
states.states.find(
|
||||
(state) =>
|
||||
state.static_layer_id === candidate?.static_layer_id &&
|
||||
state.static_node_id === candidate?.static_node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
reason: "static-or-runtime-coverage-incomplete",
|
||||
});
|
||||
});
|
||||
-23
@@ -224,29 +224,6 @@ it("keeps static load states unknown when one scoped capture omits scripts", asy
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a captured digest disagreement instead of accepting a path match", async () => {
|
||||
const fixture = await applicationFixture();
|
||||
const staticEvidence = await analyzeFixture(fixture);
|
||||
const runtimeEvidence = electronRuntimeEvidence(
|
||||
fixture,
|
||||
"export const observed = 'different';\n",
|
||||
);
|
||||
|
||||
const result = reconcileInput({
|
||||
static_layers: [{ role: "application", analysis: staticEvidence }],
|
||||
runtime_observations: [runtimeEvidence],
|
||||
});
|
||||
const script = result.reconciliations.find(
|
||||
({ entity_kind: kind }) => kind === "script",
|
||||
);
|
||||
|
||||
expect(script).toMatchObject({
|
||||
status: "unmatched",
|
||||
reason: "captured-content-disagrees-with-static-location",
|
||||
});
|
||||
expect(script?.candidate_static_nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reconciles active Electron as a partial target-only runtime capture", async () => {
|
||||
const fixture = await applicationFixture();
|
||||
const staticEvidence = await analyzeFixture(fixture);
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
reconstructionCoverageEvidenceId,
|
||||
} from "../../../src/domain/reconstructionCoverage.fixture.js";
|
||||
import { createReconstructionCoverageData } from "../../../src/domain/reconstructionCoverage.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
@@ -43,6 +45,64 @@ describe("reconstruction coverage MCP", () => {
|
||||
const latest = coverage.verifier_results[0];
|
||||
if (latest === undefined)
|
||||
throw new Error("Expected fixture verifier result");
|
||||
const inference = createEvidence(
|
||||
undefined,
|
||||
{ id: "weak-proof", name: "Weak proof fixture", version: "1" },
|
||||
{
|
||||
operation: "infer",
|
||||
parameters: {},
|
||||
result: { status: "unknown", note: "No replay was performed" },
|
||||
authority: "analyst-inference",
|
||||
confidence: "inferred",
|
||||
},
|
||||
);
|
||||
const weakCoverage = createReconstructionCoverageData({
|
||||
...coverage,
|
||||
evidence_bundle: createEvidenceBundle([
|
||||
...coverage.evidence_bundle.records,
|
||||
inference,
|
||||
]),
|
||||
verifier_results: [
|
||||
{ ...latest, evidence_ids: [inference.evidence_id] },
|
||||
],
|
||||
});
|
||||
const weak = await client.callTool({
|
||||
name: "evaluate_reconstruction_coverage",
|
||||
arguments: { coverage: weakCoverage, boundary_id: boundaryId },
|
||||
});
|
||||
expect(weak.isError).not.toBe(true);
|
||||
expect(weak.structuredContent).toMatchObject({
|
||||
status: "unknown",
|
||||
reasons: [
|
||||
expect.objectContaining({ code: "verifier-result-incompatible" }),
|
||||
],
|
||||
evidence_ids: expect.arrayContaining([inference.evidence_id]),
|
||||
});
|
||||
const tampered = await client.callTool({
|
||||
name: "evaluate_reconstruction_coverage",
|
||||
arguments: {
|
||||
boundary_id: boundaryId,
|
||||
coverage: {
|
||||
...weakCoverage,
|
||||
evidence_bundle: {
|
||||
...weakCoverage.evidence_bundle,
|
||||
records: weakCoverage.evidence_bundle.records.map((record) =>
|
||||
record.evidence_id === inference.evidence_id
|
||||
? {
|
||||
...record,
|
||||
authority: "controlled-replay",
|
||||
confidence: "observed",
|
||||
}
|
||||
: record,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tampered.isError).toBe(true);
|
||||
expect(parseMcpToolError(tampered)).toMatchObject({
|
||||
error: { code: "invalid_request" },
|
||||
});
|
||||
const ambiguous = await client.callTool({
|
||||
name: "evaluate_reconstruction_coverage",
|
||||
arguments: {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
it("requires actual cancellation and confirmed cleanup before the environment verifier passes", async () => {
|
||||
// Execute the verifier's actual oracle with controlled provider outcomes.
|
||||
// The real-browser lane separately proves emulation and restoration in Chrome.
|
||||
const { stdout } = await execute(process.execPath, [
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
String.raw`
|
||||
const { verifyCancelledEnvironment } = await import(process.argv[1]);
|
||||
const { AnalysisCancelledError, AnalysisProtocolError } = await import(
|
||||
new URL("../../dist/domain/analysisErrorCore.js", process.argv[1])
|
||||
);
|
||||
const outcomes = [];
|
||||
for (const kind of ["cancelled", "protocol-error", "cleanup-failed", "completed", "cancelled-step", "incomplete-step"]) {
|
||||
let snapshots = 0;
|
||||
let aborted = false;
|
||||
const connection = {
|
||||
send: async () => ({ result: {
|
||||
type: "string",
|
||||
value: JSON.stringify({
|
||||
timeOrigin: ++snapshots,
|
||||
environment: { timezone: "Pacific/Honolulu", locale: "fr-FR", dpr: 2, width: 900, height: 600 },
|
||||
}),
|
||||
} }),
|
||||
};
|
||||
const error = kind === "protocol-error"
|
||||
? new AnalysisProtocolError("controlled protocol failure")
|
||||
: new AnalysisCancelledError("capture_browser_scenario", kind === "cleanup-failed"
|
||||
? { cleanup: { reason: "controlled cleanup failure", resources: ["browser_transport"] } }
|
||||
: undefined);
|
||||
const result = ["completed", "cancelled-step", "incomplete-step"].includes(kind)
|
||||
? { ok: true, value: {
|
||||
steps: [{ status: kind === "completed" ? "completed" : "cancelled" }],
|
||||
browser: { cleanup: kind === "incomplete-step" ? "incomplete" : "disconnected-external" },
|
||||
} }
|
||||
: { ok: false, error };
|
||||
const provider = {
|
||||
captureScenario: (_scenario, { signal }) => new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => { aborted = true; resolve(result); }, { once: true });
|
||||
}),
|
||||
};
|
||||
let accepted = false;
|
||||
try {
|
||||
await verifyCancelledEnvironment(provider, connection, { actions: [{}] });
|
||||
accepted = true;
|
||||
} catch {}
|
||||
outcomes.push({ kind, accepted, aborted, snapshots });
|
||||
}
|
||||
console.log(JSON.stringify(outcomes));
|
||||
`,
|
||||
new URL(
|
||||
"../../../scripts/lib/browser-scenario-environment-e2e.mjs",
|
||||
import.meta.url,
|
||||
).href,
|
||||
]);
|
||||
expect(JSON.parse(stdout)).toEqual(
|
||||
[
|
||||
["cancelled", true],
|
||||
["protocol-error", false],
|
||||
["cleanup-failed", false],
|
||||
["completed", false],
|
||||
["cancelled-step", true],
|
||||
["incomplete-step", false],
|
||||
].map(([kind, accepted]) => ({
|
||||
kind,
|
||||
accepted,
|
||||
aborted: true,
|
||||
snapshots: 2,
|
||||
})),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { HopperRegexSearch } from "../../../src/hopper/HopperRegexSearch.js";
|
||||
import { matchRegexValues } from "../../../src/process/RegexMatcher.js";
|
||||
|
||||
it("preserves Unicode matching order and refuses a pre-cancelled match", async () => {
|
||||
expect(
|
||||
await matchRegexValues({
|
||||
operation: "search_procedures",
|
||||
pattern: "^δ",
|
||||
caseSensitive: false,
|
||||
values: ["Δelta", "delta", "δίκη"],
|
||||
}),
|
||||
).toEqual(ok([0, 2]));
|
||||
expect(
|
||||
await matchRegexValues({
|
||||
operation: "search_procedures",
|
||||
pattern: ".",
|
||||
caseSensitive: true,
|
||||
values: ["value"],
|
||||
signal: AbortSignal.abort(),
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
|
||||
it("keeps Hopper's result projection and invalid-pattern contract with the shared worker", async () => {
|
||||
const search = new HopperRegexSearch({
|
||||
callTool: async () =>
|
||||
ok([
|
||||
{ address: "0x10", value: "Δelta" },
|
||||
{ address: "0x20", value: "other" },
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
await search.execute("search_strings", { pattern: "^δ", mode: "regex" }),
|
||||
).toEqual(ok([{ address: "0x10", value: "Δelta" }]));
|
||||
expect(
|
||||
await search.execute("search_strings", { pattern: "[", mode: "regex" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "HopperRemoteError",
|
||||
code: -32000,
|
||||
diagnosticType: "invalid_request",
|
||||
operation: "search_strings",
|
||||
},
|
||||
});
|
||||
await search.close();
|
||||
});
|
||||
@@ -26,6 +26,13 @@ export interface FakeKunaBehavior {
|
||||
readonly functions?: readonly FakeKunaFunction[];
|
||||
/** Extra `.asm` bytes, for capacity tests. */
|
||||
readonly asmPadding?: number;
|
||||
/** Extra C comment bytes inside each exported body, for buffered-read capacity. */
|
||||
readonly cPadding?: number;
|
||||
readonly decompile?: "ok" | "hang";
|
||||
/** Milliseconds one `decompile --addr` run takes before printing. */
|
||||
readonly decompileDelayMs?: number;
|
||||
/** Milliseconds `functions --json` takes before printing. */
|
||||
readonly inventoryDelayMs?: number;
|
||||
/** Milliseconds the export takes before writing its files. */
|
||||
readonly delayMs?: number;
|
||||
}
|
||||
@@ -40,7 +47,7 @@ const SCRIPT = String.raw`
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const behavior = JSON.parse(fs.readFileSync(__filename + ".json", "utf8"));
|
||||
fs.appendFileSync(__filename + ".calls", JSON.stringify({ pid: process.pid, argv: process.argv.slice(2), home: process.env.HOME, xdg: process.env.XDG_CACHE_HOME, path: process.env.PATH ?? null }) + "\n");
|
||||
fs.appendFileSync(__filename + ".calls", JSON.stringify({ pid: process.pid, at: Date.now(), argv: process.argv.slice(2), home: process.env.HOME, xdg: process.env.XDG_CACHE_HOME, path: process.env.PATH ?? null }) + "\n");
|
||||
const args = process.argv.slice(2);
|
||||
const functions = behavior.functions ?? [{ name: "main", addr: "0x1000" }, { name: "helper", addr: "0x1040" }];
|
||||
const hang = () => setInterval(() => {}, 1000);
|
||||
@@ -55,7 +62,7 @@ if (args[0] === "--version") {
|
||||
else if (mode === "not-json") process.stdout.write("functions: 2\n");
|
||||
else {
|
||||
const listed = [...functions, { name: "puts", addr: "0x2000" }].map((fn) => ({ name: fn.name, address: parseInt(fn.addr, 16), address_hex: fn.addr, aliases: [], object_location: null, size: fn.name === "puts" ? 0 : 16 }));
|
||||
process.stdout.write(JSON.stringify({ binary: args[1], count: listed.length, total: listed.length, error: null, functions: listed }, null, 2) + "\n");
|
||||
setTimeout(() => process.stdout.write(JSON.stringify({ binary: args[1], count: listed.length, total: listed.length, error: null, functions: listed }, null, 2) + "\n"), behavior.inventoryDelayMs ?? 0);
|
||||
}
|
||||
} else if (args[0] === "decompile-project") {
|
||||
const input = args[1];
|
||||
@@ -69,12 +76,15 @@ if (args[0] === "--version") {
|
||||
let c = '#include "' + stem + '.h"\n\n';
|
||||
functions.forEach((fn, index) => {
|
||||
c += fn.error === undefined
|
||||
? "// Function: " + fn.name + " @ " + fn.addr + "\nint " + fn.name + "(void)\n{\n return " + index + ";\n}\n\n"
|
||||
? "// Function: " + fn.name + " @ " + fn.addr + "\nint " + fn.name + "(void)\n{\n return " + index + ";\n" + (behavior.cPadding ? " /*" + "x".repeat(behavior.cPadding) + "*/\n" : "") + "}\n\n"
|
||||
: "// Function: " + fn.name + " @ " + fn.addr + " (error: " + fn.error + ")\n\n";
|
||||
});
|
||||
fs.writeFileSync(path.join(output, stem + ".c"), c);
|
||||
fs.writeFileSync(path.join(output, stem + ".h"), "#pragma once\n");
|
||||
fs.writeFileSync(path.join(output, stem + ".asm"), "; asm\n" + "x".repeat(behavior.asmPadding ?? 0));
|
||||
let asm = "; kuna decompile-project — " + stem + "\n\n; --- code section 0x1000..0x2000 ---\n\n";
|
||||
functions.forEach((fn) => { asm += fn.name + ": ; " + fn.addr + "\n " + fn.addr.slice(2).padStart(8, "0") + ": c3 RET\n\n"; });
|
||||
asm += "; --- data ---\n\ndat_3000: ; 0x3000 = dat_3000\n 00003000: 00\n\ng_table: ; 0x3010\n 00003010: 01\n" + "x".repeat(behavior.asmPadding ?? 0);
|
||||
fs.writeFileSync(path.join(output, stem + ".asm"), asm);
|
||||
fs.writeFileSync(path.join(output, "README.md"), "# " + stem + "\n");
|
||||
// Occupy the manifest path so REA's publication write fails.
|
||||
if (mode === "block-manifest") fs.mkdirSync(path.join(output, "..", "manifest.json"));
|
||||
@@ -82,6 +92,15 @@ if (args[0] === "--version") {
|
||||
const reported = mode === "miscounted" ? functions.length + 1 : functions.length - failed;
|
||||
if (mode !== "silent") process.stdout.write("wrote " + output + ": " + stem + ".c (" + c.length + " bytes); functions: " + reported + " ok, " + failed + " failed\n");
|
||||
}, behavior.delayMs ?? 0);
|
||||
} else if (args[0] === "decompile") {
|
||||
// decompile <input> <0xaddr> --addr: the single-function path.
|
||||
const fn = functions.find((candidate) => candidate.addr === args[2]);
|
||||
if (behavior.decompile === "hang") hang();
|
||||
else if (fn === undefined) { process.stderr.write("error: no function at " + args[2] + "\n"); process.exit(1); }
|
||||
else setTimeout(() => {
|
||||
process.stdout.write("int " + fn.name + "(void) // single-function\n{\n return 0;\n}\n");
|
||||
fs.appendFileSync(__filename + ".exits", JSON.stringify({ pid: process.pid, at: Date.now() }) + "\n");
|
||||
}, behavior.decompileDelayMs ?? 0);
|
||||
} else {
|
||||
process.stderr.write("unexpected command\n");
|
||||
process.exit(2);
|
||||
@@ -111,6 +130,8 @@ export const fakeKunaCalls = async (
|
||||
): Promise<
|
||||
readonly {
|
||||
readonly pid: number;
|
||||
/** Epoch milliseconds when the invocation started. */
|
||||
readonly at: number;
|
||||
readonly argv: readonly string[];
|
||||
readonly home: string;
|
||||
readonly xdg: string;
|
||||
@@ -123,3 +144,19 @@ export const fakeKunaCalls = async (
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as never);
|
||||
};
|
||||
|
||||
/** When each completed single-function run finished, by process id. */
|
||||
export const fakeKunaExits = async (
|
||||
path: string,
|
||||
): Promise<ReadonlyMap<number, number>> => {
|
||||
const text = await readFile(`${path}.exits`, "utf8").catch(() => "");
|
||||
return new Map(
|
||||
text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const { pid, at } = JSON.parse(line) as { pid: number; at: number };
|
||||
return [pid, at];
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -40,6 +40,7 @@ it("identifies a caller-selected kuna by version and digest", async () => {
|
||||
expect(await fakeKunaCalls(kuna)).toEqual([
|
||||
{
|
||||
pid: expect.any(Number),
|
||||
at: expect.any(Number),
|
||||
argv: ["--version"],
|
||||
home: join(root, "cache", "home"),
|
||||
xdg: join(root, "cache", "home", ".cache"),
|
||||
@@ -145,6 +146,7 @@ it("parses cache, timeout and job settings at the configuration boundary", () =>
|
||||
cacheRoot: "/home/analyst/.cache/rea/kuna",
|
||||
cacheMaxBytes: 4 * 1024 ** 3,
|
||||
projectTimeoutMs: 1_800_000,
|
||||
functionTimeoutMs: 120_000,
|
||||
// Kuna's reference answer is a serial export; more jobs change results.
|
||||
jobs: 1,
|
||||
},
|
||||
@@ -164,18 +166,25 @@ it("parses cache, timeout and job settings at the configuration boundary", () =>
|
||||
...environment,
|
||||
REA_KUNA_CACHE_MAX_BYTES: "2097152",
|
||||
REA_KUNA_PROJECT_TIMEOUT_MS: "60000",
|
||||
REA_KUNA_TIMEOUT_MS: "5000",
|
||||
REA_KUNA_JOBS: "3",
|
||||
},
|
||||
"linux",
|
||||
),
|
||||
).toMatchObject({
|
||||
value: { cacheMaxBytes: 2097152, projectTimeoutMs: 60000, jobs: 3 },
|
||||
value: {
|
||||
cacheMaxBytes: 2097152,
|
||||
projectTimeoutMs: 60000,
|
||||
functionTimeoutMs: 5000,
|
||||
jobs: 3,
|
||||
},
|
||||
});
|
||||
for (const [setting, value] of [
|
||||
["REA_KUNA_CACHE_DIR", "relative/cache"],
|
||||
["REA_KUNA_CACHE_MAX_BYTES", "1024"],
|
||||
["REA_KUNA_CACHE_MAX_BYTES", "4GiB"],
|
||||
["REA_KUNA_PROJECT_TIMEOUT_MS", "0"],
|
||||
["REA_KUNA_TIMEOUT_MS", "-1"],
|
||||
["REA_KUNA_JOBS", "33"],
|
||||
["REA_KUNA_JOBS", "0"],
|
||||
] as const)
|
||||
|
||||
@@ -168,7 +168,13 @@ it("publishes a verified export and serves later requests from the cache", async
|
||||
// Kuna listed and decompiled a staged copy from REA's own HOME, in reference mode.
|
||||
const [listing] = await callsOf(kuna, "functions");
|
||||
const [exported] = await callsOf(kuna, "decompile-project");
|
||||
expect(listing?.argv).toEqual(["functions", exported?.argv[1], "--json"]);
|
||||
expect(listing?.argv).toEqual([
|
||||
"functions",
|
||||
exported?.argv[1],
|
||||
"--json",
|
||||
"--jobs",
|
||||
"1",
|
||||
]);
|
||||
expect(exported?.argv.slice(2)).toEqual([
|
||||
"-o",
|
||||
expect.stringContaining(join("cache", "staging")),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, it } from "vitest";
|
||||
import {
|
||||
parseKunaExportSummary,
|
||||
parseKunaInventory,
|
||||
scanKunaAssembly,
|
||||
scanKunaProjectSource,
|
||||
} from "../../../../src/kuna/KunaProjectFiles.js";
|
||||
|
||||
@@ -162,3 +163,45 @@ it("reads the callable inventory, ignoring fields a later release adds", () => {
|
||||
parseKunaInventory(JSON.stringify({ functions: [{ name: "x" }] })),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("indexes code listings and data labels in Kuna's .asm", async () => {
|
||||
const asm = [
|
||||
"; kuna decompile-project — a.out",
|
||||
"; arch: x86:LE:64:default:gcc",
|
||||
"",
|
||||
"; --- code section 0x1000..0x1020 ---",
|
||||
"",
|
||||
"main: ; 0x1000",
|
||||
"; arg: param_1 (int)",
|
||||
" 00001000: c3 RET",
|
||||
"",
|
||||
"operator new(unsigned long): ; 0x1010",
|
||||
" 00001010: c3 RET",
|
||||
"",
|
||||
"; --- data ---",
|
||||
"",
|
||||
"dat_3000: ; 0x3000 = dat_3000",
|
||||
" 00003000: 00",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
const scanned = await scanKunaAssembly(chunked(asm, 5));
|
||||
if (!scanned.ok) throw new Error(scanned.error);
|
||||
const main = scanned.value.code.get("0x1000");
|
||||
// Offsets are bytes; the header's em dash is three of them.
|
||||
expect(
|
||||
Buffer.from(asm)
|
||||
.subarray(main?.offset, (main?.offset ?? 0) + (main?.length ?? 0))
|
||||
.toString(),
|
||||
).toBe(
|
||||
"main: ; 0x1000\n; arg: param_1 (int)\n 00001000: c3 RET\n\n",
|
||||
);
|
||||
expect(scanned.value.code.get("0x1010")?.name).toBe(
|
||||
"operator new(unsigned long)",
|
||||
);
|
||||
expect([...scanned.value.code.keys()]).toEqual(["0x1000", "0x1010"]);
|
||||
expect([...scanned.value.data]).toEqual([["0x3000", "dat_3000"]]);
|
||||
expect(
|
||||
await scanKunaAssembly(chunked("a: ; 0x10\n\nb: ; 0x10\n", 64)),
|
||||
).toEqual({ ok: false, error: "the .asm repeats code address 0x10" });
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readdir, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, expect, it as test, vi } from "vitest";
|
||||
|
||||
import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js";
|
||||
import { run, session } from "./kunaSessionFixture.js";
|
||||
|
||||
const reading = vi.hoisted(() => ({
|
||||
asm: "",
|
||||
c: "",
|
||||
held: false,
|
||||
gate: undefined as Promise<void> | undefined,
|
||||
missing: 0,
|
||||
onMissing: undefined as (() => void) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
try {
|
||||
const handle = await actual.open(...args);
|
||||
if (String(args[0]) !== reading.asm || reading.held) return handle;
|
||||
return new Proxy(handle, {
|
||||
get(target, key) {
|
||||
if (key === "read")
|
||||
return async (...readArgs: unknown[]) => {
|
||||
const result: unknown = await Reflect.apply(
|
||||
target.read,
|
||||
target,
|
||||
readArgs,
|
||||
);
|
||||
reading.held = true;
|
||||
await reading.gate;
|
||||
return result;
|
||||
};
|
||||
const value: unknown = Reflect.get(target, key, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (String(args[0]) === reading.c) {
|
||||
reading.missing += 1;
|
||||
if (reading.missing === 2) reading.onMissing?.();
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reading.asm = "";
|
||||
reading.c = "";
|
||||
reading.held = false;
|
||||
reading.gate = undefined;
|
||||
reading.missing = 0;
|
||||
reading.onMissing = undefined;
|
||||
});
|
||||
|
||||
const barrier = () => {
|
||||
let release = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
};
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"retries the whole dossier when another read loses its project",
|
||||
async () => {
|
||||
const assembly = barrier();
|
||||
const rebuild = barrier();
|
||||
const missing = barrier();
|
||||
let exports = 0;
|
||||
const { cacheRoot, open } = await session(
|
||||
"rea-kuna-reader-eviction-",
|
||||
{},
|
||||
{},
|
||||
async (options) => {
|
||||
if (options.arguments[0] === "decompile-project" && ++exports === 2)
|
||||
await rebuild.promise;
|
||||
return spawnOwnedProviderProcess(options);
|
||||
},
|
||||
);
|
||||
const client = open();
|
||||
let dossier: ReturnType<typeof run> | undefined;
|
||||
try {
|
||||
await run(client, "procedure_assembly", { procedure: "main" });
|
||||
const [key] = await readdir(join(cacheRoot, "projects"));
|
||||
const project = join(cacheRoot, "projects", key ?? "");
|
||||
reading.asm = join(project, "project", "program.asm");
|
||||
reading.c = join(project, "project", "program.c");
|
||||
reading.gate = assembly.promise;
|
||||
reading.onMissing = missing.release;
|
||||
dossier = run(client, "analyze_function", { procedure: "main" });
|
||||
await expect.poll(() => reading.held).toBe(true);
|
||||
await rename(project, join(cacheRoot, "trash", "evicted-elsewhere"));
|
||||
|
||||
const fallback = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "parse",
|
||||
});
|
||||
expect(fallback.rawResult).toMatchObject({
|
||||
source: { kind: "single-function" },
|
||||
});
|
||||
await expect.poll(() => exports).toBe(2);
|
||||
assembly.release();
|
||||
// The fixed reader attempts the missing C before retrying the entire
|
||||
// report. The old reader instead completes with a single-function source.
|
||||
await Promise.race([dossier, missing.promise]);
|
||||
rebuild.release();
|
||||
const result = await dossier;
|
||||
expect(result.rawResult).toMatchObject({
|
||||
source: {
|
||||
kind: "project-export",
|
||||
files: [{ facet: "pseudocode" }, { facet: "assembly" }],
|
||||
},
|
||||
});
|
||||
expect(result.result).toMatchObject({
|
||||
pseudocode: "int main(void)\n{\n return 0;\n}",
|
||||
assembly: ["main: ; 0x1000", " 00001000: c3 RET"],
|
||||
});
|
||||
} finally {
|
||||
assembly.release();
|
||||
rebuild.release();
|
||||
await dossier?.catch(() => undefined);
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,823 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
appendFile,
|
||||
chmod,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it as test, vi } from "vitest";
|
||||
|
||||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
import type { JsonValue } from "../../../../src/domain/jsonValue.js";
|
||||
import type { KunaProcessLauncher } from "../../../../src/kuna/KunaCommand.js";
|
||||
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
|
||||
import { KUNA_FUNCTION_TEXT_BYTES } from "../../../../src/kuna/KunaLimits.js";
|
||||
import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
fakeKunaCalls,
|
||||
setFakeKunaBehavior,
|
||||
writeFakeKuna,
|
||||
} from "./fakeKuna.js";
|
||||
|
||||
import {
|
||||
session,
|
||||
run,
|
||||
exportsOf,
|
||||
FUNCTIONS,
|
||||
unprovenCleanupLauncher,
|
||||
} from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("discovers configuration without probing and admits only verified targets", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-discovery-");
|
||||
const kuna = await writeFakeKuna(root);
|
||||
const configured = new KunaProvider({ REA_KUNA_COMMAND: kuna });
|
||||
|
||||
expect(new KunaProvider({}).inspectAvailability()).toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
});
|
||||
expect(
|
||||
new KunaProvider({
|
||||
REA_KUNA_COMMAND: join(root, "absent"),
|
||||
}).inspectAvailability(),
|
||||
).toMatchObject({ status: "unavailable", code: "executable_missing" });
|
||||
expect(configured.inspectAvailability()).toMatchObject({
|
||||
status: "available",
|
||||
diagnostics: { version_probed: false },
|
||||
});
|
||||
expect(await fakeKunaCalls(kuna)).toEqual([]);
|
||||
expect(
|
||||
configured
|
||||
.capabilities()
|
||||
.every(
|
||||
({ available, cachePolicy }) => available && cachePolicy === "live",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const executable = (
|
||||
format: "elf" | "mach-o" | "pe" | "mach-o-universal",
|
||||
architecture: "x86_64" | "arm64" | "arm" | "x86",
|
||||
) =>
|
||||
({
|
||||
path: "/tmp/fixture",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format,
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
}) as unknown as BinaryTarget;
|
||||
expect(
|
||||
configured.inspectTargetSupport(executable("elf", "x86_64")).status,
|
||||
).toBe("supported");
|
||||
expect(
|
||||
configured.inspectTargetSupport(executable("mach-o", "arm64")).status,
|
||||
).toBe("supported");
|
||||
expect(
|
||||
configured.inspectTargetSupport(executable("pe", "x86_64")),
|
||||
).toMatchObject({ status: "unsupported", code: "target_format_unsupported" });
|
||||
expect(
|
||||
configured.inspectTargetSupport(executable("mach-o-universal", "arm64")),
|
||||
).toMatchObject({ code: "target_format_unsupported" });
|
||||
expect(
|
||||
configured.inspectTargetSupport(executable("elf", "x86")),
|
||||
).toMatchObject({
|
||||
code: "architecture_unsupported",
|
||||
});
|
||||
});
|
||||
|
||||
it("commits the observed Kuna release and digest to the analysis profile", async () => {
|
||||
const { profile } = await session("rea-kuna-profile-");
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
provider: { id: "kuna", name: "Kuna", version: "1.833" },
|
||||
parameters: {
|
||||
kuna_version: "1.833",
|
||||
kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
verified_release: true,
|
||||
jobs: 1,
|
||||
cache_policy: "live",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the selected document without starting target analysis", async () => {
|
||||
const { kuna, target, cacheRoot, open } = await session(
|
||||
"rea-kuna-document-metadata-",
|
||||
{ project: "hang" },
|
||||
);
|
||||
const client = open();
|
||||
try {
|
||||
expect((await run(client, "list_documents")).result).toEqual([target.path]);
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).every(({ argv }) => argv[0] === "--version"),
|
||||
).toBe(true);
|
||||
await expect(readdir(cacheRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("answers a cold request from one function, then serves the finished export", async () => {
|
||||
const { kuna, cacheRoot, open } = await session("rea-kuna-cold-", {
|
||||
delayMs: 300,
|
||||
});
|
||||
const client = open();
|
||||
|
||||
const cold = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(cold.result).toBe(
|
||||
"int main(void) // single-function\n{\n return 0;\n}",
|
||||
);
|
||||
expect(cold.rawResult).toMatchObject({
|
||||
source: { kind: "single-function" },
|
||||
project_export: { state: "building" },
|
||||
});
|
||||
expect(cold.limitations.at(-1)).toContain("single-function `kuna decompile`");
|
||||
|
||||
// Assembly waits for the export, which then serves every later request.
|
||||
const assembly = await run(client, "procedure_assembly", {
|
||||
procedure: "0x1000",
|
||||
});
|
||||
expect(assembly.result).toBe("main: ; 0x1000\n 00001000: c3 RET");
|
||||
const warm = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(warm.result).toBe("int main(void)\n{\n return 0;\n}");
|
||||
expect(warm.rawResult).toMatchObject({
|
||||
source: {
|
||||
kind: "project-export",
|
||||
directory: expect.stringContaining(join(cacheRoot, "projects")),
|
||||
file: "program.c",
|
||||
file_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
},
|
||||
project_export: { state: "ready" },
|
||||
});
|
||||
// The recorded span is exactly the function's block in the cached .c.
|
||||
const source = warm.rawResult as {
|
||||
source: { directory: string; byte_offset: number; byte_length: number };
|
||||
};
|
||||
const c = await readFile(join(source.source.directory, "program.c"));
|
||||
expect(
|
||||
c
|
||||
.subarray(
|
||||
source.source.byte_offset,
|
||||
source.source.byte_offset + source.source.byte_length,
|
||||
)
|
||||
.toString(),
|
||||
).toBe("// Function: main @ 0x1000\nint main(void)\n{\n return 0;\n}\n\n");
|
||||
expect(await exportsOf(kuna)).toHaveLength(1);
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
|
||||
// A new session over a warm cache never takes the single-function path.
|
||||
const again = open();
|
||||
expect(
|
||||
await run(again, "procedure_pseudo_code", { procedure: "parse" }),
|
||||
).toMatchObject({
|
||||
result: "int parse(void)\n{\n return 1;\n}",
|
||||
rawResult: { source: { kind: "project-export" } },
|
||||
});
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === "decompile"),
|
||||
).toHaveLength(1);
|
||||
await again.close();
|
||||
});
|
||||
|
||||
it("verifies a warm export once for concurrent first requests on a fresh server", async () => {
|
||||
const { kuna, target, cacheRoot, profile, open } = await session(
|
||||
"rea-kuna-warm-concurrent-",
|
||||
);
|
||||
const builder = open();
|
||||
await run(builder, "procedure_assembly", { procedure: "main" });
|
||||
expect(await builder.close()).toEqual({ ok: true, value: null });
|
||||
|
||||
const lookup = vi.spyOn(KunaProjectCache.prototype, "lookup");
|
||||
const fresh = new KunaProvider(
|
||||
{ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot },
|
||||
{ platform: process.platform },
|
||||
).createClient(target, profile);
|
||||
try {
|
||||
const answers = await Promise.all(
|
||||
["main", "parse", "0x1000", "main"].map((procedure) =>
|
||||
run(fresh, "procedure_pseudo_code", { procedure }),
|
||||
),
|
||||
);
|
||||
for (const answer of answers)
|
||||
expect(answer.rawResult).toMatchObject({
|
||||
source: { kind: "project-export" },
|
||||
});
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
lookup.mockRestore();
|
||||
expect(await fresh.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs every inventory with the configured decode lanes", async () => {
|
||||
const { kuna, open } = await session(
|
||||
"rea-kuna-inventory-jobs-",
|
||||
{ project: "hang" },
|
||||
{ REA_KUNA_JOBS: "4" },
|
||||
);
|
||||
const client = open();
|
||||
try {
|
||||
await run(client, "list_procedures");
|
||||
await expect.poll(() => exportsOf(kuna)).toHaveLength(1);
|
||||
const calls = await fakeKunaCalls(kuna);
|
||||
const inventories = calls.filter(({ argv }) => argv[0] === "functions");
|
||||
expect(inventories.length).toBeGreaterThan(0);
|
||||
for (const { argv } of inventories)
|
||||
expect(argv.slice(2)).toEqual(["--json", "--jobs", "4"]);
|
||||
expect((await exportsOf(kuna))[0]?.argv.slice(-2)).toEqual(["--jobs", "4"]);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("lists, searches and resolves procedures from Kuna's inventory", async () => {
|
||||
const { open } = await session("rea-kuna-inventory-");
|
||||
const client = open();
|
||||
|
||||
expect((await run(client, "list_procedures")).result).toEqual([
|
||||
{ address: "0x1000", value: "main" },
|
||||
{ address: "0x1040", value: "parse" },
|
||||
{ address: "0x1080", value: "broken" },
|
||||
{ address: "0x2000", value: "puts" },
|
||||
]);
|
||||
expect(
|
||||
(await run(client, "search_procedures", { pattern: "^p", mode: "regex" }))
|
||||
.result,
|
||||
).toEqual([
|
||||
{ address: "0x1040", value: "parse" },
|
||||
{ address: "0x2000", value: "puts" },
|
||||
]);
|
||||
expect(
|
||||
(await run(client, "procedure_address", { procedure: "0X1040" })).result,
|
||||
).toBe("0x1040");
|
||||
for (const procedure of ["missing", "0x1044"])
|
||||
expect(
|
||||
await client.execute("procedure_address", { procedure }),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("rejects a name that identifies several entries", async () => {
|
||||
const { open } = await session("rea-kuna-ambiguous-", {
|
||||
functions: [
|
||||
{ name: "init", addr: "0x1000" },
|
||||
{ name: "init", addr: "0x1040" },
|
||||
],
|
||||
});
|
||||
const client = open();
|
||||
|
||||
expect(
|
||||
await client.execute("procedure_address", { procedure: "init" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
userMessage: expect.stringContaining("0x1000, 0x1040"),
|
||||
},
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("names function entries and the data Kuna's export labels", async () => {
|
||||
const { open } = await session("rea-kuna-names-");
|
||||
const client = open();
|
||||
|
||||
expect(
|
||||
(await run(client, "address_name", { address: "0x1040" })).result,
|
||||
).toBe("parse");
|
||||
expect(
|
||||
(await run(client, "address_name", { address: "0x3000" })).result,
|
||||
).toBe("dat_3000");
|
||||
expect(
|
||||
(await run(client, "address_name", { address: "0x3010" })).result,
|
||||
).toBe("g_table");
|
||||
const unnamed = await run(client, "address_name", { address: "0x9999" });
|
||||
expect(unnamed.result).toBeNull();
|
||||
expect(unnamed.limitations.at(-1)).toContain("not that no symbol exists");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("reports a failed function and an import slot as unavailable text, not errors", async () => {
|
||||
const { open } = await session("rea-kuna-unavailable-");
|
||||
const client = open();
|
||||
await run(client, "procedure_assembly", { procedure: "main" });
|
||||
|
||||
const broken = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "broken",
|
||||
});
|
||||
expect(broken.result).toBeNull();
|
||||
expect(broken.limitations.at(-1)).toBe(
|
||||
"Kuna could not decompile this function: Flow exceeded maximum instructions",
|
||||
);
|
||||
const imported = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "puts",
|
||||
});
|
||||
expect(imported.result).toBeNull();
|
||||
expect(
|
||||
await client.execute("procedure_assembly", { procedure: "puts" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("builds a dossier with pseudocode and assembly, and the rest explicitly unavailable", async () => {
|
||||
const { open } = await session("rea-kuna-dossier-");
|
||||
const client = open();
|
||||
|
||||
const dossier = await run(client, "analyze_function", { procedure: "main" });
|
||||
|
||||
expect(dossier.rawResult).toMatchObject({
|
||||
source: {
|
||||
kind: "project-export",
|
||||
files: [
|
||||
{
|
||||
facet: "pseudocode",
|
||||
file_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
},
|
||||
{
|
||||
facet: "assembly",
|
||||
file_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { source } = dossier.rawResult as {
|
||||
source: {
|
||||
directory: string;
|
||||
files: {
|
||||
facet: "pseudocode" | "assembly";
|
||||
file: string;
|
||||
file_sha256: string;
|
||||
byte_offset: number;
|
||||
byte_length: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
for (const file of source.files) {
|
||||
const bytes = await readFile(join(source.directory, file.file));
|
||||
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
|
||||
file.file_sha256,
|
||||
);
|
||||
const text = bytes
|
||||
.subarray(file.byte_offset, file.byte_offset + file.byte_length)
|
||||
.toString("utf8");
|
||||
const facet = (dossier.result as Record<string, JsonValue>)[file.facet];
|
||||
expect(text).toContain(Array.isArray(facet) ? facet.join("\n") : facet);
|
||||
}
|
||||
|
||||
expect(dossier.result).toMatchObject({
|
||||
procedure: {
|
||||
address: "0x1000",
|
||||
name: "main",
|
||||
body: { available: false },
|
||||
},
|
||||
pseudocode: "int main(void)\n{\n return 0;\n}",
|
||||
assembly: ["main: ; 0x1000", " 00001000: c3 RET"],
|
||||
facet_availability: {
|
||||
pseudocode: { available: true },
|
||||
assembly: { available: true },
|
||||
callers: { available: false },
|
||||
callees: { available: false },
|
||||
references: { available: false },
|
||||
cfg: { available: false },
|
||||
},
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("rejects oversized cached text before buffering while retaining its full export", async () => {
|
||||
const { cacheRoot, open } = await session("rea-kuna-text-capacity-", {
|
||||
functions: [{ name: "main", addr: "0x1000" }],
|
||||
cPadding: KUNA_FUNCTION_TEXT_BYTES,
|
||||
});
|
||||
const client = open();
|
||||
try {
|
||||
await run(client, "procedure_assembly", { procedure: "main" });
|
||||
for (const operation of [
|
||||
"procedure_pseudo_code",
|
||||
"analyze_function",
|
||||
] as const) {
|
||||
const result = await client.execute(operation, { procedure: "main" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "capacity",
|
||||
reportedLimits: { limit_bytes: KUNA_FUNCTION_TEXT_BYTES },
|
||||
});
|
||||
}
|
||||
const [key] = await readdir(join(cacheRoot, "projects"));
|
||||
const bytes = await readFile(
|
||||
join(cacheRoot, "projects", key ?? "", "project", "program.c"),
|
||||
);
|
||||
expect(bytes.length).toBeGreaterThan(KUNA_FUNCTION_TEXT_BYTES);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps answering pseudocode one function at a time when the export fails", async () => {
|
||||
const { open } = await session("rea-kuna-export-failed-", {
|
||||
project: "exit-failure",
|
||||
});
|
||||
const client = open();
|
||||
|
||||
expect(
|
||||
await client.execute("procedure_assembly", { procedure: "main" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
userMessage: expect.stringContaining(
|
||||
"not in recognized object file format",
|
||||
),
|
||||
},
|
||||
});
|
||||
const pseudocode = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(pseudocode.rawResult).toMatchObject({
|
||||
source: { kind: "single-function" },
|
||||
});
|
||||
expect(pseudocode.limitations.at(-1)).toContain("The project export failed");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("stops waiting for the export when the caller cancels, without abandoning it", async () => {
|
||||
const { open } = await session("rea-kuna-wait-cancel-", { delayMs: 1500 });
|
||||
const client = open();
|
||||
const caller = new AbortController();
|
||||
|
||||
const waiting = client.execute(
|
||||
"procedure_assembly",
|
||||
{ procedure: "main" },
|
||||
{ signal: caller.signal },
|
||||
);
|
||||
setTimeout(() => caller.abort(), 200);
|
||||
expect(await waiting).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
// The session's export continues and serves the next request.
|
||||
expect(
|
||||
(await run(client, "procedure_assembly", { procedure: "main" })).rawResult,
|
||||
).toMatchObject({ source: { kind: "project-export" } });
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("stops the export it alone wanted before reporting itself closed", async () => {
|
||||
const { kuna, cacheRoot, open } = await session("rea-kuna-close-", {
|
||||
project: "hang",
|
||||
});
|
||||
const client = open();
|
||||
await run(client, "procedure_pseudo_code", { procedure: "main" });
|
||||
await expect.poll(() => exportsOf(kuna)).toHaveLength(1);
|
||||
const pid = (await exportsOf(kuna))[0]?.pid ?? 0;
|
||||
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
expect(await readdir(join(cacheRoot, "staging"))).toEqual([]);
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
expect(
|
||||
await client.execute("procedure_pseudo_code", { procedure: "main" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"drains an active single-function request before close (caller signal: %s)",
|
||||
async (withCallerSignal) => {
|
||||
const { kuna, open } = await session("rea-kuna-close-active-", {
|
||||
project: "hang",
|
||||
decompile: "hang",
|
||||
});
|
||||
const client = open();
|
||||
const caller = new AbortController();
|
||||
const request = client.execute(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "main" },
|
||||
withCallerSignal ? { signal: caller.signal } : {},
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile",
|
||||
),
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const call = (await fakeKunaCalls(kuna)).find(
|
||||
({ argv }) => argv[0] === "decompile",
|
||||
);
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
expect(() => process.kill(call?.pid ?? 0, 0)).toThrow();
|
||||
expect(await request).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
} finally {
|
||||
caller.abort();
|
||||
await request;
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses another document, an unsupported operation and a replaced executable", async () => {
|
||||
const { kuna, open } = await session("rea-kuna-refusals-");
|
||||
const client = open();
|
||||
|
||||
expect(
|
||||
await client.execute("procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
document: "/other/program",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
expect(await client.execute("xrefs", { address: "0x1000" })).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
await client.close();
|
||||
|
||||
await appendFile(kuna, "\n// replaced\n");
|
||||
await setFakeKunaBehavior(kuna, { functions: FUNCTIONS });
|
||||
const replaced = open();
|
||||
expect(
|
||||
await replaced.execute("procedure_pseudo_code", { procedure: "main" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
userMessage: expect.stringContaining(
|
||||
"changed after this target was opened",
|
||||
),
|
||||
},
|
||||
});
|
||||
await replaced.close();
|
||||
});
|
||||
|
||||
it("retains an active request's workspace when close cannot prove its stop", async () => {
|
||||
const unproven = unprovenCleanupLauncher("decompile");
|
||||
const { kuna, open } = await session(
|
||||
"rea-kuna-close-active-unproven-",
|
||||
{ project: "hang", decompile: "hang" },
|
||||
{},
|
||||
unproven.launcher,
|
||||
);
|
||||
const client = open();
|
||||
const caller = new AbortController();
|
||||
const request = client.execute(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "main" },
|
||||
{ signal: caller.signal },
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile",
|
||||
),
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const closed = await client.close();
|
||||
expect(closed).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupIncomplete: true },
|
||||
});
|
||||
expect(await request).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupIncomplete: true },
|
||||
});
|
||||
const input = (await fakeKunaCalls(kuna)).find(
|
||||
({ argv }) => argv[0] === "decompile",
|
||||
)?.argv[1];
|
||||
expect(await readFile(input ?? "", "utf8")).toBe("program bytes");
|
||||
} finally {
|
||||
caller.abort();
|
||||
await request;
|
||||
unproven.release();
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps reporting a failed close and succeeds once a retry proves the stop", async () => {
|
||||
const unproven = unprovenCleanupLauncher("decompile");
|
||||
const { open } = await session(
|
||||
"rea-kuna-close-retry-",
|
||||
{ project: "hang", decompile: "hang" },
|
||||
{ REA_KUNA_TIMEOUT_MS: "300" },
|
||||
unproven.launcher,
|
||||
);
|
||||
const client = open();
|
||||
|
||||
expect(
|
||||
await client.execute("procedure_pseudo_code", { procedure: "main" }),
|
||||
).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
|
||||
const first = await client.close();
|
||||
expect(first).toMatchObject({
|
||||
ok: false,
|
||||
error: { diagnostics: { reason: "decompile stop unproven" } },
|
||||
});
|
||||
// A retry that still cannot prove the stop keeps failing.
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
|
||||
unproven.release();
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("reports an abandoned export's unproven cleanup from close", async () => {
|
||||
const unproven = unprovenCleanupLauncher("decompile-project");
|
||||
const { kuna, open } = await session(
|
||||
"rea-kuna-close-export-",
|
||||
{ project: "hang" },
|
||||
{},
|
||||
unproven.launcher,
|
||||
);
|
||||
const client = open();
|
||||
await run(client, "procedure_pseudo_code", { procedure: "main" });
|
||||
await expect.poll(() => exportsOf(kuna)).toHaveLength(1);
|
||||
|
||||
expect(await client.close()).toMatchObject({
|
||||
ok: false,
|
||||
error: { diagnostics: { reason: "decompile-project stop unproven" } },
|
||||
});
|
||||
unproven.release();
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("retries the abandoned export's file cleanup before reporting close success", async () => {
|
||||
const launcher: KunaProcessLauncher = async (options) => {
|
||||
const spawned = await spawnOwnedProviderProcess(options);
|
||||
if (options.arguments[0] !== "decompile-project") return spawned;
|
||||
return {
|
||||
...spawned,
|
||||
cleanup: async () => {
|
||||
const stopped = await cleanupOwnedProcessGroup(spawned.ownership);
|
||||
// The child is stopped, but its private input cannot yet be removed.
|
||||
await chmod(options.cwd ?? "", 0o500);
|
||||
return stopped;
|
||||
},
|
||||
};
|
||||
};
|
||||
const { kuna, cacheRoot, open } = await session(
|
||||
"rea-kuna-close-export-files-",
|
||||
{ project: "hang" },
|
||||
{},
|
||||
launcher,
|
||||
);
|
||||
const client = open();
|
||||
try {
|
||||
await run(client, "procedure_pseudo_code", { procedure: "main" });
|
||||
await expect.poll(() => exportsOf(kuna)).toHaveLength(1);
|
||||
expect(await client.close()).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupIncomplete: true },
|
||||
});
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
} finally {
|
||||
const restore = async (directory: string): Promise<void> => {
|
||||
await chmod(directory, 0o700);
|
||||
for (const entry of await readdir(directory, { withFileTypes: true }))
|
||||
if (entry.isDirectory()) await restore(join(directory, entry.name));
|
||||
};
|
||||
await restore(cacheRoot);
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
expect(await readdir(join(cacheRoot, "staging"))).toEqual([]);
|
||||
expect(await readdir(join(cacheRoot, "trash"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to launch an executable replaced after the target was opened", async () => {
|
||||
const { kuna, open } = await session("rea-kuna-swapped-", {
|
||||
project: "exit-failure",
|
||||
});
|
||||
const client = open();
|
||||
await run(client, "procedure_pseudo_code", { procedure: "main" });
|
||||
|
||||
await appendFile(kuna, "\n// replaced mid-session\n");
|
||||
expect(
|
||||
await client.execute("procedure_pseudo_code", { procedure: "parse" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
userMessage: expect.stringContaining(
|
||||
"changed after this target was opened",
|
||||
),
|
||||
},
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("recovers when another server evicts the export this session adopted", async () => {
|
||||
const { cacheRoot, open } = await session("rea-kuna-evicted-");
|
||||
const client = open();
|
||||
await run(client, "procedure_assembly", { procedure: "main" });
|
||||
const [key] = await readdir(join(cacheRoot, "projects"));
|
||||
await mkdir(join(cacheRoot, "trash"), { recursive: true });
|
||||
await rename(
|
||||
join(cacheRoot, "projects", key ?? ""),
|
||||
join(cacheRoot, "trash", "elsewhere"),
|
||||
);
|
||||
|
||||
// Pseudocode falls back while the export rebuilds; assembly waits for it.
|
||||
const pseudocode = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "parse",
|
||||
});
|
||||
expect(pseudocode.result).toContain("parse");
|
||||
const assembly = await run(client, "procedure_assembly", {
|
||||
procedure: "parse",
|
||||
});
|
||||
expect(assembly.rawResult).toMatchObject({
|
||||
source: { kind: "project-export" },
|
||||
});
|
||||
expect(await readdir(join(cacheRoot, "projects"))).toEqual([key]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("reports a failed export as failed rather than building", async () => {
|
||||
const { open } = await session("rea-kuna-failed-state-", {
|
||||
project: "exit-failure",
|
||||
});
|
||||
const client = open();
|
||||
await client.execute("procedure_assembly", { procedure: "main" });
|
||||
|
||||
expect(
|
||||
(await run(client, "procedure_pseudo_code", { procedure: "main" }))
|
||||
.rawResult,
|
||||
).toMatchObject({
|
||||
project_export: {
|
||||
state: "failed",
|
||||
reason: expect.stringContaining("not in recognized object file format"),
|
||||
},
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("lets one caller stop waiting for the shared inventory without failing another", async () => {
|
||||
const { open } = await session("rea-kuna-shared-inventory-", {
|
||||
inventoryDelayMs: 700,
|
||||
});
|
||||
const client = open();
|
||||
const first = new AbortController();
|
||||
|
||||
const cancelled = client.execute(
|
||||
"list_procedures",
|
||||
{},
|
||||
{ signal: first.signal },
|
||||
);
|
||||
const waiting = client.execute("search_procedures", { pattern: "main" });
|
||||
setTimeout(() => first.abort(), 100);
|
||||
|
||||
expect(await cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(await waiting).toMatchObject({
|
||||
ok: true,
|
||||
value: { result: [{ address: "0x1000", value: "main" }] },
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("leaves no private copy behind when workspace setup fails", async () => {
|
||||
const { target, open } = await session("rea-kuna-workspace-leak-", {
|
||||
project: "hang",
|
||||
});
|
||||
const runtime = await createTestTempDirectory("rea-kuna-runtime-");
|
||||
const previous = process.env.TMPDIR;
|
||||
process.env.TMPDIR = runtime;
|
||||
try {
|
||||
const client = open();
|
||||
// A vanished target makes the copy itself throw mid-setup.
|
||||
await rename(target.path, `${target.path}.moved`);
|
||||
|
||||
expect(
|
||||
await client.execute("procedure_pseudo_code", { procedure: "main" }),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(await readdir(runtime)).toEqual([]);
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TMPDIR;
|
||||
else process.env.TMPDIR = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { addAbortSignal, PassThrough } from "node:stream";
|
||||
|
||||
import { afterEach, expect, it as test, vi } from "vitest";
|
||||
|
||||
import { run, session } from "./kunaSessionFixture.js";
|
||||
|
||||
const held = vi.hoisted(() => ({
|
||||
path: "",
|
||||
streams: [] as PassThrough[],
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
createReadStream: (...args: Parameters<typeof actual.createReadStream>) => {
|
||||
if (String(args[0]) !== held.path)
|
||||
return actual.createReadStream(...args);
|
||||
const stream = new PassThrough();
|
||||
const options = args[1];
|
||||
if (typeof options === "object" && options?.signal != null)
|
||||
addAbortSignal(options.signal, stream);
|
||||
held.streams.push(stream);
|
||||
return stream;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
held.path = "";
|
||||
for (const stream of held.streams) stream.destroy();
|
||||
held.streams = [];
|
||||
});
|
||||
|
||||
const ready = async () => {
|
||||
const { cacheRoot, open } = await session("rea-kuna-reader-cancel-");
|
||||
const client = open();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await run(client, "procedure_pseudo_code", { procedure: "main" }))
|
||||
.rawResult,
|
||||
)
|
||||
.toMatchObject({ source: { kind: "project-export" } });
|
||||
const [key] = await readdir(join(cacheRoot, "projects"));
|
||||
held.path = join(cacheRoot, "projects", key ?? "", "project", "program.asm");
|
||||
return { client, bytes: await readFile(held.path) };
|
||||
};
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it.each([
|
||||
{ operation: "procedure_assembly", parameters: { procedure: "main" } },
|
||||
{ operation: "analyze_function", parameters: { procedure: "main" } },
|
||||
{ operation: "address_name", parameters: { address: "0x3010" } },
|
||||
] as const)(
|
||||
"cancels $operation without cancelling another index reader",
|
||||
async ({ operation, parameters }) => {
|
||||
const { client, bytes } = await ready();
|
||||
const controller = new AbortController();
|
||||
let finished = false;
|
||||
const cancelled = client
|
||||
.execute(operation, parameters, { signal: controller.signal })
|
||||
.then((value) => {
|
||||
finished = true;
|
||||
return value;
|
||||
});
|
||||
const other = run(client, "procedure_assembly", { procedure: "parse" });
|
||||
try {
|
||||
await expect.poll(() => held.streams.length).toBe(1);
|
||||
controller.abort();
|
||||
await expect.poll(() => finished, { timeout: 1000 }).toBe(true);
|
||||
expect(await cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(held.streams[0]?.destroyed).toBe(false);
|
||||
expect((await run(client, "health")).result).toBeDefined();
|
||||
held.streams[0]?.end(bytes);
|
||||
expect((await other).result).toContain("parse: ; 0x1040");
|
||||
expect(held.streams).toHaveLength(1);
|
||||
} finally {
|
||||
held.streams[0]?.end(bytes);
|
||||
await Promise.allSettled([cancelled, other]);
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("close aborts and drains the shared scan after its only caller cancels", async () => {
|
||||
const { client } = await ready();
|
||||
const controller = new AbortController();
|
||||
let result: unknown;
|
||||
const request = client
|
||||
.execute(
|
||||
"procedure_assembly",
|
||||
{ procedure: "main" },
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((value) => {
|
||||
result = value;
|
||||
return value;
|
||||
});
|
||||
let closing: ReturnType<typeof client.close> | undefined;
|
||||
try {
|
||||
await expect.poll(() => held.streams.length).toBe(1);
|
||||
controller.abort();
|
||||
await expect
|
||||
.poll(() => result, { timeout: 1000 })
|
||||
.toMatchObject({ ok: false });
|
||||
expect(held.streams[0]?.destroyed).toBe(false);
|
||||
let closed: unknown;
|
||||
closing = client.close().then((value) => {
|
||||
closed = value;
|
||||
return value;
|
||||
});
|
||||
await expect
|
||||
.poll(() => closed, { timeout: 1000 })
|
||||
.toEqual({ ok: true, value: null });
|
||||
expect(held.streams[0]?.destroyed).toBe(true);
|
||||
expect(held.streams[0]?.closed).toBe(true);
|
||||
} finally {
|
||||
held.streams[0]?.destroy();
|
||||
await request;
|
||||
await closing;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { session } from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it.each(["cancel", "deadline"])(
|
||||
"keeps regex %s bounded and the same session usable",
|
||||
async (mode) => {
|
||||
const { kuna, target, cacheRoot, profile } = await session(
|
||||
"rea-kuna-regex-",
|
||||
{
|
||||
functions: [
|
||||
{ name: `${"a".repeat(40)}!`, addr: "0x1000" },
|
||||
{ name: "Δelta", addr: "0x1040" },
|
||||
],
|
||||
},
|
||||
);
|
||||
// A parent watchdog bounds this regression even if matching freezes the
|
||||
// child's event loop. All Kuna commands finish before adversarial matching.
|
||||
const script = `
|
||||
import assert from "node:assert/strict";
|
||||
import { KunaProvider } from ${JSON.stringify(pathToFileURL(resolve("dist/kuna/KunaProvider.js")).href)};
|
||||
const provider = new KunaProvider(${JSON.stringify({ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot })});
|
||||
const client = provider.createClient(${JSON.stringify(target)}, ${JSON.stringify(profile)});
|
||||
try {
|
||||
assert.equal((await client.execute("procedure_assembly", {procedure:"0x1000"})).ok, true);
|
||||
const options = ${mode === "cancel" ? "{signal:AbortSignal.timeout(100)}" : "{}"};
|
||||
const matched = await client.execute("search_procedures", {pattern:"^(a+)+$",mode:"regex"}, options);
|
||||
assert.equal(matched.ok, false);
|
||||
assert.equal(matched.error._tag, ${JSON.stringify(mode === "cancel" ? "AnalysisCancelledError" : "AnalysisResourceConstraintError")});
|
||||
if (${JSON.stringify(mode)} === "deadline") assert.equal(matched.error.reportedLimits.matching_deadline_ms, 5000);
|
||||
assert.equal((await client.execute("health", {})).ok, true);
|
||||
const unicode = await client.execute("search_procedures", {pattern:"^δ",mode:"regex"});
|
||||
assert.equal(unicode.ok, true);
|
||||
assert.deepEqual(unicode.value.result, [{address:"0x1040",value:"Δelta"}]);
|
||||
const invalid = await client.execute("search_procedures", {pattern:"[",mode:"regex"});
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error._tag, "AnalysisInputError");
|
||||
const literal = await client.execute("search_procedures", {pattern:"^(a+)+$"});
|
||||
assert.equal(literal.ok, true);
|
||||
assert.deepEqual(literal.value.result, []);
|
||||
} finally {
|
||||
assert.equal((await client.close()).ok, true);
|
||||
}
|
||||
console.log("bounded and healthy");
|
||||
`;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", script],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 25_000,
|
||||
killSignal: "SIGKILL",
|
||||
},
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("bounded and healthy");
|
||||
},
|
||||
);
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user