diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 02c8ed478e..e45d5d566f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Contribution scope and integration requirements + url: https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope + about: Check supported capabilities, excluded workflows, and the fail-closed integration rule before proposing a feature. - name: REA Documentation url: https://github.com/morluto/rea#readme about: Read the README and AGENTS.md for usage guides and configuration details. diff --git a/.github/ISSUE_TEMPLATE/design_discussion.yml b/.github/ISSUE_TEMPLATE/design_discussion.yml index 912e18c44a..38d33ac821 100644 --- a/.github/ISSUE_TEMPLATE/design_discussion.yml +++ b/.github/ISSUE_TEMPLATE/design_discussion.yml @@ -1,5 +1,5 @@ name: Design discussion -description: Resolve an REA architecture, provider, or contract question before implementation +description: Discuss an REA architecture, provider, contract, or scope decision title: "[Design] " labels: - enhancement @@ -9,7 +9,12 @@ body: attributes: value: | Use this for a substantial provider, public/persisted contract, or - authority-boundary decision. Keep one decision per discussion. + authority-boundary decision. One question and a short explanation are + enough to start. Choose "Not sure yet" where needed; maintainers can help + with technical details, alternatives, and verification. + + Read the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope). + Unresolved boundaries block integration, not opening a discussion. - type: checkboxes id: before-submitting @@ -18,14 +23,16 @@ body: options: - label: I searched the open and closed issues and pull requests for a related discussion. required: true - - label: I removed credentials, tokens, private paths, and sensitive target data. + - label: I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here. + required: true + - label: This discussion does not include credentials, private samples, or sensitive target data. required: true - type: textarea id: decision attributes: label: Decision needed - description: State the exact question maintainers need to resolve. + description: What would you like maintainers to help decide? A short question is enough. placeholder: Should REA ... or ...? validations: required: true @@ -34,23 +41,57 @@ body: id: context-evidence attributes: label: Context and evidence - description: Explain why the decision is needed now. Separate observed behavior, derived evidence, and inference. + description: Explain the problem or motivation. If you have evidence, distinguish observed behavior from ideas or guesses. + validations: + required: true + + - type: dropdown + id: target-interaction + attributes: + label: Expected interaction with targets + description: >- + Select all known effects, including upstream tool behavior. Add "Not sure + yet" if anything is unclear. Use "No target interaction" only on its own. + These selections help discussion and do not establish scope approval. + multiple: true + options: + - Read existing files or captured data + - Launch or attach to a process + - Inject instrumentation or run scripts/code + - Change process memory or registers + - Write or change files/configuration + - Contact network services + - Recover protected code or work around protections + - No target interaction (documentation or usability only) + - Not sure yet validations: required: true - type: textarea id: boundary-invariants attributes: - label: Boundary and invariants - description: Identify the owning layer, provider/bridge boundary, artifact or evidence invariants, and any process or authorization constraints. - validations: - required: true + label: Boundary details (optional) + description: >- + If known, clarify target selection, selected effects, provider boundaries, + non-goals, or evidence invariants. Leave blank if you need help identifying + them. Do not include private samples or proof-of-authorization documents. - type: textarea id: options attributes: label: Options and tradeoffs - description: Compare a small number of viable approaches, including platform, compatibility, safety, evidence, and maintenance costs. + description: Optional approaches or tradeoffs. One idea is enough; maintainers can help compare alternatives. + + - type: dropdown + id: verification-readiness + attributes: + label: Verification available so far + description: Pick the closest match. An idea without samples or test results is welcome. + options: + - Idea only; I need help defining checks + - I can suggest a sample or manual check + - I have a prototype or observed results + - Not sure yet validations: required: true @@ -58,9 +99,10 @@ body: id: success-criteria attributes: label: Success criteria and open questions - description: State what outcome would settle the decision and list only unresolved questions that could change the choice. - validations: - required: true + description: >- + Optional expected output, sample, possible check, or unresolved question. + A complete test plan is not required to start; maintainers and implementers + will resolve material verification gaps before integration. - type: textarea id: references diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 7b0767b276..4125f3db52 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -8,9 +8,13 @@ body: - type: markdown attributes: value: | - Keep one primary capability or workflow outcome per request. For a - substantial provider, public contract, or architecture change, explain - the decision maintainers need to make before implementation. + Describe one problem and the outcome you would like. Short answers are + welcome. Choose "Not sure yet" wherever needed; you do not need code, a + finished design, or a complete test plan to propose an idea. + + Read the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope). + Maintainers can help resolve scope and technical questions. Unresolved + boundaries block integration, not opening a request for discussion. - type: checkboxes id: before-submitting @@ -19,7 +23,9 @@ body: options: - label: I searched the open and closed issues and pull requests for a related proposal. required: true - - label: I removed credentials, tokens, private paths, and sensitive target data. + - label: I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here. + required: true + - label: This request does not include credentials, private samples, or sensitive target data. required: true - type: dropdown @@ -36,6 +42,7 @@ body: - MCP or agent integration - Documentation - Other + - Not sure yet validations: required: true @@ -43,7 +50,7 @@ body: id: current-limitation attributes: label: Current limitation - description: What user or agent workflow is blocked, ambiguous, or unnecessarily difficult today? + description: What are you trying to understand or do, and what is difficult today? A short example is enough. validations: required: true @@ -51,23 +58,69 @@ body: id: use-cases attributes: label: Use cases - description: Give concrete applications, targets, callers, or investigation workflows. Explain why current tools are insufficient. - validations: - required: true + description: Optional additional examples of applications, targets, or callers. Leave blank if covered above. - type: textarea id: desired-outcome attributes: label: Desired outcome and success criteria - description: Describe observable behavior, inputs, outputs, evidence, and compatibility criteria that would make this useful. + description: What would a useful result look like? Describe the output or behavior you want; implementation details are optional. + placeholder: For a selected APK, show whether it is packed and include the detection evidence. + validations: + required: true + + - type: dropdown + id: target-interaction + attributes: + label: Expected interaction with targets + description: >- + Select all known effects, including upstream tool behavior. Add "Not sure + yet" if anything is unclear. Use "No target interaction" only on its own. + These selections help discussion and do not establish scope approval. + multiple: true + options: + - Read existing files or captured data + - Launch or attach to a process + - Inject instrumentation or run scripts/code + - Change process memory or registers + - Write or change files/configuration + - Contact network services + - Recover protected code or work around protections + - No target interaction (documentation or usability only) + - Not sure yet validations: required: true - type: textarea id: boundaries attributes: - label: Authority boundaries and non-goals - description: Call out process execution, target mutation, provider licensing, evidence provenance, supported platforms, and work that should remain out of scope. + label: Target and scope details (optional) + description: >- + If known, name the target/platform, clarify selected effects, or list a + non-goal or scope question. You can leave this blank; maintainers can help. + Do not include private samples or proof-of-authorization documents. + + - type: dropdown + id: verification-readiness + attributes: + label: Verification available so far + description: Pick the closest match. An idea without samples or test results is welcome. + options: + - Idea only; I need help defining checks + - I can suggest a sample or manual check + - I have a prototype or observed results + - Not sure yet + validations: + required: true + + - type: textarea + id: verification + attributes: + label: Verification details (optional) + description: >- + Share an expected output, a possible check, or existing results if you have + them. Self-owned or appropriately licensed samples can help, but are not + required to open a request. Do not attach private or sensitive data. - type: textarea id: alternatives diff --git a/.github/actions/portable-build-cache/action.yml b/.github/actions/portable-build-cache/action.yml index 0fb8903c12..ad127ce3dc 100644 --- a/.github/actions/portable-build-cache/action.yml +++ b/.github/actions/portable-build-cache/action.yml @@ -11,7 +11,7 @@ runs: id: plan shell: bash env: - BUILD_CACHE_PREFIX: rea-portable-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc') }} + BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }} run: | npm run --silent test:prepare -- --dry=json | node --input-type=module -e ' @@ -25,6 +25,7 @@ runs: }).sort(); const hash = createHash("sha256").update(hashes.join("\n")).digest("hex"); console.log(`key=${process.env.BUILD_CACHE_PREFIX}-${hash}`); + console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`); ' >> "$GITHUB_OUTPUT" - name: Restore portable build outputs if: inputs.save != 'true' @@ -32,9 +33,11 @@ runs: with: path: .turbo/cache/ key: ${{ steps.plan.outputs.key }} + restore-keys: ${{ steps.plan.outputs.prefix }} - name: Restore and save portable build outputs if: inputs.save == 'true' uses: actions/cache@v4 with: path: .turbo/cache/ key: ${{ steps.plan.outputs.key }} + restore-keys: ${{ steps.plan.outputs.prefix }} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e33ce01f4f..3dec52bd07 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,17 +19,39 @@ describe the use case and observable outcome. --> +Follow the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope). +Unresolved scope, authority, side effects, or required verification block +integration. Link the required maintainer scope decision for new providers or +capabilities involving target execution, injection, mutation, or protection +recovery; ordinary fixes within existing boundaries may state "not applicable". +Draft PRs may leave the scope decision pending and the final review checklist +unchecked. Maintainers can help resolve open questions. + ## Contract and boundary impact - + - Semantic owner and earliest changed stage: +- Contribution scope fit and linked maintainer decision: - CLI and MCP/tool-catalog contract: - Provider, bridge, target-format, or platform compatibility: - Evidence, artifact, provenance, or reconstruction contract: -- Process execution, authorization, cleanup, or containment impact: +- Target selection, authority, upstream behavior, and cleanup details (if applicable): - Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `.agents/skills/reverse-engineer-anything/` and never commit build outputs): +Expected effects (check known effects; use documentation-only on its own). +Unchecked items do not establish absence; leave uncertainties visible. + +- [ ] Read existing files or captured data. +- [ ] Launch or attach to a process. +- [ ] Inject instrumentation or run scripts/code. +- [ ] Change process memory, registers, files, or configuration. +- [ ] Contact network services. +- [ ] Recover protected code or work around protections. +- [ ] Documentation or usability only; no target interaction. +- [ ] Not sure yet; investigation or maintainer guidance needed. + ## Evidence and regression coverage - [ ] This PR addresses a concrete problem or an agreed enhancement. +- [ ] Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff. - [ ] The PR has one focused outcome and the title follows `type(scope): outcome`. - [ ] Related issue is linked, or the reason for not linking one is stated above. - [ ] Tests cover changed observable behavior and meaningful failure paths. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20824bae33..ba8c026177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: run: npm run check:ci - if: needs.changes.outputs.code == 'true' run: npm run verify:test-discovery + - name: Check LLDB producer retention and launch contracts + if: needs.changes.outputs.code == 'true' + run: npm run test:python - if: needs.changes.outputs.code != 'true' run: npm run format:check @@ -168,7 +171,7 @@ jobs: continue-on-error: true uses: actions/download-artifact@v4 with: - name: test-results + name: test-timings run-id: ${{ steps.history.outputs.run-id }} github-token: ${{ github.token }} path: .cache/ci-test-history @@ -203,14 +206,10 @@ jobs: if: ${{ always() }} runs-on: ubuntu-latest steps: - - name: Require successful test shards + - name: Require successful change classification env: CHANGES_RESULT: ${{ needs.changes.result }} - CODE_REQUIRED: ${{ needs.changes.outputs.code }} - SHARD_RESULT: ${{ needs.test-shard.result }} - run: | - test "${CHANGES_RESULT}" = "success" - test "${CODE_REQUIRED}" != "true" || test "${SHARD_RESULT}" = "success" + run: test "${CHANGES_RESULT}" = "success" - uses: actions/checkout@v4 if: needs.changes.outputs.code == 'true' && success() - uses: actions/setup-node@v4 @@ -227,6 +226,7 @@ jobs: pattern: vitest-report-* merge-multiple: true - name: Merge coverage, JUnit, and timing reports + id: merge if: needs.changes.outputs.code == 'true' && success() run: >- npm run test:ci:merge -- @@ -240,27 +240,13 @@ jobs: --outputFile.junit=test-results.xml --outputFile.json=test-results.json - name: Summarize slowest test files - if: needs.changes.outputs.code == 'true' && success() - shell: bash - run: | - node <<'NODE' >> "${GITHUB_STEP_SUMMARY}" - const report = JSON.parse(require("node:fs").readFileSync("test-results.json", "utf8")); - const files = (report.testResults ?? []) - .map((result) => ({ - name: result.name, - duration: Math.max(0, (result.endTime ?? result.startTime) - result.startTime), - })) - .sort((left, right) => right.duration - left.duration) - .slice(0, 20); - console.log("## Slowest deterministic test files\n"); - console.log("| File | Duration (ms) |"); - console.log("| --- | ---: |"); - for (const file of files) { - console.log(`| \`${file.name}\` | ${file.duration} |`); - } - NODE + if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != '' + env: + SHARD_RESULT: ${{ needs.test-shard.result }} + MERGE_RESULT: ${{ steps.merge.outcome }} + run: node scripts/ci/test-report.mjs - uses: actions/upload-artifact@v4 - if: needs.changes.outputs.code == 'true' && always() + if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != '' with: name: test-results path: | @@ -269,6 +255,21 @@ jobs: coverage/ if-no-files-found: error retention-days: 30 + - name: Publish successful timing history + if: needs.changes.outputs.code == 'true' && success() && needs.test-shard.result == 'success' + uses: actions/upload-artifact@v4 + with: + name: test-timings + path: .cache/ci-test-history/test-results.json + include-hidden-files: true + if-no-files-found: error + retention-days: 30 + - name: Require successful test shards + if: ${{ !cancelled() }} + env: + CODE_REQUIRED: ${{ needs.changes.outputs.code }} + SHARD_RESULT: ${{ needs.test-shard.result }} + run: test "${CODE_REQUIRED}" != "true" || test "${SHARD_RESULT}" = "success" conformance-fixtures: name: Source-built conformance fixtures @@ -503,6 +504,7 @@ jobs: tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts tests/conformance/providers/ghidra/windowsCapabilities.test.ts src/ghidra/GhidraInstallation.test.ts + tests/boundary/filesystem/ghidraJavaProbe.test.ts tests/boundary/providers/ghidra/ghidraTransport.test.ts tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts tests/boundary/providers/ghidra/ghidraLauncher.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6ee0229f2b..7849a33eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ REA is a local-only reverse-engineering tool with shared CLI/MCP workflows. Distinguish observations, derivations, inferences, and unknowns; preserve Evidence provenance and session semantics through both adapters. Provider support and setup live in [README.md](README.md#choosing-a-deep-analysis-provider) and its linked guides. +Follow the [contribution scope and fail-closed integration rule](CONTRIBUTING.md#contribution-scope). Do not integrate capabilities while material scope, authority, side-effect, or required verification questions remain unresolved. For new providers or capabilities involving target execution, injection, mutation, or protection recovery, seek the required maintainer scope decision before substantial implementation for integration. Welcome early proposals, small exploratory prototypes, and draft PRs that help resolve questions; they do not authorize integration. + Ghidra uses ephemeral databases without modifying executable bytes or controlling a GUI; Windows Ghidra P0 has no mutation authority. IDA analysis is read-only; never save or close an attached GUI database. Configuration changes must be additive and idempotent, with backups. Installers must not install or upgrade unrelated software, including Homebrew, Node.js, npm, Java, or Ghidra. Ghidra and IDA are bring-your-own. `rea setup` must show its planned changes and require approval before writing files or installing Hopper. @@ -26,6 +28,31 @@ Parse unknown boundary values and model expected failures with the tagged error Validate advertised JSON Schemas after SDK conversion against their declared dialect and actual clients. Bind handlers to named contracts with exact types; catalog ordering must never select an operation or schema. +## Performance + +Design analysis workflows and agent interfaces for efficiency from the first +implementation. Correctness, useful answers, Evidence provenance, caller choices, +compatibility and safe resource bounds remain required. + +Optimize in this order: + +1. Agent tokens and context. Return the smallest sufficient answer with + necessary provenance, coverage and limitations. Use task selection, + summaries, paging and retained Evidence where supported; honor requested + detail and keep complete Evidence accessible. Count the whole task, + including schemas, extra calls and retries. +2. Analysis time. Reuse validated results and shared work. Cache keys must + cover result-affecting inputs; define invalidation and lifetime. Validate + freshness before reporting cached live state as current. +3. CPU and memory. Avoid unnecessary work and copies; bound concurrency, + retained state and serialization growth. + +For changes affecting these costs, measure representative completed tasks and +compare equivalent workflows before and after where available. Report token +usage, elapsed time, CPU time and peak process-family memory. Separate cold +and warm runs, label estimates, and state unmeasured costs. Fewer bytes alone +do not establish fewer tokens or faster analysis. + ## Development Use [.nvmrc](.nvmrc) and `package.json#packageManager` for the pinned toolchain. Cursor Cloud may place older Node.js first on `PATH`; environment setup installs the pinned toolchain under `/usr/local/bin`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7a65be588..a8d5f3635a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,108 @@ targets; optional adapter failures must preserve successful peers. Run `npm run verify:test-discovery` after adding or moving tests. Resolved import boundaries are checked by `npm run verify:module-boundaries`, included in lint. +For analysis workflows and agent interfaces, follow the +[performance priorities](AGENTS.md#performance). Explain the selected output +scope, reuse/cache decisions, and relevant before/after measurements when these +costs change. + When adding or changing an MCP tool, follow the [tool design guide](docs/tool-design.md) and preserve the canonical contracts and generated catalog. +## Contribution scope + +REA focuses on software structure, runtime behavior, and evidence through +general-purpose reverse-engineering workflows. Evaluate a proposal by its actual +capabilities, side effects, defaults, and complete workflow. A tool name or a +reference to vulnerability research, malware analysis, or CTFs does not determine +whether it belongs in REA. + +Supported contributions include format inspection, ordinary archive and standard +compression extraction, disassembly, decompilation, cross-references, general +static deobfuscation, vulnerability analysis, and evidence reporting. Static +protection detection is distinct from defeating that protection. Security fixes +and isolated regression tests remain in scope. + +Runtime investigation needs explicit boundaries. Launching or attaching to a +process, injecting instrumentation, running scripts, and changing memory or +registers require review of target selection, execution and mutation authority, +provenance, resource limits, cancellation, and cleanup. General Frida or debugger +support is not inherently read-only, and existing support does not authorize +every workflow built on it. General-purpose interfaces are not classified as +dedicated bypass workflows solely because caller-provided code could be misused; +their execution and mutation authority must still be reviewed. + +REA does not integrate or officially distribute: + +- Purpose-built workflows to defeat commercial packing or code-virtualization + protections and automatically recover protected code. +- Anti-debugging or anti-instrumentation evasion, or DRM, license, anti-cheat, or + access-control bypass workflows. +- Exploit or attack-payload automation, credential theft, covert persistence, + unauthorized access, or bulk attack workflows. + +These boundaries apply to first-party providers, adapters, plugins, bundled +scripts, defaults, examples, and documentation. Moving a capability into an +optional dependency or plugin does not exempt it. Review what the integration +exposes, including upstream defaults and raw-command or script execution paths. +Ordinary extraction and general static deobfuscation are not excluded merely +because the input is packed or obfuscated; assess added bypass or execution +behavior separately. + +This is a product and maintenance boundary, not a judgment about a contributor's +intentions or a declaration that all excluded research is unlawful. Contributors +must have the rights and authorization needed for their work. A local-only design +or an "authorized use only" disclaimer does not establish containment or settle +legal and scope questions. + +### Fail-closed integration + +If scope fit, target or authorization boundaries, side effects, or the verification +needed to establish those boundaries remain materially unresolved, **do not merge, +enable, or include the capability in official packages or distributions**. Keep +the proposal pending, narrow it, or decline it. Record unknowns explicitly; do not +turn them into "none" or assumed safety. Passing CI, completed implementation, +upstream popularity, or silence from maintainers is not scope approval. + +For a new provider or a capability involving target execution, injection, +mutation, or protection recovery, seek an explicit maintainer scope decision in +the linked issue or design discussion before substantial implementation intended +for integration. Small exploratory prototypes and draft PRs may help inform that +decision; they do not authorize integration. The decision must cover the final +proposed behavior; a scope expansion needs a new decision. Routine fixes and +documentation changes within existing boundaries do not need a separate advance +decision, but must not silently expand those boundaries. + +### Starting a proposal + +To open a feature request or design discussion, describe the problem and desired +outcome or decision. Short answers and the forms' "Not sure yet" options are +welcome. You do not need code, a complete design or test plan, legal analysis, or +private proof-of-authorization documents to start a discussion. Share what you +know and leave optional details blank when you need help. + +Form selections guide triage; they are not proof of authorization or scope +approval. Unselected effects must not be treated as established absence. +Maintainers and implementers work together to resolve the following before +integration: + +- The concrete analyst question, requested inputs and outputs, and why existing + REA operations are insufficient. +- Why the workflow fits this scope, its non-goals, and any protection recovery, + bypass, or attack automation it would expose. +- The selected targets and every execution, injection, mutation, network, or + persistence effect, including behavior inherited from upstream tools. +- How the boundaries and outcome will be verified with self-owned or appropriately + licensed samples; identify real CLI/MCP and provider coverage, cleanup and + resource checks, and remaining unknowns. Do not publish private binaries, + credentials, or sensitive target data; follow [SECURITY.md](SECURITY.md) for + private vulnerability reports. + +Mixed proposals should separate acceptable analysis capabilities from excluded +workflows so each can be decided independently. An issue may discuss unresolved +questions; opening an issue or PR does not authorize integration. This is a +maintainer integration gate, not a per-call confirmation requirement or a sandbox +guarantee. + ## Development setup Use the development toolchain pinned in `.nvmrc` and @@ -87,6 +187,10 @@ CI diagnosis. Packaging/setup/distribution changes additionally require `npm run verify:package` and `npm pack --dry-run`; provider changes require the matching real-provider lane. State which host/provider workflows ran. +`check:pr` reuses build and static-check outputs while executing the test suite +fresh, including the fast Python producer checks. `docs:check` also runs the +onboarding example consistency suite for README-only changes. + Pre-commit formats and lints staged files; pre-push runs `check:fast`. ### Generated files and command ownership diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index fb2ce9aac7..e5a95c5365 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -148,6 +148,11 @@ existing analysis families directly, for example - Cancellation during a package pull preserves caller-selected output. The error includes partial observations identifying completed artifacts and the interrupted local path; remaining APKs were not attempted. +- Bugreports collect into isolated staging and publish with exclusive creation; + filename collisions select the next numeric suffix. If publication fails, the + completed archive remains in staging. The error's partial observation gives + its recoverable `local_path`, the original `adb_reported_path`, and an unknown + digest status. Later operations and shutdown preserve this retained evidence. - Failed package acquisition rollback retains its directory identity and retries cleanup before later work and during shutdown. Cleanup refuses a replacement directory. Terminal shutdown stops admission and drains active operations. diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 1674d8fd11..1f4ff14d60 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -123,8 +123,10 @@ Select a verified native child for the existing tools: ``` Then use `analyze_function`, `inspect_native_api` or the other native primitives. -Native architecture and executable validation still apply. This milestone does -not add MIPS/PPC/RISC-V decompilation, raw flash load profiles, bootloader/device +Native architecture and executable validation still apply. Generic +[MIPS ELF](ghidra-mips.md) and [RISC-V ELF](ghidra-riscv.md) lanes are separate +documents with their own admission boundaries. This milestone does +not add PPC decompilation, raw flash load profiles, bootloader/device tree semantics, vulnerability scanning, emulation or hardware runtime capture. ## Resource and lifecycle policy diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md new file mode 100644 index 0000000000..e180122d71 --- /dev/null +++ b/docs/ghidra-riscv.md @@ -0,0 +1,105 @@ +# RISC-V ELF analysis through Ghidra + +This lane extends native ELF analysis for `EM_RISCV` targets. It does not +execute RISC-V code, emulate a boot sequence, or rehost firmware. It addresses +[#2063](https://github.com/morluto/rea/issues/2063) and follows the same +lane shape as [generic MIPS support](ghidra-mips.md). + +## Admission boundary + +ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The +target also retains the raw ELF declarations that decide a lane: class, byte +order, `e_type`, and `e_flags`. Recognition does not mean every provider or +RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets +are unchanged, and caller-supplied headerless raw-binary entry still accepts only +its existing four families. + +The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that +declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`, +`EF_RISCV_TSO`, or any bit outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI`. These are +header declarations, not proof that every instruction conforms to that ISA. + +Provider selection returns the specific unsupported constraint rather than +guessing a compatible profile: + +| Declaration | Outcome | +| ----------------------------------------------- | --------------------------------------------------------------------------- | +| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc` | +| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc` | +| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language | +| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value | +| `EF_RISCV_RVE` | refused; the embedded 15-register ABI needs its own processor specification | +| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here | +| Soft, single, or quad float ABI | refused; the pinned generic compiler specification models double-float | +| Reserved or newer `e_flags` bits | refused with the offending bits named | + +`e_type` is the only load-shape declaration this lane reads. A fixed-address +`ET_EXEC` declaration does **not** establish static linkage: `PT_INTERP` and +dynamic dependencies are not inspected, and the session records that boundary as +a limitation rather than inferring a load model. + +## Why the language is pinned + +Ghidra's header-based selection is not reliable for RV32. The 12.1.4 +`riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves the +32-bit constraint unpinned with the comment that the loader "must choose between +'default' and 'AndeStar' variants". Measured against the installed release tree: + +| Fixture | Ghidra auto-selection | +| ----------------------------------------------- | ----------------------------- | +| RV64 ELF, `e_flags 0x5` (RVC, double-float ABI) | `RISCV:LE:64:default:gcc` | +| RV32 ELF, `e_flags 0x5`, no Andes declaration | `RISCV:LE:32:AndeStar_v5:gcc` | + +So an ordinary RV32 image lands on an Andes processor model unless the language +is stated. REA therefore pins `RISCV:LE:32:default` or `RISCV:LE:64:default` +with the `gcc` compiler specification per class, records +`language_selection: provider-pinned-v1`, and refuses a caller-selected +`REA_GHIDRA_LANGUAGE_ID` or `REA_GHIDRA_COMPILER_SPEC_ID` that disagrees with the +pin without changing the requested override. An override that matches the pin is +accepted and recorded as `configured-v1`. + +## Evidence + +The lane identity is `elf32-exec-le-double-float-generic-v1` or +`elf64-exec-le-double-float-generic-v1`, and `riscv_elf` retains `elf_class`, +`byte_order`, `type`, and the raw `flags` alongside the read-only booleans +`float_abi_double`, `rvc_declared`, `rve_declared`, and `tso_declared`. Those +fields report declarations the header makes; none of them is evidence of the +original compiler, of instruction conformance, or of a floating-point calling +convention beyond the ABI the pinned language models. + +## Verification and limits + +Ghidra 12.1.4 PUBLIC with a 64-bit JDK 21+ is required, and on macOS the release +tree ships no host decompiler binary, so `Decompiler/os//decompile` and +`sleigh` must come from your own build; see +[installation](installation.md#ghidra). + +Run `npm run verify:ghidra:riscv` with an existing Ghidra 12.1.4 installation, +JDK 21+, and a RISC-V-capable Clang, LLD and LLVM readelf. The verifier acquires +no tools and never executes target code. Select installed tools explicitly when +they are not on `PATH`: + +```sh +GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC \ +JAVA_HOME=/path/to/jdk-21 \ +REA_RISCV_CLANG=/path/to/llvm/bin/clang \ +REA_RISCV_LLD=/path/to/lld/bin/ld.lld \ +REA_RISCV_READELF=/path/to/llvm/bin/llvm-readelf \ +npm run verify:ghidra:riscv +``` + +The lane builds the committed freestanding C and assembly sources into RV32 and +RV64 `ET_EXEC` fixtures. Independent LLVM readelf observations establish ELF +declarations and symbol addresses. Public CLI and stdio MCP workflows verify +function enumeration, decompilation, pinned language/compiler identity, decoded +probe bytes, global data, and direct calls. It validates advertised MCP output +schemas and both JSON representations, saves and reopens snapshots, rejects +header-mutated soft-float and unsupported high-bit declarations, and checks +unchanged source bytes and owned cleanup. Failures retain the diagnostic +workspace; successful runs remove it and report the observed process lineage. + +Module tests cover admission and malformed headers. Call-graph completeness, +string inventories, dynamic linkage, Andes or RVE/TSO images, and big-endian +targets are **not** established by this lane. `.riscv.attributes` ISA extensions +are not inspected; the generic language pin remains an analysis convention. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 3627379ad6..27381246b4 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -1,3 +1,5 @@ +
+ # Native binary investigation Use a native provider to discover functions, inspect instructions and pseudocode, diff --git a/docs/reconstruction-readiness.md b/docs/reconstruction-readiness.md index 1827311c49..cf4784aca8 100644 --- a/docs/reconstruction-readiness.md +++ b/docs/reconstruction-readiness.md @@ -36,7 +36,7 @@ check project-owned conformance fixtures and fail-closed report behavior. | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `discover-classify` | target-free startup, artifact inventory, compatible workflow selection, and exact visible limits | | `diagnose-environment` | broken runtime separated from analysis failure, version skew detected, and revision changed after recovery | -| `declare-request` | exact target, operation, and lifecycle are carried by the request before launch | +| `acquire-authority` | missing scope reported, narrow grant resumed, expansion reprompted, and denial launched nothing | | `static-analysis` | native and JavaScript routing plus preserved ambiguity, truncation, unsupported syntax, and residual unknowns | | `reactive-scenarios` | predicate-driven interaction, descendant settlement, Electron correlation, and cancellation cleanup Evidence | | `compare-authority-candidate` | explicit partial-order/finite-trace semantics, primary divergence localization, and unknown for incomplete comparison | diff --git a/docs/testing.md b/docs/testing.md index edf2be452f..bbad3f77ed 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -19,11 +19,12 @@ behavior, and complete CI evidence before merging. | `npm run test:boundary` | Boundary, CLI boundary, MCP boundary, process boundary, and process-global projects | | `npm run test:mcp` | MCP boundary project | | `npm run test:acceptance` | Complete compiled CLI and MCP acceptance workflows | +| `npm run test:python` | LLDB producer retention and launch contracts with a fake LLDB API; no native debugger required | | `npm run test:watch` | Dirty source tests in watch mode, without build | | `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests | | `npm run check:changed` | Cached typecheck/lint and branch-related source feedback | | `npm run check:pr` | Opt-in complete local deterministic gate and generated-file checks | -| `npm run docs:check` | Generated-document validation from current source and build outputs | +| `npm run docs:check` | Generated-document validation and fresh onboarding example consistency checks | For example: @@ -97,6 +98,12 @@ runner. Preserve simultaneous launches where concurrency is itself under test. Build/documentation writers hold checkout-local output locks. Tests do not hold a broad command lock; `check:pr` finishes tests before document validation. +Turbo caches deterministic preparation and static checks, but `test:run` always +executes. Repository files do not fully describe installed tool contents or host +state. A previous test pass therefore cannot replace current validation, even +when the source tree is unchanged. `check:pr` also runs the fast Python producer +suite; CI runs it in the static job for code changes. These producer tests do not +establish real LLDB engine coverage. Vitest's persistent compile cache remains disabled. The `cliTest` fixture shares a temporary Node compile cache among its fresh CLI subprocesses. The worker @@ -247,17 +254,25 @@ Coverage does not replace named boundary or real-provider scenarios. CI runs six native Vitest shards without retries. Each shard emits a blob report; the merge job produces aggregate coverage plus JUnit and JSON timing -reports, uploads them together, and writes the slowest files to the workflow -summary. Static checks, documentation, build, package verification, and +reports even when a shard fails, uploads available diagnostics, and writes the +slowest files to the workflow summary. Partial reports are marked diagnostic +only. Failed, cancelled, or missing selected shards still fail the required gate, +and aggregate coverage thresholds remain enforced by the merge command. +Only successful complete merges publish the small `test-timings` artifact, with +checkout-relative paths; the build job downloads that artifact for the sequencer +instead of the coverage/JUnit bundle. Missing history falls back to native +sharding. Static checks, documentation, build, package verification, and real-system lanes remain separate jobs so one kind of evidence cannot stand in for another. The build job saves portable Turbo outputs across runs. Eight Linux real-system lanes restore this cache without waiting for the build job. The shared action derives its key from `test:prepare --dry=json` task hashes, runner OS and -architecture, and `.npmrc`; test-only changes can reuse compiled outputs. Every +architecture, `.npmrc`, `.nvmrc`, and the lockfile; test-only changes can reuse compiled outputs. Every build and verification command still runs, so Turbo checks task inputs and -rebuilds misses. There is no prefix fallback to accumulate older build outputs. +rebuilds misses. A restore prefix with the same platform and toolchain permits +partial task reuse when one generator's inputs change; Turbo still invalidates +that generator's entry. Native outputs are not stored in this cache. Native package jobs, the Windows curated lane, and the Apple artifact lane reuse the same workflow's Turbo cache for portable compiled JavaScript, skills, and @@ -266,9 +281,10 @@ commands, which restore matching outputs or rebuild on a cache miss. Native artifacts, dependency installs, package archives, and runtime verification remain host-local. Keep platform-dependent outputs out of this shared cache. -The PR acceptance target is a median `npm run check:pr` wall time below three -minutes across three warm-build runs on the benchmark host. Keep Vitest caches -cold unless separately identified. A PR that touches packaging or real-system +Record cold and warm preparation separately from fresh test execution when +benchmarking the optional `npm run check:pr` gate. Compare multiple runs on the +same host, including subprocess and queue costs; a cached pass is not a test +runtime measurement. A PR that touches packaging or real-system behavior requires the applicable `verify:*` lanes; packaging and installation changes also require `npm run verify:package`. The full-gate benchmark measures that explicit lane, not the routine iteration requirement. @@ -292,6 +308,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. | `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback and unknown-name rejection | Ghidra 12.1.4 and full JDK 21 | | `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites | @@ -684,7 +701,8 @@ not make native host acceptance unavailable. Pull requests that change only root `README*.md` files, `docs/`, `AGENTS.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md`, `native/windows/README.md`, or the GitHub issue and pull-request templates run formatting and -generated-document validation without the source-test shards or native package +generated-document validation, including the small onboarding example consistency +suite, without the source-test shards or native package lanes. Classification compares the PR head with its merge base, so later base-branch changes do not expand that scope. Static and coverage aggregate jobs remain present and fail if classification fails. Every code change retains the diff --git a/docs/tool-design.md b/docs/tool-design.md index a4dd155f06..6117ff6d03 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -48,10 +48,12 @@ satisfy. A plan without execution does not establish runtime behavior. - Use strict inputs with explicit target identity and required fields. Preserve meaningful target, action, capture, and output choices; remove ignored options and repeated permission declarations. -- Return complete evidence inline, with artifact/provider identity, source - locations, Evidence references, and relevant confidence and limitations. - Missing coverage is unknown, not absence. Avoid extra lookups solely to - understand the answer or its provenance. +- Return the requested facts inline with artifact/provider identity, source + locations, Evidence references, coverage, and relevant confidence and + limitations. Use supported selection, summaries or views when they answer + the question; preserve access to complete source Evidence and honor existing + contracts and requested detail. Missing coverage is unknown, not absence. + Avoid extra lookups solely to understand the answer or its provenance. - Keep observed, derived, and inferred relationships distinct. Preserve unresolved edges and partial provider facts instead of requiring every provider to supply the same metadata. diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 9a09e9d365..834d717cd8 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -7,6 +7,10 @@ JSON measurements belong outside the checkout. This document keeps the measured findings, verification scope and revision references; the scripts and `corpus.json` keep the reproduction inputs. Generated `results/` is ignored. +Follow-up: [native PE/DOS entry views and real model verification](native-entry-views.md) +records the post-merge native continuation, including a small-function case where +four selected replies cost more bytes than the complete dossier. + The 12 detailed checkpoint receipts were archived byte-for-byte before removal from the final PR tree. The archive `review-receipts-00d51e0fc-20261012` is in the private experiment lab outside the checkout; its `archive-index.json` records diff --git a/experiments/analysis-efficiency/compare.mjs b/experiments/analysis-efficiency/compare.mjs index 8c386a4d93..b6ba1a5528 100644 --- a/experiments/analysis-efficiency/compare.mjs +++ b/experiments/analysis-efficiency/compare.mjs @@ -30,6 +30,7 @@ const sameSettings = [ "provider_search_path_sha256", "provider_environment_policy_sha256", "measure_source_sha256", + "native_workflow_source_sha256", "package_lock_sha256", "node_version", "sdk_client_version", @@ -40,7 +41,9 @@ const sameSettings = [ const settingDifferences = sameSettings.filter( (key) => !isDeepStrictEqual(left[key], right[key]) || - (key === "provider_search_path_sha256" && + ((key === "provider_search_path_sha256" || + (key === "native_workflow_source_sha256" && + typeof left.native_question === "string")) && !/^[a-f0-9]{64}$/.test(left[key] ?? "")), ); // Producer/view IDs identify different delivery observations; a named recovery @@ -54,6 +57,17 @@ const facts = (answer) => const a = facts(leftAnswer); const b = facts(rightAnswer); const runSpecific = []; +for (const [side, answer] of [ + ["left", leftAnswer], + ["right", rightAnswer], +]) { + if (Object.hasOwn(answer, "evidence_ids")) + runSpecific.push({ + side, + path: ["evidence_ids"], + value: answer.evidence_ids, + }); +} // Binwalk assigns a new opaque region UUID in each invocation. Preserve both // original lookup values in this report; compare the observed region facts // separately. The product and original replies retain those IDs unchanged. diff --git a/experiments/analysis-efficiency/corpus.json b/experiments/analysis-efficiency/corpus.json index 911fa88f5f..5d2ad531dd 100644 --- a/experiments/analysis-efficiency/corpus.json +++ b/experiments/analysis-efficiency/corpus.json @@ -116,6 +116,36 @@ "derivation": "selected regular ZIP member ShareX.HelpersLib.dll; no target execution" } }, + { + "id": "sharex.exe", + "file": "ShareX.exe", + "bytes": 198656, + "sha256": "46af1ccc514b395cb703633abb5a505a771942b7db9b1915f31aeea541b3685f", + "provenance": { + "project": "ShareX native executable component", + "version": "19.0.2", + "upstreamFile": "ShareX-19.0.2-portable.zip", + "url": "https://github.com/ShareX/ShareX/releases/download/v19.0.2/ShareX-19.0.2-portable.zip", + "transport_sha256": "967b5d838df5ce25fb5fd7cbd16f1bd797427e2df16907b022b74a4bc0fb9d54", + "transport_bytes": 168796487, + "derivation": "selected regular ZIP member ShareX.exe; PE32+ x86-64 with no CLR directory; no target execution" + } + }, + { + "id": "freedos.edlin.exe", + "file": "EDLIN.EXE", + "bytes": 31492, + "sha256": "96b2b6d543e2d8bdc83e313756444e103339eb5ee2c1229abe3607539b31ad6e", + "provenance": { + "project": "FreeDOS EDLIN", + "version": "2.24", + "upstreamFile": "edlin.zip", + "url": "https://www.ibiblio.org/pub/micro/pc-stuff/freedos/files/repositories/1.4/base/edlin.zip", + "transport_sha256": "9db8a0b50bc4be5c2358cd0fa1fb6fc77342c6d5175a6b1084547c370e428f8a", + "transport_bytes": 377009, + "derivation": "selected regular ZIP member BIN/EDLIN.EXE; original UPX-compressed DOS MZ; APPINFO/EDLIN.LSM reports version; no execution or unpacking" + } + }, { "id": "vscode.tree-sitter.wasm", "file": "tree-sitter.wasm", diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs index 348bf02c2a..0bfd6da529 100644 --- a/experiments/analysis-efficiency/measure.mjs +++ b/experiments/analysis-efficiency/measure.mjs @@ -1,16 +1,14 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; -import { - mkdir, - open, - readFile, - readdir, - stat, - writeFile, -} from "node:fs/promises"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import { + NATIVE_ARTIFACTS, + measureNativeEntry, + nativeEntrySeed, +} from "./native-entry.mjs"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { Ajv2020 } from "ajv/dist/2020.js"; @@ -38,10 +36,15 @@ if ( !isAbsolute(values.path) || !isAbsolute(values.output) || !["complete", "summary"].includes(values.delivery) || - !["entry", "entry-and-main"].includes(values["native-question"]) || + !["entry", "entry-and-main", "entry-views"].includes( + values["native-question"], + ) || !["inventory", "selected"].includes(values["javascript-question"]) || !["default", "selected"].includes(values.schemas) || - (values.artifact !== "jq" && values["native-question"] !== "entry") || + (!NATIVE_ARTIFACTS.includes(values.artifact) && + values["native-question"] !== "entry") || + (values.artifact !== "jq" && + values["native-question"] === "entry-and-main") || (values.artifact !== "obsidian.asar" && values["javascript-question"] !== "inventory") ) @@ -118,6 +121,9 @@ await writeFile( ); const fingerprints = { measure_source_sha256: await fileSha(fileURLToPath(import.meta.url)), + native_workflow_source_sha256: await fileSha( + fileURLToPath(new URL("./native-entry.mjs", import.meta.url)), + ), runtime_files_sha256: sha(JSON.stringify(runtimeFiles)), runtime_dispatcher_sha256: await fileSha( resolve(values["runtime-root"], "scripts/rea.mjs"), @@ -545,111 +551,19 @@ const workflows = { evidence_ids: [info.evidence_id], }; }, - jq: async () => { - const file = await open(values.path, "r"); - let header; - try { - header = (await file.read(Buffer.alloc(32), 0, 32, 0)).buffer; - } finally { - await file.close(); - } - if (header.subarray(0, 6).toString("hex") !== "7f454c460201") - throw new Error("Expected ELF64 little endian entry seed"); - const address = `0x${header.readBigUInt64LE(24).toString(16)}`; - await call("open_binary", { path: values.path, provider_id: "ghidra" }); - try { - const containing = await call("resolve_containing_procedure", { - address, - }); - const info = await call("analyze_function", { procedure: address }); - const n = normalized(info); - // This exact application entry is materialized in the pinned jq entry's - // observed MOV RDI, 0x401f00 instruction, not an original-source claim. - let main; - const mainEvidenceIds = []; - if (values["native-question"] === "entry-and-main") { - let sourceId; - try { - const completed = await call("analyze_function", { - procedure: "0x401f00", - }); - sourceId = completed.evidence_id; - } catch (error) { - const reference = - error instanceof ToolFailure && - error.value?.error?.code === "resource_constraint" - ? error.value.error.details?.reported_limits?.evidence_reference - : undefined; - if (reference?.kind !== "retained-evidence") throw error; - sourceId = reference.evidence_id; - } - mainEvidenceIds.push(sourceId); - main = {}; - for (const facet of [ - "procedure", - "pseudocode", - "referenced_strings", - "callees", - "callers", - "basic_blocks", - ]) { - let offset = 0; - const items = []; - const pages = []; - do { - const selected = await call("inspect_analysis_view", { - source: { kind: "retained-evidence", evidence_id: sourceId }, - view: { - kind: "native", - facet, - offset, - limit: - facet === "pseudocode" - ? 65536 - : facet === "basic_blocks" - ? 1 - : 64, - }, - }); - mainEvidenceIds.push(selected.evidence_id); - const v = normalized(selected); - items.push(v.item); - pages.push({ - coverage: v.coverage, - limitations: v.limitations, - unknowns: v.unknowns, - }); - if (v.coverage.exhausted || facet === "basic_blocks") break; - if ( - v.coverage.next_offset === null || - v.coverage.next_offset <= offset - ) - throw new Error("Native view did not advance"); - offset = v.coverage.next_offset; - } while (true); - main[facet] = { items, pages }; - } - } - return { - artifact_sha256: targetSha(info), - entry_address: address, - containing_procedure: normalized(containing), - procedure: n.procedure, - pseudocode_prefix: n.pseudocode.slice(0, 512), - caller_count: n.callers.length, - callee_count: n.callees.length, - ...(main === undefined ? {} : { main }), - analysis_profile: info.analysis_profile, - evidence_ids: [ - containing.evidence_id, - info.evidence_id, - ...mainEvidenceIds, - ], - }; - } finally { - await call("close_binary", {}); - } - }, + ...Object.fromEntries( + NATIVE_ARTIFACTS.map((artifact) => [ + artifact, + async () => + measureNativeEntry({ + seed: await nativeEntrySeed(artifact, values.path), + values, + call, + inputSha, + ToolFailure, + }), + ]), + ), }; try { @@ -740,7 +654,9 @@ const report = { artifact_sha256_after: finalInputSha, delivery: values.delivery, sdk_schema_mode: values.schemas, - native_question: values.artifact === "jq" ? values["native-question"] : null, + native_question: NATIVE_ARTIFACTS.includes(values.artifact) + ? values["native-question"] + : null, javascript_question: values.artifact === "obsidian.asar" ? values["javascript-question"] : null, provider_settings_sha256: sha(JSON.stringify(selectedProviders)), diff --git a/experiments/analysis-efficiency/native-entry-views.md b/experiments/analysis-efficiency/native-entry-views.md new file mode 100644 index 0000000000..1c02fbfd14 --- /dev/null +++ b/experiments/analysis-efficiency/native-entry-views.md @@ -0,0 +1,178 @@ +# Native PE and DOS entry-view checkpoint + +Continuation of [#2016](https://github.com/morluto/rea/issues/2016) after +#2122 and #2193 merged. Real Ghidra observations and real Codex CLI answers +support the selected native facets on these shipped inputs. They do not justify +an automatic native summary policy: the four selected replies are larger than +the complete dossier for the small PE entry, and smaller for the DOS entry. +The initial complete analysis still runs and is delivered in both cases. + +This checkpoint changes experiment scripts, corpus inputs and grading only. +Existing production CLI/MCP contracts, defaults and session behavior are unchanged. + +## Inputs and runtime + +Production runtime: main `10400fdb57e4226c5ce15af17968a664f35498ce`, +including the merged WASM summary work and subsequent main changes. Node +24.18.0/npm 11.16.0, locked dependencies, actual Ghidra 12.1.4 on Linux. +Default auto-analysis remains enabled, with a 768 MiB JVM ceiling and one +active processor. The whole process family uses the existing one-CPU, +4 GiB hard/3 GiB early-stop, no-swap, bounded-task resource scope. + +| Shipped artifact | Selected bytes | SHA-256 | Static scope | +| ---------------------------------- | -------------: | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| ShareX 19.0.2 `ShareX.exe` | 198,656 | `46af1ccc514b395cb703633abb5a505a771942b7db9b1915f31aeea541b3685f` | Native PE32+ x86-64 component with no CLR directory; entry `0x1400125c0` derived from original image base/RVA | +| FreeDOS EDLIN 2.24 `BIN/EDLIN.EXE` | 31,492 | `96b2b6d543e2d8bdc83e313756444e103339eb5ee2c1229abe3607539b31ad6e` | Original UPX-compressed DOS MZ; admitted entry `0x10000`, independently verified static load image | + +Acquisition URLs, archive sizes/digests and exact regular ZIP members are in +[corpus.json](corpus.json). EDLIN's package `APPINFO/EDLIN.LSM` reports its +version and UPX compression. No binary was executed, unpacked or committed. +The independent PE header does not establish Windows runtime behavior. +DOS load-image verification and unpacking-stub pseudocode do not recover the +editor's application logic or establish DOS execution. DOS COM is still untested. + +The SDK and frozen model runtime share the exact `dist/` and `bridge/` file +table digest: +`e2b67eb0966072fd8b159edfab074cbaf1db9bfc0ac9c57d25ba1435cc0fdbcf`. +The model copy was independently checked against all 1,375 compiled files and +30 bridge files. Runtime copies and detailed receipts remain outside Git. + +## Public SDK observations + +The workflow opens the selected target with Ghidra, resolves the entry, +calls `analyze_function` once, reads retained procedure/pseudocode/callers/callees +facets, and closes the ephemeral session. DOS obtains its address from +`inspect_native_load_image` before resolving the procedure. Each facet is checked +against the complete producer facts, original parent ID, target digest and distinct +derived Evidence ID. The actual SDK validates named advertised output contracts. + +| Entry | Complete dossier JSON bytes | Four facet replies combined | Workflow seconds | First provider query seconds | Last-observed family CPU seconds | Sampled family peak MiB | +| ------------ | --------------------------: | --------------------------: | ---------------: | ---------------------------: | -------------------------------: | ----------------------: | +| ShareX PE | 50,936 | 68,257 | 63.123 | 57.490 | 63.660 | 1,197.85 | +| EDLIN DOS MZ | 224,670 | 70,539 | 26.436 | 21.366 | 26.899 | 939.25 | + +These are exploratory observations, not a paired implementation performance +cohort. JSON byte counts include the SDK tool-result representations, excluding +catalog discovery and wire framing; they are not model token counts. Four PE +facets cost about 34% more bytes than that dossier. The DOS facets cost about +69% fewer bytes **when reading already-retained Evidence**. Their first analysis +still returned the full dossier; summing both paths increases first-task traffic. +The first provider query includes the observable wait for the imported Program +and its analysis, not an independently measured engine-only stage. + +The observed PE entry contains 18 analyzed bytes, zero recorded callers and two +resolved callees. Ghidra's terminal-call caveat remains explicit. The DOS entry +contains 53 analyzed bytes and zero recorded callers/callees; overlapping +instruction and abnormal stack-frame warnings remain in the pseudocode. +Neither zero count establishes absence of unresolved runtime calls. + +An earlier PE probe completed production analysis and two facets, then failed +its harness assertion: a pseudocode page is `{text, unit}`, not a string. That +failed attempt remains archived and is excluded from successful workflow figures. +The corrected exploratory SDK driver fingerprints are +`4876c0ecaa84069ddc508e29649715c6c914bd3c4960a76fa9724b5992123699` +and native-helper `76023ad45cd1edab57dcd4a8ccf29a9b8a8378029ff2d9711ae35c73940e80d6`. +The published helper in `6d7f6885c` removes redundant scopes; replaying the actual +PE/DOS replies reproduces every answer field and the same 9/10 calls. Recorded +real jq entry and entry/main replies also preserve their selected facts, profile +and 4/12-call ordering. These replays are driver regression checks, not fresh +provider runs or timing measurements. + +## Real Codex CLI observations + +Three serial runs used Codex CLI 0.161.0, requested alias `gpt-5.6-terra`, high +reasoning, temporary isolated profiles and the frozen modified REA MCP runtime. +Resolved server-side model identity is not inferred. The same explicit seven-tool +subset was enabled: open, containing-procedure resolution, dossier, analysis view, +load image, retained Evidence discovery and close. This is not full-catalog tool +selection verification. Questions are [PE](questions/native-pe.txt) and +[DOS](questions/native-dos.txt). + +| Trial | Total input | Cached input | Uncached input | Output | Seconds | Calls / failed | +| ----- | ----------: | -----------: | -------------: | -----: | ------: | -------------: | +| PE 1 | 451,337 | 399,616 | 51,721 | 3,820 | 130.073 | 12 / 2 | +| DOS 1 | 730,578 | 658,944 | 71,634 | 3,310 | 134.435 | 9 / 0 | +| PE 2 | 498,322 | 429,056 | 69,266 | 3,038 | 192.373 | 9 / 1 | + +All three answers match the selected original digest, address, procedure +classification/signature/locals, inclusive body ranges, body byte count, +pseudocode prefix and relationship counts. Actual Evidence references were +observed in the responses; target bytes remained unchanged, close completed and +the copied profiles were removed. DOS additionally matches the verified load +status and every supplied load-image metadata field. Manual review preserves +static/packed-target and terminal-flow qualifications. + +The first grader expected only particular nested JSON shapes. It rejected PE's +equivalent text-page object/inclusive-range label and could not grade DOS's +explicit `entry @ 0x10000` display. The updated grader compares exact source +values for those representations without parsing displays into lookup identities. +It still rejects wrong text units, returned lengths, body endpoints and missing +facts. Original answers and first grading attempts remain archived; regrading +does not repeat model calls or change their tokens/times. + +The PE agent hit the advertised-but-unimplemented native summary recipe: both +trials attempted `view: {kind: "summary"}`; the first also tried the invalid +native facet `summary`. Those failed calls and all subsequent recovery/repeated +reads remain included. The public SDK independently reproduced `invalid_request` +for native summary on both inputs. This pre-existing contract-description mismatch +is recorded in `/tmp/rea-analysis-efficiency-bugs-20261012.md` for team repair, +as requested; this branch does not repair it. + +Model sampled peaks were 1,318.74 / 1,017.70 / 1,234.92 MiB, with last-observed +family CPU 72.956 / 71.534 / 74.089 seconds. SDK/model scopes did not abort and +their last-observed limit-event counters were zero. Final cgroup counters remained +unavailable after scope teardown: the strict resource-qualified overall grades +remain unverified. The selected fact/lifecycle checks pass; no universal latency, +OOM absence or CPU/memory saving is inferred from these samples. + +## Reproduction and next decision + +Use the pinned toolchain and build the selected production revision. Keep its +runtime copy, matching dependencies, provider settings, binaries and receipts +outside the checkout, as described in [README.md](README.md). + +```sh +python3 experiments/analysis-efficiency/resource-run.py -- \ + node experiments/analysis-efficiency/measure.mjs \ + --runtime-root /absolute/frozen-runtime --revision EXACT_PRODUCTION_SHA \ + --artifact sharex.exe --path /absolute/ShareX.exe \ + --providers /absolute/explicit-providers.json \ + --native-question entry-views --schemas selected \ + --output /absolute/new-native-measurement +``` + +For DOS select `--artifact freedos.edlin.exe` and the original extracted +`EDLIN.EXE`. `--native-question entry` retains the existing compact experimental +answer; `entry-views` performs additional verification reads and the explicit +summary refusal probe. It does not select summary delivery from `analyze_function`. +The helper fingerprint participates in comparison, and missing native helper +fingerprints are not treated as a comparable current driver. + +The reviewed `entry-views` answer separates selected facet facts from run-specific +references: `evidence_ids.retained_views` retains each facet's original view and +parent ID, while `retained_views` holds its item, coverage, limitations and unknowns. +DOS load-image facts retain their source ID and must match the selected carrier +digest before their entry address can be used; a stale result closes the session +without querying a procedure. +Derived view IDs are checked against every prior producer reference as well as +other derived views. DOS model grades require the actual completed load-image +response and its reported source ID; status, digest, entry and supplied metadata +are checked against that observation. Missing, failed or unreferenced responses +cannot establish load-image verification. +The comparator preserves both runs' reference maps and compares the facts. Existing +`entry`/`entry-and-main` answers keep their ID arrays. Historical SDK receipts above +retain their original pre-review representation; no replies or measurements were +rewritten, and no provider/model runs were repeated for this representation fix. + +Relevant validation includes the real workflows above, recorded-reply regression, +grader negative probes, JavaScript/Python syntax, formatting/diff checks and +`npm run check:fast`. The cold type/lint gate passed in 112.602 seconds with +sampled peak 3,220,914,176 bytes and five last-observed `memory.high` events; +this gate is not a performance-qualified experiment. Unrelated domain suites, +packaging and earlier model cohorts were not rerun for this experiment-only change. + +The next useful comparison is question-specific native primitives versus a +complete dossier, with identical requested facts and actual model trials. +Larger PE functions and another unpacked DOS/COM application can extend the +coverage progressively. Preserve existing complete defaults and solve the +summary-description bug separately before recommending a native summary route. diff --git a/experiments/analysis-efficiency/native-entry.mjs b/experiments/analysis-efficiency/native-entry.mjs new file mode 100644 index 0000000000..84bc934ff6 --- /dev/null +++ b/experiments/analysis-efficiency/native-entry.mjs @@ -0,0 +1,272 @@ +import { open, readFile } from "node:fs/promises"; +import { isDeepStrictEqual } from "node:util"; + +export const NATIVE_ARTIFACTS = ["jq", "sharex.exe", "freedos.edlin.exe"]; +const normalized = (value) => value.normalized_result; + +const retainedEntryViews = async ( + call, + info, + inputSha, + ToolFailure, + sourceEvidenceIds, +) => { + const n = normalized(info); + const retainedViews = []; + const retainedViewEvidence = []; + const observedEvidenceIds = new Set([info.evidence_id, ...sourceEvidenceIds]); + let nativeSummaryOutcome, summaryEvidenceId; + for (const facet of ["procedure", "pseudocode", "callers", "callees"]) { + const selected = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: info.evidence_id }, + view: { + kind: "native", + facet, + offset: 0, + limit: facet === "pseudocode" ? 512 : 64, + }, + }); + const view = normalized(selected); + if (observedEvidenceIds.has(selected.evidence_id)) + throw new Error("Retained native view reused an Evidence identity"); + const expected = + facet === "procedure" + ? n.procedure + : facet === "pseudocode" + ? { text: n.pseudocode.slice(0, 512), unit: "utf16-code-units" } + : n[facet].slice(0, 64); + if ( + !isDeepStrictEqual(view.item, expected) || + view.parent_evidence_id !== info.evidence_id || + view.artifact.sha256 !== inputSha + ) + throw new Error( + "Retained native view changed the selected source facts or identity", + ); + observedEvidenceIds.add(selected.evidence_id); + retainedViews.push({ + facet, + item: view.item, + coverage: view.coverage, + limitations: view.limitations, + unknowns: view.unknowns, + }); + retainedViewEvidence.push({ + facet, + evidence_id: selected.evidence_id, + parent_evidence_id: view.parent_evidence_id, + }); + } + // Exercise the advertised summary recipe separately. A known refusal + // is recorded as such; it does not validate a nonexistent capability. + try { + const summary = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: info.evidence_id }, + view: { kind: "summary" }, + }); + if (observedEvidenceIds.has(summary.evidence_id)) + throw new Error("Native summary reused an Evidence identity"); + nativeSummaryOutcome = { status: "supported" }; + summaryEvidenceId = summary.evidence_id; + } catch (error) { + if ( + !(error instanceof ToolFailure) || + error.value?.error?.code !== "invalid_request" + ) + throw error; + nativeSummaryOutcome = { + status: "unsupported", + error: error.value.error, + }; + } + return { + retainedViews, + retainedViewEvidence, + nativeSummaryOutcome, + summaryEvidenceId, + }; +}; + +const retainedJqMain = async (call, ToolFailure) => { + // This exact application entry is materialized in the pinned jq entry's + // observed MOV RDI, 0x401f00 instruction, not an original-source claim. + let main; + const mainEvidenceIds = []; + let sourceId; + try { + const completed = await call("analyze_function", { + procedure: "0x401f00", + }); + sourceId = completed.evidence_id; + } catch (error) { + const reference = + error instanceof ToolFailure && + error.value?.error?.code === "resource_constraint" + ? error.value.error.details?.reported_limits?.evidence_reference + : undefined; + if (reference?.kind !== "retained-evidence") throw error; + sourceId = reference.evidence_id; + } + mainEvidenceIds.push(sourceId); + main = {}; + for (const facet of [ + "procedure", + "pseudocode", + "referenced_strings", + "callees", + "callers", + "basic_blocks", + ]) { + let offset = 0; + const items = []; + const pages = []; + do { + const selected = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: sourceId }, + view: { + kind: "native", + facet, + offset, + limit: + facet === "pseudocode" ? 65536 : facet === "basic_blocks" ? 1 : 64, + }, + }); + mainEvidenceIds.push(selected.evidence_id); + const v = normalized(selected); + items.push(v.item); + pages.push({ + coverage: v.coverage, + limitations: v.limitations, + unknowns: v.unknowns, + }); + if (v.coverage.exhausted || facet === "basic_blocks") break; + if (v.coverage.next_offset === null || v.coverage.next_offset <= offset) + throw new Error("Native view did not advance"); + offset = v.coverage.next_offset; + } while (true); + main[facet] = { items, pages }; + } + return { main, mainEvidenceIds }; +}; + +export const measureNativeEntry = async ({ + seed, + values, + call, + inputSha, + ToolFailure, +}) => { + await call("open_binary", { path: values.path, provider_id: "ghidra" }); + try { + // DOS addresses follow the actual admitted load image. A file header + // offset is not a provider address or proof of runtime execution. + let loadImage, loadImageEvidenceId; + if (seed === undefined) { + const imageEvidence = await call("inspect_native_load_image", {}); + loadImage = normalized(imageEvidence); + if (loadImage.status !== "verified") + throw new Error( + "The shipped DOS load image was not independently verified", + ); + if (loadImage.target_sha256 !== inputSha) + throw new Error("The DOS load image belongs to a different target"); + loadImageEvidenceId = imageEvidence.evidence_id; + seed = loadImage.entry.linear_address; + } + const address = seed; + const containing = await call("resolve_containing_procedure", { + address, + }); + const info = await call("analyze_function", { procedure: address }); + const n = normalized(info); + const selected = + values["native-question"] === "entry-views" + ? await retainedEntryViews(call, info, inputSha, ToolFailure, [ + containing.evidence_id, + ...(loadImageEvidenceId === undefined ? [] : [loadImageEvidenceId]), + ]) + : {}; + const { main, mainEvidenceIds = [] } = + values["native-question"] === "entry-and-main" + ? await retainedJqMain(call, ToolFailure) + : {}; + return { + artifact_sha256: info.subject?.digest.sha256, + entry_address: address, + containing_procedure: normalized(containing), + procedure: n.procedure, + pseudocode_prefix: n.pseudocode.slice(0, 512), + caller_count: n.callers.length, + callee_count: n.callees.length, + ...(main === undefined ? {} : { main }), + analysis_profile: info.analysis_profile, + ...(loadImage === undefined ? {} : { load_image: loadImage }), + ...(selected.retainedViews === undefined + ? {} + : { + retained_views: selected.retainedViews, + native_summary_outcome: selected.nativeSummaryOutcome, + }), + evidence_ids: + selected.retainedViews === undefined + ? [ + containing.evidence_id, + info.evidence_id, + ...mainEvidenceIds, + ...(loadImageEvidenceId === undefined + ? [] + : [loadImageEvidenceId]), + ] + : { + containing_procedure: containing.evidence_id, + function_dossier: info.evidence_id, + ...(loadImageEvidenceId === undefined + ? {} + : { native_load_image: loadImageEvidenceId }), + retained_views: selected.retainedViewEvidence, + ...(selected.summaryEvidenceId === undefined + ? {} + : { native_summary: selected.summaryEvidenceId }), + }, + }; + } finally { + await call("close_binary", {}); + } +}; + +export const nativeEntrySeed = async (artifact, path) => { + if (artifact === "jq") { + const file = await open(path, "r"); + let header; + try { + header = (await file.read(Buffer.alloc(32), 0, 32, 0)).buffer; + } finally { + await file.close(); + } + if (header.subarray(0, 6).toString("hex") !== "7f454c460201") + throw new Error("Expected ELF64 little endian entry seed"); + return `0x${header.readBigUInt64LE(24).toString(16)}`; + } + if (artifact === "sharex.exe") { + const bytes = await readFile(path); + const pe = bytes.readUInt32LE(0x3c); + const optional = pe + 24; + if ( + bytes.subarray(pe, pe + 4).toString("hex") !== "50450000" || + bytes.readUInt16LE(pe + 4) !== 0x8664 || + bytes.readUInt16LE(optional) !== 0x20b || + bytes.readUInt32LE(optional + 108) <= 14 || + bytes.readUInt32LE(optional + 112 + 14 * 8) !== 0 || + bytes.readUInt32LE(optional + 116 + 14 * 8) !== 0 + ) + throw new Error( + "Expected the pinned native PE32+ entry seed without CLR metadata", + ); + const address = + bytes.readBigUInt64LE(optional + 24) + + BigInt(bytes.readUInt32LE(optional + 16)); + return `0x${address.toString(16)}`; + } + if (artifact === "freedos.edlin.exe") return undefined; + throw new Error("Select a pinned native artifact"); +}; diff --git a/experiments/analysis-efficiency/questions/native-dos.txt b/experiments/analysis-efficiency/questions/native-dos.txt new file mode 100644 index 0000000000..48c52a9ec7 --- /dev/null +++ b/experiments/analysis-efficiency/questions/native-dos.txt @@ -0,0 +1 @@ +Statically inspect {{artifact_path}} using only the registered REA tools and Ghidra. This is the original UPX-compressed FreeDOS EDLIN 2.24 DOS MZ executable; inspect its admitted load image and entry stub, without running or unpacking it. Keep default auto-analysis enabled. Open the target with provider_id ghidra, call inspect_native_load_image, and use its verified entry.linear_address as the seed. Resolve the containing procedure, analyze that procedure once, and close the ephemeral session after obtaining the facts. If a result is clipped, use retained native facets without reanalysis. Report exact target SHA-256, entry_address, containing_procedure, complete procedure metadata including classification, signature, locals and inclusive body ranges, the first 512 UTF-16 units of pseudocode, caller_count, callee_count, real Evidence IDs and limitations. Preserve missing/partial facts and terminal-flow caveats. Verified static import and unpacking-stub pseudocode do not prove DOS execution or recovery of the editor's application logic. Do not execute targets, launch a GUI, or use shell/file tools/network or other agents. Final JSON fields: artifact_sha256, load_image_status, entry_address, containing_procedure, procedure, pseudocode_prefix, caller_count, callee_count, evidence_ids, limitations. Stop when these facts and cleanup are supported; report unsupported outcomes explicitly. diff --git a/experiments/analysis-efficiency/questions/native-pe.txt b/experiments/analysis-efficiency/questions/native-pe.txt new file mode 100644 index 0000000000..749e962003 --- /dev/null +++ b/experiments/analysis-efficiency/questions/native-pe.txt @@ -0,0 +1 @@ +Statically inspect {{artifact_path}} using only the registered REA tools and Ghidra. The independently read, pinned PE32+ header reports image base 0x140000000 and entry RVA 0x125c0, so use address 0x1400125c0 as the analyst seed. This is a native x86-64 Windows PE component with no CLR directory, inspected on Linux; do not claim Windows runtime behavior. Keep default auto-analysis enabled. Open the target with provider_id ghidra, resolve the containing procedure, analyze that procedure once, and close the ephemeral session after obtaining the facts. If a result is clipped, use retained native facets without reanalysis. Report exact target SHA-256, entry_address, containing_procedure, complete procedure metadata including classification, signature, locals and inclusive body ranges, the first 512 UTF-16 units of pseudocode, caller_count, callee_count, real Evidence IDs and limitations. Preserve terminal-flow caveats and unresolved static edges; recovered pseudocode is not original source. Do not execute targets, launch a GUI, or use shell/file tools/network or other agents. Final JSON fields: artifact_sha256, entry_address, containing_procedure, procedure, pseudocode_prefix, caller_count, callee_count, evidence_ids, limitations. The seed applies only to the pinned ShareX.exe in corpus.json. Stop when these facts and cleanup are supported. diff --git a/experiments/analysis-efficiency/review-model.py b/experiments/analysis-efficiency/review-model.py index 22d6dd7668..c31a012902 100644 --- a/experiments/analysis-efficiency/review-model.py +++ b/experiments/analysis-efficiency/review-model.py @@ -182,18 +182,61 @@ def main(): } for row in rows], key=lambda row: row['node_id']) check('all_selected_module_identities', select(answer.get('modules', [])), select(reference['modules'])) elif domain == 'native': - for key in ['entry_address', 'pseudocode_prefix', 'caller_count', 'callee_count']: + for key in ['entry_address', 'caller_count', 'callee_count']: check(key, answer.get(key), reference[key]) - containing = answer['containing_procedure'] + prefix = answer.get('pseudocode_prefix') + if isinstance(prefix, dict) and prefix.get('unit') == 'utf16-code-units': + text = prefix.get('text') + if 'requested' in prefix: + check('pseudocode_prefix.requested', prefix['requested'], 512) + if 'returned' in prefix: + check('pseudocode_prefix.returned', prefix['returned'], len(reference['pseudocode_prefix'].encode('utf-16-le')) // 2) + prefix = text + check('pseudocode_prefix', prefix, reference['pseudocode_prefix']) + containing = answer.get('containing_procedure') expected_containing = reference['containing_procedure']['procedure'] - for key in ['address', 'name']: - check('containing_procedure.' + key, containing.get(key, containing.get('procedure', {}).get(key)), expected_containing[key]) - procedure = answer['procedure'] + if isinstance(containing, str): + # Compare the explicitly reported display to the known source; + # never parse a display name into a canonical lookup identity. + check('containing_procedure.display', containing, + expected_containing['name'] + ' @ ' + expected_containing['address']) + else: + containing = containing if isinstance(containing, dict) else {} + nested = containing.get('procedure') + nested = nested if isinstance(nested, dict) else {} + for key in ['address', 'name']: + check('containing_procedure.' + key, containing.get(key, nested.get(key)), expected_containing[key]) + procedure = answer.get('procedure') + procedure = procedure if isinstance(procedure, dict) else {} expected = reference['procedure'] for key in ['address', 'name', 'classification', 'signature', 'locals']: check('procedure.' + key, procedure.get(key), expected[key]) - check('procedure.body_ranges', procedure.get('body_ranges', procedure.get('body', {}).get('ranges')), expected['body']['ranges']) - check('procedure.total_bytes', procedure.get('total_bytes', procedure.get('body', {}).get('total_bytes')), expected['body']['total_bytes']) + body = procedure.get('body') + body = body if isinstance(body, dict) else {} + check('procedure.body_ranges', procedure.get('body_ranges', body.get('ranges', body.get('ranges_inclusive'))), expected['body']['ranges']) + check('procedure.total_bytes', procedure.get('total_bytes', body.get('total_bytes')), expected['body']['total_bytes']) + if 'load_image' in reference: + sources = [source for source in producer_results.get('inspect_native_load_image', []) + if isinstance(source, dict) + and source.get('operation') == 'inspect_native_load_image' + and isinstance(source.get('evidence_id'), str) + and source.get('evidence_id') in reported_ids + and isinstance(source.get('normalized_result'), dict)] + checks['load_image_source_observed_and_referenced'] = bool(sources) + load = sources[-1]['normalized_result'] if sources else {} + entry = load.get('entry') + entry = entry if isinstance(entry, dict) else {} + check('load_image.target_sha256', load.get('target_sha256'), reference['artifact_sha256']) + check('load_image.source_status', load.get('status'), reference['load_image']['status']) + check('load_image.source_entry', entry.get('linear_address'), reference['entry_address']) + status = answer.get('load_image_status') + check('load_image_status', status.get('status') if isinstance(status, dict) else status, load.get('status')) + if isinstance(status, dict): + for key in ['format', 'load_segment', 'header_bytes', 'module_bytes', 'overlay_bytes']: + if key in status: + check('load_image.' + key, status[key], load.get(key)) + if 'entry_linear_address' in status: + check('load_image.entry_linear_address', status['entry_linear_address'], entry.get('linear_address')) calls = summary['completed_mcp_calls'] checks['default_analysis_not_disabled'] = all(not call['arguments'].get('skip_auto_analysis', False) for call in calls if call['tool'] == 'open_binary') checks['session_close_completed'] = any(call['tool'] == 'close_binary' and call['status'] == 'completed' for call in calls) @@ -248,6 +291,7 @@ def main(): and events['memory.events'].get('high') == 0 ) report = { + 'review_source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), 'domain': domain, 'checks': checks, 'all_selected_checks_pass': all(checks.values()), 'differences': differences, 'tool_errors': tool_errors, 'summary': summary, 'fingerprints': {key: value for key, value in settings.items() if 'sha256' in key or key in ['runtime_revision', 'enabled_tools', 'authority']}, diff --git a/install.sh b/install.sh index 8790bb8df9..46260fe715 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,14 @@ fail() { exit 1 } +# Bash can return status 0 while parsing an incomplete [[ condition. Keep that +# grammar in a constant quoted expression so truncated downloads fail while +# parsing the outer brace group. eval parses only this literal; the arguments +# are expanded as data by the conditional, not interpolated into shell code. +matches() { + eval '[[ "$1" =~ $2 ]]' +} + # Resolve the current host's Node.js tarball label pair; a label we cannot map # is kept verbatim so the operator can still read it. Falls back on the raw # `uname` outputs rather than inventing a label the Node.js release index does @@ -95,41 +103,41 @@ main() { esac done - if [[ "$verbose" == true ]]; then + if [ "$verbose" = true ]; then set -x fi platform="$(uname -s)" - [[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions." + [ "$platform" = "Darwin" ] || [ "$platform" = "Linux" ] || fail "REA supports macOS and selected 64-bit Linux distributions." command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer." command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry." command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry." node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" IFS=. read -r node_major node_minor _node_patch <<<"$node_version" - [[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry." - if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] || + matches "$node_major" '^[0-9]+$' && matches "$node_minor" '^[0-9]+$' || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry." + if ! matches "$_node_patch" '^[0-9]+(\+[0-9A-Za-z.-]+)?$' || ! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then fail_node_version "$node_version" fi - if [[ -n "$version" ]]; then - [[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version." + if [ -n "$version" ]; then + matches "$version" "^${version_pattern}$" || fail "version must be an exact semantic version." else release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry." tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION." - [[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION." + matches "$tag" "^rea-agents-(${version_pattern})$" || fail "the latest release tag was invalid. Retry later or pass --version VERSION." version="${BASH_REMATCH[1]}" fi # npm's exact-version resolver cleans build metadata from the requested version, # so it may install the base release and make the post-install identity check fail. - if [[ "$version" =~ \+ ]]; then + if matches "$version" '\+'; then fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry." fi install_args=(install --global) - if [[ "$platform" == "Linux" ]]; then + if [ "$platform" = "Linux" ]; then install_args+=(--prefix "$HOME/.local") install_bin="$HOME/.local/bin/rea" else @@ -145,21 +153,21 @@ main() { printf ' %q' npm "${install_args[@]}" printf '\n' printf ' Binary: %s\n' "$install_bin" - printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')" + printf ' Setup: %s\n' "$([ "$start_setup" = true ] && printf 'start when a terminal is available' || printf 'skipped')" - if [[ "$dry_run" == true ]]; then + if [ "$dry_run" = true ]; then printf 'Dry run complete; no changes made.\n' exit 0 fi printf 'Installing %s@%s...\n' "$PACKAGE" "$version" npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry." - [[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry." + [ -x "$install_bin" ] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry." installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry." - [[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry." + [ "$installed_version" = "$version" ] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry." printf 'REA %s is installed.\n' "$version" - if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then + if [ "$start_setup" = true ] && [ -r /dev/tty ] && [ -w /dev/tty ] && [ -t 1 ]; then printf 'Starting REA setup...\n' "$install_bin" setup /dev/tty else diff --git a/package.json b/package.json index ea47ce8509..dd0ef57d80 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "precheck:test": "npm run deps:check", "check:test": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run", "precheck:pr": "npm run deps:check", - "check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run docs:check && npm run jscpd && npm run scan:todos", + "check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos", "precheck:ci": "npm run deps:check", "check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos && npm run verify:architecture-guards", "build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills", @@ -128,6 +128,7 @@ "test": "npm run test:with-build", "test:with-build": "npm run test:prepare && vitest run", "test:run": "vitest run", + "test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py -v", "test:local": "node scripts/run-development-tests.mjs local", "test:fast": "vitest run --project domain --project services --project adapters --project composition --project conformance --project evaluation", "pretest:boundary": "npm run test:prepare", @@ -169,6 +170,7 @@ "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", + "verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs", "verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs", "verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", @@ -231,7 +233,7 @@ "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", "docs:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts npm run docs:prepare", - "docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built", + "docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built && npm run test:local -- tests/conformance/readmeConsistency.test.ts", "docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check", "docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:error-schema", "prepack": "node scripts/prepack.mjs", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 728cedc570..497dfeed0e 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -64,6 +64,7 @@ export const SCOPES = { "tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts", "tests/conformance/providers/ghidra/windowsCapabilities.test.ts", "src/ghidra/GhidraInstallation.test.ts", + "tests/boundary/filesystem/ghidraJavaProbe.test.ts", "tests/boundary/providers/ghidra/ghidraTransport.test.ts", "tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts", "tests/boundary/providers/ghidra/ghidraLauncher.test.ts", diff --git a/scripts/ci/test-report.mjs b/scripts/ci/test-report.mjs new file mode 100644 index 0000000000..a3686d6ed0 --- /dev/null +++ b/scripts/ci/test-report.mjs @@ -0,0 +1,73 @@ +import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { z } from "zod"; + +const root = process.cwd(); +const historyDirectory = join(root, ".cache/ci-test-history"); +const historyPath = join(historyDirectory, "test-results.json"); +// Never let a failed or partial rerun leave a previous successful history file. +await rm(historyPath, { force: true }); +const report = z + .object({ + success: z.boolean(), + numFailedTests: z.number().int().nonnegative(), + testResults: z.array( + z.object({ + name: z.string().min(1), + startTime: z.number().finite(), + endTime: z.number().finite(), + }), + ), + }) + .parse(JSON.parse(await readFile("test-results.json", "utf8"))); + +const files = report.testResults.map(({ name, startTime, endTime }) => { + const path = relative(root, resolve(root, name)); + if ( + path === "" || + path === ".." || + path.startsWith(".." + sep) || + isAbsolute(path) + ) + throw new Error(`Test report path is outside the checkout: ${name}`); + const duration = endTime - startTime; + if (!Number.isFinite(duration) || duration < 0) + throw new Error(`Test report has an invalid duration: ${name}`); + return { name: path.split(sep).join("/"), startTime: 0, endTime: duration }; +}); +const complete = process.env.SHARD_RESULT === "success"; +const passed = + complete && + process.env.MERGE_RESULT === "success" && + report.success && + report.numFailedTests === 0; +const summary = [ + complete ? "## Merged test diagnostics" : "## Partial test diagnostics", + "", + `Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`, + passed + ? "Successful timing history is available." + : "This report is diagnostic only and is not a successful timing baseline.", + "", + "| Slowest file | Duration (ms) |", + "| --- | ---: |", + ...files + .toSorted((left, right) => right.endTime - left.endTime) + .slice(0, 20) + .map( + ({ name, endTime }) => `| ${name.replaceAll("|", "\\|")} | ${endTime} |`, + ), + "", +].join("\n"); +if (process.env.GITHUB_STEP_SUMMARY) + await appendFile(process.env.GITHUB_STEP_SUMMARY, summary); +else console.log(summary); + +if (passed) { + await mkdir(historyDirectory, { recursive: true }); + await writeFile( + historyPath, + JSON.stringify({ success: true, numFailedTests: 0, testResults: files }), + ); +} diff --git a/scripts/lib/real-ghidra-snapshot-lifecycle.mjs b/scripts/lib/real-ghidra-snapshot-lifecycle.mjs index 4a854c7314..e359f22ac8 100644 --- a/scripts/lib/real-ghidra-snapshot-lifecycle.mjs +++ b/scripts/lib/real-ghidra-snapshot-lifecycle.mjs @@ -36,16 +36,11 @@ export async function verifyGhidraSnapshotLifecycle( ); const error = requireMcpToolError(reply); assert.equal( - error?.code, + error.code, "evidence_integrity_mismatch", JSON.stringify(reply), ); - assert.match(error.message, /after analysis metadata mutations/u); - assert.match( - error.remediation.action, - /close.*without saving a snapshot/iu, - ); - assert.match(error.remediation.action, /export_evidence_bundle/u); + assert.equal(error.retryable, false); rejectedCalls++; }; try { diff --git a/scripts/verify-real-ghidra-riscv.mjs b/scripts/verify-real-ghidra-riscv.mjs new file mode 100644 index 0000000000..a87adeeb5b --- /dev/null +++ b/scripts/verify-real-ghidra-riscv.mjs @@ -0,0 +1,391 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import Ajv from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; +import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; +import { buildRiscvFixture } from "../tests/conformance/ghidra/riscv-fixture.mjs"; +import { + mcpTextValue, + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-riscv.mjs"); +if (!["linux", "darwin"].includes(process.platform)) + throw new Error("This optional lane requires a Linux/macOS Ghidra host."); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR"); +for (const key of ["REA_GHIDRA_LANGUAGE_ID", "REA_GHIDRA_COMPILER_SPEC_ID"]) + if (process.env[key] !== undefined) + throw new Error( + `Unset ${key}: this lane verifies provider-pinned selection.`, + ); +const installation = inspectGhidraInstallation({ + environment: process.env, + installDir: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), +}); +assert.equal(installation.status, "available", JSON.stringify(installation)); +assert.equal(installation.providerVersion, "12.1.4"); + +const execute = promisify(execFile); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-riscv-proof-")); +const runtime = join(workspace, "runtime"); +await mkdir(runtime); +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: runtime, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_PROCESS_RUN_ID: run.run_id, + GHIDRA_INSTALL_DIR: installation.installDir, + JAVA_HOME: installation.javaHome, +}; +for (const key of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) + if (process.env[key] !== undefined) env[key] = process.env[key]; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", +}); +const client = new Client({ name: "rea-real-riscv-proof", version: "1" }); +let stderr = ""; +transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); +}); + +const reports = []; +let failure; +try { + await client.connect(transport); + const catalog = await client.listTools(); + const ajv = new Ajv({ strict: false }); + addFormats(ajv); + const validators = new Map(); + for (const tool of catalog.tools) { + assert.ok( + ajv.validateSchema(tool.outputSchema), + `${tool.name}: invalid output schema`, + ); + const existing = + typeof tool.outputSchema.$id === "string" + ? ajv.getSchema(tool.outputSchema.$id) + : undefined; + validators.set(tool.name, existing ?? ajv.compile(tool.outputSchema)); + } + for (const bits of [32, 64]) { + const fixture = await buildRiscvFixture(workspace, bits); + const resolved = await parseBinaryTarget(fixture.path); + if (!resolved.ok) throw resolved.error; + assert.equal(resolved.value.architecture, "riscv"); + assert.equal(resolved.value.sha256, fixture.sha256); + assert.deepEqual(resolved.value.riscv, fixture.riscv); + const expectedLanguage = `RISCV:LE:${bits}:default`; + const assertProfile = (profile) => { + assert.equal(profile.provider.id, "ghidra"); + assert.equal(profile.provider.version, installation.providerVersion); + const parameters = profile.parameters; + assert.equal(parameters.language_id, expectedLanguage); + assert.equal(parameters.compiler_spec_id, "gcc"); + assert.equal(parameters.language_selection, "provider-pinned-v1"); + assert.equal( + parameters.riscv_support_lane, + `elf${bits}-exec-le-double-float-generic-v1`, + ); + assert.deepEqual(parameters.riscv_elf, { + elf_class: bits, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + }; + const assertEvidence = (evidence) => { + assert.equal(evidence.provider.id, "ghidra"); + assert.equal(evidence.subject.architecture, "riscv"); + assert.equal(evidence.subject.digest.sha256, fixture.sha256); + assertProfile(evidence.analysis_profile); + }; + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + const projected = requireMcpOperationResult(reply, name); + const textual = JSON.parse(mcpTextValue(reply)); + assert.deepEqual(reply.structuredContent, textual); + const validate = validators.get(name); + assert.ok(validate, `${name} missing from advertised catalog`); + assert.ok( + validate(textual), + `${name}: ${JSON.stringify(validate.errors)}`, + ); + if (!["open_binary", "close_binary", "binary_session"].includes(name)) + assertEvidence(parseEvidence(textual)); + return projected; + }; + const opened = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(opened.architecture, "riscv"); + assert.equal(opened.sha256, fixture.sha256); + const image = await call("inspect_native_load_image"); + assert.equal(image.observations.language_id, expectedLanguage); + assert.equal(image.observations.compiler_spec_id, "gcc"); + assert.ok(image.observations.source_files.length > 0); + for (const source of image.observations.source_files) { + assert.equal(source.original_sha256, fixture.sha256); + assert.equal(source.modified_sha256, fixture.sha256); + } + const procedures = await call("list_procedures"); + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + ]) { + const procedure = procedures.find((value) => value.value === name); + assert.ok(procedure, `${name} missing from Ghidra inventory`); + assert.equal(BigInt(procedure.address), BigInt(fixture.symbols[name])); + } + const probe = await call("inspect_native_instruction", { + address: fixture.symbols.rea_riscv_probe, + }); + assert.equal(probe.status, "decoded"); + assert.equal(probe.architecture, expectedLanguage); + assert.equal(probe.length, 4); + assert.equal(probe.bytes, "13051501"); // addi a0, a0, 17 + const mapping = await call("address_to_file_offset", { + address: fixture.symbols.rea_riscv_probe, + }); + const sourceBytes = await readFile(fixture.path); + assert.equal( + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 4) + .toString("hex"), + probe.bytes, + ); + const global = await call("read_bytes", { + address: fixture.symbols.rea_riscv_global, + length: 4, + }); + assert.equal(global.bytes_hex, "07000000"); + const callees = await call("procedure_callees", { + procedure: fixture.symbols.rea_riscv_entry, + }); + for (const name of ["rea_riscv_leaf", "rea_riscv_probe"]) + assert.ok( + callees.some( + (address) => BigInt(address) === BigInt(fixture.symbols[name]), + ), + ); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { + procedure: fixture.symbols.rea_riscv_entry, + }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + const floatCode = await call("procedure_pseudo_code", { + procedure: fixture.symbols.rea_riscv_float, + }); + assert.ok(typeof floatCode === "string" && floatCode.trim().length > 0); + assert.match(floatCode, /\bdouble\b/u); + + const refusals = []; + for (const [flags, reason] of [ + [1, "EF_RISCV_FLOAT_ABI_DOUBLE"], + [0x80000005, "(0x80000000)"], + ]) { + // Header-mutated copies test admission; they are not new compiler claims. + const rejectedPath = join( + workspace, + `rv${bits}-flags-${flags.toString(16)}.elf`, + ); + const rejectedBytes = Buffer.from(sourceBytes); + rejectedBytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + await writeFile(rejectedPath, rejectedBytes); + const reply = await client.callTool( + { + name: "open_binary", + arguments: { path: rejectedPath, provider_id: "ghidra" }, + }, + { timeout: 60000 }, + ); + const error = requireMcpToolError(reply); + assert.ok(JSON.stringify(error).includes(reason), JSON.stringify(error)); + await assert.rejects( + execute( + process.execPath, + [ + entrypoint, + "function", + rejectedPath, + "rea_riscv_entry", + "--provider", + "ghidra", + "--json", + ], + { + env, + timeout: 60000, + maxBuffer: 1024 * 1024, + }, + ), + (cause) => + Number.isInteger(cause.code) && + cause.code !== 0 && + `${cause.stdout}\n${cause.stderr}`.includes(reason), + ); + refusals.push({ flags, reason }); + } + const snapshotPath = join(workspace, `rv${bits}.snapshot.json`); + const saved = await call("close_binary", { snapshot_path: snapshotPath }); + assert.ok(saved.evidence_records > 0); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal(snapshot.target.architecture, "riscv"); + assert.equal(snapshot.target.sha256, fixture.sha256); + assertProfile(snapshot.binding.analysis_profile); + for (const record of snapshot.evidence_bundle.records) + assertEvidence(parseEvidence(record)); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + + for (const [command, arguments_] of [ + ["search", ["rea_riscv_", "--kind", "procedures"]], + ["function", ["rea_riscv_entry"]], + ]) { + const cli = await execute( + process.execPath, + [ + entrypoint, + command, + fixture.path, + ...arguments_, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(cli.stdout)); + assertEvidence(evidence); + if (command === "search") + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + ]) + assert.ok( + evidence.normalized_result.some((value) => value.value === name), + ); + else { + const cliDossier = functionDossierSchema.parse( + evidence.normalized_result, + ); + assert.equal(cliDossier.procedure.address, dossier.procedure.address); + assert.ok(cliDossier.pseudocode.trim().length > 0); + } + } + assert.equal( + createHash("sha256") + .update(await readFile(fixture.path)) + .digest("hex"), + fixture.sha256, + ); + reports.push({ + ...fixture, + language: expectedLanguage, + procedures: procedures.length, + refusals, + cli: true, + stdio_mcp: true, + snapshot_replay: true, + original_unchanged: true, + }); + } +} catch (cause) { + failure = cause; +} +for (const close of [() => client.close(), () => transport.close()]) { + try { + await close(); + } catch (cause) { + failure = new AggregateError( + failure === undefined ? [cause] : [failure, cause], + "Verification or cleanup failed", + ); + } +} +const completed = await completeVerifierRun(run); +if ( + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error(`Cleanup unverified; retained workspace: ${workspace}`, { + cause: failure, + }); +if (failure !== undefined) + throw new Error( + `RISC-V verification failed; retained workspace: ${workspace}; MCP stderr: ${stderr}`, + { cause: failure }, + ); +assert.deepEqual( + await readdir(runtime), + [], + `Runtime resources survived server shutdown; retained workspace: ${workspace}`, +); +await rm(workspace, { recursive: true, force: true }); +console.log( + JSON.stringify( + { + verifier_run: completed, + lane: "ghidra-riscv", + mocked: false, + host: { platform: process.platform, architecture: process.arch }, + provider: { id: "ghidra", version: installation.providerVersion }, + fixtures: reports, + cleanup: "complete", + }, + null, + 2, + ), +); diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 4d6c13001e..5f36917e4a 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1086,10 +1086,10 @@ it("rejects mistyped intent extras before touching the device", async () => { it("collects a device bugreport archive with its digest", async () => { const { binary, root } = await writeAdbStub( - (root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then - printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}" - printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n' + printf 'bugreport-bytes' > "$4/bugreport test.zip" + printf 'Bug report copied to %s/bugreport test.zip\\n' "$4" exit 0 fi exit 1`, @@ -1103,7 +1103,7 @@ exit 1`, local_path: join(root, "bugreport test.zip"), bytes: 15, sha256: digest, - adb_reported_path: join(root, "bugreport test.zip"), + adb_reported_path: expect.stringContaining(".rea-bugreport-"), }); }); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 2a64930a56..bb2f49ce03 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -2,14 +2,16 @@ import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { access, + copyFile, lstat, + mkdtemp, mkdir, rmdir, rm, stat, writeFile, } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, dirname, extname, join, resolve } from "node:path"; import type { ExecutionOptions, @@ -20,7 +22,10 @@ import { createAnalysisExecution, type AnalysisExecution, } from "../application/AnalysisProvider.js"; -import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js"; +import type { + AdbAcquiredFilePartialObservation, + AdbRequest, +} from "../domain/adb/adbDeviceAnalysis.js"; import { AnalysisArtifactChangedError, AnalysisCancelledError, @@ -305,30 +310,56 @@ const refuseExistingTarget = async ( return undefined; }; +/** Publish an ADB-produced archive without replacing an existing caller file. */ +const publishBugreport = async ( + source: string, + directory: string, +): Promise => { + const fileName = basename(source); + const extension = extname(fileName); + const stem = + extension === "" ? fileName : fileName.slice(0, -extension.length); + for (let suffix = 0; ; suffix += 1) { + const destination = join( + directory, + suffix === 0 ? fileName : `${stem} (${String(suffix)})${extension}`, + ); + try { + await copyFile(source, destination, fsConstants.COPYFILE_EXCL); + return destination; + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "EEXIST") + continue; + throw cause; + } + } +}; + export interface AdbProviderOptions { readonly environment?: Readonly>; } -interface PendingPackageDirectoryCleanup { +interface PendingOutputDirectoryCleanup { + readonly operation: "pull_adb_package" | "collect_adb_bugreport"; readonly path: string; - readonly identity: PackageDirectoryIdentity | undefined; + readonly identity: OutputDirectoryIdentity | undefined; readonly primary: AnalysisError; readonly remove: () => Promise; } -interface PackageDirectoryIdentity { +interface OutputDirectoryIdentity { readonly dev: number; readonly ino: number; readonly mode: number; } -const removeOwnedPackageDirectory = async ( +const removeOwnedOutputDirectory = async ( path: string, - identity: PackageDirectoryIdentity | undefined, + identity: OutputDirectoryIdentity | undefined, ): Promise => { if (identity === undefined) throw new Error( - `Cannot verify the created package directory identity; refusing to remove ${path}`, + `Cannot verify the created output directory identity; refusing to remove ${path}`, ); let current: Awaited>; try { @@ -346,7 +377,7 @@ const removeOwnedPackageDirectory = async ( current.mode !== identity.mode ) throw new Error( - `The created package directory was replaced or changed; refusing to remove ${path}`, + `The created output directory was replaced or changed; refusing to remove ${path}`, ); await rm(path, { recursive: true, force: true }); }; @@ -363,7 +394,7 @@ const reservePackageDestinations = async ( ): Promise => { const reservations: { readonly path: string; - readonly identity: PackageDirectoryIdentity; + readonly identity: OutputDirectoryIdentity; }[] = []; for (const entry of entries) { const path = join(packageDirectory, entry.file_name); @@ -424,12 +455,12 @@ const reservePackageDestinations = async ( */ export class AdbProvider { private readonly environment: Readonly>; - readonly #pendingPackageDirectoryCleanup = - new Set(); + readonly #pendingOutputDirectoryCleanup = + new Set(); readonly #activeOperations = new Set>(); #closing = false; #closePromise: Promise | undefined; - #packageCleanupPromise: Promise | undefined; + #outputCleanupPromise: Promise | undefined; constructor(options: AdbProviderOptions = {}) { this.environment = options.environment ?? process.env; @@ -462,13 +493,13 @@ export class AdbProvider { } } - /** Retry removal of package directories retained after failed rollback. */ + /** Retry removal of owned output directories retained after failed cleanup. */ close(): Promise { this.#closing = true; if (this.#closePromise !== undefined) return this.#closePromise; const closing = (async () => { await Promise.all(this.#activeOperations); - await this.#retryPackageDirectoryCleanup(); + await this.#retryOutputDirectoryCleanup(); })().finally(() => { if (this.#closePromise === closing) this.#closePromise = undefined; }); @@ -476,23 +507,23 @@ export class AdbProvider { return this.#closePromise; } - #retryPackageDirectoryCleanup(): Promise { - if (this.#packageCleanupPromise !== undefined) - return this.#packageCleanupPromise; - const cleanup = this.#performPackageDirectoryCleanup().finally(() => { - if (this.#packageCleanupPromise === cleanup) - this.#packageCleanupPromise = undefined; + #retryOutputDirectoryCleanup(): Promise { + if (this.#outputCleanupPromise !== undefined) + return this.#outputCleanupPromise; + const cleanup = this.#performOutputDirectoryCleanup().finally(() => { + if (this.#outputCleanupPromise === cleanup) + this.#outputCleanupPromise = undefined; }); - this.#packageCleanupPromise = cleanup; + this.#outputCleanupPromise = cleanup; return cleanup; } - async #performPackageDirectoryCleanup(): Promise { + async #performOutputDirectoryCleanup(): Promise { const failures: AnalysisError[] = []; - for (const owner of this.#pendingPackageDirectoryCleanup) { + for (const owner of this.#pendingOutputDirectoryCleanup) { try { await owner.remove(); - this.#pendingPackageDirectoryCleanup.delete(owner); + this.#pendingOutputDirectoryCleanup.delete(owner); } catch (cause: unknown) { const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, @@ -500,13 +531,13 @@ export class AdbProvider { { reason: cause instanceof Error ? cause.message : String(cause), }, - { operation: "pull_adb_package", cause }, + { operation: owner.operation, cause }, ); failures.push( analysisErrorWithCleanupFailure( owner.primary, cleanup, - "pull_adb_package", + owner.operation, ), ); } @@ -515,20 +546,22 @@ export class AdbProvider { if (failures.length > 1) throw new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingPackageDirectoryCleanup].map(({ path }) => path), + [...this.#pendingOutputDirectoryCleanup].map(({ path }) => path), { - reason: - "Failed package output directories remain after cleanup retry", + reason: "Failed ADB output directories remain after cleanup retry", + operations: [...this.#pendingOutputDirectoryCleanup].map( + ({ operation }) => operation, + ), failures: failures.map(({ message, cleanupResources }) => ({ message, resources: [...cleanupResources], })), }, { - operation: "pull_adb_package", + operation: "close_binary", cause: new AggregateError( failures, - "Multiple ADB package rollback cleanups failed", + "Multiple ADB output directory cleanups failed", ), }, ); @@ -546,16 +579,16 @@ export class AdbProvider { }); this.#activeOperations.add(active); try { - if (this.#pendingPackageDirectoryCleanup.size > 0) { + if (this.#pendingOutputDirectoryCleanup.size > 0) { try { - await this.#retryPackageDirectoryCleanup(); + await this.#retryOutputDirectoryCleanup(); } catch (cause: unknown) { return err( cause instanceof AnalysisError ? cause : new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingPackageDirectoryCleanup].map( + [...this.#pendingOutputDirectoryCleanup].map( ({ path }) => path, ), { @@ -1245,7 +1278,7 @@ export class AdbProvider { if (!directory.ok) return directory; const packageDirectory = join(directory.value, packageName); let packageDirectoryCreated = false; - let packageDirectoryIdentity: PackageDirectoryIdentity | undefined; + let packageDirectoryIdentity: OutputDirectoryIdentity | undefined; try { if ( await lstat(packageDirectory).then( @@ -1275,17 +1308,18 @@ export class AdbProvider { { cause }, ); if (!packageDirectoryCreated) return err(primary); - const owner: PendingPackageDirectoryCleanup = { + const owner: PendingOutputDirectoryCleanup = { + operation, path: packageDirectory, identity: packageDirectoryIdentity, primary, remove: () => - removeOwnedPackageDirectory( + removeOwnedOutputDirectory( packageDirectory, packageDirectoryIdentity, ), }; - this.#pendingPackageDirectoryCleanup.add(owner); + this.#pendingOutputDirectoryCleanup.add(owner); const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, [packageDirectory], @@ -1299,12 +1333,13 @@ export class AdbProvider { const failOwnedPackage = async ( primary: AnalysisError, ): Promise> => { - const owner: PendingPackageDirectoryCleanup = { + const owner: PendingOutputDirectoryCleanup = { + operation, path: packageDirectory, identity: packageDirectoryIdentity, primary, remove: () => - removeOwnedPackageDirectory( + removeOwnedOutputDirectory( packageDirectory, packageDirectoryIdentity, ), @@ -1312,7 +1347,7 @@ export class AdbProvider { try { await owner.remove(); } catch (cause: unknown) { - this.#pendingPackageDirectoryCleanup.add(owner); + this.#pendingOutputDirectoryCleanup.add(owner); const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, [packageDirectory], @@ -2084,48 +2119,71 @@ export class AdbProvider { input.output_directory, ); if (!directory.ok) return directory; - const collected = await runAdb( - operation, - input.serial, - selection.binary, - adbArguments(input.serial, ["bugreport", directory.value]), - { - timeoutMs: BUGREPORT_TIMEOUT_MS, - maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES, - }, - signal, - ); - if (!collected.ok) return collected; - const reportedPath = parseBugreportCopiedPath(collected.value.stdout); - if (reportedPath === null) - return err( - new AnalysisProtocolError( - "adb bugreport did not report an archive path; the build may use an unsupported format", - ), - ); + let stagingDirectory: string; + let stagingIdentity: OutputDirectoryIdentity | undefined; + let retainedObservation: AdbAcquiredFilePartialObservation | undefined; + let publishedPath: string | undefined; try { - const digest = await hashAdbFile(reportedPath, operation, signal); - return ok( - createAnalysisExecution( - { - client, - serial: input.serial, - local_path: reportedPath, - bytes: digest.bytes, - sha256: digest.sha256, - adb_reported_path: reportedPath, - }, - provider, - { - limitations: [ - "The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report", - ], - }, - ), + stagingDirectory = await mkdtemp( + join(directory.value, ".rea-bugreport-"), ); } catch (cause) { - if (cause instanceof AnalysisCancelledError) { - cause.retainPartialObservation({ + return err( + new AnalysisOutputError( + operation, + "The bugreport staging directory could not be created", + { cause }, + ), + ); + } + const collectedResult = await (async (): Promise< + Result + > => { + let reportedPath: string | null = null; + try { + const created = await lstat(stagingDirectory); + if (!created.isDirectory() || created.isSymbolicLink()) + throw new Error( + `The created bugreport staging output is not a regular directory: ${stagingDirectory}`, + ); + stagingIdentity = { + dev: created.dev, + ino: created.ino, + mode: created.mode, + }; + const collected = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, ["bugreport", stagingDirectory]), + { + timeoutMs: BUGREPORT_TIMEOUT_MS, + maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!collected.ok) return collected; + reportedPath = parseBugreportCopiedPath(collected.value.stdout); + if (reportedPath === null) + return err( + new AnalysisProtocolError( + "adb bugreport did not report an archive path; the build may use an unsupported format", + ), + ); + if (dirname(resolve(reportedPath)) !== stagingDirectory) + return err( + new AnalysisProtocolError( + `adb bugreport reported an archive outside its staging directory: ${reportedPath}`, + ), + ); + const archive = await lstat(reportedPath); + if (!archive.isFile() || archive.isSymbolicLink()) + return err( + new AnalysisProtocolError( + `adb bugreport did not report a regular archive file: ${reportedPath}`, + ), + ); + retainedObservation = { provider_id: "adb", operation, result: { @@ -2135,16 +2193,94 @@ export class AdbProvider { adb_reported_path: reportedPath, digest_status: "unknown", }, - }); - return err(cause); - } - return err( - new AnalysisOutputError( + }; + try { + publishedPath = await publishBugreport(reportedPath, directory.value); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + `The reported bugreport archive could not be published: ${reportedPath}`, + { cause, partialObservation: retainedObservation }, + ), + ); + } + retainedObservation = { + ...retainedObservation, + result: { ...retainedObservation.result, local_path: publishedPath }, + }; + const digest = await hashAdbFile(publishedPath, operation, signal); + const report = { + client, + serial: input.serial, + local_path: publishedPath, + bytes: digest.bytes, + sha256: digest.sha256, + adb_reported_path: reportedPath, + }; + retainedObservation = { + provider_id: "adb", + operation, + result: report, + }; + return ok( + createAnalysisExecution(report, provider, { + limitations: [ + "The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report", + ], + }), + ); + } catch (cause) { + if (cause instanceof AnalysisCancelledError) { + if (retainedObservation !== undefined) + cause.retainPartialObservation(retainedObservation); + return err(cause); + } + return err( + new AnalysisOutputError( + operation, + `The reported bugreport archive could not be read back: ${reportedPath ?? stagingDirectory}`, + { cause, partialObservation: retainedObservation }, + ), + ); + } + })(); + // A completed acquisition becomes caller evidence even if publication fails. + // Its staging directory must not enter automatic cleanup or cleanup retries. + if (retainedObservation !== undefined && publishedPath === undefined) + return collectedResult; + try { + await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity); + } catch (cause) { + const primary = collectedResult.ok + ? new AnalysisOutputError( + operation, + "The archive was published, but its staging directory could not be removed", + ) + : collectedResult.error; + if (retainedObservation !== undefined) + primary.retainPartialObservation(retainedObservation); + this.#pendingOutputDirectoryCleanup.add({ + operation, + path: stagingDirectory, + identity: stagingIdentity, + primary, + remove: () => + removeOwnedOutputDirectory(stagingDirectory, stagingIdentity), + }); + return err( + analysisErrorWithCleanupFailure( + primary, + new ProviderCleanupError( + ADB_PROVIDER_IDENTITY.id, + [stagingDirectory], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { operation, cause }, + ), operation, - `The reported bugreport archive could not be read back: ${reportedPath}`, - { cause }, ), ); } + return collectedResult; } } diff --git a/src/application/ClientConfigurationDocument.shape.test.ts b/src/application/ClientConfigurationDocument.shape.test.ts new file mode 100644 index 0000000000..67c557a440 --- /dev/null +++ b/src/application/ClientConfigurationDocument.shape.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { parseClientConfiguration } from "./ClientConfigurationDocument.js"; + +describe("parseClientConfiguration shape diagnostics", () => { + it.each([ + ["null", "null"], + ["array", "[]"], + ["string", '"x"'], + ] as const)("names a non-object JSON root (%s)", (_label, text) => { + expect(() => parseClientConfiguration(text, "json")).toThrow( + new TypeError("The configuration root must be an object"), + ); + }); + + it.each([ + ["null", "null"], + ["array", "[]"], + ["string", '"x"'], + ] as const)( + 'names a non-object "mcpServers" value (%s)', + (_label, servers) => { + expect(() => + parseClientConfiguration(`{"mcpServers":${servers},"keep":1}`, "json"), + ).toThrow(new TypeError('"mcpServers" must be an object')); + }, + ); + + it("still rejects unparsable JSON with SyntaxError", () => { + expect(() => parseClientConfiguration("not-json", "json")).toThrow( + SyntaxError, + ); + }); +}); diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index cecbd440ab..b32df3fd01 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -107,18 +107,21 @@ const parseDocument = ( format: ClientConfigurationFormat, ): Record => { if (format === "toml" || format === "grok") - return objectSchema.parse(parseToml(text)); + return requireObject(parseToml(text), "The configuration root"); // Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so // comments, trailing commas, a BOM, or an empty existing file must be // rejected instead of registering into a document Pi cannot load. - if (format === "pi") return objectSchema.parse(JSON.parse(text)); + if (format === "pi") + return requireObject(JSON.parse(text), "The configuration root"); // An empty file, which some clients create before any server is added, // holds no settings to preserve. if (isEmptyClientConfigurationText(text)) return {}; // Hermes stores MCP servers in `mcp_servers` inside a YAML document. - if (format === "hermes") return objectSchema.parse(parseYaml(text)); + if (format === "hermes") + return requireObject(parseYaml(text), "The configuration root"); // ZCode loads nonempty native files with strict JSON.parse. - if (format === "zcode") return objectSchema.parse(JSON.parse(text)); + if (format === "zcode") + return requireObject(JSON.parse(text), "The configuration root"); const errors: ParseError[] = []; // Accept a UTF-8 BOM without shifting diagnostics or editing the original text. const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text; @@ -128,12 +131,13 @@ const parseDocument = ( throw new SyntaxError( `Invalid JSON/JSONC at offset ${firstError.offset}: ${printParseErrorCode(firstError.error)}`, ); - return objectSchema.parse(document); + return requireObject(document, "The configuration root"); }; /** A parsed configuration whose structure, not its syntax, cannot hold MCP servers. */ export class ClientConfigurationShapeError extends TypeError {} +/** Validate and copy own properties, excluding non-record YAML values and parser-injected prototypes. */ const requireObject = ( value: unknown, description: string, @@ -174,17 +178,7 @@ export const parseClientConfiguration = ( throw new TypeError("client does not have a supported MCP config format"); // DeepSeek Harness stores a Cordis patch list, not a server table. if (format === "dsh") return parseDshCordisDocument(text); - let document: Record; - try { - document = parseDocument(text, format); - } catch (cause: unknown) { - if (cause instanceof z.ZodError) - throw new ClientConfigurationShapeError( - "The configuration root must be an object", - { cause }, - ); - throw cause; - } + const document = parseDocument(text, format); const serversKey = clientConfigurationServersKey(format); const value = document[serversKey]; const servers = diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 570b5589e0..c410280bca 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -289,7 +289,7 @@ const readClientConfiguration = async ( "failed", cause instanceof ClientConfigurationShapeError ? `Configuration was not changed: ${cause.message}. Repair it, then rerun uninstall.` - : `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, + : `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : client.format === "hermes" || client.format === "dsh" ? "YAML" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, ), ); } diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index 9f53310ab4..c1251f9a52 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -1,3 +1,6 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { expect, it } from "vitest"; import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js"; @@ -102,6 +105,66 @@ it.each(["startSession", "instrument"] as const)( }, ); +it.each([ + [join(tmpdir(), "rea-missing-frida-script.js"), "no file exists there"], + [tmpdir(), "it is not a regular file"], +])( + "rejects the unreadable script file %s before starting Frida", + async (path, reason) => { + let starts = 0; + const provider = createProvider({ + async startSession() { + starts += 1; + return { ok: true, value: createSession() }; + }, + }); + const service = new FridaInstrumentationService(provider); + + const result = await service.instrument({ + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + source: { sourceKind: "file", path }, + durationMs: 1, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisInputError); + expect((result.error as AnalysisInputError).issues).toEqual([ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot read Frida script file ${path}: ${reason}.`, + }, + ]); + expect(starts).toBe(0); + }, +); + +it("reports cancellation before checking a file script source", async () => { + const service = new FridaInstrumentationService(createProvider()); + const controller = new AbortController(); + controller.abort(new Error("cancelled before call")); + + const result = await service.instrument( + { + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + source: { + sourceKind: "file", + path: join(tmpdir(), "rea-missing-frida-script.js"), + }, + durationMs: 1, + }, + controller.signal, + ); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); +}); + it("does not resume a spawned target when cancellation arrives during script loading", async () => { const controller = new AbortController(); let resumes = 0; diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index 9e9e85931e..c359850d66 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -1,11 +1,15 @@ +import { constants } from "node:fs"; +import { access, stat } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { + AnalysisAccessDeniedError, AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisInputError, } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { @@ -19,6 +23,64 @@ import type { StartFridaSessionInput, } from "./FridaInstrumentationPort.js"; +const unreadableScriptFile = async ( + source: FridaScriptSource, +): Promise => { + if (source.sourceKind !== "file") return undefined; + let reason: string; + let cause: unknown; + try { + if (!(await stat(source.path)).isFile()) + reason = "it is not a regular file"; + else { + await access(source.path, constants.R_OK); + return undefined; + } + } catch (failure: unknown) { + cause = failure; + const code = + failure instanceof Error && "code" in failure ? failure.code : undefined; + if (code === "EACCES" || code === "EPERM") + return new AnalysisAccessDeniedError( + "instrument_with_frida", + source.path, + code, + { cause }, + ); + reason = + code === "ENOENT" ? "no file exists there" : String(code ?? failure); + } + return new AnalysisInputError( + "instrument_with_frida", + cause === undefined ? undefined : { cause }, + [ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot read Frida script file ${source.path}: ${reason}.`, + }, + ], + ); +}; + +/** + * Reject an unreadable script file before any attach or spawn effect; + * cancellation before or during the check still wins. + */ +const rejectBeforeStart = async ( + source: FridaScriptSource, + signal: AbortSignal | undefined, +): Promise => { + const cancelled = () => + new AnalysisCancelledError( + "instrument_with_frida", + signal?.reason === undefined ? {} : { cause: signal.reason }, + ); + if (signal?.aborted) return cancelled(); + const unreadable = await unreadableScriptFile(source); + return signal?.aborted ? cancelled() : unreadable; +}; + /** Shared CLI/MCP workflows for Frida's device and instrumentation operations. */ export class FridaInstrumentationService { readonly #provider: FridaInstrumentationPort; @@ -163,6 +225,8 @@ export class FridaInstrumentationService { AnalysisError > > { + const rejected = await rejectBeforeStart(input.source, signal); + if (rejected !== undefined) return err(rejected); const started = await this.#startSession( input, signal, diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index 2268858b55..027bbcd3f0 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { build, buildBinary } from "plist"; +import { build, buildBinary, type PlistValue } from "plist"; import { decodeKeyedArchiveBytes } from "./KeyedArchiveReader.js"; +import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js"; const archive = { $archiver: "NSKeyedArchiver", @@ -323,3 +324,66 @@ describe("binary plist reference preflight", () => { ); }); }); + +describe("XML plist nesting preflight", () => { + const selection = { offset: 0, limit: 1 }; + + /** A valid XML NSKeyedArchiver envelope whose object 1 nests `depth` dictionaries. */ + const deeplyNestedArchive = (depth: number) => { + let payload: PlistValue = "leaf"; + for (let level = 0; level < depth; level += 1) payload = { k: payload }; + return Buffer.from( + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", payload], + $top: { root: { UID: 1 } }, + }), + ); + }; + + /** Follow the single-member chain of a decoded object to its deepest value. */ + const deepestValue = (value: unknown): unknown => { + let cursor = value; + while ( + cursor !== null && + typeof cursor === "object" && + !Array.isArray(cursor) && + "k" in cursor + ) { + cursor = (cursor as Record).k; + } + return cursor; + }; + + it.each([129, 200, 3000, 4000])( + "rejects %i-level XML nesting as the documented resource limit", + (depth) => { + // Recursive decoding exceeds the call stack before the projection limit + // can report it, so the decoder must reject the nesting up front with + // the same bounded-resource error an in-reach document would produce. + expect(() => + decodeKeyedArchiveBytes( + Buffer.from(deeplyNestedXmlPlist(depth)), + selection, + ), + ).toThrow(new RangeError("Plist exceeds 128 nesting levels")); + }, + ); + + it("decodes a valid XML keyed archive exactly at the nesting limit", () => { + const graph = decodeKeyedArchiveBytes(deeplyNestedArchive(126), { + root: "root", + offset: 1, + limit: 1, + }); + expect(graph.objects).toHaveLength(1); + expect(deepestValue(graph.objects[0]?.value)).toBe("leaf"); + }); + + it("rejects one nesting level past the limit before decoding", () => { + expect(() => + decodeKeyedArchiveBytes(deeplyNestedArchive(127), selection), + ).toThrow(new RangeError("Plist exceeds 128 nesting levels")); + }); +}); diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index fd956dde98..11198df5e7 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -709,7 +709,7 @@ export type AdbPackagePullPartialObservation = { readonly result: z.infer; }; -/** A completed acquisition whose digest read was cancelled before verification. */ +/** Acquired file facts retained when publication, digest verification or cleanup fails. */ export type AdbAcquiredFilePartialObservation = | { readonly provider_id: "adb"; @@ -727,13 +727,15 @@ export type AdbAcquiredFilePartialObservation = | { readonly provider_id: "adb"; readonly operation: "collect_adb_bugreport"; - readonly result: { - readonly client: z.infer< - typeof adbResultSchemas.collect_adb_bugreport - >["client"]; - readonly serial: string; - readonly local_path: string; - readonly adb_reported_path: string; - readonly digest_status: "unknown"; - }; + readonly result: + | z.infer + | { + readonly client: z.infer< + typeof adbResultSchemas.collect_adb_bugreport + >["client"]; + readonly serial: string; + readonly local_path: string; + readonly adb_reported_path: string; + readonly digest_status: "unknown"; + }; }; diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index dc6e4d0ea4..418688dd72 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -2,7 +2,12 @@ import { z } from "zod"; import type { JsonValue } from "../jsonValue.js"; -const MAX_PLIST_DEPTH = 128; +/** + * Maximum plist value nesting the JSON projection supports. The XML decode + * preflight shares this limit so oversized documents are rejected before any + * recursive pass rather than during one. + */ +export const MAX_PLIST_DEPTH = 128; /** A decoded plist projected into JSON, with values JSON cannot express. */ export interface ProjectedPlistValue { diff --git a/src/domain/binaryTarget.riscv.test.ts b/src/domain/binaryTarget.riscv.test.ts new file mode 100644 index 0000000000..fcc77cf891 --- /dev/null +++ b/src/domain/binaryTarget.riscv.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { parseExecutableHeader } from "./binaryTarget.js"; + +const header = ( + little: boolean, + bits: 32 | 64 = 32, + flags = 0x5, + type = 2, + machine = 243, +): Buffer => { + const bytes = Buffer.alloc(bits === 32 ? 52 : 64); + bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 32 ? 1 : 2, little ? 1 : 2, 1]); + if (little) { + bytes.writeUInt16LE(type, 16); + bytes.writeUInt16LE(machine, 18); + bytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + } else { + bytes.writeUInt16BE(type, 16); + bytes.writeUInt16BE(machine, 18); + bytes.writeUInt32BE(flags, bits === 32 ? 36 : 48); + } + return bytes; +}; + +describe("RISC-V ELF header boundaries", () => { + it.each([ + [true, 32], + [false, 32], + [true, 64], + [false, 64], + ] as const)( + "retains class, endian, e_type and e_flags (little=%s, class=%s)", + (little, bits) => { + const parsed = parseExecutableHeader(header(little, bits), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: bits, + byteOrder: little ? "little" : "big", + type: 2, + flags: 0x5, + }, + }); + }, + ); + + it("reads ELF64 flags at their own offset", () => { + const bytes = header(true, 64); + bytes.writeUInt32LE(0xdeadbeef, 36); + bytes.writeUInt32LE(0x00000014, 48); + const parsed = parseExecutableHeader(bytes, "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + architecture: "riscv", + riscv: { elfClass: 64, flags: 0x14 }, + }); + }); + + it("does not attach ELF metadata to a family that declares none", () => { + const parsed = parseExecutableHeader(header(true, 64, 0x5, 2, 62), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }); + }); + + it("rejects a truncated header before reading flags", () => { + const parsed = parseExecutableHeader(header(true).subarray(0, 39), "x64"); + expect(parsed).toEqual({ + ok: false, + error: + "truncated ELF header: an ELF32 header needs 52 bytes; the file has 39", + }); + }); + + it("does not turn invalid byte order into little-endian RISC-V", () => { + const bytes = header(true); + bytes[5] = 0; + expect(parseExecutableHeader(bytes, "x64")).toEqual({ + ok: false, + error: "unsupported ELF endianness", + }); + }); + + it("keeps an unknown e_machine outside the recognized family set", () => { + expect(parseExecutableHeader(header(true, 32, 0x5, 2, 244), "x64")).toEqual( + { + ok: false, + error: "unsupported ELF architecture", + }, + ); + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 79a9daefab..29818b674e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,6 +1,9 @@ import type { BinaryArchitecture, + ElfFamilyMetadata, + HeaderArchitecture, MipsElfMetadata, + RiscvElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; @@ -15,31 +18,28 @@ export type ExecutableMetadata = readonly availableArchitectures: readonly "mips"[]; readonly mips: MipsElfMetadata; } + | { + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + } | { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice?: never; } | { readonly format: "mach-o-universal"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice: UniversalMachOSlice; } | { readonly format: "pe"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly executableRole: | "application" | "shared-library" @@ -115,9 +115,9 @@ const parseFatMachO = ( const entrySize = is64 ? 32 : 20; if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) return err("truncated or invalid FAT architecture table"); - const architectures: Exclude[] = []; + const architectures: HeaderArchitecture[] = []; const slices = new Map< - Exclude, + HeaderArchitecture, { readonly offset: bigint; readonly size: bigint } >(); for (let index = 0; index < count; index += 1) { @@ -191,20 +191,31 @@ const parseElf = ( const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); const architecture = elfArchitecture(machine); if (architecture === undefined) return err("unsupported ELF architecture"); - if (architecture === "mips") - return ok({ - format: "elf", - architecture, - availableArchitectures: [architecture], - mips: { - elfClass: bytes[4] === 1 ? 32 : 64, - byteOrder: little ? "little" : "big", - type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), - flags: little - ? bytes.readUInt32LE(bytes[4] === 1 ? 36 : 48) - : bytes.readUInt32BE(bytes[4] === 1 ? 36 : 48), - }, - }); + if (architecture === "mips" || architecture === "riscv") { + const elfClass = bytes[4] === 1 ? 32 : 64; + const flagsOffset = elfClass === 32 ? 36 : 48; + const metadata: ElfFamilyMetadata = { + elfClass, + byteOrder: little ? "little" : "big", + type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), + flags: little + ? bytes.readUInt32LE(flagsOffset) + : bytes.readUInt32BE(flagsOffset), + }; + return architecture === "mips" + ? ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + mips: metadata, + }) + : ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + riscv: metadata, + }); + } return ok({ format: "elf", architecture, @@ -299,9 +310,7 @@ const peManagedStatus = ( ); }; -const machArchitecture = ( - cpu: number, -): Exclude | undefined => { +const machArchitecture = (cpu: number): HeaderArchitecture | undefined => { switch (cpu) { case 7: return "x86"; @@ -327,6 +336,8 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { return "arm"; case 183: return "arm64"; + case 243: // EM_RISCV: XLEN, byte order and e_flags remain separate ELF facts. + return "riscv"; } return undefined; }; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index e73d86cec6..b4d44c1101 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -5,15 +5,44 @@ export const BINARY_ARCHITECTURES = [ "arm", "arm64", "mips", + "riscv", ] as const; export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; -/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ -export interface MipsElfMetadata { +/** + * ELF header facts for a family whose ISA and ABI are not established by the + * family name alone. Consumers admit a lane from these declarations rather than + * re-reading the header. + */ +export interface ElfFamilyMetadata { readonly elfClass: 32 | 64; readonly byteOrder: "little" | "big"; readonly type: number; readonly flags: number; +} + +/** Families whose parsed target variant carries `ElfFamilyMetadata`. */ +export const ELF_METADATA_ARCHITECTURES = [ + "mips", + "riscv", +] as const satisfies readonly BinaryArchitecture[]; +export type ElfMetadataArchitecture = + (typeof ELF_METADATA_ARCHITECTURES)[number]; + +/** Families whose ISA and ABI need a separate ELF declaration to be admitted. */ +export const isElfMetadataArchitecture = ( + architecture: BinaryArchitecture, +): architecture is ElfMetadataArchitecture => + (ELF_METADATA_ARCHITECTURES as readonly string[]).includes(architecture); + +/** Recognized family with no ELF metadata member; the header alone is the fact. */ +export type HeaderArchitecture = Exclude< + BinaryArchitecture, + ElfMetadataArchitecture +>; + +/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ +export interface MipsElfMetadata extends ElfFamilyMetadata { /** Undefined until inspected; null means no ABI record was observed. */ readonly abiFlags?: MipsAbiFlags | null; /** Unavailable declaration sources; missing records there remain unknown. */ @@ -44,6 +73,12 @@ export interface MipsAbiFlags { readonly flags2: number; } +/** + * ELF class and flags retain the width and ABI declarations used by this lane. + * ISA extensions in `.riscv.attributes` are not inspected by this header parser. + */ +export type RiscvElfMetadata = ElfFamilyMetadata; + /** Byte range of the selected slice inside its universal Mach-O container. */ export interface UniversalMachOSlice { readonly offset: number; @@ -67,11 +102,8 @@ type NonExecutableMetadata = { type ExecutableTarget = BinaryTargetIdentity & { readonly kind: "executable"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; }; /** @@ -88,6 +120,15 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) + | (BinaryTargetIdentity & { + readonly kind: "executable"; + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + readonly executableRole?: never; + readonly managed?: never; + }) | (ExecutableTarget & { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly universalSlice?: never; diff --git a/src/domain/peMachine.ts b/src/domain/peMachine.ts index 16a5ace371..18981c88c6 100644 --- a/src/domain/peMachine.ts +++ b/src/domain/peMachine.ts @@ -1,6 +1,6 @@ -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { HeaderArchitecture } from "./binaryTargetTypes.js"; -export type PeArchitecture = Exclude; +export type PeArchitecture = HeaderArchitecture; /** A decoded COFF machine and whether it carried a ReadyToRun OS override. */ export interface PeMachine { diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index c6c157a014..ded36daff8 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -98,6 +98,119 @@ it("preserves OpenStep dictionary and array autodetection", () => { }); }); +/** The single-member chain of a nested dictionary, down to its deepest value. */ +const deepestValue = (value: unknown): unknown => { + let cursor = value; + while ( + cursor !== null && + typeof cursor === "object" && + !Array.isArray(cursor) && + "k" in cursor + ) { + cursor = (cursor as Record).k; + } + return cursor; +}; + +/** The single-member chain of a nested array, down to its deepest value. */ +const deepestEntry = (value: unknown): unknown => { + let cursor = value; + while (Array.isArray(cursor) && cursor.length === 1) cursor = cursor[0]; + return cursor; +}; + +const nestedDictionaries = (depth: number) => + plist( + "k".repeat(depth) + + "x" + + "".repeat(depth), + ); + +const nestedArrays = (depth: number) => + plist( + "".repeat(depth) + "x" + "".repeat(depth), + ); + +it("rejects XML nesting beyond the projection limit with the limit diagnostic", () => { + // `plist` and the prototype-key traversal recurse once per level, so the + // depth check must report the same bounded-resource error as the projection + // instead of letting those recursive passes exhaust the call stack. + const limit = new RangeError("Plist exceeds 128 nesting levels"); + expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit); + expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit); + expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow(limit); + expect(() => parseXmlPropertyList(nestedArrays(4000))).toThrow(limit); +}); + +it("decodes XML property lists exactly at the projection nesting limit", () => { + expect( + deepestValue(parseXmlPropertyList(nestedDictionaries(128)).value), + ).toBe("x"); + expect(deepestEntry(parseXmlPropertyList(nestedArrays(128)).value)).toBe("x"); + expect( + parseXmlPropertyList( + plist("".repeat(128) + "" + "".repeat(128)), + ).value, + ).toBeDefined(); +}); + +it("does not count markup inside comments, CDATA, or the doctype as nesting", () => { + const text = ` +">]> + + a]]> + + bx +`; + expect(parseXmlPropertyList(text)).toEqual({ + value: { a: "", b: ["x"] }, + omittedPrototypeKeys: 0, + }); +}); + +it.each(['"', "'"])( + "counts containers with quoted XML delimiters using %s attributes", + (quote) => { + const opening = `${quote}>k`; + const text = plist( + opening.repeat(3000) + "x" + "".repeat(3000), + ); + expect(() => parseXmlPropertyList(text)).toThrow( + new RangeError("Plist exceeds 128 nesting levels"), + ); + expect( + parseXmlPropertyList( + plist( + "" + `${quote}/>`.repeat(130) + "", + ), + ).value, + ).toEqual(Array.from({ length: 130 }, () => ({}))); + }, +); + +it("ignores complete DOCTYPE internal subsets when checking value depth", () => { + const text = ` + +".repeat(130)}"> +]>x`; + expect(parseXmlPropertyList(text)).toEqual({ + value: "x", + omittedPrototypeKeys: 0, + }); +}); + +it("bounds nested plist wrappers before recursive decoding", () => { + const text = + "".repeat(3000) + "x" + "".repeat(3000); + expect(() => parseXmlPropertyList(text)).toThrow( + new RangeError("Plist exceeds 128 nesting levels"), + ); + expect( + parseXmlPropertyList(plist("x")).value, + ).toEqual(["x"]); +}); + it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])( "preserves unrelated source values around %j line endings", (newline) => { diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 778af9295e..35aa0889d9 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -1,6 +1,13 @@ -import { DOMParser, normalizeLineEndings } from "@xmldom/xmldom"; +import { + DOMParser, + normalizeLineEndings, + type Document, + type Node, +} from "@xmldom/xmldom"; import { parse } from "plist"; +import { MAX_PLIST_DEPTH } from "./apple/plistValue.js"; + /** The dictionary key REA results cannot carry: validation strips it. */ const PROTOTYPE_KEY = "__proto__"; @@ -88,18 +95,64 @@ export const parseXmlPropertyList = ( // by app Info.plist resolution. Only XML needs the key-element substitution. const first = text.trimStart()[0]; if (first === "{" || first === "(") return omitPrototypeKeys(parse(text)); + // Normalize only the literal line endings the pinned decoder normalizes. + // Preserve character references in the source: serializing a DOM would turn + // e.g. an encoded carriage return into a literal newline on the second parse. + const normalized = normalizeLineEndings(text); + // The pinned XML parser is iterative. Reuse its document for the depth + // preflight and key lookup so quoted attributes and declarations have the + // same meaning at both boundaries, before plist's recursive value decode. + const document = new DOMParser().parseFromString(normalized, "text/xml"); + assertXmlPlistDepthWithinLimit(document); const { substituted, placeholder } = substitutePrototypeKeys( - normalizeLineEndings(text), + normalized, + document, ); return withoutKey(parse(substituted), placeholder); }; +/** Elements whose children the pinned plist value decoder visits recursively. */ +const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]); + +/** + * Reject XML plists nested deeper than the projection supports before any + * recursive value pass runs. `plist` and the prototype-key traversal recurse once + * per nesting level, so a deeply nested document exhausts the call stack + * before the projection limit can report the resource bound. The failure + * matches `projectPlistValue` so callers classify it as a limit failure. + * + * Depth counts values the way the projection does: the root value is level 0 + * and every container member is one level deeper. A dictionary key names a + * member instead of nesting one, and a self-closing element is the value at + * its own level. The outer plist envelope is unwrapped by the decoder; + * nested plist elements decode as arrays and must count toward the bound. + */ +const assertXmlPlistDepthWithinLimit = (document: Document): void => { + const root = document.documentElement; + if (root === null) return; + const pending: Array<{ node: Node; depth: number }> = [ + { node: root, depth: -1 }, + ]; + while (pending.length > 0) { + const entry = pending.pop(); + if (entry === undefined) break; + const { node, depth } = entry; + // Schedule only the next sibling, keeping traversal storage proportional + // to depth rather than the number of members in a wide container. + if (node.nextSibling !== null) + pending.push({ node: node.nextSibling, depth }); + if (node.nodeType !== node.ELEMENT_NODE) continue; + if (node.nodeName !== "key" && depth > MAX_PLIST_DEPTH) + throw new RangeError( + `Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`, + ); + if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null) + pending.push({ node: node.firstChild, depth: depth + 1 }); + } +}; + /** Locate keys with the same XML parser and first-child semantics as plist. */ -const substitutePrototypeKeys = (text: string) => { - // Normalize only the literal line endings the pinned decoder normalizes. - // Preserve character references in the source: serializing a DOM would turn - // e.g. an encoded carriage return into a literal newline on the second parse. - const document = new DOMParser().parseFromString(text, "text/xml"); +const substitutePrototypeKeys = (text: string, document: Document) => { const keys = Array.from(document.getElementsByTagName("key")); const decodedKeys = new Set(keys.map((key) => key.firstChild?.nodeValue)); let placeholder = "__rea_prototype_key__"; diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index d00af8d498..94fdf6ce71 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { + BinaryArchitecture, + HeaderArchitecture, +} from "./binaryTargetTypes.js"; import type { ExecutableFormatHint } from "./dosCom.js"; import { err, ok, type Result } from "./result.js"; @@ -55,7 +58,7 @@ export const validateRawBinaryLength = ( /** Admitted headerless load identity used by target resolution and profiles. */ export interface RawBinaryAdmission { - readonly architecture: Exclude; + readonly architecture: HeaderArchitecture; readonly entryPoint: string; readonly entryPointSource: string; } diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index a736cd0bf4..60edb0971b 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -17,6 +17,10 @@ import { ghidraMipsProfileParameters, ghidraMipsSharedLanguageOverride, } from "./GhidraMipsProfile.js"; +import { + ghidraRiscvLanguageOverride, + ghidraRiscvProfileParameters, +} from "./GhidraRiscvProfile.js"; import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; import { @@ -32,6 +36,17 @@ import { NATIVE_AOT_PROFILE_CONTRACT_REVISION, } from "../domain/native/nativeAotPe.js"; +/** + * The processor language REA pins for this target, or undefined when Ghidra may + * select from the header. The committed profile and the session readback both + * compare against this single owner rather than re-deriving a lane. + */ +export const ghidraPinnedLanguage = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + ghidraMipsSharedLanguageOverride(target) ?? + ghidraRiscvLanguageOverride(target); + /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = async ( target: BinaryTarget, @@ -136,22 +151,26 @@ export const resolveGhidraAnalysisProfile = async ( const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; const dos = dosMz || dosCom; - const mipsLanguage = ghidraMipsSharedLanguageOverride(target); + const pinnedLanguage = ghidraPinnedLanguage(target); if ( - mipsLanguage !== undefined && + pinnedLanguage !== undefined && languageOverride !== undefined && - (languageOverride.languageId !== mipsLanguage.languageId || + (languageOverride.languageId !== pinnedLanguage.languageId || (languageOverride.compilerSpecId !== undefined && - languageOverride.compilerSpecId !== mipsLanguage.compilerSpecId)) + languageOverride.compilerSpecId !== pinnedLanguage.compilerSpecId)) ) return err( new AnalysisUnsupportedTargetError( "resolve_analysis_profile", target.path, - "The caller-selected processor or compiler specification is outside the verified endian-matched MIPS32 shared-image profile. The requested override has not been changed.", + `The caller-selected processor or compiler specification is outside the verified ${ + target.architecture === "riscv" + ? "generic little-endian RISC-V" + : "endian-matched MIPS32 shared-image" + } profile. The requested override has not been changed.`, ), ); - const selectedLanguage = languageOverride ?? mipsLanguage; + const selectedLanguage = languageOverride ?? pinnedLanguage; // DOS imports already force an admitted loader, language, and context. if (dos && languageOverride !== undefined) return Promise.resolve( @@ -227,7 +246,7 @@ export const resolveGhidraAnalysisProfile = async ( ? "auto-default" : (selectedLanguage.compilerSpecId ?? "language-default"), ...(languageOverride === undefined - ? mipsLanguage === undefined + ? pinnedLanguage === undefined ? {} : { language_selection: "provider-pinned-v1" } : { language_selection: "configured-v1" }), @@ -251,6 +270,7 @@ export const resolveGhidraAnalysisProfile = async ( : {}), ...analyzerParameters, ...ghidraMipsProfileParameters(target), + ...ghidraRiscvProfileParameters(target), ...(pspExtension?.ok === true ? { mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2", diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 059915d99c..185c719738 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -22,6 +22,7 @@ const WINDOWS_INSTALL = "C:\\tools\\ghidra_12.1.4_PUBLIC"; const WINDOWS_PROPERTIES = `${WINDOWS_INSTALL}\\Ghidra\\application.properties`; const WINDOWS_HEADLESS = `${WINDOWS_INSTALL}\\support\\analyzeHeadless.bat`; const WINDOWS_DECOMPILER = `${WINDOWS_INSTALL}\\Ghidra\\Features\\Decompiler\\os\\win_x86_64\\decompile.exe`; + const JAVA: GhidraJavaObservation = { version: "21.0.11", major: SUPPORTED_GHIDRA_JAVA_MAJOR, @@ -43,7 +44,9 @@ const host = ( ...overrides, }); -afterEach(() => vi.unstubAllEnvs()); +afterEach(() => { + vi.unstubAllEnvs(); +}); it("probes the selected Java environment without reading conflicting ambient settings", () => { vi.stubEnv("REA_GHIDRA_CONTEXT", "ambient"); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 8d2043e6bf..da762cb797 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -734,7 +734,16 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ probeJava(command, environment) { const observed = spawnSync( command, - ["-XX:-UsePerfData", "-XshowSettings:properties", "-version"], + [ + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + // JDK 17/18 use the legacy names; JDK 19+ use the names above. + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + "-XX:-UsePerfData", + "-XshowSettings:properties", + "-version", + ], { encoding: "utf8", env: environment, diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index aa50440797..c48f589576 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ "arm", "arm64", "mips", + "riscv", ]); /** Ghidra candidate backed by an isolated ephemeral headless import. */ diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index fc0297789b..39c89c46ae 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -14,6 +14,7 @@ import { ghidraSeedLimitations, } from "./GhidraAnalysisSeeds.js"; import { + ghidraPinnedLanguage, ghidraProfileDisabledAnalyzers, ghidraProfileLanguageOverride, sameGhidraDisabledAnalyzers, @@ -61,10 +62,10 @@ import { ghidraMipsSoftFloatFailure, ghidraMipsRelocatableLimitations, ghidraMipsSharedLimitations, - ghidraMipsSharedLanguageOverride, ghidraMipsSoftFloatLimitations, isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; +import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -453,7 +454,7 @@ export const createGhidraProviderClient = (input: { if ( !sameGhidraLanguageOverride( languageOverride, - config.ghidraLanguageOverride ?? ghidraMipsSharedLanguageOverride(target), + config.ghidraLanguageOverride ?? ghidraPinnedLanguage(target), ) ) return unavailableClient( @@ -523,6 +524,7 @@ export const createGhidraProviderClient = (input: { : []), ...ghidraMipsRelocatableLimitations(target), ...ghidraMipsSharedLimitations(target), + ...ghidraRiscvLimitations(target), ]; const providerLimitations = installation.platform === "win32" diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts index b1ee12fe55..17260d038e 100644 --- a/src/ghidra/GhidraPspProfile.ts +++ b/src/ghidra/GhidraPspProfile.ts @@ -1,5 +1,6 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js"; +import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js"; /** Allegrex's ELF machine declaration, not a filename or inferred instruction. */ export const isGhidraPspTarget = (target: BinaryTarget): boolean => @@ -13,6 +14,8 @@ export const isGhidraPspTarget = (target: BinaryTarget): boolean => export const ghidraProcessorUnsupportedReason = ( target: BinaryTarget, ): string | null => { + if (target.kind === "executable" && target.architecture === "riscv") + return ghidraRiscvUnsupportedReason(target); if ( target.kind !== "executable" || target.architecture !== "mips" || diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index 9a4f254b28..ea024bdbaa 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -1,4 +1,4 @@ -import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; +import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js"; /** Bound Ghidra language for a caller-supplied raw-binary architecture. */ export interface GhidraRawBinaryLanguage { @@ -13,7 +13,7 @@ export interface GhidraRawBinaryLanguage { * Big-endian, Thumb-only, and Cortex-M variants are out of scope. */ export const ghidraRawBinaryLanguage = ( - architecture: Exclude, + architecture: HeaderArchitecture, ): GhidraRawBinaryLanguage => { switch (architecture) { case "x86": diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts new file mode 100644 index 0000000000..9bbb65cf32 --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -0,0 +1,256 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import type { + BinaryTarget, + RiscvElfMetadata, +} from "../domain/binaryTargetTypes.js"; +import { silentLogger } from "../logger.js"; +import { HopperProvider } from "../hopper/HopperProvider.js"; +import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; +import { IdaProvider } from "../ida/IdaProvider.js"; +import { GhidraProvider } from "./GhidraProvider.js"; +import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; +import { + ghidraPinnedLanguage, + ghidraProfileLanguageOverride, + sameGhidraLanguageOverride, +} from "./GhidraAnalysisProfile.js"; + +// Abbreviated from Ghidra 12.1.4 Processors/RISCV/data/languages/riscv.ldefs. +// The release tree ships no big-endian or ELF-class-mismatched RISC-V language. +const RISCV_LDEFS = ` + + + RISC-V 64 little default + + + + RISC-V 32 little default + + + +`; + +// Fake installation inspection tests admission/profile contracts, not Ghidra. +const host: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), +}; +const configOf = ( + installDir: string, + environment: Record = {}, +) => { + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: installDir, + ...environment, + }); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; +const target = (change: Partial = {}): BinaryTarget => ({ + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: 64, + byteOrder: "little", + type: 2, + flags: 0x5, + ...change, + }, +}); + +let installDir = ""; +beforeAll(async () => { + installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-")); + const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages"); + await mkdir(languages, { recursive: true }); + await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS); +}); +afterAll(async () => { + await rm(installDir, { recursive: true, force: true }); +}); + +const ghidra = (environment: Record = {}) => + new GhidraProvider(configOf(installDir, environment), silentLogger, {}, host); + +describe("bounded Ghidra RISC-V admission", () => { + it("pins the generic language per width instead of accepting the header guess", async () => { + const provider = ghidra(); + const rv64 = target(); + const rv32 = target({ elfClass: 32, flags: 0x4 }); + expect(provider.inspectTargetSupport(rv64).status).toBe("supported"); + expect(provider.inspectTargetSupport(rv32).status).toBe("supported"); + expect(ghidraPinnedLanguage(rv64)).toEqual({ + languageId: "RISCV:LE:64:default", + compilerSpecId: "gcc", + }); + expect(ghidraPinnedLanguage(rv32)).toEqual({ + languageId: "RISCV:LE:32:default", + compilerSpecId: "gcc", + }); + const wide = await provider.resolveAnalysisProfile(rv64); + const narrow = await provider.resolveAnalysisProfile(rv32); + if (!wide.ok || !narrow.ok) + throw new Error("expected admitted RISC-V profiles"); + expect(wide.value.profile?.parameters.riscv_elf).toEqual({ + elf_class: 64, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + expect(wide.value.profile?.parameters.riscv_support_lane).toBe( + "elf64-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_support_lane).toBe( + "elf32-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_elf).toMatchObject({ + elf_class: 32, + rvc_declared: false, + }); + expect(wide.value.profile?.digest).not.toBe(narrow.value.profile?.digest); + }); + + it("commits the pinned language the session readback expects", async () => { + // A pinned lane has one owner: the committed profile and the open_binary + // readback must agree without either re-deriving a different family's pin. + const provider = ghidra(); + for (const value of [target(), target({ elfClass: 32 })]) { + const resolved = await provider.resolveAnalysisProfile(value); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a RISC-V profile"); + expect( + sameGhidraLanguageOverride( + ghidraProfileLanguageOverride(resolved.value.profile.parameters), + ghidraPinnedLanguage(value), + ), + ).toBe(true); + } + }); + + it.each([ + [{ byteOrder: "big" }, "little-endian"], + [{ type: 3 }, "fixed-address ET_EXEC only"], + [{ type: 1 }, "fixed-address ET_EXEC only"], + [{ type: 0xffa0 }, "e_type 65440"], + [{ flags: 0xc }, "EF_RISCV_RVE"], + [{ flags: 0x14 }, "EF_RISCV_TSO"], + [{ flags: 0x1 }, "soft-float convention"], + [{ flags: 0x3 }, "single-float"], + [{ flags: 0x7 }, "quad-float"], + [{ flags: 0x8000_0005 }, "(0x80000000)"], + [{ flags: 0x24 }, "bits outside the verified lane"], + ] satisfies [Partial, string][])( + "refuses an unverified RISC-V interpretation %j", + async (change, reason) => { + const provider = ghidra(); + const value = target(change); + expect(provider.inspectTargetSupport(value)).toMatchObject({ + status: "unsupported", + code: "architecture_unsupported", + reason: expect.stringContaining(reason), + }); + expect(await provider.resolveAnalysisProfile(value)).toEqual( + expect.objectContaining({ ok: false }), + ); + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + }, + ); + + it("refuses a caller-selected processor outside the verified generic lane", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:32:AndeStar_v5", + }).resolveAnalysisProfile(target({ elfClass: 32 })); + expect(resolved.ok).toBe(false); + if (resolved.ok) throw new Error("expected a refusal"); + expect(resolved.error.message).toContain("generic little-endian RISC-V"); + }); + + it("keeps a caller-selected language that matches the pin as configured", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:64:default", + REA_GHIDRA_COMPILER_SPEC_ID: "gcc", + }).resolveAnalysisProfile(target()); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a configured profile"); + expect(resolved.value.profile.parameters.language_selection).toBe( + "configured-v1", + ); + expect(resolved.value.profile.parameters.language_id).toBe( + "RISCV:LE:64:default", + ); + }); + + it("states the linkage claim boundary in the lane limitations", () => { + const notes = ghidraRiscvLimitations(target()); + expect(notes).toHaveLength(3); + expect(notes.join(" ")).toContain("does not establish static linkage"); + expect(notes[0]).toContain("RISCV:LE:64:default"); + expect( + ghidraRiscvLimitations({ + path: "/other.elf", + sha256: "b".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }), + ).toEqual([]); + }); + + it("keeps RISC-V out of unverified Hopper and IDA adapter lanes", () => { + const hopper = new HopperProvider( + configOf("/hopper"), + silentLogger, + {}, + "linux", + ); + expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported"); + expect(hopperLoaderArgsForTarget(target()).ok).toBe(false); + expect(hopperLoaderArgsForTarget(target())).toEqual( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("RISC-V"), + }), + }), + ); + const ida = new IdaProvider(configOf("/ida"), {}); + expect(ida.inspectTargetSupport(target()).status).toBe("unsupported"); + }); +}); diff --git a/src/ghidra/GhidraRiscvProfile.ts b/src/ghidra/GhidraRiscvProfile.ts new file mode 100644 index 0000000000..3ccd5fc4cb --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.ts @@ -0,0 +1,129 @@ +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; + +/** + * Ghidra's generic RISC-V languages model the double-float ABI: the shipped + * `riscv.ldefs` maps `RISCV:LE:{32,64}:default` to the `riscv{32,64}-fp.cspec` + * compiler specification. Soft, single and quad ABI declarations therefore need + * their own verification rather than inheriting this lane. + */ +const EF_RISCV_RVC = 0x0001; +const EF_RISCV_FLOAT_ABI = 0x0006; +const EF_RISCV_FLOAT_ABI_DOUBLE = 0x0004; +const EF_RISCV_RVE = 0x0008; +const EF_RISCV_TSO = 0x0010; + +const ET_REL = 1; +const ET_EXEC = 2; +const ET_DYN = 3; + +/** + * Bits this lane reads. Anything else in `e_flags` — reserved today or defined by + * a newer psABI — is refused rather than assumed compatible. + */ +const EF_RISCV_DECLARED_BITS = EF_RISCV_RVC | EF_RISCV_FLOAT_ABI; + +/** Header declarations outside the verified generic-language lane. */ +const riscvUnsupportedFloatAbi = (abi: number): string => + abi === 0 + ? "unspecified (soft-float convention)" + : abi === 0x0002 + ? "single-float" + : "quad-float"; + +/** + * Keep family recognition separate from the deliberately bounded Ghidra lane. + * + * Ghidra 12.1.4 selects `RISCV:LE:32:AndeStar_v5` for an ordinary RV32 ELF: its + * `riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves + * the 32-bit one with the comment that the loader "must choose between 'default' + * and 'AndeStar' variants". The lane therefore pins the language instead of + * accepting the header-based guess. + */ +export const ghidraRiscvUnsupportedReason = ( + target: BinaryTarget, +): string | null => { + if (target.kind !== "executable" || target.architecture !== "riscv") + return null; + const metadata = target.riscv; + if (target.format !== "elf") + return "RISC-V analysis requires ELF header metadata; a family name alone does not select an ISA or ABI."; + if (metadata.byteOrder !== "little") + return "This Ghidra RISC-V lane supports little-endian images only; Ghidra 12.1.4 ships no big-endian RISC-V language."; + if (metadata.elfClass !== 32 && metadata.elfClass !== 64) + return "This Ghidra RISC-V lane requires ELF32 or ELF64; the class decides which generic language is pinned."; + if ((metadata.flags & EF_RISCV_RVE) !== 0) + return "EF_RISCV_RVE declares the embedded 15-register ABI, which needs its own processor specification and compiler verification."; + if ((metadata.flags & EF_RISCV_TSO) !== 0) + return "EF_RISCV_TSO declares the total-store-order memory model; this lane verifies the baseline model only."; + if (metadata.type !== ET_EXEC) + return `This Ghidra RISC-V lane supports fixed-address ET_EXEC only; ${ + metadata.type === ET_REL + ? "relocatable" + : metadata.type === ET_DYN + ? "shared and position-independent" + : `e_type ${metadata.type}` + } images need separate load verification. ET_EXEC alone does not establish static linkage, which this lane does not inspect.`; + const abi = metadata.flags & EF_RISCV_FLOAT_ABI; + if (abi !== EF_RISCV_FLOAT_ABI_DOUBLE) + return `This Ghidra RISC-V lane requires the EF_RISCV_FLOAT_ABI_DOUBLE declaration (${riscvUnsupportedFloatAbi( + abi, + )} observed); the pinned generic language uses the double-float compiler specification.`; + const unsupportedFlags = (metadata.flags & ~EF_RISCV_DECLARED_BITS) >>> 0; + if (unsupportedFlags !== 0) + return `RISC-V e_flags declares bits outside the verified lane (0x${unsupportedFlags.toString(16)}); reserved or newer declarations are not treated as compatible with this profile.`; + return null; +}; + +/** Pin a language only for declarations admitted by the same lane boundary. */ +export const ghidraRiscvLanguageOverride = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + target.kind === "executable" && + target.architecture === "riscv" && + ghidraRiscvUnsupportedReason(target) === null + ? { + languageId: `RISCV:LE:${target.riscv.elfClass}:default`, + compilerSpecId: "gcc", + } + : undefined; + +/** Commit source ELF interpretation without confusing target ISA with host CPU. */ +export const ghidraRiscvProfileParameters = ( + target: BinaryTarget, +): Readonly> => + target.kind === "executable" && + target.architecture === "riscv" && + target.riscv !== undefined + ? { + riscv_elf: { + elf_class: target.riscv.elfClass, + byte_order: target.riscv.byteOrder, + type: target.riscv.type, + flags: target.riscv.flags, + float_abi_double: + (target.riscv.flags & EF_RISCV_FLOAT_ABI) === + EF_RISCV_FLOAT_ABI_DOUBLE, + rvc_declared: (target.riscv.flags & EF_RISCV_RVC) !== 0, + rve_declared: (target.riscv.flags & EF_RISCV_RVE) !== 0, + tso_declared: (target.riscv.flags & EF_RISCV_TSO) !== 0, + }, + riscv_support_lane: + target.riscv.elfClass === 32 + ? "elf32-exec-le-double-float-generic-v1" + : "elf64-exec-le-double-float-generic-v1", + } + : {}; + +/** The pinned language is an analysis convention, not proof of the producer. */ +export const ghidraRiscvLimitations = ( + target: BinaryTarget, +): readonly string[] => + target.kind === "executable" && target.architecture === "riscv" + ? [ + `RISC-V ELF: REA pins Ghidra's generic \`RISCV:LE:${target.riscv.elfClass}:default\` language with the \`gcc\` compiler specification instead of accepting the loader's header-based choice, which selects \`RISCV:LE:32:AndeStar_v5\` for ordinary RV32 images on Ghidra 12.1.4.`, + "The pinned generic language models the double-float ABI declared by `e_flags`; the declaration is recorded as an ELF fact and is not evidence of the original compiler or of verified instruction semantics.", + "Admission reads `e_type` only: a fixed-address ET_EXEC declaration does not establish static linkage, and program headers (`PT_INTERP`, dynamic dependencies) are not inspected by this lane.", + ] + : []; diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 927f7289b2..299862a97e 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -8,8 +8,10 @@ import type { import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, - type BinaryArchitecture, + type ElfMetadataArchitecture, + type HeaderArchitecture, } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, @@ -101,6 +103,15 @@ export const resolveHopperAnalysisProfile = async ( }); }; +/** Ghidra owns these lanes; Hopper names the family it declines rather than guessing. */ +const HOPPER_DECLINED_ARCHITECTURE_LABELS: Record< + ElfMetadataArchitecture, + string +> = { + mips: "MIPS", + riscv: "RISC-V", +}; + /** Derive Hopper's complete non-interactive CLI loader selection. */ export const hopperLoaderArgsForTarget = ( target: BinaryTarget, @@ -113,12 +124,12 @@ export const hopperLoaderArgsForTarget = ( if (target.kind !== "executable") return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); const architecture = target.architecture; - if (architecture === "mips") + if (isElfMetadataArchitecture(architecture)) return err( new AnalysisCapabilityUnavailableError( "hopper", "resolve_analysis_profile", - "REA's Hopper adapter does not admit MIPS targets. Select a verified Ghidra MIPS profile instead.", + `REA's Hopper adapter does not admit ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} targets. Select a verified Ghidra ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} profile instead.`, ), ); const flag = hopperArchitectureFlag(architecture); @@ -151,9 +162,7 @@ export const hopperLoaderArgsForTarget = ( } }; -const hopperArchitectureFlag = ( - architecture: Exclude, -): string => { +const hopperArchitectureFlag = (architecture: HeaderArchitecture): string => { switch (architecture) { case "x86": return "--intel-32"; diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 4995333161..20a53105c3 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, } from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; @@ -47,7 +48,10 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - if (!isMachOFormat(target.format) || target.architecture === "mips") + if ( + !isMachOFormat(target.format) || + isElfMetadataArchitecture(target.architecture) + ) return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index c52eefe280..aec8c20829 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -16,7 +16,10 @@ import type { } from "../application/AnalysisProvider.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { AppConfig } from "../config/types.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; @@ -118,11 +121,13 @@ export class HopperProvider implements AnalysisProviderCandidate { reason: null, diagnostics, }; - if (target.architecture === "mips") + if (isElfMetadataArchitecture(target.architecture)) return { status: "unsupported", code: "architecture_unsupported", - reason: "REA's Hopper adapter does not admit MIPS targets.", + reason: `REA's Hopper adapter does not admit ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } targets.`, diagnostics, }; if (target.format === "raw-binary") diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 16b934f800..09011caa57 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -1,7 +1,10 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { @@ -102,12 +105,16 @@ export class IdaProvider implements AnalysisProviderCandidate { target_kind: target.kind, target_format: target.format, }; - if (target.kind === "executable" && target.architecture === "mips") + if ( + target.kind === "executable" && + isElfMetadataArchitecture(target.architecture) + ) return { status: "unsupported", code: "architecture_unsupported", - reason: - "REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.", + reason: `REA's IDA adapter has no verified ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } lane. Select a supported Ghidra profile instead.`, diagnostics, }; if (target.kind !== "executable") diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index ec31113ee8..2d2fcec00f 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -662,6 +662,7 @@ export const observeSettlement = async ( } const started = Date.now(); let consecutiveEmpty = 0; + let sawLive = false; let deadlineReached = false; while (!deadlineReached) { onLiveProgress?.(); @@ -685,13 +686,20 @@ export const observeSettlement = async ( recordEvent("lifecycle", 1); return { state: "quiesced", elapsed_ms: Date.now() - started }; } - } else consecutiveEmpty = 0; + } else { + consecutiveEmpty = 0; + sawLive = true; + } deadlineReached = Date.now() - started >= settleMs; if (deadlineReached) break; await delay(50, undefined, { signal }); } recordEvent("lifecycle", 1); - return { state: "alive_at_deadline", elapsed_ms: Date.now() - started }; + // A lone empty observation proves neither liveness nor quiescence; only an + // observed live member justifies alive_at_deadline. + return sawLive + ? { state: "alive_at_deadline", elapsed_ms: Date.now() - started } + : { state: "unverifiable", elapsed_ms: Date.now() - started }; }; /** Name why an attempt is unverified; a blank message never reaches the capture schema. */ diff --git a/tests/acceptance/process/processFinalization.test.ts b/tests/acceptance/process/processFinalization.test.ts index 65690bf5ca..7dcb4e969e 100644 --- a/tests/acceptance/process/processFinalization.test.ts +++ b/tests/acceptance/process/processFinalization.test.ts @@ -30,7 +30,7 @@ type CaptureOutcome = | { readonly kind: "partial"; readonly partial: unknown }; const captureScenario = ( - mode: "cooperative" | "ignoring" | "descendant", + mode: "cooperative" | "ignoring" | "delayed-ignoring" | "descendant", root: string, additions: CaptureScenario = {}, finalExitCode?: number, @@ -453,11 +453,13 @@ captureTest( "rea-finalization-cli-cancel-", ); const cliScenarioPath = join(cliRoot, "scenario.json"); + // Match the other finalization cases' observation window. The old 300 ms + // deadline could signal the fixture before it installed its handlers. await writeFile( cliScenarioPath, JSON.stringify( - captureScenario("ignoring", cliRoot, { - timeout_ms: 300, + captureScenario("delayed-ignoring", cliRoot, { + timeout_ms: 1_500, finalization_ms: 20_000, }), ), @@ -527,18 +529,36 @@ captureTest( const mcpRoot = await createTestTempDirectory( "rea-finalization-mcp-cancel-", ); - const { client } = await connectLocalToolsMcp(); + const { client, call: callTool } = await connectLocalToolsMcp(); const controller = new AbortController(); - const call = client.callTool( - { - name: "capture_process_scenario", - arguments: captureScenario("ignoring", mcpRoot, { - timeout_ms: 300, - finalization_ms: 20_000, - }), - }, - { signal: controller.signal }, + let reportStarted: (() => void) | undefined; + const captureStarted = new Promise((resolve) => { + reportStarted = resolve; + }); + const call = callTool( + "capture_process_scenario", + captureScenario("delayed-ignoring", mcpRoot, { + timeout_ms: 1_500, + finalization_ms: 20_000, + }), + { signal: controller.signal, onprogress: () => reportStarted?.() }, ); + // Observe rejection immediately: a failed readiness wait must not leave a + // pending request to reject unhandled when the fixture closes its client. + // The original promise remains available for the cancellation assertion. + const observedCall = call.catch(() => undefined); + onTestFinished(async () => { + controller.abort(); + await observedCall; + }); + // Start the fixture's readiness wait after the server begins this capture; + // SDK schema preparation before dispatch is not target startup time. + await Promise.race([ + captureStarted, + call.then(() => { + throw new Error("MCP capture completed before reporting startup"); + }), + ]); const mcpFixturePid = await waitForFinalization(mcpRoot); const mcpStarted = Date.now(); controller.abort(new Error("cancel finalization acceptance fixture")); @@ -548,7 +568,7 @@ captureTest( ).rejects.toThrow("cancel finalization acceptance fixture"); await waitForProcessExit(mcpFixturePid); await expect( - client.listTools(), + client.listTools(undefined, { cacheMode: "refresh" }), "the server accepts a follow-up request after cancelling the capture", ).resolves.toMatchObject({ tools: expect.any(Array) }); expect( diff --git a/tests/acceptance/setup/clientConfigurationDiagnosticsCli.test.ts b/tests/acceptance/setup/clientConfigurationDiagnosticsCli.test.ts new file mode 100644 index 0000000000..d9363988f8 --- /dev/null +++ b/tests/acceptance/setup/clientConfigurationDiagnosticsCli.test.ts @@ -0,0 +1,154 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const cases = [ + { + client: "cursor", + path: ".cursor/mcp.json", + text: "[]", + diagnostic: "The configuration root must be an object", + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: "null", + diagnostic: "The configuration root must be an object", + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: '{"mcpServers":null,"keep":1}', + diagnostic: '"mcpServers" must be an object', + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: '{"mcpServers":"x","keep":1}', + diagnostic: '"mcpServers" must be an object', + }, + { + client: "pi", + path: ".pi/agent/mcp.json", + text: '{"mcpServers":[]}', + diagnostic: '"mcpServers" must be an object', + }, + { + client: "codex", + path: ".codex/config.toml", + text: 'mcp_servers = "x"\n', + diagnostic: '"mcp_servers" must be an object', + }, + { + client: "opencode", + path: ".config/opencode/opencode.json", + text: '{"mcp":{"servers":null}}', + diagnostic: '"mcp.servers" must be an object', + }, + { + client: "hermes", + path: ".hermes/config.yaml", + text: "- item\n", + diagnostic: "The configuration root must be an object", + }, + { + client: "hermes", + path: ".hermes/config.yaml", + text: "mcp_servers: null\nkeep: 1\n", + diagnostic: '"mcp_servers" must be an object', + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: "not-json", + diagnostic: "Configuration is not valid JSON", + syntax: "JSON", + }, + { + client: "hermes", + path: ".hermes/config.yaml", + text: "mcp_servers: [\n", + diagnostic: "Configuration is not valid YAML", + syntax: "YAML", + }, + { + client: "dsh", + path: ".dsh/cordis.patch.yml", + text: "- insert: [\n", + diagnostic: "Configuration is not valid YAML", + syntax: "YAML", + }, +] as const; + +for (const scenario of cases) + cliTest( + `setup and uninstall explain ${scenario.client} configuration ${scenario.text}`, + async ({ cli }) => { + const home = await createTestTempDirectory( + "rea-client-config-diagnostic-", + ); + const config = join(home, scenario.path); + await mkdir(dirname(config), { recursive: true }); + await writeFile(config, scenario.text); + const environment = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + HERMES_HOME: join(home, ".hermes"), + DSH_HOME: join(home, ".dsh"), + }; + const setup = await cli.run({ + arguments: [ + "setup", + "--client", + scenario.client, + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment, + }); + expect(setup.exitCode).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + }); + if ("syntax" in scenario) { + expect(setup.json).toMatchObject({ remediation: expect.any(String) }); + if (scenario.syntax === "JSON") + expect(setup.stdout).toContain("Invalid JSON/JSONC"); + } else { + expect(setup.json).toMatchObject({ + remediation: expect.stringContaining(scenario.diagnostic), + }); + expect(setup.stdout).not.toContain("invalid_type"); + } + const uninstall = await cli.run({ + arguments: ["uninstall", "--json"], + cwd: home, + environment, + }); + expect(uninstall.exitCode).toBe(1); + expect(uninstall.json).toMatchObject({ + status: "failed", + items: expect.arrayContaining([ + expect.objectContaining({ + name: scenario.client, + status: "failed", + detail: expect.stringContaining(scenario.diagnostic), + }), + ]), + }); + if (!("syntax" in scenario)) + expect(uninstall.stdout).not.toContain("not valid JSON"); + expect(await readFile(config, "utf8")).toBe(scenario.text); + expect(await readdir(dirname(config))).toEqual([ + scenario.path.split("/").at(-1), + ]); + }, + ); diff --git a/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts new file mode 100644 index 0000000000..6e37acd239 --- /dev/null +++ b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts @@ -0,0 +1,78 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { build } from "plist"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +for (const [name, xml] of [ + ["dictionaries", deeplyNestedXmlPlist(3000)], + [ + "quoted attributes", + deeplyNestedXmlPlist(3000).replaceAll("", ''), + ], + [ + "plist wrappers", + "".repeat(3000) + "x" + "".repeat(3000), + ], +] as const) { + cliTest( + `compiled CLI reports deeply nested XML ${name} as the decode limit`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-cli-depth-"); + const path = join(root, "Deep.plist"); + await writeFile(path, xml); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(1); + expect(output.json).toMatchObject({ + code: "truncated", + category: "truncated", + details: { + reason: "limit", + truncated: true, + detail: + "Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels", + }, + }); + }, + ); +} + +cliTest( + "compiled CLI decodes a shallow archive with opaque DOCTYPE markup", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-cli-doctype-"); + const path = join(root, "Shallow.plist"); + const archive = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { UID: 1 } }, + }); + await writeFile( + path, + archive.replace( + /]*>/u, + `".repeat(130)}">]>`, + ), + ); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + expect(parseEvidence(output.json).normalized_result).toMatchObject({ + archive_format: "xml-plist", + objects: expect.arrayContaining([ + expect.objectContaining({ value: "leaf" }), + ]), + }); + }, +); diff --git a/tests/boundary/cli/nativeEfficiencyComparison.test.ts b/tests/boundary/cli/nativeEfficiencyComparison.test.ts new file mode 100644 index 0000000000..65542f0ea2 --- /dev/null +++ b/tests/boundary/cli/nativeEfficiencyComparison.test.ts @@ -0,0 +1,336 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); + +// A recording port checks the experimental producer/consumer representation. +// Real provider verification remains in the separate native experiment receipts. +const generate = ` +import {measureNativeEntry} from './experiments/analysis-efficiency/native-entry.mjs'; +const sha='0'.repeat(64); +const duplicate=process.argv[1]==='duplicate'; +const stale=process.argv[1]==='stale-dos'; +const collision=process.argv[1]; +const dos=process.argv[1]==='dos'||stale||collision==='load-collision'; +const dossier={procedure:{address:'0x1000'},pseudocode:'return;',callers:[],callees:[]}; +class ToolFailure extends Error {} +const build=async digit=>{ + const parent='ev_'+digit.repeat(63)+'0'; + let ordinal=0; + const call=async(name,args)=>{ + if(stale)process.stdout.write(name+'\\n'); + if(name==='open_binary'||name==='close_binary')return {}; + if(name==='inspect_native_load_image')return {evidence_id:'ev_'+digit.repeat(63)+'6',normalized_result:{status:'verified',target_sha256:stale?'f'.repeat(64):sha,entry:{linear_address:'0x1000'}}}; + if(name==='resolve_containing_procedure')return {evidence_id:'ev_'+digit.repeat(63)+'7',normalized_result:{found:true}}; + if(name==='analyze_function')return {evidence_id:parent,subject:{digest:{sha256:sha}},normalized_result:dossier}; + const id='ev_'+digit.repeat(63)+(collision==='containing-collision'?7:collision==='load-collision'?6:duplicate?1:++ordinal); + if(args.view.kind==='summary')return {evidence_id:id}; + const facet=args.view.facet; + const item=facet==='pseudocode'?{text:dossier.pseudocode,unit:'utf16-code-units'}:dossier[facet]; + return {evidence_id:id,normalized_result:{parent_evidence_id:parent,artifact:{sha256:sha},item,coverage:{exhausted:true},limitations:[],unknowns:[]}}; + }; + return measureNativeEntry({seed:dos?undefined:'0x1000',values:{path:'/selected/input','native-question':'entry-views'},call,inputSha:sha,ToolFailure}); +}; +process.stdout.write(JSON.stringify([await build('1'),await build('2')])); +`; + +it.each([ + "observed", + "missing", + "errored", + "wrong-target", + "wrong-entry", + "unreferenced", +])( + "binds the DOS model grade to its observed load image: %s", + async (scenario) => { + // Synthetic transcripts exercise grading; they are not real-provider or + // resource measurements. Real DOS transcripts are replayed separately. + const root = await createTestTempDirectory("rea-dos-grader-"); + const trial = join(root, "trial"); + const referenceDirectory = join(root, "reference"); + await mkdir(trial); + await mkdir(referenceDirectory); + const sha = "0".repeat(64); + const imageId = "ev_" + "1".repeat(64); + const procedureId = "ev_" + "2".repeat(64); + const procedure = { + address: "0x1000", + name: "entry", + classification: "observed", + signature: null, + locals: [], + body: { ranges: [], total_bytes: 0 }, + }; + const loadImage = { + status: "verified", + target_sha256: sha, + entry: { linear_address: "0x1000" }, + }; + const reference = { + artifact_sha256: sha, + entry_address: "0x1000", + caller_count: 0, + callee_count: 0, + pseudocode_prefix: "return;", + containing_procedure: { procedure }, + procedure, + load_image: loadImage, + }; + const answer = { + ...reference, + load_image_status: "verified", + evidence_ids: [ + procedureId, + ...(["missing", "errored", "unreferenced"].includes(scenario) + ? [] + : [imageId]), + ], + }; + const event = (tool: string, status: string, value: unknown) => ({ + type: "item.completed", + item: { + type: "mcp_tool_call", + tool, + status, + arguments: {}, + result: { structured_content: value }, + }, + }); + const events = [ + event("analyze_function", "completed", { evidence_id: procedureId }), + ]; + if (scenario !== "missing") + events.push( + event( + "inspect_native_load_image", + scenario === "errored" ? "failed" : "completed", + scenario === "errored" + ? { error: { code: "unsupported_target" } } + : { + operation: "inspect_native_load_image", + evidence_id: imageId, + normalized_result: { + ...loadImage, + target_sha256: + scenario === "wrong-target" ? "f".repeat(64) : sha, + entry: { + linear_address: + scenario === "wrong-entry" ? "0x2000" : "0x1000", + }, + }, + }, + ), + ); + const resourceLog = join(root, "resource.log"); + const output = join(root, "review.json"); + for (const [path, value] of [ + [ + join(trial, "summary.json"), + { + completed_mcp_calls: [ + { tool: "close_binary", status: "completed", arguments: {} }, + ], + }, + ], + [join(trial, "trial-settings.json"), {}], + [join(trial, "cleanup.json"), { profile_removed: true }], + [join(trial, "answer.txt"), answer], + [join(referenceDirectory, "answer.json"), reference], + [ + resourceLog, + { + resource_scope: "synthetic-grader-fixture", + abort: null, + resource_counters_verified: true, + resource_events_final: { + "memory.events": { high: 0, max: 0, oom: 0, oom_kill: 0 }, + "pids.events": { max: 0 }, + }, + }, + ], + ] as const) + await writeFile(path, JSON.stringify(value)); + await writeFile( + join(trial, "events.jsonl"), + events.map((value) => JSON.stringify(value)).join("\n"), + ); + const command = execute("python3", [ + resolve("experiments/analysis-efficiency/review-model.py"), + "--domain", + "native", + "--trial", + trial, + "--reference", + referenceDirectory, + "--resource-log", + resourceLog, + "--output", + output, + ]); + if (scenario === "observed") await command; + else await expect(command).rejects.toMatchObject({ code: 1 }); + const report = JSON.parse(await readFile(output, "utf8")); + expect(report.all_selected_checks_pass).toBe(scenario === "observed"); + if (["missing", "errored", "unreferenced"].includes(scenario)) + expect(report.checks.load_image_source_observed_and_referenced).toBe( + false, + ); + if (scenario === "wrong-target") + expect(report.checks["load_image.target_sha256"]).toBe(false); + if (scenario === "wrong-entry") + expect(report.checks["load_image.source_entry"]).toBe(false); + }, +); + +it("retains the DOS load-image source for its facts and derived entry seed", async () => { + const { stdout } = await execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + "dos", + ]); + const [answer] = JSON.parse(stdout); + expect(answer).toMatchObject({ + entry_address: "0x1000", + load_image: { status: "verified", entry: { linear_address: "0x1000" } }, + evidence_ids: { native_load_image: "ev_" + "1".repeat(63) + "6" }, + }); +}); + +it("rejects a stale DOS load image before using its entry and closes the session", async () => { + await expect( + execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + "stale-dos", + ]), + ).rejects.toMatchObject({ + stdout: "open_binary\ninspect_native_load_image\nclose_binary\n", + stderr: expect.stringContaining( + "The DOS load image belongs to a different target", + ), + }); +}); + +it.each(["containing-collision", "load-collision"])( + "rejects a retained view that reuses an earlier producer identity: %s", + async (collision) => { + await expect( + execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + collision, + ]), + ).rejects.toMatchObject({ + stderr: expect.stringContaining( + "Retained native view reused an Evidence identity", + ), + }); + }, +); + +it("rejects a producer that reuses a view identity across facets", async () => { + await expect( + execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + "duplicate", + ]), + ).rejects.toMatchObject({ + stderr: expect.stringContaining( + "Retained native view reused an Evidence identity", + ), + }); +}); + +it.each(["references", "item", "coverage", "limitations", "unknowns"])( + "separates native references while comparing %s exactly", + async (change) => { + const { stdout: generated } = await execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + ]); + const [leftAnswer, rightAnswer] = JSON.parse(generated); + if (change === "item") + rightAnswer.retained_views[0].item.address = "0x1001"; + if (change === "coverage") + rightAnswer.retained_views[0].coverage.exhausted = false; + if (change === "limitations") + rightAnswer.retained_views[0].limitations.push("partial"); + if (change === "unknowns") + rightAnswer.retained_views[0].unknowns.push("unresolved"); + + const root = await createTestTempDirectory("rea-native-comparison-"); + const left = join(root, "left"); + const right = join(root, "right"); + const summary = { + artifact: "sharex.exe", + native_question: "entry-views", + provider_search_path_sha256: "0".repeat(64), + native_workflow_source_sha256: "1".repeat(64), + calls: [], + failure: null, + }; + for (const [directory, answer] of [ + [left, leftAnswer], + [right, rightAnswer], + ]) { + await mkdir(directory); + await writeFile(join(directory, "summary.json"), JSON.stringify(summary)); + await writeFile(join(directory, "answer.json"), JSON.stringify(answer)); + } + const output = join(root, "comparison.json"); + const command = execute(process.execPath, [ + resolve("experiments/analysis-efficiency/compare.mjs"), + "--left", + left, + "--right", + right, + "--output", + output, + ]); + if (change !== "references") { + await expect(command).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining('"retained_views"'), + }); + return; + } + const { stdout } = await command; + expect(JSON.parse(stdout)).toMatchObject({ + settings_match: true, + facts_match: true, + preserved_run_specific_values: [ + { + side: "left", + path: ["evidence_ids"], + value: leftAnswer.evidence_ids, + }, + { + side: "right", + path: ["evidence_ids"], + value: rightAnswer.evidence_ids, + }, + ], + }); + expect(leftAnswer.evidence_ids).not.toEqual(rightAnswer.evidence_ids); + expect(leftAnswer.evidence_ids.retained_views[0]).toMatchObject({ + evidence_id: "ev_" + "1".repeat(64), + parent_evidence_id: "ev_" + "1".repeat(63) + "0", + }); + expect(leftAnswer.evidence_ids.native_summary).toBe( + "ev_" + "1".repeat(63) + "5", + ); + }, +); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index e655754966..f1b16a52c6 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -312,6 +312,14 @@ it.each([ "tests/boundary/process/darwinProcessRunTokenReader.test.ts", { code: true, apple: true, package: false, runtime: false }, ], + [ + "tests/boundary/cli/nativePlistCli.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/boundary/filesystem/windowsNpxRegistration.test.ts", + { code: true, package: true, runtime: true, apple: false }, + ], [ "src/process/ProcessOwnership.test.ts", { code: true, package: true, apple: false, evm: false, browser: false }, diff --git a/tests/boundary/filesystem/ciTestReports.test.ts b/tests/boundary/filesystem/ciTestReports.test.ts new file mode 100644 index 0000000000..0c9bb4ccf9 --- /dev/null +++ b/tests/boundary/filesystem/ciTestReports.test.ts @@ -0,0 +1,174 @@ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; + +import { parseTestDurations } from "../../ciTestSequencer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const script = new URL("../../../scripts/ci/test-report.mjs", import.meta.url); +const stepsSchema = z.array( + z.object({ + name: z.string().optional(), + run: z.string().optional(), + uses: z.string().optional(), + if: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + with: z.record(z.string(), z.unknown()).optional(), + }), +); +const workflow = async () => + z + .object({ + jobs: z.object({ + test: z.object({ steps: stepsSchema }), + build: z.object({ steps: stepsSchema }), + docs: z.object({ steps: stepsSchema }), + static: z.object({ steps: stepsSchema }), + }), + }) + .parse( + parse( + await readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ), + ), + ).jobs; + +it("collects failed-shard diagnostics before enforcing the shard result", async () => { + const steps = (await workflow()).test!.steps; + const merge = steps.findIndex((step) => step.run?.includes("test:ci:merge")); + const upload = steps.findIndex((step) => step.with?.name === "test-results"); + const gate = steps.findIndex( + (step) => step.env?.SHARD_RESULT && step.run?.includes("test "), + ); + expect(merge).toBeGreaterThan(0); + expect(upload).toBeGreaterThan(merge); + expect(gate).toBeGreaterThan(upload); + expect(steps[merge]?.if).not.toContain("needs.test-shard.result"); + const command = steps[gate]?.run; + if (command === undefined) throw new Error("Missing shard result gate"); + for (const result of ["failure", "cancelled", "skipped", ""]) { + await expect( + execute("bash", ["-c", command], { + env: { ...process.env, CODE_REQUIRED: "true", SHARD_RESULT: result }, + }), + ).rejects.toMatchObject({ code: 1 }); + } + await expect( + execute("bash", ["-c", command], { + env: { ...process.env, CODE_REQUIRED: "false", SHARD_RESULT: "skipped" }, + }), + ).resolves.toMatchObject({ stderr: "" }); +}); + +it("keeps diagnosis available after merge failure and publishes only complete successful history", async () => { + const steps = (await workflow()).test!.steps; + const diagnosis = steps.find((step) => step.with?.name === "test-results"); + expect(diagnosis?.if).toContain("!cancelled()"); + expect(diagnosis?.if).not.toContain("success()"); + const history = steps.find((step) => step.with?.name === "test-timings"); + expect(history?.if).toContain("success()"); + expect(history?.if).toContain("needs.test-shard.result == 'success'"); + expect(history?.with?.path).toBe(".cache/ci-test-history/test-results.json"); + const download = (await workflow()).build!.steps.find( + (step) => step.name === "Download previous timing report", + ); + expect(download?.with?.name).toBe(history?.with?.name); +}); + +it("runs the existing onboarding and Python producer guards from their selected CI owners", async () => { + const jobs = await workflow(); + const scripts = z + .object({ scripts: z.record(z.string(), z.string()) }) + .parse( + JSON.parse( + await readFile( + new URL("../../../package.json", import.meta.url), + "utf8", + ), + ), + ).scripts; + expect( + jobs.docs.steps.some((step) => step.run === "npm run docs:check"), + ).toBe(true); + expect(scripts["docs:check"]).toContain( + "tests/conformance/readmeConsistency.test.ts", + ); + const producer = jobs.static.steps.find( + (step) => step.run === "npm run test:python", + ); + expect(producer?.if).toBe("needs.changes.outputs.code == 'true'"); + expect(scripts["test:python"]).toContain( + "bridge/native/test_rea_lldb_tracer.py", + ); +}); + +it("exports portable timing history and removes it on a failed, partial, or invalid rerun", async () => { + const root = await createTestTempDirectory("rea-ci-report-"); + const summary = join(root, "summary.md"); + const history = join(root, ".cache/ci-test-history/test-results.json"); + const report = { + success: true, + numFailedTests: 0, + testResults: [ + { + name: join(root, "tests/slow.test.ts"), + startTime: 10, + endTime: 110, + assertionResults: [{ large: "not timing data" }], + }, + { name: join(root, "tests/fast.test.ts"), startTime: 20, endTime: 30 }, + ], + }; + const run = async (value: unknown, shards = "success", merge = "success") => { + await writeFile(join(root, "test-results.json"), JSON.stringify(value)); + await writeFile(summary, ""); + return execute(process.execPath, [fileURLToPath(script)], { + cwd: root, + env: { + ...process.env, + GITHUB_STEP_SUMMARY: summary, + SHARD_RESULT: shards, + MERGE_RESULT: merge, + }, + }); + }; + await run(report); + const contents = await readFile(history, "utf8"); + expect(contents).not.toContain("assertionResults"); + expect(contents).not.toContain(root); + expect( + parseTestDurations(contents, resolve(root, "different-checkout")), + ).toEqual( + new Map([ + ["tests/slow.test.ts", 100], + ["tests/fast.test.ts", 10], + ]), + ); + for (const [value, shards, merge] of [ + [{ ...report, success: false, numFailedTests: 1 }, "failure", "failure"], + [report, "cancelled", "success"], + [report, "success", "failure"], + ] as const) { + await run(report); + await run(value, shards, merge); + await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(summary, "utf8")).toContain("diagnostic only"); + } + await run(report); + await expect( + run({ + ...report, + testResults: [{ name: "../outside.test.ts", startTime: 1, endTime: 3 }], + }), + ).rejects.toMatchObject({ code: 1 }); + await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 2d814a5f36..506a6d6dfa 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -1661,8 +1661,9 @@ describe("uninstall configuration preflight", () => { }); it.each([ - ['{"mcpServers": null}', '"mcpServers" must be an object'], + ['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'], ["[]", "The configuration root must be an object"], + ['{"mcpServers":"x"}', '"mcpServers" must be an object'], ])( "names the structural problem in valid JSON %s", async (content, problem) => { @@ -1679,12 +1680,14 @@ describe("uninstall configuration preflight", () => { }, }); const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.status).toBe("failed"); expect(result.items).toContainEqual({ name: "cursor", status: "failed", detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`, }); expect(await readFile(config, "utf8")).toBe(content); + await expect(readFile(`${config}.rea.backup`, "utf8")).rejects.toThrow(); }, ); diff --git a/tests/boundary/filesystem/ghidraJavaProbe.test.ts b/tests/boundary/filesystem/ghidraJavaProbe.test.ts new file mode 100644 index 0000000000..82ed00ef76 --- /dev/null +++ b/tests/boundary/filesystem/ghidraJavaProbe.test.ts @@ -0,0 +1,82 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; + +import { inspectGhidraInstallation } from "../../../src/ghidra/GhidraInstallation.js"; +import { SUPPORTED_GHIDRA_VERSION } from "../../../src/ghidra/GhidraInstallationPolicy.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn() })); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawnSync: mockSpawnSync, +})); + +afterEach(() => mockSpawnSync.mockReset()); + +it.each([ + { major: 17, fullJdk: true }, + { major: 21, fullJdk: true }, + { major: 21, fullJdk: false }, +])( + "preserves the reported Unicode Java home and classifies JDK $major (full JDK: $fullJdk)", + async ({ major, fullJdk }) => { + const root = await createTestTempDirectory("rea-java-probe-"); + const configuredHome = join(root, "selected-jdk"); + const reportedHome = join(root, "Réa逆向", `JDK ${String(major)}`); + const installDir = join(root, "ghidra"); + await mkdir(join(installDir, "Ghidra"), { recursive: true }); + await writeFile( + join(installDir, "Ghidra", "application.properties"), + `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`, + ); + if (fullJdk) { + await mkdir(join(reportedHome, "bin"), { recursive: true }); + await writeFile( + join( + reportedHome, + "bin", + process.platform === "win32" ? "javac.exe" : "javac", + ), + "compiler presence fixture", + { mode: 0o755 }, + ); + } + const version = `${String(major)}.0.11`; + mockSpawnSync.mockReturnValue({ + stdout: "", + stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n`, + status: 0, + }); + + const inspection = inspectGhidraInstallation({ + environment: {}, + installDir, + javaHome: configuredHome, + }); + + expect(inspection.javaHome).toBe(reportedHome); + expect(inspection.javaVersion).toBe(version); + expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject( + { + status: fullJdk ? "passed" : "failed", + detail: expect.stringContaining(fullJdk ? "; JDK;" : "; runtime only;"), + }, + ); + expect(mockSpawnSync).toHaveBeenCalledExactlyOnceWith( + join( + configuredHome, + "bin", + process.platform === "win32" ? "java.exe" : "java", + ), + expect.arrayContaining([ + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + ]), + expect.objectContaining({ encoding: "utf8" }), + ); + }, +); diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts index f6b3815ee9..43a5f841c0 100644 --- a/tests/boundary/filesystem/hermesClientConfiguration.test.ts +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -6,9 +6,15 @@ import { parse as parseYaml } from "yaml"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; -import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} from "../../../src/application/SetupClientConfiguration.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; -import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { + runUninstall, + systemUninstallHost, +} from "../../../src/application/Uninstall.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; @@ -90,18 +96,75 @@ describe("Hermes configuration paths", () => { }); }); -describe("Hermes YAML registration", () => { - const hermesClient = async () => { - const home = await createTestTempDirectory("rea-hermes-"); - const client = supportedClients(home, process.platform, {}).find( - ({ name }) => name === "hermes", - ); - if (client?.markerPath === undefined) - throw new Error("missing Hermes client"); - await mkdir(client.markerPath, { recursive: true }); - return { home, client }; - }; +const hermesClient = async () => { + const home = await createTestTempDirectory("rea-hermes-"); + const client = supportedClients(home, process.platform, {}).find( + ({ name }) => name === "hermes", + ); + if (client?.markerPath === undefined) + throw new Error("missing Hermes client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; +}; +describe("Hermes YAML shape diagnostics", () => { + it.each( + [ + "!!set {rea: null}", + "!!omap [{rea: {command: rea, args: [mcp]}}]", + "!!timestamp 2001-12-15", + "!!binary cmVh", + ].flatMap((value) => [ + { + original: `${value}\n`, + problem: "The configuration root must be an object", + }, + { + original: `mcp_servers: ${value}\n`, + problem: '"mcp_servers" must be an object', + }, + ]), + )( + "blocks setup and uninstall before changes for tagged non-record YAML: $original", + async ({ original, problem }) => { + const { home, client } = await hermesClient(); + const cursor = join(home, ".cursor", "mcp.json"); + const cursorOriginal = + '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}'; + await mkdir(join(home, ".cursor")); + await writeFile(cursor, cursorOriginal); + await writeFile(client.configPath, original); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ + status: "invalid", + remediation: `Configuration ${client.configPath} is malformed: ${problem}`, + }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + const result = await runUninstall( + false, + systemUninstallHost(home, undefined, {}), + ); + expect(result.status).toBe("failed"); + expect(result.items).toContainEqual({ + name: "hermes", + status: "failed", + detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`, + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + expect(await readFile(cursor, "utf8")).toBe(cursorOriginal); + await expect( + readFile(`${client.configPath}.rea.backup`, "utf8"), + ).rejects.toThrow(); + await expect(readFile(`${cursor}.rea.backup`, "utf8")).rejects.toThrow(); + }, + ); +}); + +describe("Hermes YAML registration", () => { it("writes mcp_servers, preserves comments, reads back, and uninstalls", async () => { const { home, client } = await hermesClient(); await writeFile( diff --git a/tests/boundary/filesystem/installScript.test.ts b/tests/boundary/filesystem/installScript.test.ts index 0c45c12347..3fbcf7a3c0 100644 --- a/tests/boundary/filesystem/installScript.test.ts +++ b/tests/boundary/filesystem/installScript.test.ts @@ -334,13 +334,18 @@ describe("native macOS installer", { timeout: 20_000 }, () => { }); describe("installer semantic version parsing", { timeout: 20_000 }, () => { - it.each(["1.2.3-01"])( + it.each([ + "1.2.3-01", + "1.2.3-$(printf unexpected)", + '1.2.3-"; printf unexpected; #', + ])( "rejects malformed semantic versions before invoking npm: %s", async (version) => { const fixture = await createFixture(); await expect( runInstaller(fixture, ["--version", version, "--dry-run"]), ).rejects.toMatchObject({ + stdout: "", stderr: "REA installation failed: version must be an exact semantic version.\n", }); diff --git a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts new file mode 100644 index 0000000000..e2dbe084dc --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts @@ -0,0 +1,29 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js"; + +it.each([129, 3000])( + "reports XML nesting of %i levels as the keyed archive decode limit", + async (depth) => { + const root = await createTestTempDirectory("rea-keyed-depth-"); + await writeFile(join(root, "Deep.plist"), deeplyNestedXmlPlist(depth)); + const rejected = await inspectBundleKeyedArchive({ + bundlePath: root, + targetSha256: "a".repeat(64), + parameters: { path: "Deep.plist" }, + platform: "linux", + }).catch((cause: unknown) => cause); + // Recursive decoding exhausts the call stack before the projection limit + // can report it, so the limit diagnostic must come from the preflight. + expect(rejected).toMatchObject({ + reason: "limit", + message: + "Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels", + }); + }, +); diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index 6819b69cd7..79b489de5c 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { resolveClientConfigTransactionPath } from "../../../src/application/ClientConfigPath.js"; -import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} from "../../../src/application/SetupClientConfiguration.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier; @@ -330,3 +334,87 @@ describe("JSON client configuration migration and provider paths", () => { ).rejects.toThrow(); }); }); + +describe("JSON client configuration shape diagnostics", () => { + it.each([ + { + format: "json" as const, + original: + '{"__proto__":{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}}', + serversKey: "mcpServers", + }, + { + format: "opencode" as const, + original: + '{"mcp":{"__proto__":{"servers":{"rea":{"type":"local","command":["rea","mcp"]}}}}}', + serversKey: "mcp", + }, + ])( + "registers an own server instead of trusting inherited $format settings", + async ({ format, original, serversKey }) => { + const directory = await createTestTempDirectory("rea-setup-prototype-"); + const configPath = join(directory, "mcp.json"); + const client = { + name: format === "json" ? "cursor" : "opencode", + configPath, + format, + }; + await writeFile(configPath, original); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "update" }); + expect( + await configureClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "configured" }); + const document = JSON.parse(await readFile(configPath, "utf8")); + expect(Object.hasOwn(document, serversKey)).toBe(true); + expect(Object.hasOwn(document[serversKey], "rea")).toBe(true); + expect(document[serversKey].rea.command).toEqual( + format === "json" ? "rea" : ["rea", "mcp"], + ); + const originalDocument = JSON.parse(original); + expect( + format === "json" ? document.__proto__ : document[serversKey].__proto__, + ).toEqual( + format === "json" + ? originalDocument.__proto__ + : originalDocument[serversKey].__proto__, + ); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "already_current" }); + }, + ); + + it.each([ + ['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'], + ["[]", "The configuration root must be an object"], + ['{"mcpServers":"x"}', '"mcpServers" must be an object'], + ] as const)( + "names structural shape problems in setup remediation: %s", + async (original, problem) => { + const directory = await createTestTempDirectory("rea-setup-shape-"); + const configPath = join(directory, "mcp.json"); + const client = { + name: "cursor", + configPath, + format: "json" as const, + }; + await writeFile(configPath, original); + const read = await readConfigurationFile(configPath, "json"); + expect(read.ok).toBe(false); + if (read.ok) throw new Error("expected malformed configuration"); + expect(read.error.detail).toBe( + `Configuration ${configPath} is malformed: ${problem}`, + ); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toEqual({ + status: "invalid", + remediation: `Configuration ${configPath} is malformed: ${problem}`, + }); + expect(await readFile(configPath, "utf8")).toBe(original); + }, + ); +}); diff --git a/tests/boundary/filesystem/testTaskCache.test.ts b/tests/boundary/filesystem/testTaskCache.test.ts new file mode 100644 index 0000000000..7e06ce1a1a --- /dev/null +++ b/tests/boundary/filesystem/testTaskCache.test.ts @@ -0,0 +1,108 @@ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { workflowGit } from "../../support/workflowGit.js"; + +const execute = promisify(execFile); + +it.skipIf(process.platform === "android")( + "reruns test validation after repository or external input changes while reusing preparation", + async () => { + const config = z + .object({ + tasks: z.record(z.string(), z.record(z.string(), z.unknown())), + }) + .parse( + JSON.parse( + await readFile( + new URL("../../../turbo.json", import.meta.url), + "utf8", + ), + ), + ); + const testTask = config.tasks["test:run"]; + const dependencies = z.array(z.string()).parse(testTask?.dependsOn); + const root = await createTestTempDirectory("rea-test-cache-"); + const external = await createTestTempDirectory("rea-test-dependency-"); + const dependency = join(external, "provider.json"); + const scripts: Record = { "test:run": "node check.mjs" }; + const tasks: Record = { "test:run": testTask }; + for (const [index, task] of dependencies.entries()) { + scripts[task] = `node prepare.mjs ${index}`; + tasks[task] = { + inputs: ["artifact-input.txt"], + outputs: [`prepared-${index}.txt`], + }; + } + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-cache-fixture", + version: "1.0.0", + private: true, + type: "module", + packageManager: "npm@11.16.0", + scripts, + }), + ); + await writeFile( + join(root, "package-lock.json"), + JSON.stringify({ + name: "rea-cache-fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { "": { name: "rea-cache-fixture", version: "1.0.0" } }, + }), + ); + await writeFile(join(root, "turbo.json"), JSON.stringify({ tasks })); + await writeFile( + join(root, "artifact-input.txt"), + "unchanged preparation input", + ); + await writeFile(join(root, "README_zh.md"), "aligned"); + await writeFile(dependency, "healthy"); + await writeFile( + join(root, "prepare.mjs"), + 'import { appendFileSync } from "node:fs"; appendFileSync(`prepared-${process.argv[2]}.txt`, "prepared\\n");', + ); + await writeFile( + join(root, "check.mjs"), + `import { readFileSync } from "node:fs"; if(readFileSync("README_zh.md","utf8") !== "aligned" || readFileSync(${JSON.stringify(dependency)},"utf8") !== "healthy") throw new Error("current input is invalid");`, + ); + await workflowGit(root, ["init", "--initial-branch=main"]); + await workflowGit(root, ["add", "."]); + await workflowGit(root, ["commit", "-m", "test: seed cache fixture"]); + const run = () => + execute( + process.execPath, + [ + join(process.cwd(), "node_modules/turbo/bin/turbo"), + "run", + "test:run", + "--output-logs=errors-only", + ], + { cwd: root, env: { ...process.env, TURBO_DAEMON: "false" } }, + ); + await run(); + await writeFile(join(root, "README_zh.md"), "wrong command"); + await expect(run()).rejects.toMatchObject({ code: 1 }); + await writeFile(join(root, "README_zh.md"), "aligned"); + await run(); + await writeFile(dependency, "faulty"); + await expect(run()).rejects.toMatchObject({ code: 1 }); + await expect(run()).rejects.toMatchObject({ code: 1 }); + await writeFile(dependency, "healthy"); + await run(); + for (const index of dependencies.keys()) { + expect(await readFile(join(root, `prepared-${index}.txt`), "utf8")).toBe( + "prepared\n", + ); + } + }, +); diff --git a/tests/boundary/process/processSettlementDeadline.test.ts b/tests/boundary/process/processSettlementDeadline.test.ts new file mode 100644 index 0000000000..83c7483663 --- /dev/null +++ b/tests/boundary/process/processSettlementDeadline.test.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { observeSettlement } from "../../../src/process/capture/ProcessCaptureLifecycle.js"; +import { observeOwnedProcessGroups } from "../../../src/process/ProcessOwnershipObservation.js"; + +/** Process group id that cannot exist on any supported host. */ +const ABSENT_GROUP_ID = 4_294_967_295; + +const observeGroup = async (groupId: number) => + observeOwnedProcessGroups("settlement-deadline-probe", [groupId]); + +describe("settlement deadline classification", () => { + it("does not report a single empty observation as alive_at_deadline", async () => { + expect( + (await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state, + ).toBe("empty"); + + const settlement = await observeSettlement( + "settlement-single-empty", + [ABSENT_GROUP_ID], + 0, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("unverifiable"); + }); + + it("still reports observed liveness as alive_at_deadline", async () => { + const runId = "settlement-live-group"; + // A detached child leads its own process group and carries the run token, + // so the group observes as a genuinely owned live member. + const child = spawn("sleep", ["30"], { + detached: true, + stdio: "ignore", + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + }); + child.unref(); + try { + // Wait until the child is actually listed before asserting on it. + const started = Date.now(); + while ( + (await observeOwnedProcessGroups(runId, [child.pid!])).get(child.pid!) + ?.state !== "alive" + ) { + if (Date.now() - started > 5_000) + throw new Error("live fixture group never observed as alive"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + const settlement = await observeSettlement( + runId, + [child.pid!], + 0, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("alive_at_deadline"); + } finally { + child.kill("SIGKILL"); + } + }); + + it("still reports two consecutive empty observations as quiesced", async () => { + const settlement = await observeSettlement( + "settlement-two-empty", + [ABSENT_GROUP_ID], + 1_000, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("quiesced"); + }); +}); diff --git a/tests/boundary/providers/adb/adbBugreportPublication.test.ts b/tests/boundary/providers/adb/adbBugreportPublication.test.ts new file mode 100644 index 0000000000..b99799127e --- /dev/null +++ b/tests/boundary/providers/adb/adbBugreportPublication.test.ts @@ -0,0 +1,266 @@ +import { createHash } from "node:crypto"; +import { + copyFile, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; + +import { AdbProvider } from "../../../../src/adb/AdbProvider.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { + execFileBytes, + execFileOutput, +} from "../../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +// Exercise real filesystem publication; the Android process reply is simulated. +vi.mock( + "../../../../src/process/ExecFileOutput.js", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../../src/process/ExecFileOutput.js") + >()), + execFileBytes: vi.fn(), + execFileOutput: vi.fn(), + }), +); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + copyFile: vi.fn(actual.copyFile), + mkdtemp: vi.fn(actual.mkdtemp), + rm: vi.fn(actual.rm), + }; +}); + +afterEach(() => vi.restoreAllMocks()); + +const fixture = async (duringCollection?: (root: string) => Promise) => { + const root = await createTestTempDirectory("rea-adb-bugreport-"); + const binary = join(root, "adb"); + let reportedPath = ""; + vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => { + expect(args).toEqual(["version"]); + return { + stdout: `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`, + stderr: "", + }; + }); + vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { + expect(args.slice(0, 3)).toEqual(["-s", "device", "bugreport"]); + const staging = args[3]!; + reportedPath = join(staging, "bugreport test.zip"); + await writeFile(reportedPath, "bugreport-bytes"); + await duringCollection?.(root); + return { + stdout: Buffer.from(`Bug report copied to ${reportedPath}\n`), + stderr: Buffer.alloc(0), + }; + }); + const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } }); + return { + root, + provider, + reportedPath: () => reportedPath, + request: { + operation: "collect_adb_bugreport" as const, + input: { serial: "device", output_directory: root }, + }, + }; +}; + +test.each(["before", "during"] as const)( + "preserves a same-name caller archive created %s collection", + async (when) => { + const existing = async (root: string) => { + await writeFile(join(root, "bugreport test.zip"), "existing evidence"); + await writeFile(join(root, "bugreport test (1).zip"), "other evidence"); + }; + const f = await fixture(when === "during" ? existing : undefined); + if (when === "before") await existing(f.root); + const result = await f.provider.execute(f.request); + if (!result.ok) throw result.error; + const localPath = join(f.root, "bugreport test (2).zip"); + expect(result.value.result).toMatchObject({ + local_path: localPath, + adb_reported_path: f.reportedPath(), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }); + expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe( + "existing evidence", + ); + expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe( + "other evidence", + ); + expect(await readFile(localPath, "utf8")).toBe("bugreport-bytes"); + expect((await readdir(f.root)).sort()).toEqual([ + "bugreport test (1).zip", + "bugreport test (2).zip", + "bugreport test.zip", + ]); + await f.provider.close(); + }, +); + +test("retains a completed archive after failed publication and later provider work", async () => { + const f = await fixture(); + const existing = join(f.root, "bugreport test.zip"); + await writeFile(existing, "existing evidence"); + const publicationFailure = Object.assign(new Error("no space left"), { + code: "ENOSPC", + }); + vi.mocked(copyFile).mockRejectedValueOnce(publicationFailure); + const failed = await f.provider.execute(f.request); + if (failed.ok) throw new Error("expected publication failure"); + const retainedPath = f.reportedPath(); + expect(failed.error._tag).toBe("AnalysisOutputError"); + expect(failed.error.cause).toBe(publicationFailure); + expect( + projectAnalysisError(failed.error).details?.partial_observation, + ).toMatchObject({ + provider_id: "adb", + operation: f.request.operation, + result: { + serial: "device", + local_path: retainedPath, + adb_reported_path: retainedPath, + digest_status: "unknown", + }, + }); + expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + + // New work and terminal cleanup must not reclaim the retained acquisition. + const next = await f.provider.execute(f.request); + if (!next.ok) throw next.error; + expect(next.value.result).toMatchObject({ + local_path: join(f.root, "bugreport test (1).zip"), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }); + await f.provider.close(); + expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe( + "bugreport-bytes", + ); + expect((await readdir(f.root)).sort()).toEqual( + [ + basename(dirname(retainedPath)), + "bugreport test.zip", + "bugreport test (1).zip", + ].sort(), + ); +}); + +test("retains final and producer paths when digest verification is cancelled", async () => { + const f = await fixture(); + const controller = new AbortController(); + const actualCopy = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(copyFile).mockImplementationOnce(async (...args) => { + await actualCopy.copyFile(...args); + controller.abort(); + }); + const result = await f.provider.execute(f.request, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + operation: f.request.operation, + result: { + local_path: join(f.root, "bugreport test.zip"), + adb_reported_path: f.reportedPath(), + digest_status: "unknown", + }, + }); + expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe( + "bugreport-bytes", + ); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); + await f.provider.close(); +}); + +test("returns staging allocation failures without invoking device collection", async () => { + const f = await fixture(); + vi.mocked(mkdtemp).mockRejectedValueOnce(new Error("staging denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected output failure"); + expect(result.error._tag).toBe("AnalysisOutputError"); + expect(execFileBytes).not.toHaveBeenCalled(); + await f.provider.close(); +}); + +test("retains published evidence and retries failed staging cleanup", async () => { + const f = await fixture(); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cleanup failure"); + expect(result.error.cleanupResources).toContain(dirname(f.reportedPath())); + expect(result.error.partialObservation).toMatchObject({ + operation: f.request.operation, + result: { + local_path: join(f.root, "bugreport test.zip"), + adb_reported_path: f.reportedPath(), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }, + }); + await f.provider.close(); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); +}); + +test("refuses an archive reported outside the owned staging directory", async () => { + const f = await fixture(); + const existing = join(f.root, "bugreport test.zip"); + await writeFile(existing, "existing evidence"); + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.from(`Bug report copied to ${existing}\n`), + stderr: Buffer.alloc(0), + }); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected protocol refusal"); + expect(result.error._tag).toBe("AnalysisProtocolError"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); + await f.provider.close(); +}); + +test("preserves the primary protocol failure when staging cleanup also fails", async () => { + const f = await fixture(); + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.from("unsupported completion format\n"), + stderr: Buffer.alloc(0), + }); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected protocol and cleanup failure"); + expect(result.error.cause).toMatchObject({ + errors: [ + { + _tag: "AnalysisProtocolError", + message: expect.stringContaining("did not report an archive path"), + }, + expect.any(Error), + ], + }); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toHaveLength(1); + await f.provider.close(); + expect(await readdir(f.root)).toEqual([]); +}); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 27e591a17d..813b90004b 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -374,9 +374,7 @@ describe("evidence metadata imports and snapshot cache", () => { expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: false, error: { - message: expect.stringContaining( - "Analysis snapshots are unavailable because analysis metadata may have changed", - ), + _tag: "EvidenceIntegrityError", }, }); expect((await session.open(target)).ok).toBe(true); @@ -384,9 +382,7 @@ describe("evidence metadata imports and snapshot cache", () => { expect(session.importAnalysisSnapshot(baseline.value)).toMatchObject({ ok: false, error: { - userMessage: expect.stringContaining( - "Close the active target without saving a snapshot", - ), + _tag: "EvidenceIntegrityError", }, }); expect(session.exportEvidenceBundle().records).toContainEqual(relocated); diff --git a/tests/conformance/c/riscv.c b/tests/conformance/c/riscv.c new file mode 100644 index 0000000000..c100c4f186 --- /dev/null +++ b/tests/conformance/c/riscv.c @@ -0,0 +1,18 @@ +/* Source-owned freestanding analysis fixture. Never execute this target. */ +volatile unsigned int rea_riscv_global = 7; +extern unsigned int rea_riscv_probe(unsigned int value); + +__attribute__((noinline)) unsigned int rea_riscv_leaf(unsigned int value) +{ + return value * 3u + rea_riscv_global; +} + +__attribute__((noinline)) double rea_riscv_float(double left, double right) +{ + return left + right; +} + +__attribute__((noinline)) unsigned int rea_riscv_entry(unsigned int selector) +{ + return rea_riscv_probe(rea_riscv_leaf(selector + 1u)); +} diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index ae9cc7b4c5..d2161e1ff3 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -3,6 +3,14 @@ These source-owned fixtures are compiled at verification time; generated binaries and Ghidra projects are never committed. +- `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images + with double-float ABI declarations and an independently known instruction. + `npm run verify:ghidra:riscv` exercises real CLI/MCP discovery, decompilation, + output schemas, profile and snapshot identity, refusal diagnostics, immutable + source bytes, and owned cleanup. It requires BYO Ghidra 12.1.4/JDK 21+ and + RISC-V Clang/LLD/LLVM readelf; `REA_RISCV_CLANG`, `REA_RISCV_LLD` and + `REA_RISCV_READELF` select existing tool commands. + - `inventory.c` produces separate x86-64 debug and stripped ELF executables. It covers imports, an external `puts` function, linker thunks, source symbols, direct calls, a targetless callback call, two referenced strings, and a diff --git a/tests/conformance/ghidra/riscv-fixture.mjs b/tests/conformance/ghidra/riscv-fixture.mjs new file mode 100644 index 0000000000..27b354094e --- /dev/null +++ b/tests/conformance/ghidra/riscv-fixture.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +/** Build and independently inspect a source-owned ELF; never execute it. */ +export async function buildRiscvFixture(directory, bits) { + assert.ok(bits === 32 || bits === 64); + const compiler = process.env.REA_RISCV_CLANG ?? "clang"; + const linker = process.env.REA_RISCV_LLD ?? "ld.lld"; + const reader = process.env.REA_RISCV_READELF ?? "llvm-readelf"; + const source = fileURLToPath(new URL("../c/riscv.c", import.meta.url)); + const probe = fileURLToPath(new URL("./riscv-probe.S", import.meta.url)); + const path = join(directory, `rv${bits}.elf`); + const args = [ + `--target=riscv${bits}-unknown-elf`, + `-march=rv${bits}imafdc`, + `-mabi=${bits === 32 ? "ilp32d" : "lp64d"}`, + `--ld-path=${linker}`, + "-nostdlib", + "-static", + "-fno-pic", + "-O0", + "-g", + "-Wl,-e,rea_riscv_entry", + "-Wl,--build-id=none", + source, + probe, + "-o", + path, + ]; + const options = { timeout: 10000, maxBuffer: 1024 * 1024 }; + const compilerVersion = await execute(compiler, ["--version"], options); + const linkerVersion = await execute(linker, ["--version"], options); + const readerVersion = await execute(reader, ["--version"], options); + await execute(compiler, args, { ...options, timeout: 60000 }); + const { stdout, stderr } = await execute(reader, ["-h", "-s", "-W", path], { + ...options, + env: { ...process.env, LC_ALL: "C" }, + }); + assert.equal(stderr.trim(), "", "Unexpected ELF reader diagnostics"); + const field = (expression, label) => { + const matches = [...stdout.matchAll(expression)]; + assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); + return matches[0][1].trim(); + }; + assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), `ELF${bits}`); + assert.match(field(/^\s*Data:\s*(.+)$/gmu, "byte order"), /little endian/u); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^RISC-V\b/u); + const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+).*$/gmu, "flags")); + assert.equal(flags, 5, "Compiler did not declare RVC and double-float ABI"); + const symbols = {}; + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + "rea_riscv_global", + ]) { + const value = field( + new RegExp( + `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`, + "gmu", + ), + name, + ); + symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`; + } + return { + path, + sha256: createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + riscv: { elfClass: bits, byteOrder: "little", type: 2, flags }, + symbols, + toolchain: { + compiler: { command: compiler, version: compilerVersion.stdout.trim() }, + linker: { command: linker, version: linkerVersion.stdout.trim() }, + reader: { command: reader, version: readerVersion.stdout.trim() }, + arguments: args, + }, + sources: await Promise.all( + [source, probe].map(async (sourcePath) => ({ + path: sourcePath, + sha256: createHash("sha256") + .update(await readFile(sourcePath)) + .digest("hex"), + })), + ), + }; +} diff --git a/tests/conformance/ghidra/riscv-probe.S b/tests/conformance/ghidra/riscv-probe.S new file mode 100644 index 0000000000..701ca9582d --- /dev/null +++ b/tests/conformance/ghidra/riscv-probe.S @@ -0,0 +1,9 @@ +/* Fixed instruction oracle, independent of the compiler's C code generation. */ +.section .text +.option norvc +.global rea_riscv_probe +.type rea_riscv_probe, @function +rea_riscv_probe: + addi a0, a0, 17 + ret +.size rea_riscv_probe, .-rea_riscv_probe diff --git a/tests/fixtures/keyedArchiveXmlNesting.ts b/tests/fixtures/keyedArchiveXmlNesting.ts new file mode 100644 index 0000000000..77a1b3bc29 --- /dev/null +++ b/tests/fixtures/keyedArchiveXmlNesting.ts @@ -0,0 +1,9 @@ +/** A well-formed XML plist of N nested dictionaries, each with one key. */ +export const deeplyNestedXmlPlist = (depth: number): string => + '\n' + + '\n' + + '\n' + + "k".repeat(depth) + + "x" + + "".repeat(depth) + + "\n\n"; diff --git a/tests/fixtures/localToolsMcp.ts b/tests/fixtures/localToolsMcp.ts index bdfc3b84c3..1e1e91909b 100644 --- a/tests/fixtures/localToolsMcp.ts +++ b/tests/fixtures/localToolsMcp.ts @@ -1,4 +1,7 @@ -import { Client } from "@modelcontextprotocol/client"; +import { + Client, + type CallToolRequestOptions, +} from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { Ajv2020 } from "ajv/dist/2020.js"; import type { ValidateFunction } from "ajv"; @@ -69,12 +72,19 @@ export async function connectLocalToolsMcp() { validatedSchemas.add(serialized); } const definitions = new Map(tools.map((tool) => [tool.name, tool])); - const call = async (name: string, arguments_: Record) => { + const call = async ( + name: string, + arguments_: Record, + options?: Pick, + ) => { const definition = definitions.get(name); if (definition !== undefined) compileValidator(definition.inputSchema); const response = await client.callTool( { name, arguments: arguments_ }, - definition === undefined ? undefined : { toolDefinition: definition }, + { + ...options, + ...(definition === undefined ? {} : { toolDefinition: definition }), + }, ); if ( response.structuredContent !== undefined && diff --git a/tests/fixtures/processFinalization.mjs b/tests/fixtures/processFinalization.mjs index f70aa0d8bd..b51d2ea61e 100644 --- a/tests/fixtures/processFinalization.mjs +++ b/tests/fixtures/processFinalization.mjs @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; // Writes a periodic report, then behaves by mode: @@ -8,11 +9,16 @@ import { fileURLToPath } from "node:url"; // and exits cleanly. // - "ignoring": stays alive and swallows SIGTERM and SIGINT, so only SIGKILL // ends the process. +// - "delayed-ignoring": installs those handlers after a slow startup. // - "exits": exits on its own at once. // - "descendant": cooperates while leaving a SIGTERM-ignoring child for owned cleanup. // An optional third argument is the exit code a cooperative finalizer uses. const [mode, directory, finalExitCode = "0"] = process.argv.slice(2); +// Make the cancellation case exercise startup beyond its old 300 ms capture +// window deterministically, rather than relying on a busy CI runner. +if (mode === "delayed-ignoring") await delay(500); + writeFileSync(join(directory, "pid.txt"), String(process.pid)); writeFileSync( join(directory, "periodic.json"), @@ -31,7 +37,7 @@ if (mode === "descendant") { ); } -if (mode === "ignoring") { +if (mode === "ignoring" || mode === "delayed-ignoring") { process.on("SIGTERM", () => { writeFileSync(join(directory, "sigterm-received"), "received"); }); diff --git a/turbo.json b/turbo.json index 50bfacfb34..8de89672db 100644 --- a/turbo.json +++ b/turbo.json @@ -104,31 +104,12 @@ ] }, "test:run": { + "cache": false, "dependsOn": [ "artifacts:mcp-catalog", "artifacts:product-catalog", "artifacts:managed-evidence", "artifacts:skills" - ], - "inputs": [ - ".nvmrc", - "package.json", - "package-lock.json", - "src/**", - "tests/**", - "scripts/**", - "bridge/**", - "vitest.config.ts", - { - "mode": "dependencyOutputs", - "globs": [ - ".cache/mcp-tool-catalog.json", - "skills/**", - "docs/public/product-catalog.json", - "docs/verification/managed-conformance-manifest.json", - "docs/verification/managed-conformance-ledger.json" - ] - } ] }, "docs:check:built": {