diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index de9f6b7c1..f7e44e17a 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -31,6 +31,49 @@ Diagnose the observed blocker, continue useful supported work, and resume the original task after repair. Client approval, shell PATH, registration and engine availability have different remedies; avoid repeating setup for all of them. +1. Diagnose without changing REA or client configuration. `npx` may download + the package and write to npm's cache. For Codex, run + `npx -y rea-agents@latest doctor --client codex --json`. Substitute the current + supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`, + `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, + `commandcode`, `vscode`, `grok_build`, `omp`, or `grok_bot`. `grok_bot` has no local + file registration: doctor reports the Grok Bot chat step, and setup does not + write `mcp.json`. If the client is unknown, use `doctor --json` and + inspect its registration results before choosing a setup scope. +2. Distinguish the reason. Missing, malformed, or stale registration needs a + scoped configuration repair. An aligned registration with no tools in the + active session needs a restart/reconnection; doctor cannot prove that the + current agent has connected. A missing provider affects only tasks requiring + that provider: static JavaScript inspection needs neither Hopper nor Ghidra, + and Android inspection has separate bring-your-own JADX/Java prerequisites. +3. For a configuration repair, prepare the read-only plan: + `npx -y rea-agents@latest setup --client codex --dry-run --json`. + Use the current client's ID, show its exact proposed paths, backups, and + changes, and obtain approval before setup writes configuration or installs + Hopper. Setup normally installs the matching bundled skill too; include that + replacement in the reviewed plan. After approval, apply the same scope with + `npx -y rea-agents@latest setup --client codex --yes`. Add `--install-hopper` + only if that separate installation was needed and explicitly approved. +4. Restart/reconnect the affected agent. Verify that REA tools actually appear + in the session, then resume the original investigation. If they remain + absent, inspect the client's MCP launch error rather than repeating setup. + +REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra, IDA, +JADX, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated +software to repair +MCP registration. For an unsupported client, use manual stdio registration +with a version-pinned `rea-agents` package or continue through the CLI. + +A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is: + +```bash +npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json +``` + +No MCP registration or native engine is required. Read the returned Evidence, +graph, limitations, and unknowns with the same care as an MCP result; the CLI +returns the Evidence record directly. This fallback does not establish that +MCP is configured. Native CLI tasks still require their selected engine. Prefer an existing `rea` CLI. When none is installed and REA package acquisition is within the authorized scope, use `npx -y rea-agents@latest` in its place. Configuration repair starts with a read-only setup plan; show paths, backups and @@ -122,6 +165,14 @@ explicit path or endpoint and do not need it. absent metadata does not prove a binary is not Go, and unparsed text records remain explicit. See the [Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md). +- Embedded cargo-auditable Rust dependency metadata: `inspect_rust_binary` + with the explicit local ELF, PE, or thin Mach-O file. This built-in + target-free inspection requires no Rust installation or disassembler. Preserve + package order, dependency indices, and the section file range. A missing + `.dep-v0` section is null metadata, not proof the binary has no dependencies. + A rustc commit hash is an inference from a read-only `/rustc/<40-hex>/` path. + See the + [Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md). - Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema diff --git a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md index c651287f3..539bfb6b2 100644 --- a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md +++ b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md @@ -65,3 +65,7 @@ Use `inspect_analysis_view` with `view: {kind: "api-surface"}` on retained method, origin and path template. Format matches are inferences, not handler identity or schemas. Group pages cite parent record ordinals; consult record coverage and exclusions before claiming completeness. No requests are replayed. +When the connected schema advertises `include_query_parameter_names`, select +it in the view for distinct observed names with original record ordinals. Query +values are omitted; the names establish neither required parameters nor a +complete server schema. Keep the original parent Evidence for exact URLs. diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 3e185a8a2..960e46094 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -11,6 +11,18 @@ unparsed module-text line. A missing record is an unknown, not proof of a non-Go binary; observed metadata does not authenticate its claims. Function, source and type recovery remain a separate native-analysis question. +## Rust dependency metadata + +Use `inspect_rust_binary` for an explicit local ELF, PE, or thin Mach-O when +the question concerns the cargo-auditable crate list embedded in `.dep-v0` +(Mach-O: `__DATA,.dep-v0`). No `open_binary`, Rust installation, or target +execution is required. The CLI equivalent is `inspect-rust-binary PATH`. +Preserve artifact SHA-256, package order, dependency indices, and the section +file range. A missing section is null metadata, not proof the binary has no +dependencies. An omitted package kind stays null. A rustc commit hash is +inferred from a read-only `/rustc/<40-hex>/` path and does not by itself +identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md). + ## SQLite snapshots Use `inspect_sqlite_database` with an explicit local database snapshot for its diff --git a/.github/actions/npm-cache/action.yml b/.github/actions/npm-cache/action.yml new file mode 100644 index 000000000..1af22da2b --- /dev/null +++ b/.github/actions/npm-cache/action.yml @@ -0,0 +1,42 @@ +name: Cache npm package downloads +description: Restore npm's download cache and save it only from trusted default-branch events. +outputs: + can-save: + description: Whether this run may update the shared npm download cache. + value: ${{ steps.context.outputs.can-save }} + path: + description: The npm download-cache directory. + value: ${{ steps.context.outputs.path }} +runs: + using: composite + steps: + - id: context + shell: bash + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + LOCKFILES_HASH: ${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }} + run: | + cache_path="$(npm config get cache)" + runtime_arch="$(node -p 'process.arch')" + can_save=false + if [[ -n "${DEFAULT_BRANCH}" && "${GITHUB_REF}" == "refs/heads/${DEFAULT_BRANCH}" && ( "${GITHUB_EVENT_NAME}" == "push" || "${GITHUB_EVENT_NAME}" == "schedule" || "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ) ]]; then + can_save=true + fi + cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}" + { + printf 'can-save=%s\n' "${can_save}" + printf 'path=%s\n' "${cache_path}" + printf 'key=%s\n' "${cache_key}" + } >> "${GITHUB_OUTPUT}" + - name: Restore and save npm package downloads + if: steps.context.outputs.can-save == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.context.outputs.path }} + key: ${{ steps.context.outputs.key }} + - name: Restore npm package downloads without saving + if: steps.context.outputs.can-save != 'true' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.context.outputs.path }} + key: ${{ steps.context.outputs.key }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b47fcf89..8da32ee27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: tier: ${{ steps.plan.outputs.tier }} qualification_run_id: ${{ steps.qualification.outputs.run_id }} code: ${{ steps.plan.outputs.code }} + reverse_tools: ${{ steps.plan.outputs.reverse_tools }} sqlite: ${{ steps.plan.outputs.sqlite }} runtime: ${{ steps.plan.outputs.runtime }} package: ${{ steps.plan.outputs.package }} @@ -122,8 +123,19 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci + - name: Restore static task cache + if: needs.changes.outputs.code == 'true' + id: static-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: .turbo/cache/ + key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }} + # Turbo validates each task's inputs after restoring this snapshot. + restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}- - name: Validate workflow syntax and expressions if: needs.changes.outputs.workflows == 'true' run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes= @@ -136,6 +148,15 @@ jobs: run: npm run test:python - if: needs.changes.outputs.code != 'true' run: npm run format:check + - name: Save validated static task cache + if: >- + success() && needs.changes.outputs.code == 'true' && + steps.npm-cache.outputs.can-save == 'true' && + steps.static-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: .turbo/cache/ + key: ${{ steps.static-cache.outputs.cache-primary-key }} - name: Upload complete static diagnostics if: always() && needs.changes.outputs.code == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -153,7 +174,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 strategy: - fail-fast: false + # Failed PRs release the remaining matrix slots; qualification retains + # every shard's diagnostics and coverage. Passing PRs run the full suite. + fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }} matrix: shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6] steps: @@ -161,7 +184,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: @@ -187,6 +212,24 @@ jobs: overwrite: true retention-days: 7 + reverse-tools-quality: + name: Reverse tools coverage and mutation + needs: [changes, static] + if: needs.changes.outputs.reverse_tools == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - name: Run reverse tools tests with coverage + run: npm run test:coverage:reverse-tools + - name: Report reverse tools mutation results + run: npm run test:mutation:reverse-tools + build: name: Build package needs: changes @@ -212,7 +255,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - name: Reuse portable build outputs across runs uses: ./.github/actions/portable-build-cache @@ -286,7 +331,10 @@ jobs: if: needs.changes.outputs.code == 'true' && success() with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + if: needs.changes.outputs.code == 'true' && success() + id: npm-cache + uses: ./.github/actions/npm-cache - if: needs.changes.outputs.code == 'true' && success() run: npm ci - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -450,7 +498,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - name: Verify native runtime identity env: EXPECTED_CI_PLATFORM: ${{ matrix.platform }} @@ -472,11 +522,6 @@ jobs: - run: npm run verify:windows-native:artifact - name: Verify NativeAOT diagnostic applicability on this host run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts - - name: Resolve npm cache before verifier HOME isolation - id: npm-cache - run: | - cache_path="$(npm config get cache)" - echo "path=${cache_path}" >> "${GITHUB_OUTPUT}" - run: npm run verify:package env: npm_config_cache: ${{ steps.npm-cache.outputs.path }} @@ -506,7 +551,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: @@ -554,7 +601,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - name: Run the public CLI/MCP reconstruction journey env: @@ -583,7 +632,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: @@ -703,7 +754,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - run: npm run docs:check - name: Generation leaves authored files unchanged @@ -792,6 +845,7 @@ jobs: changes, static, build, + reverse-tools-quality, test-shard, sqlite-runtime, test, diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6b8a05b46..baa6ba785 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,6 +23,8 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - run: npm run docs:build diff --git a/.github/workflows/real-binary-layout.yml b/.github/workflows/real-binary-layout.yml index 0218543f0..06fcc290a 100644 --- a/.github/workflows/real-binary-layout.yml +++ b/.github/workflows/real-binary-layout.yml @@ -32,7 +32,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: ./.github/actions/portable-build-cache with: diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml index 3c3fe955a..aa7e6ebb2 100644 --- a/.github/workflows/real-browser.yml +++ b/.github/workflows/real-browser.yml @@ -25,7 +25,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: ./.github/actions/portable-build-cache with: diff --git a/.github/workflows/real-cutter.yml b/.github/workflows/real-cutter.yml new file mode 100644 index 000000000..856777999 --- /dev/null +++ b/.github/workflows/real-cutter.yml @@ -0,0 +1,74 @@ +name: Real upstream Cutter verification + +on: + workflow_dispatch: + repository_dispatch: + types: [real-cutter-upstream] + +permissions: + contents: read + +jobs: + windows-native-artifact: + name: Build Windows bridge security boundary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Install the cross compiler used by the Windows native lane + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 + - run: npm run build:windows-native + - uses: actions/upload-artifact@v4 + with: + name: cutter-windows-native + path: native/windows/build/ + if-no-files-found: error + + cutter-upstream: + name: Cutter upstream smoke (${{ matrix.platform }}) + needs: windows-native-artifact + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - platform: linux + runner: [self-hosted, cutter-upstream, linux] + - platform: macOS + runner: [self-hosted, cutter-upstream, macOS] + - platform: Windows + runner: [self-hosted, cutter-upstream, Windows] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - if: matrix.platform == 'Windows' + uses: actions/download-artifact@v4 + with: + name: cutter-windows-native + path: native/windows/build/ + - if: matrix.platform == 'Windows' + run: npm run verify:windows-native:artifact + - if: matrix.platform == 'Windows' + run: npm run build:cached + - if: matrix.platform == 'Windows' + run: npm run verify:windows-native + - name: Verify host and real Cutter bridge + env: + EXPECTED_PLATFORM: ${{ matrix.platform }} + run: npm run verify:cutter + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cutter-upstream-${{ matrix.platform }} + path: cutter-smoke.json + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml index 8588a269f..3a47b8f90 100644 --- a/.github/workflows/real-evm-interface.yml +++ b/.github/workflows/real-evm-interface.yml @@ -26,7 +26,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: ./.github/actions/portable-build-cache with: diff --git a/.github/workflows/real-go-binary.yml b/.github/workflows/real-go-binary.yml index c7d74b001..d99622067 100644 --- a/.github/workflows/real-go-binary.yml +++ b/.github/workflows/real-go-binary.yml @@ -48,7 +48,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26.1" diff --git a/.github/workflows/real-hopper-linux.yml b/.github/workflows/real-hopper-linux.yml index d5e5f8c33..417af2ca5 100644 --- a/.github/workflows/real-hopper-linux.yml +++ b/.github/workflows/real-hopper-linux.yml @@ -19,7 +19,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - name: Pack CLI for setup verification shell: bash @@ -40,7 +42,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - name: Install packed CLI in isolation shell: bash diff --git a/.github/workflows/real-hopper.yml b/.github/workflows/real-hopper.yml index b3ceeb7d9..da1c60c9f 100644 --- a/.github/workflows/real-hopper.yml +++ b/.github/workflows/real-hopper.yml @@ -19,7 +19,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - name: Install packed CLI in isolation shell: bash diff --git a/.github/workflows/real-javascript-recovery.yml b/.github/workflows/real-javascript-recovery.yml index a5043b62e..786d4a2d4 100644 --- a/.github/workflows/real-javascript-recovery.yml +++ b/.github/workflows/real-javascript-recovery.yml @@ -27,7 +27,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: ./.github/actions/portable-build-cache with: diff --git a/.github/workflows/real-packet-capture.yml b/.github/workflows/real-packet-capture.yml index 8daebfd8a..12cb3d23d 100644 --- a/.github/workflows/real-packet-capture.yml +++ b/.github/workflows/real-packet-capture.yml @@ -47,7 +47,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3 - run: npm ci - run: npm run build:cached diff --git a/.github/workflows/real-recorded-crash.yml b/.github/workflows/real-recorded-crash.yml index 0463df27d..58cf90aed 100644 --- a/.github/workflows/real-recorded-crash.yml +++ b/.github/workflows/real-recorded-crash.yml @@ -29,7 +29,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: ./.github/actions/portable-build-cache with: diff --git a/.github/workflows/real-signal-recording.yml b/.github/workflows/real-signal-recording.yml index 5c1380e2d..1ddc2d5e0 100644 --- a/.github/workflows/real-signal-recording.yml +++ b/.github/workflows/real-signal-recording.yml @@ -40,7 +40,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3 - run: npm ci - run: npm run build:cached diff --git a/.github/workflows/real-web-network-captures.yml b/.github/workflows/real-web-network-captures.yml index 3ee59d2c8..7901114f0 100644 --- a/.github/workflows/real-web-network-captures.yml +++ b/.github/workflows/real-web-network-captures.yml @@ -26,7 +26,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - name: Use pinned package manager run: npm install --global npm@11.16.0 - run: npm ci diff --git a/.github/workflows/real-web-runtime.yml b/.github/workflows/real-web-runtime.yml index f5b9f201a..0ceb9b057 100644 --- a/.github/workflows/real-web-runtime.yml +++ b/.github/workflows/real-web-runtime.yml @@ -21,12 +21,14 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: ./.github/actions/portable-build-cache with: build_cache_artifact: ${{ inputs.build_cache_artifact }} - - run: npm run verify:browser:runtime + - run: env -u DBUS_SESSION_BUS_ADDRESS npm run verify:browser:runtime env: REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome REA_BROWSER_NO_SANDBOX: "true" diff --git a/.github/workflows/real-web-source-map.yml b/.github/workflows/real-web-source-map.yml index 2a1e65dc0..eb1ecde34 100644 --- a/.github/workflows/real-web-source-map.yml +++ b/.github/workflows/real-web-source-map.yml @@ -21,7 +21,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: ./.github/actions/portable-build-cache with: diff --git a/.github/workflows/sqlite-runtime.yml b/.github/workflows/sqlite-runtime.yml index fc379d1dc..1ea6964b7 100644 --- a/.github/workflows/sqlite-runtime.yml +++ b/.github/workflows/sqlite-runtime.yml @@ -27,7 +27,9 @@ jobs: with: node-version: ${{ matrix.node }} check-latest: true - cache: npm + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache - run: npm ci - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: diff --git a/.gitignore b/.gitignore index 0d006e13f..e4dc40814 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ codedb.snapshot htmlcov/ .coverage coverage/ +.stryker-tmp/ +reports/mutation/ .vitest/ .vitest-reports/ test-results.xml diff --git a/README.md b/README.md index 53a86c5fc..ec6928580 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ [Quick start](#quick-start) · [How REA works](#how-rea-works) · [What you can analyze](#what-you-can-analyze) · [Showcases](#showcases) · [FAQ](#faq) · [Documentation](#documentation) +> [!IMPORTANT] +> Get REA from [morluto/rea](https://github.com/morluto/rea), +> [rea.tools](https://rea.tools/), or the [rea-agents npm package](https://www.npmjs.com/package/rea-agents). +> Follow the installation and release links from these official sources. A similar +> repository name or copied README does not establish that a download is official. + npx rea-agents setup
@@ -155,6 +161,7 @@ Additional tools and host support depend on the target: | Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) | | Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) | | Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required | +| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) | | WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) | | SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits | @@ -179,6 +186,11 @@ Static JavaScript and .NET inspection read the supplied files without running the application. Runtime capture runs or interacts with the selected target using your user permissions; each runtime guide describes its effects. +For offline native-binary inspection, REA also supports caller-supplied GNU +objdump and Rizin. See the [reverse-engineering tools guide](docs/reverse-engineering-tools.md) +for provider setup, CLI commands, and Evidence limitations. REA does not install +or update either provider. + Native formats and host support vary by provider. See diff --git a/bridge/cutter/rea_bridge.py b/bridge/cutter/rea_bridge.py new file mode 100644 index 000000000..473f1d5fe --- /dev/null +++ b/bridge/cutter/rea_bridge.py @@ -0,0 +1,816 @@ +"""REA's experimental local bridge plugin for Cutter. + +Install this file in Cutter's user plugin directory. The bridge is REA-owned +IPC, not an upstream Cutter transport. It binds to loopback and requires a +per-process random token stored in a user-only descriptor file. +""" + +from __future__ import annotations + +import json +import hashlib +import hmac +import os +import secrets +import socket +import stat +import tempfile +import threading +import time +import uuid +from pathlib import Path +from typing import Any + +if os.name == "nt": + import ctypes + from ctypes import wintypes + +import cutter + +try: + from PySide6.QtCore import QObject, Signal +except ImportError: + from PySide2.QtCore import QObject, Signal + + +MAX_FRAME_BYTES = 16 * 1024 * 1024 +SOCKET_TIMEOUT_SECONDS = 20 +AUTH_FRAME_TIMEOUT_SECONDS = 1 +MAX_DESCRIPTOR_BYTES = 64 * 1024 +MAX_COMPLETION_RECEIPTS = 64 + + +def _windows_api() -> tuple[Any, Any]: + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.LocalFree.argtypes = [wintypes.HLOCAL] + kernel32.LocalFree.restype = wintypes.HLOCAL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.GetFileAttributesW.argtypes = [wintypes.LPCWSTR] + kernel32.GetFileAttributesW.restype = wintypes.DWORD + kernel32.CreateDirectoryW.argtypes = [wintypes.LPCWSTR, wintypes.LPVOID] + kernel32.CreateDirectoryW.restype = wintypes.BOOL + kernel32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID] + kernel32.WriteFile.restype = wintypes.BOOL + kernel32.FlushFileBuffers.argtypes = [wintypes.HANDLE] + kernel32.FlushFileBuffers.restype = wintypes.BOOL + kernel32.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD] + kernel32.MoveFileExW.restype = wintypes.BOOL + kernel32.DeleteFileW.argtypes = [wintypes.LPCWSTR] + kernel32.DeleteFileW.restype = wintypes.BOOL + advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] + advapi32.OpenProcessToken.restype = wintypes.BOOL + advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, + wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + advapi32.GetTokenInformation.restype = wintypes.BOOL + advapi32.ConvertSidToStringSidW.argtypes = [wintypes.LPVOID, ctypes.POINTER(wintypes.LPWSTR)] + advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL + advapi32.GetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD, + ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.LPVOID), + ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.LPVOID), + ctypes.POINTER(wintypes.LPVOID)] + advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD + advapi32.GetSecurityDescriptorControl.argtypes = [wintypes.LPVOID, ctypes.POINTER(wintypes.WORD), + ctypes.POINTER(wintypes.DWORD)] + advapi32.GetSecurityDescriptorControl.restype = wintypes.BOOL + advapi32.EqualSid.argtypes = [wintypes.LPVOID, wintypes.LPVOID] + advapi32.EqualSid.restype = wintypes.BOOL + advapi32.GetAclInformation.argtypes = [wintypes.LPVOID, wintypes.LPVOID, wintypes.DWORD, ctypes.c_int] + advapi32.GetAclInformation.restype = wintypes.BOOL + advapi32.GetAce.argtypes = [wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.LPVOID)] + advapi32.GetAce.restype = wintypes.BOOL + advapi32.IsValidSid.argtypes = [wintypes.LPVOID] + advapi32.IsValidSid.restype = wintypes.BOOL + advapi32.GetLengthSid.argtypes = [wintypes.LPVOID] + advapi32.GetLengthSid.restype = wintypes.DWORD + advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [ + wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.DWORD), + ] + advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL + return kernel32, advapi32 + + +def _windows_user_sid_text() -> str: + kernel32, advapi32 = _windows_api() + token = wintypes.HANDLE() + if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)): + raise ctypes.WinError(ctypes.get_last_error()) + try: + required = wintypes.DWORD() + advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required)) + if required.value == 0: + raise ctypes.WinError(ctypes.get_last_error()) + buffer = ctypes.create_string_buffer(required.value) + if not advapi32.GetTokenInformation(token, 1, buffer, required, ctypes.byref(required)): + raise ctypes.WinError(ctypes.get_last_error()) + + class SID_AND_ATTRIBUTES(ctypes.Structure): + _fields_ = [("Sid", wintypes.LPVOID), ("Attributes", wintypes.DWORD)] + + class TOKEN_USER(ctypes.Structure): + _fields_ = [("User", SID_AND_ATTRIBUTES)] + + sid = ctypes.cast(buffer, ctypes.POINTER(TOKEN_USER)).contents.User.Sid + text = wintypes.LPWSTR() + if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)): + raise ctypes.WinError(ctypes.get_last_error()) + try: + return text.value + finally: + kernel32.LocalFree(text) + finally: + kernel32.CloseHandle(token) + + +def _verify_windows_private_path(path: Path, directory: bool) -> None: + kernel32, advapi32 = _windows_api() + attributes = kernel32.GetFileAttributesW(str(path)) + if attributes == 0xFFFFFFFF: + raise ctypes.WinError(ctypes.get_last_error()) + if attributes & 0x400 or bool(attributes & 0x10) != directory: + raise RuntimeError("Cutter bridge path is a reparse point or has the wrong object type") + + class ACL_SIZE_INFORMATION(ctypes.Structure): + _fields_ = [("AceCount", wintypes.DWORD), ("AclBytesInUse", wintypes.DWORD), ("AclBytesFree", wintypes.DWORD)] + + class ACE_HEADER(ctypes.Structure): + _fields_ = [("AceType", wintypes.BYTE), ("AceFlags", wintypes.BYTE), ("AceSize", wintypes.WORD)] + + class SID_HEADER(ctypes.Structure): + _fields_ = [("Revision", wintypes.BYTE), ("SubAuthorityCount", wintypes.BYTE), + ("IdentifierAuthority", wintypes.BYTE * 6)] + + class ACCESS_ALLOWED_ACE(ctypes.Structure): + _fields_ = [("Header", ACE_HEADER), ("Mask", wintypes.DWORD), ("SidStart", wintypes.DWORD)] + + owner = wintypes.LPVOID() + dacl = wintypes.LPVOID() + descriptor = wintypes.LPVOID() + result = advapi32.GetNamedSecurityInfoW( + str(path), 1, 0x1 | 0x4, ctypes.byref(owner), None, ctypes.byref(dacl), None, ctypes.byref(descriptor), + ) + if result != 0: + raise ctypes.WinError(result) + try: + control = wintypes.WORD() + revision = wintypes.DWORD() + if not advapi32.GetSecurityDescriptorControl(descriptor, ctypes.byref(control), ctypes.byref(revision)): + raise ctypes.WinError(ctypes.get_last_error()) + if not control.value & 0x1000 or not dacl: + raise RuntimeError("Cutter bridge ACL must be present and protected from inheritance") + + token = wintypes.HANDLE() + if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)): + raise ctypes.WinError(ctypes.get_last_error()) + try: + required = wintypes.DWORD() + advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required)) + token_user = ctypes.create_string_buffer(required.value) + if not advapi32.GetTokenInformation(token, 1, token_user, required, ctypes.byref(required)): + raise ctypes.WinError(ctypes.get_last_error()) + + class SID_AND_ATTRIBUTES(ctypes.Structure): + _fields_ = [("Sid", wintypes.LPVOID), ("Attributes", wintypes.DWORD)] + + class TOKEN_USER(ctypes.Structure): + _fields_ = [("User", SID_AND_ATTRIBUTES)] + + current_sid = ctypes.cast(token_user, ctypes.POINTER(TOKEN_USER)).contents.User.Sid + if not advapi32.EqualSid(owner, current_sid): + raise RuntimeError("Cutter bridge path owner does not match the current user") + finally: + kernel32.CloseHandle(token) + + info = ACL_SIZE_INFORMATION() + if not advapi32.GetAclInformation(dacl, ctypes.byref(info), ctypes.sizeof(info), 2) or info.AceCount != 2: + raise RuntimeError("Cutter bridge ACL must allow only the current user and SYSTEM") + expected_user = _windows_user_sid_text() + user_allowed = False + system_allowed = False + for index in range(info.AceCount): + ace_pointer = wintypes.LPVOID() + if not advapi32.GetAce(dacl, index, ctypes.byref(ace_pointer)): + raise ctypes.WinError(ctypes.get_last_error()) + header = ctypes.cast(ace_pointer, ctypes.POINTER(ACE_HEADER)).contents + if header.AceType != 0 or header.AceSize < ctypes.sizeof(ACE_HEADER) + ctypes.sizeof(wintypes.DWORD) + ctypes.sizeof(SID_HEADER): + raise RuntimeError("Cutter bridge ACL contains a malformed or unsupported ACE") + ace_address = ctypes.cast(ace_pointer, ctypes.c_void_p).value + if ace_address is None: + raise RuntimeError("Cutter bridge ACL returned a null ACE") + sid_pointer = ctypes.c_void_p(ace_address + ctypes.sizeof(ACE_HEADER) + ctypes.sizeof(wintypes.DWORD)) + sid_header = ctypes.cast(sid_pointer, ctypes.POINTER(SID_HEADER)).contents + sid_size = ctypes.sizeof(SID_HEADER) + sid_header.SubAuthorityCount * ctypes.sizeof(wintypes.DWORD) + if sid_size > header.AceSize - ctypes.sizeof(ACE_HEADER) - ctypes.sizeof(wintypes.DWORD) or not advapi32.IsValidSid(sid_pointer) or advapi32.GetLengthSid(sid_pointer) != sid_size: + raise RuntimeError("Cutter bridge ACL contains an invalid SID") + ace = ctypes.cast(ace_pointer, ctypes.POINTER(ACCESS_ALLOWED_ACE)).contents + sid_text = wintypes.LPWSTR() + if ace.Mask != 0x001F01FF or ace.Header.AceFlags != 0x03: + raise RuntimeError("Cutter bridge ACL contains an unsupported ACE") + if not advapi32.ConvertSidToStringSidW(sid_pointer, ctypes.byref(sid_text)): + raise ctypes.WinError(ctypes.get_last_error()) + try: + if sid_text.value == expected_user: + user_allowed = True + elif sid_text.value == "S-1-5-18": + system_allowed = True + else: + raise RuntimeError("Cutter bridge ACL allows an unrelated principal") + finally: + kernel32.LocalFree(sid_text) + if not user_allowed or not system_allowed: + raise RuntimeError("Cutter bridge ACL must allow only the current user and SYSTEM") + finally: + kernel32.LocalFree(descriptor) + + +def _windows_security_attributes() -> tuple[Any, Any]: + kernel32, advapi32 = _windows_api() + sid = _windows_user_sid_text() + sddl = f"O:{sid}G:{sid}D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)" + descriptor = wintypes.LPVOID() + if not advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(descriptor), None): + raise ctypes.WinError(ctypes.get_last_error()) + + class SECURITY_ATTRIBUTES(ctypes.Structure): + _fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", wintypes.LPVOID), ("bInheritHandle", wintypes.BOOL)] + + attributes = SECURITY_ATTRIBUTES(ctypes.sizeof(SECURITY_ATTRIBUTES), descriptor, False) + return kernel32, (descriptor, attributes) + + +def _ensure_windows_bridge_directory(directory: Path) -> None: + directory.parent.mkdir(parents=True, exist_ok=True) + if not directory.exists(): + kernel32, (descriptor, attributes) = _windows_security_attributes() + try: + if not kernel32.CreateDirectoryW(str(directory), ctypes.byref(attributes)): + error = ctypes.get_last_error() + if error != 183: + raise ctypes.WinError(error) + finally: + kernel32.LocalFree(descriptor) + _verify_windows_private_path(directory, True) + + +def _ensure_posix_bridge_directory(directory: Path) -> None: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"): + raise RuntimeError("Cutter bridge directory requires no-follow directory handles") + descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + directory_info = os.fstat(descriptor) + if directory_info.st_uid != os.getuid(): + raise RuntimeError("Cutter bridge directory must be owned by the current user") + os.fchmod(descriptor, 0o700) + directory_info = os.fstat(descriptor) + if stat.S_IMODE(directory_info.st_mode) != 0o700: + raise RuntimeError("Cutter bridge directory must have mode 0700") + finally: + os.close(descriptor) + + +def _write_windows_descriptor(directory: Path, destination: Path, payload: bytes) -> None: + if len(payload) > MAX_DESCRIPTOR_BYTES: + raise RuntimeError("Cutter bridge descriptor exceeds its byte limit") + kernel32, (security_descriptor, attributes) = _windows_security_attributes() + temporary = directory / f".{destination.name}.{uuid.uuid4().hex}.tmp" + handle = wintypes.HANDLE() + try: + handle = kernel32.CreateFileW(str(temporary), 0x40000000 | 0x00020000 | 0x00040000, + 0x1 | 0x2 | 0x4, ctypes.byref(attributes), 1, 0x80, None) + if handle == wintypes.HANDLE(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + _verify_windows_private_path(temporary, False) + if payload: + data = ctypes.create_string_buffer(payload) + written = wintypes.DWORD() + if not kernel32.WriteFile(handle, data, len(payload), ctypes.byref(written), None) or written.value != len(payload): + raise ctypes.WinError(ctypes.get_last_error()) + if not kernel32.FlushFileBuffers(handle): + raise ctypes.WinError(ctypes.get_last_error()) + kernel32.CloseHandle(handle) + handle = wintypes.HANDLE() + if not kernel32.MoveFileExW(str(temporary), str(destination), 0x1 | 0x8): + raise ctypes.WinError(ctypes.get_last_error()) + finally: + if handle and handle != wintypes.HANDLE(-1).value: + kernel32.CloseHandle(handle) + kernel32.DeleteFileW(str(temporary)) + kernel32.LocalFree(security_descriptor) + + +def _bridge_directory() -> Path: + configured = os.environ.get("REA_CUTTER_BRIDGE_DIR") + if configured: + return Path(configured).expanduser() + local_app_data = os.environ.get("LOCALAPPDATA") + if os.name == "nt" and local_app_data: + return Path(local_app_data) / "REA" / "CutterBridge" + return Path.home() / ".cache" / "rea" / "cutter-bridge" + + +class _RequestDispatcher(QObject): + dispatch = Signal(object, object) + + def __init__(self) -> None: + super().__init__() + self.dispatch.connect(self._handle) + self.generation = 0 + self.completed_commands: dict[str, str] = {} + self._receipt_lock = threading.Lock() + self.current_file = self._read_current_file() + try: + self.cutter_version = str(cutter.core().getVersionInformation()) + except Exception: + self.cutter_version = None + + @staticmethod + def _read_current_file() -> str | None: + try: + value = cutter.core().currentlyOpenFile() + return str(value) if value else None + except Exception: + return None + + def _handle(self, request: dict[str, Any], reply: dict[str, Any]) -> None: + kind = request.get("kind") + request_id = request.get("request_id") + if kind in ("command", "ack"): + try: + valid_request_id = ( + isinstance(request_id, str) + and str(uuid.UUID(request_id)) == request_id.lower() + ) + except (ValueError, AttributeError): + valid_request_id = False + if not valid_request_id: + reply.update({ + "ok": False, + "error": "invalid-command-request-id", + "execution_state": "not_started", + }) + request["_event"].set() + return + if kind == "ack": + with self._receipt_lock: + acknowledged = self.completed_commands.pop(request_id, None) is not None + reply.update({"ok": True, "acknowledged": acknowledged}) + request["_event"].set() + return + if request["_cancelled"].is_set(): + reply.update({ + "ok": False, + "error": "request-cancelled-before-execution", + "execution_state": "not_started", + }) + if kind == "command": + self._record_command_completion(request_id, "not_started") + request["_event"].set() + return + self._refresh_document_identity() + if kind == "status": + with self._receipt_lock: + completed_commands = [ + {"request_id": receipt_id, "execution_state": state} + for receipt_id, state in self.completed_commands.items() + ] + reply.update({ + "ok": True, + "session_id": request.get("session_id"), + "current_file": self.current_file, + "document_generation": self.generation, + "cutter_version": self.cutter_version, + "identity_status": "partial", + "completed_commands": completed_commands, + }) + request["_event"].set() + return + if kind != "command": + reply.update({"ok": False, "error": "unsupported-request-kind"}) + request["_event"].set() + return + if request.get("expected_generation") != self.generation: + reply.update({ + "ok": False, + "error": "active-document-generation-changed", + "execution_state": "not_started", + }) + self._record_command_completion(request_id, "not_started") + request["_event"].set() + return + command = request.get("command") + if not isinstance(command, str) or not command.strip(): + reply.update({ + "ok": False, + "error": "invalid-command", + "execution_state": "not_started", + }) + self._record_command_completion(request_id, "not_started") + request["_event"].set() + return + reply["execution_state"] = "running" + try: + output = cutter.cmdj(command) if request.get("json") is True else cutter.cmd(command) + command_error = None + except Exception as error: + output = None + command_error = str(error) + self._refresh_document_identity() + if command_error is None: + reply.update({ + "ok": True, + "output": output, + "current_file": self.current_file, + "document_generation": self.generation, + "cutter_version": self.cutter_version, + "identity_status": "partial", + "execution_state": "complete", + }) + self._record_command_completion(request_id, "complete") + else: + reply.update({ + "ok": False, + "error": command_error, + "current_file": self.current_file, + "document_generation": self.generation, + "cutter_version": self.cutter_version, + "identity_status": "partial", + "execution_state": "unknown", + "message": "The command raised an exception; it may have produced partial effects. Do not retry automatically.", + }) + self._record_command_completion(request_id, "failed") + request["_event"].set() + + def _record_command_completion( + self, request_id: str, execution_state: str + ) -> None: + with self._receipt_lock: + if request_id in self.completed_commands or len(self.completed_commands) < MAX_COMPLETION_RECEIPTS: + self.completed_commands[request_id] = execution_state + + def can_accept_command(self) -> bool: + with self._receipt_lock: + return len(self.completed_commands) < MAX_COMPLETION_RECEIPTS + + def _refresh_document_identity(self) -> None: + current_file = self._read_current_file() + if current_file != self.current_file: + self.current_file = current_file + self.generation += 1 + + +class _BridgeServer(threading.Thread): + def __init__(self, dispatcher: _RequestDispatcher) -> None: + super().__init__(name="rea-cutter-bridge", daemon=True) + self.dispatcher = dispatcher + self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server.bind(("127.0.0.1", 0)) + self.server.listen(8) + self.server.settimeout(0.5) + self.port = self.server.getsockname()[1] + self.session_id = str(uuid.uuid4()) + self.token = secrets.token_urlsafe(32) + self.poisoned = False + self.connection_ordinal = 0 + self.directory = _bridge_directory() + self.descriptor = self.directory / f"cutter-{os.getpid()}-{self.session_id}.json" + + def publish(self) -> None: + if os.name == "nt": + _ensure_windows_bridge_directory(self.directory) + else: + _ensure_posix_bridge_directory(self.directory) + if os.name == "nt" and (self.directory.is_symlink() or not self.directory.is_dir()): + raise RuntimeError("Cutter bridge directory must be a real directory") + payload = { + "session_id": self.session_id, + "pid": os.getpid(), + "host": "127.0.0.1", + "port": self.port, + "token": self.token, + "document_generation": self.dispatcher.generation, + "current_file": self.dispatcher.current_file, + "cutter_version": self.dispatcher.cutter_version, + "identity_status": "partial", + } + if os.name == "nt": + _write_windows_descriptor(self.directory, self.descriptor, json.dumps(payload).encode("utf-8")) + return + descriptor, temporary_path = tempfile.mkstemp(prefix=f".{self.descriptor.name}.", dir=self.directory) + try: + os.fchmod(descriptor, 0o600) + descriptor_info = os.fstat(descriptor) + if descriptor_info.st_uid != os.getuid() or stat.S_IMODE(descriptor_info.st_mode) & 0o077: + raise RuntimeError("Cutter bridge descriptor must be owned by the current user with mode 0600") + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(json.dumps(payload)) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, self.descriptor) + except Exception: + try: + os.close(descriptor) + except OSError: + pass + try: + os.unlink(temporary_path) + except OSError: + pass + raise + + def run(self) -> None: + try: + self.publish() + except Exception: + self.server.close() + return + while True: + try: + connection, _ = self.server.accept() + except socket.timeout: + if getattr(self, "stopping", False): + break + continue + except OSError: + break + self.connection_ordinal += 1 + connection_ordinal = self.connection_ordinal + with connection: + connection.settimeout(AUTH_FRAME_TIMEOUT_SECONDS) + authenticated = False + try: + hello = self._receive_frame(connection) + if not isinstance(hello, dict) or hello.get("kind") != "rea-auth-v2" or not isinstance(hello.get("nonce"), str) or len(hello["nonce"]) != 64: + self._send(connection, {"ok": False, "error": "authentication-failed"}) + continue + client_nonce = hello["nonce"] + server_nonce = f"{connection_ordinal:016x}{secrets.token_hex(24)}" + server_proof = hmac.new(self.token.encode(), f"rea-cutter-server-v2:{self.session_id}:{client_nonce}:{server_nonce}".encode(), hashlib.sha256).hexdigest() + self._send(connection, {"session_id": self.session_id, "nonce": server_nonce, "proof": server_proof}) + authenticated_frame = self._receive_frame(connection) + request_bytes = authenticated_frame.get("request") if isinstance(authenticated_frame, dict) else None + expected_proof = hmac.new(self.token.encode(), f"rea-cutter-client-v2:{self.session_id}:{client_nonce}:{server_nonce}:{request_bytes}".encode(), hashlib.sha256).hexdigest() + if not isinstance(request_bytes, str) or not isinstance(authenticated_frame.get("proof"), str) or not hmac.compare_digest(authenticated_frame["proof"], expected_proof): + self._send(connection, {"ok": False, "error": "authentication-failed"}) + continue + request = json.loads(request_bytes) + if not isinstance(request, dict) or request.get("session_id") != self.session_id: + self._send(connection, {"ok": False, "error": "authentication-failed"}) + continue + authenticated = True + connection.settimeout(SOCKET_TIMEOUT_SECONDS) + if self.poisoned and request.get("kind") not in ("status", "ack"): + self._send_authenticated(connection, { + "ok": False, + "error": "session-poisoned-after-ui-timeout", + "execution_state": "not_started", + }, client_nonce, server_nonce) + continue + if ( + request.get("kind") == "command" + and not self.dispatcher.can_accept_command() + ): + self._send_authenticated(connection, { + "ok": False, + "error": "completion-receipt-capacity-exceeded", + "execution_state": "not_started", + }, client_nonce, server_nonce) + continue + request["session_id"] = self.session_id + event = threading.Event() + cancelled = threading.Event() + reply: dict[str, Any] = {} + request["_event"] = event + request["_cancelled"] = cancelled + self.dispatcher.dispatch.emit(request, reply) + if not event.wait(SOCKET_TIMEOUT_SECONDS): + cancelled.set() + self.poisoned = True + self._send_authenticated(connection, { + "ok": False, + "error": "ui-thread-request-timeout", + "execution_state": "unknown", + "message": "The UI operation may still complete after this timeout; restart Cutter before issuing further commands.", + }, client_nonce, server_nonce) + else: + if request.get("kind") == "status" and reply.get("ok") is True: + reply["completed_connection"] = f"{connection_ordinal - 1:016x}" + self._send_authenticated(connection, reply, client_nonce, server_nonce) + except socket.timeout: + if authenticated: + self._send_authenticated(connection, { + "ok": False, + "error": "request-timeout", + "execution_state": "unknown", + "message": "The command may have produced partial or persistent effects. Do not retry automatically.", + }, client_nonce, server_nonce) + except Exception as error: + self._send(connection, {"ok": False, "error": str(error)}) + self.server.close() + try: + self.descriptor.unlink(missing_ok=True) + except OSError: + pass + + @staticmethod + def _receive_frame(connection: socket.socket) -> dict[str, Any]: + data = bytearray() + deadline = time.monotonic() + AUTH_FRAME_TIMEOUT_SECONDS + while b"\n" not in data: + if len(data) >= MAX_FRAME_BYTES: + raise ValueError("frame-limit-exceeded") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout("authentication frame timed out") + connection.settimeout(remaining) + part = connection.recv(min(65536, MAX_FRAME_BYTES + 1 - len(data))) + if not part: + raise ValueError("incomplete authentication frame") + data.extend(part) + line, remainder = bytes(data).split(b"\n", 1) + if len(line) > MAX_FRAME_BYTES or remainder: + raise ValueError("invalid framed request") + value = json.loads(line) + return value if isinstance(value, dict) else {} + + def _send_authenticated(self, connection: socket.socket, value: dict[str, Any], client_nonce: str, server_nonce: str) -> None: + encoded = self._encode_authenticated_response(value, client_nonce, server_nonce) + if encoded is None: + value = self._truncated_response(value) + encoded = self._encode_authenticated_response(value, client_nonce, server_nonce) + if encoded is None: + # This fixed-size fallback is checked through the same signed framing path. + value = { + "ok": False, + "error": "response-limit-exceeded", + "execution_state": value.get("execution_state", "unknown"), + "output": None, + "output_truncated": True, + "message": "The result exceeded the bridge frame limit; command completion state is preserved.", + } + encoded = self._encode_authenticated_response(value, client_nonce, server_nonce) + if encoded is None: + return + try: + connection.sendall(encoded) + except OSError: + pass + + def _encode_authenticated_response(self, value: dict[str, Any], client_nonce: str, server_nonce: str) -> bytes | None: + if not _within_response_limit(value): + return None + prefix = b'{"response":"' + suffix = b'","proof":"' + (b"0" * 64) + b'"}\n' + frame_size = len(prefix) + len(suffix) + response_parts: list[bytes] = [] + escaped_parts: list[bytes] = [] + for fragment in json.JSONEncoder(ensure_ascii=False, separators=(",", ":")).iterencode(value): + raw = fragment.encode("utf-8") + escaped = json.dumps(fragment, ensure_ascii=False)[1:-1].encode("utf-8") + frame_size += len(escaped) + if frame_size > MAX_FRAME_BYTES: + return None + response_parts.append(raw) + escaped_parts.append(escaped) + proof = hmac.new(self.token.encode(), digestmod=hashlib.sha256) + proof.update(f"rea-cutter-response-v2:{self.session_id}:{client_nonce}:{server_nonce}:".encode()) + for part in response_parts: + proof.update(part) + return prefix + b"".join(escaped_parts) + b'","proof":"' + proof.hexdigest().encode() + b'"}\n' + + @staticmethod + def _truncated_response(value: dict[str, Any]) -> dict[str, Any]: + response: dict[str, Any] = { + "ok": value.get("ok") is True, + "output": None, + "execution_state": value.get("execution_state", "unknown"), + "output_truncated": True, + "message": "The command result exceeded the bridge frame limit; completion state is preserved and output was omitted.", + } + for key in ("current_file", "document_generation", "cutter_version", "identity_status"): + if key in value: + response[key] = value[key] + if value.get("ok") is not True and isinstance(value.get("error"), str): + response["error"] = value["error"] + return response + + @staticmethod + def _send(connection: socket.socket, value: dict[str, Any]) -> None: + if not _within_response_limit(value): + value = { + "ok": False, + "error": "response-limit-exceeded", + "execution_state": value.get("execution_state", "unknown"), + "output_truncated": True, + "message": "The command completed or may have completed, but its result exceeded the bridge response limit. Do not retry automatically.", + } + parts: list[bytes] = [] + encoded_size = 0 + for part in json.JSONEncoder(ensure_ascii=False).iterencode(value): + encoded_part = part.encode("utf-8") + encoded_size += len(encoded_part) + if encoded_size > MAX_FRAME_BYTES: + fallback = { + "ok": False, + "error": "response-limit-exceeded", + "execution_state": value.get("execution_state", "unknown"), + "output_truncated": True, + "message": "The command completed or may have completed, but its result exceeded the bridge response limit. Do not retry automatically.", + } + parts = [json.dumps(fallback, separators=(",", ":")).encode("utf-8")] + break + parts.append(encoded_part) + encoded = b"".join(parts) + try: + connection.sendall(encoded + b"\n") + except OSError: + # A disconnected caller must not terminate the bridge thread or + # prevent later commands from being served. + pass + + def stop(self) -> None: + self.stopping = True + try: + self.server.close() + except OSError: + pass + + +class ReaCutterPlugin(cutter.CutterPlugin): + name = "REA Local Bridge" + description = "Local REA analysis bridge for the active Cutter session" + version = "1.0" + author = "REA" + + def __init__(self) -> None: + super().__init__() + self._dispatcher: _RequestDispatcher | None = None + self._bridge: _BridgeServer | None = None + + def setupPlugin(self) -> None: + return + + def setupInterface(self, main: Any) -> None: + self._dispatcher = _RequestDispatcher() + self._bridge = _BridgeServer(self._dispatcher) + self._bridge.start() + + def terminate(self) -> None: + if self._bridge is not None: + self._bridge.stop() + self._bridge.join(timeout=2) + self._bridge = None + self._dispatcher = None + + +def create_cutter_plugin() -> ReaCutterPlugin: + return ReaCutterPlugin() + + +def _within_response_limit(value: Any) -> bool: + pending = [value] + seen: set[int] = set() + size = 0 + while pending: + current = pending.pop() + if isinstance(current, str): + size += 2 + if current.isascii(): + size += len(current) + size += current.count('"') + current.count("\\") + size += sum(5 for character in current if ord(character) < 0x20) + else: + size += 4 * len(current) + size += current.count('"') + current.count("\\") + size += sum(5 for character in current if ord(character) < 0x20) + elif isinstance(current, dict): + identity = id(current) + if identity in seen: + return False + seen.add(identity) + size += 2 + 4 * len(current) + if size > MAX_FRAME_BYTES: + return False + pending.extend(current.keys()) + pending.extend(current.values()) + elif isinstance(current, (list, tuple)): + identity = id(current) + if identity in seen: + return False + seen.add(identity) + size += 2 + 2 * len(current) + if size > MAX_FRAME_BYTES: + return False + pending.extend(current) + else: + size += 16 + if size > MAX_FRAME_BYTES: + return False + return True diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5d7fcc2f3..b4cf2e4ad 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -29,6 +29,7 @@ export default defineConfig({ items: [ { text: "Native binaries", link: "/native-investigation" }, { text: "Go build metadata", link: "/go-binaries" }, + { text: "Rust dependencies", link: "/rust-binaries" }, { text: "JavaScript and Electron", link: "/javascript-artifact-reconstruction", diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 753c57024..90dd41ab1 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -38,6 +38,10 @@ graph TD JS_CODEC["src/javascript/sourceMaps/
pinned upstream map codec
owned heap-limited Node process, complete point evidence"] INSPECTOR["src/inspector/
passive V8 Inspector discovery and capture
script and target locations"] DOTNET["src/dotnet/
Managed static metadata provider
PE/CLI metadata inventory, members, IL"] + OBJDUMP["src/objdump/
GNU objdump command profile"] + RIZIN["src/rizin/
Static command profile and persistent debugger sessions"] + GDB["src/gdb/
Persistent GDB/MI sessions
owned process lifecycle and target state"] + FIRMWARE["src/firmware/
Binwalk / Unblob adapters
owned processes, report normalization, private extraction"] ANDROID["src/android/
Static APK adapter
owned SDK stdio, pinned headless JADX, normalization"] REFERENCE["src/reference/
Filesystem reference-source reader
bounded traversal, validation, errors"] @@ -50,7 +54,7 @@ graph TD end subgraph "Contracts Layer" - CONTRACTS["src/contracts/
Caller-visible schemas for analysis, managed, browser, Electron, application, and session tools"] + CONTRACTS["src/contracts/
Caller-visible schemas for analysis, reverse engineering, managed, browser, Electron, application, and session tools"] ERRORS["src/contracts/errorSchemas.ts
Shared analysis error schemas
CLI diagnostics, generated error catalog"] end @@ -122,6 +126,14 @@ graph TD INSPECTOR --> DOMAIN APP --> DOTNET DOTNET --> DOMAIN + COMPOSITION --> OBJDUMP + OBJDUMP --> DOMAIN + COMPOSITION --> RIZIN + RIZIN --> DOMAIN + CLI --> GDB + SERVER --> GDB + GDB --> PROCESS + GDB --> DOMAIN HOPPER --> HOPPER_BRIDGE GHIDRA --> GHIDRA_BRIDGE HOPPER --> PROCESS diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 72dde900b..879d01520 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -243,6 +243,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex - Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded. - WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. +- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers. - Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Script metadata admission bounds retained identity and source-map strings to 8 MiB and 50,000 records. When a source-map URL cannot fit, the script identity remains and source-map coverage reports `resource_budget_exhausted`; rejected script observations are counted without retaining their IDs. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest. Frames, resources, scripts, events, and workers outside the selected origin scope are excluded. An explicit `allowed_origins` list can include additional origins; excluded target details are counted without being exposed. diff --git a/docs/cli.md b/docs/cli.md index c43ec2181..c801bf006 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -83,6 +83,18 @@ The shared CLI/MCP workflow reports compiler version, modules, replacements, build settings, original bytes and SHA-256. See [Go binaries](go-binaries.md) for supported containers and absent or malformed metadata. +Inspect an embedded cargo-auditable Rust dependency list without installing +Rust or configuring a disassembler: + +```bash +rea inspect-rust-binary /absolute/path/to/program --json +``` + +The same workflow reports package names, versions, sources, the root package, +dependency edges, and the section's file range. A missing `.dep-v0` section is +null metadata, not proof that the binary has no dependencies. See +[Rust binaries](rust-binaries.md). + Configure [Hopper or Ghidra](installation.md#hopper), or the [IDA adapter](ida-provider.md), before analyzing a native target. Substitute your target, search text and function name or address in these examples: diff --git a/docs/frida-instrumentation.md b/docs/frida-instrumentation.md index c93c76465..718af30c2 100644 --- a/docs/frida-instrumentation.md +++ b/docs/frida-instrumentation.md @@ -6,8 +6,8 @@ For remote devices, pass a host with an optional port (`host[:port]`; bracket IP MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, `load_frida_script`, `resume_frida_session`, `unload_frida_script`, `frida_session_status`, and `close_frida_session`. A spawned process starts paused so scripts can load first. Closing unloads session scripts and detaches. If a spawn remains paused, REA resumes it only after script cleanup or session detach confirms the instrumentation is released; if both cleanup paths fail, the target stays paused and the cleanup error is retained. REA never kills the target. MCP sessions end with the server process. The one-shot CLI command `instrument-with-frida` accepts one inline script or script file, observes messages for a bounded duration, and returns Evidence with the source hash and captured messages. -Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. A failed cleanup remains reported rather than treating an unregistered session as fully released. +Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released. -Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. +Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility. diff --git a/docs/index.md b/docs/index.md index 732ad9f05..9e0912dd1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,7 +49,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Task | Guides | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [JEB](jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | | Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md) | diff --git a/docs/installation.md b/docs/installation.md index a39471585..3ad6b7071 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -764,6 +764,9 @@ errors expose Error names, messages, and available codes under truncation markers. Darwin ownership-helper compilation failures also retain the compiler outcome, exit code or signal, and bounded stderr; a timed-out compiler remains distinguishable from a compiler that exited with an error. +The helper compile deadline defaults to 120,000 ms; set +`REA_SWIFTC_COMPILE_TIMEOUT_MS` to an integer between 1 and 2,147,483,647 when +a busy host needs longer. Other rejection values retain their primitive value or an explicit type. Shutdown warnings include the failure kind, message, and diagnostics, while process and temporary-project cleanup diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 1d1a579e6..9f86551d7 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -139,14 +139,40 @@ The result preserves the requested names in `selection.type_names` and reports unmatched names separately. If metadata names are unreadable, limitations retain that uncertainty. With no selection, `selection` is null and whole-assembly inspection keeps its existing resource policy. Selection restricts types, -fields, methods, call edges and field accesses; member references remain an -assembly-wide inventory, so output size is not guaranteed to be small. +fields, methods, call edges, field accesses and user strings; member references +remain an assembly-wide inventory, so output size is not guaranteed to be small. Coverage describes the selected members. Comparisons leave members outside the opposite observation's selection unknown, and application graphs report their selected scope as partial. Neither makes an absence claim about the whole assembly. Older member Evidence without `selection` remains readable. +### User strings + +`ldstr` anchors keep their build-local `0x70` tokens. `user_strings.literals` +lists each distinct token loaded by an inspected method body once, in token +order, with its `#US` file offset and decoded value. Values keep their stored +UTF-16 code units, including unpaired surrogates. Token `0x70000000` names the +heap's empty entry at offset 0 and reads as `""`; any other item there is +malformed. The terminal flag is checked against ECMA-335 II.24.2.4, and the +length prefix against the width II.23.2 assigns. A difference is reported with +its byte offset as a user-string issue while preserving the readable literal. +Toolchain conventions can differ; this conformance observation does not +establish runtime rejection. A missing `#US` stream is one stream-level issue; +a malformed item is reported against its token. User strings carry their own +coverage: any of these makes `user_strings.state` partial and lists the issue +in `user_strings.issues`, while the member `coverage` and `metadata.status` are +unaffected, and readable literals remain. The state is also partial when an +inspected method's CIL was not fully decoded (a malformed exception section +alone does not count) or a method that may belong to the inspected types was +not inspected (its row could not be read, or its type's name could not be read +under a `type_names` selection), because `ldstr` instructions there were not +observed. Normalized CIL digests do not include decoded values. +`compare_managed_members` does not decode or compare literals: an unchanged +method does not establish unchanged strings, and the comparison says so in its +limitations. Older member Evidence without `user_strings` reads as `null`: not +examined. + ### Edge target declaring types `member_refs[].parent_name`, `call_edges[].target_declaring_type`, and @@ -183,9 +209,11 @@ native/runtime questions retain their own authority and coverage. Names and semantic labels do not establish durable member identity. Comparison preserves observed differences, structural inferences, and unknowns separately. -Cross-version matching first prefers exact CIL/signature identity. When that -does not match, it pairs an exact declared type, method name, and raw signature -before trying structural body shape. The exact-signature key uses names only +Cross-version matching first prefers exact raw CIL/signature identity, with +equal decoded fingerprints. Equal decoded fingerprints alone cannot establish +an exact match: they omit switch targets and collapse distinct NaN encodings. +When that does not match, it pairs an exact declared type, method name, and raw +signature before trying structural body shape. The exact-signature key uses names only as part of that full tuple; names alone never select a pair. Duplicate tuples remain ambiguous rather than being paired by token order. Fields follow the same exact tiers. The raw signature is exact whether or not REA decoded it, so @@ -196,8 +224,11 @@ unpaired member, one-sided or ambiguous, with the same declared type and name whose signature was not decoded. For a matched method, an unavailable or partial body makes body-shape facets -unknown while preserving observed signature differences. Structural identity -compares normalized signatures, the limited decoded-CIL tuple fingerprint, +unknown while preserving observed signature differences. For complete bodies, +a difference in either the raw CIL digest or the decoded fingerprint is reported +in the `cil` dimension, including reordered switch targets with equal decoded +fingerprints. Structural identity compares normalized signatures, the limited +decoded-CIL tuple fingerprint, constants, and bounded shape context. It reports all candidates at the winning score and remains ambiguous when the evidence does not distinguish them. The digest does not itself remap metadata tokens. @@ -334,8 +365,8 @@ Opcode names retain short/long forms and prefix opcodes. This makes the value a reproducible decoded-tuple fingerprint, not a canonical semantic CIL identity: it does not resolve tokens across MVIDs, does not commit full control flow or exception semantics, and does not replace the raw CIL digest. Cross-build -comparison uses the value only as one unique-only structural tier and reports -ambiguity when that evidence is insufficient. +comparison requires equal raw CIL digests as well for exact CIL/signature +matches and reports ambiguity when structural evidence is insufficient. For example, a decoded `nop; ret` body serializes exactly as `[["nop","none",null],["ret","none",null]]` and has digest diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 876a30f6d..307d323df 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -21,6 +21,31 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to `compare_artifacts`. Use `get_evidence_bundle` when the task needs broader retained session history or an explicit bundle for transfer. +Artifact inventories declare `coverage.scope` (`root` or `container-entries`) +and `coverage.status` (`complete` or `unknown`) independently of diagnostic +wording. Comparison requires matching scopes, complete graph members and complete +traversal coverage before concluding equality. A fully observed root without +container children can be `unchanged`; its scope notice stays in `limitations` +without creating a gap. Content and executable metadata changes still produce +root deltas. Missing graph members yield `truncated`; unknown traversal coverage +or unavailable occurrence hashes retain uncertainty. Integrity mismatches remain +contradictions. Static artifact equality does not establish unchanged runtime +behavior. + +Exhausted traversals with deliberately omitted filesystem entries retain +`unknown` coverage and identify each omitted path/type in `limitations`; FIFO, +socket and device entries are not opened. Case-distinct archive members remain +separate observations. Their case-insensitive materialization diagnostic does +not reduce traversal coverage when both spellings were read. + +Current output also retains `coverage_status` (`complete` or `partial`) for existing clients, derived from the scoped traversal status. The owning parser normalizes retained `coverage_status` records to scoped coverage and rejects conflicting declarations. + +The coverage field is optional when reading older retained Evidence. The owning +inventory parser recognizes historical REA scope notices for compatibility; +unfamiliar legacy limitations remain conservative. Typed records do not derive +coverage from limitation text. Pages from one inventory must carry the same +coverage declaration. + Function dossiers can declare per-facet coverage in the optional `normalized_result.facet_availability` map. Each supplied facet is either `{ "available": true }` or `{ "available": false, "reason": "..." }`. @@ -474,6 +499,7 @@ These deadlines have different owners: | MCP initialize | The client bounds transport/REA connection startup, before any target query. | | Hopper startup | REA allows 120,000 ms by default; `REA_HOPPER_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. Startup failure is reported by the first provider query. | | Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. | +| Darwin swiftc helper | REA allows 120,000 ms by default for ownership-helper compilation; `REA_SWIFTC_COMPILE_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. | | Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. | For an already connected client using the pinned SDK, request options are the diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 8df2522bc..2c796533e 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -19,7 +19,11 @@ **[الموقع](https://rea.tools/ar/) · [الأدلة](https://rea.tools/ar/guides/) · [دراسات الحالة](https://rea.tools/ar/showcase/)** -[البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [التوثيق](#التوثيق) +[البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [أدوات GNU objdump وRizin](../reverse-engineering-tools.md) · [التوثيق](#التوثيق) + +> [!IMPORTANT] +> المصادر الرسمية لـ REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> استخدم روابط التثبيت والإصدارات المنشورة في هذه المصادر الرسمية. تشابه اسم المستودع أو نسخ ملف README لا يثبت أن التنزيل رسمي. npx rea-agents setup @@ -135,6 +139,7 @@ npx rea-agents@latest setup | الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/ar/guides/native/) | | بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) | | شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) | | JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) | | المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 2dbb1d41a..c6f768824 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -19,7 +19,11 @@ **[Website](https://rea.tools/de/) · [Anleitungen](https://rea.tools/de/guides/) · [Fallstudien](https://rea.tools/de/showcase/)** -[Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [Häufige Fragen](#häufige-fragen) · [Dokumentation](#dokumentation) +[Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [Häufige Fragen](#häufige-fragen) · [GNU-objdump- und Rizin-Tools](../reverse-engineering-tools.md) · [Dokumentation](#dokumentation) + +> [!IMPORTANT] +> Offizielle Quellen für REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Folge den Installations- und Release-Links aus diesen offiziellen Quellen. Ein ähnlicher Repository-Name oder eine kopierte README belegt nicht, dass ein Download offiziell ist. npx rea-agents setup @@ -135,6 +139,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](https://rea.tools/de/guides/native/) | | Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) | | EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) | | JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) | | Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index d09be25e2..ba11c44f2 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -19,7 +19,11 @@ **[Sitio web](https://rea.tools/es/) · [Guías](https://rea.tools/es/guides/) · [Casos prácticos](https://rea.tools/es/showcase/)** -[Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Preguntas frecuentes](#preguntas-frecuentes) · [Documentación](#documentación) +[Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Preguntas frecuentes](#preguntas-frecuentes) · [Herramientas GNU objdump y Rizin](../reverse-engineering-tools.md) · [Documentación](#documentación) + +> [!IMPORTANT] +> Fuentes oficiales de REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Usa los enlaces de instalación y de versiones publicados en estas fuentes oficiales. Un nombre de repositorio parecido o un README copiado no demuestra que una descarga sea oficial. npx rea-agents setup @@ -135,6 +139,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](https://rea.tools/es/guides/native/) | | Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) | | Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) | | JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) | | Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index b24e4380c..43aae1384 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -19,7 +19,11 @@ **[وب‌سایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونه‌های عملی](https://rea.tools/fa/showcase/)** -[شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-می‌کند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-می‌توان-تحلیل-کرد) · [نمونه‌های عملی](#نمونه‌های-عملی) · [پرسش‌های متداول](#پرسش‌های-متداول) · [مستندات](#مستندات) +[شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-می‌کند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-می‌توان-تحلیل-کرد) · [نمونه‌های عملی](#نمونه‌های-عملی) · [پرسش‌های متداول](#پرسش‌های-متداول) · [ابزارهای GNU objdump و Rizin](../reverse-engineering-tools.md) · [مستندات](#مستندات) + +> [!IMPORTANT] +> منابع رسمی REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> از پیوندهای نصب و انتشار در این منابع رسمی استفاده کنید. نام مشابه مخزن یا README کپی‌شده ثابت نمی‌کند که فایل دانلودی رسمی است. npx rea-agents setup @@ -127,6 +131,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) | | ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) | | بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](../recorded-crashes.md) | | JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) | | وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index e6278ac93..0ff19b62d 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -19,7 +19,11 @@ **[Site web](https://rea.tools/fr/) · [Guides](https://rea.tools/fr/guides/) · [Études de cas](https://rea.tools/fr/showcase/)** -[Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Questions fréquentes](#questions-fréquentes) · [Documentation](#documentation) +[Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Questions fréquentes](#questions-fréquentes) · [Outils GNU objdump et Rizin](../reverse-engineering-tools.md) · [Documentation](#documentation) + +> [!IMPORTANT] +> Sources officielles de REA : [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Utilisez les liens d’installation et de publication de ces sources officielles. Un nom de dépôt similaire ou un README copié ne prouve pas qu’un téléchargement est officiel. npx rea-agents setup @@ -139,6 +143,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) | | Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` | | Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) | | Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index fba947fb2..f880983ca 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -19,7 +19,11 @@ **[Situs Web](https://rea.tools/id/) · [Panduan](https://rea.tools/id/guides/) · [Contoh Penggunaan](https://rea.tools/id/showcase/)** -[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [FAQ](#faq) · [Dokumentasi](#dokumentasi) +[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [FAQ](#faq) · [Alat GNU objdump dan Rizin](../reverse-engineering-tools.md) · [Dokumentasi](#dokumentasi) + +> [!IMPORTANT] +> Sumber resmi REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Gunakan tautan instalasi dan rilis dari sumber resmi ini. Nama repositori yang mirip atau README yang disalin tidak membuktikan bahwa unduhan tersebut resmi. npx rea-agents setup @@ -142,6 +146,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) | | Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` | | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) | | JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) | | Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index b7e7c6100..231ae2587 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -19,7 +19,11 @@ **[Sito web](https://rea.tools/it/) · [Guide](https://rea.tools/it/guides/) · [Showcase](https://rea.tools/it/showcase/)** -[Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [FAQ](#faq) · [Documentazione](#documentazione) +[Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [FAQ](#faq) · [Strumenti GNU objdump e Rizin](../reverse-engineering-tools.md) · [Documentazione](#documentazione) + +> [!IMPORTANT] +> Fonti ufficiali di REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Segui i link di installazione e delle versioni pubblicate da queste fonti ufficiali. Un nome di repository simile o un README copiato non dimostra che un download sia ufficiale. npx rea-agents setup @@ -139,6 +143,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) | | Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` | | Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) | | JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) | | Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index c9d7981ba..3377c52bc 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -19,7 +19,11 @@ **[ウェブサイト](https://rea.tools/ja/) · [ガイド](https://rea.tools/ja/guides/) · [事例](https://rea.tools/ja/showcase/)** -[クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [よくある質問](#よくある質問) · [ドキュメント](#ドキュメント) +[クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [よくある質問](#よくある質問) · [GNU objdump・Rizin ツール](../reverse-engineering-tools.md) · [ドキュメント](#ドキュメント) + +> [!IMPORTANT] +> REA の公式配布元: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> インストールとリリースのリンクは、これらの公式配布元からたどってください。似たリポジトリ名やコピーされた README だけでは、公式の配布物とは確認できません。 npx rea-agents setup @@ -135,6 +139,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/ja/guides/native/) | | オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) | | EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) | | JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) | | ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 26db0d824..6eb9ae9a7 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -19,7 +19,11 @@ **[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)** -[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서) +[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [GNU objdump 및 Rizin 도구](../reverse-engineering-tools.md) · [문서](#문서) + +> [!IMPORTANT] +> REA 공식 배포처: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> 이 공식 배포처에서 제공하는 설치 및 릴리스 링크를 이용하세요. 비슷한 저장소 이름이나 복사된 README만으로는 공식 배포 파일인지 확인할 수 없습니다. npx rea-agents setup @@ -135,6 +139,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) | | 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | | EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) | | JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) | | 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 48200087d..96b46685d 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -19,7 +19,11 @@ **[Strona internetowa](https://rea.tools/pl/) · [Przewodniki](https://rea.tools/pl/guides/) · [Przykłady zastosowań](https://rea.tools/pl/showcase/)** -[Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [FAQ](#faq) · [Dokumentacja](#dokumentacja) +[Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [FAQ](#faq) · [Narzędzia GNU objdump i Rizin](../reverse-engineering-tools.md) · [Dokumentacja](#dokumentacja) + +> [!IMPORTANT] +> Oficjalne źródła REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Korzystaj z odnośników do instalacji i wydań w tych oficjalnych źródłach. Podobna nazwa repozytorium lub skopiowany README nie potwierdzają, że pobierany plik jest oficjalny. npx rea-agents setup @@ -135,6 +139,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](https://rea.tools/pl/guides/native/) | | Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) | | Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) | | JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) | | Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 6c3f34e35..7988f0d77 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -19,7 +19,11 @@ **[Site](https://rea.tools/pt-BR/) · [Guias](https://rea.tools/pt-BR/guides/) · [Casos de uso](https://rea.tools/pt-BR/showcase/)** -[Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Perguntas frequentes](#perguntas-frequentes) · [Documentação](#documentação) +[Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Perguntas frequentes](#perguntas-frequentes) · [Ferramentas GNU objdump e Rizin](../reverse-engineering-tools.md) · [Documentação](#documentação) + +> [!IMPORTANT] +> Fontes oficiais do REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Use os links de instalação e de versões dessas fontes oficiais. Um nome de repositório parecido ou um README copiado não comprova que um download seja oficial. npx rea-agents setup @@ -135,6 +139,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](https://rea.tools/pt-BR/guides/native/) | | Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) | | Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) | | JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) | | Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 744e26f6c..bf2cf30e6 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -19,7 +19,11 @@ **[Сайт](https://rea.tools/ru/) · [Руководства](https://rea.tools/ru/guides/) · [Примеры](https://rea.tools/ru/showcase/)** -[Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Частые вопросы](#частые-вопросы) · [Документация](#документация) +[Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Частые вопросы](#частые-вопросы) · [Инструменты GNU objdump и Rizin](../reverse-engineering-tools.md) · [Документация](#документация) + +> [!IMPORTANT] +> Официальные источники REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Используйте ссылки на установку и выпуски из этих официальных источников. Похожее название репозитория или скопированный README не подтверждают, что скачиваемый файл является официальным. npx rea-agents setup @@ -135,6 +139,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) | | Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) | | Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | | JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) | | Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 734b28c86..5584157c3 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -19,7 +19,11 @@ **[เว็บไซต์](https://rea.tools/th/) · [คู่มือ](https://rea.tools/th/guides/) · [ตัวอย่างการใช้งาน](https://rea.tools/th/showcase/)** -[เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เอกสาร](#เอกสาร) +[เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เครื่องมือ GNU objdump และ Rizin](../reverse-engineering-tools.md) · [เอกสาร](#เอกสาร) + +> [!IMPORTANT] +> แหล่งทางการของ REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> โปรดใช้ลิงก์ติดตั้งและรุ่นเผยแพร่จากแหล่งทางการเหล่านี้ ชื่อที่เก็บโค้ดที่คล้ายกันหรือ README ที่คัดลอกมาไม่ได้ยืนยันว่าไฟล์ดาวน์โหลดเป็นของทางการ npx rea-agents setup @@ -135,6 +139,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](https://rea.tools/th/guides/native/) | | โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) | | ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) | | JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) | | เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 152c81819..ea09a48b8 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -19,7 +19,11 @@ **[Web sitesi](https://rea.tools/tr/) · [Rehberler](https://rea.tools/tr/guides/) · [Örnek çalışmalar](https://rea.tools/tr/showcase/)** -[Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [Sık sorulan sorular](#sık-sorulan-sorular) · [Belgeler](#belgeler) +[Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [Sık sorulan sorular](#sık-sorulan-sorular) · [GNU objdump ve Rizin araçları](../reverse-engineering-tools.md) · [Belgeler](#belgeler) + +> [!IMPORTANT] +> REA’nın resmî kaynakları: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Kurulum ve sürüm bağlantılarını bu resmî kaynaklardan takip edin. Benzer bir depo adı veya kopyalanmış bir README, indirilen dosyanın resmî olduğunu kanıtlamaz. npx rea-agents setup @@ -137,6 +141,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](https://rea.tools/tr/guides/native/) | | Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) | | Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez | +| Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) | | WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` | | EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) | | SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 5c77d80b0..ceec0b63b 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -19,7 +19,11 @@ **[Сайт](https://rea.tools/uk/) · [Посібники](https://rea.tools/uk/guides/) · [Приклади](https://rea.tools/uk/showcase/)** -[Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Поширені запитання](#поширені-запитання) · [Документація](#документація) +[Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Поширені запитання](#поширені-запитання) · [Інструменти GNU objdump і Rizin](../reverse-engineering-tools.md) · [Документація](#документація) + +> [!IMPORTANT] +> Офіційні джерела REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Користуйтеся посиланнями на встановлення та випуски з цих офіційних джерел. Схожа назва репозиторію або скопійований README не підтверджують, що завантажуваний файл є офіційним. npx rea-agents setup @@ -135,6 +139,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](https://rea.tools/uk/guides/native/) | | Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) | | Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) | | JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) | | Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index 3d2062da6..4f7aba8b6 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -19,7 +19,11 @@ **[Trang web](https://rea.tools/vi/) · [Hướng dẫn](https://rea.tools/vi/guides/) · [Các nghiên cứu điển hình](https://rea.tools/vi/showcase/)** -[Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Tài liệu](#tài-liệu) +[Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Công cụ GNU objdump và Rizin](../reverse-engineering-tools.md) · [Tài liệu](#tài-liệu) + +> [!IMPORTANT] +> Các nguồn chính thức của REA: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> Hãy dùng các liên kết cài đặt và bản phát hành từ những nguồn chính thức này. Tên kho mã tương tự hoặc README được sao chép không chứng minh rằng tệp tải xuống là bản chính thức. npx rea-agents setup @@ -135,6 +139,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](https://rea.tools/vi/guides/native/) | | Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) | | Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | | JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) | | Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 1ddbbd26e..95dedaccd 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -19,7 +19,11 @@ **[網站](https://rea.tools/zh-TW/) · [指南](https://rea.tools/zh-TW/guides/) · [案例](https://rea.tools/zh-TW/showcase/)** -[快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [常見問題](#常見問題) · [文件](#文件) +[快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [常見問題](#常見問題) · [GNU objdump 與 Rizin 工具](../reverse-engineering-tools.md) · [文件](#文件) + +> [!IMPORTANT] +> REA 官方來源: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> 請透過這些官方來源提供的安裝與發布連結取得 REA。相似的儲存庫名稱或複製的 README 不能證明下載內容來自官方。 npx rea-agents setup @@ -139,6 +143,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go | | WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` | | EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | | JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) | | 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 6e70fd3c3..64fc99193 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -19,7 +19,11 @@ **[网站](https://rea.tools/zh-CN/) · [指南](https://rea.tools/zh-CN/guides/) · [案例](https://rea.tools/zh-CN/showcase/)** -[快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [常见问题](#常见问题) · [文档](#文档) +[快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [常见问题](#常见问题) · [GNU objdump 和 Rizin 工具](../reverse-engineering-tools.md) · [文档](#文档) + +> [!IMPORTANT] +> REA 官方来源: [morluto/rea](https://github.com/morluto/rea), [rea.tools](https://rea.tools/), [rea-agents](https://www.npmjs.com/package/rea-agents) (npm). +> 请通过这些官方来源提供的安装和发布链接获取 REA。相似的仓库名称或复制的 README 不能证明下载内容来自官方。 npx rea-agents setup @@ -139,6 +143,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go | | WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` | | EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | | JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) | | 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) | diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md new file mode 100644 index 000000000..124c92dc0 --- /dev/null +++ b/docs/reverse-engineering-tools.md @@ -0,0 +1,81 @@ +# Reverse-engineering tool integrations + +REA can call caller-supplied GNU objdump, GDB, and Rizin installations. REA does not install or update these tools. Static objdump/Rizin workflows use the same services from CLI and MCP. GDB's persistent MCP sessions and ephemeral CLI command are available only on Linux. Persistent debugger sessions exist only for the lifetime of the REA MCP server process. + +In a shared MCP server, unrestricted Rizin and Cutter commands, GDB console +commands, Rizin debugger start/close, and GDB debugger close retire any active +binary analysis session after the operation. This prevents a fresh analysis +provider from being rebound automatically while command effects may still be +writing. After the command and any background writers have stopped, call +`open_binary` explicitly before continuing analysis. A successful command +response alone does not prove that background writers have stopped. An active +Ghidra analysis session must be closed explicitly before an external mutation +can run. + +## GNU objdump + +`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported. + +For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI option is `--follow-debug-links true`. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema. + +```sh +rea inspect-with-objdump ./program disassemble +rea inspect-with-objdump ./program dwarf --follow-debug-links false +``` + +Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. + +## GDB + +Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. + +Before a normal close, REA checks for live GDB thread groups. If one remains and a durable `inferior_tty` is verified, REA asks GDB to detach it and confirms the detach before stopping the debugger. Without that sink, select and apply a disposition to each inferior through `gdb_console` (for example, detach or kill as appropriate), then retry close. REA never infers target ownership from a PID or path. When the MCP server shuts down, REA terminates only its directly owned GDB process after confirming no inferior or a sink-backed detach; otherwise it retains the process and its output streams for retry. + +```text +start_gdb_session {"inferior_tty":"/dev/pts/7"} +gdb_console {"session_id":"…","command":"file ./program"} +gdb_console {"session_id":"…","command":"info files"} +gdb_session_status {"session_id":"…"} +close_gdb_session {"session_id":"…"} +``` + +## Rizin + +`start_rizin_debug_session` and `rea debug-with-rizin` require an explicit `inferior_tty` on Linux. Rizin launches the target with stdout and stderr routed to that caller-selected TTY and `dbg.exitkills=false`; the target can therefore continue running and writing to the TTY after REA closes the debugger. This is a visible target output effect, and the caller is responsible for selecting a suitable terminal that remains available. Before closing, REA serializes after the active command, parses the structured `olj` descriptor list, detaches only a unique `ptrace://` descriptor, confirms its absence, reads back `dbg.exitkills=false`, and verifies `/proc//fd/1` and `/proc//fd/2` still identify the selected TTY by device and inode. Any malformed or ambiguous readback keeps the Rizin session owned for retry. Persistent Rizin debugger sessions are unavailable outside Linux because this verification depends on `/proc`. + +Persistent command Evidence retains the caller-selected and resolved startup paths in `parameters.launch_provenance`. These paths describe launch provenance, not the current target after unrestricted commands. `rizin_debug_session_status.output_invalid_utf8` reports whether lossy frame decoding has been observed during the session. Command Evidence sets `output_invalid_utf8` only when loss was observed in frames collected for that command, including retained partial output; earlier startup or command frames do not mark later valid output as lossy. + +```sh +rea debug-with-rizin --inferior-tty /dev/pts/7 ./program dr +``` + +`execute_rizin_command` and `rea execute-rizin-command` run one selected command against a local artifact. The one-shot profile uses `-N -q -c`, suppressing user settings and scripts while leaving plugins available. Commands and plugins run with the REA process user's security context and may access files, the network, or perform other actions. REA preserves raw output and does not claim that command-specific JSON has one universal schema. + +```sh +rea execute-rizin-command ./program iI +``` + +Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. + +## Cutter bridge + +Cutter commands use the local REA Python bridge and authenticated loopback messages. +The client and plugin must support the current v2 handshake; older peers are +rejected before dispatch because they cannot preserve cross-caller recovery proof. +The client checks the final encoded request against the bridge's 16 MiB frame +limit before dispatch; an oversized command does not retain a cleanup owner. +Discovery admits at most 256 descriptor candidates and reads and probes at most +eight concurrently. A larger directory reports `bridge_directory_capacity_exceeded`. + +Terminal receipts are recovered before another command, including from a new CLI +invocation. A signed status response also confirms completion of older bridge +connections after its UI dispatch, preserving recovery proof for a caller whose +receipt another caller has acknowledged. Neither receipt recovery nor a status +probe retries the original command. Failed acknowledgements remain pending and +block new commands until they can be retried successfully. + +## Configuration and coverage + +The optional `REA_OBJDUMP_COMMAND`, `REA_GDB_COMMAND`, and `REA_RIZIN_COMMAND` values select executable names or paths; otherwise REA searches `PATH`. Values are passed directly to process spawning, not expanded as shell command lines. Provider versions and capabilities remain unknown until observed from the installed tool and verified in the corresponding real-provider lane. + +Provider capture limits are separate from the MCP response budget. A result that fits the provider limit may still exceed the default 10 MiB MCP response budget and return a transport `resource_constraint`. Configure `REA_MCP_MAX_RESPONSE_BYTES` to match a larger client receive buffer when that client supports one; otherwise use retained Evidence/export or complete CLI JSON output. See [MCP response limits](./mcp-contracts.md#tool-results). diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md new file mode 100644 index 000000000..d7cb7a750 --- /dev/null +++ b/docs/rust-binaries.md @@ -0,0 +1,67 @@ +# Rust binary dependency metadata + +`inspect_rust_binary` / `inspect-rust-binary` answers which crate names, +versions, sources, and dependency edges are embedded by cargo-auditable in one +selected local executable or library. It reads the file without running it and +requires no Rust installation, active binary target, Hopper, Ghidra, or IDA. + +```bash +rea inspect-rust-binary /absolute/path/to/application --json +``` + +The CLI resolves relative paths against the caller's working directory. MCP +requires an absolute filesystem path and preserves that exact selection: + +```json +{ + "name": "inspect_rust_binary", + "arguments": { "path": "/artifacts/application" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, byte length, +container format, architecture, word size, and byte order. When a `.dep-v0` +section is present, `auditable_metadata` contains: + +- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for thin Mach-O; +- `file_offset` and `section_bytes`, the exact file range of the compressed section; +- `compressed_bytes` and `uncompressed_bytes`; +- `format_version`, the embedded integer, or `null` when the field is omitted; +- `packages`, in the section's original order, with `name`, `version`, `source`, + `kind`, `dependencies`, and `root`; +- `root_package_index`, the single package marked `root`, or `null` when none is marked; +- `dependency_edges`, pairs of package indices in package-then-dependency order; +- `cyclic_dependency_indices`, sorted unique indices that participate in a cycle. + +An omitted `kind` stays `null`. The embedded format treats that omission as +runtime, and that default is not stored in the section. An omitted dependency +list is an empty array. An omitted `root` flag is false. + +`rustc_commit` is present only when a `/rustc/<40-hex>/` path is found in +read-only data. Its `evidence_kind` is `inferred`. The hash is not a claim that +that compiler produced the binary. Several distinct hashes leave a single +commit unknown. + +## Limits and failures + +The selected file is limited to 256 MiB. A dependency section is limited to 1 +MiB compressed. Inflated JSON is limited to the smaller of 8 MiB and 64 times +the compressed size. The complete report is limited to 8 MiB. Empty sections, +truncated or trailing zlib bytes, invalid JSON, unknown fields, dependency +indices outside the package list, more than one root package, and compressed +dependency sections are typed failures. A cycle is reported and is not walked. + +A missing section returns success with `auditable_metadata: null` and a +limitation. Absence of `.dep-v0` does not mean the binary has no dependencies. +Embedded names and versions are file content. They do not authenticate the +packages or the compiler. + +Universal Mach-O containers are unsupported. Select a thin slice first. ELF, +PE, and thin Mach-O are the supported containers. + +Package and dependency counts are checked against the minimum required JSON +representation before graph allocation or schema cloning. Oversized graphs +report the output byte budget; the compiler-path capacity reports +`maximum_rustc_hashes` in hashes, independently of byte limits. diff --git a/docs/testing.md b/docs/testing.md index 213c48e44..b543fe89f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -262,6 +262,24 @@ CI owns coverage; aggregate and domain/contract thresholds are maintained in `vitest.config.ts`. They are glob-specific and never updated automatically. Coverage does not replace named boundary or real-provider scenarios. +The `reverse-tools` commands run the focused tests and report coverage and +mutation results without an additional percentage requirement. Test failures, +invalid mutation configuration, and runner failures still fail the check. +Mutation targets cover reviewed production behavior, including signal-terminated +commands, Cutter descriptor identity, debugger cleanup ownership, GDB command +exit and cancellation, and Rizin startup, timeout Evidence, and process exit. +A second exhaustive pass runs the focused Cutter and Rizin timeout semantics tests against every compilable +selected mutant to avoid relying on per-test mutation coverage associations; +Stryker reports compile-error mutants separately because those cannot execute +tests. Both passes resolve named syntax owners, scoped declarations, guards, +callbacks, and reviewed expressions from the current TypeScript source; no +hand-maintained line ranges select mutations. Missing, ambiguous, or malformed +source targets stop configuration before mutation generation. The selections +retain their review scope rather than expanding to whole source files. The +focused suite also exercises affected MCP routes and the Python Cutter bridge. +Contract, integration, and real-provider lanes complement these focused tests; +simulated providers do not establish real-provider behavior. + CI runs six native Vitest shards without retries. Each shard emits a blob report; the merge job produces JUnit and JSON diagnostics, a module timing report, plus aggregate coverage during full qualification. It retains available @@ -346,6 +364,17 @@ that explicit lane, not the routine iteration requirement. ## Real-toolchain verification lanes +Linux Rizin debugger ownership changes require a separate real-provider gate on +Rizin 0.7.4. Run +`npm run test:focused -- tests/boundary/providers/RizinInferiorTty.test.ts`. +This test verifies the caller-selected TTY against the actual inferior's file +descriptors, confirms `dbg.exitkills=false`, detaches through the `olj`/`o-` +protocol, and checks that the target survives debugger close and writes to the +selected TTY. The suite is included in the reverse-tools Vitest and mutation +typecheck selections; its real-provider assertions skip when Rizin 0.7.4 is +unavailable. Run the focused command above on a host with that provider for the +explicit real-provider verification gate. + Each real-toolchain command must require only the host tools needed to prove its stated claim. Use a host-native fixture for host/provider acceptance, and place optional cross-target formats or platform-specific runners in separate @@ -887,8 +916,13 @@ successful ancestor push run of this CI workflow, rather than the preceding push: changes from failed or cancelled intermediate runs remain in scope. Missing, untrusted, or non-ancestor history selects every lane. The lookup is bounded to the last 100 successful push runs; lookup failures broaden selection. -Every code change still runs all six portable shards, without coverage -instrumentation in this fast tier. This deliberately avoids relying on an +Every code change still selects all six portable shards, without coverage +instrumentation in this fast tier. Ordinary PR matrices cancel remaining +shards after the first failure; passing PRs execute the complete suite. Main, +`ci:full`, scheduled, manual, and release qualification retain every shard's +results after a failure. Available failed-shard reports remain diagnostic only: +failed, cancelled, skipped, or missing selected shards cannot pass the gate or +publish successful timing history. This deliberately avoids relying on an import graph for generated files, filesystem inputs, subprocesses, or bridges. Scheduled qualification is admitted hourly at minute 23. A forced full run at @@ -916,10 +950,11 @@ classification job records its selection and broad-fallback reasons in the run summary. Representative Git-diff cases live in `tests/boundary/filesystem/ciChangeScope.test.ts`. -`CI required` always runs and checks classification and every selected lane, +`CI required` checks classification and every selected lane, rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or unknown tiers and requires every canonical scope to be explicitly `true` or `false`; incomplete classification cannot silently omit verification lanes. +The aggregate remains cancellable so superseded runs release their slots. Branch protection should require this aggregate, `Static checks`, `Portable tests`, and `Conventional PR title`. Title validation runs separately on PR opening, @@ -968,7 +1003,7 @@ filename cases. POSIX package verification resolves the runner's configured npm cache before creating disposable client homes and passes that location to its child installs. -This reuses the download cache restored by `setup-node` while keeping client +This reuses the download cache restored by the shared npm-cache action while keeping client configuration isolated. Installs retain their existing registry and integrity checks. @@ -998,7 +1033,24 @@ thresholds remain enforced in full qualification. Missing or unusable history falls back to Vitest's default assignment. Only timing data is reused; live provider results are never cached. Turbo outputs are restored by task hash, with trusted default-branch runs as cache writers and PRs -as restore-only consumers. npm caches hold downloads, not `node_modules`. +as restore-only consumers. The static job also restores its Turbo snapshot; +each task revalidates its own input hash before reusing outputs or diagnostics. +The Node pin and npm install settings are global hash inputs. Knip includes its ignore rules and VitePress +entry, so changes to those inputs cannot reuse an older successful check. + +The shared npm-cache action caches downloads, not `node_modules`, and every +consumer still runs `npm ci`. Keys cover the runner OS, actual Node architecture, +root lockfiles, and `.npmrc`. Only push, schedule, and manual events on the +repository's default branch may save shared npm caches; PRs restore only. +Static snapshots use the same write authority and are saved only after the +static job's validation succeeds. Main warms the new cache namespace after +merge; a cold cache or changed lockfile falls back to the normal install. +This prevents each PR from saving another branch-scoped copy of the same +download cache. It does not remove runner assignment waits. + +Portable owner-based test selection remains observational. Its reports compare +proposed omissions with the full run; incomplete or skipped observations do not +establish that fewer tests would preserve regression detection. The eight automatic browser, binary-layout, EVM, JavaScript-recovery, recorded-crash, and web provider lanes download `rea-build-cache` from their own diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 17b6c77f9..12d4da85b 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -61,9 +61,24 @@ The tool profile preserves upstream release/source identity, resolved executable paths, executable digests, exact banners and arguments. Executable digests are checked again after each command. Caller-supplied tools are trusted programs; version and digest recording are not publisher authentication or an OS sandbox. -Each subprocess has a 30-second deadline and 32 MiB combined output budget; -truncation fails. All commands use existing owned-process cancellation and -cleanup. Workspace cleanup failures remain explicit errors with resource paths. +Each subprocess has a 30-second deadline and 32 MiB combined output budget. +Inspection output past the budget fails with `resource_constraint`; an oversized +`--version` probe still reports `unreadable_output`. Both streams share the +budget, so a stream that starts after the other has filled it retains nothing. +The captured output keeps up to the first 4,096 bytes that each stream retained, +each stream's retained byte count, and the exit code and signal. Retained text +can end in U+FFFD where the budget cut a multi-byte character. + +These excerpts bound the returned diagnostic text. +[PR #2117](https://github.com/morluto/rea/pull/2117) reports elapsed time, CPU +time, and peak process-family memory, including WABT children, for warm Linux +x64 WABT 1.0.42 CLI workflows; those costs did not change outside measurement +noise. Agent token usage and truly cold workflows remain +unmeasured. The underlying error cause still retains the full captured +subprocess snapshot, so the excerpt does not establish lower retained memory +or faster analysis. +All commands use existing owned-process cancellation and cleanup. Workspace +cleanup failures remain explicit errors with resource paths. WAT is a decoded representation, not original source or proof of runtime behavior. Custom annotations can preserve payloads, but WAT does not establish original diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index c447c4783..2d5bdcae1 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -224,6 +224,22 @@ are 0 and 50. The view groups request lines by method, WHATWG URL origin and path template, sorted by origin, template and method. Query strings, fragments, credentials, headers and bodies are not copied into groups. +Select `include_query_parameter_names: true` in the view to include distinct +observed query names per group. The default and explicit `false` omit the +`query_parameter_names` field. Selected groups include an array of +`{name, record_ordinals}` entries, sorted by Unicode code point; each name's +ordinals follow the parent's selected record order and occur once per record. +For `/products?category=books&page=1` and +`/products?category=games&sort=price`, names are `category`, `page` and `sort`. +Query values and fragments are not copied into this inventory. + +Names use WHATWG `URLSearchParams` form-query decoding: literal `+` becomes +space, `%2B` becomes `+`, percent escapes decode as UTF-8, and empty names are +retained. Empty query pairs are ignored. Original URLs stay in parent Evidence; +name decoding does not normalize the recorded paths used for grouping. +The inventory describes observed names, without inferring required parameters, +value types or a complete API schema. Sensitive-parent refusals still apply. + | Rule | Exact segment format | Placeholder | | ---------- | ---------------------------------------------------- | ------------ | | `numeric` | ASCII digits only | `{numeric}` | @@ -269,13 +285,16 @@ and validation scratch by walking the parent without cloning it. Inline requests borrow the Evidence value through CLI/MCP envelope validation; the application admits the entire portable envelope before authenticated parsing or hashing. The envelope clone, hash scratch and capture clone stay charged during grouping. -Temporary URL/path/MIME work and retained state must +Temporary URL/path/MIME/query work and retained state must fit together within a 96 MiB projection representation budget. A whole group or merge is admitted before allocating its retained sets, map entries or array values. Fixed group storage, distinct text, exclusions and limitations count as well as matched segments. Per-record reservations are released after each record; repeated values do not accumulate another retained charge. Only the requested page is materialized as output groups. +Opt-in query projection reserves parser tuples and the per-record name set +before decoding, then admits distinct retained names and source ordinals before +changing a group. Repeated names within a request retain that ordinal once. The representation budget uses conservative storage and worst-case JSON-escaping allowances; it is not an enforced V8 heap or process-memory limit. It is separate diff --git a/knip.json b/knip.json index 72d093397..5ed82edf0 100644 --- a/knip.json +++ b/knip.json @@ -9,6 +9,7 @@ "src/application/RealHopperAssertions.ts", "scripts/verify/fridaBindingsCompatibility.ts", "scripts/verify/termux/browser.mjs", + "scripts/verify-real-cutter.mjs", "scripts/verify-real-ghidra-noreturn.mjs", "tests/processOwnershipRunner.ts" ], @@ -17,7 +18,12 @@ "entry": ["docs/.vitepress/config.ts"] }, "ignoreBinaries": ["mkfifo"], - "ignoreDependencies": ["electron", "esbuild", "webpack"], + "ignoreDependencies": [ + "@stryker-mutator/command-runner", + "electron", + "esbuild", + "webpack" + ], "ignoreExportsUsedInFile": true, "rules": { "files": "error", diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index 05fc2f173..794d5287e 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 587; runtime: 175. +Locked package entries: 719; runtime: 175. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -95,21 +95,236 @@ Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. Package: https://www.npmjs.com/package/@algolia/requester-node-http/v/5.59.0 Archive: https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz Integrity: sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA== -@babel/helper-string-parser@8.0.0 | runtime | MIT +@babel/code-frame@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/code-frame + Package: https://www.npmjs.com/package/@babel/code-frame/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz + Integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw== +@babel/compat-data@8.0.5 | development | MIT + Lockfile path: node_modules/@babel/compat-data + Package: https://www.npmjs.com/package/@babel/compat-data/v/8.0.5 + Archive: https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz + Integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw== +@babel/core@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/core + Package: https://www.npmjs.com/package/@babel/core/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/core/-/core-8.0.7.tgz + Integrity: sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA== +@babel/parser@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/core/node_modules/@babel/parser + Package: https://www.npmjs.com/package/@babel/parser/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz + Integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA== +@babel/types@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/core/node_modules/@babel/types + Package: https://www.npmjs.com/package/@babel/types/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz + Integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A== +obug@3.0.0 | development | MIT + Lockfile path: node_modules/@babel/core/node_modules/obug + Package: https://www.npmjs.com/package/obug/v/3.0.0 + Archive: https://registry.npmjs.org/obug/-/obug-3.0.0.tgz + Integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw== +@babel/generator@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/generator + Package: https://www.npmjs.com/package/@babel/generator/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/generator/-/generator-8.0.6.tgz + Integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng== +@babel/parser@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/generator/node_modules/@babel/parser + Package: https://www.npmjs.com/package/@babel/parser/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz + Integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA== +@babel/types@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/generator/node_modules/@babel/types + Package: https://www.npmjs.com/package/@babel/types/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz + Integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A== +@babel/helper-annotate-as-pure@8.0.0 | development | MIT + Lockfile path: node_modules/@babel/helper-annotate-as-pure + Package: https://www.npmjs.com/package/@babel/helper-annotate-as-pure/v/8.0.0 + Archive: https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz + Integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw== +@babel/helper-compilation-targets@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/helper-compilation-targets + Package: https://www.npmjs.com/package/@babel/helper-compilation-targets/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.7.tgz + Integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg== +@babel/helper-create-class-features-plugin@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/helper-create-class-features-plugin + Package: https://www.npmjs.com/package/@babel/helper-create-class-features-plugin/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-8.0.7.tgz + Integrity: sha512-7ug3KrOhNVPPxFFLTel0/pMzimKzU/vTlNJKVL/dKguCpmNJZjjibyFbWg+Qs8QHmiIZPNuZ2UNnDGdHWGEpOg== +@babel/helper-globals@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/helper-globals + Package: https://www.npmjs.com/package/@babel/helper-globals/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.6.tgz + Integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA== +@babel/helper-member-expression-to-functions@8.0.5 | development | MIT + Lockfile path: node_modules/@babel/helper-member-expression-to-functions + Package: https://www.npmjs.com/package/@babel/helper-member-expression-to-functions/v/8.0.5 + Archive: https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-8.0.5.tgz + Integrity: sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA== +@babel/types@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/types + Package: https://www.npmjs.com/package/@babel/types/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz + Integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A== +@babel/helper-module-imports@8.0.0 | development | MIT + Lockfile path: node_modules/@babel/helper-module-imports + Package: https://www.npmjs.com/package/@babel/helper-module-imports/v/8.0.0 + Archive: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz + Integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew== +@babel/helper-module-transforms@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/helper-module-transforms + Package: https://www.npmjs.com/package/@babel/helper-module-transforms/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-8.0.6.tgz + Integrity: sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ== +@babel/helper-optimise-call-expression@8.0.0 | development | MIT + Lockfile path: node_modules/@babel/helper-optimise-call-expression + Package: https://www.npmjs.com/package/@babel/helper-optimise-call-expression/v/8.0.0 + Archive: https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-8.0.0.tgz + Integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg== +@babel/helper-plugin-utils@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/helper-plugin-utils + Package: https://www.npmjs.com/package/@babel/helper-plugin-utils/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz + Integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g== +@babel/helper-replace-supers@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/helper-replace-supers + Package: https://www.npmjs.com/package/@babel/helper-replace-supers/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-8.0.1.tgz + Integrity: sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q== +@babel/helper-skip-transparent-expression-wrappers@8.0.0 | development | MIT + Lockfile path: node_modules/@babel/helper-skip-transparent-expression-wrappers + Package: https://www.npmjs.com/package/@babel/helper-skip-transparent-expression-wrappers/v/8.0.0 + Archive: https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-8.0.0.tgz + Integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw== +@babel/helper-string-parser@8.0.6 | runtime | MIT Lockfile path: node_modules/@babel/helper-string-parser - Package: https://www.npmjs.com/package/@babel/helper-string-parser/v/8.0.0 - Archive: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz - Integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg== -@babel/helper-validator-identifier@8.0.4 | runtime | MIT + Package: https://www.npmjs.com/package/@babel/helper-string-parser/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz + Integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA== +@babel/helper-validator-identifier@8.0.6 | runtime | MIT Lockfile path: node_modules/@babel/helper-validator-identifier - Package: https://www.npmjs.com/package/@babel/helper-validator-identifier/v/8.0.4 - Archive: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz - Integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg== + Package: https://www.npmjs.com/package/@babel/helper-validator-identifier/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz + Integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ== +@babel/helper-validator-option@8.0.0 | development | MIT + Lockfile path: node_modules/@babel/helper-validator-option + Package: https://www.npmjs.com/package/@babel/helper-validator-option/v/8.0.0 + Archive: https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz + Integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q== +@babel/helpers@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/helpers + Package: https://www.npmjs.com/package/@babel/helpers/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.7.tgz + Integrity: sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w== +@babel/types@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/helpers/node_modules/@babel/types + Package: https://www.npmjs.com/package/@babel/types/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz + Integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A== @babel/parser@8.0.4 | runtime | MIT Lockfile path: node_modules/@babel/parser Package: https://www.npmjs.com/package/@babel/parser/v/8.0.4 Archive: https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz Integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g== +@babel/plugin-proposal-decorators@8.0.2 | development | MIT + Lockfile path: node_modules/@babel/plugin-proposal-decorators + Package: https://www.npmjs.com/package/@babel/plugin-proposal-decorators/v/8.0.2 + Archive: https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-8.0.2.tgz + Integrity: sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg== +@babel/plugin-syntax-decorators@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/plugin-syntax-decorators + Package: https://www.npmjs.com/package/@babel/plugin-syntax-decorators/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-8.0.1.tgz + Integrity: sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw== +@babel/plugin-syntax-jsx@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/plugin-syntax-jsx + Package: https://www.npmjs.com/package/@babel/plugin-syntax-jsx/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-8.0.1.tgz + Integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g== +@babel/plugin-syntax-typescript@8.0.3 | development | MIT + Lockfile path: node_modules/@babel/plugin-syntax-typescript + Package: https://www.npmjs.com/package/@babel/plugin-syntax-typescript/v/8.0.3 + Archive: https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz + Integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg== +@babel/plugin-transform-destructuring@8.0.5 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-destructuring + Package: https://www.npmjs.com/package/@babel/plugin-transform-destructuring/v/8.0.5 + Archive: https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-8.0.5.tgz + Integrity: sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg== +@babel/plugin-transform-explicit-resource-management@8.0.5 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-explicit-resource-management + Package: https://www.npmjs.com/package/@babel/plugin-transform-explicit-resource-management/v/8.0.5 + Archive: https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-8.0.5.tgz + Integrity: sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw== +@babel/plugin-transform-modules-commonjs@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-modules-commonjs + Package: https://www.npmjs.com/package/@babel/plugin-transform-modules-commonjs/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-8.0.1.tgz + Integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA== +@babel/plugin-transform-react-display-name@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-react-display-name + Package: https://www.npmjs.com/package/@babel/plugin-transform-react-display-name/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-8.0.1.tgz + Integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA== +@babel/plugin-transform-react-jsx@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-react-jsx + Package: https://www.npmjs.com/package/@babel/plugin-transform-react-jsx/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-8.0.1.tgz + Integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw== +@babel/plugin-transform-react-jsx-development@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-react-jsx-development + Package: https://www.npmjs.com/package/@babel/plugin-transform-react-jsx-development/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-8.0.1.tgz + Integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw== +@babel/plugin-transform-react-pure-annotations@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-react-pure-annotations + Package: https://www.npmjs.com/package/@babel/plugin-transform-react-pure-annotations/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-8.0.1.tgz + Integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg== +@babel/plugin-transform-typescript@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/plugin-transform-typescript + Package: https://www.npmjs.com/package/@babel/plugin-transform-typescript/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-8.0.6.tgz + Integrity: sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw== +@babel/preset-react@8.0.1 | development | MIT + Lockfile path: node_modules/@babel/preset-react + Package: https://www.npmjs.com/package/@babel/preset-react/v/8.0.1 + Archive: https://registry.npmjs.org/@babel/preset-react/-/preset-react-8.0.1.tgz + Integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg== +@babel/preset-typescript@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/preset-typescript + Package: https://www.npmjs.com/package/@babel/preset-typescript/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-8.0.7.tgz + Integrity: sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q== +@babel/template@8.0.0 | development | MIT + Lockfile path: node_modules/@babel/template + Package: https://www.npmjs.com/package/@babel/template/v/8.0.0 + Archive: https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz + Integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ== +@babel/traverse@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/traverse + Package: https://www.npmjs.com/package/@babel/traverse/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.7.tgz + Integrity: sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg== +@babel/parser@8.0.7 | development | MIT + Lockfile path: node_modules/@babel/traverse/node_modules/@babel/parser + Package: https://www.npmjs.com/package/@babel/parser/v/8.0.7 + Archive: https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz + Integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA== +@babel/types@8.0.6 | development | MIT + Lockfile path: node_modules/@babel/traverse/node_modules/@babel/types + Package: https://www.npmjs.com/package/@babel/types/v/8.0.6 + Archive: https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz + Integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A== +obug@3.0.0 | development | MIT + Lockfile path: node_modules/@babel/traverse/node_modules/obug + Package: https://www.npmjs.com/package/obug/v/3.0.0 + Archive: https://registry.npmjs.org/obug/-/obug-3.0.0.tgz + Integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw== @babel/types@8.0.4 | runtime | MIT Lockfile path: node_modules/@babel/types Package: https://www.npmjs.com/package/@babel/types/v/8.0.4 @@ -190,6 +405,91 @@ commander@13.1.0 | runtime | MIT Package: https://www.npmjs.com/package/@iconify/types/v/2.0.0 Archive: https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz Integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== +@inquirer/ansi@2.0.9 | development | MIT + Lockfile path: node_modules/@inquirer/ansi + Package: https://www.npmjs.com/package/@inquirer/ansi/v/2.0.9 + Archive: https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz + Integrity: sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw== +@inquirer/checkbox@5.2.6 | development | MIT + Lockfile path: node_modules/@inquirer/checkbox + Package: https://www.npmjs.com/package/@inquirer/checkbox/v/5.2.6 + Archive: https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.6.tgz + Integrity: sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w== +@inquirer/confirm@6.3.3 | development | MIT + Lockfile path: node_modules/@inquirer/confirm + Package: https://www.npmjs.com/package/@inquirer/confirm/v/6.3.3 + Archive: https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz + Integrity: sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A== +@inquirer/core@12.0.4 | development | MIT + Lockfile path: node_modules/@inquirer/core + Package: https://www.npmjs.com/package/@inquirer/core/v/12.0.4 + Archive: https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz + Integrity: sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg== +@inquirer/editor@5.3.4 | development | MIT + Lockfile path: node_modules/@inquirer/editor + Package: https://www.npmjs.com/package/@inquirer/editor/v/5.3.4 + Archive: https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.4.tgz + Integrity: sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ== +@inquirer/expand@5.1.6 | development | MIT + Lockfile path: node_modules/@inquirer/expand + Package: https://www.npmjs.com/package/@inquirer/expand/v/5.1.6 + Archive: https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.6.tgz + Integrity: sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ== +@inquirer/external-editor@3.0.6 | development | MIT + Lockfile path: node_modules/@inquirer/external-editor + Package: https://www.npmjs.com/package/@inquirer/external-editor/v/3.0.6 + Archive: https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.6.tgz + Integrity: sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ== +@inquirer/figures@2.0.9 | development | MIT + Lockfile path: node_modules/@inquirer/figures + Package: https://www.npmjs.com/package/@inquirer/figures/v/2.0.9 + Archive: https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz + Integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg== +@inquirer/input@5.1.7 | development | MIT + Lockfile path: node_modules/@inquirer/input + Package: https://www.npmjs.com/package/@inquirer/input/v/5.1.7 + Archive: https://registry.npmjs.org/@inquirer/input/-/input-5.1.7.tgz + Integrity: sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg== +@inquirer/number@4.2.4 | development | MIT + Lockfile path: node_modules/@inquirer/number + Package: https://www.npmjs.com/package/@inquirer/number/v/4.2.4 + Archive: https://registry.npmjs.org/@inquirer/number/-/number-4.2.4.tgz + Integrity: sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg== +@inquirer/password@5.2.3 | development | MIT + Lockfile path: node_modules/@inquirer/password + Package: https://www.npmjs.com/package/@inquirer/password/v/5.2.3 + Archive: https://registry.npmjs.org/@inquirer/password/-/password-5.2.3.tgz + Integrity: sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw== +@inquirer/prompts@8.7.3 | development | MIT + Lockfile path: node_modules/@inquirer/prompts + Package: https://www.npmjs.com/package/@inquirer/prompts/v/8.7.3 + Archive: https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.3.tgz + Integrity: sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q== +@inquirer/rawlist@5.3.6 | development | MIT + Lockfile path: node_modules/@inquirer/rawlist + Package: https://www.npmjs.com/package/@inquirer/rawlist/v/5.3.6 + Archive: https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.6.tgz + Integrity: sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA== +@inquirer/search@4.3.4 | development | MIT + Lockfile path: node_modules/@inquirer/search + Package: https://www.npmjs.com/package/@inquirer/search/v/4.3.4 + Archive: https://registry.npmjs.org/@inquirer/search/-/search-4.3.4.tgz + Integrity: sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww== +@inquirer/select@5.2.6 | development | MIT + Lockfile path: node_modules/@inquirer/select + Package: https://www.npmjs.com/package/@inquirer/select/v/5.2.6 + Archive: https://registry.npmjs.org/@inquirer/select/-/select-5.2.6.tgz + Integrity: sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g== +@inquirer/type@4.2.0 | development | MIT + Lockfile path: node_modules/@inquirer/type + Package: https://www.npmjs.com/package/@inquirer/type/v/4.2.0 + Archive: https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz + Integrity: sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ== +@jridgewell/gen-mapping@0.4.0-beta.0 | development | MIT + Lockfile path: node_modules/@jridgewell/gen-mapping + Package: https://www.npmjs.com/package/@jridgewell/gen-mapping/v/0.4.0-beta.0 + Archive: https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz + Integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w== @jridgewell/resolve-uri@3.1.2 | runtime | MIT Lockfile path: node_modules/@jridgewell/resolve-uri Package: https://www.npmjs.com/package/@jridgewell/resolve-uri/v/3.1.2 @@ -865,6 +1165,11 @@ commander@13.1.0 | runtime | MIT Package: https://www.npmjs.com/package/@scalar/openapi-types/v/0.8.0 Archive: https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.8.0.tgz Integrity: sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g== +@sec-ant/readable-stream@0.4.1 | development | MIT + Lockfile path: node_modules/@sec-ant/readable-stream + Package: https://www.npmjs.com/package/@sec-ant/readable-stream/v/0.4.1 + Archive: https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz + Integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== @shikijs/core@2.5.0 | development | MIT Lockfile path: node_modules/@shikijs/core Package: https://www.npmjs.com/package/@shikijs/core/v/2.5.0 @@ -905,6 +1210,41 @@ commander@13.1.0 | runtime | MIT Package: https://www.npmjs.com/package/@shikijs/vscode-textmate/v/10.0.2 Archive: https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz Integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg== +@sindresorhus/merge-streams@4.0.0 | development | MIT + Lockfile path: node_modules/@sindresorhus/merge-streams + Package: https://www.npmjs.com/package/@sindresorhus/merge-streams/v/4.0.0 + Archive: https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz + Integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ== +@stryker-mutator/api@10.0.0 | development | Apache-2.0 + Lockfile path: node_modules/@stryker-mutator/api + Package: https://www.npmjs.com/package/@stryker-mutator/api/v/10.0.0 + Archive: https://registry.npmjs.org/@stryker-mutator/api/-/api-10.0.0.tgz + Integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw== +@stryker-mutator/core@10.0.0 | development | Apache-2.0 + Lockfile path: node_modules/@stryker-mutator/core + Package: https://www.npmjs.com/package/@stryker-mutator/core/v/10.0.0 + Archive: https://registry.npmjs.org/@stryker-mutator/core/-/core-10.0.0.tgz + Integrity: sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw== +@stryker-mutator/instrumenter@10.0.0 | development | Apache-2.0 + Lockfile path: node_modules/@stryker-mutator/instrumenter + Package: https://www.npmjs.com/package/@stryker-mutator/instrumenter/v/10.0.0 + Archive: https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-10.0.0.tgz + Integrity: sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ== +@stryker-mutator/typescript-checker@10.0.0 | development | Apache-2.0 + Lockfile path: node_modules/@stryker-mutator/typescript-checker + Package: https://www.npmjs.com/package/@stryker-mutator/typescript-checker/v/10.0.0 + Archive: https://registry.npmjs.org/@stryker-mutator/typescript-checker/-/typescript-checker-10.0.0.tgz + Integrity: sha512-GLvC0LVd8RJ5SxR3eFcgb7DFKmw07DYtPXs942z6Qh7l6p7YBnFyd7lPw7nvA9UKEYDd22embrFmLlyC1XJjXw== +@stryker-mutator/util@10.0.0 | development | Apache-2.0 + Lockfile path: node_modules/@stryker-mutator/util + Package: https://www.npmjs.com/package/@stryker-mutator/util/v/10.0.0 + Archive: https://registry.npmjs.org/@stryker-mutator/util/-/util-10.0.0.tgz + Integrity: sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w== +@stryker-mutator/vitest-runner@10.0.0 | development | Apache-2.0 + Lockfile path: node_modules/@stryker-mutator/vitest-runner + Package: https://www.npmjs.com/package/@stryker-mutator/vitest-runner/v/10.0.0 + Archive: https://registry.npmjs.org/@stryker-mutator/vitest-runner/-/vitest-runner-10.0.0.tgz + Integrity: sha512-SHK2/vfvRUpiz7jXPnQMBnr6zLdm69DK03Mo5mPhaZWcRSygrKUqYsPqWsXsK+5ySHzlMTfCyFK5NQ/X9sJFFw== @toon-format/toon@2.3.1 | runtime | MIT Lockfile path: node_modules/@toon-format/toon Package: https://www.npmjs.com/package/@toon-format/toon/v/2.3.1 @@ -965,11 +1305,21 @@ commander@13.1.0 | runtime | MIT Package: https://www.npmjs.com/package/@types/estree/v/1.0.9 Archive: https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz Integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== +@types/gensync@1.0.5 | development | MIT + Lockfile path: node_modules/@types/gensync + Package: https://www.npmjs.com/package/@types/gensync/v/1.0.5 + Archive: https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz + Integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg== @types/hast@3.0.5 | development | MIT Lockfile path: node_modules/@types/hast Package: https://www.npmjs.com/package/@types/hast/v/3.0.5 Archive: https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz Integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g== +@types/jsesc@2.5.1 | development | MIT + Lockfile path: node_modules/@types/jsesc + Package: https://www.npmjs.com/package/@types/jsesc/v/2.5.1 + Archive: https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz + Integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw== @types/linkify-it@5.0.0 | development | MIT Lockfile path: node_modules/@types/linkify-it Package: https://www.npmjs.com/package/@types/linkify-it/v/5.0.0 @@ -1325,6 +1675,11 @@ algoliasearch@5.59.0 | development | MIT Package: https://www.npmjs.com/package/algoliasearch/v/5.59.0 Archive: https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz Integrity: sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw== +angular-html-parser@10.11.0 | development | MIT + Lockfile path: node_modules/angular-html-parser + Package: https://www.npmjs.com/package/angular-html-parser/v/10.11.0 + Archive: https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.11.0.tgz + Integrity: sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA== ansi-escapes@7.3.0 | development | MIT Lockfile path: node_modules/ansi-escapes Package: https://www.npmjs.com/package/ansi-escapes/v/7.3.0 @@ -1375,6 +1730,11 @@ base64-js@1.5.1 | runtime | MIT Package: https://www.npmjs.com/package/base64-js/v/1.5.1 Archive: https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz Integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +baseline-browser-mapping@2.11.28 | development | Apache-2.0 + Lockfile path: node_modules/baseline-browser-mapping + Package: https://www.npmjs.com/package/baseline-browser-mapping/v/2.11.28 + Archive: https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz + Integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA== bindings@1.5.0 | runtime, optional | MIT Lockfile path: node_modules/bindings Package: https://www.npmjs.com/package/bindings/v/1.5.0 @@ -1405,6 +1765,11 @@ brace-expansion@5.0.12 | runtime | MIT Package: https://www.npmjs.com/package/brace-expansion/v/5.0.12 Archive: https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz Integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== +browserslist@4.29.3 | development | MIT + Lockfile path: node_modules/browserslist + Package: https://www.npmjs.com/package/browserslist/v/4.29.3 + Archive: https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz + Integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA== buffer@6.0.3 | runtime | MIT Lockfile path: node_modules/buffer Package: https://www.npmjs.com/package/buffer/v/6.0.3 @@ -1425,6 +1790,11 @@ call-bound@1.0.4 | runtime | MIT Package: https://www.npmjs.com/package/call-bound/v/1.0.4 Archive: https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz Integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== +caniuse-lite@1.0.30001815 | development | CC-BY-4.0 + Lockfile path: node_modules/caniuse-lite + Package: https://www.npmjs.com/package/caniuse-lite/v/1.0.30001815 + Archive: https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz + Integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q== canonicalize@3.0.0 | runtime | Apache-2.0 Lockfile path: node_modules/canonicalize Package: https://www.npmjs.com/package/canonicalize/v/3.0.0 @@ -1445,6 +1815,11 @@ chai@6.3.0 | development | MIT Package: https://www.npmjs.com/package/chai/v/6.3.0 Archive: https://registry.npmjs.org/chai/-/chai-6.3.0.tgz Integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg== +chalk@5.6.2 | development | MIT + Lockfile path: node_modules/chalk + Package: https://www.npmjs.com/package/chalk/v/5.6.2 + Archive: https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz + Integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== character-entities-html4@2.1.0 | development | MIT Lockfile path: node_modules/character-entities-html4 Package: https://www.npmjs.com/package/character-entities-html4/v/2.1.0 @@ -1455,6 +1830,11 @@ character-entities-legacy@3.0.0 | development | MIT Package: https://www.npmjs.com/package/character-entities-legacy/v/3.0.0 Archive: https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz Integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== +chardet@2.2.0 | development | MIT + Lockfile path: node_modules/chardet + Package: https://www.npmjs.com/package/chardet/v/2.2.0 + Archive: https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz + Integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA== chownr@1.1.4 | runtime, optional | ISC Lockfile path: node_modules/chownr Package: https://www.npmjs.com/package/chownr/v/1.1.4 @@ -1475,6 +1855,11 @@ cli-truncate@5.2.0 | development | MIT Package: https://www.npmjs.com/package/cli-truncate/v/5.2.0 Archive: https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz Integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw== +cli-width@4.1.0 | development | ISC + Lockfile path: node_modules/cli-width + Package: https://www.npmjs.com/package/cli-width/v/4.1.0 + Archive: https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz + Integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== colorette@2.0.20 | development | MIT Lockfile path: node_modules/colorette Package: https://www.npmjs.com/package/colorette/v/2.0.20 @@ -1490,6 +1875,11 @@ commander@14.0.3 | development | MIT Package: https://www.npmjs.com/package/commander/v/14.0.3 Archive: https://registry.npmjs.org/commander/-/commander-14.0.3.tgz Integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== +convert-source-map@2.0.0 | development | MIT + Lockfile path: node_modules/convert-source-map + Package: https://www.npmjs.com/package/convert-source-map/v/2.0.0 + Archive: https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz + Integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== copy-anything@4.1.5 | development | MIT Lockfile path: node_modules/copy-anything Package: https://www.npmjs.com/package/copy-anything/v/4.1.5 @@ -1530,6 +1920,11 @@ dequal@2.0.3 | development | MIT Package: https://www.npmjs.com/package/dequal/v/2.0.3 Archive: https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz Integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +des.js@1.1.0 | development | MIT + Lockfile path: node_modules/des.js + Package: https://www.npmjs.com/package/des.js/v/1.1.0 + Archive: https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz + Integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg== detect-libc@2.1.2 | runtime | Apache-2.0 Lockfile path: node_modules/detect-libc Package: https://www.npmjs.com/package/detect-libc/v/2.1.2 @@ -1545,6 +1940,11 @@ diff@9.0.0 | runtime | BSD-3-Clause Package: https://www.npmjs.com/package/diff/v/9.0.0 Archive: https://registry.npmjs.org/diff/-/diff-9.0.0.tgz Integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw== +diff-match-patch@1.0.5 | development | Apache-2.0 + Lockfile path: node_modules/diff-match-patch + Package: https://www.npmjs.com/package/diff-match-patch/v/1.0.5 + Archive: https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz + Integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== diff3@0.0.3 | runtime | MIT Lockfile path: node_modules/diff3 Package: https://www.npmjs.com/package/diff3/v/0.0.3 @@ -1555,6 +1955,11 @@ dunder-proto@1.0.1 | runtime | MIT Package: https://www.npmjs.com/package/dunder-proto/v/1.0.1 Archive: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz Integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== +electron-to-chromium@1.5.452 | development | ISC + Lockfile path: node_modules/electron-to-chromium + Package: https://www.npmjs.com/package/electron-to-chromium/v/1.5.452 + Archive: https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz + Integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg== emoji-regex@10.6.0 | development | MIT Lockfile path: node_modules/emoji-regex Package: https://www.npmjs.com/package/emoji-regex/v/10.6.0 @@ -1565,6 +1970,11 @@ emoji-regex-xs@1.0.0 | development | MIT Package: https://www.npmjs.com/package/emoji-regex-xs/v/1.0.0 Archive: https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz Integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg== +empathic@2.1.0 | development | MIT + Lockfile path: node_modules/empathic + Package: https://www.npmjs.com/package/empathic/v/2.1.0 + Archive: https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz + Integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw== end-of-stream@1.4.5 | runtime, optional | MIT Lockfile path: node_modules/end-of-stream Package: https://www.npmjs.com/package/end-of-stream/v/1.4.5 @@ -1600,6 +2010,11 @@ es-object-atoms@1.1.2 | runtime | MIT Package: https://www.npmjs.com/package/es-object-atoms/v/1.1.2 Archive: https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz Integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== +escalade@3.2.0 | development | MIT + Lockfile path: node_modules/escalade + Package: https://www.npmjs.com/package/escalade/v/3.2.0 + Archive: https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz + Integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== estree-walker@3.0.3 | development | MIT Lockfile path: node_modules/estree-walker Package: https://www.npmjs.com/package/estree-walker/v/3.0.3 @@ -1635,6 +2050,11 @@ evmole@0.9.3 | runtime | MIT Package: https://www.npmjs.com/package/evmole/v/0.9.3 Archive: https://registry.npmjs.org/evmole/-/evmole-0.9.3.tgz Integrity: sha512-UhO5HMAoEPCupSxvBJPlm/L0Qk/KVJ/OJo3r4EyhXADIEHqU5aTkAzsBfzk4lXGjqxVnaYbh1GPSmZtmSHFnQQ== +execa@9.6.1 | development | MIT + Lockfile path: node_modules/execa + Package: https://www.npmjs.com/package/execa/v/9.6.1 + Archive: https://registry.npmjs.org/execa/-/execa-9.6.1.tgz + Integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA== expand-template@2.0.3 | runtime, optional | (MIT OR WTFPL) Lockfile path: node_modules/expand-template Package: https://www.npmjs.com/package/expand-template/v/2.0.3 @@ -1685,11 +2105,21 @@ fdir@6.5.0 | development | MIT Package: https://www.npmjs.com/package/fdir/v/6.5.0 Archive: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz Integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +figures@6.1.0 | development | MIT + Lockfile path: node_modules/figures + Package: https://www.npmjs.com/package/figures/v/6.1.0 + Archive: https://registry.npmjs.org/figures/-/figures-6.1.0.tgz + Integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== file-uri-to-path@1.0.0 | runtime, optional | MIT Lockfile path: node_modules/file-uri-to-path Package: https://www.npmjs.com/package/file-uri-to-path/v/1.0.0 Archive: https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz Integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +flru@1.0.2 | development | MIT + Lockfile path: node_modules/flru + Package: https://www.npmjs.com/package/flru/v/1.0.2 + Archive: https://registry.npmjs.org/flru/-/flru-1.0.2.tgz + Integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog== focus-trap@7.8.0 | development | MIT Lockfile path: node_modules/focus-trap Package: https://www.npmjs.com/package/focus-trap/v/7.8.0 @@ -1725,6 +2155,11 @@ function-bind@1.1.2 | runtime | MIT Package: https://www.npmjs.com/package/function-bind/v/1.1.2 Archive: https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz Integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +gensync@1.0.0-beta.2 | development | MIT + Lockfile path: node_modules/gensync + Package: https://www.npmjs.com/package/gensync/v/1.0.0-beta.2 + Archive: https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz + Integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== get-east-asian-width@1.6.0 | development | MIT Lockfile path: node_modules/get-east-asian-width Package: https://www.npmjs.com/package/get-east-asian-width/v/1.6.0 @@ -1740,6 +2175,11 @@ get-proto@1.0.1 | runtime | MIT Package: https://www.npmjs.com/package/get-proto/v/1.0.1 Archive: https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz Integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== +get-stream@9.0.1 | development | MIT + Lockfile path: node_modules/get-stream + Package: https://www.npmjs.com/package/get-stream/v/9.0.1 + Archive: https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz + Integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== get-tsconfig@4.14.3 | development | MIT Lockfile path: node_modules/get-tsconfig Package: https://www.npmjs.com/package/get-tsconfig/v/4.14.3 @@ -1805,11 +2245,21 @@ html-void-elements@3.0.0 | development | MIT Package: https://www.npmjs.com/package/html-void-elements/v/3.0.0 Archive: https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz Integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== +human-signals@8.0.1 | development | Apache-2.0 + Lockfile path: node_modules/human-signals + Package: https://www.npmjs.com/package/human-signals/v/8.0.1 + Archive: https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz + Integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ== husky@9.1.7 | development | MIT Lockfile path: node_modules/husky Package: https://www.npmjs.com/package/husky/v/9.1.7 Archive: https://registry.npmjs.org/husky/-/husky-9.1.7.tgz Integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== +iconv-lite@0.7.3 | development | MIT + Lockfile path: node_modules/iconv-lite + Package: https://www.npmjs.com/package/iconv-lite/v/0.7.3 + Archive: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz + Integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== ieee754@1.2.1 | runtime | BSD-3-Clause Lockfile path: node_modules/ieee754 Package: https://www.npmjs.com/package/ieee754/v/1.2.1 @@ -1820,6 +2270,11 @@ ignore@7.0.6 | runtime | MIT Package: https://www.npmjs.com/package/ignore/v/7.0.6 Archive: https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz Integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw== +import-meta-resolve@4.2.0 | development | MIT + Lockfile path: node_modules/import-meta-resolve + Package: https://www.npmjs.com/package/import-meta-resolve/v/4.2.0 + Archive: https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz + Integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg== imurmurhash@0.1.4 | runtime | MIT Lockfile path: node_modules/imurmurhash Package: https://www.npmjs.com/package/imurmurhash/v/0.1.4 @@ -1855,11 +2310,26 @@ is-fullwidth-code-point@5.1.0 | development | MIT Package: https://www.npmjs.com/package/is-fullwidth-code-point/v/5.1.0 Archive: https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz Integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== +is-plain-obj@4.1.0 | development | MIT + Lockfile path: node_modules/is-plain-obj + Package: https://www.npmjs.com/package/is-plain-obj/v/4.1.0 + Archive: https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz + Integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== +is-stream@4.0.1 | development | MIT + Lockfile path: node_modules/is-stream + Package: https://www.npmjs.com/package/is-stream/v/4.0.1 + Archive: https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz + Integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== is-typed-array@1.1.15 | runtime | MIT Lockfile path: node_modules/is-typed-array Package: https://www.npmjs.com/package/is-typed-array/v/1.1.15 Archive: https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz Integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== +is-unicode-supported@2.1.0 | development | MIT + Lockfile path: node_modules/is-unicode-supported + Package: https://www.npmjs.com/package/is-unicode-supported/v/2.1.0 + Archive: https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz + Integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== isarray@2.0.5 | runtime | MIT Lockfile path: node_modules/isarray Package: https://www.npmjs.com/package/isarray/v/2.0.5 @@ -1890,6 +2360,11 @@ jose@6.2.3 | runtime | MIT Package: https://www.npmjs.com/package/jose/v/6.2.3 Archive: https://registry.npmjs.org/jose/-/jose-6.2.3.tgz Integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw== +js-md4@0.3.2 | development | MIT + Lockfile path: node_modules/js-md4 + Package: https://www.npmjs.com/package/js-md4/v/0.3.2 + Archive: https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz + Integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA== js-tokens@10.0.0 | development | MIT Lockfile path: node_modules/js-tokens Package: https://www.npmjs.com/package/js-tokens/v/10.0.0 @@ -1930,11 +2405,26 @@ jscpd-windows-x64-msvc@5.0.12 | development, optional | MIT Package: https://www.npmjs.com/package/jscpd-windows-x64-msvc/v/5.0.12 Archive: https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.0.12.tgz Integrity: sha512-01x1klKjvfzI6Of5tI9u72x7TIQZQLLG6kMdsEPJKl/BXbskdknrfCepeTXRBbdFPKs25jfcd0klZ4OdDvww5w== +jsesc@3.1.0 | development | MIT + Lockfile path: node_modules/jsesc + Package: https://www.npmjs.com/package/jsesc/v/3.1.0 + Archive: https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz + Integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== +json-rpc-2.0@1.8.1 | development | MIT + Lockfile path: node_modules/json-rpc-2.0 + Package: https://www.npmjs.com/package/json-rpc-2.0/v/1.8.1 + Archive: https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.8.1.tgz + Integrity: sha512-BmN42JKn6FI36pcMMzHCXQhpKNEfbeqgSIcUYHKnifq0/4Ey6PDinovwUOgajT7NsgUtpWdDXaCWlpI4LHXN3g== json-schema-traverse@1.0.0 | runtime | MIT Lockfile path: node_modules/json-schema-traverse Package: https://www.npmjs.com/package/json-schema-traverse/v/1.0.0 Archive: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz Integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== +json5@2.2.3 | development | MIT + Lockfile path: node_modules/json5 + Package: https://www.npmjs.com/package/json5/v/2.2.3 + Archive: https://registry.npmjs.org/json5/-/json5-2.2.3.tgz + Integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== jsonc-parser@3.3.1 | runtime | MIT Lockfile path: node_modules/jsonc-parser Package: https://www.npmjs.com/package/jsonc-parser/v/3.3.1 @@ -2020,6 +2510,11 @@ listr2@9.0.5 | development | MIT Package: https://www.npmjs.com/package/listr2/v/9.0.5 Archive: https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz Integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g== +lodash.groupby@4.6.0 | development | MIT + Lockfile path: node_modules/lodash.groupby + Package: https://www.npmjs.com/package/lodash.groupby/v/4.6.0 + Archive: https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz + Integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw== log-update@6.1.0 | development | MIT Lockfile path: node_modules/log-update Package: https://www.npmjs.com/package/log-update/v/6.1.0 @@ -2120,6 +2615,11 @@ mimic-response@3.1.0 | runtime | MIT Package: https://www.npmjs.com/package/mimic-response/v/3.1.0 Archive: https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz Integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== +minimalistic-assert@1.0.1 | development | ISC + Lockfile path: node_modules/minimalistic-assert + Package: https://www.npmjs.com/package/minimalistic-assert/v/1.0.1 + Archive: https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz + Integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== minimatch@10.2.5 | runtime | BlueOak-1.0.0 Lockfile path: node_modules/minimatch Package: https://www.npmjs.com/package/minimatch/v/10.2.5 @@ -2155,6 +2655,31 @@ mkdirp-classic@0.5.3 | runtime, optional | MIT Package: https://www.npmjs.com/package/mkdirp-classic/v/0.5.3 Archive: https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz Integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== +mutation-server-protocol@0.4.1 | development | Apache-2.0 + Lockfile path: node_modules/mutation-server-protocol + Package: https://www.npmjs.com/package/mutation-server-protocol/v/0.4.1 + Archive: https://registry.npmjs.org/mutation-server-protocol/-/mutation-server-protocol-0.4.1.tgz + Integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g== +mutation-testing-elements@3.8.4 | development | Apache-2.0 + Lockfile path: node_modules/mutation-testing-elements + Package: https://www.npmjs.com/package/mutation-testing-elements/v/3.8.4 + Archive: https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.8.4.tgz + Integrity: sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ== +mutation-testing-metrics@3.8.4 | development | Apache-2.0 + Lockfile path: node_modules/mutation-testing-metrics + Package: https://www.npmjs.com/package/mutation-testing-metrics/v/3.8.4 + Archive: https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.8.4.tgz + Integrity: sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw== +mutation-testing-report-schema@3.8.4 | development | Apache-2.0 + Lockfile path: node_modules/mutation-testing-report-schema + Package: https://www.npmjs.com/package/mutation-testing-report-schema/v/3.8.4 + Archive: https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.8.4.tgz + Integrity: sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA== +mute-stream@3.0.0 | development | ISC + Lockfile path: node_modules/mute-stream + Package: https://www.npmjs.com/package/mute-stream/v/3.0.0 + Archive: https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz + Integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw== nanoid@3.3.20 | development | MIT Lockfile path: node_modules/nanoid Package: https://www.npmjs.com/package/nanoid/v/3.3.20 @@ -2170,6 +2695,26 @@ node-abi@3.98.0 | runtime, optional | MIT Package: https://www.npmjs.com/package/node-abi/v/3.98.0 Archive: https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz Integrity: sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw== +node-releases@2.0.58 | development | MIT + Lockfile path: node_modules/node-releases + Package: https://www.npmjs.com/package/node-releases/v/2.0.58 + Archive: https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz + Integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg== +npm-run-path@6.0.0 | development | MIT + Lockfile path: node_modules/npm-run-path + Package: https://www.npmjs.com/package/npm-run-path/v/6.0.0 + Archive: https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz + Integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA== +path-key@4.0.0 | development | MIT + Lockfile path: node_modules/npm-run-path/node_modules/path-key + Package: https://www.npmjs.com/package/path-key/v/4.0.0 + Archive: https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz + Integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== +object-inspect@1.13.4 | development | MIT + Lockfile path: node_modules/object-inspect + Package: https://www.npmjs.com/package/object-inspect/v/1.13.4 + Archive: https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz + Integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== obug@2.2.1 | development | MIT Lockfile path: node_modules/obug Package: https://www.npmjs.com/package/obug/v/2.2.1 @@ -2230,6 +2775,11 @@ pako@1.0.11 | runtime | (MIT AND Zlib) Package: https://www.npmjs.com/package/pako/v/1.0.11 Archive: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz Integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +parse-ms@4.0.0 | development | MIT + Lockfile path: node_modules/parse-ms + Package: https://www.npmjs.com/package/parse-ms/v/4.0.0 + Archive: https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz + Integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== parse5@8.0.1 | runtime | MIT Lockfile path: node_modules/parse5 Package: https://www.npmjs.com/package/parse5/v/8.0.1 @@ -2320,6 +2870,11 @@ prebuild-install@7.1.3 | runtime, optional | MIT Package: https://www.npmjs.com/package/prebuild-install/v/7.1.3 Archive: https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz Integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== +pretty-ms@9.3.1 | development | MIT + Lockfile path: node_modules/pretty-ms + Package: https://www.npmjs.com/package/pretty-ms/v/9.3.1 + Archive: https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz + Integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA== process@0.11.10 | runtime | MIT Lockfile path: node_modules/process Package: https://www.npmjs.com/package/process/v/0.11.10 @@ -2330,6 +2885,11 @@ process-warning@5.0.0 | runtime | MIT Package: https://www.npmjs.com/package/process-warning/v/5.0.0 Archive: https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz Integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== +progress@2.0.3 | development | MIT + Lockfile path: node_modules/progress + Package: https://www.npmjs.com/package/progress/v/2.0.3 + Archive: https://registry.npmjs.org/progress/-/progress-2.0.3.tgz + Integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== property-information@7.2.0 | development | MIT Lockfile path: node_modules/property-information Package: https://www.npmjs.com/package/property-information/v/7.2.0 @@ -2345,6 +2905,11 @@ pure-rand@8.4.2 | development | MIT Package: https://www.npmjs.com/package/pure-rand/v/8.4.2 Archive: https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz Integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng== +qs@6.15.1 | development | BSD-3-Clause + Lockfile path: node_modules/qs + Package: https://www.npmjs.com/package/qs/v/6.15.1 + Archive: https://registry.npmjs.org/qs/-/qs-6.15.1.tgz + Integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== quick-format-unescaped@4.0.4 | runtime | MIT Lockfile path: node_modules/quick-format-unescaped Package: https://www.npmjs.com/package/quick-format-unescaped/v/4.0.4 @@ -2415,6 +2980,11 @@ rollup@4.64.1 | development | MIT Package: https://www.npmjs.com/package/rollup/v/4.64.1 Archive: https://registry.npmjs.org/rollup/-/rollup-4.64.1.tgz Integrity: sha512-7s4V+3J8ux75WNj9qwSJnrWmlCmZ2YHr2fojtHy5RiIMvhJv6DGe7HKKz7L6G1sq1Eg1fLC8SjYtAtDis1F4CQ== +rxjs@7.8.2 | development | Apache-2.0 + Lockfile path: node_modules/rxjs + Package: https://www.npmjs.com/package/rxjs/v/7.8.2 + Archive: https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz + Integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== safe-buffer@5.2.1 | runtime | MIT Lockfile path: node_modules/safe-buffer Package: https://www.npmjs.com/package/safe-buffer/v/5.2.1 @@ -2425,6 +2995,11 @@ safe-stable-stringify@2.5.0 | runtime | MIT Package: https://www.npmjs.com/package/safe-stable-stringify/v/2.5.0 Archive: https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz Integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== +safer-buffer@2.1.2 | development | MIT + Lockfile path: node_modules/safer-buffer + Package: https://www.npmjs.com/package/safer-buffer/v/2.1.2 + Archive: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz + Integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== search-insights@2.17.3 | development | MIT Lockfile path: node_modules/search-insights Package: https://www.npmjs.com/package/search-insights/v/2.17.3 @@ -2460,6 +3035,26 @@ shiki@2.5.0 | development | MIT Package: https://www.npmjs.com/package/shiki/v/2.5.0 Archive: https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz Integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ== +side-channel@1.1.1 | development | MIT + Lockfile path: node_modules/side-channel + Package: https://www.npmjs.com/package/side-channel/v/1.1.1 + Archive: https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz + Integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== +side-channel-list@1.0.1 | development | MIT + Lockfile path: node_modules/side-channel-list + Package: https://www.npmjs.com/package/side-channel-list/v/1.0.1 + Archive: https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz + Integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== +side-channel-map@1.0.1 | development | MIT + Lockfile path: node_modules/side-channel-map + Package: https://www.npmjs.com/package/side-channel-map/v/1.0.1 + Archive: https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz + Integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== +side-channel-weakmap@1.0.2 | development | MIT + Lockfile path: node_modules/side-channel-weakmap + Package: https://www.npmjs.com/package/side-channel-weakmap/v/1.0.2 + Archive: https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz + Integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== signal-exit@4.1.0 | runtime | ISC Lockfile path: node_modules/signal-exit Package: https://www.npmjs.com/package/signal-exit/v/4.1.0 @@ -2495,6 +3090,11 @@ sonic-boom@4.2.1 | runtime | MIT Package: https://www.npmjs.com/package/sonic-boom/v/4.2.1 Archive: https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz Integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q== +source-map@0.7.6 | development | BSD-3-Clause + Lockfile path: node_modules/source-map + Package: https://www.npmjs.com/package/source-map/v/0.7.6 + Archive: https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz + Integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== source-map-js@1.2.2 | development | BSD-3-Clause Lockfile path: node_modules/source-map-js Package: https://www.npmjs.com/package/source-map-js/v/1.2.2 @@ -2555,6 +3155,11 @@ strip-ansi@7.2.0 | development | MIT Package: https://www.npmjs.com/package/strip-ansi/v/7.2.0 Archive: https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz Integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== +strip-final-newline@4.0.0 | development | MIT + Lockfile path: node_modules/strip-final-newline + Package: https://www.npmjs.com/package/strip-final-newline/v/4.0.0 + Archive: https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz + Integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw== strip-json-comments@5.0.3 | development | MIT Lockfile path: node_modules/strip-json-comments Package: https://www.npmjs.com/package/strip-json-comments/v/5.0.3 @@ -2625,16 +3230,26 @@ tokenx@1.3.0 | runtime | MIT Package: https://www.npmjs.com/package/tokenx/v/1.3.0 Archive: https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz Integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ== +tree-kill@1.2.2 | development | MIT + Lockfile path: node_modules/tree-kill + Package: https://www.npmjs.com/package/tree-kill/v/1.2.2 + Archive: https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz + Integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== trim-lines@3.0.1 | development | MIT Lockfile path: node_modules/trim-lines Package: https://www.npmjs.com/package/trim-lines/v/3.0.1 Archive: https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz Integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== -tslib@2.8.1 | development, optional | 0BSD +tslib@2.8.1 | development | 0BSD Lockfile path: node_modules/tslib Package: https://www.npmjs.com/package/tslib/v/2.8.1 Archive: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz Integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tunnel@0.0.6 | development | MIT + Lockfile path: node_modules/tunnel + Package: https://www.npmjs.com/package/tunnel/v/0.0.6 + Archive: https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz + Integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== tunnel-agent@0.6.0 | runtime, optional | Apache-2.0 Lockfile path: node_modules/tunnel-agent Package: https://www.npmjs.com/package/tunnel-agent/v/0.6.0 @@ -2650,6 +3265,16 @@ typed-array-buffer@1.0.3 | runtime | MIT Package: https://www.npmjs.com/package/typed-array-buffer/v/1.0.3 Archive: https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz Integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== +typed-inject@5.0.0 | development | Apache-2.0 + Lockfile path: node_modules/typed-inject + Package: https://www.npmjs.com/package/typed-inject/v/5.0.0 + Archive: https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz + Integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA== +typed-rest-client@2.3.1 | development | MIT + Lockfile path: node_modules/typed-rest-client + Package: https://www.npmjs.com/package/typed-rest-client/v/2.3.1 + Archive: https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.1.tgz + Integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ== typescript@7.0.2 | development | Apache-2.0 Lockfile path: node_modules/typescript Package: https://www.npmjs.com/package/typescript/v/7.0.2 @@ -2660,16 +3285,31 @@ typescript@5.9.3 | development | Apache-2.0 Package: https://www.npmjs.com/package/typescript/v/5.9.3 Archive: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz Integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +typescript@6.0.3 | development | Apache-2.0 + Lockfile path: node_modules/typescript6 + Package: https://www.npmjs.com/package/typescript/v/6.0.3 + Archive: https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz + Integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== unbash@4.0.12 | development | ISC Lockfile path: node_modules/unbash Package: https://www.npmjs.com/package/unbash/v/4.0.12 Archive: https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz Integrity: sha512-PnKvRX+Fbxl/usVlTr/31tXIOVAH+8OdC9qaxUQcvurwEnoc3Vk/ipi2Ce8IjMxzK9byfSGdsUBjqqJGc1o5lw== +underscore@1.13.8 | development | MIT + Lockfile path: node_modules/underscore + Package: https://www.npmjs.com/package/underscore/v/1.13.8 + Archive: https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz + Integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ== undici-types@8.9.0 | development | MIT Lockfile path: node_modules/undici-types Package: https://www.npmjs.com/package/undici-types/v/8.9.0 Archive: https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz Integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== +unicorn-magic@0.3.0 | development | MIT + Lockfile path: node_modules/unicorn-magic + Package: https://www.npmjs.com/package/unicorn-magic/v/0.3.0 + Archive: https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz + Integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA== unist-util-is@6.0.1 | development | MIT Lockfile path: node_modules/unist-util-is Package: https://www.npmjs.com/package/unist-util-is/v/6.0.1 @@ -2695,11 +3335,21 @@ unist-util-visit-parents@6.0.2 | development | MIT Package: https://www.npmjs.com/package/unist-util-visit-parents/v/6.0.2 Archive: https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz Integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ== +update-browserslist-db@1.3.4 | development | MIT + Lockfile path: node_modules/update-browserslist-db + Package: https://www.npmjs.com/package/update-browserslist-db/v/1.3.4 + Archive: https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz + Integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A== util-deprecate@1.0.2 | runtime, optional | MIT Lockfile path: node_modules/util-deprecate Package: https://www.npmjs.com/package/util-deprecate/v/1.0.2 Archive: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz Integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +verkit@0.5.0 | development | MIT + Lockfile path: node_modules/verkit + Package: https://www.npmjs.com/package/verkit/v/0.5.0 + Archive: https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz + Integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g== vfile@6.0.3 | development | MIT Lockfile path: node_modules/vfile Package: https://www.npmjs.com/package/vfile/v/6.0.3 @@ -2880,6 +3530,11 @@ walk-up-path@4.0.0 | development | ISC Package: https://www.npmjs.com/package/walk-up-path/v/4.0.0 Archive: https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz Integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A== +weapon-regex@2.0.6 | development | Apache-2.0 + Lockfile path: node_modules/weapon-regex + Package: https://www.npmjs.com/package/weapon-regex/v/2.0.6 + Archive: https://registry.npmjs.org/weapon-regex/-/weapon-regex-2.0.6.tgz + Integrity: sha512-iJC2vqYp+bh3r4X6zjuP0h65XqXjgUX3nJgmhj0PQMrdhAbUwCfN76CSTEEQqV3G7468kjOJ69PmytTG8l/8Gg== which@2.0.2 | runtime | ISC Lockfile path: node_modules/which Package: https://www.npmjs.com/package/which/v/2.0.2 @@ -2930,6 +3585,11 @@ yaml@2.9.0 | runtime | ISC Package: https://www.npmjs.com/package/yaml/v/2.9.0 Archive: https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz Integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== +yoctocolors@2.2.0 | development | MIT + Lockfile path: node_modules/yoctocolors + Package: https://www.npmjs.com/package/yoctocolors/v/2.2.0 + Archive: https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz + Integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg== zod@4.4.3 | runtime | MIT Lockfile path: node_modules/zod Package: https://www.npmjs.com/package/zod/v/4.4.3 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index 2a0c45d24..a2032f7a7 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,12 +1,12 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 63d5b55e027a0cfb3e77989ca337aca905f1652d10cfbfc4d4dd876d3ecd4f3a +Runtime dependency fingerprint (SHA-256): b0d0151958456c6e72c5720af80d783893be88a7b4c9a409db8505296fe10bd9 -===== @babel/helper-string-parser@8.0.0 ===== +===== @babel/helper-string-parser@8.0.6 ===== Declared license: MIT -Archive: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz -Integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg== +Archive: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz +Integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA== Scope: npm runtime dependency, including optional platform packages. --- package/LICENSE --- @@ -34,11 +34,10 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -===== @babel/helper-validator-identifier@8.0.4 ===== +===== @babel/helper-validator-identifier@8.0.6 ===== Declared license: MIT -Archive: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz -Integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg== +Archive: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz +Integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ== Scope: npm runtime dependency, including optional platform packages. --- package/LICENSE --- @@ -66,7 +65,6 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ===== @babel/parser@8.0.4 ===== Declared license: MIT Archive: https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz @@ -8443,5 +8441,3 @@ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - diff --git a/native/windows/src/authority.hpp b/native/windows/src/authority.hpp index 13d7e4d60..c8bed30dc 100644 --- a/native/windows/src/authority.hpp +++ b/native/windows/src/authority.hpp @@ -123,7 +123,8 @@ napi_value wrap(napi_env env, std::unique_ptr resource); Resource& resource(napi_env env, napi_value value, Kind kind); std::unique_ptr openFile(const std::wstring& path, DWORD access = GENERIC_READ, - bool directory = false); + bool directory = false, + DWORD finalSharing = FILE_SHARE_READ); napi_value identity(napi_env env, HANDLE handle, const std::wstring& path); std::unique_ptr createRuntime(const std::wstring& parent, const std::wstring& prefix); void closeRuntime(Runtime& runtime); diff --git a/native/windows/src/filesystem.cc b/native/windows/src/filesystem.cc index 71c8bbd44..e5129dbb7 100644 --- a/native/windows/src/filesystem.cc +++ b/native/windows/src/filesystem.cc @@ -61,7 +61,8 @@ static Handle openComponent(const std::wstring& path, DWORD access, bool directo return handle; } -std::unique_ptr openFile(const std::wstring& requested, DWORD access, bool directory) { +std::unique_ptr openFile(const std::wstring& requested, DWORD access, bool directory, + DWORD finalSharing) { auto result = std::make_unique(localPath(requested)); result->requestedPath = requested; const auto& path = result->path; @@ -77,7 +78,8 @@ std::unique_ptr openFile(const std::wstring& requested, DWORD access, bool while (offset < path.size()) { auto end = path.find(L'\\', offset); const bool final = end == std::wstring::npos; - auto child = openComponent(path.substr(0, end), final ? access : 0, !final || directory); + auto child = openComponent(path.substr(0, end), final ? access : 0, !final || directory, + final ? finalSharing : FILE_SHARE_READ); // Once the next component is pinned against deletion, its parent cannot // become empty and NTFS cannot convert that parent into a reparse point. // Relax only ancestor write sharing so unrelated child renames can proceed. @@ -238,6 +240,100 @@ static void verifyPrivate(HANDLE handle, const std::wstring& path, bool root) { require(user && system, "Private DACL must allow only current user and SYSTEM", path, ERROR_ACCESS_DENIED); } +static std::unique_ptr openPrivateBridgeDirectory(const std::wstring& path) { + // The retained runtime root handle carries DELETE authority for owned + // cleanup. Windows checks sharing in both directions, so this read-only + // inspection handle must share DELETE even though it requests no such right. + auto directory = openFile(path, FILE_LIST_DIRECTORY, true, + FILE_SHARE_READ | FILE_SHARE_DELETE); + verifyPrivate(directory->get(), path, true); + return directory; +} + +static Handle openPrivateBridgeDescriptor(HANDLE parent, const std::wstring& name, + const std::wstring& path) { + require(!name.empty() && name.find_first_of(L"\\/:\"<>|?*") == std::wstring::npos && + name != L"." && name != L".." && name.size() <= 255, + "Private descriptor requires one filename component", path, ERROR_INVALID_PARAMETER); + UNICODE_STRING coordinate{}; + coordinate.Buffer = const_cast(name.data()); + coordinate.Length = static_cast(name.size() * sizeof(wchar_t)); + coordinate.MaximumLength = coordinate.Length; + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, &coordinate, OBJ_CASE_INSENSITIVE, parent, nullptr); + IO_STATUS_BLOCK observation{}; + HANDLE raw = INVALID_HANDLE_VALUE; + // Immutable runtime files retain their original writer handle so later + // opens cannot acquire write or delete access. This read-only handle must + // share those granted rights for Windows' symmetric share check to pass. + const auto status = NtCreateFile(&raw, GENERIC_READ | READ_CONTROL | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + &attributes, &observation, nullptr, FILE_ATTRIBUTE_NORMAL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, FILE_OPEN, + FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + FILE_NON_DIRECTORY_FILE, + nullptr, 0); + if (status < 0) + throw Failure("Open Cutter bridge descriptor relative to its verified directory failed", path, + RtlNtStatusToDosError(status)); + Handle descriptor(raw); + require(descriptor.valid() && observation.Information == FILE_OPENED, + "Cutter bridge descriptor was not opened as an existing file", path, ERROR_ACCESS_DENIED); + FILE_STANDARD_INFO standard{}; + require(GetFileInformationByHandleEx(descriptor.get(), FileStandardInfo, &standard, sizeof(standard)), + "Read Cutter bridge descriptor metadata failed", path); + require(!standard.Directory && standard.EndOfFile.QuadPart >= 0 && + standard.EndOfFile.QuadPart <= 64 * 1024, + "Cutter bridge descriptor exceeds its byte limit or is not a file", path, ERROR_FILE_TOO_LARGE); + FILE_ATTRIBUTE_TAG_INFO fileAttributes{}; + require(GetFileInformationByHandleEx(descriptor.get(), FileAttributeTagInfo, &fileAttributes, sizeof(fileAttributes)), + "Read Cutter bridge descriptor attributes failed", path); + require((fileAttributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0, + "Cutter bridge descriptor cannot be a reparse point", path, ERROR_REPARSE_TAG_INVALID); + verifyPrivate(descriptor.get(), path, false); + return descriptor; +} + +static napi_value verifyCutterBridgeDirectory(napi_env env, const std::vector& args) { + require(args.size() == 1, "Cutter bridge directory verification requires one path", L"", + ERROR_INVALID_PARAMETER); + const auto path = wide(env, args[0]); + const auto directory = openPrivateBridgeDirectory(path); + auto result = object(env); + set(env, result, "privateDacl", boolean(env, true)); + set(env, result, "identity", identity(env, directory->get(), path)); + return result; +} + +static napi_value readCutterBridgeDescriptor(napi_env env, const std::vector& args) { + require(args.size() == 3, "Cutter bridge descriptor read requires directory, name, and byte limit", L"", + ERROR_INVALID_PARAMETER); + const auto path = wide(env, args[0]); + const auto name = wide(env, args[1]); + const double maximum = numeric(env, args[2]); + require(maximum >= 1 && maximum <= 64 * 1024 && maximum == static_cast(maximum), + "Cutter bridge descriptor limit is outside the supported range", path, ERROR_INVALID_PARAMETER); + auto directory = openPrivateBridgeDirectory(path); + auto descriptor = openPrivateBridgeDescriptor(directory->get(), name, path + L"\\" + name); + FILE_STANDARD_INFO standard{}; + require(GetFileInformationByHandleEx(descriptor.get(), FileStandardInfo, &standard, sizeof(standard)), + "Read Cutter bridge descriptor size failed", path); + require(standard.EndOfFile.QuadPart <= maximum, + "Cutter bridge descriptor exceeds the requested byte limit", path, ERROR_FILE_TOO_LARGE); + std::vector bytes(static_cast(standard.EndOfFile.QuadPart)); + size_t offset = 0; + while (offset < bytes.size()) { + DWORD count = 0; + const DWORD requested = static_cast(std::min(bytes.size() - offset, MAXDWORD)); + require(ReadFile(descriptor.get(), bytes.data() + offset, requested, &count, nullptr) && count > 0, + "Read Cutter bridge descriptor through its verified handle failed", path); + offset += count; + } + napi_value result; + const void* source = bytes.empty() ? static_cast("") : bytes.data(); + check(napi_create_buffer_copy(env, bytes.size(), source, nullptr, &result)); + return result; +} + std::unique_ptr createRuntime(const std::wstring& parentPath, const std::wstring& prefix) { std::unique_ptr parent; try { parent = openFile(parentPath, 0, true); } @@ -559,6 +655,8 @@ static napi_value snapshot(napi_env env, napi_value rootValue, Runtime& root, } napi_value filesystemCall(napi_env env, const std::wstring& operation, const std::vector& args) { + if (operation == L"cutter_bridge_verify_directory") return verifyCutterBridgeDirectory(env, args); + if (operation == L"cutter_bridge_read_descriptor") return readCutterBridgeDescriptor(env, args); const auto expected = operation == L"open" || operation == L"close" || operation == L"runtime_close" || operation == L"runtime_snapshot_cancel" ? 1 : operation == L"runtime_create" || operation == L"runtime_mkdir" || operation == L"runtime_open" ? 2 : 3; require(args.size() == static_cast(expected), "Wrong native filesystem argument count", L"", ERROR_INVALID_PARAMETER); diff --git a/native/windows/src/process.cc b/native/windows/src/process.cc index 80a4d0ac5..42ee9582b 100644 --- a/native/windows/src/process.cc +++ b/native/windows/src/process.cc @@ -464,6 +464,7 @@ napi_value inspect(napi_env env, std::unique_ptr& probe) { set(env, result, "architecture", string(env, std::string("x64"))); set(env, result, "filesystem", filesystem); set(env, result, "privateDacl", boolean(env, true)); + set(env, result, "privateBridgeDescriptorAccess", boolean(env, true)); set(env, result, "atomicJobAssignment", boolean(env, true)); set(env, result, "killOnOwnerClose", boolean(env, true)); set(env, result, "protocolStdin", boolean(env, true)); diff --git a/package-lock.json b/package-lock.json index 7eae3799e..48764a796 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,9 @@ }, "devDependencies": { "@fast-check/vitest": "0.5.0", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", "@types/cross-spawn": "6.0.6", "@types/node": "26.6.4", "@types/semver": "7.7.1", @@ -70,6 +73,7 @@ "turbo": "2.11.7", "typescript": "7.0.2", "typescript-termux": "npm:typescript@5.9.3", + "typescript6": "npm:typescript@6.0.3", "vitepress": "1.6.4", "vitest": "5.0.3" }, @@ -338,24 +342,390 @@ "node": ">= 14.0.0" } }, - "node_modules/@babel/helper-string-parser": { + "node_modules/@babel/code-frame": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", + "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.6", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.7.tgz", + "integrity": "sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.6", + "@babel/generator": "^8.0.6", + "@babel/helper-compilation-targets": "^8.0.7", + "@babel/helpers": "^8.0.7", + "@babel/parser": "^8.0.7", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.7", + "@babel/types": "^8.0.6", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^3.0.0", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.6.tgz", + "integrity": "sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.6", + "@babel/types": "^8.0.6", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.7.tgz", + "integrity": "sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "flru": "1.0.2", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-8.0.7.tgz", + "integrity": "sha512-7ug3KrOhNVPPxFFLTel0/pMzimKzU/vTlNJKVL/dKguCpmNJZjjibyFbWg+Qs8QHmiIZPNuZ2UNnDGdHWGEpOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-member-expression-to-functions": "^8.0.5", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/helper-replace-supers": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/traverse": "^8.0.7", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.6.tgz", + "integrity": "sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-8.0.5.tgz", + "integrity": "sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.5", + "@babel/types": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz", + "integrity": "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-8.0.6.tgz", + "integrity": "sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.6", + "@babel/traverse": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-8.0.0.tgz", + "integrity": "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz", + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-8.0.1.tgz", + "integrity": "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-8.0.0.tgz", + "integrity": "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", "license": "MIT", "engines": { "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", "license": "MIT", "engines": { "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.7.tgz", + "integrity": "sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, "node_modules/@babel/parser": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", @@ -371,6 +741,329 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-8.0.2.tgz", + "integrity": "sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-decorators": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-8.0.1.tgz", + "integrity": "sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-8.0.1.tgz", + "integrity": "sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz", + "integrity": "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-8.0.5.tgz", + "integrity": "sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-8.0.5.tgz", + "integrity": "sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-8.0.1.tgz", + "integrity": "sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-8.0.1.tgz", + "integrity": "sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-8.0.1.tgz", + "integrity": "sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-jsx": "^8.0.1", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-8.0.1.tgz", + "integrity": "sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-8.0.1.tgz", + "integrity": "sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-8.0.6.tgz", + "integrity": "sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-create-class-features-plugin": "^8.0.6", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/plugin-syntax-typescript": "^8.0.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-8.0.1.tgz", + "integrity": "sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-react-display-name": "^8.0.1", + "@babel/plugin-transform-react-jsx": "^8.0.1", + "@babel/plugin-transform-react-jsx-development": "^8.0.1", + "@babel/plugin-transform-react-pure-annotations": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-8.0.7.tgz", + "integrity": "sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-modules-commonjs": "^8.0.1", + "@babel/plugin-transform-typescript": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.7.tgz", + "integrity": "sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.6", + "@babel/generator": "^8.0.6", + "@babel/helper-globals": "^8.0.6", + "@babel/parser": "^8.0.7", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.6", + "obug": "^3.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/@babel/types": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", @@ -579,6 +1272,361 @@ "dev": true, "license": "MIT" }, + "node_modules/@inquirer/ansi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.6.tgz", + "integrity": "sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz", + "integrity": "sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz", + "integrity": "sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.4.tgz", + "integrity": "sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/external-editor": "^3.0.6", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.6.tgz", + "integrity": "sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.6.tgz", + "integrity": "sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.7.tgz", + "integrity": "sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.4.tgz", + "integrity": "sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.3.tgz", + "integrity": "sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.3.tgz", + "integrity": "sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.6", + "@inquirer/confirm": "^6.3.3", + "@inquirer/editor": "^5.3.4", + "@inquirer/expand": "^5.1.6", + "@inquirer/input": "^5.1.7", + "@inquirer/number": "^4.2.4", + "@inquirer/password": "^5.2.3", + "@inquirer/rawlist": "^5.3.6", + "@inquirer/search": "^4.3.4", + "@inquirer/select": "^5.2.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.6.tgz", + "integrity": "sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.4.tgz", + "integrity": "sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.6.tgz", + "integrity": "sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2655,6 +3703,13 @@ "node": ">=22" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@shikijs/core": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", @@ -2742,6 +3797,148 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/api": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-10.0.0.tgz", + "integrity": "sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", + "tslib": "~2.8.0", + "typed-inject": "~5.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/core": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-10.0.0.tgz", + "integrity": "sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@inquirer/prompts": "^8.0.0", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/instrumenter": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "ajv": "~8.20.0", + "chalk": "~5.6.0", + "commander": "~14.0.0", + "diff-match-patch": "1.0.5", + "emoji-regex": "~10.6.0", + "execa": "~9.6.0", + "json-rpc-2.0": "^1.7.0", + "lodash.groupby": "~4.6.0", + "minimatch": "~10.2.4", + "mutation-server-protocol": "~0.4.0", + "mutation-testing-elements": "3.8.4", + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", + "npm-run-path": "~6.0.0", + "progress": "~2.0.3", + "rxjs": "~7.8.1", + "semver": "^7.6.3", + "source-map": "~0.7.4", + "tree-kill": "~1.2.2", + "tslib": "2.8.1", + "typed-inject": "~5.0.0", + "typed-rest-client": "~2.3.0" + }, + "bin": { + "stryker": "bin/stryker.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/instrumenter": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-10.0.0.tgz", + "integrity": "sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "~8.0.0", + "@babel/generator": "~8.0.0", + "@babel/parser": "~8.0.0", + "@babel/plugin-proposal-decorators": "~8.0.0", + "@babel/plugin-transform-explicit-resource-management": "^8.0.0", + "@babel/preset-react": "~8.0.0", + "@babel/preset-typescript": "~8.0.0", + "@babel/traverse": "~8.0.4", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "angular-html-parser": "~10.11.0", + "semver": "~7.8.0", + "tslib": "2.8.1", + "weapon-regex": "~2.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/typescript-checker": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/typescript-checker/-/typescript-checker-10.0.0.tgz", + "integrity": "sha512-GLvC0LVd8RJ5SxR3eFcgb7DFKmw07DYtPXs942z6Qh7l6p7YBnFyd7lPw7nvA9UKEYDd22embrFmLlyC1XJjXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "semver": "~7.8.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@stryker-mutator/core": "10.0.0", + "typescript": ">=3.6" + } + }, + "node_modules/@stryker-mutator/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-10.0.0.tgz", + "integrity": "sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stryker-mutator/vitest-runner": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/vitest-runner/-/vitest-runner-10.0.0.tgz", + "integrity": "sha512-SHK2/vfvRUpiz7jXPnQMBnr6zLdm69DK03Mo5mPhaZWcRSygrKUqYsPqWsXsK+5ySHzlMTfCyFK5NQ/X9sJFFw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "semver": "^7.7.4", + "tslib": "~2.8.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@stryker-mutator/core": "10.0.0", + "vitest": ">=2.0.0" + } + }, "node_modules/@toon-format/toon": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.1.tgz", @@ -2880,6 +4077,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", @@ -2890,6 +4094,13 @@ "@types/unist": "*" } }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -3892,6 +5103,16 @@ "node": ">= 14.0.0" } }, + "node_modules/angular-html-parser": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.11.0.tgz", + "integrity": "sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -4015,6 +5236,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -4099,6 +5333,40 @@ "node": "20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -4170,6 +5438,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/canonicalize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", @@ -4212,6 +5501,19 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -4234,6 +5536,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -4280,6 +5589,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -4308,6 +5627,13 @@ "node": ">=20" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.5.tgz", @@ -4406,6 +5732,17 @@ "node": ">=6" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4439,6 +5776,13 @@ "node": ">=0.3.1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff3": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", @@ -4459,6 +5803,13 @@ "node": ">= 0.4" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -4473,6 +5824,16 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4546,6 +5907,16 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -4614,6 +5985,33 @@ "node": ">=22" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -4731,6 +6129,22 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4738,6 +6152,16 @@ "license": "MIT", "optional": true }, + "node_modules/flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/focus-trap": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", @@ -4827,6 +6251,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -4877,6 +6311,23 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.14.3", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", @@ -5042,6 +6493,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -5058,6 +6519,23 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5087,6 +6565,17 @@ "node": ">= 4" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5171,6 +6660,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -5186,6 +6701,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5251,6 +6779,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -5363,12 +6898,45 @@ "win32" ] }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-rpc-2.0": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.8.1.tgz", + "integrity": "sha512-BmN42JKn6FI36pcMMzHCXQhpKNEfbeqgSIcUYHKnifq0/4Ey6PDinovwUOgajT7NsgUtpWdDXaCWlpI4LHXN3g==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -5745,6 +7313,13 @@ "node": ">=20.0.0" } }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -6026,6 +7601,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -6089,6 +7671,53 @@ "license": "MIT", "optional": true }, + "node_modules/mutation-server-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mutation-server-protocol/-/mutation-server-protocol-0.4.1.tgz", + "integrity": "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.1.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mutation-testing-elements": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.8.4.tgz", + "integrity": "sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mutation-testing-metrics": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.8.4.tgz", + "integrity": "sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-report-schema": "3.8.4" + } + }, + "node_modules/mutation-testing-report-schema": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.8.4.tgz", + "integrity": "sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -6128,6 +7757,59 @@ "node": ">=10" } }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -6380,6 +8062,19 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -6621,6 +8316,22 @@ "node": ">=10" } }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -6646,6 +8357,16 @@ ], "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -6685,6 +8406,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -6889,6 +8626,16 @@ "fsevents": "~2.3.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6918,6 +8665,13 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/search-insights": { "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", @@ -7013,6 +8767,82 @@ "@types/hast": "^3.0.4" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7114,6 +8944,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -7249,6 +9089,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -7416,6 +9269,16 @@ "integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==", "license": "MIT" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7432,8 +9295,17 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -7480,6 +9352,33 @@ "node": ">= 0.4" } }, + "node_modules/typed-inject": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", + "integrity": "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/typed-rest-client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.1.tgz", + "integrity": "sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "6.15.1", + "tunnel": "0.0.6", + "underscore": "^1.13.8" + }, + "engines": { + "node": ">= 16.0.0" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -7530,6 +9429,21 @@ "node": ">=14.17" } }, + "node_modules/typescript6": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbash": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz", @@ -7540,6 +9454,13 @@ "node": ">=14" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -7547,6 +9468,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -7620,6 +9554,37 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7627,6 +9592,19 @@ "license": "MIT", "optional": true }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -8466,6 +10444,13 @@ "node": "20 || >=22" } }, + "node_modules/weapon-regex": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-2.0.6.tgz", + "integrity": "sha512-iJC2vqYp+bh3r4X6zjuP0h65XqXjgUX3nJgmhj0PQMrdhAbUwCfN76CSTEEQqV3G7468kjOJ69PmytTG8l/8Gg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8615,6 +10600,19 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 19e978205..dff275eb4 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,14 @@ "mcp-server", "model-context-protocol", "static-analysis", - "disassembler", + "rea", "decompiler", "mcp", - "hopper" + "hopper", + "electron", + "javascript-analysis", + "ghidra", + "ida" ], "publishConfig": { "access": "public" @@ -45,6 +49,7 @@ "dist", "install.sh", "bridge/hopper_bridge.py", + "bridge/cutter/rea_bridge.py", "bridge/native/NativeUIChildren.swift", "bridge/native/ReaNativeUI.swift", "bridge/native/rea_lldb_tracer.py", @@ -145,6 +150,10 @@ "test:integration": "vitest run --project boundary --project cli-boundary --project process-boundary --project mcp-boundary --project process-global --project acceptance", "pretest:coverage": "npm run test:prepare", "test:coverage": "vitest run --coverage.enabled --coverage.provider=v8", + "test:coverage:reverse-tools": "vitest run --config vitest.reverse-tools.config.ts --coverage.enabled", + "pretest:coverage:reverse-tools": "npm run test:prepare", + "test:mutation:reverse-tools": "node scripts/prepare-reverse-tools-mutation.mjs && stryker run stryker.config.mjs && stryker run stryker.semantics.config.mjs", + "pretest:mutation:reverse-tools": "npm run test:prepare", "test:ci": "npm run test:coverage -- --reporter=default --reporter=junit --outputFile.junit=test-results.xml", "pretest:ci:shard": "npm run test:prepare", "test:ci:shard": "npm run test:ci:shard:run", @@ -253,6 +262,7 @@ "build:windows-native": "node scripts/build-windows-native.mjs", "verify:windows-native:artifact": "node scripts/verify-windows-native-artifact.mjs", "prepublishOnly": "npm run verify:windows-native:artifact", + "verify:cutter": "npm run build:cached && node scripts/verify-real-cutter.mjs", "verify:windows-native": "node scripts/verify-windows-native.mjs", "verify:ghidra:windows:package": "node scripts/verify-packaged-ghidra-windows.mjs", "build:ghidra:nativeaot": "node scripts/build-ghidra-nativeaot.mjs", @@ -344,6 +354,9 @@ }, "devDependencies": { "@fast-check/vitest": "0.5.0", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", "@types/cross-spawn": "6.0.6", "@types/node": "26.6.4", "@types/semver": "7.7.1", @@ -360,6 +373,7 @@ "turbo": "2.11.7", "typescript": "7.0.2", "typescript-termux": "npm:typescript@5.9.3", + "typescript6": "npm:typescript@6.0.3", "vitepress": "1.6.4", "vitest": "5.0.3" }, diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index 86879216d..6e3f950b8 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -62,6 +62,10 @@ function classifyPackage(base, head) { ]), ].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]); for (const script of changed) { + if (/^test:(coverage|mutation):reverse-tools$/.test(script)) { + selected.reverse_tools = true; + continue; + } if (script === "test:sqlite" || script === "test:sqlite:run") { selected.sqlite = true; continue; @@ -162,6 +166,7 @@ if (tier === "reused") { .split("\0") .filter(Boolean); for (const file of files) { + if (matchesAny(file, SCOPES.reverse_tools)) selected.reverse_tools = true; if ( /^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test( file, diff --git a/scripts/ci/required.mjs b/scripts/ci/required.mjs index 01907de37..20fbac23f 100644 --- a/scripts/ci/required.mjs +++ b/scripts/ci/required.mjs @@ -31,6 +31,7 @@ if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length) const required = reused ? ["changes"] : ["changes", "static", "test"]; const lanes = { code: ["build", "test-shard"], + reverse_tools: ["reverse-tools-quality"], runtime: ["windows-native-build", "package-e2e"], package: ["windows-curated"], sqlite: ["sqlite-runtime"], diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index a4f0f0fac..b13057b33 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -2,6 +2,62 @@ // Shared build inputs are classified once in plan.mjs rather than repeated in // every workflow. export const SCOPES = { + reverse_tools: [ + "src/application/reverse/**", + "src/application/binary/BinarySession.ts", + "src/cutter/**", + "src/gdb/**", + "src/frida/frida-bindings.d.ts", + "src/objdump/**", + "src/cli/reverseEngineeringCommands.ts", + "src/process/ownedDebuggerSession.ts", + "src/rizin/**", + "src/contracts/*ToolContracts.ts", + "src/server/registerCutterTools.ts", + "src/server/externalMutationAdmission.ts", + "src/server/registerGdbTools.ts", + "src/server/registerReverseEngineeringTools.ts", + "src/server/providerEnvironment.ts", + "src/process/ExecFileOutput.ts", + "src/process/ExecFileOutput.test.ts", + "tests/composition/analysis-sessions/binarySession.test.ts", + "tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts", + "tests/boundary/filesystem/reverseToolsMutationConfig.test.ts", + "src/server/registerRizinDebugTools.ts", + "tests/boundary/providers/CutterBridge*.test.ts", + "tests/boundary/providers/cutterBridgeProducer.test.ts", + "tests/boundary/providers/GdbSession*.test.ts", + "tests/boundary/providers/GdbCommandQueue.test.ts", + "tests/boundary/providers/gdbTestHelpers.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part*.test.ts", + "tests/boundary/providers/RizinCommandTimeout.test.ts", + "tests/boundary/providers/RizinTimeoutEvidence.test.ts", + "tests/boundary/providers/RizinDebugSession*.test.ts", + "tests/boundary/providers/RizinInferiorTty.test.ts", + "tests/boundary/providers/RizinTestInferiorTty.ts", + "tests/boundary/process/ownedDebuggerSession.test.ts", + "tests/boundary/cli/gdbStartupCleanupCli.test.ts", + "tests/boundary/cli/reverseEngineeringCommands.test.ts", + "tests/boundary/mcp/cutter*.test.ts", + "tests/boundary/mcp/gdbCancellationMcp.test.ts", + "tests/boundary/mcp/debuggerCancellationMcp.test.ts", + "tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts", + "tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts", + "tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts", + "tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts", + "tests/support/reverseEngineering*.ts", + "tests/support/ownedRizinCommandEvidenceTests.ts", + "tests/support/reverseDebuggerMcpTestHelpers.ts", + "tests/processOwnershipRunner.ts", + "tests/composition/reverseEngineering*.test.ts", + "vitest.reverse-tools.config.ts", + "vitest.reverse-tools-semantics.config.ts", + "stryker.config.mjs", + "stryker.semantics.config.mjs", + "tsconfig.reverse-tools-mutation.json", + "scripts/prepare-reverse-tools-mutation.mjs", + "scripts/lib/reverse-tools-mutation-targets.mjs", + ], // Apple artifact CLI/MCP lane on macOS, including its focused regressions. apple: [ "src/native/Apple*", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index d924ef49c..48f90c6e7 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -19,6 +19,11 @@ export const toolFamilyCatalog = (sources) => { contracts: [ ...sources.nativeContracts.NATIVE_TOOL_CONTRACTS, ...sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + ...sources.cutterContracts.CUTTER_TOOL_CONTRACTS, + ...sources.reverseEngineeringContracts + .REVERSE_ENGINEERING_TOOL_CONTRACTS, + ...sources.gdbContracts.GDB_TOOL_CONTRACTS, + ...sources.rizinDebugContracts.RIZIN_DEBUG_TOOL_CONTRACTS, ], }, { @@ -28,6 +33,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, ...sources.evmContracts.EVM_TOOL_CONTRACTS, ...sources.goContracts.GO_TOOL_CONTRACTS, + ...sources.rustContracts.RUST_TOOL_CONTRACTS, ...sources.wasmContracts.WASM_TOOL_CONTRACTS, ...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, ...sources.captureContracts.CAPTURE_TOOL_CONTRACTS, @@ -163,6 +169,32 @@ export const providerCatalog = (sources) => { activeContracts, } = electronContractSlices(sources); return [ + { + identity: sources.cutterProvider.CUTTER_PROVIDER_IDENTITY, + contracts: sources.cutterContracts.CUTTER_TOOL_CONTRACTS, + }, + { + identity: sources.objdumpProvider.OBJDUMP_PROVIDER_IDENTITY, + contracts: + sources.reverseEngineeringContracts.REVERSE_ENGINEERING_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_with_objdump", + ), + }, + { + identity: sources.rizinProvider.RIZIN_PROVIDER_IDENTITY, + contracts: + sources.reverseEngineeringContracts.REVERSE_ENGINEERING_TOOL_CONTRACTS.filter( + ({ name }) => name === "execute_rizin_command", + ), + }, + { + identity: sources.gdbProvider.GDB_PROVIDER_IDENTITY, + contracts: sources.gdbContracts.GDB_TOOL_CONTRACTS, + }, + { + identity: sources.rizinDebugProvider.RIZIN_DEBUGGER_PROVIDER_IDENTITY, + contracts: sources.rizinDebugContracts.RIZIN_DEBUG_TOOL_CONTRACTS, + }, { identity: sources.fridaProvider.FRIDA_PROVIDER_IDENTITY, contracts: sources.fridaContracts.FRIDA_TOOL_CONTRACTS, @@ -225,6 +257,10 @@ export const providerCatalog = (sources) => { identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY, contracts: sources.goContracts.GO_TOOL_CONTRACTS, }, + { + identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY, + contracts: sources.rustContracts.RUST_TOOL_CONTRACTS, + }, { identity: sources.sqliteDomain.SQLITE_PROVIDER_IDENTITY, contracts: sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index f5488a9e6..262493b73 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -43,6 +43,16 @@ const SOURCE_PATHS = { sessionContracts: "dist/contracts/sessionToolContracts.js", binaryDiagnosticsContracts: "dist/contracts/native/binaryDiagnosticsToolContracts.js", + cutterContracts: "dist/contracts/cutterToolContracts.js", + cutterProvider: "dist/cutter/CutterBridgeClient.js", + reverseEngineeringContracts: + "dist/contracts/reverseEngineeringToolContracts.js", + gdbContracts: "dist/contracts/gdbToolContracts.js", + gdbProvider: "dist/gdb/GdbSessionManager.js", + rizinDebugContracts: "dist/contracts/rizinDebugToolContracts.js", + objdumpProvider: "dist/objdump/ObjdumpCommand.js", + rizinProvider: "dist/rizin/RizinCommand.js", + rizinDebugProvider: "dist/rizin/RizinDebugSessionManager.js", fridaContracts: "dist/contracts/fridaToolContracts.js", fridaProvider: "dist/frida/FridaInstrumentationManager.js", pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js", @@ -59,6 +69,8 @@ const SOURCE_PATHS = { evmProvider: "dist/evm/EvmoleRelease.js", goContracts: "dist/contracts/go/goToolContracts.js", goProvider: "dist/go/GoBinaryProvider.js", + rustContracts: "dist/contracts/rust/rustToolContracts.js", + rustProvider: "dist/rust/RustBinaryProvider.js", artifactContracts: "dist/contracts/artifactToolContracts.js", managedContracts: "dist/contracts/managed/managedToolContracts.js", firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js", diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index 160dad7c0..a6c74491c 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -8,6 +8,7 @@ const MIGRATED_PROVIDER_ROOTS = new Set([ "sqlite", "evm", "go", + "rust", "wasm", "captures", "signals", diff --git a/scripts/lib/reverse-tools-mutation-targets.mjs b/scripts/lib/reverse-tools-mutation-targets.mjs new file mode 100644 index 000000000..cccf986ac --- /dev/null +++ b/scripts/lib/reverse-tools-mutation-targets.mjs @@ -0,0 +1,182 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript6"; + +const compact = (text) => text.replace(/\s+/gu, ""); +const nameOf = (node, source) => node.name?.getText(source); + +const methodOwner = (node, source) => { + for (let parent = node; parent !== undefined; parent = parent.parent) { + if (ts.isMethodDeclaration(parent) || ts.isConstructorDeclaration(parent)) + return { + owner: nameOf(parent.parent, source), + body: parent.body, + method: ts.isConstructorDeclaration(parent) + ? "constructor" + : nameOf(parent, source), + }; + } + return undefined; +}; + +const withinOwner = (node, selector, source) => { + if (selector.owner === undefined) return true; + const owner = methodOwner(node, source); + return ( + owner?.owner === selector.owner && + (selector.method === undefined || owner.method === selector.method) + ); +}; + +const contains = (node, predicate) => + predicate(node) || + ts.forEachChild(node, (child) => contains(child, predicate)) === true; + +const inBlockWithBinding = (node, binding) => { + if (binding === undefined) return true; + let block = node.parent; + while (block !== undefined && !ts.isBlock(block)) block = block.parent; + return block?.statements.some( + (statement) => + ts.isVariableStatement(statement) && + statement.declarationList.declarations.some( + (declaration) => + ts.isIdentifier(declaration.name) && + declaration.name.text === binding, + ), + ); +}; + +const isCommandHandler = (node, command) => { + if (!ts.isArrowFunction(node)) return false; + const property = node.parent; + if ( + !ts.isPropertyAssignment(property) || + !ts.isIdentifier(property.name) || + property.name.text !== "run" + ) + return false; + const call = property.parent.parent; + if ( + !ts.isCallExpression(call) || + !ts.isPropertyAccessExpression(call.expression) || + !ts.isIdentifier(call.expression.expression) || + call.expression.expression.text !== "cli" || + call.expression.name.text !== "command" + ) + return false; + const selected = call.arguments[0]; + return ( + selected !== undefined && + ts.isPropertyAccessExpression(selected) && + ts.isIdentifier(selected.expression) && + selected.expression.text === "CLI_COMMANDS" && + selected.name.text === command + ); +}; + +const matches = (node, selector, source) => { + if (!withinOwner(node, selector, source)) return false; + if ( + selector.direct === true && + node.parent !== methodOwner(node, source)?.body + ) + return false; + switch (selector.kind) { + case "expression": + return ( + ts.isBinaryExpression(node) && + compact(node.getText(source)) === compact(selector.text) + ); + case "declaration": + return ( + ts.isVariableDeclaration(node) && + nameOf(node, source) === selector.name && + inBlockWithBinding(node, selector.withinBlockWithBinding) + ); + case "method": + return ( + ts.isMethodDeclaration(node) && nameOf(node, source) === selector.name + ); + case "constructor": + return ts.isConstructorDeclaration(node); + case "guard": + return ( + ts.isIfStatement(node) && + compact(node.expression.getText(source)) === compact(selector.condition) + ); + case "call": + return ( + ts.isCallExpression(node) && + compact(node.expression.getText(source)) === compact(selector.callee) && + (selector.event === undefined || + (node.arguments[0] !== undefined && + ts.isStringLiteral(node.arguments[0]) && + node.arguments[0].text === selector.event)) + ); + case "try": + return ( + ts.isTryStatement(node) && + contains(node.tryBlock, (child) => + selector.assignment !== undefined + ? ts.isBinaryExpression(child) && + child.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(child.left) && + child.left.text === selector.assignment + : ts.isCallExpression(child) && + compact(child.expression.getText(source)) === + compact(selector.callee), + ) + ); + case "command-handler": + return isCommandHandler(node, selector.command); + default: + throw new Error( + `Unknown mutation selector kind: ${String(selector.kind)}`, + ); + } +}; + +/** Parse a source once and resolve one owned region for every requested selector. */ +export const mutationRanges = (path, selectors) => { + if (!Array.isArray(selectors) || selectors.length === 0) + throw new Error(`Mutation selectors must be nonempty: ${path}`); + for (const selector of selectors) + if ( + ["method", "constructor", "guard", "call", "try"].includes( + selector.kind, + ) && + typeof selector.owner !== "string" + ) + throw new Error( + `Mutation selector requires a named class owner: ${path}`, + ); + const source = ts.createSourceFile( + path, + readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + if (source.parseDiagnostics.length !== 0) + throw new Error( + `Cannot select mutations from malformed TypeScript: ${path}`, + ); + const found = selectors.map(() => []); + const visit = (node) => { + for (const [index, selector] of selectors.entries()) + if (matches(node, selector, source)) found[index].push(node); + ts.forEachChild(node, visit); + }; + visit(source); + return selectors.map((selector, index) => { + const candidates = found[index]; + if (candidates.length !== 1) + throw new Error( + `Expected one mutation target in ${path}: ${JSON.stringify(selector)}; found ${candidates.length}`, + ); + const node = candidates[0]; + const first = + source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + const last = source.getLineAndCharacterOfPosition(node.getEnd()).line + 1; + return `${path}:${first}-${last}`; + }); +}; diff --git a/scripts/prepare-reverse-tools-mutation.mjs b/scripts/prepare-reverse-tools-mutation.mjs new file mode 100644 index 000000000..a591138a5 --- /dev/null +++ b/scripts/prepare-reverse-tools-mutation.mjs @@ -0,0 +1,19 @@ +import { mkdir, rm, symlink } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const compiler = resolve(root, "node_modules/typescript6"); + +for (const packageName of ["core", "typescript-checker"]) { + const packagePath = resolve( + root, + "node_modules/@stryker-mutator", + packageName, + ); + const nodeModulesPath = resolve(packagePath, "node_modules"); + const typescriptPath = resolve(nodeModulesPath, "typescript"); + await mkdir(nodeModulesPath, { recursive: true }); + await rm(typescriptPath, { recursive: true, force: true }); + await symlink(compiler, typescriptPath, "dir"); +} diff --git a/scripts/verify-package-discovery.d.mts b/scripts/verify-package-discovery.d.mts index 7c5d27821..1531e9dba 100644 --- a/scripts/verify-package-discovery.d.mts +++ b/scripts/verify-package-discovery.d.mts @@ -1,12 +1,31 @@ -/** Derive agent and Hopper setup support from the corresponding doctor checks. */ +/** + * Host facts the packaged setup support is derived from. + * + * `linuxDistribution` is the distribution record the Hopper installer owner + * parses; it is absent when no distribution could be read, which leaves + * installer applicability unknown rather than unsupported. + */ +export interface SetupHostFacts { + readonly platform: NodeJS.Platform; + readonly linuxDistribution?: { + readonly hopperSetupSupported: boolean; + }; +} + +/** + * Derive agent setup support from the doctor node check and automated Hopper + * installer applicability from the facts their owners parse. Applicability is + * undefined when its owning fact is unavailable. + */ export function packageSetupSupport( checks: | readonly { readonly name: string; readonly ok: boolean }[] | undefined, - platform: NodeJS.Platform, + host: SetupHostFacts, ): { supportedSetupHost: boolean; - hopperSetupSupported: boolean; + hopperReadinessRequired: boolean; + hopperSetupSupported: boolean | undefined; }; /** Validate --help, --llms, and doctor output and determine host setup support. */ @@ -15,5 +34,5 @@ export function verifyPackageDiscovery(input: { readonly environment: Readonly>; }): Promise<{ supportedSetupHost: boolean; - hopperSetupSupported: boolean; + hopperSetupSupported: boolean | undefined; }>; diff --git a/scripts/verify-package-discovery.mjs b/scripts/verify-package-discovery.mjs index 299b9d07c..9f2a5d7a8 100644 --- a/scripts/verify-package-discovery.mjs +++ b/scripts/verify-package-discovery.mjs @@ -27,15 +27,56 @@ const REQUIRED_LLM_TOPICS = [ "analyze-javascript-application", ]; -/** Derive agent and Hopper setup support from the corresponding doctor checks. */ -export function packageSetupSupport(checks, platform) { +/** + * Derive agent setup support from the doctor node check and automated Hopper + * installer applicability from the facts their owners parse. + * + * Host readiness and installer applicability are different facts: a + * distribution REA supports can still be absent from the Hopper package's + * tested list, so the doctor host check cannot answer the second question. + * Applicability stays undefined when its owning fact is unavailable, which is + * distinct from a host known to lack an automated installer. + */ +export function packageSetupSupport(checks, host) { const supportedSetupHost = checks?.find(({ name }) => name === "node")?.ok === true; - const hopperSetupSupported = - supportedSetupHost && - checks?.find(({ name }) => name === "host")?.ok === true && - platform !== "win32"; - return { supportedSetupHost, hopperSetupSupported }; + const hopperReadinessRequired = + host.platform !== "win32" && + checks?.find(({ name }) => name === "host")?.ok === true; + return { + supportedSetupHost, + hopperReadinessRequired, + hopperSetupSupported: hopperInstallerSupported( + host, + checks?.find(({ name }) => name === "host")?.ok, + ), + }; +} + +/** + * Whether `rea setup --install-hopper` can install Hopper automatically here. + * On macOS the doctor host check already is the owner's installer predicate + * (macOS 12 or later); on Linux it reports REA host support instead, so the + * distribution's own parsed applicability governs. + */ +function hopperInstallerSupported(host, hostCheckOk) { + if (host.platform === "win32") return false; + if (host.platform === "linux") + return host.linuxDistribution?.hopperSetupSupported; + return hostCheckOk; +} + +/** + * The distribution facts the Hopper installer owner parses, taken from the + * built application so the verifier follows the predicate the package ships. + * Loaded only where a distribution can exist, which also keeps the derivation + * above testable without a build. + */ +async function readHostLinuxDistribution() { + if (process.platform !== "linux") return undefined; + const { readLinuxDistribution } = + await import("../dist/application/LinuxHopper.js"); + return readLinuxDistribution(); } /** Validate --help, --llms, and doctor output and determine host setup support. */ @@ -48,10 +89,11 @@ export async function verifyPackageDiscovery({ cli, environment }) { environment, ); const doctor = json(doctorExecution.stdout); - const { supportedSetupHost, hopperSetupSupported } = packageSetupSupport( - doctor.checks, - process.platform, - ); + const { supportedSetupHost, hopperReadinessRequired, hopperSetupSupported } = + packageSetupSupport(doctor.checks, { + platform: process.platform, + linuxDistribution: await readHostLinuxDistribution(), + }); const hopperReady = doctor.checks?.find(({ name }) => name === "hopper")?.ok; const expectedDoctorHealth = doctor.checks?.every(({ ok }) => ok) === true; const missingHelp = REQUIRED_HELP_COMMANDS.filter( @@ -65,7 +107,7 @@ export async function verifyPackageDiscovery({ cli, environment }) { missingLlms.length !== 0 || doctor.healthy !== expectedDoctorHealth || doctorExecution.status !== (expectedDoctorHealth ? 0 : 1) || - (hopperSetupSupported && hopperReady !== true) + (hopperReadinessRequired && hopperReady !== true) ) throw new Error( `packaged CLI discovery or doctor failed: ${JSON.stringify({ helpSetup: help.includes("setup"), llmsDecompile: llms.includes("decompile"), doctor })}`, diff --git a/scripts/verify-package-platform.d.mts b/scripts/verify-package-platform.d.mts index 4342027c2..631ddc93b 100644 --- a/scripts/verify-package-platform.d.mts +++ b/scripts/verify-package-platform.d.mts @@ -8,4 +8,10 @@ export function assertLinuxPackageProviderFailure(execution: { export function verifyPackagePlatform(input: { readonly cli: string; readonly environment: Readonly>; + readonly platform?: string; + readonly runCommand?: ( + command: string, + arguments_: readonly string[], + environment: Readonly>, + ) => Promise; }): Promise; diff --git a/scripts/verify-package-platform.mjs b/scripts/verify-package-platform.mjs index b4db6bf16..253937688 100644 --- a/scripts/verify-package-platform.mjs +++ b/scripts/verify-package-platform.mjs @@ -1,8 +1,13 @@ import { json, run, runWithStatus } from "./lib/verify-package-core.mjs"; /** Run platform-specific deep-analysis smoke tests. */ -export async function verifyPackagePlatform({ cli, environment }) { - if (process.platform === "linux") { +export async function verifyPackagePlatform({ + cli, + environment, + platform = process.platform, + runCommand = run, +}) { + if (platform === "linux") { const unsupportedExecution = await runWithStatus( cli, ["analyze", process.execPath, "--json"], @@ -11,25 +16,27 @@ export async function verifyPackagePlatform({ cli, environment }) { assertLinuxPackageProviderFailure(unsupportedExecution); } else { const overview = json( - await run(cli, ["analyze", process.execPath, "--json"], environment), + await runCommand( + cli, + ["analyze", process.execPath, "--json"], + environment, + ), ); - if ( - overview.operation !== "binary_overview" || - overview.normalized_result?.procedure_count < 1 - ) + if (!hasPositiveProcedureCount(overview)) throw new Error( `packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`, ); const inspected = json( - await run(cli, ["inspect", process.execPath, "--json"], environment), + await runCommand( + cli, + ["inspect", process.execPath, "--json"], + environment, + ), ); - if ( - inspected.operation !== "binary_overview" || - inspected.normalized_result?.procedure_count < 1 - ) + if (!hasPositiveProcedureCount(inspected)) throw new Error("packaged inspect CLI failed"); const functionResult = json( - await run( + await runCommand( cli, ["function", process.execPath, "0x1000", "--json"], environment, @@ -44,7 +51,7 @@ export async function verifyPackagePlatform({ cli, environment }) { `packaged function CLI failed: ${JSON.stringify(functionResult)}`, ); const xrefs = json( - await run( + await runCommand( cli, ["xrefs", process.execPath, "0x1000", "--json"], environment, @@ -56,7 +63,7 @@ export async function verifyPackagePlatform({ cli, environment }) { ) throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`); const trace = json( - await run( + await runCommand( cli, ["trace", process.execPath, "fixture", "--json"], environment, @@ -93,3 +100,10 @@ export const assertLinuxPackageProviderFailure = (execution) => { `packaged Linux Hopper verification did not fail closed: ${JSON.stringify(failure)}`, ); }; + +const hasPositiveProcedureCount = (result) => + typeof result === "object" && + result !== null && + result.operation === "binary_overview" && + Number.isInteger(result.normalized_result?.procedure_count) && + result.normalized_result.procedure_count > 0; diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index b415965e0..9a87a1695 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -356,9 +356,8 @@ const verifySetupApply = async ({ hopperSetupSupported, }) => { const status = "ready"; - const hopperStatus = hopperSetupSupported - ? "needs_confirmation" - : "needs_human"; + const hopperStatus = + hopperSetupSupported === true ? "needs_confirmation" : "needs_human"; const firstExecution = await runWithStatus( cli, ["setup", "--yes", "--all-detected", "--json"], diff --git a/scripts/verify-packed-bridge.mjs b/scripts/verify-packed-bridge.mjs index 0ed18eaa3..b83dc7715 100644 --- a/scripts/verify-packed-bridge.mjs +++ b/scripts/verify-packed-bridge.mjs @@ -4,6 +4,7 @@ import { promisify } from "node:util"; const exec = promisify(execFile); const packedHopperBridge = "package/bridge/hopper_bridge.py"; +const packedCutterBridge = "package/bridge/cutter/rea_bridge.py"; const packedGhidraBridge = "package/bridge/ghidra/ReaGhidraBridge.java"; const packedGhidraActivator = "package/bridge/ghidra/ReaGhidraBundleActivator.java"; @@ -12,6 +13,7 @@ const packedGhidraActivator = export async function verifyPackedBridge({ workspace, tarball, packedFiles }) { for (const path of [ packedHopperBridge, + packedCutterBridge, packedGhidraBridge, packedGhidraActivator, ]) { diff --git a/scripts/verify-real-cutter.mjs b/scripts/verify-real-cutter.mjs new file mode 100644 index 000000000..5ffde438d --- /dev/null +++ b/scripts/verify-real-cutter.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { execFile } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + access, + constants, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { createServer } from "../dist/server/createServer.js"; + +const execFileAsync = promisify(execFile); +const cli = resolve("scripts/rea.mjs"); +const runCli = async (...arguments_) => { + const { stdout } = await execFileAsync( + process.execPath, + [cli, ...arguments_, "--format", "json"], + { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + env: process.env, + }, + ); + return JSON.parse(stdout); +}; + +const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-")); +const projectPath = join(projectRoot, "smoke.rzdb"); +const rizinQuote = (value) => + `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +const unavailableAnalysis = { + execute: async () => { + throw new Error( + "The Cutter verification does not call unrelated analysis tools.", + ); + }, +}; +const expectedPlatform = { + Linux: "linux", + macOS: "darwin", + Windows: "win32", +}[process.env.EXPECTED_PLATFORM]; +if (expectedPlatform !== undefined) + assert.equal( + process.platform, + expectedPlatform, + "Cutter runner did not match its declared platform.", + ); +try { + const discovery = await runCli("list-cutter-sessions"); + assert.equal( + discovery.discovery_status, + "sessions_found", + "Open upstream Cutter with the REA plugin before running this smoke test.", + ); + assert.ok(discovery.sessions.length > 0); + const session = discovery.sessions[0]; + assert.ok(session); + assert.ok( + session.cutter_version, + "The upstream Cutter API did not report its version.", + ); + if (process.env.REA_CUTTER_EXPECTED_VERSION !== undefined) + assert.ok( + session.cutter_version.includes(process.env.REA_CUTTER_EXPECTED_VERSION), + "The live Cutter version did not match the configured version.", + ); + assert.ok( + session.current_file, + "Open a disposable local binary in Cutter before running this smoke test.", + ); + const artifactPath = session.current_file; + const artifactDigest = createHash("sha256") + .update(await readFile(artifactPath)) + .digest("hex"); + + const inspection = await runCli( + "cutter-command", + session.session_id, + String(session.document_generation), + "ij", + "--json", + ); + assert.equal(inspection.predicate_type, "rea.cutter.command-observation"); + assert.ok( + Object.hasOwn(inspection, "raw_result"), + "Cutter ij result was not retained as Evidence.", + ); + assert.equal(typeof inspection.raw_result.bin?.arch, "string"); + + const mcpServer = createServer( + { kind: "fixed", analysis: unavailableAnalysis }, + { + providerEnvironment: process.env, + }, + ); + const mcpClient = new Client({ + name: "rea-cutter-upstream-smoke", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await mcpServer.connect(serverTransport); + await mcpClient.connect(clientTransport); + const mcpDiscovery = await mcpClient.callTool({ + name: "list_cutter_sessions", + arguments: {}, + }); + assert.notEqual(mcpDiscovery.isError, true); + assert.equal( + mcpDiscovery.structuredContent.discovery_status, + "sessions_found", + ); + const mcpInspection = await mcpClient.callTool({ + name: "cutter_command", + arguments: { + session_id: session.session_id, + expected_generation: session.document_generation, + command: "ij", + json: true, + }, + }); + assert.notEqual(mcpInspection.isError, true); + assert.deepEqual( + mcpInspection.structuredContent.normalized_result, + inspection.normalized_result, + "Cutter CLI and MCP returned different analysis results.", + ); + } finally { + await mcpClient.close(); + await mcpServer.close(); + } + + const annotation = `rea-smoke-${randomUUID()}`; + await runCli( + "cutter-command", + session.session_id, + String(session.document_generation), + `CC ${annotation} @ entry0`, + ); + await runCli( + "cutter-command", + session.session_id, + String(session.document_generation), + `Ps ${rizinQuote(projectPath)}`, + ); + await access(projectPath, constants.R_OK | constants.W_OK); + await runCli( + "cutter-command", + session.session_id, + String(session.document_generation), + "CC- @ entry0", + ); + const removed = await runCli( + "cutter-command", + session.session_id, + String(session.document_generation), + "CC. @ entry0", + ); + assert.ok( + !String(removed.raw_result).includes(annotation), + "The in-memory annotation was not removed before project reopening.", + ); + await runCli( + "cutter-command", + session.session_id, + String(session.document_generation), + `Po ${rizinQuote(projectPath)}`, + ); + const recovered = await runCli( + "cutter-command", + session.session_id, + String(session.document_generation), + "CC. @ entry0", + ); + assert.equal(recovered.predicate_type, "rea.cutter.command-observation"); + assert.ok( + String(recovered.raw_result).includes(annotation), + "The saved project did not preserve the analysis comment after reopening.", + ); + const finalArtifactDigest = createHash("sha256") + .update(await readFile(artifactPath)) + .digest("hex"); + assert.equal( + finalArtifactDigest, + artifactDigest, + "Cutter project edits changed the executable bytes.", + ); + + const report = { + ok: true, + platform: process.platform, + cutter_version: session.cutter_version, + document_generation: session.document_generation, + cli_mcp_evidence_parity: true, + annotation_saved_and_reopened: true, + executable_digest_unchanged: true, + }; + await writeFile("cutter-smoke.json", `${JSON.stringify(report, null, 2)}\n`, { + mode: 0o600, + }); + console.log(JSON.stringify(report)); +} finally { + await rm(projectRoot, { recursive: true, force: true }); +} diff --git a/scripts/verify-signal-recording.mjs b/scripts/verify-signal-recording.mjs index f5baaf0b9..2d3910e62 100644 --- a/scripts/verify-signal-recording.mjs +++ b/scripts/verify-signal-recording.mjs @@ -276,12 +276,41 @@ try { ) ).stdout.split(/\r?\n/); if (nativeHelp.at(-1) === "") nativeHelp.pop(); + const nativeCatalogText = ( + await execute( + process.env.REA_SIGROK_COMMAND, + ["--dont-scan", "--list-supported"], + { env: environment, timeout: 30000, maxBuffer: 4 * 1024 * 1024 }, + ) + ).stdout; + const catalogHeading = "Supported protocol decoders:\n"; + assert.ok(nativeCatalogText.includes(catalogHeading)); + // Independent native-output oracle, without importing the production parser. + const nativeCatalog = nativeCatalogText + .split(catalogHeading)[1] + .trim() + .split("\n") + .map((line) => { + const record = line.trimStart(); + const separator = record.indexOf(" "); + assert.ok(separator > 0); + return { + id: record.slice(0, separator), + name: record.slice(separator).trimStart(), + }; + }); + assert.ok(nativeCatalog.length > 0); for (const input of [ - { query: "uart" }, + {}, + { query: "UaRt" }, { limit: 1 }, { offset: 1, limit: 2 }, + { limit: null }, + { offset: nativeCatalog.length, limit: 2 }, + { decoder: "uart" }, { decoder: "uart", query: "BaUd" }, { decoder: "uart", offset: 50, limit: null }, + { decoder: "uart", query: "[" }, { query: "rea_no_such_decoder" }, ]) { const started = performance.now(); @@ -306,23 +335,28 @@ try { JSON.stringify(response), ), }); - if (input.decoder) { - const matched = nativeHelp - .map((text, i) => ({ line_number: i + 1, text })) - .filter(({ text }) => - text.toLowerCase().includes((input.query ?? "").toLowerCase()), - ); - assert.deepEqual( - report.help_lines, - matched.slice( - input.offset ?? 0, - input.limit === null - ? undefined - : (input.offset ?? 0) + (input.limit ?? 50), - ), - ); - } else if (input.query === "uart") - assert.ok(report.decoders.some(({ id }) => id === "uart")); + const nativeRecords = input.decoder + ? nativeHelp.map((text, i) => ({ line_number: i + 1, text })) + : nativeCatalog; + const matched = nativeRecords.filter((record) => + ("text" in record ? record.text : `${record.id} ${record.name}`) + .toLowerCase() + .includes((input.query ?? "").toLowerCase()), + ); + const offset = input.offset ?? 0; + const page = matched.slice( + offset, + input.limit === null ? undefined : offset + (input.limit ?? 50), + ); + assert.deepEqual(input.decoder ? report.help_lines : report.decoders, page); + assert.deepEqual(input.decoder ? report.decoders : report.help_lines, []); + assert.deepEqual(report.output_window, { + unit: input.decoder ? "help-lines" : "decoders", + examined: nativeRecords.length, + matching: matched.length, + returned: page.length, + has_more: offset + page.length < matched.length, + }); const flags = Object.entries(input).flatMap(([key, value]) => value === null ? ["--all"] : [`--${key}`, String(value)], ); diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 305063ccc..5123d3400 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -190,12 +190,21 @@ try { const candidate = join(candidateRoot, "module.wasm"); await writeFile(join(candidateRoot, "source.wat"), "(module (memory 2))"); await tool("wat2wasm", ["source.wat", "-o", "module.wasm"], candidateRoot); - const glue = join(fixtureRoot, "glue.js"); - await writeFile( - glue, - 'fetch("./module.wasm"); new URL("https://example.invalid/module.wasm?version=1", import.meta.url);', + const glueSource = + 'fetch("./module.wasm"); new URL("https://example.invalid/module.wasm?version=1", import.meta.url);'; + const glueSources = [ + glueSource, + `\uFEFF${glueSource}`, + `\uFEFF\n${glueSource}`, + `#!/usr/bin/env node\n${glueSource}`, + `\uFEFF#!/usr/bin/env node\n${glueSource}`, + ]; + const gluePaths = glueSources.map((_, index) => + join(fixtureRoot, `glue-${index}.js`), ); - const input = { path, glue_paths: [glue], candidate_paths: [candidate] }; + for (const [index, gluePath] of gluePaths.entries()) + await writeFile(gluePath, glueSources[index]); + const input = { path, glue_paths: gluePaths, candidate_paths: [candidate] }; assert.ok(ajv.validate(advertised.inputSchema, input)); const cliValue = await inspectCli(input); assert.equal(cliValue.category, undefined, JSON.stringify(cliValue)); @@ -219,11 +228,31 @@ try { assert.equal(report.wat.sha256, sha256(wat)); assert.equal(report.headers, `${headers}\n`); assert.equal(report.details, details.slice(1)); - assert.equal(report.glue[0].artifact.sha256, sha256(await readFile(glue))); - assert.deepEqual( - report.glue[0].references.map(({ candidate_paths }) => candidate_paths), - [[path], [path, candidate]], - ); + assert.equal(report.glue.length, glueSources.length); + for (const [index, source] of glueSources.entries()) { + const observed = report.glue[index]; + assert.equal(observed.artifact.path, gluePaths[index]); + assert.equal(observed.artifact.sha256, sha256(source)); + assert.equal(observed.artifact.bytes, Buffer.byteLength(source)); + assert.equal(await readFile(gluePaths[index], "utf8"), source); + assert.equal(observed.parse_status, "complete"); + assert.deepEqual( + observed.references.map(({ candidate_paths }) => candidate_paths), + [[path], [path, candidate]], + ); + assert.deepEqual( + observed.references.map(({ line, column }) => ({ line, column })), + ["./module.wasm", "https://example.invalid/module.wasm?version=1"].map( + (value) => { + const offset = source.indexOf(`"${value}"`); + return { + line: source.slice(0, offset).split("\n").length, + column: offset - source.lastIndexOf("\n", offset) - 1, + }; + }, + ), + ); + } assert.notEqual(report.candidates[0].sha256, report.candidates[1].sha256); for (const command of report.tool_profile.commands) assert.equal(command.sha256, sha256(await readFile(command.path))); diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index 0a42cb604..fb4226bf4 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -148,6 +148,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe "//localhost/share/target.bin", ]) assert.throws(() => native.call("open", [rejected])); + assert.throws( + () => native.call("cutter_bridge_verify_directory", [sourceDirectory]), + /owner|DACL/u, + ); report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true; // An owned runtime's DELETE lease conflicts with a fresh parent admission. // This produces a genuine sharing failure after successful authority caching, @@ -282,6 +286,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe () => native.call("open", [join(junction, "target.bin")]), /Reparse/u, ); + assert.throws( + () => native.call("cutter_bridge_verify_directory", [junction]), + /Reparse/u, + ); assert.throws(() => native.call("open", [source + ":stream"])); const runtime = native.call("runtime_create", [ workspace.replaceAll("\\", "/"), @@ -326,6 +334,47 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe "descriptor.json", Buffer.from("fixture"), ]); + assert.equal( + native.call("cutter_bridge_verify_directory", [runtime.path]).privateDacl, + true, + ); + assert.equal( + native + .call("cutter_bridge_read_descriptor", [ + runtime.path, + "descriptor.json", + 64 * 1024, + ]) + .toString("utf8"), + "fixture", + ); + const descriptorJunction = join(runtime.path, "descriptor-link.json"); + await symlink(sourceDirectory, descriptorJunction, "junction"); + assert.throws(() => + native.call("cutter_bridge_read_descriptor", [ + runtime.path, + "descriptor-link.json", + 64 * 1024, + ]), + ); + assert.throws( + () => + native.call("cutter_bridge_read_descriptor", [ + runtime.path, + "descriptor.json", + 3, + ]), + /byte limit/u, + ); + assert.throws( + () => + native.call("cutter_bridge_read_descriptor", [ + runtime.path, + "..\\escape.json", + 64 * 1024, + ]), + /filename component/u, + ); assert.throws(() => native.call("runtime_write", [ runtime.handle, diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index bce989aca..efe3eb3fa 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1707,9 +1707,7 @@ export class AdbProvider { }, provider, { - limitations: [ - "Push writes the caller's file to the device; this is the only device-mutating ADB tool", - ], + limitations: ["Push writes the caller's file to the device"], }, ), ); @@ -1731,7 +1729,7 @@ export class AdbProvider { { limitations: [ "The device did not answer sha256sum, so the transfer is not device-side verified", - "Push writes the caller's file to the device; this is the only device-mutating ADB tool", + "Push writes the caller's file to the device", ], }, ), diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index b07364adf..348454e72 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -40,10 +40,14 @@ import { } from "../domain/zipPackageFormat.js"; import { + parseCodeViewData, parseExecutableHeader, parsePeRecord, + peDebugDirectoryEntry, + peRvaToFileOffset, type ExecutableMetadata, } from "../domain/binaryTarget.js"; +import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js"; /** Options for resolving a local binary target and its stable identity. */ export interface BinaryTargetResolverOptions { @@ -549,10 +553,98 @@ const readPeMetadata = async ( return err(`unsupported ${windowsHeader.signature} executable in MZ image`); const optionalHeaderSize = fileHeader.readUInt16LE(20); if (optionalHeaderSize > 4096) return err("invalid PE optional header size"); + const numberOfSections = fileHeader.readUInt16LE(6); const record = Buffer.alloc(24 + optionalHeaderSize); const recordRead = await handle.read(record, 0, record.length, offset); throwIfTargetResolutionCancelled(signal); - return recordRead.bytesRead === record.length - ? parsePeRecord(record) - : err("invalid or truncated PE header"); + if (recordRead.bytesRead !== record.length) + return err("invalid or truncated PE header"); + const codeView = await readPeCodeView( + handle, + offset, + numberOfSections, + record.subarray(24), + optionalHeaderSize, + signal, + ); + return parsePeRecord(record, codeView); +}; + +const readPeCodeView = async ( + handle: FileHandle, + peOffset: number, + numberOfSections: number, + optionalHeader: Buffer, + optionalHeaderSize: number, + signal?: AbortSignal, +): Promise => { + throwIfTargetResolutionCancelled(signal); + const debugDir = peDebugDirectoryEntry(optionalHeader); + if (debugDir === undefined || numberOfSections > 96) return undefined; + const sectionTableOffset = peOffset + 24 + optionalHeaderSize; + const sectionTableSize = numberOfSections * 40; + const sectionTable = Buffer.alloc(sectionTableSize); + const sectionRead = await handle.read( + sectionTable, + 0, + sectionTableSize, + sectionTableOffset, + ); + throwIfTargetResolutionCancelled(signal); + if (sectionRead.bytesRead !== sectionTableSize) return undefined; + const debugFileOffset = peRvaToFileOffset( + debugDir.debugRva, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(debugDir.debugSize, 4096), + ); + if (debugFileOffset === undefined) return undefined; + const maxDebugSize = Math.min(debugDir.debugSize, 4096); + const debugDirectoryBuffer = Buffer.alloc(maxDebugSize); + const debugDirRead = await handle.read( + debugDirectoryBuffer, + 0, + maxDebugSize, + debugFileOffset, + ); + throwIfTargetResolutionCancelled(signal); + const numEntries = Math.floor(debugDirRead.bytesRead / 28); + for (let j = 0; j < numEntries; j++) { + const entryOffset = j * 28; + const type = debugDirectoryBuffer.readUInt32LE(entryOffset + 12); + if (type !== 2) continue; // IMAGE_DEBUG_TYPE_CODEVIEW + const sizeOfData = debugDirectoryBuffer.readUInt32LE(entryOffset + 16); + let pointerToRawData = debugDirectoryBuffer.readUInt32LE(entryOffset + 24); + if (pointerToRawData === 0) { + const addressOfRawData = debugDirectoryBuffer.readUInt32LE( + entryOffset + 20, + ); + const mapped = peRvaToFileOffset( + addressOfRawData, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(sizeOfData, 4096), + ); + if (mapped === undefined) continue; + pointerToRawData = mapped; + } + if (sizeOfData < 16) continue; + const maxCodeViewRead = Math.min(sizeOfData, 4096); + const codeViewBuffer = Buffer.alloc(maxCodeViewRead); + const codeViewRead = await handle.read( + codeViewBuffer, + 0, + maxCodeViewRead, + pointerToRawData, + ); + throwIfTargetResolutionCancelled(signal); + const parsed = parseCodeViewData( + codeViewBuffer.subarray(0, codeViewRead.bytesRead), + sizeOfData, + ); + if (parsed !== undefined) return parsed; + } + return undefined; }; diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts index cc4ce41f4..640f7c510 100644 --- a/src/application/CallPathTracing.ts +++ b/src/application/CallPathTracing.ts @@ -158,7 +158,7 @@ const expandAddress = async ( state.residual.add(`Call relationships were unavailable for ${address}.`); return ok(false); } - const related = parseRelatedAddresses(result.value.result, relation); + const related = parseRelatedAddresses(result.value.result, relation, tool); if (!related.ok) { state.failures.push({ address, diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 4286ff1d0..94b9ac659 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -498,6 +498,106 @@ it("reports host denial before target mismatch but keeps composed workflow prere ).toBe("target_required"); }); +describe("binary_overview capability requirements", () => { + it("admits a provider with current_document and other mandatory capabilities", () => { + const capabilities = [ + "list_segments", + "current_document", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + }); + + it("admits a provider with list_documents and other mandatory capabilities", () => { + const capabilities = [ + "list_segments", + "list_documents", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + }); + + it("reports document selection remediation when neither current_document nor list_documents is declared", () => { + const capabilities = [ + "list_segments", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: false, + reason: "provider_missing", + remediation: + "Configure a provider for one of: current_document, list_documents.", + }); + }); + + it("reports missing segment enumeration independently when segments are missing", () => { + const capabilities = [ + "current_document", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: false, + reason: "provider_missing", + remediation: "Configure a provider for required operation list_segments.", + }); + }); + + it("reports provider_unavailable when declared document selection capability is unavailable", () => { + const capabilities = [ + { operation: "list_segments", available: true, reason: null }, + { + operation: "current_document", + available: false, + reason: "Document view is closed", + }, + { operation: "list_procedures", available: true, reason: null }, + { operation: "list_strings", available: true, reason: null }, + ]; + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: false, + reason: "provider_unavailable", + remediation: "Document view is closed", + }); + }); +}); + describe("trace_feature requirements", () => { const capabilities = (operations: readonly string[]) => operations.map((operation) => ({ diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index f4584eb29..87734fbd1 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -98,7 +98,9 @@ type AvailabilityContext = { readonly policy: AvailabilityPolicy; }; -const ENHANCED_REQUIREMENTS: Readonly> = { +const ENHANCED_REQUIREMENTS: Readonly< + Record +> = { trace_native_values: ["analyze_function", "resolve_native_call_targets"], trace_native_ui_action: [ "decode_interface_builder", @@ -114,7 +116,7 @@ const ENHANCED_REQUIREMENTS: Readonly> = { find_xrefs_to_name: ["list_names", "xrefs"], binary_overview: [ "list_segments", - "list_documents", + ["current_document", "list_documents"], "list_procedures", "list_strings", ], @@ -343,7 +345,7 @@ const workflowAvailabilityFor = ({ kind, policy, }: AvailabilityContext): Availability | null => { - if (name === "inspect_go_binary") + if (name === "inspect_go_binary" || name === "inspect_rust_binary") return { reason: "available", remediation: null }; if (name === "inspect_sqlite_database") { const capability = sqliteDatabaseCapability(process.versions.node); @@ -642,27 +644,62 @@ const composedAvailability = ( }; const composedAvailabilityFor = ( - requirements: readonly string[], + requirements: readonly (string | readonly string[])[], descriptors: ReadonlyMap, ): Availability => { - const missing = requirements.find((operation) => !descriptors.has(operation)); - if (missing !== undefined) - return { - reason: "provider_missing", - remediation: `Configure a provider for required operation ${missing}.`, - }; - const unavailable = requirements - .map((operation) => descriptors.get(operation)) - .find((descriptor) => descriptor?.available === false); - if (unavailable !== undefined) - return { - reason: - unavailable.availability_code === "unsupported_host" - ? "unsupported_host" - : "provider_unavailable", - remediation: - unavailable.reason ?? "Choose another target or configured provider.", - }; + for (const requirement of requirements) { + if (typeof requirement === "string") { + if (!descriptors.has(requirement)) + return { + reason: "provider_missing", + remediation: `Configure a provider for required operation ${requirement}.`, + }; + } else { + const declared = requirement.some((operation) => + descriptors.has(operation), + ); + if (!declared) + return { + reason: "provider_missing", + remediation: `Configure a provider for one of: ${requirement.join(", ")}.`, + }; + } + } + + for (const requirement of requirements) { + if (typeof requirement === "string") { + const descriptor = descriptors.get(requirement); + if (descriptor?.available === false) + return { + reason: + descriptor.availability_code === "unsupported_host" + ? "unsupported_host" + : "provider_unavailable", + remediation: + descriptor.reason ?? + "Choose another target or configured provider.", + }; + } else { + const anyAvailable = requirement.some( + (operation) => descriptors.get(operation)?.available === true, + ); + if (!anyAvailable) { + const unavailable = requirement + .map((operation) => descriptors.get(operation)) + .find((descriptor) => descriptor?.available === false); + if (unavailable !== undefined) + return { + reason: + unavailable.availability_code === "unsupported_host" + ? "unsupported_host" + : "provider_unavailable", + remediation: + unavailable.reason ?? + "Choose another target or configured provider.", + }; + } + } + } return { reason: "available", remediation: null }; }; diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index baaa6b7ca..f12033ee0 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -49,7 +49,10 @@ export const inspectAnalysisViewValidated = ( ): Result => { const accumulator = input.view.kind === "api-surface" - ? new WebNetworkApiSurfaceAccumulator() + ? new WebNetworkApiSurfaceAccumulator( + undefined, + input.view.include_query_parameter_names === true, + ) : undefined; if (input.source.kind === "inline" && accumulator !== undefined) { const admitted = accumulator.admitInlineEvidence(input.source.evidence); diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index 8eae6c9c9..fde2f6718 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -186,6 +186,28 @@ describe("analysis provider registry: binding failures and cancellation", () => expect(explicit.error.reason).toBe("target_unsupported"); }); + it("rejects an unknown environment preference even when every candidate rejects the target", async () => { + const alpha = candidate("alpha"); + const misconfigured = new AnalysisProviderRegistry( + [alpha.provider], + "missing", + ); + + for (const selected of [ + await misconfigured.select(ARTIFACT_TARGET), + await misconfigured.select(ARTIFACT_TARGET, "missing"), + ]) { + if (selected.ok) throw new Error("expected unknown provider rejection"); + expect(projectAnalysisError(selected.error)).toMatchObject({ + details: { + selection_reason: "unknown_provider", + requested_provider_id: "missing", + candidate_ids: ["alpha"], + }, + }); + } + }); + it("does not bind a profile with unresolved version or adapter failure", async () => { const unresolved = candidate("unresolved", { profile: "missing" }); const failed = candidate("failed", { profile: "error" }); diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index 5b7e78813..033677881 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -176,7 +176,7 @@ export class AnalysisProviderRegistry { return err(new AnalysisCancelledError("open_binary")); if ( request === undefined && - selector !== "auto" && + this.#byId.has(selector) && baseline.length > 0 && baseline.every( ({ targetSupport }) => targetSupport.status === "unsupported", diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index b151958e7..350642016 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -5,7 +5,10 @@ import { analysisProfilesEqual, type AnalysisProfileCommitment, } from "../../domain/analysisProfile.js"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { JsonValue } from "../../domain/jsonValue.js"; @@ -73,6 +76,7 @@ export class BinarySession implements BinarySessionPort { #active: SessionBinding | undefined; + #externalMutationCleanupFailure: AnalysisError | undefined; #pendingCleanup: | { readonly client: AnalysisClient; @@ -158,6 +162,10 @@ export class BinarySession ); } + invalidateSnapshotForExternalMutation(): void { + this.invalidateSnapshot(); + } + /** Return the selected immutable profile, optionally scoped to an operation. */ analysisProfile( operation?: AnalysisOperation, @@ -245,110 +253,180 @@ export class BinarySession resolve: () => Promise>, options: Pick, ): Promise> { - return this.#serialize(async (admittedThrough) => { - if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); - const resolved = await resolve(); - if (!resolved.ok) return resolved; - const { target, route, sameTarget } = resolved.value; - const { profile } = route; - if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); - const activeProfile = this.#active?.profile; - const sameProfile = - activeProfile === null || activeProfile === undefined - ? profile === null - : profile !== null && analysisProfilesEqual(activeProfile, profile); - await this.#drainCalls(admittedThrough); - if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); - const pendingClosed = await this.#retryPendingCleanup(); - if (!pendingClosed.ok) return pendingClosed; - if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); - const retained = sameTarget && sameProfile ? this.#active : undefined; - if (retained !== undefined) { - const fastPath = await this.#tryRetainedFastPath( - retained, - target, - options, - ); - if (fastPath !== undefined) return fastPath; - } - const previous = this.#active; - this.#active = undefined; - if (previous !== undefined) { - const closed = await this.#retireClient( - previous.client, - previous.route.identity.id, - ); - if (!closed.ok) { - // A failed open retires the provider cache, not the investigation. - // Preserve Evidence and Unknown history for export and recovery. - this.clearSnapshot(); - this.#clearRuntimeAvailability(); - return closed; - } - } - if (isAborted(options.signal)) - return err( - await this.#restoreAfterFailedOpen( - previous, - new AnalysisCancelledError("open_binary"), - ), - ); - const runId = randomUUID(); - const client = route.createClient(target, { runId }); - const started = await client.execute("health", {}, options); - if (!started.ok) { - return err( - await this.#failedOpen( - client, - route.identity.id, - previous, - started.error, - ), - ); - } - if (isAborted(options.signal)) { - return err( - await this.#failedOpen( - client, - route.identity.id, - previous, - new AnalysisCancelledError("open_binary"), - ), - ); - } - this.#active = { + return this.#serialize((admittedThrough) => + this.#openWithinTransition(admittedThrough, resolve, options), + ); + } + + async #openWithinTransition( + admittedThrough: number, + resolve: () => Promise>, + options: Pick, + ): Promise> { + if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) + return err(this.#externalMutationCleanupFailure); + if (isAborted(options.signal)) + return err(new AnalysisCancelledError("open_binary")); + const resolved = await resolve(); + if (!resolved.ok) return resolved; + const { target, route, sameTarget } = resolved.value; + const { profile } = route; + if (isAborted(options.signal)) + return err(new AnalysisCancelledError("open_binary")); + const activeProfile = this.#active?.profile; + const sameProfile = + activeProfile === null || activeProfile === undefined + ? profile === null + : profile !== null && analysisProfilesEqual(activeProfile, profile); + await this.#drainCalls(admittedThrough); + if (isAborted(options.signal)) + return err(new AnalysisCancelledError("open_binary")); + const pendingClosed = await this.#retryPendingCleanup(); + if (!pendingClosed.ok) return pendingClosed; + if (isAborted(options.signal)) + return err(new AnalysisCancelledError("open_binary")); + const retained = sameTarget && sameProfile ? this.#active : undefined; + if (retained !== undefined) { + const fastPath = await this.#tryRetainedFastPath( + retained, target, - client, - profile, - route, - runId, - nativeSourceAdmitted: false, - }; - this.#clearRuntimeAvailability(); - this.resetSnapshotInvalidation(); - if (options.snapshot === undefined) { - if (profile === null) this.clearSnapshot(); - else this.selectSnapshot(target, profile); + options, + ); + if (fastPath !== undefined) return fastPath; + } + const previous = this.#active; + this.#active = undefined; + if (previous !== undefined) { + const closed = await this.#retireClient( + previous.client, + previous.route.identity.id, + ); + if (!closed.ok) { + // A failed open retires the provider cache, not the investigation. + // Preserve Evidence and Unknown history for export and recovery. + this.clearSnapshot(); + this.#clearRuntimeAvailability(); + return closed; } - if (options.snapshot !== undefined) { - const imported = this.importAnalysisSnapshot(options.snapshot); - if (!imported.ok) { - this.#active = undefined; - return err( - await this.#failedOpen( - client, - route.identity.id, - previous, - imported.error, - ), - ); - } + } + if (isAborted(options.signal)) + return err( + await this.#restoreAfterFailedOpen( + previous, + new AnalysisCancelledError("open_binary"), + ), + ); + const runId = randomUUID(); + const client = route.createClient(target, { runId }); + const started = await client.execute("health", {}, options); + if (!started.ok) { + return err( + await this.#failedOpen( + client, + route.identity.id, + previous, + started.error, + ), + ); + } + if (isAborted(options.signal)) { + return err( + await this.#failedOpen( + client, + route.identity.id, + previous, + new AnalysisCancelledError("open_binary"), + ), + ); + } + this.#active = { + target, + client, + profile, + route, + runId, + nativeSourceAdmitted: false, + }; + this.#clearRuntimeAvailability(); + this.resetSnapshotInvalidation(); + if (options.snapshot === undefined) { + if (profile === null) this.clearSnapshot(); + else this.selectSnapshot(target, profile); + } + if (options.snapshot !== undefined) { + const imported = this.importAnalysisSnapshot(options.snapshot); + if (!imported.ok) { + this.#active = undefined; + return err( + await this.#failedOpen( + client, + route.identity.id, + previous, + imported.error, + ), + ); } - return ok(target); + } + return ok(target); + } + + /** Serialize an external mutation and retire the potentially stale analysis. */ + withAdmittedExternalMutation( + _path: string | undefined, + signal: AbortSignal | undefined, + operation: () => Promise>, + retirementFailure: ( + value: Result, + error: AnalysisError, + ) => AnalysisError, + operationName = "execute_rizin_command", + cleanupRecovery?: ( + failure: AnalysisError | undefined, + ) => Promise>, + ): Promise, AnalysisError>> { + return this.#serialize(async (admittedThrough) => { + if (isAborted(signal)) + return err(new AnalysisCancelledError(operationName)); + await this.#drainCalls(admittedThrough); + if (isAborted(signal)) + return err(new AnalysisCancelledError(operationName)); + + if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) { + if (cleanupRecovery === undefined) + return err(this.#externalMutationCleanupFailure); + const recovered = await cleanupRecovery( + this.#externalMutationCleanupFailure, + ); + if (!recovered.ok) return recovered; + if (this.#externalMutationCleanupFailure.cleanupIncomplete) + return err(this.#externalMutationCleanupFailure); + this.#externalMutationCleanupFailure = undefined; + } + + const pendingCleanup = await this.#retryPendingCleanup(); + if (!pendingCleanup.ok) return pendingCleanup; + + const activeAtDispatch = this.#active; + if (activeAtDispatch?.route.binding?.identity.id === "ghidra") + return err( + new AnalysisOutputError( + operationName, + "Close the active Ghidra session explicitly before an external mutation; rebinding would discard its ephemeral analysis edits.", + ), + ); + // A protocol reply does not prove that background writers have stopped. + // Retire the old analysis and require an explicit fresh open rather than + // silently binding a snapshot while an unrestricted command can still write. + const retireTarget = activeAtDispatch !== undefined; + this.invalidateSnapshot(); + const value = await operation(); + this.#externalMutationCleanupFailure = + !value.ok && value.error.cleanupIncomplete ? value.error : undefined; + if (!retireTarget) return ok(value); + + const retired = await this.#closeActive({}, false); + if (!retired.ok) return err(retirementFailure(value, retired.error)); + return ok(value); }); } @@ -396,6 +474,7 @@ export class BinarySession options: Pick & { readonly retainProviderDocuments?: boolean; }, + clearRecords = true, ): Promise> { const previous = this.#active; this.#active = undefined; @@ -417,7 +496,7 @@ export class BinarySession : {}), }, ); - this.clearSessionRecords(); + if (clearRecords) this.clearSessionRecords(); this.#clearRuntimeAvailability(); const fileResources = await this.closeWriteResources(); if (!closed.ok && !fileResources.ok) diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts index 749e98b2f..49f513c69 100644 --- a/src/application/binary/BinarySessionPort.ts +++ b/src/application/binary/BinarySessionPort.ts @@ -48,6 +48,20 @@ export interface BinarySessionPort signal: AbortSignal | undefined, operation: (analysis: AnalysisOperationPort) => Promise, ): Promise>; + /** Serialize an external mutation and retire any active target afterward. */ + withAdmittedExternalMutation( + _path: string | undefined, + signal: AbortSignal | undefined, + operation: () => Promise>, + retirementFailure: ( + value: Result, + error: AnalysisError, + ) => AnalysisError, + operationName?: string, + cleanupRecovery?: ( + failure: AnalysisError | undefined, + ) => Promise>, + ): Promise, AnalysisError>>; open( path: string, options?: { @@ -82,6 +96,8 @@ export interface BinarySessionPort operation?: AnalysisOperation, ): AnalysisProfileCommitment | undefined; allowsSnapshotReplay(operation: AnalysisOperation): boolean; + /** Invalidate cached Evidence before an external command may mutate the target. */ + invalidateSnapshotForExternalMutation(): void; recordWorkflowSnapshot( input: WorkflowSnapshotRecordInput, ): Result; diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index 7a527a807..d66280f66 100644 --- a/src/application/frida/FridaInstrumentationManager.test.ts +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -1023,6 +1023,28 @@ it("serializes script loading against concurrent close and unloads the loaded sc expect(bindings.device.session.isDetached()).toBe(true); }); +it("drains startup when the provider loader initiates shutdown", async () => { + const bindings = new FakeDeviceManager(); + let closing: Promise | undefined; + const manager = new FridaInstrumentationManager({ + deviceManagerLoader: async () => { + closing = manager.closeAll(); + return bindings; + }, + }); + const started = await manager.startSession({ + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + }); + if (!started.ok) throw started.error; + expect(closing).toBeDefined(); + await closing; + expect(bindings.device.session.isDetached()).toBe(true); + expect(bindings.device.resumed).toEqual([512]); + expect(manager.status(started.value.sessionId)).toBeUndefined(); +}); + it("names the unknown session or script ID it rejects", async () => { const { manager, started } = await startLocalAttachedSession(); if (!started.ok) throw started.error; diff --git a/src/application/frida/FridaInstrumentationPort.ts b/src/application/frida/FridaInstrumentationPort.ts index 39c51eb26..39fa30936 100644 --- a/src/application/frida/FridaInstrumentationPort.ts +++ b/src/application/frida/FridaInstrumentationPort.ts @@ -1,4 +1,8 @@ -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + AnalysisError, + AnalysisErrorOptions, +} from "../../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import type { OptionalProviderLoadFailure } from "../OptionalObservationProviders.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { Result } from "../../domain/result.js"; @@ -60,6 +64,15 @@ export interface FridaScriptObservation { readonly messagesTruncated: boolean; } +export class FridaScriptLoadCancelledError extends AnalysisCancelledError { + constructor( + readonly script: FridaScriptObservation, + options?: AnalysisErrorOptions, + ) { + super("load_frida_script", options); + } +} + export interface FridaSessionStatus extends FridaSessionObservation { /** True only after REA has successfully resumed this spawned target. */ readonly targetResumeObserved: boolean; @@ -100,14 +113,20 @@ export interface FridaInstrumentationPort { startSession( input: StartFridaSessionInput, ): Promise>; + /** Own cancellation and new-script cleanup before returning a final load outcome. */ loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise>; - resumeSession(sessionId: string): Promise>; + resumeSession( + sessionId: string, + signal?: AbortSignal, + ): Promise>; unloadScript( sessionId: string, scriptId: string, + signal?: AbortSignal, ): Promise>; status(sessionId: string): FridaSessionStatus | undefined; closeSession( diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index c1251f9a5..dc25bc62b 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; +import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js"; import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js"; import { FridaInstrumentationService } from "./FridaInstrumentationService.js"; import { @@ -569,3 +570,74 @@ it("cleans up a session and returns typed cancellation after a pending start", a if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); expect(closed).toEqual(["00000000-0000-4000-8000-000000000001"]); }); + +it("retains a cancelled provider load's source and messages in one-shot Evidence", async () => { + const controller = new AbortController(); + const script = { + ...createScriptObservation(), + messages: [{ type: "send", payload: "cancelled-load" }], + }; + const service = new FridaInstrumentationService( + createProvider({ + async loadScript() { + controller.abort(new Error("cancel during native load")); + return { + ok: false, + error: new FridaScriptLoadCancelledError(script, { + cause: controller.signal.reason, + }), + }; + }, + }), + ); + const result = await service.instrument( + { + mode: "spawn", + program: "/fixture", + source: { sourceKind: "inline", source: "send('cancelled-load')" }, + durationMs: 0, + }, + controller.signal, + ); + if (result.ok) throw new Error("Cancelled instrumentation succeeded"); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + source_kind: "inline", + source_sha256: script.sourceSha256, + messages: script.messages, + instrumentation_cleanup: "released", + }, + }); +}); + +it("preserves a provider-committed load when cancellation arrives during result delivery", async () => { + const controller = new AbortController(); + let unloads = 0; + const script = createScriptObservation(); + const service = new FridaInstrumentationService( + createProvider({ + loadScript() { + const completed = Promise.resolve({ ok: true as const, value: script }); + void completed.then(() => + controller.abort(new Error("late cancellation")), + ); + return completed; + }, + async unloadScript() { + unloads += 1; + return { ok: true, value: null }; + }, + }), + ); + const loaded = await service.loadScript( + createSession().sessionId, + { sourceKind: "inline", source: "send('ready')" }, + controller.signal, + ); + expect(controller.signal.aborted).toBe(true); + if (!loaded.ok) throw loaded.error; + expect(loaded.value.normalized_result).toMatchObject({ + script_id: script.scriptId, + }); + expect(unloads).toBe(0); +}); diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index c359850d6..3cba20d41 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -12,6 +12,7 @@ import { AnalysisInputError, } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js"; import type { FridaDeviceSelector, FridaInstrumentationPort, @@ -181,8 +182,17 @@ export class FridaInstrumentationService { async loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise> { - const loaded = await this.#provider.loadScript(sessionId, source); + if (signal?.aborted) + return err( + new AnalysisCancelledError("load_frida_script", { + cause: signal.reason, + }), + ); + const loaded = await this.#provider.loadScript(sessionId, source, signal); + if (!loaded.ok && loaded.error instanceof FridaScriptLoadCancelledError) + return err(cancelledScriptEvidence(sessionId, loaded.error)); return loaded.ok ? ok( fridaEvidence("load_frida_script", scriptOutput(loaded.value), { @@ -192,12 +202,12 @@ export class FridaInstrumentationService { : loaded; } - resumeSession(sessionId: string) { - return this.#provider.resumeSession(sessionId); + resumeSession(sessionId: string, signal?: AbortSignal) { + return this.#provider.resumeSession(sessionId, signal); } - unloadScript(sessionId: string, scriptId: string) { - return this.#provider.unloadScript(sessionId, scriptId); + unloadScript(sessionId: string, scriptId: string, signal?: AbortSignal) { + return this.#provider.unloadScript(sessionId, scriptId, signal); } status(sessionId: string): FridaSessionStatus | undefined { @@ -243,14 +253,19 @@ export class FridaInstrumentationService { const loaded = await this.#provider.loadScript( started.value.sessionId, input.source, + signal, ); + let scriptObservation: FridaScriptObservation | undefined; + if (loaded.ok) scriptObservation = loaded.value; + else if (loaded.error instanceof FridaScriptLoadCancelledError) + scriptObservation = loaded.error.script; if (signal?.aborted) return this.#cancelSession( "instrument_with_frida", started.value, input, signal.reason, - loaded.ok ? loaded.value : undefined, + scriptObservation, ); if (!loaded.ok) { const closed = await this.#provider.closeSession(started.value.sessionId); @@ -267,6 +282,7 @@ export class FridaInstrumentationService { if (started.value.mode === "spawn") { const resumed = await this.#provider.resumeSession( started.value.sessionId, + signal, ); if (signal?.aborted) return this.#cancelSession( @@ -462,3 +478,23 @@ const sessionStatusOutput = (status: FridaSessionStatus) => ({ messages: status.messages, messages_truncated: status.messagesTruncated, }); + +const cancelledScriptEvidence = ( + sessionId: string, + error: FridaScriptLoadCancelledError, +): AnalysisCancelledError => + new AnalysisCancelledError(error.operation, { + cause: error.cause, + cleanup: error.cleanup, + partialObservation: fridaEvidence( + error.operation, + { + ...scriptOutput(error.script), + cleanup_error: error.cleanup?.reason ?? null, + script_cleanup: error.cleanupIncomplete + ? "incomplete_or_unknown" + : "released", + }, + { session_id: sessionId }, + ), + }); diff --git a/src/application/managed/ManagedMemberComparisonService.ts b/src/application/managed/ManagedMemberComparisonService.ts index 252feb8ca..04fae187f 100644 --- a/src/application/managed/ManagedMemberComparisonService.ts +++ b/src/application/managed/ManagedMemberComparisonService.ts @@ -92,7 +92,11 @@ export const compareManagedMemberPaths = async ( throw new EvidenceIntegrityError( `Managed artifact digest changed after open: expected ${target.sha256}, observed ${snapshot.sha256} at ${target.path}`, ); - const inspection = inspectManagedMembersBytes(snapshot.bytes, target); + // Member comparison never reads literals, so it does not spend the + // decode budget on them. + const inspection = inspectManagedMembersBytes(snapshot.bytes, target, { + userStrings: false, + }); const evidence = createEvidence(target, MANAGED_STATIC_PROVIDER, { operation: "inspect_managed_members", parameters: {}, diff --git a/src/application/reverse/ReverseEngineeringCommandPort.ts b/src/application/reverse/ReverseEngineeringCommandPort.ts new file mode 100644 index 000000000..947af38dd --- /dev/null +++ b/src/application/reverse/ReverseEngineeringCommandPort.ts @@ -0,0 +1,19 @@ +import type { EvidenceProvider } from "../../domain/evidence.js"; +import type { + ObjdumpInput, + RizinInput, +} from "../../domain/reverseEngineering.js"; + +/** Provider-neutral command profile consumed by the shared application workflow. */ +export interface ReverseEngineeringProviderCommand { + readonly executable: string; + readonly arguments: readonly string[]; + readonly identity: EvidenceProvider; + readonly environmentOverrides?: Readonly; +} + +/** Provider-specific command construction stays outside the application layer. */ +export interface ReverseEngineeringCommandPort { + objdump(input: ObjdumpInput): ReverseEngineeringProviderCommand; + rizin(input: RizinInput): ReverseEngineeringProviderCommand; +} diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts new file mode 100644 index 000000000..41589747b --- /dev/null +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -0,0 +1,948 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat as statPath } from "node:fs/promises"; +import type { Stats } from "node:fs"; +import { resolve } from "node:path"; + +import { + AnalysisAccessDeniedError, + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../../domain/analysisErrorCore.js"; +import type { + AnalysisCleanupObservation, + AnalysisError, +} from "../../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + objdumpInputSchema, + rizinInputSchema, +} from "../../domain/reverseEngineering.js"; +import type { + ReverseEngineeringCommandPort, + ReverseEngineeringProviderCommand, +} from "./ReverseEngineeringCommandPort.js"; +import { + execFileOutput, + execFileOutputFailure, +} from "../../process/ExecFileOutput.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../../process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../../process/ProviderProcess.js"; + +export type ReverseEngineeringOperation = + | "inspect_with_objdump" + | "execute_rizin_command"; + +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_COMBINED_OUTPUT_BYTES = 16 * 1024 * 1024; +// Eight concurrent captures bound retained provider output to 128 MiB. +const MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS = 8; + +export interface ReverseEngineeringServiceOptions { + readonly environment?: Readonly; + readonly commands: ReverseEngineeringCommandPort; + readonly run?: ReverseEngineeringCommandRunner; + readonly runOwned?: typeof runOwnedCommand; + readonly digest?: typeof digestFile; + readonly stat?: (path: string) => Promise; +} + +interface ReverseEngineeringCommandRunner { + ( + command: string, + arguments_: readonly string[], + options?: Parameters[2], + ): Promise<{ + readonly stdout: string; + readonly stderr: string; + readonly stdoutInvalidUtf8?: boolean; + readonly stderrInvalidUtf8?: boolean; + readonly stdoutBytes?: number; + readonly stderrBytes?: number; + }>; +} + +export type ReverseEngineeringServiceRuntimeOptions = Omit< + ReverseEngineeringServiceOptions, + "commands" | "environment" +>; + +/** Shared application workflow for caller-selected local analysis commands. */ +export class ReverseEngineeringService { + readonly #environment: Readonly; + readonly #commands: ReverseEngineeringCommandPort; + readonly #run: ReverseEngineeringCommandRunner; + readonly #digest: typeof digestFile; + readonly #stat: (path: string) => Promise; + readonly #pendingCleanup = new Map< + ProviderProcessSupervisor, + { + readonly observation: AnalysisCleanupObservation; + readonly failures: Set; + readonly retryFailure?: AnalysisError; + } + >(); + readonly #activeOperations = new Map< + AbortController, + Promise> + >(); + #closing = false; + #closePromise: Promise | undefined; + + constructor(options: ReverseEngineeringServiceOptions) { + this.#environment = { ...(options.environment ?? process.env) }; + this.#commands = options.commands; + this.#run = + options.run ?? + runOwnedCommandOutput( + options.runOwned ?? runOwnedCommand, + (owner, cleanup) => + this.#pendingCleanup.set(owner, { + observation: cleanup, + failures: this.#pendingCleanup.get(owner)?.failures ?? new Set(), + }), + ); + this.#digest = options.digest ?? digestFile; + this.#stat = options.stat ?? statPath; + } + + /** Ask retained process owners to settle cleanup before admitting more work. */ + async retryCleanup( + operation = "reverse_engineering_cleanup", + ): Promise> { + let failure: AnalysisError | undefined; + for (const [owner, retained] of this.#pendingCleanup) { + let stopped: Awaited>; + try { + stopped = await owner.stop(); + } catch (cause) { + stopped = { + status: "incomplete", + reason: cause instanceof Error ? cause.message : String(cause), + }; + } + if (stopped.status === "incomplete") { + const observation = { + reason: stopped.reason, + resources: retained.observation.resources, + }; + let retryFailure = retained.retryFailure; + if (retryFailure === undefined) { + retryFailure = new AnalysisOutputError( + operation, + "A previous provider process still requires cleanup.", + { cleanup: observation }, + ); + retained.failures.add(retryFailure); + } + this.#pendingCleanup.set(owner, { + observation, + failures: retained.failures, + retryFailure, + }); + if (failure === undefined) failure = retryFailure; + } else { + for (const previous of retained.failures) previous.resolveCleanup(); + this.#pendingCleanup.delete(owner); + } + } + return failure === undefined ? ok(null) : err(failure); + } + + /** Retry cleanup for provider process groups retained after an incomplete stop. */ + close(): Promise { + this.#closing = true; + this.#closePromise ??= this.#closeActiveOperationsAndCleanup().catch( + (cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }, + ); + return this.#closePromise; + } + + async #closeActiveOperationsAndCleanup(): Promise { + const active = [...this.#activeOperations.entries()]; + for (const [controller] of active) controller.abort(); + await Promise.allSettled(active.map(([, operation]) => operation)); + + const cleanup = await this.retryCleanup(); + if (!cleanup.ok) throw cleanup.error; + } + + inspectWithObjdump( + rawInput: unknown, + options: { readonly signal?: AbortSignal } = {}, + ): Promise> { + const parsed = objdumpInputSchema.safeParse(rawInput); + if (!parsed.success) + return Promise.resolve( + err(new AnalysisInputError("inspect_with_objdump")), + ); + const input = parsed.data; + const command = this.#commands.objdump(input); + return this.#observe( + "inspect_with_objdump", + command, + input.operation === "architectures" ? undefined : input.path, + input, + options.signal, + ); + } + + executeRizinCommand( + rawInput: unknown, + options: { readonly signal?: AbortSignal } = {}, + ): Promise> { + const parsed = rizinInputSchema.safeParse(rawInput); + if (!parsed.success) + return Promise.resolve( + err(new AnalysisInputError("execute_rizin_command")), + ); + const input = parsed.data; + const command = this.#commands.rizin(input); + return this.#observe( + "execute_rizin_command", + command, + input.path, + input, + options.signal, + ); + } + + #observe( + operation: ReverseEngineeringOperation, + profile: ReverseEngineeringProviderCommand, + path: string | undefined, + parameters: Readonly>, + signal?: AbortSignal, + ): Promise> { + if (signal?.aborted) + return Promise.resolve(err(new AnalysisCancelledError(operation))); + if (this.#closing) + return Promise.resolve( + err( + new AnalysisOutputError( + operation, + "Reverse-engineering service is closed.", + ), + ), + ); + if ( + this.#activeOperations.size >= + MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS + ) + return Promise.resolve( + err( + new AnalysisResourceConstraintError( + operation, + "transport", + "Concurrent reverse-engineering operation capacity reached.", + { + max_concurrent_operations: + MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS, + active_operations: this.#activeOperations.size, + }, + ), + ), + ); + const controller = new AbortController(); + const relayAbort = (): void => controller.abort(signal?.reason); + if (signal instanceof AbortSignal) { + if (signal.aborted) relayAbort(); + else signal.addEventListener("abort", relayAbort, { once: true }); + } + const operationSignal = + signal === undefined || signal instanceof AbortSignal + ? controller.signal + : signal; + const running = this.#observeProvider( + operation, + profile, + path, + parameters, + operationSignal, + ); + const tracked = running.finally(() => { + signal?.removeEventListener("abort", relayAbort); + this.#activeOperations.delete(controller); + }); + this.#activeOperations.set(controller, tracked); + return tracked; + } + + async #observeProvider( + operation: ReverseEngineeringOperation, + profile: ReverseEngineeringProviderCommand, + path: string | undefined, + parameters: Readonly>, + signal?: AbortSignal, + ): Promise> { + const { executable: command, arguments: args } = profile; + if (signal?.aborted) return err(new AnalysisCancelledError(operation)); + const cleanup = await this.retryCleanup(operation); + if (!cleanup.ok) return cleanup; + const absolutePath = path === undefined ? undefined : resolve(path); + let digest: string | undefined; + try { + if (absolutePath !== undefined) + digest = await this.#digest(absolutePath, signal, this.#stat); + } catch (cause) { + const code = (cause as NodeJS.ErrnoException).code; + if (signal?.aborted) + return err(new AnalysisCancelledError(operation, { cause })); + if (code === "EACCES" || code === "EPERM") { + const deniedPath = absolutePath === undefined ? command : absolutePath; + return err( + new AnalysisAccessDeniedError(operation, deniedPath, code, { + cause, + }), + ); + } + if (code === "ETIMEDOUT") + return err( + new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, { cause }), + ); + return err(new AnalysisInputError(operation, { cause })); + } + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; + let exitCode: number | string | null = 0; + let signalName: string | null = null; + let outputTruncated = false; + let stdoutInvalidUtf8 = false; + let stderrInvalidUtf8 = false; + let cleanupIncomplete = false; + let cleanupOnlyFailure = false; + let completionStatus: "complete" | "unknown" = "complete"; + let failure: AnalysisError | undefined; + let cleanupOwner: ProviderProcessSupervisor | undefined; + let earlyFailure: AnalysisError | undefined; + try { + const output = await this.#run(command, args, { + env: { + ...this.#environment, + ...profile.environmentOverrides, + }, + signal, + timeout: DEFAULT_TIMEOUT_MS, + stopSignal: "SIGTERM", + maxBuffer: MAX_COMBINED_OUTPUT_BYTES, + }); + stdout = output.stdout; + stderr = output.stderr; + stdoutBytes = output.stdoutBytes ?? Buffer.byteLength(stdout); + stderrBytes = output.stderrBytes ?? Buffer.byteLength(stderr); + stdoutInvalidUtf8 = output.stdoutInvalidUtf8 === true; + stderrInvalidUtf8 = output.stderrInvalidUtf8 === true; + } catch (cause) { + cleanupOwner = + cause instanceof OwnedOutputFailure ? cause.cleanupOwner : undefined; + if ( + cause instanceof OwnedOutputFailure && + cause.capabilityError !== undefined + ) + earlyFailure = new AnalysisCapabilityUnavailableError( + cause.capabilityError.providerId, + cause.capabilityError.operation, + cause.capabilityError.reason, + { + cause: cause.capabilityError, + ...(cause.capabilityError.diagnostics === undefined + ? {} + : { diagnostics: cause.capabilityError.diagnostics }), + ...(cause.capabilityError.capturedOutput === undefined + ? {} + : { capturedOutput: cause.capabilityError.capturedOutput }), + ...(cause.capabilityError.partialObservation === undefined + ? {} + : { + partialObservation: cause.capabilityError.partialObservation, + }), + ...(cause.capabilityError.userMessage === undefined + ? {} + : { userMessage: cause.capabilityError.userMessage }), + retryable: cause.capabilityError.retryable, + ...ownedCleanupOptions(cause), + }, + ); + const capture = execFileOutputFailure(cause); + const code = + capture?.code ?? (cause as NodeJS.ErrnoException).code ?? undefined; + if (earlyFailure === undefined && code === "E2BIG") + earlyFailure = new AnalysisResourceConstraintError( + operation, + "transport", + "The host rejected the provider command because its argument list exceeds the process launch limit.", + { + argument_bytes: args.reduce( + (bytes, argument, index) => + bytes + Buffer.byteLength(argument) + (index === 0 ? 0 : 1), + 0, + ), + }, + { cause, ...ownedCleanupOptions(cause) }, + ); + if ( + earlyFailure === undefined && + (capture === undefined || + code === "ENOENT" || + code === "ERR_PROVIDER_LAUNCH") + ) { + if (signal?.aborted || code === "ABORT_ERR") + earlyFailure = new AnalysisCancelledError(operation, { + cause, + ...ownedCleanupOptions(cause), + }); + else if (code === "ETIMEDOUT") + earlyFailure = new AnalysisTimeoutError( + operation, + DEFAULT_TIMEOUT_MS, + { + cause, + ...ownedCleanupOptions(cause), + }, + ); + else if (code === "EACCES" || code === "EPERM") + earlyFailure = new AnalysisAccessDeniedError( + operation, + command, + code, + { + cause, + ...ownedCleanupOptions(cause), + }, + ); + else + earlyFailure = new AnalysisCapabilityUnavailableError( + operation === "inspect_with_objdump" ? "objdump" : "rizin", + operation, + `Unable to run configured command (${String(code ?? "unknown error")})`, + { cause, ...ownedCleanupOptions(cause) }, + ); + } + if (earlyFailure === undefined && capture !== undefined) { + stdout = capture.stdout; + stderr = capture.stderr; + stdoutBytes = capture.stdoutBytes ?? Buffer.byteLength(stdout); + stderrBytes = capture.stderrBytes ?? Buffer.byteLength(stderr); + exitCode = capture.code; + signalName = capture.signal; + outputTruncated = capture.outputTruncated; + if (capture.collectionFailure) { + exitCode = null; + completionStatus = "unknown"; + } + const capturedOutput = { + stdout, + stderr, + truncated: outputTruncated, + stdout_bytes: stdoutBytes, + stderr_bytes: stderrBytes, + exit_code: + !capture.collectionFailure && typeof capture.code === "number" + ? capture.code + : null, + signal: capture.signal, + }; + if (signal?.aborted || code === "ABORT_ERR") + failure = new AnalysisCancelledError(operation, { + cause, + capturedOutput, + ...ownedCleanupOptions(cause), + }); + else if (code === "ETIMEDOUT") + failure = new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, { + cause, + capturedOutput, + ...ownedCleanupOptions(cause), + }); + else if (code === "EACCES" || code === "EPERM") + failure = new AnalysisAccessDeniedError(operation, command, code, { + cause, + capturedOutput, + ...ownedCleanupOptions(cause), + }); + if ( + cause instanceof OwnedOutputFailure && + cause.cleanup !== undefined + ) { + cleanupIncomplete = true; + if (failure === undefined) { + cleanupOnlyFailure = + !capture.collectionFailure && + !capture.outputTruncated && + !capture.killed && + capture.signal === null && + typeof capture.code === "number" && + !signal?.aborted; + failure = new AnalysisOutputError( + operation, + "Provider process cleanup remains incomplete.", + { + cause, + capturedOutput, + cleanup: cause.cleanup, + cleanupOnly: cleanupOnlyFailure, + }, + ); + } + } + if ( + failure === undefined && + cause instanceof OwnedOutputFailure && + cause.collectionFailure + ) + failure = new AnalysisOutputError( + operation, + "Provider output collection failed.", + { cause, capturedOutput }, + ); + if ( + capture.killed || + capture.signal !== null || + typeof capture.code !== "number" + ) { + exitCode = null; + completionStatus = "unknown"; + } + if (cause instanceof OwnedOutputFailure) { + stdoutInvalidUtf8 = cause.stdoutInvalidUtf8; + stderrInvalidUtf8 = cause.stderrInvalidUtf8; + } + } + } + if (earlyFailure !== undefined) + return err(this.#registerCleanupFailure(earlyFailure, cleanupOwner)); + if (failure !== undefined && cleanupOwner !== undefined) + failure = this.#registerCleanupFailure(failure, cleanupOwner); + let digestAfter: string | null = null; + if (absolutePath === undefined) { + digestAfter = null; + } else if (cleanupIncomplete) { + if (!cleanupOnlyFailure) completionStatus = "unknown"; + } else { + try { + digestAfter = await this.#digest(absolutePath, signal, this.#stat); + } catch { + // Preserve the provider's collected observations if the artifact vanished or became unreadable. + } + } + if (signal?.aborted && failure === undefined) { + failure = new AnalysisCancelledError(operation, { + capturedOutput: { + stdout, + stderr, + truncated: outputTruncated, + stdout_bytes: stdoutBytes, + stderr_bytes: stderrBytes, + exit_code: typeof exitCode === "number" ? exitCode : null, + signal: signalName, + }, + }); + exitCode = null; + completionStatus = "unknown"; + } + const artifactChanged = + absolutePath === undefined + ? null + : digestAfter === null || digest === undefined + ? null + : digestAfter !== digest; + const result = { + stdout, + stderr, + exit_code: exitCode, + signal: signalName, + output_truncated: outputTruncated, + completion_status: completionStatus, + artifact_sha256_after: absolutePath === undefined ? null : digestAfter, + artifact_changed: absolutePath === undefined ? null : artifactChanged, + }; + const limitations = [ + ...(absolutePath === undefined + ? [ + "The objdump version is not reported; the output lists BFD targets advertised by the installed tool and does not establish support for the selected artifact.", + ] + : [ + "The executable version and supported formats are reported as unknown because no reliable version probe was performed.", + ]), + "A non-zero process exit is preserved as an observed command result.", + ...(operation === "inspect_with_objdump" && + parameters.follow_debug_links === true + ? [ + "DWARF output may include local debug-link files whose identities are not bound to Evidence.", + ] + : []), + ...(operation === "execute_rizin_command" + ? [ + "Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.", + ] + : []), + ...(stdoutInvalidUtf8 || stderrInvalidUtf8 + ? [ + `Captured ${[ + ...(stdoutInvalidUtf8 ? ["stdout"] : []), + ...(stderrInvalidUtf8 ? ["stderr"] : []), + ].join( + " and ", + )} bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.`, + ] + : []), + ...(outputTruncated + ? [ + "The provider command ended before completion could be confirmed; captured output is partial.", + ] + : []), + ...(signalName === null + ? [] + : [ + `The provider process terminated by signal ${signalName}; captured output may be partial and command completion is unknown.`, + ]), + ...(failure === undefined || + (failure instanceof AnalysisOutputError && failure.cleanupOnly) + ? [] + : [ + `Command completion is unknown because the operation failed with ${failure._tag}: ${failure.message}`, + ]), + ...(absolutePath === undefined + ? [ + "This output lists BFD targets advertised by the installed objdump and is independent of the selected artifact.", + ] + : [ + "When both are available, pre-run and post-run digests detect artifact changes across the provider call but cannot prove the exact bytes consumed during the call.", + ]), + ...(artifactChanged === true + ? [ + "The provider command may have changed the artifact while analysis was running; pre-run and post-run digests are both reported.", + ] + : []), + ...(absolutePath !== undefined && digestAfter === null + ? [ + cleanupIncomplete + ? "The post-run artifact digest was not captured because owned provider cleanup was incomplete at observation time; artifact changes during the command remain unquantified." + : signal?.aborted + ? "The post-run artifact digest is unknown because cancellation interrupted the digest." + : "The post-run artifact digest is unknown because the artifact could not be read after the provider call.", + ] + : []), + ]; + const evidence = createEvidence( + absolutePath === undefined || digest === undefined + ? undefined + : { path: absolutePath, sha256: digest, format: "unknown" }, + profile.identity, + { + predicateType: "rea.reverse-engineering.command-observation", + operation, + parameters: { + ...(absolutePath === undefined + ? { operation: "architectures" } + : parameters), + provider_executable: command, + command_arguments: [...args], + }, + rawResult: result, + result, + limitations, + ...(absolutePath === undefined + ? {} + : { + locations: [ + { kind: "artifact-path" as const, path: absolutePath }, + ], + }), + }, + ); + return failure === undefined + ? ok(evidence) + : err(failure.retainPartialObservation(evidence)); + } + + #registerCleanupFailure( + failure: AnalysisError, + cleanupOwner: ProviderProcessSupervisor | undefined, + ): AnalysisError { + if (cleanupOwner !== undefined && failure.cleanupIncomplete) + this.#pendingCleanup.get(cleanupOwner)?.failures.add(failure); + return failure; + } +} + +interface OwnedOutputFailureOptions { + readonly stdout: string; + readonly stderr: string; + readonly code: number | string | null; + readonly signal: string | null; + readonly outputTruncated: boolean; + readonly stdoutInvalidUtf8: boolean; + readonly stderrInvalidUtf8: boolean; + readonly stdoutBytes: number; + readonly stderrBytes: number; + readonly capabilityError?: AnalysisCapabilityUnavailableError; + readonly collectionFailure?: boolean; + readonly cleanup?: AnalysisCleanupObservation; + readonly cleanupOwner?: ProviderProcessSupervisor; + readonly cause?: unknown; +} + +class OwnedOutputFailure extends Error { + readonly stdout: string; + readonly stderr: string; + readonly code: number | string | null; + readonly signal: string | null; + readonly outputTruncated: boolean; + readonly stdoutInvalidUtf8: boolean; + readonly stderrInvalidUtf8: boolean; + readonly stdoutBytes: number; + readonly stderrBytes: number; + readonly capabilityError: AnalysisCapabilityUnavailableError | undefined; + readonly collectionFailure: boolean; + readonly cleanup: AnalysisCleanupObservation | undefined; + readonly cleanupOwner: ProviderProcessSupervisor | undefined; + + constructor(options: OwnedOutputFailureOptions) { + super("Owned provider command did not produce a complete result.", { + cause: options.cause, + }); + this.stdout = options.stdout; + this.stderr = options.stderr; + this.code = options.code; + this.signal = options.signal; + this.outputTruncated = options.outputTruncated; + this.stdoutInvalidUtf8 = options.stdoutInvalidUtf8; + this.stderrInvalidUtf8 = options.stderrInvalidUtf8; + this.stdoutBytes = options.stdoutBytes; + this.stderrBytes = options.stderrBytes; + this.capabilityError = options.capabilityError; + this.collectionFailure = options.collectionFailure ?? false; + this.cleanup = options.cleanup; + this.cleanupOwner = options.cleanupOwner; + } +} + +const runOwnedCommandOutput = + ( + runOwned: typeof runOwnedCommand, + onCleanupOwner: ( + owner: ProviderProcessSupervisor, + cleanup: AnalysisCleanupObservation, + ) => void, + ): ReverseEngineeringCommandRunner => + async (command, args, options = {}) => { + const spawnEnv = { ...options.env }; + try { + const snapshot = await runOwned( + { + command, + arguments: [...args], + runId: randomUUID(), + expectedCommand: null, + env: spawnEnv, + hostEnvironment: {}, + sweepTokenOwnedProcesses: true, + }, + { + timeoutMs: DEFAULT_TIMEOUT_MS, + diagnosticBytes: MAX_COMBINED_OUTPUT_BYTES, + acceptNonZeroExit: true, + }, + { signal: options.signal }, + ); + if (snapshot.signal !== null && snapshot.signal !== undefined) + throw new OwnedOutputFailure({ + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + code: snapshot.exitCode ?? null, + signal: snapshot.signal, + outputTruncated: snapshot.diagnosticTruncated === true, + stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true, + stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true, + stdoutBytes: snapshot.stdout.bytes, + stderrBytes: snapshot.stderr.bytes, + }); + if (snapshot.exitCode !== 0) + throw new OwnedOutputFailure({ + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + code: snapshot.exitCode ?? null, + signal: null, + outputTruncated: snapshot.diagnosticTruncated === true, + stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true, + stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true, + stdoutBytes: snapshot.stdout.bytes, + stderrBytes: snapshot.stderr.bytes, + }); + return { + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true, + stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true, + stdoutBytes: snapshot.stdout.bytes, + stderrBytes: snapshot.stderr.bytes, + }; + } catch (cause) { + if (!(cause instanceof OwnedCommandFailure)) throw cause; + if (cause.cleanupOwner !== undefined && cause.cleanupFailure !== null) + onCleanupOwner(cause.cleanupOwner, { + reason: cause.cleanupFailure, + resources: cause.resources, + }); + const snapshot = cause.snapshot; + const launchErrorCode = + cause.cause instanceof Error + ? Reflect.get(cause.cause, "code") + : undefined; + const code = + cause.reason === "cancelled" + ? "ABORT_ERR" + : cause.reason === "timeout" + ? "ETIMEDOUT" + : cause.reason === "output-limit" + ? "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" + : launchErrorCode === "E2BIG" + ? "E2BIG" + : snapshot === null + ? "ERR_PROVIDER_LAUNCH" + : (snapshot.exitCode ?? null); + throw new OwnedOutputFailure({ + stdout: snapshot?.stdout.text ?? "", + stderr: snapshot?.stderr.text ?? "", + code, + signal: snapshot?.signal ?? null, + outputTruncated: snapshot?.diagnosticTruncated === true, + stdoutInvalidUtf8: snapshot?.stdout.invalidUtf8 === true, + stderrInvalidUtf8: snapshot?.stderr.invalidUtf8 === true, + stdoutBytes: snapshot?.stdout.bytes ?? 0, + stderrBytes: snapshot?.stderr.bytes ?? 0, + ...(cause.cause instanceof AnalysisCapabilityUnavailableError + ? { capabilityError: cause.cause } + : {}), + ...(cause.collectionFailure ? { collectionFailure: true } : {}), + ...(cause.cleanupFailure === null + ? {} + : { + cleanup: { + reason: cause.cleanupFailure, + resources: cause.resources, + }, + }), + ...(cause.cleanupOwner === undefined + ? {} + : { cleanupOwner: cause.cleanupOwner }), + cause, + }); + } + }; + +const ownedCleanupOptions = ( + cause: unknown, +): { readonly cleanup?: AnalysisCleanupObservation } => + cause instanceof OwnedOutputFailure && cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}; + +const digestFile = async ( + path: string, + signal?: AbortSignal, + stat: (path: string) => Promise = statPath, +): Promise => { + const info = await statWithDeadline(path, signal, stat); + if (!info.isFile()) + throw Object.assign(new Error("Artifact digest requires a regular file"), { + code: "EINVAL", + }); + return new Promise((resolveDigest, rejectDigest) => { + const hash = createHash("sha256"); + const stream = createReadStream(path); + const timer = setTimeout( + () => + stream.destroy( + Object.assign(new Error("Artifact digest timed out"), { + code: "ETIMEDOUT", + }), + ), + DEFAULT_TIMEOUT_MS, + ); + const abort = (): void => { + stream.destroy(new AnalysisCancelledError("artifact_digest")); + }; + const cleanup = (): void => { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + }; + if (signal?.aborted) { + cleanup(); + stream.destroy(); + rejectDigest(new AnalysisCancelledError("artifact_digest")); + return; + } + signal?.addEventListener("abort", abort, { once: true }); + stream.on("data", (chunk: Buffer | string) => hash.update(chunk)); + stream.once("error", (cause: Error) => { + cleanup(); + rejectDigest(cause); + }); + stream.once("end", () => { + cleanup(); + resolveDigest(hash.digest("hex")); + }); + }); +}; + +const statWithDeadline = ( + path: string, + signal: AbortSignal | undefined, + stat: (path: string) => Promise, +): Promise => { + if (signal?.aborted) + return Promise.reject(new AnalysisCancelledError("artifact_digest")); + return new Promise((resolveStat, rejectStat) => { + let settled = false; + const finish = (cause?: unknown, value?: Stats): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + if (cause !== undefined) rejectStat(cause); + else resolveStat(value!); + }; + const abort = (): void => + finish(new AnalysisCancelledError("artifact_digest")); + const timer = setTimeout( + () => + finish( + Object.assign(new Error("Artifact stat timed out"), { + code: "ETIMEDOUT", + }), + ), + DEFAULT_TIMEOUT_MS, + ); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) { + abort(); + return; + } + // Promise handlers stay attached after timeout/cancellation so a delayed + // stat rejection is observed and cannot become an unhandled rejection. + try { + void stat(path).then( + (info) => finish(undefined, info), + (cause: unknown) => finish(cause), + ); + } catch (cause) { + finish(cause); + } + }); +}; diff --git a/src/application/rust/RustBinaryPort.ts b/src/application/rust/RustBinaryPort.ts new file mode 100644 index 000000000..b0956aea1 --- /dev/null +++ b/src/application/rust/RustBinaryPort.ts @@ -0,0 +1,19 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectRustBinaryInput, + RustBinary, +} from "../../domain/rust/rustBinary.js"; +import type { Result } from "../../domain/result.js"; + +/** Replaceable offline dependency reader with no target-execution authority. */ +export interface RustBinaryPort { + readonly identity: ProviderIdentity; + inspect( + input: InspectRustBinaryInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts new file mode 100644 index 000000000..7fbee615d --- /dev/null +++ b/src/application/rust/RustBinaryService.test.ts @@ -0,0 +1,132 @@ +import { expect, it } from "vitest"; +import { + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import type { RustBinary } from "../../domain/rust/rustBinary.js"; +import { ok } from "../../domain/result.js"; +import { RustBinaryService } from "./RustBinaryService.js"; + +const identity = { + id: "fixture-rust", + name: "Source-owned Rust reader seam", + version: "1", +}; +const report: RustBinary = { + artifact: { path: "/artifacts/program", sha256: "a".repeat(64), bytes: 128 }, + format: "elf", + architecture: "amd64", + bits: 64, + byte_order: "little", + auditable_metadata: { + section_name: ".dep-v0", + file_offset: 16, + section_bytes: 8, + compressed_bytes: 8, + uncompressed_bytes: 20, + format_version: 1, + packages: [ + { + name: "sample", + version: "0.1.0", + source: "local", + kind: null, + dependencies: [], + root: true, + }, + ], + root_package_index: 0, + dependency_edges: [], + cyclic_dependency_indices: [], + }, + rustc_commit: null, + limitations: [ + "No /rustc/<40-hex>/ path was found in read-only data, so a rustc commit hash is unknown.", + ], +}; + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new RustBinaryService({ + identity, + inspect: () => { + calls += 1; + return Promise.resolve(ok(report)); + }, + }); + const relative = await service.inspect({ path: "program" }); + const extra = await service.inspect({ + path: report.artifact.path, + detail: "complete", + }); + expect(relative.ok).toBe(false); + expect(extra.ok).toBe(false); + if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError); + if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); + +it("records the selected artifact and the dependency section range", async () => { + const result = await new RustBinaryService({ + identity, + inspect: () => Promise.resolve(ok(report)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "observed", + subject: { format: "elf", architecture: "x86_64" }, + normalized_result: report, + locations: [ + { kind: "artifact-path", path: report.artifact.path }, + { kind: "file-offset-range", start: 16, end: 24 }, + ], + }); + const mismatched: RustBinary = { + ...report, + artifact: { ...report.artifact, path: "/artifacts/other" }, + }; + const invalid = await new RustBinaryService({ + identity, + inspect: () => Promise.resolve(ok(mismatched)), + }).inspect({ path: report.artifact.path }); + expect(invalid.ok).toBe(false); + if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("rejects a cycle claim the package dependencies do not contain", async () => { + if (report.auditable_metadata === null) throw new Error("expected metadata"); + const invalidReport: RustBinary = { + ...report, + auditable_metadata: { + ...report.auditable_metadata, + cyclic_dependency_indices: [0], + }, + }; + const result = await new RustBinaryService({ + identity, + inspect: () => Promise.resolve(ok(invalidReport)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it.each([ + { from_index: 1, to_index: 0 }, + { from_index: 0, to_index: 1 }, +])("rejects provider edges referencing missing packages: %j", async (edge) => { + if (report.auditable_metadata === null) throw new Error("expected metadata"); + const invalidReport: RustBinary = { + ...report, + auditable_metadata: { + ...report.auditable_metadata, + dependency_edges: [edge], + }, + }; + const result = await new RustBinaryService({ + identity, + inspect: () => Promise.resolve(ok(invalidReport)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts new file mode 100644 index 000000000..b112b366d --- /dev/null +++ b/src/application/rust/RustBinaryService.ts @@ -0,0 +1,113 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { RustBinaryPort } from "./RustBinaryPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; +import { + inspectRustBinaryInputSchema, + rustBinarySchema, +} from "../../domain/rust/rustBinary.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const OPERATION = "inspect_rust_binary"; + +const architectureForSubject = (architecture: string) => { + switch (architecture) { + case "386": + return "x86"; + case "amd64": + return "x86_64"; + default: + return BINARY_ARCHITECTURES.find( + (supported) => supported === architecture, + ); + } +}; + +/** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */ +export class RustBinaryService { + constructor(readonly provider: RustBinaryPort) {} + + /** Inspect one explicitly selected file without acquiring a binary session. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectRustBinaryInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const inspected = await this.provider.inspect(input.data, options); + if (!inspected.ok) return inspected; + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const report = rustBinarySchema.safeParse(inspected.value); + if (!report.success || report.data.artifact.path !== input.data.path) + return err( + new AnalysisOutputError( + OPERATION, + "Rust dependency reader returned malformed metadata or changed the selected artifact identity.", + ), + ); + return ok(evidenceFor(this.provider.identity, input.data, report.data)); + } +} + +const evidenceFor = ( + provider: RustBinaryPort["identity"], + parameters: { readonly path: string }, + value: ReturnType, +): Evidence => { + const architecture = architectureForSubject(value.architecture); + const section = value.auditable_metadata; + return createEvidence( + { + path: value.artifact.path, + sha256: value.artifact.sha256, + format: value.format === "macho" ? "mach-o" : value.format, + ...(architecture === undefined ? {} : { architecture }), + }, + provider, + { + operation: OPERATION, + parameters, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [ + { kind: "artifact-path", path: value.artifact.path }, + ...(section === null + ? [] + : [ + { + kind: "file-offset-range" as const, + start: section.file_offset, + end: section.file_offset + section.section_bytes, + }, + ]), + ], + }, + ); +}; diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index f072dd35c..ad40de57d 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -1,5 +1,8 @@ import type { Readable } from "node:stream"; -import type { ArtifactCommand } from "../domain/artifactGraph.js"; +import type { + ArtifactCommand, + ArtifactInventoryCoverage, +} from "../domain/artifactGraph.js"; import type { ZipPackageFormat } from "../domain/zipPackageFormat.js"; import type { AnalysisCleanupObservation, @@ -90,10 +93,36 @@ export const sameArtifactEntry = ( entry.sourceIdentity?.mtimeNs === produced.sourceIdentity?.mtimeNs && entry.sourceIdentity?.ctimeNs === produced.sourceIdentity?.ctimeNs; +/** One entry or subtree deliberately excluded from a traversal. */ +export interface ArtifactTraversalOmission { + readonly path: string; + readonly kind: + | "fifo" + | "socket" + | "block-device" + | "character-device" + | "unsupported" + | "directory-descendants"; +} + +/** Facts from an exhausted traversal, separate from entry content certainty. */ +export interface ArtifactReaderTraversal { + readonly status: ArtifactInventoryCoverage["status"]; + readonly limitations: readonly string[]; +} + +/** Per-iterator observation sink; adapters report intentional omissions without shared state. */ +export interface ArtifactTraversalOptions { + readonly onOmission?: (omission: ArtifactTraversalOmission) => void; +} + /** Read-only adapter over one directory, archive, or virtual container. */ export interface ArtifactReader { readonly format: "directory" | ZipPackageFormat | "asar" | "file"; - entries(signal?: AbortSignal): AsyncIterable; + entries( + signal?: AbortSignal, + options?: ArtifactTraversalOptions, + ): AsyncIterable; open(entry: ArtifactEntry, signal?: AbortSignal): Promise; provenance(): readonly ArtifactCommand[]; close(): Promise; diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 4a1d67135..399bf979b 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -14,12 +14,18 @@ import { sameArtifactSourceIdentity, type ArtifactEntry, type ArtifactReader, + type ArtifactTraversalOmission, + type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { hostExecutableMarking } from "./hostExecutableMarking.js"; import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; +interface DirectoryTraversalOptions extends ArtifactTraversalOptions { + readonly descendInto?: (path: string) => boolean; +} + /** Non-symlink-following directory reader. */ export class DirectoryArtifactReader implements ArtifactReader { readonly format = "directory" as const; @@ -35,9 +41,11 @@ export class DirectoryArtifactReader implements ArtifactReader { /** Enumerate entries, optionally restricting descent while retaining directories. */ async *entries( signal?: AbortSignal, - descendInto?: (path: string) => boolean, + selection?: DirectoryTraversalOptions | ((path: string) => boolean), ): AsyncIterable { const root = await this.#resolveRoot(); + const options = + typeof selection === "function" ? { descendInto: selection } : selection; const pending = [root]; while (pending.length > 0) { abortIfNeeded(signal); @@ -50,7 +58,7 @@ export class DirectoryArtifactReader implements ArtifactReader { directory, pending, signal, - descendInto, + options, )) yield entry; await withDirectoryIoContext("inspect directory", directory, () => @@ -75,7 +83,7 @@ export class DirectoryArtifactReader implements ArtifactReader { directory: string, pending: string[], signal?: AbortSignal, - descendInto?: (path: string) => boolean, + options?: DirectoryTraversalOptions, ): AsyncIterable { const root = await this.#resolveRoot(); const handle = new OwnedDirectoryHandle( @@ -103,10 +111,16 @@ export class DirectoryArtifactReader implements ArtifactReader { ); const path = relative(root, absolute).split(sep).join("/"); const entry = artifactEntry(path, absolute, metadata); - if (entry === undefined) continue; + if (entry === undefined) { + options?.onOmission?.({ path, kind: unsupportedEntryKind(metadata) }); + continue; + } yield entry; - if (entry.kind === "directory" && (descendInto?.(path) ?? true)) - childDirectories.push(absolute); + if (entry.kind === "directory") { + if (options?.descendInto?.(path) ?? true) + childDirectories.push(absolute); + else options?.onOmission?.({ path, kind: "directory-descendants" }); + } } } catch (cause: unknown) { traversal = { @@ -256,6 +270,19 @@ export class DirectoryArtifactReader implements ArtifactReader { } } +const unsupportedEntryKind = ( + metadata: BigIntStats, +): ArtifactTraversalOmission["kind"] => + metadata.isFIFO() + ? "fifo" + : metadata.isSocket() + ? "socket" + : metadata.isBlockDevice() + ? "block-device" + : metadata.isCharacterDevice() + ? "character-device" + : "unsupported"; + const artifactEntry = ( path: string, absolute: string, diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index 801ee4daf..93c12ece3 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -8,6 +8,7 @@ import { ArtifactReaderFailure, type ArtifactEntry, type ArtifactReader, + type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js"; import { @@ -149,13 +150,24 @@ export class NativeDmgArtifactReader implements ArtifactReader { this.host = host ?? createSystemHost(environment); } - async *entries(signal?: AbortSignal): AsyncIterable { + async *entries( + signal?: AbortSignal, + options?: ArtifactTraversalOptions, + ): AsyncIterable { await this.#ensureAttached(signal); const directory = this.#directory; if (directory === undefined) throw new ArtifactReaderFailure("unavailable", "DMG is not attached"); const prefix = basename(this.path); - const entries = directory.entries(signal)[Symbol.asyncIterator](); + const entries = directory + .entries(signal, { + onOmission: (omission) => + options?.onOmission?.({ + ...omission, + path: `${prefix}/${omission.path}`, + }), + }) + [Symbol.asyncIterator](); try { for (;;) { if (this.#closed) return; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index f195eac27..bdff4f927 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -1,5 +1,8 @@ import { AsarArtifactReader } from "../AsarArtifactReader.js"; -import type { ArtifactReader } from "../ArtifactReader.js"; +import type { + ArtifactReader, + ArtifactReaderTraversal, +} from "../ArtifactReader.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js"; import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js"; @@ -7,11 +10,10 @@ import { ZipArtifactReader } from "../ZipArtifactReader.js"; import type { ArtifactInventoryCoverage, ArtifactOccurrence, - IntegrityContradiction, } from "../../domain/artifactGraph.js"; import { isZipPackageFormat, - ZIP_NON_ENTRY_TAIL_LIMITATION, + ZIP_NON_ENTRY_LIMITATION, } from "../../domain/zipPackageFormat.js"; import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js"; @@ -40,42 +42,47 @@ export const createReader = ( } }; -/** Classify source coverage and preserve its user-facing explanation once. */ -export const inventoryAssessment = ( +/** Decide traversal scope and availability once, independently of diagnostic wording. */ +export const inventoryCoverage = ( format: ArtifactOccurrence["artifact_format"], reader: ArtifactReader | undefined, - integrityContradictions: readonly IntegrityContradiction[], - caseCollisionLimitation: string | undefined, + traversal: ArtifactReaderTraversal, ): { - readonly coverage_status: ArtifactInventoryCoverage; + readonly coverage: ArtifactInventoryCoverage; readonly limitations: string[]; } => { - let coverageStatus: ArtifactInventoryCoverage = "complete"; - const limitations: string[] = []; - if (reader !== undefined) { - if (isZipPackageFormat(reader.format)) - limitations.push(ZIP_NON_ENTRY_TAIL_LIMITATION); - } else if (format === "dmg" || format === "pkg") { - coverageStatus = "partial"; - limitations.push( - `${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`, - ); - } else if (format === "mach-o-universal" && process.platform !== "darwin") { - coverageStatus = "partial"; - limitations.push( - "Universal Mach-O slices require the native macOS lipo adapter.", - ); - } else { - // The graph's modeled scope is the root itself for recognized leaf formats. - limitations.push("Artifact has no child container entries."); - } - if (caseCollisionLimitation !== undefined) { - coverageStatus = "partial"; - limitations.push(caseCollisionLimitation); - } - if (integrityContradictions.length > 0) - limitations.push( - `${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`, - ); - return { coverage_status: coverageStatus, limitations }; + if (reader !== undefined) + return { + coverage: { scope: "container-entries", status: traversal.status }, + limitations: [ + ...(isZipPackageFormat(reader.format) + ? [ZIP_NON_ENTRY_LIMITATION] + : []), + ...traversal.limitations, + ], + }; + // dmg and pkg reach here only after classifyRootFormat matched their bytes. + if (format === "dmg" || format === "pkg") + return { + coverage: { scope: "container-entries", status: "unknown" }, + limitations: [ + `${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`, + ], + }; + if (format === "mach-o-universal") + return { + coverage: { scope: "container-entries", status: "unknown" }, + limitations: [ + "Universal Mach-O slices require the native macOS lipo adapter.", + ], + }; + if (isZipPackageFormat(format) || format === "directory" || format === "asar") + return { + coverage: { scope: "container-entries", status: "unknown" }, + limitations: [`${format} child inventory reader is unavailable.`], + }; + return { + coverage: { scope: "root", status: "complete" }, + limitations: ["Artifact has no child container entries."], + }; }; diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index d5234bdf6..2c8679e21 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -10,6 +10,7 @@ import type { RootInventorySource } from "./classify.js"; import { ArtifactReaderFailure, type ArtifactReader, + type ArtifactReaderTraversal, } from "../ArtifactReader.js"; import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js"; import { @@ -40,7 +41,7 @@ import { hashStableRootArtifact, hashStableRootArtifactHandle, } from "./hashStableRootArtifact.js"; -import { createReader, inventoryAssessment } from "./reader.js"; +import { createReader, inventoryCoverage } from "./reader.js"; import { scanReader, type PendingIntegrityContradiction, @@ -99,6 +100,7 @@ export const scanCanonicalArtifactInventoryInScope = async ( occurrences, pendingContradictions, caseCollisionLimitation, + traversal, } = await scanReader(reader, ownedReaders, options.signal, integrity); outcome = { kind: "completed", @@ -115,6 +117,7 @@ export const scanCanonicalArtifactInventoryInScope = async ( occurrences, pendingContradictions, caseCollisionLimitation, + traversal, }), }; } catch (cause: unknown) { @@ -196,6 +199,7 @@ interface SnapshotBuildInput { readonly occurrences: MutableOccurrence[]; readonly pendingContradictions: PendingIntegrityContradiction[]; readonly caseCollisionLimitation: string | undefined; + readonly traversal: ArtifactReaderTraversal; } const buildInventorySnapshot = async ( @@ -268,12 +272,7 @@ const buildInventorySnapshot = async ( orderedOccurrences, orderedEdges, }); - const assessment = inventoryAssessment( - rootFormat, - reader, - integrityContradictions, - input.caseCollisionLimitation, - ); + const traversal = inventoryCoverage(rootFormat, reader, input.traversal); return { manifest, nodes: orderedNodes, @@ -281,8 +280,14 @@ const buildInventorySnapshot = async ( edges: orderedEdges, provenance: reader?.provenance() ?? [], integrity_contradictions: integrityContradictions, - coverage_status: assessment.coverage_status, - limitations: assessment.limitations, + coverage: traversal.coverage, + coverage_status: + traversal.coverage.status === "complete" ? "complete" : "partial", + limitations: buildLimitations( + traversal.limitations, + integrityContradictions, + input.caseCollisionLimitation, + ), }; }; @@ -395,3 +400,17 @@ const buildManifest = ({ occurrence_count: orderedOccurrences.length, edge_count: orderedEdges.length, }); + +const buildLimitations = ( + scopeLimitations: readonly string[], + integrityContradictions: readonly IntegrityContradiction[], + caseCollisionLimitation: string | undefined, +): string[] => [ + ...scopeLimitations, + ...(caseCollisionLimitation === undefined ? [] : [caseCollisionLimitation]), + ...(integrityContradictions.length === 0 + ? [] + : [ + `${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`, + ]), +]; diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index 6873b14d7..38b67d843 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -9,9 +9,12 @@ import { ArtifactReaderFailure, type ArtifactEntry, type ArtifactReader, + type ArtifactReaderTraversal, + type ArtifactTraversalOmission, } from "../ArtifactReader.js"; import { AsarArtifactReader } from "../AsarArtifactReader.js"; import type { ArtifactNode } from "../../domain/artifactGraph.js"; +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import { classifyArtifactContent, classifyArtifactBytes, @@ -55,6 +58,7 @@ interface ScanContext { readonly registry: ArtifactPathRegistry; readonly expandedContainerIds: Set; readonly ownedReaders: ArtifactReader[]; + readonly omissions: ArtifactTraversalOmission[]; } export const scanReader = async ( @@ -67,6 +71,7 @@ export const scanReader = async ( readonly occurrences: MutableOccurrence[]; readonly pendingContradictions: PendingIntegrityContradiction[]; readonly caseCollisionLimitation: string | undefined; + readonly traversal: ArtifactReaderTraversal; }> => { const { nodes, occurrences, pendingContradictions } = emptyScan(); if (reader === undefined) @@ -75,6 +80,7 @@ export const scanReader = async ( occurrences, pendingContradictions, caseCollisionLimitation: undefined, + traversal: { status: "complete", limitations: [] }, }; const context: ScanContext = { reader, @@ -87,6 +93,7 @@ export const scanReader = async ( registry: new ArtifactPathRegistry(), expandedContainerIds: new Set(), ownedReaders, + omissions: [], }; await visitArtifactEntries(context, reader, ""); // Archive directories may appear after their children. Resolve containment @@ -101,9 +108,37 @@ export const scanReader = async ( occurrences, pendingContradictions, caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences), + traversal: { + status: context.omissions.length === 0 ? "complete" : "unknown", + limitations: [ + ...new Set( + context.omissions.map( + ({ path, kind }) => + `Artifact traversal omitted ${kind} at ${JSON.stringify(path)}.`, + ), + ), + ].sort(compareUnicodeCodePoints), + }, }; }; +const inventoryEntries = ( + context: ScanContext, + reader: ArtifactReader, + prefix: string, +): AsyncIterator => + reader + .entries(context.signal, { + onOmission: (omission) => + context.omissions.push({ + ...omission, + path: normalizeArtifactPath( + prefix.length === 0 ? omission.path : `${prefix}/${omission.path}`, + ), + }), + }) + [Symbol.asyncIterator](); + const visitArtifactEntries = async ( context: ScanContext, currentReader: ArtifactReader, @@ -117,7 +152,7 @@ const visitArtifactEntries = async ( { reader: currentReader, prefix, - iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](), + iterator: inventoryEntries(context, currentReader, prefix), }, ]; let failure: { readonly cause: unknown } | undefined; @@ -191,7 +226,7 @@ const visitArtifactEntries = async ( stack.push({ reader: nested, prefix: logicalPath, - iterator: nested.entries(context.signal)[Symbol.asyncIterator](), + iterator: inventoryEntries(context, nested, logicalPath), }); } } diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 1e88ca74a..9c3eae8c5 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -1,5 +1,6 @@ import type { InspectWebPageInput } from "../domain/browserObservation.js"; import type { WebPageInspection } from "../domain/browserObservationSchemas.js"; +import type { BrowserExclusionReason } from "../domain/browserCompleteness.js"; import { CdpConnection } from "./CdpConnection.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { @@ -26,6 +27,7 @@ export const captureStorage = async ( limitations: string[], ): Promise<{ readonly value: WebPageInspection["storage"]; + readonly keysExclusion: BrowserExclusionReason | undefined; }> => { const origin = new URL(pageUrl).origin; const quota = recordValue( @@ -82,7 +84,18 @@ export const captureStorage = async ( // list is an incomplete enumeration rather than an empty one. Array.isArray(cacheField) && caches.length === cacheField.length; + const keysExclusion = !context.input.include_storage_keys + ? undefined + : local.exclusion === "provider_unavailable" || + session.exclusion === "provider_unavailable" || + indexedRaw === undefined || + cacheRaw === undefined + ? "provider_unavailable" + : !local.complete || !session.complete || !structuredStorageComplete + ? "invalid_protocol_value" + : undefined; return { + keysExclusion, value: { origin, usage_bytes: numberValue(quota?.usage) ?? null, @@ -99,7 +112,11 @@ export const captureStorage = async ( }; }; -const emptyStorageItems: CapturedStorageItems = { +type StorageItemsCapture = CapturedStorageItems & { + readonly exclusion?: "provider_unavailable" | "invalid_protocol_value"; +}; + +const emptyStorageItems: StorageItemsCapture = { items: [], complete: false, }; @@ -118,7 +135,7 @@ const storageItems = async ( origin: string, isLocalStorage: boolean, limitations: string[], -): Promise => { +): Promise => { const raw = await optionalCdpCommand( context, "DOMStorage.getDOMStorageItems", @@ -127,7 +144,12 @@ const storageItems = async ( ); const entries = recordValue(raw)?.entries; if (raw === undefined || !Array.isArray(entries)) - return { items: [], complete: false }; + return { + items: [], + complete: false, + exclusion: + raw === undefined ? "provider_unavailable" : "invalid_protocol_value", + }; const items = entries.flatMap((entry) => { if (!Array.isArray(entry)) return []; const key = cdpStringValue(entry[0]); diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts index 02a52b3be..ec7a06850 100644 --- a/src/browser/CdpCaptureStorageFingerprints.ts +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -193,7 +193,9 @@ const addIndexedDb = async ( scope: "indexed_db_schema", identity: databaseName, value: stores.map(indexedDbStoreSchema), - complete: indexedDbSchemaComplete(stores), + complete: + stores.length === database.objectStores.length && + indexedDbSchemaComplete(stores), }); for (const store of stores) { const objectStoreName = cdpStringValue(store.name); @@ -228,19 +230,37 @@ const indexedDbSchemaComplete = ( stores.every( (store) => cdpStringValue(store.name) !== undefined && - stableValueComplete(store.keyPath) && + indexedDbKeyPathComplete(store.keyPath) && typeof store.autoIncrement === "boolean" && Array.isArray(store.indexes) && recordsValue(store.indexes).length === store.indexes.length && recordsValue(store.indexes).every( (index) => cdpStringValue(index.name) !== undefined && - stableValueComplete(index.keyPath) && + indexedDbKeyPathComplete(index.keyPath) && typeof index.unique === "boolean" && typeof index.multiEntry === "boolean", ), ); +const indexedDbKeyPathComplete = (value: unknown): boolean => { + const keyPath = recordValue(value); + if (keyPath === undefined) return false; + switch (keyPath.type) { + case "null": + return true; + case "string": + return typeof keyPath.string === "string"; + case "array": + return ( + Array.isArray(keyPath.array) && + keyPath.array.every((key) => typeof key === "string") + ); + default: + return false; + } +}; + const addIndexedDbRecords = async ( run: FingerprintRun, input: { @@ -474,14 +494,49 @@ const stableValueComplete = (value: unknown): boolean => { const remoteObjectComplete = (value: unknown): boolean => { const record = recordValue(value); - if (record === undefined) return true; - return ( - record.objectId === undefined && - record.preview === undefined && - record.customPreview === undefined && - (record.type !== "object" || record.value !== undefined) && - stableValueComplete(record) - ); + if ( + record === undefined || + record.objectId !== undefined || + record.preview !== undefined || + record.customPreview !== undefined || + !stableValueComplete(record) + ) + return false; + switch (record.type) { + case "undefined": + return ( + record.value === undefined && record.unserializableValue === undefined + ); + case "string": + case "boolean": + return ( + typeof record.value === record.type && + record.unserializableValue === undefined + ); + case "number": + return record.unserializableValue === undefined + ? typeof record.value === "number" && Number.isFinite(record.value) + : record.value === undefined && + typeof record.unserializableValue === "string" && + ["-0", "NaN", "Infinity", "-Infinity"].includes( + record.unserializableValue, + ); + case "bigint": + return ( + record.value === undefined && + typeof record.unserializableValue === "string" && + /^-?\d+n$/u.test(record.unserializableValue) + ); + case "object": + return ( + record.unserializableValue === undefined && + (record.subtype === "null" + ? record.value === null + : record.value !== null && typeof record.value === "object") + ); + default: + return false; + } }; const decodeBase64 = (value: string): Buffer | null => { diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index f49d4e186..337f99dfa 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -155,6 +155,12 @@ const captureAuthorizedPage = async ( ); if (!input.include_storage_keys) state.events.completeness.exclude("storage_keys", "not_approved", null); + else if (storageCapture.keysExclusion !== undefined) + state.events.completeness.exclude( + "storage_keys", + storageCapture.keysExclusion, + null, + ); await report(context.progress, 3, "Normalizing browser evidence"); const completedFrameResult = await authorizedMainFrame({ connection: context.connection, diff --git a/src/browser/history/QlogCapture.test.ts b/src/browser/history/QlogCapture.test.ts index bd67d4e80..fb4e10072 100644 --- a/src/browser/history/QlogCapture.test.ts +++ b/src/browser/history/QlogCapture.test.ts @@ -186,6 +186,21 @@ it("excludes structural auth material and explicit normalized values without gue expect(traces[0]?.incomplete_fact_records).toContain(2); }); +it("omits a hidden derived total for a stream with more contributions than call arguments allow", () => { + const events = Array.from({ length: 130 }, (_, index) => + event( + Array.from({ length: 1_000 }, (_, offset) => + frame(index * 1_000 + offset, 1), + ), + ), + ); + const { traces } = facts(capture(events), ["130000"]); + expect(traces[0]?.streams).toEqual([]); + expect(traces[0]?.incomplete_fact_records).toEqual( + Array.from({ length: 130 }, (_, ordinal) => ordinal), + ); +}); + it("hides marked ancestor keys and safe derived sums while still validating hidden subtrees", () => { const text = capture([event([frame(0, 3), frame(3, 4)])]); expect(facts(text, ["7"]).traces[0]?.streams).toEqual([]); diff --git a/src/browser/history/QlogCapture.ts b/src/browser/history/QlogCapture.ts index 2e8a1f790..8d19fc64f 100644 --- a/src/browser/history/QlogCapture.ts +++ b/src/browser/history/QlogCapture.ts @@ -95,10 +95,10 @@ export const decodeQlogCapture = ( ...stream.reported_final_sizes, ...stream.covered_ranges.flatMap((r) => [r.start, r.end]), ]; + // A stream can have more contributions than call arguments allow. if (numericValues.some(excluded)) - fact.incomplete_fact_records.push( - ...stream.contributions.map((c) => c.record_ordinal), - ); + for (const contribution of stream.contributions) + fact.incomplete_fact_records.push(contribution.record_ordinal); else fact.streams.push(stream); } fact.incomplete_fact_records = [...new Set(fact.incomplete_fact_records)]; diff --git a/src/cli.ts b/src/cli.ts index d1bdf2912..a12d1e327 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; import { registerGoCommands } from "./cli/goCommands.js"; +import { registerRustCommands } from "./cli/rustCommands.js"; import { registerSqliteCommands } from "./cli/sqliteCommands.js"; import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; @@ -48,6 +49,8 @@ import { streamCliCommandResults, type CliResultOutput, } from "./cli/cliResultOutput.js"; +import { registerReverseEngineeringCommands } from "./cli/reverseEngineeringCommands.js"; +import { registerCutterCommands } from "./cli/cutterCommands.js"; /** Describe CLI settings that are invalid before any command runs, as MCP does. */ export const cliConfigurationError = ( @@ -94,6 +97,8 @@ export const createCli = ( }); registerSetupCommands(cli, logger, environment); + registerReverseEngineeringCommands(cli, logger, environment); + registerCutterCommands(cli, logger, environment); registerCoreAnalysisCommands(cli, logger, analysis.runDirectAnalysis); registerUtilityCommands(cli, logger, environment, analysis); registerWasmCommands(cli, logger, environment); @@ -101,6 +106,7 @@ export const createCli = ( registerSignalCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); registerGoCommands(cli, logger); + registerRustCommands(cli, logger); registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); registerManagedCommands(cli, logger, analysis.runProviderAnalysis); diff --git a/src/cli/cutterCommands.ts b/src/cli/cutterCommands.ts new file mode 100644 index 000000000..2b8ce574b --- /dev/null +++ b/src/cli/cutterCommands.ts @@ -0,0 +1,69 @@ +import { z } from "incur"; +import type { Logger } from "pino"; + +import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js"; +import { CutterBridgeService } from "../cutter/CutterBridgeService.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import type { CliInstance } from "./types.js"; + +/** Register commands that control an already-running Cutter instance. */ +export const registerCutterCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + const cutter = new CutterBridgeService(new CutterBridgeClient(environment)); + + cli.command(CLI_COMMANDS.listCutterSessions, { + description: + "List live Cutter instances connected through the REA Python plugin bridge", + args: z.object({}), + run: () => + logCliCommand(logger, CLI_COMMANDS.listCutterSessions, () => + cutter.listSessions(), + ), + }); + cli.command(CLI_COMMANDS.cutterCommand, { + description: "Execute one Rizin command in a selected live Cutter instance", + args: z.object({ + sessionId: z + .string() + .uuid() + .describe("Session ID copied from list-cutter-sessions."), + expectedGeneration: z.coerce + .number() + .int() + .nonnegative() + .describe("Session generation returned by list-cutter-sessions."), + command: z + .string() + .min(1) + .describe("One unrestricted Rizin command to run in Cutter.") + .refine((value) => !/[\r\n]/u.test(value)), + }), + options: z.object({ + json: z + .boolean() + .default(false) + .describe("Return command-specific JSON output when available."), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.cutterCommand, async () => { + const result = await cutter.execute( + { + session_id: args.sessionId, + expected_generation: args.expectedGeneration, + command: args.command, + json: options.json, + }, + { signal }, + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index eee4eae5a..782549720 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -56,13 +56,13 @@ const registerInspectManagedMembers = ( ): void => { cli.command(CLI_COMMANDS.inspectManagedMembers, { description: - "Inspect PE/CLI metadata members, signatures, and CIL anchors without loading target code", + "Inspect PE/CLI metadata members, signatures, CIL anchors, and ldstr string literals without loading target code", args: z.object({ path: z.string().describe("Managed PE executable or assembly path"), }), options: z.object({ type: managedMembersInputSchema.shape.type_names.describe( - "Inspect only members declared by this exact type full name, such as System.Threading.Monitor; a nested type is named by its bare name, which selects every nested type of that name (repeatable)", + "Inspect only members declared by this exact type full name, and the string literals they load, such as System.Threading.Monitor; a nested type is named by its bare name, which selects every nested type of that name (repeatable)", ), }), run: ({ args, options }) => diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts new file mode 100644 index 000000000..1feb9184d --- /dev/null +++ b/src/cli/reverseEngineeringCommands.ts @@ -0,0 +1,367 @@ +import { z } from "incur"; +import type { Logger } from "pino"; + +import { createReverseEngineeringService } from "../composition/reverseEngineering.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { evidenceSchema, type Evidence } from "../domain/evidence.js"; +import type { Result } from "../domain/result.js"; +import { err } from "../domain/result.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { logCliCommand } from "../cliLogging.js"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js"; +import { + GdbSessionManager, + gdbOneShotCommandMayInvalidateDetachProof, +} from "../gdb/GdbSessionManager.js"; +import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js"; + +const executeWithCleanup = async ( + service: ReverseEngineeringService, + execute: () => Promise>, +): Promise> => { + let result: Result; + try { + result = await execute(); + } catch (cause) { + await service.close().catch(() => undefined); + throw cause; + } + const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete; + try { + await service.close(); + } catch (cause) { + if (result.ok) return err(asCleanupError(cause)); + const cleanup = cause instanceof AnalysisError ? cause.cleanup : undefined; + const error = new AnalysisOutputError( + "reverse_engineering_cleanup", + "Provider cleanup remains incomplete after the command failed.", + { + cause: result.error, + ...(cleanup === undefined ? {} : { cleanup }), + ...(result.error.capturedOutput === undefined + ? {} + : { capturedOutput: result.error.capturedOutput }), + ...(result.error.partialObservation === undefined + ? {} + : { partialObservation: result.error.partialObservation }), + }, + ); + return err(error); + } + if (!result.ok && hadIncompleteCleanup && !result.error.cleanupIncomplete) { + const evidence = cleanupOnlyCompleteEvidence(result.error); + if (evidence !== undefined) return { ok: true, value: evidence }; + } + return result; +}; + +const cleanupOnlyCompleteEvidence = ( + error: AnalysisError, +): Evidence | undefined => { + if ( + !(error instanceof AnalysisOutputError) || + !error.cleanupOnly || + error.partialObservation === undefined + ) + return undefined; + const parsed = evidenceSchema.safeParse(error.partialObservation); + if (!parsed.success) return undefined; + const normalized = parsed.data.normalized_result; + return typeof normalized === "object" && + normalized !== null && + !Array.isArray(normalized) && + normalized.completion_status === "complete" + ? parsed.data + : undefined; +}; + +const asCleanupError = (cause: unknown): AnalysisError => + cause instanceof AnalysisError + ? cause + : new AnalysisOutputError( + "reverse_engineering_cleanup", + "Provider process cleanup failed.", + { cause }, + ); + +/** Add one-shot reverse-engineering commands using the shared service layer. */ +export const registerReverseEngineeringCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, + createGdbManager: ( + environment: Readonly, + ) => GdbSessionManager = (providerEnvironment) => + new GdbSessionManager({ environment: providerEnvironment }), + createService: typeof createReverseEngineeringService = createReverseEngineeringService, +): void => { + const service = createService(environment); + cli.command(CLI_COMMANDS.inspectWithObjdump, { + description: + "Inspect a local artifact with GNU objdump and retain its raw output as Evidence", + args: z.object({ + path: z + .string() + .min(1) + .describe("Path to the local artifact to inspect."), + operation: z + .enum([ + "file_headers", + "section_headers", + "symbols", + "relocations", + "dwarf", + "disassemble", + "disassemble_all", + ]) + .describe("GNU objdump inspection operation to run."), + }), + options: z.object({ + followDebugLinks: z + .boolean() + .default(false) + .describe( + "Follow local DWARF debug links. External debug-file identity is not bound to Evidence.", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => { + const result = await executeWithCleanup(service, () => + service.inspectWithObjdump( + { + ...args, + follow_debug_links: options.followDebugLinks, + }, + { signal }, + ), + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); + cli.command(CLI_COMMANDS.listObjdumpArchitectures, { + description: "List BFD targets advertised by the installed GNU objdump", + args: z.object({}), + run: () => + withCommandCancellation((signal) => + logCliCommand( + logger, + CLI_COMMANDS.listObjdumpArchitectures, + async () => { + const result = await executeWithCleanup(service, () => + service.inspectWithObjdump( + { operation: "architectures" }, + { signal }, + ), + ); + return result.ok + ? result.value + : projectAnalysisError(result.error); + }, + ), + ), + }); + cli.command(CLI_COMMANDS.executeRizinCommand, { + description: "Execute one Rizin command against a local artifact", + args: z.object({ + path: z + .string() + .min(1) + .describe("Path to the local artifact to analyze."), + command: z.string().min(1).describe("One caller-selected Rizin command."), + }), + run: ({ args }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => { + const result = await executeWithCleanup(service, () => + service.executeRizinCommand(args, { signal }), + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); + cli.command(CLI_COMMANDS.debugWithGdb, { + description: + "Run one unrestricted GDB console command in an ephemeral MI session; provide an inferior TTY so a live target can be safely detached", + args: z.object({ + command: z + .string() + .min(1) + .describe("One unrestricted GDB console command."), + }), + options: z.object({ + inferiorTty: z + .string() + .min(1) + .describe( + "Caller-selected durable TTY for output from a live GDB inferior.", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => { + if (gdbOneShotCommandMayInvalidateDetachProof(args.command)) + return projectAnalysisError( + new AnalysisInputError("gdb_console", undefined, [ + { + path: ["command"], + reason: "invalid_value", + message: + "This command can invalidate the verified inferior output route; use the persistent GDB session workflow.", + }, + ]), + ); + const manager = createGdbManager(environment); + let sessionId: string | undefined; + try { + const started = await manager.start(signal, { + inferiorTty: options.inferiorTty, + }); + if (!started.ok) return projectAnalysisError(started.error); + sessionId = started.value.session_id; + const executed = await manager.execute( + sessionId, + args.command, + signal, + ); + return executed.ok + ? executed.value.evidence + : projectAnalysisError(executed.error); + } finally { + let cleanupFailed = false; + if (sessionId !== undefined) { + try { + const closed = await manager.close(sessionId); + cleanupFailed = !closed.ok; + if (!closed.ok) + logger.error( + { + command: CLI_COMMANDS.debugWithGdb, + cleanup: projectAnalysisError(closed.error), + }, + "GDB cleanup failed after collecting command output", + ); + } catch (cause) { + cleanupFailed = true; + logger.error( + { + command: CLI_COMMANDS.debugWithGdb, + cleanup: + cause instanceof Error + ? cause.message + : "Unknown cleanup failure", + }, + "GDB cleanup failed after collecting command output", + ); + } + } + try { + await manager.closeAll(); + cleanupFailed = false; + } catch (cause) { + cleanupFailed = true; + logger.error( + { + command: CLI_COMMANDS.debugWithGdb, + cleanup: + cause instanceof Error + ? cause.message + : "Unknown cleanup failure", + }, + "GDB fallback cleanup failed", + ); + } + if (cleanupFailed) process.exitCode = 1; + } + }), + ), + }); + cli.command(CLI_COMMANDS.debugWithRizin, { + description: + "Run one unrestricted Rizin debugger command in an ephemeral session", + args: z.object({ + path: z.string().min(1).describe("Path to the local artifact to debug."), + command: z + .string() + .min(1) + .describe("One unrestricted Rizin debugger command.") + .refine((value) => !/[\r\n]/u.test(value)), + }), + options: z.object({ + inferiorTty: z + .string() + .min(1) + .describe("Caller-selected Linux TTY for inferior stdout and stderr."), + backend: z + .string() + .min(1) + .optional() + .describe("Optional Rizin IO debugger backend name."), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.debugWithRizin, async () => { + const manager = new RizinDebugSessionManager(environment); + let sessionId: string | undefined; + try { + const started = await manager.start( + { + ...args, + inferior_tty: options.inferiorTty, + ...(options.backend === undefined + ? {} + : { backend: options.backend }), + }, + signal, + ); + if (!started.ok) return projectAnalysisError(started.error); + sessionId = started.value.session_id; + const executed = await manager.execute( + started.value.session_id, + args.command, + signal, + ); + return executed.ok + ? executed.value.evidence + : projectAnalysisError(executed.error); + } finally { + const closed = + sessionId === undefined + ? undefined + : await manager.close(sessionId); + if (closed !== undefined && !closed.ok) { + logger.error( + { + command: CLI_COMMANDS.debugWithRizin, + cleanup: projectAnalysisError(closed.error), + }, + "Rizin cleanup failed after collecting command output", + ); + process.exitCode = 1; + } + await manager.closeAll().catch((cause: unknown) => { + process.exitCode = 1; + logger.error( + { + command: CLI_COMMANDS.debugWithRizin, + cleanup: + cause instanceof Error + ? cause.message + : "Unknown cleanup failure", + }, + "Rizin fallback cleanup failed", + ); + }); + } + }), + ), + }); +}; diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts new file mode 100644 index 000000000..fc3f8ee85 --- /dev/null +++ b/src/cli/rustCommands.ts @@ -0,0 +1,34 @@ +import { z } from "incur"; +import { resolve } from "node:path"; +import { createRustBinaryService } from "../composition/rust.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Resolve caller paths and inspect embedded Rust dependencies through the shared workflow. */ +export const registerRustCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + const service = createRustBinaryService(); + cli.command(CLI_COMMANDS.inspectRustBinary, { + description: + "Inspect an embedded cargo-auditable Rust dependency list without target execution", + args: z.object({ + path: z.string().describe("Local ELF, PE or thin Mach-O image"), + }), + run: ({ args }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => { + const result = await service.inspect( + { path: resolve(args.path) }, + { signal }, + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index ff208c62e..798451158 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -49,7 +49,16 @@ export const CLI_COMMANDS = Object.freeze({ inspectManagedArtifact: "inspect-managed-artifact", inspectRecordedCrash: "inspect-recorded-crash", inspectBinaryLayout: "inspect-binary-layout", + listCutterSessions: "list-cutter-sessions", + cutterCommand: "cutter-command", + inspectWithObjdump: "inspect-with-objdump", + listObjdumpArchitectures: "list-objdump-architectures", + executeRizinCommand: "execute-rizin-command", + debugWithGdb: "debug-with-gdb", + debugWithRizin: "debug-with-rizin", + inspectGoBinary: "inspect-go-binary", + inspectRustBinary: "inspect-rust-binary", inspectPeResources: "inspect-pe-resources", inspectAnalysisView: "inspect-analysis-view", listFridaDevices: "list-frida-devices", @@ -163,6 +172,13 @@ export const CLI_COMMAND_TOOL_ALIASES = Object.freeze({ search: ["search_strings", "search_procedures"], "capture-process": ["capture_process_scenario"], "compare-bundles": ["compare_bundles"], + "list-cutter-sessions": ["list_cutter_sessions"], + "cutter-command": ["cutter_command"], + "inspect-with-objdump": ["inspect_with_objdump"], + "list-objdump-architectures": ["inspect_with_objdump"], + "execute-rizin-command": ["execute_rizin_command"], + "debug-with-gdb": ["gdb_console"], + "debug-with-rizin": ["rizin_debug_command"], "list-frida-devices": ["list_frida_devices"], "list-frida-processes": ["list_frida_processes"], "instrument-with-frida": ["instrument_with_frida"], @@ -223,6 +239,12 @@ export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([ "get_evidence_bundle", "get_navigation_context", "inspect_address_context", + "start_gdb_session", + "gdb_session_status", + "close_gdb_session", + "start_rizin_debug_session", + "rizin_debug_session_status", + "close_rizin_debug_session", "start_frida_session", "load_frida_script", "resume_frida_session", diff --git a/src/composition/resolveWindowsProviderExecutable.ts b/src/composition/resolveWindowsProviderExecutable.ts new file mode 100644 index 000000000..da4079056 --- /dev/null +++ b/src/composition/resolveWindowsProviderExecutable.ts @@ -0,0 +1,77 @@ +import { existsSync, statSync } from "node:fs"; +import { win32 } from "node:path"; + +export interface WindowsExecutableLookup { + /** Windows-formatted working directory; defaults to the process CWD. */ + readonly cwd?: string; + /** Test seam for Windows paths when running on a non-Windows host. */ + readonly isFile?: (path: string) => boolean; +} + +const splitWindowsPath = (value: string): readonly string[] => { + const entries: string[] = []; + let start = 0; + let quoted = false; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '"') quoted = !quoted; + else if (value[index] === ";" && !quoted) { + entries.push(value.slice(start, index)); + start = index + 1; + } + } + entries.push(value.slice(start)); + return entries.flatMap((entry) => { + if (entry.startsWith('"') && entry.endsWith('"') && entry.length >= 2) + return [entry.slice(1, -1)]; + return entry.includes('"') ? [] : [entry]; + }); +}; + +const isRegularFile = (path: string): boolean => { + try { + return existsSync(path) && statSync(path).isFile(); + } catch { + return false; + } +}; + +/** Resolve Windows bare command names against the captured child environment. */ +export const resolveWindowsProviderExecutable = ( + executable: string, + environment: Readonly, + lookup: WindowsExecutableLookup = {}, +): string => { + const cwd = lookup.cwd ?? process.cwd(); + const absolute = (...paths: string[]): string => win32.resolve(cwd, ...paths); + if ( + win32.isAbsolute(executable) || + executable.includes("/") || + executable.includes("\\") || + win32.parse(executable).root !== "" + ) + return absolute(executable); + + const pathValue = environment.PATH ?? environment.Path ?? environment.path; + if (pathValue === undefined) return executable; + const pathEntries = splitWindowsPath(pathValue); + const explicitExtension = win32.extname(executable).toLowerCase(); + if (explicitExtension !== "" && ![".com", ".exe"].includes(explicitExtension)) + return executable; + const extensions = + explicitExtension !== "" + ? [""] + : (environment.PATHEXT ?? environment.PathExt ?? ".COM;.EXE") + .split(";") + .filter((extension) => + [".com", ".exe"].includes(extension.toLowerCase()), + ); + + for (const entry of pathEntries) { + if (entry.length === 0) continue; + for (const extension of extensions) { + const candidate = absolute(entry, `${executable}${extension}`); + if ((lookup.isFile ?? isRegularFile)(candidate)) return candidate; + } + } + return executable; +}; diff --git a/src/composition/reverseEngineering.ts b/src/composition/reverseEngineering.ts new file mode 100644 index 000000000..493e86b9c --- /dev/null +++ b/src/composition/reverseEngineering.ts @@ -0,0 +1,59 @@ +import type { ReverseEngineeringServiceRuntimeOptions } from "../application/reverse/ReverseEngineeringService.js"; +import type { ReverseEngineeringCommandPort } from "../application/reverse/ReverseEngineeringCommandPort.js"; +import { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js"; +import { + objdumpCommand, + OBJDUMP_PROVIDER_IDENTITY, +} from "../objdump/ObjdumpCommand.js"; +import { + rizinCommand, + RIZIN_PROVIDER_IDENTITY, +} from "../rizin/RizinCommand.js"; +import { resolveWindowsProviderExecutable } from "./resolveWindowsProviderExecutable.js"; + +export const createReverseEngineeringCommandPort = ( + environment: Readonly, + platform: NodeJS.Platform = process.platform, +): ReverseEngineeringCommandPort => ({ + objdump: (input) => ({ + executable: providerExecutable( + environment.REA_OBJDUMP_COMMAND ?? "objdump", + environment, + platform, + ), + arguments: objdumpCommand(input), + identity: OBJDUMP_PROVIDER_IDENTITY, + environmentOverrides: { DEBUGINFOD_URLS: "" }, + }), + rizin: (input) => ({ + executable: providerExecutable( + environment.REA_RIZIN_COMMAND ?? "rizin", + environment, + platform, + ), + arguments: rizinCommand(input), + identity: RIZIN_PROVIDER_IDENTITY, + }), +}); + +const providerExecutable = ( + executable: string, + environment: Readonly, + platform: NodeJS.Platform, +): string => + platform === "win32" + ? resolveWindowsProviderExecutable(executable, environment) + : executable; + +/** Compose BYO reverse-engineering commands without acquiring tools at import time. */ +export const createReverseEngineeringService = ( + environment: Readonly = process.env, + options: ReverseEngineeringServiceRuntimeOptions = {}, +): ReverseEngineeringService => { + const environmentSnapshot = { ...environment }; + return new ReverseEngineeringService({ + ...options, + environment: environmentSnapshot, + commands: createReverseEngineeringCommandPort(environmentSnapshot), + }); +}; diff --git a/src/composition/rust.ts b/src/composition/rust.ts new file mode 100644 index 000000000..5a05eceb3 --- /dev/null +++ b/src/composition/rust.ts @@ -0,0 +1,6 @@ +import { RustBinaryService } from "../application/rust/RustBinaryService.js"; +import { RustBinaryProvider } from "../rust/RustBinaryProvider.js"; + +/** Compose bundled offline inspection without a Rust toolchain or any child process. */ +export const createRustBinaryService = (): RustBinaryService => + new RustBinaryService(new RustBinaryProvider()); diff --git a/src/config.test.ts b/src/config.test.ts index 67db7e111..efa1b48fc 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -249,6 +249,40 @@ describe("configuration failure diagnostics", () => { }, ); + it.each(["1", "90000", "2147483647"])( + "accepts Swift compile deadline %s without changing Hopper's deadline", + (value) => { + expect( + parseConfig({ REA_SWIFTC_COMPILE_TIMEOUT_MS: value }), + ).toMatchObject({ + ok: true, + value: { + hopperStartupTimeoutMs: 120_000, + }, + }); + }, + ); + + it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])( + "rejects malformed Swift compile deadline %j with its setting", + (value) => { + const result = parseConfig({ REA_SWIFTC_COMPILE_TIMEOUT_MS: value }); + if (result.ok) throw new Error("Expected invalid Swift compile deadline"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "configuration_invalid", + category: "invalid_input", + details: { + settings: [ + { + setting: "REA_SWIFTC_COMPILE_TIMEOUT_MS", + constraint: expect.any(String), + }, + ], + }, + }); + }, + ); + it.each([ ["GHIDRA_INSTALL_DIR", "./fixture"], ["JAVA_HOME", "./jdk"], diff --git a/src/config/environment.ts b/src/config/environment.ts index 7dcd955d0..121d46614 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -12,6 +12,7 @@ import { ghidraDisabledAnalyzersSchema } from "./ghidraDisabledAnalyzers.js"; import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS, DEFAULT_HOPPER_STARTUP_TIMEOUT_MS, + DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS, providerStartupTimeoutSchema, } from "./providerStartupTimeout.js"; import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js"; @@ -47,6 +48,9 @@ const environmentSchema = z.object({ REA_HOPPER_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default( DEFAULT_HOPPER_STARTUP_TIMEOUT_MS, ), + REA_SWIFTC_COMPILE_TIMEOUT_MS: providerStartupTimeoutSchema.default( + DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS, + ), REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(), REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(), REA_GHIDRA_DISABLED_ANALYZERS: ghidraDisabledAnalyzersSchema.optional(), diff --git a/src/config/providerStartupTimeout.ts b/src/config/providerStartupTimeout.ts index 27c0116de..eb3b9d52f 100644 --- a/src/config/providerStartupTimeout.ts +++ b/src/config/providerStartupTimeout.ts @@ -6,6 +6,10 @@ export const DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS = 330_000; /** Default absolute deadline for Hopper launch, analysis, bridge, and readiness. */ export const DEFAULT_HOPPER_STARTUP_TIMEOUT_MS = 120_000; +/** Default deadline for Darwin ownership-helper `swiftc` compilation. */ +/** Fresh Swift SDK cache took ~88s on the supported Intel macOS VM. */ +export const DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS = 120_000; + // Node schedules delays above this signed 32-bit boundary as a 1 ms timer. const MAX_TIMER_DELAY_MS = 2_147_483_647; diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts index b121c8846..e72b603d7 100644 --- a/src/contracts/adb/adbToolContracts.ts +++ b/src/contracts/adb/adbToolContracts.ts @@ -127,7 +127,7 @@ export const ADB_TOOL_CONTRACTS = [ name: "push_adb_file", ...toolContractMetadata("push_adb_file"), description: - "Push one local file to an absolute device path. The only device-mutating ADB tool: existing device files are refused unless overwrite is explicitly requested, and the transfer is verified by comparing the local digest with the device's own sha256sum when the device provides one.", + "Push one local file to an absolute device path. A device-mutating tool: existing device files are refused unless overwrite is explicitly requested, and the transfer is verified by comparing the local digest with the device's own sha256sum when the device provides one.", kind: "adb-provider", inputSchema: adbInputSchemas.push_adb_file, outputSchema: evidenceResultOf(adbResultSchemas.push_adb_file), diff --git a/src/contracts/cutterToolContracts.ts b/src/contracts/cutterToolContracts.ts new file mode 100644 index 000000000..e978251b8 --- /dev/null +++ b/src/contracts/cutterToolContracts.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { ToolContract } from "./toolContractTypes.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; + +/** List live Cutter plugin bridge sessions. */ +export const cutterListInputSchema = z.strictObject({}); + +/** Execute a command in a selected live Cutter instance and observed document generation. */ +export const cutterCommandInputSchema = z.strictObject({ + session_id: z.string().uuid(), + expected_generation: z.number().int().nonnegative(), + command: z + .string() + .min(1) + .refine( + (value) => !/[\r\n]/u.test(value), + "A single Rizin command line is required", + ), + json: z.boolean().default(false), +}); + +export const cutterSessionSchema = z.strictObject({ + session_id: z.string().uuid(), + pid: z.number().int().positive(), + document_generation: z.number().int().nonnegative(), + current_file: z.string().nullable(), + cutter_version: z.string().nullable(), + identity_status: z.literal("partial"), +}); + +export const cutterCommandResultSchema = z.strictObject({ + command: z.string(), + output: jsonValueSchema.nullable(), + execution_state: z.enum(["not_started", "complete", "failed", "unknown"]), + error: z.string().nullable(), + message: z.string().nullable(), + output_truncated: z.boolean(), + cutter_version: z.string().nullable(), + current_file: z.string().nullable(), + document_generation: z.number().int().nonnegative(), + identity_status: z.literal("partial"), +}); + +export const cutterDiscoverySchema = z.strictObject({ + sessions: z.array(cutterSessionSchema), + bridge_directory: z.string(), + discovery_status: z.enum([ + "sessions_found", + "no_live_bridge_found", + "bridge_directory_unavailable", + "bridge_directory_insecure", + "bridge_directory_capacity_exceeded", + ]), + bridge_directory_security: z.enum([ + "private_verified", + "not_private", + "unverified_platform_acl", + "not_checked", + ]), +}); + +/** Live Cutter discovery and arbitrary Rizin command contracts. */ +export const CUTTER_TOOL_CONTRACTS = [ + { + name: "list_cutter_sessions", + ...toolContractMetadata("list_cutter_sessions"), + kind: "native-provider", + description: + "List live REA bridge sessions hosted by upstream Cutter instances, including observed document identity and bridge-directory security status. Discovery admits at most 256 descriptor candidates (16 MiB at the descriptor byte limit), reads and probes at most eight concurrently, and reports bridge_directory_capacity_exceeded rather than claiming no sessions when that budget is exceeded.", + inputSchema: cutterListInputSchema, + outputSchema: cutterDiscoverySchema, + examples: [{ title: "List open Cutter instances", input: {} }], + }, + { + name: "cutter_command", + ...toolContractMetadata("cutter_command"), + kind: "native-provider", + description: + "Run one arbitrary Rizin command in a selected live Cutter session. Commands may edit and persist analysis state or execute plugin-provided effects; completion uncertainty is returned as Evidence and must not be retried automatically. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + inputSchema: cutterCommandInputSchema, + outputSchema: evidenceResultOf(cutterCommandResultSchema), + examples: [ + { + title: "Inspect active binary metadata", + input: { + session_id: "00000000-0000-4000-8000-000000000001", + expected_generation: 0, + command: "ij", + json: true, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/gdbToolContracts.ts b/src/contracts/gdbToolContracts.ts new file mode 100644 index 000000000..f1858a14a --- /dev/null +++ b/src/contracts/gdbToolContracts.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; + +import type { ToolContract } from "./toolContractTypes.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; + +const sessionId = z.string().uuid(); +const gdbCommandResult = z.strictObject({ + command: z.string(), + mi: z.string(), + records: z.array(z.string()), + console: z.string(), + target: z.string(), + log: z.string(), + process_exit_observed: z.boolean().optional(), + process_exit_code: z.number().int().nullable().optional(), + process_exit_signal: z.string().nullable().optional(), + output_truncated: z.boolean(), + output_invalid_utf8: z.literal(true).optional(), + completion_status: z.enum(["complete", "unknown"]), +}); + +/** Persistent GDB/MI session contracts hosted by the existing REA MCP server. */ +export const GDB_TOOL_CONTRACTS = [ + { + name: "start_gdb_session", + ...toolContractMetadata("start_gdb_session"), + kind: "native-provider", + description: + "Start a BYO GDB process in MI3 mode when available, suppress startup files, and disable auto-loading. Persistent GDB sessions are available only on Linux, where REA can verify live inferior output descriptors through /proc before detaching; macOS descriptor verification is not implemented, and Windows Job Object cleanup can terminate debugger inferiors. Optionally set inferior_tty to a caller-selected terminal for future inferior runs; REA verifies the setting through GDB/MI before the session becomes ready. The selected terminal must remain available after GDB exits. A live inferior can be detached during close only while this durable output sink remains verified. The session persists in this REA MCP server process and receives an explicit session ID.", + inputSchema: z.strictObject({ + inferior_tty: z + .string() + .min(1) + .optional() + .describe( + "Caller-selected terminal path for future inferior runs. GDB must report the exact configured path before the session is ready.", + ), + }), + outputSchema: z.strictObject({ + session_id: sessionId, + mi_version: z.enum(["mi3", "mi2"]), + }), + examples: [{ title: "Start GDB", input: {} }], + }, + { + name: "gdb_console", + ...toolContractMetadata("gdb_console"), + kind: "native-provider", + description: + "Run one unrestricted GDB CLI command through MI interpreter-exec console and return raw MI, console, target, and log records inline with Evidence. A command timeout preserves collected records with completion_status unknown and stops the owned debugger session. Commands can execute shell, alter target state, access files, and use network. Closing the debugger never implies target termination. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + inputSchema: z.strictObject({ + session_id: sessionId, + command: z.string().min(1), + }), + outputSchema: evidenceResultOf(gdbCommandResult), + examples: [ + { + title: "Inspect loaded symbols", + input: { + session_id: "00000000-0000-4000-8000-000000000001", + command: "info functions", + }, + }, + ], + }, + { + name: "gdb_session_status", + ...toolContractMetadata("gdb_session_status"), + kind: "native-provider", + description: + "Return the observed lifecycle state and bounded recent MI records and process diagnostics for one persistent REA GDB session; truncation and invalid-UTF-8 fields report discarded or lossy data.", + inputSchema: z.strictObject({ session_id: sessionId }), + outputSchema: z.strictObject({ + session_id: sessionId, + mi_version: z.enum(["mi3", "mi2"]), + state: z.enum(["ready", "closed"]), + recent_mi_records: z.array(z.string()), + recent_mi_records_truncated: z.boolean(), + mi_invalid_utf8: z.boolean(), + stderr: z.string(), + stderr_observed_bytes: z.number().int().nonnegative(), + stderr_truncated: z.boolean(), + stderr_invalid_utf8: z.boolean(), + diagnostics_truncated: z.boolean(), + inferior_tty: z.string().nullable(), + inferior_tty_verified: z.boolean(), + }), + examples: [ + { + title: "Check GDB session", + input: { session_id: "00000000-0000-4000-8000-000000000001" }, + }, + ], + }, + { + name: "close_gdb_session", + ...toolContractMetadata("close_gdb_session"), + kind: "native-provider", + description: + "Close the owned GDB process after verifying the inferior state. When one live thread group has a verified durable output TTY, REA automatically detaches it, which may resume target execution; otherwise close reports that a disposition is required. REA never kills attached or remote targets as part of debugger cleanup. In a shared MCP server, this admitted close retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + inputSchema: z.strictObject({ session_id: sessionId }), + outputSchema: z.strictObject({ + target_state: z.enum(["none_observed", "unknown"]), + }), + examples: [ + { + title: "Close GDB session", + input: { session_id: "00000000-0000-4000-8000-000000000001" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index 1e685a702..2c0088b9b 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -43,7 +43,7 @@ export const managedMembersInputSchema = managedTargetInputSchema.extend({ }) .meta({ uniqueItems: true }) .describe( - "Inspect only the fields, methods, call edges, and field accesses declared by these exact type full names; omit to inspect every type. Unmatched names are reported in selection.unmatched_type_names.", + "Inspect only the fields, methods, call edges, and field accesses declared by these exact type full names, and the user strings their method bodies load; omit to inspect every type. Unmatched names are reported in selection.unmatched_type_names.", ) .optional(), }); @@ -87,7 +87,7 @@ export const MANAGED_TOOL_CONTRACTS = [ name: "inspect_managed_members", ...toolContractMetadata("inspect_managed_members"), description: - "Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, and field-access anchors without loading or executing target code. Select type_names to decode only the members of those exact declaring types, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.", + "Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.", kind: "managed-provider", inputSchema: managedMembersInputSchema, outputSchema: memberOutputSchema, diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index 85297d671..6e5d836ec 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -53,6 +53,7 @@ const emptyManagedMembers = ( member_refs: [], call_edges: [], field_accesses: [], + user_strings: { state: "complete", literals: [], issues: [] }, selection: null, coverage: { state: "complete", issues: [] }, limitations: [], diff --git a/src/contracts/reverseEngineeringToolContracts.ts b/src/contracts/reverseEngineeringToolContracts.ts new file mode 100644 index 000000000..7b6542be1 --- /dev/null +++ b/src/contracts/reverseEngineeringToolContracts.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import { + objdumpInputSchema, + rizinInputSchema, +} from "../domain/reverseEngineering.js"; +import type { ToolContract } from "./toolContractTypes.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; + +const commandObservationSchema = z.strictObject({ + stdout: z.string(), + stderr: z.string(), + exit_code: z.union([z.string(), z.number(), z.null()]), + signal: z.string().nullable(), + output_truncated: z.boolean(), + completion_status: z.enum(["complete", "unknown"]), + artifact_sha256_after: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + artifact_changed: z.boolean().nullable(), +}); + +/** Caller-selected GNU objdump and Rizin observations. */ +export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [ + { + name: "inspect_with_objdump", + ...toolContractMetadata("inspect_with_objdump"), + kind: "native-provider", + description: + "Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.", + inputSchema: objdumpInputSchema, + outputSchema: evidenceResultOf(commandObservationSchema), + examples: [ + { + title: "Disassemble code sections", + input: { path: "/tmp/program", operation: "disassemble" }, + }, + { + title: "List advertised BFD targets", + input: { operation: "architectures" }, + }, + ], + }, + { + name: "execute_rizin_command", + ...toolContractMetadata("execute_rizin_command"), + kind: "native-provider", + description: + "Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + inputSchema: rizinInputSchema, + outputSchema: evidenceResultOf(commandObservationSchema), + examples: [ + { + title: "Inspect binary information", + input: { path: "/tmp/program", command: "iI" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/rizinDebugToolContracts.ts b/src/contracts/rizinDebugToolContracts.ts new file mode 100644 index 000000000..d638a143e --- /dev/null +++ b/src/contracts/rizinDebugToolContracts.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +import type { ToolContract } from "./toolContractTypes.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; + +const sessionId = z.string().uuid(); +const commandResult = z.strictObject({ + command: z.string(), + output: z.string(), + output_scope: z.literal("command_and_interleaved_session_output"), + output_truncated: z.boolean(), + output_invalid_utf8: z.literal(true).optional(), + completion_status: z.enum(["complete", "unknown"]), + backend: z.string().nullable(), +}); + +/** Persistent Rizin debugger session contracts. */ +export const RIZIN_DEBUG_TOOL_CONTRACTS = [ + { + name: "start_rizin_debug_session", + ...toolContractMetadata("start_rizin_debug_session"), + kind: "native-provider", + description: + "Start a persistent Rizin debugger session using `-N` to suppress user settings and scripts while retaining plugins, `-0` for NUL framing, and `-d` with an optional caller-selected IO debugger backend. Requires a caller-selected Linux TTY for inferior stdout and stderr; the target may write directly to that terminal while running and after detach. REA verifies both destinations and `dbg.exitkills=false` before detaching. Backend and target capabilities depend on the installed build. The session lives in this REA MCP process. Persistent sessions require Linux `/proc` descriptor readback. In a shared MCP server, starting this session retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + inputSchema: z.strictObject({ + path: z.string().min(1), + inferior_tty: z.string().min(1), + backend: z.string().min(1).optional(), + }), + outputSchema: z.strictObject({ + session_id: sessionId, + backend: z.string().nullable(), + }), + examples: [ + { + title: "Start Rizin debugger", + input: { path: "/tmp/program", inferior_tty: "/dev/pts/7" }, + }, + ], + }, + { + name: "rizin_debug_command", + ...toolContractMetadata("rizin_debug_command"), + kind: "native-provider", + description: + "Execute one unrestricted command in a persistent Rizin debugger session. Returns raw output inline with Evidence, including backend output interleaved before the completion marker. If the command exceeds the output or frame limit, `output_truncated` is true and `completion_status` is `unknown`; retained partial output is returned and the owned debugger session is stopped. Unsolicited frames already queued before the command make the session unavailable because correlation is ambiguous. Commands can access local files, shell, network, plugins, and target state; effects depend on the active backend and target. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + inputSchema: z.strictObject({ + session_id: sessionId, + command: z + .string() + .min(1) + .refine( + (value) => !/[\r\n]/u.test(value), + "A single command line is required", + ), + }), + outputSchema: evidenceResultOf(commandResult), + examples: [ + { + title: "Inspect debugger state", + input: { + session_id: "00000000-0000-4000-8000-000000000001", + command: "dr", + }, + }, + ], + }, + { + name: "rizin_debug_session_status", + ...toolContractMetadata("rizin_debug_session_status"), + kind: "native-provider", + description: + "Return observed lifecycle state and bounded recent NUL-framed output and process diagnostics for one Rizin debugger session; truncation fields report discarded data, and output_invalid_utf8 reports whether invalid UTF-8 has been observed in session frame bytes; recent output may contain replacement characters.", + inputSchema: z.strictObject({ session_id: sessionId }), + outputSchema: z.strictObject({ + session_id: sessionId, + state: z.enum(["ready", "closed"]), + inferior_tty: z.string(), + recent_output: z.array(z.string()), + recent_output_truncated: z.boolean(), + diagnostics_truncated: z.boolean(), + output_invalid_utf8: z.boolean(), + }), + examples: [ + { + title: "Check Rizin session", + input: { session_id: "00000000-0000-4000-8000-000000000001" }, + }, + ], + }, + { + name: "close_rizin_debug_session", + ...toolContractMetadata("close_rizin_debug_session"), + kind: "native-provider", + description: + "Close the REA-owned Rizin debugger only after verifying no inferior remains or detaching one with a verified caller-selected TTY output sink. A detached target may continue running and write to that TTY; target state is otherwise reported as unknown. REA never signals an inferior based on PID or path. In a shared MCP server, this admitted close retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + inputSchema: z.strictObject({ session_id: sessionId }), + outputSchema: z.strictObject({ target_state: z.literal("unknown") }), + examples: [ + { + title: "Close Rizin debugger", + input: { session_id: "00000000-0000-4000-8000-000000000001" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/rust/rustToolContracts.ts b/src/contracts/rust/rustToolContracts.ts new file mode 100644 index 000000000..7f827dfbe --- /dev/null +++ b/src/contracts/rust/rustToolContracts.ts @@ -0,0 +1,26 @@ +import { + inspectRustBinaryInputSchema, + rustBinarySchema, +} from "../../domain/rust/rustBinary.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; + +/** Static Rust dependency metadata is independent of active native-analysis providers. */ +export const RUST_TOOL_CONTRACTS = [ + { + name: "inspect_rust_binary", + ...toolContractMetadata("inspect_rust_binary"), + kind: "artifact-provider", + description: + "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image. Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", + inputSchema: inspectRustBinaryInputSchema, + outputSchema: evidenceResultOf(rustBinarySchema), + examples: [ + { + title: "Inspect an embedded Rust dependency list", + input: { path: "/artifacts/application" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 50a745e75..dc6567e39 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -82,6 +82,11 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< left_bundle_path: "/tmp/left-evidence.json", right_bundle_path: "/tmp/right-evidence.json", }, + inspect_with_objdump: { + path: "/tmp/program", + operation: "disassemble", + }, + execute_rizin_command: { path: "/tmp/program", command: "iI" }, list_frida_devices: {}, list_frida_processes: { device_id: "local" }, start_frida_session: { mode: "attach", device_id: "local", pid: 1234 }, diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 2fdac1fbe..91627ee5b 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -3,6 +3,7 @@ import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js"; import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; +import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.js"; import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js"; import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; @@ -28,6 +29,10 @@ import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascript/javascriptRecov import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js"; import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js"; import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js"; +import { CUTTER_TOOL_CONTRACTS } from "./cutterToolContracts.js"; +import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "./reverseEngineeringToolContracts.js"; +import { GDB_TOOL_CONTRACTS } from "./gdbToolContracts.js"; +import { RIZIN_DEBUG_TOOL_CONTRACTS } from "./rizinDebugToolContracts.js"; import { FRIDA_TOOL_CONTRACTS } from "./fridaToolContracts.js"; /** Complete ordered public inventory used by registration and verification. */ @@ -40,6 +45,7 @@ export const TOOL_CONTRACTS = [ ...ARTIFACT_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, ...GO_TOOL_CONTRACTS, + ...RUST_TOOL_CONTRACTS, ...WASM_TOOL_CONTRACTS, ...SQLITE_TOOL_CONTRACTS, ...CAPTURE_TOOL_CONTRACTS, @@ -62,6 +68,10 @@ export const TOOL_CONTRACTS = [ ...WEB_NETWORK_CAPTURE_TOOL_CONTRACTS, ...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, + ...CUTTER_TOOL_CONTRACTS, + ...REVERSE_ENGINEERING_TOOL_CONTRACTS, + ...GDB_TOOL_CONTRACTS, + ...RIZIN_DEBUG_TOOL_CONTRACTS, ...FRIDA_TOOL_CONTRACTS, ] as const; diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 3a4acca99..7d23db92d 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -13,12 +13,18 @@ const EXECUTION_WORDING = /\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu; const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { + inspect_with_objdump: + "runs a caller-selected inspection tool, not target code", + start_gdb_session: + "starts a debugger session; execution is attributed to gdb_console", start_frida_session: "spawned targets remain suspended until resume_frida_session", inspect_native_load_image: "states that it does not execute the target", resolve_native_call_targets: "states that it does not execute code", inspect_pe_resources: "states that it does not execute the image", inspect_go_binary: "reads embedded metadata without executing the target", + inspect_rust_binary: + "reads an embedded dependency list without executing the target", inspect_sqlite_database: "uses a trusted inspector worker without executing application code or caller SQL", inspect_binary_layout: @@ -127,14 +133,21 @@ describe("tool effects", () => { "capture_electron_scenario", "capture_native_ui_scenario", "capture_process_scenario", + "close_gdb_session", + "close_rizin_debug_session", + "cutter_command", + "execute_rizin_command", + "gdb_console", "install_adb_package", "instrument_with_frida", "load_frida_script", "observe_native_calls", "push_adb_file", "resume_frida_session", + "rizin_debug_command", "start_adb_activity", "start_adb_app", + "start_rizin_debug_session", "stop_adb_app", "uninstall_adb_package", ]); @@ -167,6 +180,17 @@ describe("tool effects", () => { }); }); + it("marks automatic GDB detach as destructive target execution", () => { + const effects = TOOL_EFFECTS["close_gdb_session"]; + if (effects === undefined) + throw new Error("close_gdb_session is unaudited"); + expect(effects).toMatchObject({ + mutatesTarget: true, + executesForeignCode: true, + }); + expect(annotationsFromEffects(effects).destructiveHint).toBe(true); + }); + it("publishes the advertised annotations derived from canonical effects", () => { for (const contract of TOOL_CONTRACTS) expect(contract.annotations, contract.name).toEqual( diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 377905480..c64edefca 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -53,7 +53,80 @@ const sessionEvidence = effects({ mutatesSession: true }); /** Explicit effect audit for every public tool. */ export const TOOL_EFFECTS: Readonly> = { + list_cutter_sessions: effects({ accessesNetwork: true }), + cutter_command: effects({ + mutatesTarget: true, + executesForeignCode: true, + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + accessesNetwork: true, + changesUiState: true, + idempotent: false, + }), + inspect_with_objdump: effects({ + mutatesSession: true, + launchesProcess: true, + }), + start_gdb_session: effects({ + launchesProcess: true, + mutatesSession: true, + idempotent: false, + }), + gdb_console: effects({ + mutatesTarget: true, + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + accessesNetwork: true, + executesForeignCode: true, + idempotent: false, + }), + gdb_session_status: effects(), + start_rizin_debug_session: effects({ + mutatesTarget: true, + mutatesSession: true, + launchesProcess: true, + executesForeignCode: true, + idempotent: false, + }), + rizin_debug_command: effects({ + mutatesTarget: true, + mutatesSession: true, + executesForeignCode: true, + writesFilesystem: true, + launchesProcess: true, + accessesNetwork: true, + changesUiState: true, + mayDiscardData: true, + idempotent: false, + }), + rizin_debug_session_status: effects({ mutatesSession: true }), + close_rizin_debug_session: effects({ + mutatesSession: true, + launchesProcess: true, + mutatesTarget: true, + executesForeignCode: true, + }), + close_gdb_session: effects({ + launchesProcess: true, + mutatesSession: true, + mutatesTarget: true, + executesForeignCode: true, + idempotent: false, + }), + execute_rizin_command: effects({ + mutatesTarget: true, + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + accessesNetwork: true, + mayDiscardData: true, + executesForeignCode: true, + idempotent: false, + }), inspect_go_binary: evidence, + inspect_rust_binary: evidence, inspect_sqlite_database: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts new file mode 100644 index 000000000..13973cabf --- /dev/null +++ b/src/cutter/CutterBridgeClient.ts @@ -0,0 +1,891 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { createConnection } from "node:net"; +import { lstat, open, opendir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +import { + AnalysisCapabilityUnavailableError, + AnalysisOutputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; + +const descriptorSchema = z.object({ + session_id: z.string().uuid(), + pid: z.number().int().positive(), + host: z.literal("127.0.0.1"), + port: z.number().int().min(1).max(65535), + token: z.string().min(20), + document_generation: z.number().int().nonnegative(), + current_file: z.string().nullable(), + cutter_version: z.string().nullable(), + identity_status: z.literal("partial"), +}); + +export const CUTTER_PROVIDER_IDENTITY = { + id: "cutter.python-plugin", + name: "Cutter Python plugin", + version: null, +} as const; + +const responseSchema = z.object({ + ok: z.boolean(), + session_id: z.string().uuid().optional(), + output: z.unknown().optional(), + current_file: z.string().nullable().optional(), + cutter_version: z.string().nullable().optional(), + document_generation: z.number().int().nonnegative().optional(), + identity_status: z.literal("partial").optional(), + error: z.string().optional(), + message: z.string().optional(), + execution_state: z + .enum(["not_started", "running", "complete", "failed", "unknown"]) + .optional(), + output_truncated: z.boolean().optional(), + completed_commands: z + .array( + z.object({ + request_id: z.string().uuid(), + execution_state: z.enum(["not_started", "complete", "failed"]), + }), + ) + .max(64) + .optional(), + completed_connection: z + .string() + .regex(/^[0-9a-f]{16}$/) + .optional(), +}); +const acknowledgementSchema = z.object({ + ok: z.literal(true), + acknowledged: z.boolean(), +}); +const MAX_DESCRIPTOR_BYTES = 64 * 1024; +const MAX_FRAME_BYTES = 16 * 1024 * 1024; +const FRAME_PAGE_BYTES = 64 * 1024; +// Bound admitted descriptor input bytes and concurrent file/socket handles. +const MAX_DESCRIPTOR_CANDIDATES = MAX_FRAME_BYTES / MAX_DESCRIPTOR_BYTES; +const DISCOVERY_CONCURRENCY = 8; +type AuthenticatedRequestBytes = string & { + readonly __authenticatedRequestBytes: unique symbol; +}; + +const serializeRequest = ( + descriptor: z.infer, + request: Readonly>, +): AuthenticatedRequestBytes => { + const requestBytes = JSON.stringify({ + ...request, + session_id: descriptor.session_id, + }); + let frameBytes = + Buffer.byteLength(JSON.stringify({ request: "", proof: "0".repeat(64) })) + + Buffer.byteLength(requestBytes); + for (const character of requestBytes) + if (character === '"' || character === "\\") frameBytes += 1; + if (frameBytes > MAX_FRAME_BYTES) + throw new AnalysisResourceConstraintError( + "cutter_command", + "transport", + "Cutter request exceeds its authenticated frame limit; no command was sent.", + { frame_bytes: frameBytes, max_frame_bytes: MAX_FRAME_BYTES }, + ); + return requestBytes as AuthenticatedRequestBytes; +}; + +class CutterBridgeResponseFrames { + #parts: Buffer[] = []; + #page = Buffer.allocUnsafe(FRAME_PAGE_BYTES); + #pageLength = 0; + #contentBytes = 0; + #framesRead = 0; + + push(chunk: Buffer, onFrame: (line: Buffer) => boolean): void { + let offset = 0; + while (offset < chunk.length) { + const delimiter = chunk.indexOf(0x0a, offset); + const end = delimiter < 0 ? chunk.length : delimiter; + const segment = chunk.subarray(offset, end); + if ( + this.#contentBytes + segment.length + (delimiter < 0 ? 1 : 0) > + MAX_FRAME_BYTES + ) + throw new Error( + "Cutter bridge response exceeded its framed transport limit", + ); + this.#append(segment); + if (delimiter < 0) return; + if (++this.#contentBytes > MAX_FRAME_BYTES) + throw new Error( + "Cutter bridge response exceeded its framed transport limit", + ); + if (this.#pageLength > 0) + this.#parts.push(this.#page.subarray(0, this.#pageLength)); + const line = Buffer.concat(this.#parts, this.#contentBytes - 1); + this.#reset(); + if (++this.#framesRead > 2) + throw new Error("Cutter bridge sent more than two protocol frames"); + offset = delimiter + 1; + if (!onFrame(line)) return; + } + } + + #append(segment: Buffer): void { + let copied = 0; + while (copied < segment.length) { + const length = Math.min( + segment.length - copied, + FRAME_PAGE_BYTES - this.#pageLength, + ); + segment.copy(this.#page, this.#pageLength, copied, copied + length); + this.#pageLength += length; + this.#contentBytes += length; + copied += length; + if (this.#pageLength === FRAME_PAGE_BYTES) { + this.#parts.push(this.#page); + this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES); + this.#pageLength = 0; + } + } + } + + #reset(): void { + this.#parts = []; + this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES); + this.#pageLength = 0; + this.#contentBytes = 0; + } +} + +const mapDiscovery = async ( + inputs: readonly Input[], + operation: (input: Input) => Promise, +): Promise => { + const results = new Array(inputs.length); + let next = 0; + await Promise.all( + Array.from( + { length: Math.min(inputs.length, DISCOVERY_CONCURRENCY) }, + async () => { + while (next < inputs.length) { + const index = next++; + results[index] = await operation(inputs[index]!); + } + }, + ), + ); + return results; +}; +export type CutterBridgeExecutionState = + | "not_started" + | "complete" + | "failed" + | "unknown"; +export interface CutterBridgeExecution { + readonly requestId: string; + readonly output: unknown; + readonly currentFile: string | null; + readonly documentGeneration: number; + readonly identityStatus: "partial"; + readonly cutterVersion: string | null; + readonly executionState: CutterBridgeExecutionState; + readonly error: string | null; + readonly message: string | null; + readonly outputTruncated: boolean; + readonly completionToken?: string; +} + +class CutterBridgeRequestFailure extends Error { + constructor( + message: string, + readonly requestSent: boolean, + ) { + super(message); + } +} + +export interface WindowsCutterDescriptorReader { + verifyDirectory(directory: string): boolean; + readDescriptor(directory: string, name: string, maxBytes: number): Buffer; +} +interface CutterBridgeClientOptions { + readonly platform?: NodeJS.Platform; + readonly windowsPrivateReader?: WindowsCutterDescriptorReader; +} + +export type CutterBridgeSession = Omit< + z.infer, + "token" | "host" | "port" +>; +export interface CutterBridgeDiscovery { + readonly sessions: readonly CutterBridgeSession[]; + readonly bridge_directory: string; + readonly discovery_status: + | "sessions_found" + | "no_live_bridge_found" + | "bridge_directory_unavailable" + | "bridge_directory_insecure" + | "bridge_directory_capacity_exceeded"; + readonly bridge_directory_security: + | "private_verified" + | "not_private" + | "unverified_platform_acl" + | "not_checked"; +} + +/** Discovers and communicates with REA Python bridge plugins in live Cutter processes. */ +export class CutterBridgeClient { + readonly #directory: string; + readonly #platform: NodeJS.Platform; + readonly #windowsPrivateReader: WindowsCutterDescriptorReader | undefined; + readonly #pendingAcknowledgements = new Map>(); + + constructor( + environment: Readonly = process.env, + options: CutterBridgeClientOptions = {}, + ) { + const configured = environment.REA_CUTTER_BRIDGE_DIR; + const localAppData = environment.LOCALAPPDATA; + this.#platform = options.platform ?? process.platform; + this.#windowsPrivateReader = + options.windowsPrivateReader ?? + (this.#platform === "win32" + ? { + verifyDirectory: (directory) => + z + .object({ privateDacl: z.literal(true) }) + .safeParse( + requireWindowsNativeAuthority().call( + "cutter_bridge_verify_directory", + [directory], + ), + ).success, + readDescriptor: (directory, name, maxBytes) => + z + .instanceof(Buffer) + .parse( + requireWindowsNativeAuthority().call( + "cutter_bridge_read_descriptor", + [directory, name, maxBytes], + ), + ), + } + : undefined); + this.#directory = + configured ?? + (this.#platform === "win32" && localAppData + ? join(localAppData, "REA", "CutterBridge") + : join(homedir(), ".cache", "rea", "cutter-bridge")); + } + + async listSessions(): Promise { + const loaded = await this.#readDescriptors(); + if (loaded.error !== undefined) + return { + sessions: [], + bridge_directory: this.#directory, + discovery_status: loaded.error, + bridge_directory_security: loaded.security, + }; + const descriptors = await mapDiscovery( + loaded.descriptors, + async (descriptor) => { + try { + const status = responseSchema.safeParse( + await this.#request( + descriptor, + serializeRequest(descriptor, { + kind: "status", + session_id: descriptor.session_id, + }), + ), + ); + if ( + !status.success || + !status.data.ok || + status.data.session_id !== descriptor.session_id + ) + return undefined; + return { + session_id: descriptor.session_id, + pid: descriptor.pid, + document_generation: + status.data.document_generation ?? descriptor.document_generation, + current_file: + status.data.current_file === undefined + ? descriptor.current_file + : status.data.current_file, + cutter_version: + status.data.cutter_version ?? descriptor.cutter_version, + identity_status: "partial" as const, + }; + } catch { + return undefined; + } + }, + ); + const sessions = descriptors.filter( + (value): value is CutterBridgeSession => value !== undefined, + ); + return { + sessions, + bridge_directory: this.#directory, + discovery_status: + sessions.length > 0 ? "sessions_found" : "no_live_bridge_found", + bridge_directory_security: loaded.security, + }; + } + + async execute(input: { + readonly sessionId: string; + readonly expectedGeneration: number; + readonly command: string; + readonly json: boolean; + readonly signal?: AbortSignal; + }): Promise { + input.signal?.throwIfAborted(); + const descriptor = await this.#find(input.sessionId); + input.signal?.throwIfAborted(); + const requestId = randomUUID(); + const commandRequest = serializeRequest(descriptor, { + kind: "command", + request_id: requestId, + expected_generation: input.expectedGeneration, + command: input.command, + json: input.json, + }); + await this.#recoverCompletedCommands(descriptor, input.signal); + await this.#retryPendingAcknowledgements(descriptor); + input.signal?.throwIfAborted(); + let completionToken: string | undefined; + let rawResponse: unknown; + try { + rawResponse = await this.#request( + descriptor, + commandRequest, + input.signal, + (ordinal) => { + completionToken = ordinal; + }, + ); + } catch (cause) { + if (!(cause instanceof CutterBridgeRequestFailure) || !cause.requestSent) + throw cause; + return { + requestId, + output: null, + currentFile: descriptor.current_file, + documentGeneration: input.expectedGeneration, + identityStatus: "partial", + cutterVersion: descriptor.cutter_version, + executionState: "unknown", + error: "transport-response-missing", + message: `${cause.message}; the command may have completed; do not retry automatically`, + outputTruncated: false, + ...(completionToken === undefined ? {} : { completionToken }), + }; + } + const parsed = responseSchema.safeParse(rawResponse); + if (!parsed.success) + return { + requestId, + output: null, + currentFile: descriptor.current_file, + documentGeneration: input.expectedGeneration, + identityStatus: "partial", + cutterVersion: descriptor.cutter_version, + executionState: "unknown", + error: "malformed-bridge-response", + message: + "Cutter may have completed the command, but its response was invalid; do not retry automatically", + outputTruncated: false, + ...(completionToken === undefined ? {} : { completionToken }), + }; + const response = parsed.data; + const executionState: CutterBridgeExecutionState = response.ok + ? response.execution_state === undefined || + response.execution_state === "complete" + ? "complete" + : response.execution_state === "not_started" + ? "not_started" + : "unknown" + : response.execution_state === "failed" + ? "failed" + : response.execution_state === "not_started" + ? "not_started" + : "unknown"; + if (executionState !== "unknown") + await this.#acknowledgeCompletion(descriptor, requestId); + if (response.error === "active-document-generation-changed") + throw new Error( + "Cutter's active document generation changed. List sessions again and select the current generation.", + ); + return { + requestId, + output: response.output ?? null, + currentFile: + response.current_file === undefined + ? descriptor.current_file + : response.current_file, + documentGeneration: + response.document_generation ?? input.expectedGeneration, + identityStatus: "partial", + cutterVersion: response.cutter_version ?? descriptor.cutter_version, + executionState, + error: response.ok ? null : (response.error ?? "cutter-command-failed"), + message: response.message ?? null, + outputTruncated: response.output_truncated ?? false, + ...(completionToken === undefined ? {} : { completionToken }), + }; + } + + /** Confirm completion by exact receipt or authenticated connection watermark. */ + async confirmCommandCompletion( + sessionId: string, + requestId: string, + completionToken?: string, + ): Promise> { + let descriptor: z.infer; + try { + descriptor = await this.#find(sessionId); + } catch (cause) { + return err( + new AnalysisCapabilityUnavailableError( + "cutter", + "cutter_command", + cause instanceof Error ? cause.message : "Cutter session unavailable", + { cause }, + ), + ); + } + try { + const raw = await this.#request( + descriptor, + serializeRequest(descriptor, { + kind: "status", + session_id: sessionId, + }), + ); + const parsed = responseSchema.safeParse(raw); + if ( + !parsed.success || + !parsed.data.ok || + parsed.data.session_id !== sessionId + ) + return err( + new AnalysisOutputError( + "cutter_command_status", + "The bridge status response did not contain a valid session identity.", + { cause: parsed.success ? undefined : parsed.error }, + ), + ); + const completed = (parsed.data.completed_commands ?? []).some( + (receipt) => receipt.request_id === requestId, + ); + if (completed) await this.#acknowledgeCompletion(descriptor, requestId); + return ok( + completed || + (completionToken !== undefined && + parsed.data.completed_connection !== undefined && + parsed.data.completed_connection >= completionToken), + ); + } catch (cause) { + return err( + new AnalysisCapabilityUnavailableError( + "cutter", + "cutter_command_status", + cause instanceof Error ? cause.message : "Cutter status failed", + { cause }, + ), + ); + } + } + + async #recoverCompletedCommands( + descriptor: z.infer, + signal?: AbortSignal, + ): Promise { + const raw = await this.#request( + descriptor, + serializeRequest(descriptor, { + kind: "status", + session_id: descriptor.session_id, + }), + signal, + ); + const status = responseSchema.safeParse(raw); + if ( + !status.success || + !status.data.ok || + status.data.session_id !== descriptor.session_id + ) + throw new AnalysisOutputError( + "cutter_command_status", + "The bridge status response did not contain a valid session identity.", + { cause: status.success ? undefined : status.error }, + ); + if (status.data.completed_connection === undefined) + throw new AnalysisOutputError( + "cutter_command_status", + "The bridge did not provide authenticated proof that earlier connections reached a terminal state.", + ); + const pending = + this.#pendingAcknowledgements.get(descriptor.session_id) ?? new Set(); + for (const receipt of status.data.completed_commands ?? []) + pending.add(receipt.request_id); + if (pending.size > 0) + this.#pendingAcknowledgements.set(descriptor.session_id, pending); + } + + async #acknowledgeCompletion( + descriptor: z.infer, + requestId: string, + ): Promise { + try { + const raw = await this.#request( + descriptor, + serializeRequest(descriptor, { + kind: "ack", + request_id: requestId, + }), + ); + const acknowledged = acknowledgementSchema.safeParse(raw); + if (!acknowledged.success) + throw new Error( + "Cutter did not confirm the completion receipt acknowledgement.", + ); + const pending = this.#pendingAcknowledgements.get(descriptor.session_id); + pending?.delete(requestId); + if (pending?.size === 0) + this.#pendingAcknowledgements.delete(descriptor.session_id); + return true; + } catch { + const pending = + this.#pendingAcknowledgements.get(descriptor.session_id) ?? + new Set(); + pending.add(requestId); + this.#pendingAcknowledgements.set(descriptor.session_id, pending); + return false; + } + } + + async #retryPendingAcknowledgements( + descriptor: z.infer, + ): Promise { + const pending = this.#pendingAcknowledgements.get(descriptor.session_id); + if (pending === undefined) return; + for (const requestId of pending) { + if (!(await this.#acknowledgeCompletion(descriptor, requestId))) + throw new AnalysisCapabilityUnavailableError( + "cutter", + "cutter_command", + "A retained Cutter completion receipt could not be acknowledged; no new command was sent.", + ); + } + } + + async #find(sessionId: string): Promise> { + const loaded = await this.#readDescriptors(); + if (loaded.error === "bridge_directory_capacity_exceeded") + throw new AnalysisResourceConstraintError( + "cutter_command", + "transport", + "Cutter descriptor discovery exceeded its candidate budget; no command was sent.", + { + max_descriptor_candidates: MAX_DESCRIPTOR_CANDIDATES, + bridge_directory: this.#directory, + }, + ); + if (loaded.error !== undefined) + throw new AnalysisCapabilityUnavailableError( + "cutter", + "cutter_command", + `Cutter descriptor discovery failed: ${loaded.error}`, + ); + const descriptor = loaded.descriptors.find( + (candidate) => candidate.session_id === sessionId, + ); + if (descriptor === undefined) + throw new Error("Cutter bridge session is unavailable or stale"); + return descriptor; + } + + async listDescriptors(): Promise< + readonly z.infer[] + > { + const loaded = await this.#readDescriptors(); + return loaded.descriptors; + } + + async #readDescriptors(): Promise<{ + readonly descriptors: readonly z.infer[]; + readonly security: + | "private_verified" + | "not_private" + | "unverified_platform_acl" + | "not_checked"; + readonly error?: + | "bridge_directory_unavailable" + | "bridge_directory_insecure" + | "bridge_directory_capacity_exceeded"; + }> { + let directoryInfo; + try { + directoryInfo = await lstat(this.#directory); + } catch (cause) { + const code = (cause as NodeJS.ErrnoException).code; + if (code === "ENOENT") + return { descriptors: [], security: "not_checked" }; + return { + descriptors: [], + security: "not_checked", + error: "bridge_directory_unavailable", + }; + } + if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) + return { + descriptors: [], + security: "not_private", + error: "bridge_directory_insecure", + }; + const currentUid = + this.#platform !== "win32" ? process.getuid?.() : undefined; + const posix = currentUid !== undefined; + if ( + posix && + (directoryInfo.uid !== currentUid || (directoryInfo.mode & 0o077) !== 0) + ) + return { + descriptors: [], + security: "not_private", + error: "bridge_directory_insecure", + }; + let security: + | "private_verified" + | "not_private" + | "unverified_platform_acl" = posix + ? "private_verified" + : "unverified_platform_acl"; + const windowsPrivateReader = this.#windowsPrivateReader; + if (!posix) { + if (this.#platform !== "win32" || windowsPrivateReader === undefined) + return { + descriptors: [], + security, + error: "bridge_directory_insecure", + }; + try { + if (!windowsPrivateReader.verifyDirectory(this.#directory)) + return { + descriptors: [], + security: "not_private", + error: "bridge_directory_insecure", + }; + security = "private_verified"; + } catch { + return { + descriptors: [], + security, + error: "bridge_directory_insecure", + }; + } + } + const entries: string[] = []; + try { + const directory = await opendir(this.#directory); + for await (const entry of directory) { + if (!/^cutter-\d+-[0-9a-f-]{36}\.json$/iu.test(entry.name)) continue; + if (entries.length === MAX_DESCRIPTOR_CANDIDATES) + return { + descriptors: [], + security, + error: "bridge_directory_capacity_exceeded", + }; + entries.push(entry.name); + } + } catch { + return { + descriptors: [], + security: "not_checked", + error: "bridge_directory_unavailable", + }; + } + const loaded = await mapDiscovery(entries, async (entry) => { + let file; + try { + let bytes: Buffer; + if (this.#platform === "win32") { + bytes = windowsPrivateReader!.readDescriptor( + this.#directory, + entry, + MAX_DESCRIPTOR_BYTES, + ); + } else { + const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0); + file = await open(join(this.#directory, entry), flags); + const info = await file.stat(); + if ( + !info.isFile() || + info.size > MAX_DESCRIPTOR_BYTES || + info.uid !== currentUid || + (info.mode & 0o077) !== 0 + ) + return undefined; + const boundedBytes = await readBoundedFileBytes( + file, + MAX_DESCRIPTOR_BYTES, + ); + if (boundedBytes === undefined) return undefined; + bytes = boundedBytes; + } + if (bytes.byteLength > MAX_DESCRIPTOR_BYTES) return undefined; + const parsed = descriptorSchema.safeParse( + JSON.parse(bytes.toString("utf8")) as unknown, + ); + if ( + !parsed.success || + entry !== `cutter-${parsed.data.pid}-${parsed.data.session_id}.json` + ) + return undefined; + return parsed.data; + } catch { + return undefined; + } finally { + await file?.close().catch(() => undefined); + } + }); + return { + descriptors: loaded.filter( + (value): value is z.infer => + value !== undefined, + ), + security, + }; + } + + #request( + descriptor: z.infer, + requestBytes: AuthenticatedRequestBytes, + signal?: AbortSignal, + onAuthenticated?: (connectionOrdinal: string) => void, + ): Promise { + return new Promise((resolve, reject) => { + const connection = createConnection({ + host: descriptor.host, + port: descriptor.port, + }); + const frames = new CutterBridgeResponseFrames(); + let settled = false; + let requestSent = false; + let authenticated = false; + const clientNonce = randomBytes(32).toString("hex"); + let serverNonce = ""; + let connectionOrdinal: string | undefined; + const finish = (complete: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + complete(); + }; + const rejectRequest = (message: string): void => + finish(() => + reject(new CutterBridgeRequestFailure(message, requestSent)), + ); + const timer = setTimeout(() => { + rejectRequest("Cutter bridge request timed out"); + connection.destroy(); + }, 25_000); + const onAbort = (): void => { + rejectRequest("Cutter bridge request was cancelled"); + connection.destroy(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + connection.once("connect", () => { + connection.write( + `${JSON.stringify({ kind: "rea-auth-v2", nonce: clientNonce })}\n`, + ); + }); + connection.on("data", (chunk: Buffer) => { + try { + frames.push(chunk, (line) => { + const frame: unknown = JSON.parse(line.toString("utf8")); + if (!authenticated) { + const challenge = z + .object({ + session_id: z.string().uuid(), + nonce: z.string().regex(/^[0-9a-f]{64}$/), + proof: z.string().regex(/^[0-9a-f]{64}$/), + }) + .parse(frame); + if (challenge.session_id !== descriptor.session_id) + throw new Error("unexpected peer session"); + serverNonce = challenge.nonce; + connectionOrdinal = challenge.nonce.slice(0, 16); + const expected = createHmac("sha256", descriptor.token) + .update( + `rea-cutter-server-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}`, + ) + .digest(); + if ( + !timingSafeEqual(expected, Buffer.from(challenge.proof, "hex")) + ) + throw new Error("peer proof failed"); + onAuthenticated?.(connectionOrdinal); + const proof = createHmac("sha256", descriptor.token) + .update( + `rea-cutter-client-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}:${requestBytes}`, + ) + .digest("hex"); + authenticated = true; + requestSent = true; + connection.write( + `${JSON.stringify({ request: requestBytes, proof })}\n`, + ); + return true; + } else { + const envelope = z + .object({ + response: z.string(), + proof: z.string().regex(/^[0-9a-f]{64}$/), + }) + .parse(frame); + const expected = createHmac("sha256", descriptor.token) + .update( + `rea-cutter-response-v2:${descriptor.session_id}:${clientNonce}:${serverNonce}:${envelope.response}`, + ) + .digest(); + if ( + !timingSafeEqual(expected, Buffer.from(envelope.proof, "hex")) + ) + throw new Error("response proof failed"); + const response: unknown = JSON.parse(envelope.response); + connection.end(); + finish(() => resolve(response)); + return false; + } + }); + } catch { + rejectRequest("Cutter bridge peer authentication or response failed"); + connection.destroy(); + } + }); + connection.once("error", (cause) => + rejectRequest(`Cutter bridge is unavailable: ${cause.message}`), + ); + connection.once("close", () => + rejectRequest( + "Cutter bridge closed before returning a complete response", + ), + ); + }); + } +} diff --git a/src/cutter/CutterBridgeService.ts b/src/cutter/CutterBridgeService.ts new file mode 100644 index 000000000..2d05249f0 --- /dev/null +++ b/src/cutter/CutterBridgeService.ts @@ -0,0 +1,192 @@ +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { cutterCommandInputSchema } from "../contracts/cutterToolContracts.js"; +import { + CutterBridgeClient, + type CutterBridgeDiscovery, + type CutterBridgeExecution, +} from "./CutterBridgeClient.js"; +import { CUTTER_PROVIDER_IDENTITY } from "./CutterBridgeClient.js"; + +interface CutterBridgePort { + listSessions(): Promise; + confirmCommandCompletion?( + sessionId: string, + requestId: string, + completionToken?: string, + ): Promise>; + execute(input: { + readonly sessionId: string; + readonly expectedGeneration: number; + readonly command: string; + readonly json: boolean; + readonly signal?: AbortSignal; + }): Promise; +} + +/** Shared Cutter bridge workflows used by the MCP adapter and CLI. */ +export class CutterBridgeService { + #cleanupAttempt: Promise> | undefined; + readonly #pendingCleanup = new Map< + string, + { + readonly sessionId: string; + readonly completionToken?: string; + readonly failure: AnalysisOutputError; + } + >(); + + constructor(readonly client: CutterBridgePort = new CutterBridgeClient()) {} + + /** Release timed-out work only after producer-owned terminal proof. */ + retryCleanup(): Promise> { + if (this.#cleanupAttempt !== undefined) return this.#cleanupAttempt; + const attempt = this.#retryPendingCleanup().finally(() => { + if (this.#cleanupAttempt === attempt) this.#cleanupAttempt = undefined; + }); + this.#cleanupAttempt = attempt; + return attempt; + } + + async #retryPendingCleanup(): Promise> { + for (const [requestId, pending] of this.#pendingCleanup) { + const confirmCommandCompletion = this.client.confirmCommandCompletion; + if (confirmCommandCompletion === undefined) return err(pending.failure); + const receipt = await confirmCommandCompletion.call( + this.client, + pending.sessionId, + requestId, + pending.completionToken, + ); + if (!receipt.ok || !receipt.value) return err(pending.failure); + const { failure } = pending; + failure.resolveCleanup(); + this.#pendingCleanup.delete(requestId); + } + return ok(null); + } + + listSessions(): Promise { + return this.client.listSessions(); + } + + async execute( + rawInput: unknown, + options: { readonly signal?: AbortSignal } = {}, + ): Promise> { + const parsed = cutterCommandInputSchema.safeParse(rawInput); + if (!parsed.success) return err(new AnalysisInputError("cutter_command")); + if (options.signal?.aborted) + return err(new AnalysisCancelledError("cutter_command")); + const input = parsed.data; + const cleanup = await this.retryCleanup(); + if (!cleanup.ok) return err(cleanup.error); + try { + const output = await this.client.execute({ + sessionId: input.session_id, + expectedGeneration: input.expected_generation, + command: input.command, + json: input.json, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); + const normalizedOutput = jsonValueSchema.parse(output.output); + const evidence = createEvidence( + undefined, + { + ...CUTTER_PROVIDER_IDENTITY, + version: output.cutterVersion, + }, + { + predicateType: "rea.cutter.command-observation", + operation: "cutter_command", + parameters: { + session_id: input.session_id, + expected_generation: input.expected_generation, + command: input.command, + json: input.json, + }, + result: { + command: input.command, + output: normalizedOutput, + execution_state: output.executionState, + error: output.error, + message: output.message, + output_truncated: output.outputTruncated, + cutter_version: output.cutterVersion, + current_file: output.currentFile, + document_generation: output.documentGeneration, + identity_status: output.identityStatus, + }, + rawResult: normalizedOutput, + subjectUnavailableReason: + "Cutter exposes no verified stable identity for the active unsaved document or tab.", + limitations: [ + "Document identity is partial: the bridge uses a plugin instance UUID and observed generation; a same-path tab switch or reload may not be distinguishable.", + "Cutter command effects depend on the Rizin command and active plugins.", + "The local IPC protocol is implemented by REA and is not an upstream Cutter API.", + ...(output.executionState === "unknown" + ? [ + "Command completion or its output could not be confirmed; do not retry automatically because the command may have produced partial or persistent effects.", + ] + : []), + ...(output.outputTruncated + ? [ + "The command completed or may have completed, but output exceeded the bridge transport limit and was omitted.", + ] + : []), + ], + }, + ); + if (output.executionState === "unknown") { + const failure = new AnalysisOutputError( + "cutter_command", + "Cutter command completion is unknown; the bound binary remains unavailable until the UI dispatcher confirms this request reached a terminal state.", + { + cleanup: { + reason: + "The exact UI dispatcher completion receipt has not arrived; the dispatched command may still be running.", + resources: [`cutter-session:${input.session_id}`], + }, + partialObservation: evidence, + }, + ); + this.#pendingCleanup.set(output.requestId, { + sessionId: input.session_id, + ...(output.completionToken === undefined + ? {} + : { completionToken: output.completionToken }), + failure, + }); + return err(failure); + } + return ok(evidence); + } catch (cause) { + if (cause instanceof AnalysisError) return err(cause); + const message = + cause instanceof Error + ? cause.message + : "Unknown Cutter bridge failure"; + if (options.signal?.aborted && !message.includes("may have completed")) + return err(new AnalysisCancelledError("cutter_command", { cause })); + return err( + message.includes("generation changed") || + message.includes("unavailable or stale") + ? new AnalysisInputError("cutter_command", { cause }) + : new AnalysisCapabilityUnavailableError( + "cutter", + "cutter_command", + message, + { cause }, + ), + ); + } + } +} diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 60c3c0472..a4c446f71 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -65,7 +65,7 @@ export abstract class AnalysisError extends Error { readonly diagnostics: Readonly> | undefined; readonly capturedOutput: AnalysisCapturedOutput | undefined; readonly cleanup: AnalysisCleanupObservation | undefined; - readonly cleanupIncomplete: boolean; + cleanupIncomplete: boolean; readonly cleanupResources: readonly string[]; constructor(message: string, options?: AnalysisErrorOptions) { super(message, options); @@ -89,6 +89,30 @@ export abstract class AnalysisError extends Error { partialObservation: AnalysisPartialObservation | undefined; readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined; + /** Retain a later lifecycle failure without replacing the original typed error. */ + retainSecondaryFailure( + failure: AnalysisError, + diagnostics: Readonly>, + ): this { + // Secondary resources remain owned by their cleanup lifecycle; preserve + // the primary error's live cleanup status and original output. + Object.assign(this, { + diagnostics: structuredClone({ ...this.diagnostics, ...diagnostics }), + cause: new AggregateError( + this.cause === undefined ? [failure] : [this.cause, failure], + "Analysis failed and a later lifecycle operation also failed.", + { cause: this.cause }, + ), + }); + return this; + } + + /** Clear live cleanup status only after its owning resource confirms retry success. */ + resolveCleanup(): this { + this.cleanupIncomplete = false; + return this; + } + /** Retain a completed observation when a later step fails. */ retainPartialObservation(observation: AnalysisPartialObservation): this { this.partialObservation ??= observation; diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index 50db91ccd..380b440a7 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -86,13 +86,15 @@ export interface AnalysisInputIssue { /** Provider output failed provider-neutral application parsing. */ export class AnalysisOutputError extends AnalysisError { readonly _tag = "AnalysisOutputError"; + readonly cleanupOnly: boolean; constructor( readonly operation: string, readonly reason: string, - options?: AnalysisErrorOptions, + options?: AnalysisErrorOptions & { readonly cleanupOnly?: boolean }, ) { super(`Invalid analysis output for ${operation}: ${reason}`, options); + this.cleanupOnly = options?.cleanupOnly ?? false; } } diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 0766ca6e0..c0008156b 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -796,3 +796,32 @@ describe("analysis error projection: evidence files", () => { }, ); }); + +it("retains the primary failure and both cleanup reports after a secondary failure", () => { + const primary = new AnalysisCancelledError("execute_rizin_command", { + capturedOutput: { + stdout: "partial", + stderr: "diagnostic", + truncated: false, + }, + cleanup: { reason: "writer remains", resources: ["writer"] }, + }); + const secondary = new AnalysisOutputError("open_binary", "cannot rebind", { + cleanup: { reason: "provider remains", resources: ["provider", "writer"] }, + }); + const enriched = primary.retainSecondaryFailure(secondary, { + rebind_error: projectAnalysisError(secondary), + }); + expect(enriched).toBe(primary); + expect(enriched._tag).toBe("AnalysisCancelledError"); + expect(projectAnalysisError(enriched)).toMatchObject({ + code: "cleanup_incomplete", + details: { + execution_failure: "cancelled", + resources: ["writer"], + cleanup_reason: "writer remains", + captured_output: { stdout: "partial", stderr: "diagnostic" }, + diagnostics: { rebind_error: { code: "cleanup_incomplete" } }, + }, + }); +}); diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index bfdf7eb42..fbd1b5386 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -447,8 +447,8 @@ const lifecycleErrorDetails = ( if (error instanceof AnalysisCancelledError) return { operation: error.operation, - cleanup: error.cleanup === undefined ? "complete" : "incomplete", - ...(error.cleanup === undefined + cleanup: error.cleanupIncomplete ? "incomplete" : "complete", + ...(error.cleanup === undefined || !error.cleanupIncomplete ? {} : { cleanup_reason: error.cleanup.reason, @@ -462,7 +462,7 @@ const lifecycleErrorDetails = ( return { operation: error.operation, timeout_ms: error.timeoutMs, - ...(error.cleanup === undefined + ...(error.cleanup === undefined || !error.cleanupIncomplete ? {} : { cleanup: "incomplete", diff --git a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts index 410546543..d75a23b1a 100644 --- a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts +++ b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts @@ -26,6 +26,36 @@ const add = ( if (!result.ok) throw result.error; }; +it("bounds query parser expansion before publishing or merging observed names", () => { + const accumulator = new WebNetworkApiSurfaceAccumulator(64 * 1024, true); + add(accumulator, record(0, "/products?kept=1")); + const before = accumulator.page(0, 1); + expect( + accumulator.add(record(1, `/products?${"x=1&".repeat(200)}`)), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + expect(accumulator.page(0, 1)).toEqual(before); + add(accumulator, record(2, "/products?after=2")); + expect(accumulator.page(0, 1).groups[0]?.query_parameter_names).toEqual([ + { name: "after", record_ordinals: [2] }, + { name: "kept", record_ordinals: [0] }, + ]); +}); + +it("charges each retained name once and each name's ordinal once per request", () => { + const accumulator = new WebNetworkApiSurfaceAccumulator(1024 * 1024, true); + for (let ordinal = 0; ordinal < 1000; ordinal += 1) + add(accumulator, record(ordinal, "/products?category=one&category=two")); + expect(accumulator.page(0, 1).groups[0]?.query_parameter_names).toEqual([ + { + name: "category", + record_ordinals: Array.from({ length: 1000 }, (_, index) => index), + }, + ]); +}); + it("admits the capture schema clone and grouping against the same budget", () => { const sample = record(0, `/${"a".repeat(1200)}`); const isolated = new WebNetworkApiSurfaceAccumulator(64 * 1024); diff --git a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts index 0abc64675..16f3d8efb 100644 --- a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts +++ b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts @@ -5,7 +5,10 @@ import { } from "../analysisErrorCore.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; -import { parseRecordedTransportUrl } from "../recordedTransportUrl.js"; +import { + parseRecordedTransportUrl, + parseUrlQueryParameterNames, +} from "../recordedTransportUrl.js"; import { err, ok, type Result } from "../result.js"; import { MAX_JSON_DEPTH } from "../jsonValue.js"; import { @@ -41,6 +44,7 @@ interface GroupState { readonly ordinals: number[]; readonly statuses: Set; readonly contentTypes: Set; + readonly queryNames?: Map; } type Buckets = Map>>>; @@ -120,7 +124,10 @@ export class WebNetworkApiSurfaceAccumulator { #retainedBytes = 0; #temporaryBytes = 0; - constructor(readonly maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes) {} + constructor( + readonly maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes, + readonly includeQueryParameterNames = false, + ) {} #retain(bytes: number): boolean { if (this.#retainedBytes + this.#temporaryBytes + bytes > this.maximumBytes) @@ -305,7 +312,9 @@ export class WebNetworkApiSurfaceAccumulator { if (!this.#temporary(RECORD_SCRATCH_BYTES + textBytes(url.length))) return this.#overBudget(); try { - const parsed = parseRecordedTransportUrl(url); + const parsed = parseRecordedTransportUrl(url, { + includeQuery: this.includeQueryParameterNames, + }); if (parsed.origin === null) return this.#exclude(record.ordinal, "url-unparseable"); if (typeof method !== "string" || method.length === 0) @@ -325,6 +334,8 @@ export class WebNetworkApiSurfaceAccumulator { return this.#overBudget(); const route = this.#route(parsed.path); if (!route.ok) return route; + const queryNames = this.#queryNames(parsed.query); + if (!queryNames.ok) return queryNames; return this.#append( record.ordinal, method, @@ -332,6 +343,7 @@ export class WebNetworkApiSurfaceAccumulator { route.value, typeof status === "number" && status !== 0 ? status : null, mediaTypeEssence(rawMime), + queryNames.value, ); } finally { // Per-record strings and descriptors never become cumulative scratch. @@ -339,6 +351,26 @@ export class WebNetworkApiSurfaceAccumulator { } } + #queryNames( + query: string | undefined, + ): Result | undefined, AnalysisError> { + if (query === undefined) return ok(undefined); + // Reserve form-parser tuples, decoded strings, the iterator and the name + // set before URLSearchParams materializes them. Delimiter count is an upper + // bound: empty query pairs are ignored by the WHATWG parser. + let pairs = query.length > 1 ? 1 : 0; + for (const character of query) if (character === "&") pairs += 1; + if ( + !this.#temporary( + RECORD_SCRATCH_BYTES + + textBytes(query.length) + + pairs * ENTRY_BYTES * 4, + ) + ) + return this.#overBudget(); + return ok(new Set(parseUrlQueryParameterNames(query))); + } + #append( ordinal: number, method: string, @@ -346,6 +378,7 @@ export class WebNetworkApiSurfaceAccumulator { route: Route, status: number | null, mime: string | null, + queryNames: ReadonlySet | undefined, ): Result { let group = this.#buckets .get(method) @@ -371,6 +404,14 @@ export class WebNetworkApiSurfaceAccumulator { if (status !== null && !group?.statuses.has(status)) bytes += ENTRY_BYTES; if (mime !== null && !group?.contentTypes.has(mime)) bytes += ENTRY_BYTES + textBytes(mime.length); + if (queryNames !== undefined) { + if (group === undefined) bytes += CAPTURE_CONTAINER_BYTES; + for (const name of queryNames) { + bytes += ENTRY_BYTES; // This name's source ordinal, once per record. + if (!group?.queryNames?.has(name)) + bytes += ENTRY_BYTES + textBytes(name.length); + } + } // Reserve the whole mutation while route scratch is still live. No sets, // map entries, arrays or new observed values are retained before admission. if (!this.#retain(bytes)) return this.#overBudget(); @@ -387,6 +428,7 @@ export class WebNetworkApiSurfaceAccumulator { ordinals: [], statuses: new Set(), contentTypes: new Set(), + ...(queryNames === undefined ? {} : { queryNames: new Map() }), }; const byOrigin = this.#buckets.get(method) ?? new Map(); this.#buckets.set(method, byOrigin); @@ -404,6 +446,13 @@ export class WebNetworkApiSurfaceAccumulator { if (status === null || mime === null) this.#unavailableResponse = true; if (status !== null) group.statuses.add(status); if (mime !== null) group.contentTypes.add(mime); + if (queryNames !== undefined && group.queryNames !== undefined) { + for (const name of queryNames) { + const ordinals = group.queryNames.get(name); + if (ordinals === undefined) group.queryNames.set(name, [ordinal]); + else ordinals.push(ordinal); + } + } return ok(undefined); } @@ -440,6 +489,16 @@ export class WebNetworkApiSurfaceAccumulator { record_ordinals: [...group.ordinals], statuses: [...group.statuses].sort((a, b) => a - b), content_types: [...group.contentTypes].sort(compare), + ...(group.queryNames === undefined + ? {} + : { + query_parameter_names: [...group.queryNames] + .sort(([a], [b]) => compare(a, b)) + .map(([name, record_ordinals]) => ({ + name, + record_ordinals: [...record_ordinals], + })), + }), flags: [ ...(group.ordinals.length === 1 ? ["single-sample" as const] : []), ...(group.statuses.size > 1 ? ["mixed-statuses" as const] : []), diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 4bf16e4b4..3231a2da9 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -1,3 +1,4 @@ +import { boundedViewMessages } from "./analysisViewCoverage.js"; import { expect, it } from "vitest"; import { functionDossierSchema } from "../hopperValues.js"; @@ -717,6 +718,159 @@ it("retains semantic coverage and limitations in a summary", () => { }); }); +it.each([63, 64, 65, 10_000])( + "bounds and deduplicates %i messages in JavaScript and layout views", + (count) => { + const messages = Array.from( + { length: count }, + (_, index) => `Module ${String(index).padStart(5, "0")} was not parsed.`, + ); + const analysis = analysisViewJavaScriptAnalysis(); + const { graph_id: _graphId, ...semantic } = analysis.semantic_graph; + const parent = javascriptParent({ + ...analysis, + limitations: messages, + semantic_graph: createJavaScriptSemanticGraph({ + ...semantic, + limitations: messages.slice(0, 1), + }), + }); + const duplicatedParent = { ...parent, limitations: messages }; + const notice = ( + omitted: number, + field: "limitations" | "unknowns", + evidenceId: string, + ) => + `${String(omitted)} additional ${field} omitted from this view; consult source parent Evidence ${evidenceId} for the complete list (the caller-supplied record, or its retaining session).`; + for (const view of [ + { kind: "summary" }, + { kind: "page", collection: "modules", offset: 0, limit: 1 }, + ] as const) { + const result = projectAnalysisView(duplicatedParent, view); + if (!result.ok) throw result.error; + if (count <= 65) { + expect(result.value.limitations).toEqual(messages); + expect(result.value.unknowns).toEqual( + [...messages, "semantic_graph.coverage.status is unknown"].sort(), + ); + } else { + const limitations = result.value.limitations; + const unknowns = result.value.unknowns; + expect(limitations.length).toBeGreaterThan(65); + expect(limitations.at(-1)).toBe( + notice( + count - limitations.length + 1, + "limitations", + parent.evidenceId, + ), + ); + expect(unknowns.length).toBeGreaterThan(65); + expect(unknowns.at(-1)).toBe( + notice( + count + 1 - unknowns.length + 1, + "unknowns", + parent.evidenceId, + ), + ); + } + } + const layout = layoutParent(); + const layoutWithMessages = { + ...layout, + limitations: messages, + normalizedResult: { + ...analysisViewLayoutFixture(), + limitations: messages, + }, + }; + for (const view of [ + { kind: "summary" }, + { kind: "page", collection: "sections", offset: 0, limit: 1 }, + ] as const) { + const result = projectAnalysisView(layoutWithMessages, view); + if (!result.ok) throw result.error; + if (count <= 65) expect(result.value.limitations).toEqual(messages); + else { + expect(result.value.limitations.length).toBeGreaterThan(65); + expect(result.value.limitations.at(-1)).toBe( + notice( + count - result.value.limitations.length + 1, + "limitations", + layout.evidenceId, + ), + ); + } + } + }, +); + +it.each(["partial", "unknown"] as const)( + "preserves %s parent graph completeness when a module page is exhausted", + (status) => { + const analysis = analysisViewJavaScriptAnalysis(); + const first = analysis.graph.nodes[0]; + if (first === undefined) throw new Error("missing module"); + const unknown = createJavaScriptApplicationNode({ + kind: "unknown", + identity: { + strategy: "observation-fingerprint", + stability: "observation-only", + observation_sha256: "8".repeat(64), + scope: "unclassified module", + }, + observations: first.observations.map((observation) => ({ + label: "unclassified module", + properties: {}, + evidence: observation.evidence, + })), + }); + const messages = [ + ...Array.from( + { length: 65 }, + (_, index) => + `Module ${String(index).padStart(5, "0")} was not parsed.`, + ), + "z".repeat(300_000), + ]; + const { graph_id: _applicationId, ...application } = analysis.graph; + const graph = createJavaScriptApplicationGraph({ + ...application, + nodes: [...application.nodes, unknown], + limitations: messages.slice(0, 1), + coverage: { ...application.coverage, status, omitted_count: null }, + }); + const bound = analysisViewBindJavaScriptGraphs(analysis, graph); + const { graph_id: _semanticId, ...semantic } = bound.semantic_graph; + const parent = javascriptParent({ + ...bound, + limitations: messages, + semantic_graph: createJavaScriptSemanticGraph({ + ...semantic, + limitations: messages.slice(0, 1), + }), + }); + for (const view of [ + { kind: "page", collection: "modules", offset: 0, limit: 10 }, + { + kind: "item", + collection: "modules", + selector: { path: "renderer.js" }, + }, + ] as const) { + const result = projectAnalysisView(parent, view); + if (!result.ok) throw result.error; + expect(result.value.coverage.exhausted).toBe(true); + expect(result.value.unknowns).toEqual([ + "1 application graph unknowns remain in the parent Evidence.", + ...messages.slice(0, 65), + `graph.coverage.status is ${status}`, + "semantic_graph.coverage.status is unknown", + `1 additional unknowns omitted from this view; consult source parent Evidence ${parent.evidenceId} for the complete list (the caller-supplied record, or its retaining session).`, + ]); + } + }, +); + it("does not match unavailable layout names using a display placeholder", () => { const layout = analysisViewLayoutFixture(); const section = layout.sections[2]; @@ -793,6 +947,35 @@ it("rejects unsupported parent operations and incompatible collections", () => { }); }); +it("keeps the complete unique set when it fits the byte budget", () => { + const messages = ["a".repeat(2_400), "b".repeat(2_400)]; + expect( + boundedViewMessages(messages, `ev_${"a".repeat(64)}`, "limitations"), + ).toEqual(messages); +}); + +it("does not impose a message-count cutoff below the byte budget", () => { + const messages = Array.from( + { length: 100 }, + (_, index) => `message ${index}`, + ); + expect( + boundedViewMessages(messages, `ev_${"a".repeat(64)}`, "limitations"), + ).toEqual(messages); +}); + +it("bounds repeated view messages even when fewer than 64 source strings are huge", () => { + const oversized = ["\u0000".repeat(300_000), "\\".repeat(300_000)]; + const projected = boundedViewMessages( + oversized, + `ev_${"a".repeat(64)}`, + "limitations", + ); + expect(projected).toHaveLength(1); + expect(projected[0]).toContain("2 additional limitations omitted"); + expect(Buffer.byteLength(JSON.stringify(projected))).toBeLessThan(256 * 1024); +}); + it("preserves selected native facet unavailability instead of an exhausted empty page", () => { const parent = nativeParent({ ...functionDossierSchema.parse(ghidraFunctionDossier()), diff --git a/src/domain/analysisView/analysisViewCoverage.ts b/src/domain/analysisView/analysisViewCoverage.ts index 30730a2c9..c01256846 100644 --- a/src/domain/analysisView/analysisViewCoverage.ts +++ b/src/domain/analysisView/analysisViewCoverage.ts @@ -1,4 +1,6 @@ +import { uniqueSorted } from "../canonicalOrdering.js"; import type { AnalysisViewCoverage } from "./analysisView.js"; + /** Coverage for a summary, facet, or single selected object. */ export const completeWithinViewCoverage = ( examined: number, @@ -26,3 +28,45 @@ export const pageViewCoverage = ( exhausted, }; }; + +/** Per-field repeated MCP message budget, independent of the selected page. */ +export const ANALYSIS_VIEW_MESSAGE_BYTE_LIMIT = 256 * 1024; + +// A UTF-16 unit needs at most six JSON bytes, or twelve when that JSON is +// escaped into MCP text. Reserve both forms across three Evidence projections. +// This upper bound rejects giant messages without allocating their JSON copies. +const messageWireBytes = (value: string): number => (value.length * 18 + 6) * 3; + +/** Deduplicate and bound messages by count and repeated serialized bytes. */ +export const boundedViewMessages = ( + values: readonly string[], + parentEvidenceId: string, + field: "limitations" | "unknowns", + required: readonly string[] = [], +): string[] => { + const priority = uniqueSorted(required); + const unique = uniqueSorted([...priority, ...values]); + const notice = (omitted: number): string => + `${String(omitted)} additional ${field} omitted from this view; consult source parent Evidence ${parentEvidenceId} for the complete list (the caller-supplied record, or its retaining session).`; + const totalBytes = unique.reduce( + (total, value) => total + messageWireBytes(value), + 0, + ); + if (totalBytes <= ANALYSIS_VIEW_MESSAGE_BYTE_LIMIT) return unique; + + // Reserve notice space only after proving the complete source set does not fit. + let remaining = + ANALYSIS_VIEW_MESSAGE_BYTE_LIMIT - messageWireBytes(notice(unique.length)); + const retained = new Set(); + for (const value of [...priority, ...unique]) { + if (retained.has(value)) continue; + const bytes = messageWireBytes(value); + if (bytes > remaining) continue; + retained.add(value); + remaining -= bytes; + } + const omitted = unique.length - retained.size; + return omitted === 0 + ? unique + : [...uniqueSorted([...retained]), notice(omitted)]; +}; diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index fdba8c572..408ae80ae 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -3,7 +3,6 @@ import { type AnalysisInputIssue, } from "../analysisErrorCore.js"; import type { AnalysisError } from "../analysisErrorBase.js"; -import { uniqueSorted } from "../canonicalOrdering.js"; import type { BinaryLayout } from "../native/binaryLayout.js"; import { err, ok, type Result } from "../result.js"; import type { @@ -12,6 +11,7 @@ import type { UnsignedAnalysisView, } from "./analysisView.js"; import { + boundedViewMessages, completeWithinViewCoverage as completeCoverage, pageViewCoverage as pageCoverage, } from "./analysisViewCoverage.js"; @@ -126,8 +126,16 @@ const parentFields = (parent: AnalysisViewParent, layout: BinaryLayout) => ({ path: layout.artifact.path, sha256: layout.artifact.sha256, }, - limitations: uniqueSorted([...parent.limitations, ...layout.limitations]), - unknowns: uniqueSorted([...layoutUnknowns(layout)]), + limitations: boundedViewMessages( + [...parent.limitations, ...layout.limitations], + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + layoutUnknowns(layout), + parent.evidenceId, + "unknowns", + ), }); const layoutSummary = (layout: BinaryLayout) => ({ diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 41a22f8d9..7af6645c9 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -3,12 +3,12 @@ import { type AnalysisInputIssue, } from "../analysisErrorCore.js"; import type { AnalysisError } from "../analysisErrorBase.js"; -import { uniqueSorted } from "../canonicalOrdering.js"; import { jsonObjectSchema, type JsonValue } from "../jsonValue.js"; import type { JavaScriptApplicationAnalysisResult } from "../javascript/javascriptApplicationAnalysis.js"; import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js"; import { err, ok, type Result } from "../result.js"; import { + boundedViewMessages, completeWithinViewCoverage as completeCoverage, pageViewCoverage as pageCoverage, } from "./analysisViewCoverage.js"; @@ -136,23 +136,20 @@ const applicationUnknownCount = ( 0, ); -const javascriptUnknowns = ( +const javascriptCoverageUnknowns = ( analysis: JavaScriptApplicationAnalysisResult, ): readonly string[] => { - const unknowns = [ - ...analysis.limitations, - ...analysis.graph.limitations, - ...analysis.semantic_graph.limitations, - ]; + const unknowns: string[] = []; if (analysis.graph.coverage.status !== "complete") unknowns.push(`graph.coverage.status is ${analysis.graph.coverage.status}`); if (analysis.semantic_graph.coverage.status !== "complete") unknowns.push( `semantic_graph.coverage.status is ${analysis.semantic_graph.coverage.status}`, ); - if (applicationUnknownCount(analysis) > 0) + const applicationUnknowns = applicationUnknownCount(analysis); + if (applicationUnknowns > 0) unknowns.push( - `${String(applicationUnknownCount(analysis))} application graph unknowns remain in the parent Evidence.`, + `${String(applicationUnknowns)} application graph unknowns remain in the parent Evidence.`, ); if (analysis.semantic_graph.unknowns.length > 0) unknowns.push( @@ -164,22 +161,33 @@ const javascriptUnknowns = ( const parentFields = ( parent: AnalysisViewParent, analysis: JavaScriptApplicationAnalysisResult, -) => ({ - parent_evidence_id: parent.evidenceId, - parent_operation: "analyze_javascript_application" as const, - parent_digest: parent.evidenceId.slice(3), - artifact: { - path: analysis.input_path, - sha256: analysis.root_artifact_sha256, - }, - limitations: uniqueSorted([ - ...parent.limitations, +) => { + const limitations = [ ...analysis.limitations, ...analysis.graph.limitations, ...analysis.semantic_graph.limitations, - ]), - unknowns: uniqueSorted([...javascriptUnknowns(analysis)]), -}); + ]; + return { + parent_evidence_id: parent.evidenceId, + parent_operation: "analyze_javascript_application" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { + path: analysis.input_path, + sha256: analysis.root_artifact_sha256, + }, + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + limitations, + parent.evidenceId, + "unknowns", + javascriptCoverageUnknowns(analysis), + ), + }; +}; const selectModule = ( modules: ReturnType, diff --git a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts index b57d54b59..0c5e0b5bd 100644 --- a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts +++ b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts @@ -10,6 +10,7 @@ const project = ( offset = 0, limit = 50, parameters: unknown = { sensitive_value_count: 0 }, + includeQueryParameterNames?: boolean, ) => { const parent = analysisViewCaptureEvidence(capture); return projectAnalysisView( @@ -20,9 +21,111 @@ const project = ( limitations: [], parameters, }, - { kind: "api-surface", offset, limit }, + { + kind: "api-surface", + offset, + limit, + ...(includeQueryParameterNames === undefined + ? {} + : { + include_query_parameter_names: includeQueryParameterNames, + }), + }, ); }; + +it("selects query names without values, retains source ordinals and leaves default groups unchanged", () => { + const capture = analysisViewCaptureFixture(); + capture.records = capture.records.slice(0, 3); + capture.total_records = 3; + const urls = [ + "https://api.test/products?category=books&page=1&category=repeat#fragment-only", + "https://api.test/products?category=games&sort=price", + "https://api.test/health", + ]; + for (const [index, record] of capture.records.entries()) + record.reported = { + request: { method: "GET", url: urls[index]! }, + response: { status: 200, content: { mimeType: "application/json" } }, + }; + const selected = project(capture, 0, 50, { sensitive_value_count: 0 }, true); + const omitted = project(capture); + const disabled = project(capture, 0, 50, { sensitive_value_count: 0 }, false); + if ( + !selected.ok || + selected.value.kind !== "api-surface" || + !omitted.ok || + omitted.value.kind !== "api-surface" || + !disabled.ok || + disabled.value.kind !== "api-surface" + ) + throw new Error("Expected surfaces"); + expect(disabled.value.groups).toEqual(omitted.value.groups); + expect(selected.value.groups).toEqual([ + { ...omitted.value.groups[0], query_parameter_names: [] }, + { + ...omitted.value.groups[1], + query_parameter_names: [ + { name: "category", record_ordinals: [0, 1] }, + { name: "page", record_ordinals: [0] }, + { name: "sort", record_ordinals: [1] }, + ], + }, + ]); + for (const literal of ["books", "games", "repeat", "price", "fragment-only"]) + expect(JSON.stringify(selected.value)).not.toContain(literal); + expect(selected.value.parent_evidence_id).toBe( + omitted.value.parent_evidence_id, + ); +}); + +it("preserves decoded empty and Unicode query names, plus distinctions and caller ordinal order", () => { + const capture = analysisViewCaptureFixture(); + capture.records = [capture.records[3]!, capture.records[1]!]; + const urls = [ + "https://api.test/a/%2e%2e/products?b=1&a+b=2&a%2Bb=3&=4&%E9%9B%AA=5&b=6&&%FF=7&__proto__=8#ignored=9", + "https://api.test/a/%2e%2e/products?b=10", + ]; + capture.records.forEach((record, index) => { + record.reported = { request: { method: "GET", url: urls[index]! } }; + }); + const result = project(capture, 0, 1, { sensitive_value_count: 0 }, true); + if (!result.ok || result.value.kind !== "api-surface") + throw new Error("Expected surface"); + expect(result.value.groups[0]).toMatchObject({ + path_template: "/a/%2e%2e/products", + record_ordinals: [3, 1], + query_parameter_names: [ + { name: "", record_ordinals: [3] }, + { name: "__proto__", record_ordinals: [3] }, + { name: "a b", record_ordinals: [3] }, + { name: "a+b", record_ordinals: [3] }, + { name: "b", record_ordinals: [3, 1] }, + { name: "雪", record_ordinals: [3] }, + { name: "�", record_ordinals: [3] }, + ], + }); + expect(result.value.records.total).toBe(15); + expect(result.value.limitations.join(" ")).toContain("ordinal subset"); +}); + +it("preserves sensitive-parent refusal when query names are selected", () => { + expect( + project( + analysisViewCaptureFixture(), + 0, + 50, + { sensitive_value_count: 1 }, + true, + ), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisUnsupportedTargetError", + reason: "sensitive_values_unretained", + }, + }); +}); it("groups only exact formats, preserves literal routes, excludes URLs and produces deterministic digests", () => { const result = project(); if (!result.ok || result.value.kind !== "api-surface") diff --git a/src/domain/analysisView/webNetworkApiSurfaceView.ts b/src/domain/analysisView/webNetworkApiSurfaceView.ts index a01a92d3f..142d151cb 100644 --- a/src/domain/analysisView/webNetworkApiSurfaceView.ts +++ b/src/domain/analysisView/webNetworkApiSurfaceView.ts @@ -15,6 +15,12 @@ export const apiSurfaceViewSchema = z.strictObject({ kind: z.literal("api-surface"), offset: z.number().int().nonnegative().default(0), limit: z.number().int().positive().default(50), + include_query_parameter_names: z + .boolean() + .optional() + .describe( + "Include distinct observed query parameter names and their original record ordinals; names do not establish required parameters or a complete API schema.", + ), }); const ruleSchema = z.enum(["numeric", "uuid", "hex", "iso-date"]); const groupSchema = z.strictObject({ @@ -32,6 +38,14 @@ const groupSchema = z.strictObject({ record_ordinals: z.array(z.number().int().nonnegative()), statuses: z.array(z.number()), content_types: z.array(z.string()), + query_parameter_names: z + .array( + z.strictObject({ + name: z.string(), + record_ordinals: z.array(z.number().int().nonnegative()), + }), + ) + .optional(), flags: z.array( z.enum(["single-sample", "mixed-statuses", "mixed-content-types"]), ), @@ -66,7 +80,10 @@ export const apiSurfaceFields = { export const projectWebNetworkApiSurfaceView = ( parent: AnalysisViewParent, view: AnalysisViewRequest, - accumulator = new WebNetworkApiSurfaceAccumulator(), + accumulator = new WebNetworkApiSurfaceAccumulator( + undefined, + view.kind === "api-surface" && view.include_query_parameter_names === true, + ), ): Result => { const unsupported = (reason: string) => err( @@ -111,6 +128,11 @@ export const projectWebNetworkApiSurfaceView = ( ...parent.limitations, ...capture.limitations, "Grouping does not establish handler identity, URL-space schemas or causality.", + ...(view.include_query_parameter_names === true + ? [ + "Observed query parameter names do not establish required parameters, value types or a complete API schema.", + ] + : []), ...(capture.records.length < capture.total_records ? [ "Parent contains an ordinal subset; the surface covers only retained records.", diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index c46fb9661..99b20c321 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -68,7 +68,7 @@ export const androidApplicationProjectionResultSchema = z.strictObject({ // Count completeness is separate from the declared coverage status. inventory_complete: z.boolean(), }), - limitations: z.array(z.string().min(1)), + limitations: z.array(z.string()), }); export type AndroidApplicationProjectionInput = z.infer< @@ -99,7 +99,8 @@ export const projectAndroidApplication = ( ); const components = classified; const limitations = [ - ...(!hasCompleteInventoryCoverage(inventory) + ...inventory.limitations, + ...(!hasCompleteInventoryCoverage(inventory, "container-entries") ? ["Source inventory coverage is partial; absence is unknown."] : []), "Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.", diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index dfb34e3c5..6dac13f82 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -94,7 +94,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({ // Count completeness is separate from the declared coverage status. inventory_complete: z.boolean(), }), - limitations: z.array(z.string().min(1)), + limitations: z.array(z.string()), }); export type AppleApplicationProjectionInput = z.infer< @@ -169,18 +169,24 @@ export const projectAppleApplication = ( ); const uninventoriedImage = rootFormat === "dmg" && inventory.occurrences.length === 1; - const inventoryComplete = hasCompleteInventoryCoverage(inventory); + const inventoryComplete = hasCompleteInventoryCoverage( + inventory, + "container-entries", + ); const complete = inventoryComplete && !uninventoriedImage; - const limitations = projectionLimitations({ - complete: inventoryComplete, - uninventoriedImage, - roots, - bundles, - sidecars: entries.some(({ path }) => isSidecar(path)), - symlinks: entries.some(({ kind }) => kind === "symlink"), - unattributed, - bridgeCandidateCoverage: bridgeProjection.coverage, - }); + const limitations = [ + ...inventory.limitations, + ...projectionLimitations({ + complete: inventoryComplete, + uninventoriedImage, + roots, + bundles, + sidecars: entries.some(({ path }) => isSidecar(path)), + symlinks: entries.some(({ kind }) => kind === "symlink"), + unattributed, + bridgeCandidateCoverage: bridgeProjection.coverage, + }), + ]; const withoutId = { root_sha256: inventory.manifest.root_sha256, root_format: rootFormat, diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 9d7a766af..72494136c 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -104,13 +104,51 @@ export const compareArtifacts = ( const right = parseArtifactInventoryEvidence(rightEvidence); const leftComplete = left.inventory.complete; const rightComplete = right.inventory.complete; - const leftCovered = hasCompleteInventoryCoverage(left.inventory); - const rightCovered = hasCompleteInventoryCoverage(right.inventory); + const sameScope = + left.inventory.coverage.scope === right.inventory.coverage.scope; + const rootCoverageComplete = + sameScope && + left.inventory.complete && + right.inventory.complete && + left.inventory.coverage.status === "complete" && + right.inventory.coverage.status === "complete"; + const entryCoverageComplete = + rootCoverageComplete && + left.inventory.coverage.scope === "container-entries"; + const leftCovered = + sameScope && + hasCompleteInventoryCoverage(left.inventory, left.inventory.coverage.scope); + const rightCovered = + sameScope && + hasCompleteInventoryCoverage( + right.inventory, + right.inventory.coverage.scope, + ); + const fullyCovered = + leftCovered && + rightCovered && + (entryCoverageComplete || + (rootCoverageComplete && + left.inventory.manifest.occurrence_count === 1 && + right.inventory.manifest.occurrence_count === 1)); const limitations = [ ...left.inventory.limitations.map((item) => `Left: ${item}`), ...right.inventory.limitations.map((item) => `Right: ${item}`), ...(leftComplete ? [] : ["Left artifact inventory is incomplete."]), ...(rightComplete ? [] : ["Right artifact inventory is incomplete."]), + ...(sameScope ? [] : ["Artifact inventory scopes differ."]), + ...(sameScope && + left.inventory.coverage.scope === "root" && + (left.inventory.manifest.occurrence_count > 1 || + right.inventory.manifest.occurrence_count > 1) + ? ["Container entry coverage is unavailable for child paths."] + : []), + ...(left.inventory.coverage.status === "complete" + ? [] + : ["Left artifact inventory traversal coverage is unknown."]), + ...(right.inventory.coverage.status === "complete" + ? [] + : ["Right artifact inventory traversal coverage is unknown."]), ]; const changes = compareOccurrences( left.inventory, @@ -119,7 +157,7 @@ export const compareArtifacts = ( ...left.evidence.map(({ evidence_id: id }) => id), ...right.evidence.map(({ evidence_id: id }) => id), ], - leftCovered && rightCovered, + { root: rootCoverageComplete, entries: entryCoverageComplete }, ); applyRootHashChange(left.inventory, right.inventory, changes, [ ...left.evidence.map(({ evidence_id: id }) => id), @@ -129,7 +167,7 @@ export const compareArtifacts = ( left.inventory, right.inventory, changes, - leftCovered && rightCovered, + fullyCovered, ); return artifactComparisonResultSchema.parse({ status: @@ -139,7 +177,7 @@ export const compareArtifacts = ( ({ classification }) => classification === "contradiction", ) ? "contradiction" - : !leftCovered || !rightCovered + : !fullyCovered ? "unknown" : changes.some(({ classification }) => classification === "unknown") ? "unknown" @@ -158,7 +196,7 @@ const compareOccurrences = ( left: InventorySet, right: InventorySet, evidenceLinks: readonly string[], - complete: boolean, + coverage: { readonly root: boolean; readonly entries: boolean }, ): ArtifactChange[] => { const leftByPath = new Map( left.occurrences.map((item) => [item.logical_path, item]), @@ -187,7 +225,8 @@ const compareOccurrences = ( rightNode: nodeFor(rightOccurrence, rightNodes), leftRelations: leftRelations.get(path) ?? [], rightRelations: rightRelations.get(path) ?? [], - complete, + rootCovered: coverage.root, + entriesCovered: coverage.entries, evidenceLinks, }); if (change !== null) output.push(change); @@ -203,7 +242,8 @@ interface ClassifyPathInput { readonly rightNode: ArtifactNode | undefined; readonly leftRelations: readonly RelationProjection[]; readonly rightRelations: readonly RelationProjection[]; - readonly complete: boolean; + readonly rootCovered: boolean; + readonly entriesCovered: boolean; readonly evidenceLinks: readonly string[]; } @@ -217,17 +257,18 @@ const buildArtifactChangeBase = (input: ClassifyPathInput) => ({ }); const classifyPresence = (input: ClassifyPathInput): ArtifactChange | null => { + const covered = input.path === "." ? input.rootCovered : input.entriesCovered; if (input.leftOccurrence === undefined) return { ...buildArtifactChangeBase(input), - classification: input.complete ? "added" : "unknown", - dimensions: [input.complete ? "content" : "availability"], + classification: covered ? "added" : "unknown", + dimensions: [covered ? "content" : "availability"], }; if (input.rightOccurrence === undefined) return { ...buildArtifactChangeBase(input), - classification: input.complete ? "removed" : "unknown", - dimensions: [input.complete ? "content" : "availability"], + classification: covered ? "removed" : "unknown", + dimensions: [covered ? "content" : "availability"], }; return null; }; diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 6b2709baa..5a804c8d6 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -188,8 +188,11 @@ export const artifactGraphManifestSchema = z.object({ edge_count: z.number().int().min(0), }); -/** Whether the inventory producer covered its modeled child-entry scope. */ -export const artifactInventoryCoverageSchema = z.enum(["complete", "partial"]); +/** Producer-owned traversal coverage; content certainty remains on each occurrence. */ +export const artifactInventoryCoverageSchema = z.strictObject({ + scope: z.enum(["root", "container-entries"]), + status: z.enum(["complete", "unknown"]), +}); export type ArtifactInventoryCoverage = z.infer< typeof artifactInventoryCoverageSchema >; @@ -202,15 +205,19 @@ export const artifactInventoryResultSchema = z.object({ edges: z.array(artifactEdgeSchema), provenance: z.array(artifactCommandSchema), integrity_contradictions: z.array(integrityContradictionSchema).default([]), + // Omitted by older retained Evidence; the inventory parser owns its compatibility policy. + coverage: artifactInventoryCoverageSchema.optional(), limitations: z.array(z.string()), /** Optional only for persisted inventory Evidence written before this field. */ - coverage_status: artifactInventoryCoverageSchema.optional(), + coverage_status: z.enum(["complete", "partial"]).optional(), }); -/** Current inventory producers always classify the coverage they examined. */ +/** Current producers always declare coverage; historical Evidence inputs may omit it. */ export const artifactInventoryOutputSchema = - artifactInventoryResultSchema.extend({ - coverage_status: artifactInventoryCoverageSchema, + artifactInventoryResultSchema.safeExtend({ + coverage: artifactInventoryCoverageSchema, + coverage_status: + artifactInventoryResultSchema.shape.coverage_status.unwrap(), }); export type ArtifactInventoryOutput = z.infer< typeof artifactInventoryOutputSchema diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index 213364816..5c0112d4c 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -18,7 +18,10 @@ import { } from "./artifactIdentity.js"; import { parseEvidence, type Evidence } from "./evidence.js"; import { artifactInspectionResultSchema } from "./artifactInspection.js"; -import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js"; +import { + isZipPackageFormat, + isZipInventoryScopeLimitation, +} from "./zipPackageFormat.js"; /** Complete or partially assembled inventory used by comparison workflows. */ export interface InventorySet { @@ -30,13 +33,17 @@ export interface InventorySet { readonly limitations: readonly string[]; /** True when merged node, occurrence, and edge counts match the manifest. */ readonly complete: boolean; - readonly coverage_status: ArtifactInventoryCoverage; + readonly coverage: ArtifactInventoryCoverage; } /** Whether all graph members are present and the producer reports full coverage. */ export const hasCompleteInventoryCoverage = ( inventory: InventorySet, -): boolean => inventory.complete && inventory.coverage_status === "complete"; + requiredScope: ArtifactInventoryCoverage["scope"], +): boolean => + inventory.complete && + inventory.coverage.scope === requiredScope && + inventory.coverage.status === "complete"; export interface ParsedInventorySet { readonly evidence: readonly Evidence[]; @@ -132,11 +139,14 @@ const assembleInventoryResults = ( const first = pages[0]; if (first === undefined) throw new TypeError("Artifact inventory requires Evidence pages"); - const coverageStatuses = new Set(pages.map(resolveInventoryCoverageStatus)); - if (coverageStatuses.size !== 1) - throw new TypeError("Artifact inventory pages disagree on coverage status"); - const coverageStatus = [...coverageStatuses][0]!; const manifestJson = canonicalJson(first.manifest, "Artifact inventory"); + const coverageJson = canonicalJson( + { + coverage: first.coverage ?? null, + coverage_status: first.coverage_status ?? null, + }, + "Artifact inventory coverage", + ); const nodes = new Map(); const occurrences = new Map(); const paths = new Map(); @@ -144,6 +154,18 @@ const assembleInventoryResults = ( for (const page of pages) { if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson) throw new TypeError("Artifact inventory pages do not share one manifest"); + if ( + canonicalJson( + { + coverage: page.coverage ?? null, + coverage_status: page.coverage_status ?? null, + }, + "Artifact inventory coverage", + ) !== coverageJson + ) + throw new TypeError( + "Artifact inventory pages do not share one coverage declaration", + ); if ( canonicalJson(page.integrity_contradictions, "Artifact inventory") !== canonicalJson(first.integrity_contradictions, "Artifact inventory") @@ -165,12 +187,11 @@ const assembleInventoryResults = ( for (const edge of page.edges) mergeExact(edges, edge.edge_id, edge, "edge"); } - const inventory = orderedInventory( - first, - pages, - { nodes, occurrences, edges }, - coverageStatus, - ); + const inventory = orderedInventory(first, pages, { + nodes, + occurrences, + edges, + }); if (inventory.complete) validateCompleteInventory(inventory); return inventory; }; @@ -186,7 +207,6 @@ const orderedInventory = ( ArtifactInventoryResult["edges"][number] >; }, - coverageStatus: ArtifactInventoryCoverage, ): InventorySet => { const nodes = [...values.nodes.values()].sort((left, right) => compareUnicodeCodePoints(left.artifact_id, right.artifact_id), @@ -197,40 +217,78 @@ const orderedInventory = ( const edges = [...values.edges.values()].sort((left, right) => compareUnicodeCodePoints(left.edge_id, right.edge_id), ); + const limitations = [ + ...new Set(pages.flatMap((inventory) => inventory.limitations)), + ].sort(compareUnicodeCodePoints); return { manifest: first.manifest, nodes, occurrences, edges, integrityContradictions: first.integrity_contradictions, - limitations: [ - ...new Set(pages.flatMap((inventory) => inventory.limitations)), - ].sort(compareUnicodeCodePoints), + limitations, + coverage: resolveInventoryCoverage({ ...first, limitations }), complete: nodes.length === first.manifest.node_count && occurrences.length === first.manifest.occurrence_count && edges.length === first.manifest.edge_count, - coverage_status: coverageStatus, }; }; -const resolveInventoryCoverageStatus = ( +// Normalize both retained coverage formats at this owning boundary. +const resolveInventoryCoverage = ( inventory: ArtifactInventoryResult, ): ArtifactInventoryCoverage => { - if (inventory.coverage_status !== undefined) return inventory.coverage_status; - // Older retained Evidence has no typed status. Keep only its known - // informational notices complete; unknown limitation text fails closed. - return inventory.limitations.every(isKnownNonCoverageNotice) - ? "complete" - : "partial"; + const legacy = legacyInventoryCoverage( + inventory.manifest.root_format, + inventory.limitations, + inventory.integrity_contradictions.length, + ); + if (inventory.coverage !== undefined) { + if ( + inventory.coverage_status !== undefined && + (inventory.coverage.status === "complete") !== + (inventory.coverage_status === "complete") + ) + throw new TypeError( + "Artifact inventory has conflicting coverage declarations", + ); + return inventory.coverage; + } + return inventory.coverage_status === undefined + ? legacy + : { + ...legacy, + status: + inventory.coverage_status === "complete" ? "complete" : "unknown", + }; }; -const isKnownNonCoverageNotice = (limitation: string): boolean => - limitation === "Artifact has no child container entries." || - limitation === ZIP_NON_ENTRY_TAIL_LIMITATION || - /^\d+ integrity contradiction\(s\) were recorded; mismatched content is observed-untrusted\.$/u.test( - limitation, - ); +// Retained pre-coverage records cannot classify arbitrary prose. Recognize only +// the historical producer vocabulary here; unfamiliar diagnostics remain unknown. +const legacyInventoryCoverage = ( + format: ArtifactInventoryResult["manifest"]["root_format"], + limitations: readonly string[], + contradictionCount: number, +): ArtifactInventoryCoverage => ({ + scope: + isZipPackageFormat(format) || + ["directory", "asar", "dmg", "pkg", "mach-o-universal"].includes(format) + ? "container-entries" + : "root", + status: limitations.some( + (limitation) => + limitation !== "Artifact has no child container entries." && + !isZipInventoryScopeLimitation(limitation) && + !( + contradictionCount > 0 && + limitation === + `${String(contradictionCount)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.` + ), + ) + ? "unknown" + : "complete", +}); const validateCompleteInventory = (inventory: InventorySet): void => { const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id)); diff --git a/src/domain/artifactInventorySnapshot.ts b/src/domain/artifactInventorySnapshot.ts index 2157c03ed..8626d7465 100644 --- a/src/domain/artifactInventorySnapshot.ts +++ b/src/domain/artifactInventorySnapshot.ts @@ -7,7 +7,9 @@ import type { /** Immutable inventory produced by one complete artifact scan. */ export interface ArtifactInventorySnapshot { - readonly coverage_status: ArtifactInventoryCoverage; + readonly coverage_status: NonNullable< + ArtifactInventoryResult["coverage_status"] + >; readonly manifest: ArtifactInventoryResult["manifest"]; readonly nodes: readonly ArtifactNode[]; readonly occurrences: ArtifactInventoryResult["occurrences"]; @@ -16,5 +18,6 @@ export interface ArtifactInventorySnapshot { ArtifactInventoryResult["provenance"][number] >; readonly integrity_contradictions: readonly IntegrityContradiction[]; + readonly coverage?: ArtifactInventoryCoverage; readonly limitations: readonly string[]; } diff --git a/src/domain/binaryTarget.fixture.ts b/src/domain/binaryTarget.fixture.ts index 7d8a0a7f9..345a3f24c 100644 --- a/src/domain/binaryTarget.fixture.ts +++ b/src/domain/binaryTarget.fixture.ts @@ -58,20 +58,67 @@ export const fat = ( return bytes; }; -/** Build a bounded PE image with optional managed metadata. */ +export interface PeCodeViewFixture { + readonly type?: "RSDS" | "NB10"; + readonly guid?: string; + readonly age?: number; + readonly pdbPath?: string; +} + +/** Build a bounded PE image with optional managed metadata or CodeView debug data. */ export const pe = ( machine: number, offset = 64, characteristics = 0x0002, managed = false, + codeView?: PeCodeViewFixture, ): Buffer => { const optionalHeaderSize = machine === 0x014c || machine === 0x01c0 ? 224 : 240; - const bytes = Buffer.alloc(Math.max(512, offset + 24 + optionalHeaderSize)); + const sectionTableOffset = offset + 24 + optionalHeaderSize; + const sectionCount = codeView !== undefined ? 1 : 0; + const rawDataOffset = 512; + + let codeViewData: Buffer | undefined; + if (codeView !== undefined) { + const type = codeView.type ?? "RSDS"; + const pdbPath = codeView.pdbPath ?? "app.pdb"; + const age = codeView.age ?? 1; + const pathBuf = Buffer.from(`${pdbPath}\0`, "utf8"); + if (type === "RSDS") { + codeViewData = Buffer.alloc(24 + pathBuf.length); + codeViewData.write("RSDS", 0, "ascii"); + const guid = codeView.guid ?? "01234567-89ab-cdef-0123-456789abcdef"; + const parts = guid.split("-"); + if (parts.length === 5) { + codeViewData.writeUInt32LE(parseInt(parts[0]!, 16), 4); + codeViewData.writeUInt16LE(parseInt(parts[1]!, 16), 8); + codeViewData.writeUInt16LE(parseInt(parts[2]!, 16), 10); + Buffer.from(parts[3]!, "hex").copy(codeViewData, 12); + Buffer.from(parts[4]!, "hex").copy(codeViewData, 14); + } + codeViewData.writeUInt32LE(age, 20); + pathBuf.copy(codeViewData, 24); + } else { + codeViewData = Buffer.alloc(16 + pathBuf.length); + codeViewData.write("NB10", 0, "ascii"); + codeViewData.writeUInt32LE(0, 4); + codeViewData.writeUInt32LE(0, 8); + codeViewData.writeUInt32LE(age, 12); + pathBuf.copy(codeViewData, 16); + } + } + + const minSize = + codeViewData !== undefined + ? rawDataOffset + 28 + codeViewData.length + 32 + : Math.max(512, offset + 24 + optionalHeaderSize); + const bytes = Buffer.alloc(minSize); bytes.write("MZ", 0, "ascii"); bytes.writeUInt32LE(offset, 0x3c); bytes.write("PE\0\0", offset, "binary"); bytes.writeUInt16LE(machine, offset + 4); + bytes.writeUInt16LE(sectionCount, offset + 6); bytes.writeUInt16LE(optionalHeaderSize, offset + 20); bytes.writeUInt16LE(characteristics, offset + 22); const optionalHeader = offset + 24; @@ -83,6 +130,29 @@ export const pe = ( bytes.writeUInt32LE(0x2000, optionalHeader + directoryOffset + 14 * 8); bytes.writeUInt32LE(72, optionalHeader + directoryOffset + 14 * 8 + 4); } + if (codeViewData !== undefined) { + const debugDirRva = 0x1000; + const debugDirSize = 28; + bytes.writeUInt32LE(debugDirRva, optionalHeader + directoryOffset + 6 * 8); + bytes.writeUInt32LE( + debugDirSize, + optionalHeader + directoryOffset + 6 * 8 + 4, + ); + + bytes.write(".rdata\0\0", sectionTableOffset, "ascii"); + bytes.writeUInt32LE(0x1000, sectionTableOffset + 8); + bytes.writeUInt32LE(0x1000, sectionTableOffset + 12); + bytes.writeUInt32LE(512, sectionTableOffset + 16); + bytes.writeUInt32LE(rawDataOffset, sectionTableOffset + 20); + bytes.writeUInt32LE(0x40000040, sectionTableOffset + 36); + + bytes.writeUInt32LE(2, rawDataOffset + 12); + bytes.writeUInt32LE(codeViewData.length, rawDataOffset + 16); + bytes.writeUInt32LE(debugDirRva + 28, rawDataOffset + 20); + bytes.writeUInt32LE(rawDataOffset + 28, rawDataOffset + 24); + + codeViewData.copy(bytes, rawDataOffset + 28); + } return bytes; }; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 94828ee6e..4e668e460 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -3,6 +3,7 @@ import type { ElfFamilyMetadata, HeaderArchitecture, MipsElfMetadata, + PeCodeViewRecord, RiscvElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; @@ -50,6 +51,7 @@ export type ExecutableMetadata = | "shared-library" | "non-executable"; readonly managed: boolean; + readonly codeView?: PeCodeViewRecord; }; /** @@ -257,7 +259,8 @@ const parseMz = ( ); if (windowsHeader.signature !== "PE") return err("invalid or truncated Windows executable header in MZ image"); - return parsePeRecord(bytes.subarray(offset)); + const codeView = parsePeCodeViewFromBuffer(bytes, offset); + return parsePeRecord(bytes.subarray(offset), codeView); } const parsed = parseDosMzHeader(bytes, fileSize, signatureAtOffset); return parsed.ok @@ -272,6 +275,7 @@ const parseMz = ( /** Parse a bounded PE signature, COFF record, and optional header without I/O. */ export const parsePeRecord = ( record: Buffer, + codeView: PeCodeViewRecord | undefined, ): Result => { if ( record.length < 24 || @@ -305,9 +309,176 @@ export const parsePeRecord = ( ? "application" : "shared-library", managed: managed.value, + ...(codeView !== undefined ? { codeView } : {}), }); }; +/** Parse CodeView record data (RSDS or NB10) from raw data bytes. */ +export const parseCodeViewData = ( + buffer: Buffer, + sizeOfData: number, +): PeCodeViewRecord | undefined => { + if (sizeOfData < 16 || buffer.length < 16) return undefined; + const magic = buffer.subarray(0, 4).toString("binary"); + const pathOffset = magic === "RSDS" ? 24 : magic === "NB10" ? 16 : undefined; + if ( + pathOffset === undefined || + sizeOfData <= pathOffset || + buffer.length <= pathOffset + ) + return undefined; + const end = buffer.indexOf(0, pathOffset); + if (end <= pathOffset || end >= sizeOfData) return undefined; + const pdbPath = buffer.toString("utf8", pathOffset, end); + const pdbFileName = pdbPath.split(/[/\\]/u).pop() ?? pdbPath; + if (magic === "RSDS" && sizeOfData >= 24 && buffer.length >= 24) { + const part1 = buffer.readUInt32LE(4).toString(16).padStart(8, "0"); + const part2 = buffer.readUInt16LE(8).toString(16).padStart(4, "0"); + const part3 = buffer.readUInt16LE(10).toString(16).padStart(4, "0"); + const part4 = buffer.subarray(12, 14).toString("hex"); + const part5 = buffer.subarray(14, 20).toString("hex"); + const guid = `${part1}-${part2}-${part3}-${part4}-${part5}`; + const age = buffer.readUInt32LE(20); + return { pdbPath, pdbFileName, guid, age }; + } + if (magic === "NB10") { + const age = buffer.readUInt32LE(12); + return { pdbPath, pdbFileName, guid: null, age }; + } + return undefined; +}; + +/** Locate the debug directory RVA and size from a PE optional header. */ +export const peDebugDirectoryEntry = ( + optionalHeader: Buffer, +): + | { + readonly debugRva: number; + readonly debugSize: number; + readonly sizeOfHeaders: number; + } + | undefined => { + if (optionalHeader.length < 2) return undefined; + const magic = optionalHeader.readUInt16LE(0); + if (magic !== 0x10b && magic !== 0x20b) return undefined; + const directoryOffset = magic === 0x20b ? 112 : 96; + const directoryCountOffset = directoryOffset - 4; + if (optionalHeader.length < directoryCountOffset + 4) return undefined; + const directoryCount = optionalHeader.readUInt32LE(directoryCountOffset); + if (directoryCount <= 6) return undefined; + const debugEntryOffset = directoryOffset + 6 * 8; + if (optionalHeader.length < debugEntryOffset + 8) return undefined; + const debugRva = optionalHeader.readUInt32LE(debugEntryOffset); + const debugSize = optionalHeader.readUInt32LE(debugEntryOffset + 4); + if (debugRva === 0 || debugSize < 28) return undefined; + return { + debugRva, + debugSize, + sizeOfHeaders: optionalHeader.readUInt32LE(60), + }; +}; + +/** Translate an RVA to an absolute file offset using the PE section table. */ +export const peRvaToFileOffset = ( + rva: number, + sectionTable: Buffer, + numberOfSections: number, + sizeOfHeaders: number, + size = 1, +): number | undefined => { + if (rva < sizeOfHeaders && size <= sizeOfHeaders - rva) return rva; + for (let i = 0; i < numberOfSections; i++) { + const offset = i * 40; + if (sectionTable.length < offset + 40) break; + const virtualAddress = sectionTable.readUInt32LE(offset + 12); + const sizeOfRawData = sectionTable.readUInt32LE(offset + 16); + const pointerToRawData = sectionTable.readUInt32LE(offset + 20); + if ( + pointerToRawData > 0 && + rva >= virtualAddress && + rva - virtualAddress < sizeOfRawData && + size <= sizeOfRawData - (rva - virtualAddress) + ) { + return pointerToRawData + (rva - virtualAddress); + } + } + return undefined; +}; + +/** Parse CodeView metadata from a bounded in-memory PE image buffer. */ +export const parsePeCodeViewFromBuffer = ( + fileBytes: Buffer, + peOffset: number, +): PeCodeViewRecord | undefined => { + if (fileBytes.length < peOffset + 24) return undefined; + const numberOfSections = fileBytes.readUInt16LE(peOffset + 6); + const optionalHeaderSize = fileBytes.readUInt16LE(peOffset + 20); + if ( + optionalHeaderSize > 4096 || + fileBytes.length < peOffset + 24 + optionalHeaderSize + ) + return undefined; + const optionalHeader = fileBytes.subarray( + peOffset + 24, + peOffset + 24 + optionalHeaderSize, + ); + const debugDir = peDebugDirectoryEntry(optionalHeader); + if (debugDir === undefined) return undefined; + const sectionTableOffset = peOffset + 24 + optionalHeaderSize; + const sectionTableSize = numberOfSections * 40; + if (fileBytes.length < sectionTableOffset + sectionTableSize) + return undefined; + const sectionTable = fileBytes.subarray( + sectionTableOffset, + sectionTableOffset + sectionTableSize, + ); + const debugFileOffset = peRvaToFileOffset( + debugDir.debugRva, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(debugDir.debugSize, 4096), + ); + if (debugFileOffset === undefined || fileBytes.length < debugFileOffset + 28) + return undefined; + const maxDebugBytes = Math.min( + debugDir.debugSize, + fileBytes.length - debugFileOffset, + ); + const numEntries = Math.floor(maxDebugBytes / 28); + for (let j = 0; j < numEntries; j++) { + const entryOffset = debugFileOffset + j * 28; + const type = fileBytes.readUInt32LE(entryOffset + 12); + if (type !== 2) continue; // IMAGE_DEBUG_TYPE_CODEVIEW + const sizeOfData = fileBytes.readUInt32LE(entryOffset + 16); + let pointerToRawData = fileBytes.readUInt32LE(entryOffset + 24); + if (pointerToRawData === 0) { + const addressOfRawData = fileBytes.readUInt32LE(entryOffset + 20); + const mapped = peRvaToFileOffset( + addressOfRawData, + sectionTable, + numberOfSections, + debugDir.sizeOfHeaders, + Math.min(sizeOfData, 4096), + ); + if (mapped === undefined) continue; + pointerToRawData = mapped; + } + if ( + sizeOfData < 16 || + fileBytes.length < pointerToRawData + Math.min(sizeOfData, 24) + ) + continue; + const codeViewData = fileBytes.subarray( + pointerToRawData, + Math.min(fileBytes.length, pointerToRawData + sizeOfData), + ); + const parsed = parseCodeViewData(codeViewData, sizeOfData); + if (parsed !== undefined) return parsed; + } + return undefined; +}; + const peManagedStatus = ( optionalHeader: Buffer, architecture: BinaryArchitecture, diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 99ad8487f..8c16612bc 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -24,6 +24,14 @@ export interface ElfFamilyMetadata { readonly flags: number; } +/** Referenced CodeView debug symbols from a PE debug directory. */ +export interface PeCodeViewRecord { + readonly pdbPath: string; + readonly pdbFileName: string; + readonly guid: string | null; + readonly age: number; +} + /** Families whose parsed target variant carries `ElfFamilyMetadata`. */ export const ELF_METADATA_ARCHITECTURES = [ "mips", @@ -170,6 +178,8 @@ export type BinaryTarget = | "non-executable"; /** Whether the PE declares a non-empty CLI header data-directory entry. */ readonly managed: boolean; + /** Referenced CodeView debug symbols from the PE debug directory, if present. */ + readonly codeView?: PeCodeViewRecord; }) | (BinaryTargetIdentity & NonExecutableMetadata & { diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index e15265305..a1e669b26 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { urlDeclaresQueryOrFragment } from "./urlComponents.js"; +import { parseUrlQueryParameterNames } from "./recordedTransportUrl.js"; const parseExactOrigin = (value: string): string | undefined => { let url: URL; @@ -254,7 +255,7 @@ export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => { }; } const hadCredentials = parsed.username !== "" || parsed.password !== ""; - const names = [...parsed.searchParams.keys()]; + const names = [...parseUrlQueryParameterNames(parsed.search)]; const sanitizedUrl = hadCredentials ? removeUrlUserInfo(value, parsed) : value; diff --git a/src/domain/browserObservationError.ts b/src/domain/browserObservationError.ts index 439995aeb..0ad686887 100644 --- a/src/domain/browserObservationError.ts +++ b/src/domain/browserObservationError.ts @@ -11,7 +11,7 @@ import { AnalysisCancelledError } from "./analysisErrorCore.js"; /** A bounded passive browser observation failed at its CDP boundary. */ export class BrowserObservationError extends AnalysisError { readonly _tag = "BrowserObservationError"; - override readonly cleanupIncomplete: boolean; + override cleanupIncomplete: boolean; override readonly cleanupResources: readonly string[]; override readonly userCategory: "cancelled" | undefined; override readonly userMessage: string | undefined; diff --git a/src/domain/dosMz.ts b/src/domain/dosMz.ts index 235dc25f4..c69eb36e5 100644 --- a/src/domain/dosMz.ts +++ b/src/domain/dosMz.ts @@ -72,13 +72,14 @@ const windowsHeaderSignature = ( bytes: Buffer | undefined, ): "PE" | "NE" | "LE" | "LX" | "other" | "unobserved" => { if (bytes === undefined || bytes.length < 2) return "unobserved"; - const prefix = bytes.toString("latin1", 0, 2); - if (prefix === "NE" || prefix === "LE" || prefix === "LX") return prefix; - if (prefix !== "PE") return "other"; + const first = bytes[0]; + const second = bytes[1]; + if (first === 0x4e && second === 0x45) return "NE"; + if (first === 0x4c && second === 0x45) return "LE"; + if (first === 0x4c && second === 0x58) return "LX"; + if (first !== 0x50 || second !== 0x45) return "other"; if (bytes.length < 4) return "unobserved"; - return bytes.subarray(0, 4).equals(Buffer.from("PE\0\0", "binary")) - ? "PE" - : "other"; + return bytes[2] === 0 && bytes[3] === 0 ? "PE" : "other"; }; /** diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 71755e828..fff8429cb 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -72,7 +72,7 @@ export const harmonyApplicationProjectionResultSchema = z.strictObject({ // Count completeness is separate from the declared coverage status. inventory_complete: z.boolean(), }), - limitations: z.array(z.string().min(1)), + limitations: z.array(z.string()), }); export type HarmonyApplicationProjectionInput = z.infer< @@ -112,7 +112,8 @@ export const projectHarmonyApplication = ( classified.native_libraries, ); const limitations = [ - ...(!hasCompleteInventoryCoverage(inventory) + ...inventory.limitations, + ...(!hasCompleteInventoryCoverage(inventory, "container-entries") ? ["Source inventory coverage is partial; absence is unknown."] : []), ...(unavailable.length > 0 diff --git a/src/domain/hopperValues.test.ts b/src/domain/hopperValues.test.ts index c5931cd35..7764e330e 100644 --- a/src/domain/hopperValues.test.ts +++ b/src/domain/hopperValues.test.ts @@ -78,9 +78,21 @@ describe("Hopper boundary values", () => { (_label, parse) => { const result = parse(); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("HopperProtocolError"); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); }, ); + + it("uses provider-neutral wording for malformed shared values", () => { + const result = parseSegments([{ name: 1 }]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisOutputError"); + expect(result.error.operation).toBe("list_segments"); + expect(result.error.reason).toBe( + "Provider returned an invalid segment list", + ); + } + }); }); describe("provider-neutral inclusive function body evidence", () => { diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index b42a98383..258b1a82f 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -7,7 +7,6 @@ import { err, ok, type Result } from "./result.js"; import { nativeApiBoundarySchema } from "./native/nativeApiBoundary.js"; import { nativeValueFlowSchema } from "./native/nativeValueFlow.js"; import { AnalysisOutputError } from "./analysisErrorCore.js"; -import { HopperProtocolError } from "./hopperErrors.js"; export interface AddressedName { readonly address: string; @@ -460,10 +459,11 @@ export const parseFunctionDossier = ( ); }; -/** Parse Hopper's direct or wrapped procedure map into stable entries. */ +/** Parse a provider's direct or wrapped procedure map into stable entries. */ export const parseProcedures = ( value: JsonValue, -): Result => { + operation = "list_procedures", +): Result => { const parsed = procedureMapSchema.safeParse( unwrapProperty(value, "procedures"), ); @@ -474,43 +474,48 @@ export const parseProcedures = ( name, })), ) - : invalid("procedure map", parsed.error); + : invalid(operation, "procedure map", parsed.error); }; -/** Parse Hopper's direct or wrapped list of address/name records. */ +/** Parse a provider's direct or wrapped list of address/name records. */ export const parseNames = ( value: JsonValue, -): Result => { + operation = "list_names", +): Result => { const unwrapped = unwrapProperty(value, "names"); const records = addressedNamesSchema.safeParse(unwrapped); if (records.success) return ok(records.data); const map = addressedNameMapSchema.safeParse(unwrapped); return map.success ? ok(Object.entries(map.data).map(([address, name]) => ({ address, name }))) - : invalid("name list", map.error); + : invalid(operation, "name list", map.error); }; -/** Parse callee/caller strings from direct or wrapped Hopper results. */ +/** Parse callee/caller strings from direct or wrapped provider results. */ export const parseRelatedAddresses = ( value: JsonValue, relation: "callees" | "callers", -): Result => { + operation = relation === "callees" + ? "procedure_callees" + : "procedure_callers", +): Result => { const parsed = z.array(z.string()).safeParse(unwrapProperty(value, relation)); return parsed.success ? ok(parsed.data) - : invalid(`${relation} list`, parsed.error); + : invalid(operation, `${relation} list`, parsed.error); }; /** Preserve reported address context in direct or wrapped provider segments. */ export const parseSegments = ( value: JsonValue, -): Result => { + operation = "list_segments", +): Result => { const parsed = z .array(segmentSchema) .safeParse(unwrapProperty(value, "segments")); return parsed.success ? ok(parsed.data) - : invalid("segment list", parsed.error); + : invalid(operation, "segment list", parsed.error); }; /** Return a reported overview base only when every parsed segment agrees. */ @@ -524,30 +529,32 @@ export const parseSegmentImageBase = ( : undefined; }; -/** Parse direct or wrapped Hopper document names. */ +/** Parse direct or wrapped provider document names. */ export const parseDocuments = ( value: JsonValue, -): Result => { + operation = "list_documents", +): Result => { const parsed = z .array(z.string()) .safeParse(unwrapProperty(value, "documents")); return parsed.success ? ok(parsed.data) - : invalid("document list", parsed.error); + : invalid(operation, "document list", parsed.error); }; /** Parse a direct or wrapped list when only its cardinality is required. */ export const parseListCount = ( value: JsonValue, property: string, -): Result => { + operation = property === "procedures" ? "list_procedures" : "list_strings", +): Result => { const unwrapped = unwrapProperty(value, property); const list = z.array(z.unknown()).safeParse(unwrapped); if (list.success) return ok(list.data.length); const map = z.record(z.string(), z.unknown()).safeParse(unwrapped); return map.success ? ok(Object.keys(map.data).length) - : invalid(`${property} list`, map.error); + : invalid(operation, `${property} list`, map.error); }; const unwrapProperty = (value: JsonValue, property: string): JsonValue => { @@ -563,11 +570,16 @@ const unwrapProperty = (value: JsonValue, property: string): JsonValue => { }; const invalid = ( + operation: string, expected: string, cause: z.ZodError, -): Result => +): Result => err( - new HopperProtocolError(`Hopper returned an invalid ${expected}`, { - cause, - }), + new AnalysisOutputError( + operation, + `Provider returned an invalid ${expected}`, + { + cause, + }, + ), ); diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 306e8d316..11dbf62de 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -77,15 +77,23 @@ export const matchJavaScriptApplicationVersions = ( ): ApplicationVersionMatchingProjection => { const left = new Map(leftNodes.map((node) => [node.node_id, node])); const right = new Map(rightNodes.map((node) => [node.node_id, node])); - const leftCandidates = new Map>(); - const rightCandidates = new Map>(); const pairs: ApplicationVersionNodePair[] = []; + const ambiguousGroups: AmbiguousGroup[] = []; for (const tier of matchTiers()) - applyTier({ left, right, pairs, leftCandidates, rightCandidates, tier }); + applyTier({ left, right, pairs, ambiguousGroups, tier }); const remainingLeft = sortedNodes(left.values()); const remainingRight = sortedNodes(right.values()); - const leftIds = new Set(remainingLeft.map(({ node_id: id }) => id)); - const rightIds = new Set(remainingRight.map(({ node_id: id }) => id)); + const leftCandidates = new Map>(); + const rightCandidates = new Map>(); + // Later tiers can pair members of an ambiguous group. Expand only survivors, + // without pairing groups that became unique after those later matches. + for (const group of ambiguousGroups) { + const leftGroup = group.left.filter((node) => left.has(node.node_id)); + if (leftGroup.length === 0) continue; + const rightGroup = group.right.filter((node) => right.has(node.node_id)); + if (rightGroup.length === 0) continue; + recordCandidates(leftGroup, rightGroup, leftCandidates, rightCandidates); + } return { pairs: pairs.sort((a, b) => compareUnicodeCodePoints( @@ -95,17 +103,21 @@ export const matchJavaScriptApplicationVersions = ( ), unmatchedLeft: remainingLeft, unmatchedRight: remainingRight, - candidatesForLeft: filteredCandidates(leftCandidates, rightIds), - candidatesForRight: filteredCandidates(rightCandidates, leftIds), + candidatesForLeft: sortedCandidates(leftCandidates), + candidatesForRight: sortedCandidates(rightCandidates), }; }; +interface AmbiguousGroup { + readonly left: readonly ApplicationNode[]; + readonly right: readonly ApplicationNode[]; +} + interface TierContext { readonly left: Map; readonly right: Map; readonly pairs: ApplicationVersionNodePair[]; - readonly leftCandidates: Map>; - readonly rightCandidates: Map>; + readonly ambiguousGroups: AmbiguousGroup[]; readonly tier: MatchTier; } @@ -114,15 +126,12 @@ const applyTier = (context: TierContext): void => { const rightByKey = nodesByKey(context.right.values(), context.tier.key); for (const key of [...leftByKey.keys()].sort(compareUnicodeCodePoints)) { const leftGroup = leftByKey.get(key) ?? []; - const rightGroup = rightByKey.get(key) ?? []; - if (rightGroup.length === 0) continue; - recordCandidates( - leftGroup, - rightGroup, - context.leftCandidates, - context.rightCandidates, - ); - if (leftGroup.length !== 1 || rightGroup.length !== 1) continue; + const rightGroup = rightByKey.get(key); + if (rightGroup === undefined) continue; + if (leftGroup.length !== 1 || rightGroup.length !== 1) { + context.ambiguousGroups.push({ left: leftGroup, right: rightGroup }); + continue; + } const leftNode = leftGroup[0]; const rightNode = rightGroup[0]; if (leftNode === undefined || rightNode === undefined) continue; @@ -144,7 +153,9 @@ const nodesByKey = ( for (const node of nodes) { const key = keyFor(node); if (key === null) continue; - output.set(key, [...(output.get(key) ?? []), node]); + const group = output.get(key); + if (group === undefined) output.set(key, [node]); + else group.push(node); } return output; }; @@ -169,17 +180,14 @@ const recordCandidates = ( } }; -const filteredCandidates = ( +const sortedCandidates = ( candidates: ReadonlyMap>, - retained: ReadonlySet, ): ReadonlyMap => new Map( - [...candidates.entries()].flatMap(([nodeId, values]) => { - const filtered = [...values] - .filter((value) => retained.has(value)) - .sort(compareUnicodeCodePoints); - return filtered.length === 0 ? [] : [[nodeId, filtered] as const]; - }), + [...candidates.entries()].map(([nodeId, values]) => [ + nodeId, + [...values].sort(compareUnicodeCodePoints), + ]), ); const nodeIdentityKey = (node: ApplicationNode): string => diff --git a/src/domain/javascript/javascriptApplicationVersionMatching.test.ts b/src/domain/javascript/javascriptApplicationVersionMatching.test.ts new file mode 100644 index 000000000..9299d4184 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionMatching.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + artifactEvidence, + completeCoverage, + fixtureNode, +} from "./javascriptApplicationGraph.fixture.js"; +import { + createJavaScriptApplicationGraph, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; + +const ARTIFACT_SHA = "a".repeat(64); + +const moduleNode = (name: string, source: string, structure?: string) => + fixtureNode( + "javascript-module", + { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: ARTIFACT_SHA, + path: `${name}.js`, + }, + name, + { + source_sha256: source.repeat(64), + ...(structure === undefined + ? {} + : { + structural_fingerprint_sha256: structure.repeat(64), + structural_fingerprint_algorithm: "fixture", + }), + }, + artifactEvidence(ARTIFACT_SHA, `${name}.js`), + ); + +const graph = (nodes: ApplicationNode[]): JavaScriptApplicationGraph => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: nodes.map(({ node_id }) => node_id), + nodes, + edges: [], + coverage: completeCoverage, + limitations: [], + }); + +const compare = ( + left: JavaScriptApplicationGraph, + right: JavaScriptApplicationGraph, +) => + compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: ARTIFACT_SHA, + graph: left, + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: ARTIFACT_SHA, + graph: right, + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + +describe("application version matching", () => { + it("does not revisit a source group that becomes unique after identity matching", () => { + const shared = moduleNode("shared", "1"); + const leftOnly = moduleNode("left", "1"); + const rightOnly = moduleNode("right", "1"); + const result = compare( + graph([shared, leftOnly]), + graph([shared, rightOnly]), + ); + + expect(result.matching).toMatchObject({ + exact_node_identity: 1, + exact_module_source_digest: 0, + ambiguous: 2, + unmatched: 0, + }); + expect( + result.items.find(({ left_node_id }) => left_node_id === shared.node_id), + ).toMatchObject({ + right_node_id: shared.node_id, + match: { status: "matched", basis: "exact-node-identity" }, + }); + expect( + result.items.find( + ({ left_node_id }) => left_node_id === leftOnly.node_id, + ), + ).toMatchObject({ + status: "unknown", + right_node_id: null, + match: { + status: "ambiguous", + candidate_right_node_ids: [rightOnly.node_id], + }, + }); + expect( + result.items.find( + ({ right_node_id }) => right_node_id === rightOnly.node_id, + ), + ).toMatchObject({ + status: "unknown", + left_node_id: null, + match: { + status: "ambiguous", + candidate_left_node_ids: [leftOnly.node_id], + }, + }); + }); + + it("unions all unresolved source and structural candidates without forcing pairs", () => { + const a = moduleNode("a", "1", "a"); + const b = moduleNode("b", "1", "a"); + const c = moduleNode("c", "2", "a"); + const x = moduleNode("x", "1", "a"); + const y = moduleNode("y", "1", "b"); + const z = moduleNode("z", "3", "a"); + const left = graph([a, b, c]); + const right = graph([x, y, z]); + const result = compare(left, right); + + expect(result.summary).toEqual({ + unchanged: 0, + added: 0, + removed: 0, + changed: 0, + unknown: 6, + }); + expect(result.matching).toMatchObject({ ambiguous: 6, unmatched: 0 }); + for (const [node, candidates] of [ + [a, [x, y, z]], + [b, [x, y, z]], + [c, [x, z]], + ] as const) { + expect( + result.items.find(({ left_node_id }) => left_node_id === node.node_id) + ?.match, + ).toEqual({ + status: "ambiguous", + basis: "none", + confidence: "unknown", + candidate_left_node_ids: [], + candidate_right_node_ids: candidates + .map(({ node_id }) => node_id) + .sort(), + }); + } + for (const [node, candidates] of [ + [x, [a, b, c]], + [y, [a, b]], + [z, [a, b, c]], + ] as const) { + expect( + result.items.find(({ right_node_id }) => right_node_id === node.node_id) + ?.match, + ).toEqual({ + status: "ambiguous", + basis: "none", + confidence: "unknown", + candidate_left_node_ids: candidates + .map(({ node_id }) => node_id) + .sort(), + candidate_right_node_ids: [], + }); + } + + // Permute the already validated graph so matching sees the changed order. + // Compare the full result, including item, comparison and change-graph IDs. + expect( + compare( + { ...left, nodes: [...left.nodes].reverse() }, + { ...right, nodes: [...right.nodes].reverse() }, + ), + ).toEqual(result); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts index 03e25032a..792b8a0ea 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts @@ -130,3 +130,74 @@ describe("JavaScript semantic analysis: runtime boundaries 1", () => { expect(ir.objectOperations.length).toBeGreaterThan(0); }); }); + +describe("JavaScript semantic analysis: event dispatch data", () => { + it("keeps emitted data out of dispatch listener fields", () => { + const ir = analyzeJavaScriptSemantics(` + import { EventEmitter } from "node:events"; + const bus = new EventEmitter(); + const data = 1; + const handler = () => {}; + bus.on("ready", handler); + bus.emit("ready", data); + bus.emit("ready"); + bus.off("ready", handler); + bus.removeAllListeners("ready", data); + `); + const handler = ir.bindings.find(({ name }) => name === "handler"); + expect(handler).toBeDefined(); + expect( + ir.eventOperations.map( + ({ kind, method, listenerBindingId, listenerLocation }) => ({ + kind, + method, + listenerBindingId, + hasListenerLocation: listenerLocation !== null, + }), + ), + ).toEqual([ + { + kind: "register", + method: "on", + listenerBindingId: handler?.bindingId, + hasListenerLocation: true, + }, + { + kind: "dispatch", + method: "emit", + listenerBindingId: null, + hasListenerLocation: false, + }, + { + kind: "dispatch", + method: "emit", + listenerBindingId: null, + hasListenerLocation: false, + }, + { + kind: "remove", + method: "off", + listenerBindingId: handler?.bindingId, + hasListenerLocation: true, + }, + { + kind: "remove", + method: "removeAllListeners", + listenerBindingId: null, + hasListenerLocation: false, + }, + ]); + expect( + ir.eventOperations.map(({ eventName, resolution }) => [ + eventName, + resolution, + ]), + ).toEqual([ + ["ready", "complete"], + ["ready", "complete"], + ["ready", "complete"], + ["ready", "complete"], + ["ready", "complete"], + ]); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index 2312eff5d..16b8c5091 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -10,6 +10,45 @@ import { origin, } from "./javascriptSemanticAnalysis.fixture.js"; +describe("JavaScript semantic analysis: ESM import origins", () => { + it.each(["", "a", "0"])( + "preserves exact ESM import name %j in binding origins", + (name) => { + const ir = analyzeJavaScriptSemantics( + "import { " + + JSON.stringify(name) + + ' as selected } from "./module.mjs"; const alias = selected; export { alias };', + ); + for (const binding of ["selected", "alias"]) + expect(origin(topLevelBinding(ir, binding))).toEqual({ + specifier: "./module.mjs", + importedPath: [name], + }); + expect(ir.moduleLinks).toContainEqual( + expect.objectContaining({ + kind: "import", + importedName: name, + localName: "selected", + }), + ); + }, + ); + + it("keeps default and namespace import origins distinct from an empty named import", () => { + const ir = analyzeJavaScriptSemantics( + 'import value from "./module.mjs"; import * as namespace from "./module.mjs";', + ); + expect(origin(topLevelBinding(ir, "value"))).toEqual({ + specifier: "./module.mjs", + importedPath: ["default"], + }); + expect(origin(topLevelBinding(ir, "namespace"))).toEqual({ + specifier: "./module.mjs", + importedPath: [], + }); + }); +}); + describe("JavaScript semantic analysis: structure 1", () => { it("resolves imports, require destructuring, aliases, assignments, and shadowing", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 119fbdd45..7e20c0e13 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -57,7 +57,6 @@ import { ELECTRON_MODULE, } from "./javascriptElectronMemberWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; -import { propertyName } from "./javascriptAstValues.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; import { semanticResourceLimitsIn } from "./javascriptSemanticResourceLimits.js"; import { @@ -1109,7 +1108,7 @@ const bindImports = ( ? ["default"] : t.isImportNamespaceSpecifier(specifier) ? [] - : [propertyName(specifier.imported) || "[dynamic]"]; + : [semanticStaticPropertyKey(specifier.imported, false) ?? "[dynamic]"]; addBinding({ state, scope, diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index f7914c20f..5b3068101 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -37,6 +37,13 @@ const EVENT_REMOVE_METHODS = [ "removeListener", "removeAllListeners", ] as const; +/** + * Methods whose argument position 1 names a listener. `removeAllListeners` + * takes only an optional event name, and `emit` passes its remaining + * arguments as data to listeners that already exist. + */ +const EVENT_LISTENER_METHODS: readonly JavaScriptSemanticEventOperation["method"][] = + [...EVENT_REGISTER_METHODS, "off", "removeListener"]; const EVENT_METHODS = [ ...EVENT_REGISTER_METHODS, ...EVENT_REMOVE_METHODS, @@ -137,7 +144,9 @@ const collectEvent = ( if (method === undefined) return; const kind = eventKind(method); const eventArgument = node.arguments[0]; - const listenerArgument = node.arguments[1]; + const listenerArgument = EVENT_LISTENER_METHODS.includes(method) + ? node.arguments[1] + : undefined; const emitter = emitterIdentity(member.object, state); const eventName = dataEffectLiteralString(eventArgument); output.push({ diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 7b988af88..167e8899e 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -54,6 +54,57 @@ export function* collectSemanticMemberMutationsSteps( const selections = new Map(); const coveringPaths = new Map(); let arrayIterationUnknown = false; + // Definition collection finishes before this phase, so a scope's bindings no + // longer change and one node resolves to one binding. Repeated visits of the + // same node otherwise repeat the same scope walk. + const resolvedBindings = new WeakMap< + t.Node, + JavaScriptSemanticBindingState | null + >(); + const resolveBinding = ( + node: t.Node, + name: string, + ): JavaScriptSemanticBindingState | undefined => { + const cached = resolvedBindings.get(node); + if (cached !== undefined) return cached ?? undefined; + const resolved = resolveSemanticBindingState(state, node, name); + resolvedBindings.set(node, resolved ?? null); + return resolved; + }; + // One capture tuple recurs across every alias it reaches. Re-encoding it + // would rebuild the same long key and force a fresh string hash per lookup. + const captureKeys = new WeakMap< + JavaScriptSemanticBindingState, + Map + >(); + const captureKeyOf = ( + binding: JavaScriptSemanticBindingState, + originAt: t.Node | undefined, + ownerInitializer: t.Node | undefined, + ): string => { + let byPosition = captureKeys.get(binding); + if (byPosition === undefined) { + byPosition = new Map(); + captureKeys.set(binding, byPosition); + } + const positionKey = `${originAt?.start ?? "u"}:${originAt?.end ?? "u"}:${ + ownerInitializer?.start ?? "u" + }:${ownerInitializer?.end ?? "u"}`; + const cached = byPosition.get(positionKey); + if (cached !== undefined) return cached; + const created = JSON.stringify([ + binding.bindingId, + originAt?.start, + originAt?.end, + ownerInitializer?.start, + ownerInitializer?.end, + ]); + byPosition.set(positionKey, created); + return created; + }; + // A capture key already ends the array; a path stays one nested element. + const lifetimeKey = (captureKey: string, path: PropertyPath): string => + `${captureKey.slice(0, -1)},${JSON.stringify(path)}]`; const pendingReferences: { readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number]; readonly path: PropertyPath; @@ -121,11 +172,7 @@ export function* collectSemanticMemberMutationsSteps( if (current === undefined) break; const expression = unwrapJavaScriptExpression(current.node).node; if (t.isIdentifier(expression)) { - const binding = resolveSemanticBindingState( - state, - expression, - expression.name, - ); + const binding = resolveBinding(expression, expression.name); if (binding === undefined) { if ( (expression.name === "Array" && @@ -142,21 +189,18 @@ export function* collectSemanticMemberMutationsSteps( // An alias restored from a saved reference can revisit the same binding // at an earlier capture or a shorter getter-receiver path. Only a // repeated capture and path closes a cycle. - const capture = [ - binding.bindingId, - current.originAt?.start, - current.originAt?.end, - current.ownerInitializer?.start, - current.ownerInitializer?.end, - ]; - const lifetime = JSON.stringify([...capture, current.path]); + const captureKey = captureKeyOf( + binding, + current.originAt, + current.ownerInitializer, + ); + const lifetime = lifetimeKey(captureKey, current.path); if (current.bindings.has(lifetime)) continue; // A self alias such as `node = node[key]` revisits its capture with the // same prefix added on every cycle, so exact paths never repeat. When // that prefix can never be pruned, escaping it once covers every longer // effect, including writes below shared children. // Only a capture already on this alias chain can repeat a prefix. - const captureKey = JSON.stringify(capture); const revisited = current.bindings.has(captureKey); let repeatedPrefix: PropertyPath | undefined; for ( @@ -165,8 +209,7 @@ export function* collectSemanticMemberMutationsSteps( start++ ) { const suffix = current.path.slice(start); - if (!current.bindings.has(JSON.stringify([...capture, suffix]))) - continue; + if (!current.bindings.has(lifetimeKey(captureKey, suffix))) continue; repeatedPrefix = current.path.slice(0, start); break; } @@ -408,7 +451,7 @@ export function* collectSemanticMemberMutationsSteps( if (!t.isNode(node)) continue; const pathKey = JSON.stringify(value.path); if (t.isIdentifier(node) && value.iterableFallbackPath === undefined) { - const binding = resolveSemanticBindingState(state, node, node.name); + const binding = resolveBinding(node, node.name); const identity = JSON.stringify([ binding === undefined ? null : binding.bindingId, binding === undefined ? node.name : null, diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 6de7217c1..414d3e26d 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -12,6 +12,37 @@ interface StatementPosition { readonly declaratorIndex: number; } +/** + * Initializer positions depend only on a node and its parent map, which one + * analysis builds once and never changes. Different mutations and initializers + * re-derive the same positions along shared ancestry, so retain the first + * result for as long as its parent map is reachable. + */ +interface InitializerPositionCache { + readonly direct: WeakMap; + readonly mutation: WeakMap; + readonly reference: WeakMap; +} + +const initializerPositionCaches = new WeakMap< + WeakMap, + InitializerPositionCache +>(); + +const initializerPositionCache = ( + parents: WeakMap, +): InitializerPositionCache => { + const cached = initializerPositionCaches.get(parents); + if (cached !== undefined) return cached; + const created: InitializerPositionCache = { + direct: new WeakMap(), + mutation: new WeakMap(), + reference: new WeakMap(), + }; + initializerPositionCaches.set(parents, created); + return created; +}; + const statementPosition = ( statement: t.Statement, parents: WeakMap, @@ -42,7 +73,7 @@ const positionPrecedes = ( left.declaratorIndex >= 0 && left.declaratorIndex < right.declaratorIndex)); -const directInitializerPosition = ( +const computeDirectInitializerPosition = ( node: t.Node, parents: WeakMap, ): StatementPosition | null => { @@ -67,7 +98,19 @@ const directInitializerPosition = ( return null; }; -const directMutationPosition = ( +const directInitializerPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + const cache = initializerPositionCache(parents); + const cached = cache.direct.get(node); + if (cached !== undefined) return cached; + const computed = computeDirectInitializerPosition(node, parents); + cache.direct.set(node, computed); + return computed; +}; + +const computeDirectMutationPosition = ( node: t.Node, parents: WeakMap, ): StatementPosition | null => { @@ -115,7 +158,19 @@ const directMutationPosition = ( } }; -const referenceInitializerPosition = ( +const directMutationPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + const cache = initializerPositionCache(parents); + const cached = cache.mutation.get(node); + if (cached !== undefined) return cached; + const computed = computeDirectMutationPosition(node, parents); + cache.mutation.set(node, computed); + return computed; +}; + +const computeReferenceInitializerPosition = ( node: t.Node, parents: WeakMap, ): StatementPosition | null => { @@ -137,6 +192,18 @@ const referenceInitializerPosition = ( } }; +const referenceInitializerPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + const cache = initializerPositionCache(parents); + const cached = cache.reference.get(node); + if (cached !== undefined) return cached; + const computed = computeReferenceInitializerPosition(node, parents); + cache.reference.set(node, computed); + return computed; +}; + /** Resolve origins at their use or capture, never at a later transitive effect. */ export const semanticMutationInitializers = ( binding: JavaScriptSemanticBindingState, diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 94eff2398..f41173ba6 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -219,6 +219,16 @@ const managedMemberReferenceSchema = z.object({ signature: managedSignatureSchema, }); +const managedUserStringSchema = z.object({ + token: tokenSchema, + offset: offsetSchema.describe("File offset of the #US item"), + value: z + .string() + .describe( + "Decoded UTF-16 code units, excluding the terminal flag byte; unpaired surrogates are kept", + ), +}); + const managedModuleReferenceSchema = z.object({ token: tokenSchema, row_offset: offsetSchema, @@ -457,6 +467,17 @@ export const managedMemberInspectionSchema = z.object({ member_refs: z.array(managedMemberReferenceSchema), call_edges: z.array(managedCallEdgeSchema), field_accesses: z.array(managedFieldAccessSchema), + // Distinct #US literals loaded by ldstr in the inspected method bodies, in + // token order, with their own coverage: #US issues never enter the member + // coverage. null means they were not examined, not that there were none. + user_strings: z + .object({ + state: z.enum(["complete", "partial"]), + literals: z.array(managedUserStringSchema), + issues: z.array(managedParseIssueSchema), + }) + .nullable() + .default(null), /** Caller-selected declaring types; null when every type was inspected. */ selection: z .object({ diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index 7f28cc88e..85da3b4eb 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -131,5 +131,8 @@ export const comparisonLimitations = ( limitations.push( "ExportedType rows, including type forwarders, are observed but not compared; an unchanged comparison does not establish an unchanged exported or forwarded type set.", ); + limitations.push( + "ldstr literals are not compared; normalized CIL keeps #US tokens rather than decoded text, so an unchanged method does not establish unchanged string literals.", + ); return limitations; }; diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 23b4d600b..26331be08 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -73,7 +73,11 @@ const methodDimensions = ( if (bothAbsent) return { dimensions, bodyUnknown: false }; if (!leftBodyComplete || !rightBodyComplete) return { dimensions, bodyUnknown: true }; - if (left.body.normalized_il_sha256 !== right.body.normalized_il_sha256) + // The decoded fingerprint omits switch targets and is not exact CIL identity. + if ( + left.body.il_sha256 !== right.body.il_sha256 || + left.body.normalized_il_sha256 !== right.body.normalized_il_sha256 + ) dimensions.push("cil"); if ( canonicalize(left.body.opcode_counts) !== diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index f8e2b1d43..90106a17c 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -49,10 +49,13 @@ interface Ambiguous { const keyMethod = (item: Method): Keyed => ({ item, exactKey: - item.body.status === "present" && item.body.normalized_il_sha256 !== null + item.body.status === "present" && + item.body.il_sha256 !== null && + item.body.normalized_il_sha256 !== null ? sha256([ "method-exact", item.signature.raw_sha256, + item.body.il_sha256, item.body.normalized_il_sha256, ]) : null, diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index 93e4143c5..13bbe9545 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -56,7 +56,8 @@ export const projectionCoverage = ( inventory_complete: boolean; } => ({ status: - hasCompleteInventoryCoverage(inventory) && budgetComplete + hasCompleteInventoryCoverage(inventory, "container-entries") && + budgetComplete ? ("complete-within-inventory" as const) : ("partial" as const), inventory_complete: inventory.complete, diff --git a/src/domain/providerCleanupError.ts b/src/domain/providerCleanupError.ts index 4503b583e..257d27e08 100644 --- a/src/domain/providerCleanupError.ts +++ b/src/domain/providerCleanupError.ts @@ -4,7 +4,7 @@ import type { AnalysisPartialObservation } from "./analysisErrorBase.js"; /** Provider resources could not be proven closed after bounded cleanup. */ export class ProviderCleanupError extends ProviderAdapterError { - override readonly cleanupIncomplete = true; + override cleanupIncomplete = true; override readonly cleanupResources: readonly string[]; override readonly userMessage = "Provider cleanup could not be fully confirmed. Review the reported local resources before opening another target."; diff --git a/src/domain/recordedTransportUrl.ts b/src/domain/recordedTransportUrl.ts index c524ae1a8..429c0f6ca 100644 --- a/src/domain/recordedTransportUrl.ts +++ b/src/domain/recordedTransportUrl.ts @@ -2,6 +2,8 @@ export interface RecordedTransportUrl { readonly origin: string | null; readonly path: string | null; + /** WHATWG query spelling, present only when explicitly selected for projection. */ + readonly query?: string; readonly credentials: | { readonly start: number; readonly end: number } | "ambiguous" @@ -15,6 +17,7 @@ export interface RecordedTransportUrl { */ export const parseRecordedTransportUrl = ( value: string, + options: { readonly includeQuery?: boolean } = {}, ): RecordedTransportUrl => { const prefix = /^[\x00-\x20]*(?:([A-Za-z][A-Za-z0-9+.-]*):)?\/\//u.exec( value, @@ -67,9 +70,22 @@ export const parseRecordedTransportUrl = ( (special && value[authorityEnd] === "\\") ) return { origin, path: null, credentials }; - if (value[authorityEnd] !== "/") return { origin, path: "/", credentials }; + const query = + options.includeQuery === true ? { query: absolute!.search } : {}; + if (value[authorityEnd] !== "/") + return { origin, path: "/", credentials, ...query }; let pathEnd = authorityEnd; while (pathEnd < value.length && !"?#".includes(value[pathEnd]!)) pathEnd += 1; - return { origin, path: value.slice(authorityEnd, pathEnd), credentials }; + return { + origin, + path: value.slice(authorityEnd, pathEnd), + credentials, + ...query, + }; }; + +/** WHATWG form-query name decoding; preserve order, repeats and empty names. */ +export const parseUrlQueryParameterNames = ( + query: string, +): IterableIterator => new URLSearchParams(query).keys(); diff --git a/src/domain/reverseEngineering.ts b/src/domain/reverseEngineering.ts new file mode 100644 index 000000000..fb0cc9a08 --- /dev/null +++ b/src/domain/reverseEngineering.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +const objdumpArtifactInputSchema = z + .object({ + path: z.string().min(1), + operation: z.enum([ + "file_headers", + "section_headers", + "symbols", + "relocations", + "dwarf", + "disassemble", + "disassemble_all", + ]), + follow_debug_links: z.boolean().default(false), + }) + .strict(); + +/** Caller-selected artifact facet or pathless provider capability query. */ +export const objdumpInputSchema = z.discriminatedUnion("operation", [ + objdumpArtifactInputSchema, + z.strictObject({ operation: z.literal("architectures") }), +]); + +/** Caller-selected Rizin command against one local artifact. */ +export const rizinInputSchema = z + .object({ + path: z.string().min(1), + command: z + .string() + .min(1) + .refine( + (value) => !/[\r\n\u0000]/u.test(value), + "A single command line is required", + ), + }) + .strict(); + +export type ObjdumpInput = z.input; +export type RizinInput = z.input; diff --git a/src/domain/rust/rustBinary.ts b/src/domain/rust/rustBinary.ts new file mode 100644 index 000000000..f09cde222 --- /dev/null +++ b/src/domain/rust/rustBinary.ts @@ -0,0 +1,294 @@ +import { z } from "zod"; +import { digestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; + +export const RUST_AUDITABLE_FORMAT_VERSION = 1; + +const index = z.number().int().nonnegative(); +const rustcHash = z.string().regex(/^[0-9a-f]{40}$/u); + +/** One embedded package, with omitted kind and root preserved as null and false. */ +export const rustPackageSchema = z.strictObject({ + name: z.string().min(1), + version: z.string().min(1), + source: z.string().min(1), + kind: z.enum(["runtime", "build"]).nullable(), + dependencies: z.array(index), + root: z.boolean(), +}); + +const dependencyEdgeSchema = z.strictObject({ + from_index: index, + to_index: index, +}); + +/** zlib JSON from one `.dep-v0` section, in the section's package order. */ +export const rustAuditableMetadataSchema = z.strictObject({ + section_name: z.enum([".dep-v0", "__DATA,.dep-v0"]), + file_offset: index, + section_bytes: z.number().int().positive(), + compressed_bytes: z.number().int().positive(), + uncompressed_bytes: z.number().int().positive(), + format_version: z.literal(RUST_AUDITABLE_FORMAT_VERSION).nullable(), + packages: z.array(rustPackageSchema), + root_package_index: index.nullable(), + dependency_edges: z.array(dependencyEdgeSchema), + cyclic_dependency_indices: z.array(index), +}); + +/** Inferred compiler id from read-only `/rustc/<40-hex>/` paths. */ +export const rustcCommitSchema = z.strictObject({ + evidence_kind: z.literal("inferred"), + hashes: z + .array( + z.strictObject({ + hash: rustcHash, + file_offset: index, + }), + ) + .min(1), +}); + +/** One explicit local executable or library, independent of a disassembler target. */ +export const inspectRustBinaryInputSchema = z.strictObject({ + path: localPathStringSchema.describe( + "Absolute filesystem path to the selected executable or library", + ), +}); + +/** Bounded cargo-auditable observation. A missing section is null, not an empty dependency list. */ +export const rustBinarySchema = z + .strictObject({ + artifact: z.strictObject({ + path: localPathStringSchema, + sha256: digestSchema, + bytes: index, + }), + format: z.enum(["elf", "pe", "macho"]), + architecture: z.string(), + bits: z.union([z.literal(32), z.literal(64)]), + byte_order: z.enum(["little", "big"]), + auditable_metadata: rustAuditableMetadataSchema.nullable(), + rustc_commit: rustcCommitSchema.nullable(), + limitations: z.array(z.string()), + }) + .superRefine((report, context) => { + const metadata = report.auditable_metadata; + if (metadata !== null) { + checkSection(report.artifact.bytes, metadata, context); + checkPackages(metadata, context); + } + checkRustc(report.artifact.bytes, report.rustc_commit, context); + }); + +export type RustPackage = z.infer; +export type RustAuditableMetadata = z.infer; +export type RustBinary = z.infer; +export type InspectRustBinaryInput = z.infer< + typeof inspectRustBinaryInputSchema +>; + +const issue = ( + context: z.RefinementCtx, + path: (string | number)[], + message: string, +): void => { + context.addIssue({ code: "custom", path, message }); +}; + +const inside = (fileBytes: number, offset: number, size: number): boolean => + offset <= fileBytes && size <= fileBytes - offset; + +const checkSection = ( + fileBytes: number, + metadata: RustAuditableMetadata, + context: z.RefinementCtx, +): void => { + if ( + !inside(fileBytes, metadata.file_offset, metadata.section_bytes) || + metadata.compressed_bytes !== metadata.section_bytes + ) + issue( + context, + ["auditable_metadata", "file_offset"], + "Dependency section range is outside the selected artifact or disagrees with its compressed size.", + ); +}; + +const checkPackages = ( + metadata: RustAuditableMetadata, + context: z.RefinementCtx, +): void => { + const roots: number[] = []; + const edgesByPackage = new Map< + number, + z.infer[] + >(); + for (const [position, edge] of metadata.dependency_edges.entries()) { + if ( + edge.from_index >= metadata.packages.length || + edge.to_index >= metadata.packages.length + ) { + issue( + context, + ["auditable_metadata", "dependency_edges", position], + "Dependency edge index is outside the package list.", + ); + continue; + } + const grouped = edgesByPackage.get(edge.from_index); + if (grouped === undefined) edgesByPackage.set(edge.from_index, [edge]); + else grouped.push(edge); + } + for (const [packageIndex, pkg] of metadata.packages.entries()) { + if (pkg.root) roots.push(packageIndex); + const edges = edgesByPackage.get(packageIndex) ?? []; + if ( + edges.length !== pkg.dependencies.length || + edges.some((edge, index) => edge.to_index !== pkg.dependencies[index]) + ) + issue( + context, + ["auditable_metadata", "dependency_edges"], + "Dependency edges do not match package dependency indices.", + ); + if (pkg.dependencies.some((target) => target >= metadata.packages.length)) + issue( + context, + ["auditable_metadata", "packages", packageIndex, "dependencies"], + "Dependency index is outside the package list.", + ); + } + const root = metadata.root_package_index; + if ( + root === null ? roots.length !== 0 : roots.length !== 1 || roots[0] !== root + ) + issue( + context, + ["auditable_metadata", "root_package_index"], + "Root package index does not match packages marked root.", + ); + const cycles = cyclicDependencyIndices(metadata.packages); + if ( + cycles.length !== metadata.cyclic_dependency_indices.length || + cycles.some( + (index, position) => + metadata.cyclic_dependency_indices[position] !== index, + ) + ) + issue( + context, + ["auditable_metadata", "cyclic_dependency_indices"], + "Cyclic dependency indices do not match the cycles in package dependencies.", + ); +}; + +/** Package indices that participate in a dependency cycle, in ascending order. */ +export const cyclicDependencyIndices = ( + packages: readonly { readonly dependencies: readonly number[] }[], +): number[] => { + const edges = packages.map((pkg) => pkg.dependencies); + const order = dependencyPostorder(edges); + const reversed = reversedDependencyEdges(edges); + const seen = new Uint8Array(edges.length); + const cyclic = new Set(); + for (let cursor = order.length - 1; cursor >= 0; cursor -= 1) { + const start = order[cursor]; + if (start === undefined || seen[start] === 1) continue; + const component = dependencyComponent(start, reversed, seen); + if (dependencyComponentInCycle(component, edges)) + for (const index of component) cyclic.add(index); + } + return [...cyclic].sort((left, right) => left - right); +}; + +interface DependencyWalkFrame { + readonly index: number; + edge: number; +} + +const dependencyPostorder = ( + edges: readonly (readonly number[])[], +): number[] => { + const seen = new Uint8Array(edges.length); + const order: number[] = []; + for (let start = 0; start < edges.length; start += 1) { + if (seen[start] === 1) continue; + const stack: DependencyWalkFrame[] = [{ index: start, edge: 0 }]; + seen[start] = 1; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (frame === undefined) break; + const next = edges[frame.index]?.[frame.edge]; + if (next === undefined) { + stack.pop(); + order.push(frame.index); + continue; + } + frame.edge += 1; + if (seen[next] === 0) { + seen[next] = 1; + stack.push({ index: next, edge: 0 }); + } + } + } + return order; +}; + +const reversedDependencyEdges = ( + edges: readonly (readonly number[])[], +): number[][] => { + const reversed = Array.from({ length: edges.length }, () => [] as number[]); + for (let from = 0; from < edges.length; from += 1) + for (const to of edges[from] ?? []) reversed[to]?.push(from); + return reversed; +}; + +const dependencyComponent = ( + start: number, + reversed: readonly (readonly number[])[], + seen: Uint8Array, +): number[] => { + const component: number[] = []; + const stack = [start]; + seen[start] = 1; + while (stack.length > 0) { + const index = stack.pop(); + if (index === undefined) break; + component.push(index); + for (const next of reversed[index] ?? []) { + if (seen[next] === 0) { + seen[next] = 1; + stack.push(next); + } + } + } + return component; +}; + +const dependencyComponentInCycle = ( + component: readonly number[], + edges: readonly (readonly number[])[], +): boolean => { + if (component.length > 1) return true; + const only = component[0]; + return only !== undefined && (edges[only]?.includes(only) ?? false); +}; + +const checkRustc = ( + fileBytes: number, + commit: RustBinary["rustc_commit"], + context: z.RefinementCtx, +): void => { + if (commit === null) return; + const seen = new Set(); + for (const [position, entry] of commit.hashes.entries()) { + if (seen.has(entry.hash) || !inside(fileBytes, entry.file_offset, 48)) + issue( + context, + ["rustc_commit", "hashes", position], + "rustc path hash is duplicated or its file offset is outside the artifact.", + ); + seen.add(entry.hash); + } +}; diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 72c351f31..d8ce6d9e2 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -70,9 +70,11 @@ const compareDimension = ( after: ReadonlyMap, complete: boolean, reason: string, + inventoryComplete = complete, ): Dimension => { + // Membership evidence can be complete even when selected content is unknown. const all = compareIdentities(before, after).filter( - ({ change }) => complete || change === "modified", + ({ change }) => inventoryComplete || change === "modified", ); if (all.length > 0) return { @@ -160,6 +162,7 @@ const storageDimension = ( ), storageComparable(before, after, keysComplete, usageComplete), "Storage fingerprints, inventories, usage, or quota were unavailable or truncated in at least one observation.", + keysComplete, ); }; @@ -683,16 +686,17 @@ const storageMap = ( }), ], ]); - if (!includeKeys) return map; const add = (kind: string, keys: readonly string[]) => { for (const key of keys) { map.set(`storage:${kind}:${key}`, digestCanonicalValue(key)); } }; - add("local_storage", storage.local_storage_keys); - add("session_storage", storage.session_storage_keys); - add("indexed_db", storage.indexed_db_names); - add("cache", storage.cache_names); + if (includeKeys) { + add("local_storage", storage.local_storage_keys); + add("session_storage", storage.session_storage_keys); + add("indexed_db", storage.indexed_db_names); + add("cache", storage.cache_names); + } for (const fingerprint of storage.content_fingerprints) { const identity = `${fingerprint.scope}:${fingerprint.identity_sha256}`; if (!fingerprintIdentities.has(identity)) continue; diff --git a/src/domain/webScriptExportError.ts b/src/domain/webScriptExportError.ts index 68067a3ec..db01d50b2 100644 --- a/src/domain/webScriptExportError.ts +++ b/src/domain/webScriptExportError.ts @@ -4,7 +4,7 @@ import type { AnalysisErrorOptions } from "./analysisErrorBase.js"; /** Publication failure retaining its input/output target and cleanup state. */ export class WebScriptExportError extends ArtifactOperationError { override readonly userMessage: string; - override readonly cleanupIncomplete: boolean; + override cleanupIncomplete: boolean; override readonly cleanupResources: readonly string[]; constructor( diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index 04a73065c..c85820ab4 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -52,6 +52,12 @@ export const zipPackageFormatForPath = ( return undefined; }; -/** Inventory cannot see bytes that are not central-directory entries. */ -export const ZIP_NON_ENTRY_TAIL_LIMITATION = - "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences."; +/** Inventory lists entries, not all bytes surrounding the central directory. */ +export const ZIP_NON_ENTRY_LIMITATION = + "ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences."; + +/** Recognize the same inventory scope in current and previously retained Evidence. */ +export const isZipInventoryScopeLimitation = (limitation: string): boolean => + limitation === ZIP_NON_ENTRY_LIMITATION || + limitation === + "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences."; diff --git a/src/dotnet/ManagedCilDecoding.test.ts b/src/dotnet/ManagedCilDecoding.test.ts index 4ba33f4c9..b24497db9 100644 --- a/src/dotnet/ManagedCilDecoding.test.ts +++ b/src/dotnet/ManagedCilDecoding.test.ts @@ -67,6 +67,30 @@ const managedBodyWithRegion = ({ .methods[0]?.body; }; +const fatBody = (il: Buffer) => { + const header = Buffer.alloc(12); + header.writeUInt16LE(0x3013, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(il.length, 4); + const bytes = buildManagedPeFixture({ + ilBody: Buffer.concat([header, il]), + }); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)) + .methods[0]?.body; +}; + +/** Build a tiny method body whose declared IL extent matches its supplied bytes. */ +const tinyBody = (il: readonly number[]) => { + const bytes = buildManagedPeFixture({ + ilBody: Buffer.from([(il.length << 2) | 0x02, ...il]), + }); + const inspection = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + return { body: inspection.methods[0]?.body, coverage: inspection.coverage }; +}; + describe("managed CIL decoding", () => { it("reads fat method header size from the full flags-and-size word", () => { const il = Buffer.from([ @@ -221,6 +245,189 @@ describe("managed CIL decoding", () => { }); }); +describe("managed operand extent diagnostics", () => { + it.each([ + [[0x20], 0, "Instruction ldc.i4 at IL offset 0 leaves method body"], + [ + [0x00, 0x20, 0x01], + 1, + "Instruction ldc.i4 at IL offset 1 leaves method body", + ], + [[0x2b], 0, "Instruction br.s at IL offset 0 leaves method body"], + [[0x45, 0x02], 0, "Instruction switch at IL offset 0 leaves method body"], + [ + [0xfe, 0x09, 0x00], + 0, + "Instruction ldarg at IL offset 0 leaves method body", + ], + [[0x00, 0xfe], 1, "Two-byte CIL opcode at IL offset 1 leaves method body"], + ] as const)( + "reports the instruction and IL offset for tiny IL %j", + (il, decoded, issue) => { + const { body, coverage } = tinyBody(il); + expect(body).toMatchObject({ + status: "malformed", + header_format: "tiny", + il_size: il.length, + decoded_instruction_count: decoded, + normalized_il_sha256: null, + issue, + }); + expect(coverage.state).toBe("partial"); + }, + ); + + it.each([ + ["short variable", Buffer.from([0x0e]), "ldarg.s"], + ["wide variable", Buffer.from([0xfe, 0x09]), "ldarg"], + ["int32 constant", Buffer.from([0x20]), "ldc.i4"], + ["int64 constant", Buffer.from([0x21]), "ldc.i8"], + ["float32 constant", Buffer.from([0x22]), "ldc.r4"], + ["float64 constant", Buffer.from([0x23]), "ldc.r8"], + ["short branch", Buffer.from([0x2b]), "br.s"], + ["long branch", Buffer.from([0x38]), "br"], + ["prefixed short constant", Buffer.from([0xfe, 0x12]), "unaligned."], + ["string token", Buffer.from([0x72]), "ldstr"], + ["type token", Buffer.from([0x71]), "ldobj"], + ["field token", Buffer.from([0x7b]), "ldfld"], + ["method token", Buffer.from([0x28]), "call"], + ] as const)( + "reports a truncated %s operand at its own instruction", + (_label, il, name) => { + expect(fatBody(il)).toMatchObject({ + status: "malformed", + decoded_instruction_count: 0, + normalized_il_sha256: null, + issue: `Instruction ${name} at IL offset 0 leaves method body`, + }); + }, + ); + + // A width declared too small still passes the extent check when no operand + // bytes are present, so the zero-byte cases above cannot catch it. Supplying + // one byte less than the real width makes a wrong width let the read run, + // which surfaces as the raw Buffer error rather than the diagnostic. + it.each([ + ["wide variable", Buffer.from([0xfe, 0x09]), 2, "ldarg"], + ["int32 constant", Buffer.from([0x20]), 4, "ldc.i4"], + ["int64 constant", Buffer.from([0x21]), 8, "ldc.i8"], + ["float32 constant", Buffer.from([0x22]), 4, "ldc.r4"], + ["float64 constant", Buffer.from([0x23]), 8, "ldc.r8"], + ["long branch", Buffer.from([0x38]), 4, "br"], + ["method token", Buffer.from([0x28]), 4, "call"], + ["type token", Buffer.from([0x71]), 4, "ldobj"], + ["string token", Buffer.from([0x72]), 4, "ldstr"], + ["field token", Buffer.from([0x7b]), 4, "ldfld"], + ] as const)( + "rejects a %s operand one byte short of its declared width", + (_label, opcode, width, name) => { + expect( + fatBody(Buffer.concat([opcode, Buffer.alloc(width - 1)])), + ).toMatchObject({ + status: "malformed", + decoded_instruction_count: 0, + normalized_il_sha256: null, + issue: `Instruction ${name} at IL offset 0 leaves method body`, + }); + }, + ); +}); + +describe("managed complete operand decoding", () => { + // The extent check must also reject nothing that fits, so every multi-byte + // width is exercised at exactly its own size with a known value. Each body + // ends at its operand, so a width declared too large turns a valid operand + // into the leaves-method-body diagnostic and these value assertions catch it. + // The -0 constants keep the floating-point sign fix covered through this same + // path. + it.each([ + ["wide variable", [0xfe, 0x09], [0x01, 0x00], "variable", "1"], + ["int32 constant", [0x20], [0x01, 0x00, 0x00, 0x00], "constant", "1"], + [ + "int64 constant", + [0x21], + [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + "constant", + "1", + ], + ["float32 constant", [0x22], [0x00, 0x00, 0x00, 0x80], "constant", "-0"], + [ + "float64 constant", + [0x23], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80], + "constant", + "-0", + ], + ["long branch", [0x38], [0x00, 0x00, 0x00, 0x00], "branch", "5"], + ["method token", [0x28], [0x01, 0x00, 0x00, 0x00], "method", "0x00000001"], + ["type token", [0x71], [0x02, 0x00, 0x00, 0x00], "type", "0x00000002"], + ["string token", [0x72], [0x03, 0x00, 0x00, 0x00], "string", "0x00000003"], + ["field token", [0x7b], [0x04, 0x00, 0x00, 0x00], "field", "0x00000004"], + ] as const)( + "decodes a %s operand at exactly its declared width", + (_label, opcode, operand, operandKind, value) => { + expect(fatBody(Buffer.from([...opcode, ...operand]))).toMatchObject({ + status: "present", + issue: null, + decoded_instruction_count: 1, + anchors: [{ il_offset: 0, operand_kind: operandKind, operand: value }], + }); + }, + ); + + it("reports a switch whose target table is missing", () => { + expect( + fatBody(Buffer.from([0x00, 0x45, 0x02, 0x00, 0x00, 0x00])), + ).toMatchObject({ + status: "malformed", + decoded_instruction_count: 1, + opcode_counts: { nop: 1 }, + issue: "Instruction switch at IL offset 1 leaves method body", + }); + }); + + it("reports a switch whose target count is itself truncated", () => { + expect(fatBody(Buffer.from([0x45, 0x02]))).toMatchObject({ + status: "malformed", + decoded_instruction_count: 0, + issue: "Instruction switch at IL offset 0 leaves method body", + }); + }); + + it("preserves instructions decoded before a truncated operand", () => { + expect(fatBody(Buffer.from([0x00, 0x20]))).toMatchObject({ + status: "malformed", + il_size: 2, + decoded_instruction_count: 1, + opcode_counts: { nop: 1 }, + issue: "Instruction ldc.i4 at IL offset 1 leaves method body", + }); + }); + + it("keeps a complete operand-bearing body decodable", () => { + expect( + fatBody(Buffer.from([0x00, 0x20, 0x01, 0x00, 0x00, 0x00, 0x2a])), + ).toMatchObject({ + status: "present", + decoded_instruction_count: 3, + opcode_counts: { nop: 1, "ldc.i4": 1, ret: 1 }, + issue: null, + }); + }); + + it("still decodes a switch whose targets fit", () => { + const il = Buffer.from([ + 0x00, 0x45, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, + ]); + expect(fatBody(il)).toMatchObject({ + status: "present", + il_size: il.length, + opcode_counts: { nop: 1, switch: 1, ret: 1 }, + issue: null, + }); + }); +}); + describe("managed exception region bounds", () => { it("marks out-of-method ranges malformed and preserves the raw EH clause", () => { expect( @@ -535,41 +742,3 @@ describe("managed exception section decoding", () => { }); }); }); - -describe("managed CIL truncated operands", () => { - const tinyBody = (il: readonly number[]) => { - const bytes = buildManagedPeFixture({ - ilBody: Buffer.from([(il.length << 2) | 0x02, ...il]), - }); - const inspection = inspectManagedMembersBytes( - bytes, - managedPeFixtureTarget(bytes), - ); - return { body: inspection.methods[0]?.body, coverage: inspection.coverage }; - }; - - it.each([ - [[0x20], 0, "Instruction ldc.i4 at IL offset 0 leaves method body"], - [ - [0x00, 0x20, 0x01], - 1, - "Instruction ldc.i4 at IL offset 1 leaves method body", - ], - [[0x2b], 0, "Instruction br.s at IL offset 0 leaves method body"], - [[0x45, 0x02], 0, "Instruction switch at IL offset 0 leaves method body"], - [ - [0xfe, 0x09, 0x00], - 0, - "Instruction ldarg at IL offset 0 leaves method body", - ], - [[0x00, 0xfe], 1, "Two-byte CIL opcode at IL offset 1 leaves method body"], - ])("names the instruction and IL offset for IL %j", (il, decoded, issue) => { - const { body, coverage } = tinyBody(il); - expect(body).toMatchObject({ - status: "malformed", - decoded_instruction_count: decoded, - issue, - }); - expect(coverage.state).toBe("partial"); - }); -}); diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 9ada1bf55..3aee06e73 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from "vitest"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; -import { ManagedDecodeBudget } from "./ManagedDecodeBudget.js"; +import { + ManagedDecodeBudget, + managedDecodeBudget, +} from "./ManagedDecodeBudget.js"; +import { + metadataRowOffset, + readManagedMetadataLayout, + type ManagedMetadataLayout, +} from "./ManagedMetadataLayout.js"; +import { readManagedPeLayout } from "./ManagedPeReader.js"; import { edges } from "./ManagedMemberRows.js"; +import { parseUserStrings } from "./ManagedUserStrings.js"; import { signature, type ManagedMethod } from "./ManagedMemberInspectorCore.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; import { managedMemberInspectionSchema } from "../domain/managed/managedArtifact.js"; @@ -11,9 +21,23 @@ import { comparisonLimitations } from "../domain/managed/managedMemberComparison import { buildManagedPeFixture, buildNativePeFixture, + fixtureUserStringHeap, managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; +/** A tiny-header body that loads each `ldstr` token, discards it, and returns. */ +const ldstrBody = (tokens: readonly string[]): Buffer => { + const il = Buffer.concat([ + ...tokens.map((token) => { + const instruction = Buffer.from([0x72, 0, 0, 0, 0, 0x26]); + instruction.writeUInt32LE(Number.parseInt(token, 16), 1); + return instruction; + }), + Buffer.from([0x2a]), + ]); + return Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]); +}; + describe("managed member inspection", () => { it("keeps metadata handles distinct from calls and field accesses", () => { const il = Buffer.from([ @@ -605,6 +629,433 @@ describe("managed exported types", () => { }); }); +describe("managed user strings", () => { + const inspect = (options: Parameters[0]) => { + const bytes = buildManagedPeFixture(options); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)); + }; + + it("decodes each loaded literal once, in token order, without changing CIL tokens", () => { + const { heap, tokens } = fixtureUserStringHeap([ + "quicksave.peak", + "unused", + "Ünïcødé 𝄞", + "\ud800", + ]); + const [path, , unicode, lone] = tokens as [string, string, string, string]; + // 0x70000000 names the heap's empty entry at offset 0. + const empty = "0x70000000"; + const ilBody = ldstrBody([unicode, path, lone, path, empty]); + const result = inspect({ ilBody, userStringHeap: heap }); + + expect(result.user_strings?.literals).toEqual([ + { token: empty, offset: expect.any(Number), value: "" }, + { token: path, offset: expect.any(Number), value: "quicksave.peak" }, + { token: unicode, offset: expect.any(Number), value: "Ünïcødé 𝄞" }, + { token: lone, offset: expect.any(Number), value: "\ud800" }, + ]); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + expect(result.user_strings).toMatchObject({ + state: "complete", + issues: [], + }); + const body = result.methods[0]?.body; + expect(body?.anchors.map(({ operand }) => operand)).toEqual([ + unicode, + path, + lone, + path, + empty, + ]); + expect(body?.normalized_il_sha256).toBe( + inspect({ ilBody }).methods[0]?.body.normalized_il_sha256, + ); + expect(comparisonLimitations(result, result)).toContain( + "ldstr literals are not compared; normalized CIL keeps #US tokens rather than decoded text, so an unchanged method does not establish unchanged string literals.", + ); + }); + + it("ignores a 0x70 token carried by an instruction other than ldstr", () => { + const { heap, tokens } = fixtureUserStringHeap(["not loaded"]); + const token = Number.parseInt(tokens[0] ?? "", 16); + const ldtoken = Buffer.from([0xd0, 0, 0, 0, 0, 0x26, 0x2a]); + ldtoken.writeUInt32LE(token, 1); + const result = inspect({ + ilBody: Buffer.concat([ + Buffer.from([(ldtoken.length << 2) | 2]), + ldtoken, + ]), + userStringHeap: heap, + }); + + expect(result.methods[0]?.body.anchors[0]).toMatchObject({ + opcode: "ldtoken", + operand_kind: "string", + }); + expect(result.user_strings?.literals).toEqual([]); + }); + + it("admits ldstr tokens before collecting them", () => { + const layout = { userStrings: null } as unknown as ManagedMetadataLayout; + // Leave 1 KiB of the 64 MiB budget: too little for 100 distinct tokens. + managedDecodeBudget(layout).reserve(64 * 1024 * 1024 - 1024, null); + const method = { + body: { + anchors: Array.from({ length: 100 }, (_, index) => ({ + il_offset: index * 5, + opcode: "ldstr", + operand_kind: "string", + operand: `0x70${(index + 1).toString(16).padStart(6, "0")}`, + })), + }, + } as unknown as ManagedMethod; + + expect(() => + parseUserStrings(Buffer.alloc(0), layout, { + methods: [method], + unscannedMethods: 0, + }), + ).toThrow(ManagedReaderFailure); + }); + + it("retains readable literals beside malformed #US items", () => { + const { heap, tokens } = fixtureUserStringHeap(["ok", "bad", "worse"]); + const [ok, bad, worse] = tokens as [string, string, string]; + // "bad" encodes 7 bytes; an even length cannot end in the flag byte. + heap.writeUInt8(6, Number.parseInt(bad.slice(4), 16)); + // 0xe0 is a reserved compressed-length prefix. + heap.writeUInt8(0xe0, Number.parseInt(worse.slice(4), 16)); + const result = inspect({ + ilBody: ldstrBody([ok, bad, worse, "0x70ffffff", "0x06000001"]), + userStringHeap: heap, + }); + + expect(result.user_strings?.literals).toEqual([ + { token: ok, offset: expect.any(Number), value: "ok" }, + ]); + expect(result.user_strings?.state).toBe("partial"); + expect( + result.user_strings?.issues.map(({ code, scope }) => [code, scope]), + ).toEqual([ + ["invalid-heap-index", "metadata.#US:0x06000001"], + ["invalid-string", `metadata.#US:${bad}`], + ["invalid-string", `metadata.#US:${worse}`], + ["invalid-heap-index", "metadata.#US:0x70ffffff"], + ]); + }); + + it("rejects #US items that run past the end of the stream", () => { + const { heap: readable, tokens } = fixtureUserStringHeap(["ok"]); + // An odd length claiming 127 bytes with two left, then a two-byte length + // prefix whose second byte lies past the stream end. + const crossing = Buffer.from([0x7f, 0x41, 0x00]); + const heap = Buffer.concat([readable, crossing, Buffer.from([0x80])]); + const token = (offset: number) => + `0x70${offset.toString(16).padStart(6, "0")}`; + const crossingToken = token(readable.length); + const truncatedToken = token(readable.length + crossing.length); + const result = inspect({ + ilBody: ldstrBody([tokens[0] ?? "", crossingToken, truncatedToken]), + userStringHeap: heap, + }); + + expect(result.user_strings?.literals).toEqual([ + { token: tokens[0], offset: expect.any(Number), value: "ok" }, + ]); + expect(result.user_strings).toMatchObject({ + state: "partial", + issues: [crossingToken, truncatedToken].map((item) => ({ + code: "invalid-string", + scope: `metadata.#US:${item}`, + detail: "User string leaves #US", + })), + }); + }); + + it("leaves user strings unexamined when CLI metadata is unavailable or predates them", () => { + const native = buildNativePeFixture(); + expect( + inspectManagedMembersBytes(native, managedPeFixtureTarget(native)) + .user_strings, + ).toBeNull(); + + const { user_strings: _omitted, ...legacy } = inspect({}); + const parsed = managedMemberInspectionSchema.parse(legacy); + expect(parsed.user_strings).toBeNull(); + expect(comparisonLimitations(parsed, inspect({}))).toContain( + "ldstr literals are not compared; normalized CIL keeps #US tokens rather than decoded text, so an unchanged method does not establish unchanged string literals.", + ); + }); +}); + +describe("managed user string issues", () => { + const inspect = (options: Parameters[0]) => { + const bytes = buildManagedPeFixture(options); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)); + }; + + it("reports a missing #US stream once and keeps the method", () => { + const result = inspect({ + // A MethodDef operand is not a #US reference, so it is reported alone. + ilBody: ldstrBody([ + "0x70000001", + "0x70000005", + "0x70000001", + "0x06000001", + ]), + }); + + expect(result.methods).toHaveLength(1); + expect(result.coverage.state).toBe("complete"); + expect(result.user_strings).toEqual({ + state: "partial", + literals: [], + issues: [ + { + code: "invalid-heap-index", + scope: "metadata.#US:0x06000001", + offset: null, + detail: "ldstr operand does not name an item inside #US", + }, + { + code: "missing-stream", + scope: "metadata.#US", + offset: null, + detail: + "2 distinct ldstr operand(s) reference #US, but the metadata has no #US stream", + }, + ], + }); + }); + + it("rejects a nonempty item at the reserved #US offset 0", () => { + // "A" with its flag, where only the empty entry may sit. + const result = inspect({ + ilBody: ldstrBody(["0x70000000"]), + userStringHeap: Buffer.from([3, 0x41, 0, 0]), + }); + + expect(result.user_strings?.literals).toEqual([]); + expect(result.user_strings).toMatchObject({ + state: "partial", + issues: [{ code: "invalid-string", scope: "metadata.#US:0x70000000" }], + }); + }); + + it.each([ + // "ok" is 5 bytes, which ECMA-335 encodes in one prefix byte, not 0x80 0x05. + ["0x70000001", [0, 0x80, 0x05, 0x6f, 0, 0x6b, 0, 0], "ok"], + // The empty entry at offset 0 is the single byte 0x00, not 0x80 0x00. + ["0x70000000", [0x80, 0x00], ""], + ])( + "keeps %s when its length prefix is wider than ECMA-335 assigns", + (token, heap, value) => { + const result = inspect({ + ilBody: ldstrBody([token]), + userStringHeap: Buffer.from(heap), + }); + + expect(result.user_strings?.literals).toEqual([ + { token, offset: expect.any(Number), value }, + ]); + expect(result.user_strings).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-string", + scope: `metadata.#US:${token}`, + detail: expect.stringContaining("prefix wider than ECMA-335"), + }, + ], + }); + }, + ); + + it("admits no width issue for an item that then fails", () => { + // 64 overlong zero lengths away from offset 0: each item is fatal. + const heap = Buffer.concat([ + Buffer.from([0]), + ...Array.from({ length: 64 }, () => Buffer.from([0x80, 0x00])), + ]); + const layout = { + userStrings: { name: "#US", offset: 0, size: heap.length }, + } as unknown as ManagedMetadataLayout; + // Leave 24 KiB: enough for the failures, not for 512 bytes leaked each. + managedDecodeBudget(layout).reserve(64 * 1024 * 1024 - 24 * 1024, null); + const method = { + body: { + anchors: Array.from({ length: 64 }, (_, index) => ({ + il_offset: index * 5, + opcode: "ldstr", + operand_kind: "string", + operand: `0x70${(1 + index * 2).toString(16).padStart(6, "0")}`, + })), + }, + } as unknown as ManagedMethod; + + expect( + parseUserStrings(heap, layout, { methods: [method], unscannedMethods: 0 }) + .issues, + ).toHaveLength(64); + }); + + it("keeps literals whose terminal flag differs from ECMA-335 and reports each flag", () => { + const { heap, tokens } = fixtureUserStringHeap(["ok", "it's"]); + const [ok, quoted] = tokens as [string, string]; + // A short item is a one-byte length, the UTF-16 text, then the flag byte. + const flagAt = (token: string, value: string) => + Number.parseInt(token.slice(4), 16) + 1 + value.length * 2; + heap.writeUInt8(1, flagAt(ok, "ok")); + heap.writeUInt8(0, flagAt(quoted, "it's")); + const result = inspect({ + ilBody: ldstrBody([ok, quoted]), + userStringHeap: heap, + }); + + expect(result.coverage.state).toBe("complete"); + expect(result.user_strings?.literals.map(({ value }) => value)).toEqual([ + "ok", + "it's", + ]); + expect(result.user_strings).toMatchObject({ + state: "partial", + issues: [ok, quoted].map((token) => ({ + code: "invalid-string", + scope: `metadata.#US:${token}`, + detail: expect.stringContaining("terminal flag"), + })), + }); + }); +}); + +describe("managed user string scan completeness", () => { + const inspect = (options: Parameters[0]) => { + const bytes = buildManagedPeFixture(options); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)); + }; + + it("marks user strings partial when a method body is not fully decoded", () => { + const { heap, tokens } = fixtureUserStringHeap(["ok", "later"]); + const [ok, later] = tokens as [string, string]; + // ldstr "ok", then an undefined opcode before ldstr "later". + const il = Buffer.from([ + 0x72, 0, 0, 0, 0, 0x26, 0xfe, 0xff, 0x72, 0, 0, 0, 0, 0x26, 0x2a, + ]); + il.writeUInt32LE(Number.parseInt(ok, 16), 1); + il.writeUInt32LE(Number.parseInt(later, 16), 9); + const result = inspect({ + ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]), + userStringHeap: heap, + }); + + expect(result.methods[0]?.body.status).toBe("malformed"); + expect(result.user_strings).toEqual({ + state: "partial", + literals: [{ token: ok, offset: expect.any(Number), value: "ok" }], + issues: [], + }); + }); + + it.each([ + ["outside", 1, "complete"], + ["inside", 2, "partial"], + ])( + "judges an unreadable method row %s the selection by its owner", + (_where, badRow, state) => { + const bytes = buildManagedPeFixture({ + additionalEmptyTypes: 1, + methodRowCount: 2, + }); + const cli = readManagedPeLayout(bytes); + if (cli.cli === null) throw new Error("fixture has no CLI header"); + const { rva, size } = cli.cli.metadata; + const layout = readManagedMetadataLayout( + bytes, + cli.rvaToOffset(rva, size, "cli.metadata"), + size, + ); + const write = (offset: number, width: number, value: number) => + width === 2 + ? bytes.writeUInt16LE(value, offset) + : bytes.writeUInt32LE(value, offset); + // Fixture.Program's methods now start at row 2, so Fixture.Empty0 owns row 1. + write( + metadataRowOffset(layout, 2, 2) + + 4 + + 2 * layout.stringIndexSize + + layout.codedIndexSize("TypeDefOrRef") + + layout.tableIndexSize(4), + layout.tableIndexSize(6), + 2, + ); + // An out-of-range name index makes that method row unreadable. + write( + metadataRowOffset(layout, 6, badRow) + 8, + layout.stringIndexSize, + 0xffff, + ); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { typeNames: ["Fixture.Program"] }, + ); + + expect(result.user_strings?.state).toBe(state); + }, + ); + + it("keeps user strings complete when only the exception section is malformed", () => { + const { heap, tokens } = fixtureUserStringHeap(["ok"]); + // ldstr "ok"; pop; ret all decode before an exception section that + // holds no whole clause. + const il = Buffer.from([0x72, 0, 0, 0, 0, 0x26, 0x2a]); + il.writeUInt32LE(Number.parseInt(tokens[0] ?? "", 16), 1); + const header = Buffer.alloc(12); + header.writeUInt16LE(0x300b, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(il.length, 4); + const section = Buffer.alloc(17); + section[0] = 0x01; + section[1] = section.length; + const result = inspect({ + ilBody: Buffer.concat([header, il, Buffer.alloc(1), section]), + userStringHeap: heap, + }); + + expect(result.methods[0]?.body.status).toBe("malformed"); + expect(result.user_strings).toMatchObject({ + state: "complete", + literals: [{ value: "ok" }], + }); + }); + + it("marks user strings partial when selected type identity is unreadable", () => { + const layout = { + userStrings: { name: "#US", offset: 0, size: 1 }, + } as unknown as ManagedMetadataLayout; + expect( + parseUserStrings(Buffer.from([0]), layout, { + methods: [], + unscannedMethods: 0, + unreadableSelectedTypes: 1, + }), + ).toEqual({ state: "partial", literals: [], issues: [] }); + }); + + it("marks user strings partial when an inspected method row was not read", () => { + const layout = { + userStrings: { name: "#US", offset: 0, size: 1 }, + } as unknown as ManagedMetadataLayout; + // The omitted row's body, and any ldstr in it, was never scanned. + expect( + parseUserStrings(Buffer.from([0]), layout, { + methods: [], + unscannedMethods: 1, + }), + ).toEqual({ state: "partial", literals: [], issues: [] }); + }); +}); + describe("managed member decoding budget", () => { it("admits a wide-instruction method whose actual records fit the budget", () => { // Five-byte branches all target the next instruction. A final ret leaves @@ -1243,7 +1694,7 @@ describe("managed member type selection", () => { expect(result.field_accesses).toEqual(everything.field_accesses); expect(result.coverage).toEqual(everything.coverage); expect(result.limitations).toContain( - "Types, fields, methods, call edges, and field accesses cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.", + "Types, fields, methods, call edges, field accesses, and user strings cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.", ); }); @@ -1260,6 +1711,8 @@ describe("managed member type selection", () => { expect(result.selection?.unmatched_type_names).toEqual(["Fixture.Program"]); expect(result.coverage.state).toBe("partial"); + // Its methods were never inspected, so neither were their literals. + expect(result.user_strings?.state).toBe("partial"); expect(result.limitations).toContain( "1 TypeDef name(s) could not be read; a name in selection.unmatched_type_names may belong to one of them, so its absence is not observed.", ); diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index dba20ac57..1b018522a 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -19,6 +19,7 @@ import { } from "./ManagedMetadataInventory.js"; import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { + appendManagedParseIssues, ManagedReaderFailure, uniqueManagedParseIssues, } from "./ManagedReaderFailure.js"; @@ -29,6 +30,7 @@ import { } from "./ManagedMemberInspectorCore.js"; import { edgeTargets } from "./ManagedMethodSpecs.js"; import { typeNameLookup } from "./ManagedTypeNames.js"; +import { parseUserStrings } from "./ManagedUserStrings.js"; import { edges, parseExportedTypes, @@ -70,6 +72,7 @@ const unavailable = ( }, types: [], exported_types: null, + user_strings: null, fields: [], methods: [], member_refs: [], @@ -149,11 +152,19 @@ const selectTypes = ( /** * Inspect metadata members and method bodies without loading target code. * Selected type names restrict full decoding to members of those exact types. + * A caller that never reads literals, such as member comparison, can skip + * user strings; they are then reported as not examined. */ export const inspectManagedMembersBytes = ( bytes: Buffer, target: BinaryTarget, - { typeNames }: { readonly typeNames?: readonly string[] } = {}, + { + typeNames, + userStrings: readUserStrings = true, + }: { + readonly typeNames?: readonly string[]; + readonly userStrings?: boolean; + } = {}, ): ManagedMemberInspection => { const pe = readManagedPeLayout(bytes); if (pe.cli === null) @@ -161,7 +172,7 @@ export const inspectManagedMembersBytes = ( const issues: ManagedParseIssue[] = []; try { const { layout, inventory } = readMemberInventory(bytes, pe, issues); - issues.push(...inventory.issues); + appendManagedParseIssues(issues, inventory.issues); const ranges = typeRanges(bytes, layout, issues); const { tokens: selected, @@ -169,16 +180,16 @@ export const inspectManagedMembersBytes = ( unreadableNames, } = selectTypes(ranges, typeNames); const parsedTypes = parseTypes(bytes, layout, ranges); - issues.push(...parsedTypes.issues); + appendManagedParseIssues(issues, parsedTypes.issues); const exportedTypes = parseExportedTypes(bytes, layout); - issues.push(...exportedTypes.issues); + appendManagedParseIssues(issues, exportedTypes.issues); const fields = parseFields(bytes, layout, ranges, selected); const memberRefs = parseMemberRefs( bytes, layout, typeNameLookup(bytes, layout, ranges, issues), ); - issues.push(...memberRefs.issues); + appendManagedParseIssues(issues, memberRefs.issues); const methods = parseMethods({ bytes, layout, @@ -186,13 +197,21 @@ export const inspectManagedMembersBytes = ( ranges, selected, }); - issues.push(...fields.issues, ...methods.issues); + appendManagedParseIssues(issues, fields.issues, methods.issues); + // #US literals sit beside the member inventory and carry their own + // coverage, so their issues never make member facts partial. + const userStrings = readUserStrings + ? parseUserStrings(bytes, layout, { + ...methods, + unreadableSelectedTypes: unreadableNames, + }) + : null; const targets = edgeTargets(bytes, layout, methods.methods, [ fields.core, memberRefs.core, methods.core, ]); - issues.push(...targets.issues); + appendManagedParseIssues(issues, targets.issues); const coverageIssues = uniqueManagedParseIssues(issues); const incompleteMembers = [...fields.fields, ...memberRefs.refs, ...methods.methods].some( @@ -238,6 +257,7 @@ export const inspectManagedMembersBytes = ( member_refs: memberRefs.refs, call_edges: related.callEdges, field_accesses: related.fieldAccesses, + user_strings: userStrings, selection, coverage: { state: @@ -250,11 +270,12 @@ export const inspectManagedMembersBytes = ( "Metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", "CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.", "Exported types are reported from ExportedType rows as declared; forwarded types are not resolved in their implementing assemblies.", + "User strings are the #US literals loaded by ldstr in the inspected method bodies, with their own coverage; instruction anchors and normalized CIL digests keep their build-local tokens.", "Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer, custom modifier, and typed reference forms; class and valuetype operands keep their encoded TypeDefOrRefOrSpec values, and unsupported forms such as vararg sentinels retain raw signature hashes.", ...(selection === null ? [] : [ - "Types, fields, methods, call edges, and field accesses cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.", + "Types, fields, methods, call edges, field accesses, and user strings cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.", ]), ...(unreadableNames === 0 ? [] diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 7776c0090..caac2e66e 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -35,6 +35,9 @@ export type ManagedMemberRef = ManagedMemberInspection["member_refs"][number]; export type ManagedCallEdge = ManagedMemberInspection["call_edges"][number]; export type ManagedFieldAccess = ManagedMemberInspection["field_accesses"][number]; +export type ManagedUserString = NonNullable< + ManagedMemberInspection["user_strings"] +>["literals"][number]; export type ManagedSignature = ManagedMethod["signature"]; export type ManagedMethodBody = ManagedMethod["body"]; export type ManagedInstructionAnchor = ManagedMethodBody["anchors"][number]; diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index 01c2b7814..0c04b7a36 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -271,82 +271,101 @@ type OperandReader = (il: Buffer, offset: number) => OperandResult; const floatingPointOperand = (value: number): string => Object.is(value, -0) ? "-0" : String(value); -const FIXED_OPERAND_READERS = new Map([ +/** + * A fixed-width operand declares its byte width next to its reader so the + * extent check can bound the read before any value is consumed. Keeping both + * facts in one entry is what stops the width and the reader from drifting. + */ +type FixedOperand = { + readonly width: number; + readonly read: OperandReader; +}; + +const fixed = (width: number, read: OperandReader): FixedOperand => ({ + width, + read, +}); + +const FIXED_OPERAND_READERS = new Map([ [ "none", - (_il, offset) => ({ next: offset, anchorKind: "none", value: null }), + fixed(0, (_il, offset) => ({ + next: offset, + anchorKind: "none", + value: null, + })), ], [ "short-var", - (il, offset) => ({ + fixed(1, (il, offset) => ({ next: offset + 1, anchorKind: "variable", value: String(il.readUInt8(offset)), - }), + })), ], [ "var", - (il, offset) => ({ + fixed(2, (il, offset) => ({ next: offset + 2, anchorKind: "variable", value: String(il.readUInt16LE(offset)), - }), + })), ], [ "short-i", - (il, offset) => ({ + fixed(1, (il, offset) => ({ next: offset + 1, anchorKind: "constant", value: String(il.readInt8(offset)), - }), + })), ], [ "i", - (il, offset) => ({ + fixed(4, (il, offset) => ({ next: offset + 4, anchorKind: "constant", value: String(il.readInt32LE(offset)), - }), + })), ], [ "i8", - (il, offset) => ({ + fixed(8, (il, offset) => ({ next: offset + 8, anchorKind: "constant", value: il.readBigInt64LE(offset).toString(), - }), + })), ], [ "r4", - (il, offset) => ({ + fixed(4, (il, offset) => ({ next: offset + 4, anchorKind: "constant", value: floatingPointOperand(il.readFloatLE(offset)), - }), + })), ], [ "r8", - (il, offset) => ({ + fixed(8, (il, offset) => ({ next: offset + 8, anchorKind: "constant", value: floatingPointOperand(il.readDoubleLE(offset)), - }), + })), ], [ "short-branch", - (il, offset) => ({ + fixed(1, (il, offset) => ({ next: offset + 1, anchorKind: "branch", value: String(offset + 1 + il.readInt8(offset)), - }), + })), ], [ "branch", - (il, offset) => ({ + fixed(4, (il, offset) => ({ next: offset + 4, anchorKind: "branch", value: String(offset + 4 + il.readInt32LE(offset)), - }), + })), ], ]); @@ -361,13 +380,42 @@ const TOKEN_OPERAND_KINDS = new Map< ["signature", "signature"], ]); +/** Every token operand is a 4-byte metadata token. */ +const TOKEN_OPERAND_WIDTH = 4; + +/** + * Bytes the operand at `offset` occupies, or `null` when the extent itself + * cannot be read from `il`. This is the single extent authority: `readOperand` + * must never be called for an operand whose extent it cannot bound, so no + * operand read can leave the method body. + */ +const operandExtent = ( + il: Buffer, + offset: number, + kind: string, +): number | null => { + const fixedOperand = FIXED_OPERAND_READERS.get(kind); + if (fixedOperand !== undefined) return fixedOperand.width; + if (kind === "switch") { + if (offset > il.length - 4) return null; + return 4 + il.readUInt32LE(offset) * 4; + } + return TOKEN_OPERAND_KINDS.has(kind) || kind === "token" + ? TOKEN_OPERAND_WIDTH + : 0; +}; + +/** + * Read an operand whose extent is already known to fit. `next` is always + * `offset + operandExtent(il, offset, kind)`; callers bound the read first. + */ const readOperand = ( il: Buffer, offset: number, kind: string, ): OperandResult => { - const fixed = FIXED_OPERAND_READERS.get(kind); - if (fixed !== undefined) return fixed(il, offset); + const fixedOperand = FIXED_OPERAND_READERS.get(kind); + if (fixedOperand !== undefined) return fixedOperand.read(il, offset); if (kind === "switch") { const count = il.readUInt32LE(offset); return { @@ -376,30 +424,15 @@ const readOperand = ( value: String(count), }; } + if (!TOKEN_OPERAND_KINDS.has(kind) && kind !== "token") + return { next: offset, anchorKind: "unknown", value: null }; const raw = il.readUInt32LE(offset); const token = `0x${raw.toString(16).padStart(8, "0")}`; - const tokenOperandKind = TOKEN_OPERAND_KINDS.get(kind); - if (tokenOperandKind !== undefined) - return { next: offset + 4, anchorKind: tokenOperandKind, value: token }; - if (kind === "token") - return { next: offset + 4, anchorKind: tokenKind(token), value: token }; - return { next: offset, anchorKind: "unknown", value: null }; -}; - -/** An operand cut off by the end of the IL body has no decodable value. */ -const readOperandWithin = ( - il: Buffer, - offset: number, - kind: string, -): OperandResult | undefined => { - try { - return readOperand(il, offset, kind); - } catch (cause: unknown) { - // Buffer reads past the IL body throw RangeError; report it as the - // instruction leaving the method body rather than as a Buffer message. - if (cause instanceof RangeError) return undefined; - throw cause; - } + return { + next: offset + TOKEN_OPERAND_WIDTH, + anchorKind: TOKEN_OPERAND_KINDS.get(kind) ?? tokenKind(token), + value: token, + }; }; export const decodeInstructions = ( @@ -432,15 +465,15 @@ export const decodeInstructions = ( issue = `Unsupported CIL opcode 0x${code.toString(16)} at IL offset ${String(start)}`; break; } - const operand = readOperandWithin(il, offset, descriptor.operand); - if ( - operand === undefined || - operand.next < offset || - operand.next > il.length - ) { + // Bound the operand extent before reading its value. Reading first would + // throw a raw Buffer RangeError out of a truncated body and replace this + // diagnostic with the runtime message, losing the instruction identity. + const extent = operandExtent(il, offset, descriptor.operand); + if (extent === null || offset + extent > il.length) { issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`; break; } + const operand = readOperand(il, offset, descriptor.operand); // Charge each actual instruction before growing the decoded arrays. // A multi-byte operand must not consume a full record per raw IL byte. admitInstruction?.(start); diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 201ff1c45..700f723e6 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -269,16 +269,36 @@ export const parseMethods = ({ readonly methods: readonly ManagedMethod[]; readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; + /** + * Methods that may belong to a selected type but whose CIL was not fully + * scanned: rows that produced no method, or bodies whose decoding stopped. + */ + readonly unscannedMethods: number; } => { const methods: ManagedMethod[] = []; const core = new Map(); const issues: ManagedParseIssue[] = []; + let unscannedMethods = 0; const table = layout.table(6); const ownerRow = readManagedValue( () => memberDeclaringTypeRowLookup(bytes, layout, 6), issues, ); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + // The owner is read first: a row that produced no method hides a selected + // body only when its owner is selected, unknown, or unreadably named. + const owner = + ownerRow === undefined + ? undefined + : readManagedValue(() => ownerRow(row), issues); + const declared = owner === undefined ? undefined : ranges[owner - 1]; + const mayBeSelected = + selected === null || + owner === undefined || + isSelected(selected, declared) || + // A type whose name is unreadable may be one the caller requested. + declared?.fullName === null; + const produced = methods.length; readManagedValue(() => { const cursor = metadataRowCursor(bytes, layout, 6, row); const rva = cursor.readUInt32(); @@ -290,11 +310,6 @@ export const parseMethods = ({ cursor.readIndex(layout.stringIndexSize), layout.strings.size, ); - const owner = - ownerRow === undefined - ? 0 - : (readManagedValue(() => ownerRow(row), issues) ?? 0); - const declared = ranges[owner - 1]; const token = metadataToken(6, row); const member = { token, @@ -316,6 +331,14 @@ export const parseMethods = ({ ...member, genericArity: signatureGenericArity(decoded), }); + const read = methodBody( + bytes, + pe, + rva, + { implFlags, flags }, + managedDecodeBudget(layout), + ); + if (!read.scanned) unscannedMethods += 1; methods.push({ token, row_offset: cursor.start, @@ -326,17 +349,12 @@ export const parseMethods = ({ impl_flags: implFlags, flags, signature: decoded, - body: methodBody( - bytes, - pe, - rva, - { implFlags, flags }, - managedDecodeBudget(layout), - ), + body: read.body, }); }, issues); + if (mayBeSelected && methods.length === produced) unscannedMethods += 1; } - return { methods, core, issues }; + return { methods, core, issues, unscannedMethods }; }; /** diff --git a/src/dotnet/ManagedMetadataHeaps.test.ts b/src/dotnet/ManagedMetadataHeaps.test.ts index c94ae4055..ebc2a0d49 100644 --- a/src/dotnet/ManagedMetadataHeaps.test.ts +++ b/src/dotnet/ManagedMetadataHeaps.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { readMetadataBlob } from "./ManagedMetadataHeaps.js"; +import { + readMetadataBlob, + readMetadataUserString, +} from "./ManagedMetadataHeaps.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; describe("managed metadata heaps", () => { @@ -16,6 +19,7 @@ describe("managed metadata heaps", () => { strings: { name: "#Strings", offset: 0, size: 0 }, guid: { name: "#GUID", offset: 0, size: 0 }, blob: { name: "#Blob", offset: 0, size: bytes.length }, + userStrings: null, tables: new Map(), rowCounts: [], stringIndexSize: 2, @@ -30,4 +34,26 @@ describe("managed metadata heaps", () => { itemLength, ); }); + + it.each([[[0x80]], [[0xc0, 0x00, 0x00]]])( + "reports a #US length prefix cut off by the end of the artifact as a string failure", + (prefix) => { + // #US ends at the end of the artifact, inside a multi-byte length prefix. + const bytes = Buffer.from([0x00, ...prefix]); + const layout = { + userStrings: { name: "#US", offset: 0, size: bytes.length }, + } as unknown as ManagedMetadataLayout; + + expect(() => + readMetadataUserString(bytes, layout, "0x70000001"), + ).toThrowError( + expect.objectContaining({ + issue: expect.objectContaining({ + code: "invalid-string", + scope: "metadata.#US:0x70000001", + }), + }), + ); + }, + ); }); diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index 51b85576c..0987680f0 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -1,3 +1,4 @@ +import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; import { createHash } from "node:crypto"; import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; @@ -8,21 +9,6 @@ import { } from "./ManagedMetadataLayout.js"; import { managedFailure } from "./ManagedReaderFailure.js"; -const requireRange = ( - bytes: Buffer, - offset: number, - length: number, - scope: string, -): void => { - if (offset < 0 || length < 0 || offset > bytes.length - length) - throw managedFailure( - "invalid-row", - scope, - `${scope} leaves the artifact byte range`, - offset >= 0 ? offset : null, - ); -}; - /** Cursor for checked fixed-width metadata row decoding. */ export class MetadataRowCursor { #offset: number; @@ -69,36 +55,54 @@ export class MetadataRowCursor { } } +// Issue code and label for the heap item a compressed length prefixes. +const PREFIXED_ITEMS = { + blob: { code: "invalid-blob", label: "Metadata blob" }, + userString: { code: "invalid-string", label: "User string" }, +} as const; + const compressedLength = ( bytes: Buffer, offset: number, scope: string, -): { readonly length: number; readonly prefix: number } => { - requireRange(bytes, offset, 1, scope); + item: keyof typeof PREFIXED_ITEMS, +): { + readonly length: number; + readonly prefix: number; + /** Whether ECMA-335 II.23.2 assigns this width to the value. */ + readonly minimal: boolean; +} => { + const { code, label } = PREFIXED_ITEMS[item]; + const requirePrefix = (size: number): void => { + if (offset < 0 || offset > bytes.length - size) + throw managedFailure( + code, + scope, + `${label} length prefix leaves the artifact byte range`, + offset >= 0 ? offset : null, + ); + }; + requirePrefix(1); const first = bytes[offset] ?? 0; - if ((first & 0x80) === 0) return { length: first, prefix: 1 }; + if ((first & 0x80) === 0) return { length: first, prefix: 1, minimal: true }; if ((first & 0xc0) === 0x80) { - requireRange(bytes, offset, 2, scope); - return { - length: ((first & 0x3f) << 8) | (bytes[offset + 1] ?? 0), - prefix: 2, - }; + requirePrefix(2); + const length = ((first & 0x3f) << 8) | (bytes[offset + 1] ?? 0); + return { length, prefix: 2, minimal: length >= 0x80 }; } if ((first & 0xe0) === 0xc0) { - requireRange(bytes, offset, 4, scope); - return { - length: - (first & 0x1f) * 0x01_00_00_00 + - (bytes[offset + 1] ?? 0) * 0x01_00_00 + - (bytes[offset + 2] ?? 0) * 0x0100 + - (bytes[offset + 3] ?? 0), - prefix: 4, - }; + requirePrefix(4); + const length = + (first & 0x1f) * 0x01_00_00_00 + + (bytes[offset + 1] ?? 0) * 0x01_00_00 + + (bytes[offset + 2] ?? 0) * 0x0100 + + (bytes[offset + 3] ?? 0); + return { length, prefix: 4, minimal: length >= 0x4000 }; } throw managedFailure( - "invalid-blob", + code, scope, - "Metadata blob uses a reserved compressed-length prefix", + `${label} uses a reserved compressed-length prefix`, offset, ); }; @@ -172,7 +176,7 @@ export const readMetadataBlob = ( layout.blob.offset + Math.max(index, 0), ); const start = layout.blob.offset + index; - const decoded = compressedLength(bytes, start, "metadata.#Blob"); + const decoded = compressedLength(bytes, start, "metadata.#Blob", "blob"); if (decoded.length > heapExtent) throw managedFailure( "invalid-blob", @@ -191,6 +195,124 @@ export const readMetadataBlob = ( return bytes.subarray(content, content + decoded.length); }; +/** + * Read the #US literal named by a `0x70` user-string token: a compressed byte + * length, UTF-16LE code units, then one ECMA-335 terminal flag byte. Code units + * are kept as stored, including unpaired surrogates. `parseUserStrings` owns + * which `ldstr` operands are user-string tokens. + */ +export const readMetadataUserString = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + token: string, +): { + readonly offset: number; + readonly value: string; + /** Conformance differences observed beside the readable literal. */ + readonly issues: readonly ManagedParseIssue[]; +} => { + const heap = layout.userStrings; + const scope = `metadata.#US:${token}`; + if (heap === null) + throw managedFailure( + "missing-stream", + scope, + "ldstr references #US, but the metadata has no #US stream", + ); + const index = Number.parseInt(token.slice(4), 16); + if (index >= heap.size) + throw managedFailure( + "invalid-heap-index", + scope, + "ldstr operand does not name an item inside #US", + heap.offset + index, + ); + const start = heap.offset + index; + const decoded = compressedLength(bytes, start, scope, "userString"); + const content = start + decoded.prefix; + if (content > heap.offset + heap.size - decoded.length) + throw managedFailure( + "invalid-string", + scope, + "User string leaves #US", + start, + ); + const budget = managedDecodeBudget(layout); + // A wider length prefix than the value needs still reads unambiguously, so + // it is reported like the flag rather than discarding the literal. It is + // admitted only on paths that return the literal, so a fatal item leaves + // no budget behind. + const conformance = (): ManagedParseIssue[] => { + if (decoded.minimal) return []; + budget.reserve(512, start); + return [ + { + code: "invalid-string", + scope, + offset: start, + detail: `User string length ${String(decoded.length)} uses a ${String(decoded.prefix)}-byte prefix wider than ECMA-335 II.23.2 assigns; readable UTF-16 is preserved and runtime acceptance is not assessed`, + }, + ]; + }; + // ECMA-335 II.24.2.4: offset 0 is the heap's empty entry, a zero length + // with no terminal flag byte; every other item carries the flag. + if (index === 0) { + if (decoded.length === 0) + return { offset: start, value: "", issues: conformance() }; + throw managedFailure( + "invalid-string", + scope, + `#US offset 0 is the reserved empty entry, but it encodes ${String(decoded.length)} byte(s)`, + start, + ); + } + if (decoded.length % 2 !== 1) + throw managedFailure( + "invalid-string", + scope, + "User string length is not UTF-16 code units plus one terminal flag byte", + start, + ); + const units = (decoded.length - 1) / 2; + // Admit the literal before scanning it: JSON spends at most six bytes per + // UTF-16 code unit. + budget.reserveString(units, start); + // Check the serialized flag independently of literal decoding. A difference + // from ECMA-335 is observable metadata, not proof of runtime rejection. + let expectedFlag = 0; + for (let unitIndex = 0; unitIndex < units; unitIndex++) { + const unit = bytes.readUInt16LE(content + unitIndex * 2); + if ( + unit > 0xff || + (unit >= 0x01 && unit <= 0x08) || + (unit >= 0x0e && unit <= 0x1f) || + unit === 0x27 || + unit === 0x2d || + unit === 0x7f + ) { + expectedFlag = 1; + break; + } + } + const flagOffset = content + units * 2; + const actualFlag = bytes[flagOffset]; + const issues = conformance(); + if (actualFlag !== expectedFlag) { + budget.reserve(512, flagOffset); + issues.push({ + code: "invalid-string", + scope, + offset: flagOffset, + detail: `User string terminal flag ${String(actualFlag)} differs from ECMA-335 II.24.2.4 value ${String(expectedFlag)}; readable UTF-16 is preserved and runtime acceptance is not assessed`, + }); + } + return { + offset: start, + value: bytes.subarray(content, flagOffset).toString("utf16le"), + issues, + }; +}; + /** Read one conventional GUID value from the one-based #GUID heap. */ export const readMetadataGuid = ( bytes: Buffer, diff --git a/src/dotnet/ManagedMetadataLayout.ts b/src/dotnet/ManagedMetadataLayout.ts index b7dc0a7ae..b38854645 100644 --- a/src/dotnet/ManagedMetadataLayout.ts +++ b/src/dotnet/ManagedMetadataLayout.ts @@ -78,6 +78,8 @@ export interface ManagedMetadataLayout { readonly strings: MetadataStream; readonly guid: MetadataStream; readonly blob: MetadataStream; + /** Optional: assemblies without `ldstr` literals may omit #US. */ + readonly userStrings: MetadataStream | null; readonly tables: ReadonlyMap; readonly rowCounts: readonly number[]; readonly stringIndexSize: 2 | 4; @@ -431,6 +433,7 @@ export const readManagedMetadataLayout = ( strings: requireStream(streams, "#Strings"), guid: requireStream(streams, "#GUID"), blob: requireStream(streams, "#Blob"), + userStrings: streams.get("#US") ?? null, tables, rowCounts, stringIndexSize: heaps.string, diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index 7c507cdd9..81f3543a2 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -76,17 +76,25 @@ export const methodBody = ( flags: 0, }, budget?: ManagedDecodeBudget, -): ManagedMethodBody => { - if (rva === 0) return emptyMethodBody(rva, "absent", null); - if ( +): { + readonly body: ManagedMethodBody; + /** + * Whether every CIL instruction was decoded, independent of exception-region + * validity. A body with no CIL, absent or native, has nothing left unscanned. + */ + readonly scanned: boolean; +} => { + // Without CIL there is nothing left unscanned. + if (rva === 0) + return { body: emptyMethodBody(rva, "absent", null), scanned: true }; + const native = (implementation.implFlags & 7) !== 0 || - (implementation.flags & 0x2000) !== 0 - ) - return emptyMethodBody( - rva, - "partial", - "CIL decoding is unavailable for native, runtime, unmanaged, or P/Invoke implementations", - ); + (implementation.flags & 0x2000) !== 0; + if (native) { + const issue = + "CIL decoding is unavailable for native, runtime, unmanaged, or P/Invoke implementations"; + return { body: emptyMethodBody(rva, "partial", issue), scanned: true }; + } try { const offset = pe.rvaToOffset(rva, 1, "method.body"); const methodExtent = pe.rvaAvailableBytes(rva, "method.body"); @@ -181,7 +189,10 @@ export const methodBody = ( ? "Instruction decoding stopped before the end of the method body" : null), }; - return budget === undefined ? body : admitManagedProjection(body, budget); + return { + body: budget === undefined ? body : admitManagedProjection(body, budget), + scanned: decoded.issue === null && decoded.truncated === 0, + }; } catch (cause: unknown) { if ( cause instanceof ManagedReaderFailure && @@ -193,11 +204,14 @@ export const methodBody = ( !(cause instanceof RangeError) ) throw cause; - return emptyMethodBody( - rva, - "malformed", - cause instanceof Error ? cause.message : "Method body parse failed", - ); + return { + body: emptyMethodBody( + rva, + "malformed", + cause instanceof Error ? cause.message : "Method body parse failed", + ), + scanned: false, + }; } finally { // IL and exception-section bookkeeping coexist until the body is built. budget?.releaseTransient(); diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 7ea2b06b4..0d4cf4978 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -53,6 +53,8 @@ interface ManagedPeFixtureOptions { readonly targetFramework?: string | null; readonly typeName?: string; readonly typeNamespace?: string; + /** Raw #US stream bytes; omitted means the metadata has no #US stream. */ + readonly userStringHeap?: Buffer; readonly virtualSectionSize?: number; } @@ -416,6 +418,56 @@ const fixedStringAttributeBlob = (value: string): Buffer => { return Buffer.concat([Buffer.from([1, 0, bytes.length]), bytes, u16(0)]); }; +/** + * Lay out ECMA-335 #US items (compressed length, UTF-16LE, terminal flag byte) + * and return the `ldstr` token that names each value. + */ +// ECMA-335 II.24.2.4 terminal flag: 1 when a code unit has a high byte or a +// low byte in 0x01-0x08, 0x0E-0x1F, 0x27, 0x2D, or 0x7F. +const userStringFlag = (text: Buffer): number => { + for (let index = 0; index < text.length; index += 2) { + const unit = text.readUInt16LE(index); + if ( + unit > 0xff || + (unit >= 0x01 && unit <= 0x08) || + (unit >= 0x0e && unit <= 0x1f) || + unit === 0x27 || + unit === 0x2d || + unit === 0x7f + ) + return 1; + } + return 0; +}; + +/** A #US heap whose items carry the ECMA-335 terminal flag, and their tokens. */ +export const fixtureUserStringHeap = ( + values: readonly string[], +): { readonly heap: Buffer; readonly tokens: readonly string[] } => { + const items: Buffer[] = [Buffer.from([0])]; + const tokens: string[] = []; + let size = 1; + for (const value of values) { + const text = Buffer.from(value, "utf16le"); + const length = text.length + 1; + if (length > 0x3fff) + throw new RangeError("Managed PE fixture user string is too long"); + const prefix = + length < 0x80 + ? Buffer.from([length]) + : Buffer.from([0x80 | (length >> 8), length & 0xff]); + tokens.push(`0x70${size.toString(16).padStart(6, "0")}`); + const item = Buffer.concat([ + prefix, + text, + Buffer.from([userStringFlag(text)]), + ]); + items.push(item); + size += item.length; + } + return { heap: Buffer.concat(items), tokens }; +}; + const metadataStreamHeader = ( relativeOffset: number, size: number, @@ -428,12 +480,28 @@ const metadataStreamHeader = ( }; const metadataRoot = ( - tables: Buffer, - strings: Buffer, - guid: Buffer, - blob: Buffer, + { + tables, + strings, + guid, + blob, + userStrings, + }: { + readonly tables: Buffer; + readonly strings: Buffer; + readonly guid: Buffer; + readonly blob: Buffer; + readonly userStrings?: Buffer | undefined; + }, tableStreamName: "#~" | "#-" = "#~", ): Buffer => { + const streams: readonly (readonly [string, Buffer])[] = [ + [tableStreamName, tables], + ["#Strings", strings], + ...(userStrings === undefined ? [] : [["#US", userStrings] as const]), + ["#GUID", guid], + ["#Blob", blob], + ]; const version = Buffer.from("v4.0.30319\0\0", "utf8"); const header = Buffer.concat([ u32(0x424a_5342), @@ -443,34 +511,27 @@ const metadataRoot = ( u32(version.length), version, u16(0), - u16(4), + u16(streams.length), ]); - const streamHeaderSize = - metadataStreamHeader(0, 0, tableStreamName).length + - metadataStreamHeader(0, 0, "#Strings").length + - metadataStreamHeader(0, 0, "#GUID").length + - metadataStreamHeader(0, 0, "#Blob").length; + const streamHeaderSize = streams.reduce( + (total, [name]) => total + metadataStreamHeader(0, 0, name).length, + 0, + ); let offset = align4(header.length + streamHeaderSize); - const tablesOffset = offset; - offset = align4(offset + tables.length); - const stringsOffset = offset; - offset = align4(offset + strings.length); - const guidOffset = offset; - offset = align4(offset + guid.length); - const blobOffset = offset; - const headers = Buffer.concat([ - metadataStreamHeader(tablesOffset, tables.length, tableStreamName), - metadataStreamHeader(stringsOffset, strings.length, "#Strings"), - metadataStreamHeader(guidOffset, guid.length, "#GUID"), - metadataStreamHeader(blobOffset, blob.length, "#Blob"), - ]); - const root = Buffer.alloc(align4(blobOffset + blob.length)); + const placed = streams.map(([name, data]) => { + const at = offset; + offset = align4(offset + data.length); + return { name, data, at }; + }); + const headers = Buffer.concat( + placed.map(({ name, data, at }) => + metadataStreamHeader(at, data.length, name), + ), + ); + const root = Buffer.alloc(offset); header.copy(root, 0); headers.copy(root, header.length); - tables.copy(root, tablesOffset); - strings.copy(root, stringsOffset); - guid.copy(root, guidOffset); - blob.copy(root, blobOffset); + for (const { data, at } of placed) data.copy(root, at); return root; }; @@ -951,17 +1012,18 @@ const buildManagedFixtureMetadata = ( (stringSize === 4 ? 0x01 : 0) | (guidSize === 4 ? 0x02 : 0) | (blobSize === 4 ? 0x04 : 0); - const metadata = metadataRoot( - tablesStream( + const metadata = metadataRoot({ + tables: tablesStream( rows, options.metadataValidMaskExtra ?? 0n, heapFlags, options.metadataExtraData, ), - strings.toBuffer(), - guidHeap(options.mvid), - blobs.toBuffer(), - ); + strings: strings.toBuffer(), + guid: guidHeap(options.mvid), + blob: blobs.toBuffer(), + userStrings: options.userStringHeap, + }); if (options.corruptMetadataSignature === true) metadata.writeUInt32LE(0, 0); return metadata; }; @@ -1189,10 +1251,12 @@ export const buildManagedMemberPointerFixture = ( if (fieldPointers !== null) rows.set(3, fieldPointers.map(u16)); if (methodPointers !== null) rows.set(5, methodPointers.map(u16)); return metadataRoot( - tablesStream(rows, 0n, 0), - strings.toBuffer(), - guidHeap(), - blobs.toBuffer(), + { + tables: tablesStream(rows, 0n, 0), + strings: strings.toBuffer(), + guid: guidHeap(), + blob: blobs.toBuffer(), + }, options.streamName ?? "#-", ); }); diff --git a/src/dotnet/ManagedReaderFailure.ts b/src/dotnet/ManagedReaderFailure.ts index 5e16c0ca4..e360571b4 100644 --- a/src/dotnet/ManagedReaderFailure.ts +++ b/src/dotnet/ManagedReaderFailure.ts @@ -43,3 +43,14 @@ export const uniqueManagedParseIssues = ( ): readonly ManagedParseIssue[] => [ ...new Map(issues.map((issue) => [JSON.stringify(issue), issue])).values(), ]; + +/** + * Append issue lists one issue at a time. Spreading a list into `push` passes + * one argument per issue, which overflows the stack on large malformed inputs. + */ +export const appendManagedParseIssues = ( + issues: ManagedParseIssue[], + ...found: readonly (readonly ManagedParseIssue[])[] +): void => { + for (const list of found) for (const issue of list) issues.push(issue); +}; diff --git a/src/dotnet/ManagedUserStrings.ts b/src/dotnet/ManagedUserStrings.ts new file mode 100644 index 000000000..cce2f0b65 --- /dev/null +++ b/src/dotnet/ManagedUserStrings.ts @@ -0,0 +1,120 @@ +import { + admitManagedProjection, + managedDecodeBudget, +} from "./ManagedDecodeBudget.js"; +import type { + ManagedMemberInspection, + ManagedParseIssue, +} from "../domain/managed/managedArtifact.js"; +import type { + ManagedMethod, + ManagedUserString, +} from "./ManagedMemberInspectorCore.js"; +import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { readMetadataUserString } from "./ManagedMetadataHeaps.js"; +import { readManagedValue } from "./ManagedReaderFailure.js"; + +type UserStringInventory = NonNullable; + +/** + * The literals with their own coverage, which never enters member coverage. + * Issues are unique by construction, so they are not deduplicated: each + * distinct token yields one failure or per-offset conformance issues, and a + * missing stream is one issue. + */ +const inventory = ( + literals: ManagedUserString[], + issues: ManagedParseIssue[], + scanned: boolean, +): UserStringInventory => ({ + state: issues.length === 0 && scanned ? "complete" : "partial", + literals, + issues, +}); + +/** Bookkeeping per distinct ldstr token: its set entry and its sorted slot. */ +const USER_STRING_TOKEN_BYTES = 128; + +/** Decode each distinct #US literal an inspected method loads, in token order. */ +export const parseUserStrings = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + parsed: { + readonly methods: readonly ManagedMethod[]; + /** Methods that may be in scope but whose CIL was not fully scanned. */ + readonly unscannedMethods: number; + /** Unreadable selected type names may hide a requested type. */ + readonly unreadableSelectedTypes?: number; + }, +): UserStringInventory => { + const { methods } = parsed; + const budget = managedDecodeBudget(layout); + const scanned = + parsed.unscannedMethods === 0 && + (parsed.unreadableSelectedTypes ?? 0) === 0; + const userStrings: ManagedUserString[] = []; + const issues: ManagedParseIssue[] = []; + const tokens = new Set(); + try { + // Only ldstr loads a literal; a malformed ldtoken can carry a 0x70 token. + for (const method of methods) + for (const { opcode, operand } of method.body.anchors) + if (opcode === "ldstr" && operand !== null && !tokens.has(operand)) { + budget.admitTransient(USER_STRING_TOKEN_BYTES, null); + tokens.add(operand); + } + // Tokens are fixed-width lowercase hex, so string order is token order. + const references: string[] = []; + for (const token of [...tokens].sort()) { + if (token.startsWith("0x70")) references.push(token); + // An operand from another table names no #US item, with or without + // the stream. + else + issues.push( + admitManagedProjection( + { + code: "invalid-heap-index", + scope: `metadata.#US:${token}`, + offset: null, + detail: "ldstr operand does not name an item inside #US", + }, + budget, + ), + ); + } + if (references.length > 0 && layout.userStrings === null) { + // A missing stream is one fact about the metadata, not one per token. + issues.push( + admitManagedProjection( + { + code: "missing-stream", + scope: "metadata.#US", + offset: null, + detail: `${String(references.length)} distinct ldstr operand(s) reference #US, but the metadata has no #US stream`, + }, + budget, + ), + ); + return inventory(userStrings, issues, scanned); + } + for (const token of references) { + const failures: ManagedParseIssue[] = []; + readManagedValue(() => { + const { + offset, + value, + issues: observed, + } = readMetadataUserString(bytes, layout, token); + for (const issue of observed) failures.push(issue); + userStrings.push( + admitManagedProjection({ token, offset, value }, budget), + ); + }, failures); + for (const failure of failures) + issues.push(admitManagedProjection(failure, budget)); + } + return inventory(userStrings, issues, scanned); + } finally { + budget.releaseTransient(); + } +}; diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 22b38c442..b93cffae5 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -22,9 +22,11 @@ import type { FridaSessionStatus, StartFridaSessionInput, } from "../application/frida/FridaInstrumentationPort.js"; +import { FridaScriptLoadCancelledError } from "../application/frida/FridaInstrumentationPort.js"; import type { OptionalProviderLoadFailure } from "../application/OptionalObservationProviders.js"; import { AnalysisAccessDeniedError, + AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, } from "../domain/analysisErrorCore.js"; @@ -58,7 +60,7 @@ interface ManagedScript { readonly name: string; readonly script: Script; readonly onMessage: (message: FridaMessage, data: Buffer | null) => void; - released: boolean; + state: "active" | "cleanup-pending" | "released"; } interface ManagedRemoteConnection { @@ -114,6 +116,9 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { readonly #deviceManagerLoader: () => Promise; #deviceManager: DeviceManager | undefined; #availabilityFailure: OptionalProviderLoadFailure | undefined; + readonly #operations = new Set>(); + #closing = false; + #closeAllPromise: Promise | undefined; constructor(options: FridaInstrumentationManagerOptions = {}) { this.#deviceManager = options.deviceManager; @@ -126,7 +131,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { return this.#availabilityFailure; } - async listDevices(remote?: FridaRemoteConnection): Promise< + listDevices(remote?: FridaRemoteConnection) { + return this.#withOperation("list_frida_devices", () => + this.#listDevices(remote), + ); + } + + async #listDevices(remote?: FridaRemoteConnection): Promise< Result< { readonly devices: readonly FridaDeviceObservation[]; @@ -176,7 +187,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } - async listProcesses(selector: FridaDeviceSelector): Promise< + listProcesses(selector: FridaDeviceSelector) { + return this.#withOperation("list_frida_processes", () => + this.#listProcesses(selector), + ); + } + + async #listProcesses(selector: FridaDeviceSelector): Promise< Result< { readonly deviceId: string; @@ -235,7 +252,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } - async startSession( + startSession(input: StartFridaSessionInput) { + return this.#withOperation("start_frida_session", () => + this.#startSession(input), + ); + } + + async #startSession( input: StartFridaSessionInput, ): Promise> { let manager: DeviceManager | undefined; @@ -326,16 +349,25 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise> { - return this.#withSessionLock(sessionId, () => - this.#loadScript(sessionId, source), + return this.#withOperation( + "load_frida_script", + () => + this.#withSessionLock(sessionId, () => + this.#loadScript(sessionId, source, signal), + ), + signal, ); } async #loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise> { + if (signal?.aborted) + return err(cancelledOperation("load_frida_script", signal)); const managed = this.#sessions.get(sessionId); if (managed === undefined || managed.state === "detached") return err(unknownFridaSession("load_frida_script", sessionId)); @@ -368,6 +400,8 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ); } + if (signal?.aborted) + return err(cancelledOperation("load_frida_script", signal)); const sourceSha256 = createHash("sha256") .update(scriptSource) .digest("hex"); @@ -387,19 +421,36 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { name: scriptName, script: nativeScript, onMessage, - released: false, + state: "active", }; managed.scripts.set(scriptId, entry); nativeScript.message.connect(onMessage); - await nativeScript.load(); - return ok({ + if (!signal?.aborted) await nativeScript.load(); + const observation: FridaScriptObservation = { scriptId, sourceKind: source.sourceKind, sourcePath: source.sourceKind === "file" ? source.path : null, sourceSha256, messages: managed.messagesByScript.get(scriptId)?.toArray() ?? [], messagesTruncated: managed.messagesTruncated, - }); + }; + if (signal?.aborted) { + const cleanupReason = await this.#releaseScript(managed, entry); + return err( + new FridaScriptLoadCancelledError(observation, { + cause: signal.reason, + ...(cleanupReason === undefined + ? {} + : { + cleanup: { + reason: cleanupReason, + resources: [sessionId, scriptId], + }, + }), + }), + ); + } + return ok(observation); } catch (cause: unknown) { const entry = managed.scripts.get(scriptId); const cleanupReason = @@ -418,9 +469,21 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } - resumeSession(sessionId: string): Promise> { - return this.#withSessionLock(sessionId, () => - this.#resumeSession(sessionId), + resumeSession( + sessionId: string, + signal?: AbortSignal, + ): Promise> { + return this.#withOperation( + "resume_frida_session", + () => + this.#withSessionLock(sessionId, () => + signal?.aborted + ? Promise.resolve( + err(cancelledOperation("resume_frida_session", signal)), + ) + : this.#resumeSession(sessionId), + ), + signal, ); } @@ -438,6 +501,26 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { "Only a paused process spawned by this session can be resumed.", ), ); + const pendingScripts = [...managed.scripts.values()].filter( + (entry) => entry.state === "cleanup-pending" && !entry.script.isDestroyed, + ); + if (pendingScripts.length > 0) { + const reason = + "Frida script cleanup is incomplete; unload the retained scripts or close the session before resuming."; + return err( + new AnalysisCapabilityUnavailableError( + "frida", + "resume_frida_session", + reason, + { + cleanup: { + reason, + resources: pendingScripts.map((entry) => entry.id), + }, + }, + ), + ); + } try { await managed.device.resume(managed.pid); managed.state = "running"; @@ -452,9 +535,19 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { unloadScript( sessionId: string, scriptId: string, + signal?: AbortSignal, ): Promise> { - return this.#withSessionLock(sessionId, () => - this.#unloadScript(sessionId, scriptId), + return this.#withOperation( + "unload_frida_script", + () => + this.#withSessionLock(sessionId, () => + signal?.aborted + ? Promise.resolve( + err(cancelledOperation("unload_frida_script", signal)), + ) + : this.#unloadScript(sessionId, scriptId), + ), + signal, ); } @@ -487,13 +580,14 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { parentSessionReleased = false, ): Promise { if (parentSessionReleased || entry.script.isDestroyed) - entry.released = true; - if (!entry.released) { + entry.state = "released"; + if (entry.state !== "released") { + entry.state = "cleanup-pending"; try { await entry.script.unload(); - entry.released = true; + entry.state = "released"; } catch (cause: unknown) { - if (entry.script.isDestroyed) entry.released = true; + if (entry.script.isDestroyed) entry.state = "released"; else return sanitizeError(cause, managed.remote); } } @@ -629,7 +723,17 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { return ok({ targetResumeObserved }); } - async closeAll(): Promise { + closeAll(): Promise { + this.#closing = true; + this.#closeAllPromise ??= this.#closeAll().catch((cause: unknown) => { + this.#closeAllPromise = undefined; + throw cause; + }); + return this.#closeAllPromise; + } + + async #closeAll(): Promise { + await Promise.allSettled(this.#operations); const startupFailures = ( await Promise.all( [...this.#pendingStartups].map((pending) => @@ -720,6 +824,46 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } + #withOperation( + name: string, + operation: () => Promise>, + signal?: AbortSignal, + ): Promise> { + if (this.#closing) + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "frida", + name, + "Frida instrumentation is shutting down.", + ), + ), + ); + let start = () => {}; + const pending = new Promise>( + (resolve, reject) => { + start = () => { + try { + resolve( + signal?.aborted + ? err(cancelledOperation(name, signal)) + : operation(), + ); + } catch (cause: unknown) { + reject(cause); + } + }; + }, + ); + this.#operations.add(pending); + const release = () => { + this.#operations.delete(pending); + }; + void pending.then(release, release); + start(); + return pending; + } + async #withSessionLock( sessionId: string, operation: () => Promise, @@ -1217,6 +1361,18 @@ const providerError = ( remote?: FridaRemoteConnection, cleanupReason?: string, ): AnalysisError => { + if (cause instanceof AnalysisCancelledError) + return new AnalysisCancelledError(operation, { + cause: cause.cause, + ...(cleanupReason === undefined + ? {} + : { + cleanup: { + reason: cleanupReason, + resources: ["Frida provider operation"], + }, + }), + }); const reason = sanitizeError(cause, remote); return new AnalysisCapabilityUnavailableError("frida", operation, reason, { cause: remote === undefined ? cause : new Error(reason), @@ -1234,6 +1390,12 @@ const providerError = ( const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +const cancelledOperation = ( + operation: string, + signal: AbortSignal, +): AnalysisCancelledError => + new AnalysisCancelledError(operation, { cause: signal.reason }); + interface CapturedMessage { readonly observation: JsonValue; readonly scriptId: string; diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts new file mode 100644 index 000000000..5102ad84e --- /dev/null +++ b/src/gdb/GdbSessionManager.ts @@ -0,0 +1,2057 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { isUtf8 } from "node:buffer"; +import { readFile, readlink, realpath, stat } from "node:fs/promises"; + +import { + AnalysisCapabilityUnavailableError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { + AnalysisError, + type AnalysisErrorOptions, +} from "../domain/analysisErrorBase.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, + type OwnedProviderProcessSpawnOptions, + type SpawnedOwnedProviderProcess, +} from "../process/ProviderProcess.js"; +import { + closeOwnedDebuggerSessions, + stopOwnedDebuggerProcess, + type OwnedDebuggerDetachResult, + type OwnedInferiorIdentity, + type ProcessStartTimeReader, + verifyOwnedInferiorsEnded, +} from "../process/ownedDebuggerSession.js"; +import { parseLinuxProcessStartTime } from "../process/ProcessOwnershipObservation.js"; + +const STARTUP_TIMEOUT_MS = 10_000; +const COMMAND_TIMEOUT_MS = 120_000; +const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; +const MAX_COMMAND_OUTPUT_RECORDS = 16_384; +const MAX_MI_LINE_BYTES = 16 * 1024 * 1024; +const MAX_RECENT_MI_LINE_BYTES = 64 * 1024; +const MAX_DIAGNOSTIC_BYTES = 64 * 1024; +const DEFAULT_MAX_SESSIONS = 8; +const MAX_PENDING_GDB_COMMANDS = 16; +const RECENT_RECORD_TRUNCATION_MARKER = "[recent record truncated]"; + +export const GDB_PROVIDER_IDENTITY = { + id: "gnu.gdb", + name: "GNU GDB", + version: null, +} as const; + +interface PendingCommand { + readonly token: number; + readonly command: string; + readonly evidenceCommand: string; + readonly lines: string[]; + outputBytes: number; + outputTruncated: boolean; + outputInvalidUtf8: boolean; + terminalRecord: string | undefined; + awaitingInferiorStop: boolean; + readonly resolve: (result: GdbCommandResult) => void; + readonly reject: (error: Error) => void; + readonly cleanup: () => void; + timer: ReturnType; +} + +export interface GdbCommandResult { + readonly command: string; + readonly mi: string; + readonly records: readonly string[]; + readonly console: string; + readonly target: string; + readonly log: string; + readonly process_exit_observed?: boolean; + readonly process_exit_code?: number | null; + readonly process_exit_signal?: NodeJS.Signals | null; + readonly output_truncated: boolean; + readonly completion_status: "complete" | "unknown"; + readonly output_invalid_utf8?: true; +} + +class GdbCommandTimeoutError extends AnalysisTimeoutError { + constructor( + readonly partial: GdbCommandResult, + readonly evidence: Evidence, + timeoutMs: number, + ) { + super("gdb_console", timeoutMs); + } +} + +const truncateUtf8RecordPrefix = (line: string): string => { + const budget = + MAX_RECENT_MI_LINE_BYTES - + Buffer.byteLength(RECENT_RECORD_TRUNCATION_MARKER, "utf8"); + let prefix = ""; + let retainedBytes = 0; + for (const codePoint of line) { + const codePointBytes = Buffer.byteLength(codePoint, "utf8"); + if (retainedBytes + codePointBytes > budget) break; + prefix += codePoint; + retainedBytes += codePointBytes; + } + return `${prefix}${RECENT_RECORD_TRUNCATION_MARKER}`; +}; + +/** @internal Pure parser for live PIDs in recursive GDB MI thread groups. */ +export const gdbThreadGroupPids = ( + mi: string, +): readonly string[] | undefined => { + const marker = mi.indexOf("groups=["); + if (marker < 0) return undefined; + const start = marker + "groups=[".length; + let squareDepth = 1; + let objectDepth = 0; + let quoted = false; + let escaped = false; + const pids: string[] = []; + let fieldStart = start; + let pidStart: number | undefined; + let hasLivePid = false; + let currentPid: string | undefined; + for (let index = start; index < mi.length; index += 1) { + const character = mi[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') { + quoted = false; + if (pidStart !== undefined) { + const pid = mi.slice(pidStart, index); + if (!/^[1-9]\d*$/u.test(pid)) return undefined; + hasLivePid = true; + currentPid = pid; + pidStart = undefined; + } + } + continue; + } + if (character === '"') { + if ( + squareDepth === 1 && + objectDepth === 1 && + mi.slice(fieldStart, index) === "pid=" + ) { + if (hasLivePid) return undefined; + pidStart = index + 1; + } + quoted = true; + continue; + } + if (character === "[") squareDepth += 1; + else if (character === "]") { + squareDepth -= 1; + if (squareDepth === 0) { + if (objectDepth !== 0) return undefined; + const body = mi.slice(start, index); + if (body.includes("groups=[")) return undefined; + return pids; + } + } else if (character === "{") { + if (squareDepth === 1 && objectDepth === 0) { + hasLivePid = false; + currentPid = undefined; + fieldStart = index + 1; + } + objectDepth += 1; + } else if (character === "}") { + if (squareDepth === 1 && objectDepth === 1) { + const field = mi.slice(fieldStart, index); + if (field.startsWith("pid=") && !/^pid="[1-9]\d*"$/u.test(field)) + return undefined; + } + objectDepth -= 1; + if (objectDepth < 0) return undefined; + if (squareDepth === 1 && objectDepth === 0 && hasLivePid) { + if (currentPid === undefined) return undefined; + pids.push(currentPid); + } + } else if (character === "," && squareDepth === 1 && objectDepth === 1) { + const field = mi.slice(fieldStart, index); + if (field.startsWith("pid=") && !/^pid="[1-9]\d*"$/u.test(field)) + return undefined; + fieldStart = index + 1; + } + } + return undefined; +}; + +/** @internal Pure count of recursive GDB MI groups with live PIDs. */ +export const gdbThreadGroupCount = (mi: string): number | undefined => { + const pids = gdbThreadGroupPids(mi); + return pids?.length; +}; + +const verifyGdbInferiorOutputSink = async ( + pid: string, + selectedTty: string, + rememberIdentity: (identity: OwnedInferiorIdentity) => void, +): Promise => { + if (process.platform !== "linux") + throw new Error( + "GDB cannot confirm live inferior output descriptors outside Linux /proc.", + ); + const statPath = `/proc/${pid}/stat`; + const before = parseLinuxProcessStartTime(await readFile(statPath, "utf8")); + if (before === undefined) + throw new Error("GDB could not establish the live inferior identity."); + const ttyPath = await realpath(selectedTty); + const tty = await stat(ttyPath); + if ((tty.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR) + throw new Error("The selected GDB output sink is not a character device."); + for (const descriptor of ["1", "2"]) { + const descriptorPath = `/proc/${pid}/fd/${descriptor}`; + const destination = await readlink(descriptorPath); + const observed = await stat(descriptorPath); + if ( + observed.dev !== tty.dev || + observed.ino !== tty.ino || + (observed.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR || + (await realpath(destination)) !== ttyPath + ) + throw new Error( + `GDB inferior descriptor ${descriptor} no longer points to the selected TTY.`, + ); + } + const after = parseLinuxProcessStartTime(await readFile(statPath, "utf8")); + if (after !== before) + throw new Error( + "The GDB inferior identity changed during descriptor readback.", + ); + rememberIdentity({ pid, startTime: before }); +}; + +class GdbCommandProcessExitError extends Error { + constructor(readonly partial: GdbCommandResult) { + super( + `GDB exited before returning the active MI command result (exit code ${String(partial.process_exit_code)}, signal ${String(partial.process_exit_signal)}).`, + ); + } +} + +class GdbProtocolFailureError extends Error { + constructor( + message: string, + readonly partial: GdbCommandResult, + ) { + super(message); + } +} + +/** @internal Convert an unknown boundary failure without dropping non-Error values. */ +export const gdbErrorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +interface GdbSession { + readonly id: string; + readonly miVersion: "mi3" | "mi2"; + readonly launched: SpawnedOwnedProviderProcess; + readonly supervisor: ProviderProcessSupervisor; + /** Confirms target disposition and any durable sink before process cleanup. */ + readonly detachInferior: () => Promise; + readonly inferiorStateUnknown: () => boolean; + readonly verifyInferiorsEndedAfterExit: () => Promise; + readonly knownInferiorIdentities: Map; + readonly inferiorTty: string | undefined; + readonly maxCommandOutputBytes: number; + readonly stdoutSettled: Promise; + readonly settleStdout: () => void; + inferiorTtyVerified: boolean; + noInferiorVerified: boolean; + stopAttempt: ReturnType | undefined; + readonly pending: Map; + readonly cleanupFailures: Set; + readonly lines: string[]; + readonly bufferChunks: Buffer[]; + bufferTail: Buffer | undefined; + bufferTailBytes: number; + bufferBytes: number; + invalidUtf8Observed: boolean; + promptTail: string; + ready: boolean; + exited: boolean; + exitCode: number | null; + exitSignal: NodeJS.Signals | null; + nextToken: number; + startupResolve: (() => void) | undefined; + startupReject: ((error: Error) => void) | undefined; + commandTail: Promise; + pendingCommandCount: number; + protocolError: string | undefined; + recentRecordsTruncated: boolean; +} + +/** Owns persistent GDB/MI sessions independently from the active binary target. */ +export class GdbSessionManager { + readonly #sessions = new Map(); + readonly #activeStarts = new Map< + AbortController, + Promise< + Result< + { readonly session_id: string; readonly mi_version: "mi3" | "mi2" }, + AnalysisError + > + > + >(); + readonly #command: string; + readonly #environment: NodeJS.ProcessEnv; + readonly #commandTimeoutMs: number; + readonly #startupTimeoutMs: number; + readonly #platform: NodeJS.Platform; + readonly #stopProcess: typeof stopOwnedDebuggerProcess; + readonly #spawnProcess: typeof spawnOwnedProviderProcess; + readonly #verifyInferiorOutputSink: ( + pid: string, + selectedTty: string, + rememberIdentity: (identity: OwnedInferiorIdentity) => void, + ) => Promise; + readonly #readInferiorProcessStartTime: ProcessStartTimeReader | undefined; + readonly #maxSessions: number; + readonly #maxCommandOutputBytes: number; + #sessionReservations = 0; + #closing = false; + #closePromise: Promise | undefined; + + constructor( + options: { + readonly environment?: Readonly; + readonly commandTimeoutMs?: number; + readonly startupTimeoutMs?: number; + readonly platform?: NodeJS.Platform; + readonly stopProcess?: typeof stopOwnedDebuggerProcess; + readonly spawnProcess?: typeof spawnOwnedProviderProcess; + /** @internal Tests may replace only the /proc and TTY readback boundary. */ + readonly verifyInferiorOutputSink?: ( + pid: string, + selectedTty: string, + rememberIdentity: (identity: OwnedInferiorIdentity) => void, + ) => Promise; + /** @internal Read-only process identity observer for lifecycle tests. */ + readonly readInferiorProcessStartTime?: ProcessStartTimeReader; + readonly maxSessions?: number; + /** @internal Test seam; production callers should use the 16 MiB default. */ + readonly maxCommandOutputBytes?: number; + } = {}, + ) { + this.#environment = { ...(options.environment ?? process.env) }; + this.#command = this.#environment.REA_GDB_COMMAND ?? "gdb"; + this.#commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS; + this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; + this.#platform = options.platform ?? process.platform; + this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess; + this.#spawnProcess = options.spawnProcess ?? spawnOwnedProviderProcess; + this.#verifyInferiorOutputSink = + options.verifyInferiorOutputSink ?? verifyGdbInferiorOutputSink; + this.#readInferiorProcessStartTime = options.readInferiorProcessStartTime; + this.#maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; + this.#maxCommandOutputBytes = + options.maxCommandOutputBytes ?? MAX_COMMAND_OUTPUT_BYTES; + } + + async start( + signal?: AbortSignal, + options: { readonly inferiorTty?: string } = {}, + ): Promise< + Result< + { readonly session_id: string; readonly mi_version: "mi3" | "mi2" }, + AnalysisError + > + > { + if ( + options.inferiorTty !== undefined && + (options.inferiorTty.length === 0 || options.inferiorTty.includes("\0")) + ) + return err( + new AnalysisInputError("start_gdb_session", undefined, [ + { + path: ["inferior_tty"], + reason: "invalid_value", + message: + "The inferior TTY path must be non-empty and must not contain NUL.", + }, + ]), + ); + if (this.#closing || signal?.aborted) + return err(new AnalysisCancelledError("start_gdb_session")); + if (this.#platform !== "linux") + return err( + new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + this.#platform === "win32" + ? "Persistent Windows GDB sessions are unavailable because owned Job Object cleanup can terminate debugger inferiors." + : "Persistent GDB sessions are available only on Linux because REA verifies live inferior output descriptors through /proc.", + ), + ); + const controller = new AbortController(); + const relayAbort = (): void => controller.abort(signal?.reason); + signal?.addEventListener("abort", relayAbort, { once: true }); + if (signal?.aborted) relayAbort(); + const tracked = Promise.resolve() + .then(() => this.#startWithReservation(controller.signal, options)) + .finally(() => { + signal?.removeEventListener("abort", relayAbort); + this.#activeStarts.delete(controller); + }); + this.#activeStarts.set(controller, tracked); + return tracked; + } + + async #startWithReservation( + signal: AbortSignal, + options: { readonly inferiorTty?: string } = {}, + ): Promise< + Result< + { readonly session_id: string; readonly mi_version: "mi3" | "mi2" }, + AnalysisError + > + > { + if (signal?.aborted) + return err(new AnalysisCancelledError("start_gdb_session")); + const occupied = this.#sessions.size + this.#sessionReservations; + if (occupied >= this.#maxSessions) + return err( + new AnalysisResourceConstraintError( + "start_gdb_session", + "memory", + "GDB session capacity reached.", + { + max_sessions: this.#maxSessions, + active_or_reserved_sessions: occupied, + }, + ), + ); + this.#sessionReservations += 1; + let reservationHeld = true; + const releaseReservation = (): void => { + if (!reservationHeld) return; + reservationHeld = false; + this.#sessionReservations -= 1; + }; + try { + return await this.#startReserved(releaseReservation, signal, options); + } finally { + releaseReservation(); + } + } + + async #startReserved( + releaseReservation: () => void, + signal: AbortSignal, + options: { readonly inferiorTty?: string } = {}, + ): Promise< + Result< + { readonly session_id: string; readonly mi_version: "mi3" | "mi2" }, + AnalysisError + > + > { + const id = randomUUID(); + const mi3 = await this.#startProtocol( + id, + "mi3", + releaseReservation, + signal, + options.inferiorTty, + ); + return mi3.ok || signal.aborted || this.#sessions.has(id) + ? mi3 + : this.#startProtocol( + id, + "mi2", + releaseReservation, + signal, + options.inferiorTty, + ); + } + + async #startProtocol( + id: string, + miVersion: "mi3" | "mi2", + releaseReservation: () => void, + signal: AbortSignal, + inferiorTty?: string, + ): Promise< + Result< + { readonly session_id: string; readonly mi_version: "mi3" | "mi2" }, + AnalysisError + > + > { + let launched: SpawnedOwnedProviderProcess; + try { + const spawnOptions: OwnedProviderProcessSpawnOptions = { + command: this.#command, + arguments: ["--nx", "--quiet", `--interpreter=${miVersion}`], + runId: randomUUID(), + stdin: "pipe", + hostEnvironment: this.#environment, + signal, + }; + launched = await this.#spawnProcess(spawnOptions); + } catch (cause) { + if (signal?.aborted || cause instanceof AnalysisCancelledError) + return err(new AnalysisCancelledError("start_gdb_session", { cause })); + return err( + new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + cause instanceof Error ? cause.message : "Unable to start GDB", + ), + ); + } + const supervisor = new ProviderProcessSupervisor( + { process: launched.process, ownsProcessLifetime: false }, + { captureStdout: false, maxDiagnosticBytes: MAX_DIAGNOSTIC_BYTES }, + ); + let settleStdout!: () => void; + const stdoutSettled = new Promise((resolve) => { + settleStdout = resolve; + }); + const session: GdbSession = { + id, + miVersion, + launched, + supervisor, + detachInferior: () => this.#detachInferior(session), + inferiorStateUnknown: () => !session.noInferiorVerified, + verifyInferiorsEndedAfterExit: async () => { + const ended = await verifyOwnedInferiorsEnded( + session.knownInferiorIdentities.values(), + this.#readInferiorProcessStartTime, + ); + if (ended) session.knownInferiorIdentities.clear(); + return ended; + }, + knownInferiorIdentities: new Map(), + inferiorTty, + maxCommandOutputBytes: this.#maxCommandOutputBytes, + stdoutSettled, + settleStdout, + inferiorTtyVerified: false, + // GDB is launched with --nx and receives no target-execution commands + // during startup, so no inferior can exist until execute() is called. + noInferiorVerified: true, + stopAttempt: undefined, + pending: new Map(), + cleanupFailures: new Set(), + lines: [], + bufferChunks: [], + bufferTail: undefined, + bufferTailBytes: 0, + bufferBytes: 0, + invalidUtf8Observed: false, + promptTail: "", + ready: false, + exited: false, + exitCode: null, + exitSignal: null, + nextToken: 1, + startupResolve: undefined, + startupReject: undefined, + commandTail: Promise.resolve(), + pendingCommandCount: 0, + protocolError: undefined, + recentRecordsTruncated: false, + }; + launched.process.stdout?.on("data", (chunk: string | Buffer) => + this.#receive( + session, + typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk, + ), + ); + if (launched.process.stdout === null) settleStdout(); + else launched.process.stdout.once("end", settleStdout); + launched.process.stdin?.on("error", (cause: Error) => + this.#failProtocol(session, `GDB stdin stream failed: ${cause.message}`), + ); + launched.process.stdin?.once("close", () => + this.#failProtocol(session, "GDB stdin stream closed unexpectedly."), + ); + launched.process.once("exit", (code, signalCode) => { + session.exited = true; + session.exitCode = code; + session.exitSignal = signalCode; + void this.#waitForStdoutSettlement(session).then(() => + session.startupReject?.( + new Error( + `GDB exited during startup (exit code ${String(code)}, signal ${String(signalCode)}).`, + ), + ), + ); + void this.#settleExitedCommands(session, code, signalCode); + }); + let startupTimer!: ReturnType; + let startupAbortListener!: () => void; + try { + await new Promise((resolve, reject) => { + session.startupResolve = resolve; + session.startupReject = reject; + startupAbortListener = () => + reject(new AnalysisCancelledError("start_gdb_session")); + signal?.addEventListener("abort", startupAbortListener, { + once: true, + }); + if (signal?.aborted) startupAbortListener(); + startupTimer = setTimeout( + () => + reject( + new AnalysisTimeoutError( + "start_gdb_session", + this.#startupTimeoutMs, + { + cause: new Error( + "GDB did not emit the MI prompt before the startup deadline", + ), + }, + ), + ), + this.#startupTimeoutMs, + ); + startupTimer.unref(); + }); + session.ready = true; + if (signal?.aborted) + throw new AnalysisCancelledError("start_gdb_session"); + const autoLoad = await this.#request( + session, + "-gdb-set auto-load off", + signal, + "start_gdb_session", + ); + if (!/^\d+\^done(?:,|$)/u.test(autoLoad.mi)) { + throw new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + `GDB rejected the required automatic-loading disable command (${autoLoad.mi}); the session was not started.`, + ); + } + if (inferiorTty !== undefined) { + const configured = await this.#request( + session, + `-inferior-tty-set ${miQuote(inferiorTty)}`, + signal, + "start_gdb_session", + ); + if ( + configured.completion_status !== "complete" || + configured.output_truncated || + !/^\d+\^done(?:,|$)/u.test(configured.mi) + ) + throw new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + "GDB did not confirm the inferior TTY configuration.", + ); + const observed = await this.#request( + session, + "-inferior-tty-show", + signal, + "start_gdb_session", + ); + const reportedTty = + observed.completion_status === "complete" && + !observed.output_truncated + ? miStringField(observed.mi, "inferior_tty_terminal") + : undefined; + if ( + !/^\d+\^done(?:,|$)/u.test(observed.mi) || + reportedTty !== inferiorTty + ) + throw new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + "GDB did not report the exact caller-selected inferior TTY after configuration.", + ); + session.inferiorTtyVerified = true; + } + if (signal?.aborted) + throw new AnalysisCancelledError("start_gdb_session"); + // Give queued exit notifications a turn before publishing startup. + await new Promise((resolve) => setImmediate(resolve)); + if (signal?.aborted) + throw new AnalysisCancelledError("start_gdb_session"); + if (session.exited || session.protocolError !== undefined) + throw new Error( + session.protocolError ?? "GDB exited during startup readiness.", + ); + // Before this point no target command can run. Once a usable session is + // published, the inferior state is no longer inferred from startup phase. + session.noInferiorVerified = false; + this.#sessions.set(id, session); + releaseReservation(); + return ok({ session_id: id, mi_version: miVersion }); + } catch (cause) { + const startupExited = + cause instanceof GdbCommandProcessExitError || session.exited; + const processExit = + cause instanceof GdbCommandProcessExitError + ? cause.partial + : startupExited + ? { + command: "GDB startup", + mi: "", + records: [...session.lines], + console: session.lines + .filter((line) => line.startsWith("~")) + .join("\n"), + target: session.lines + .filter((line) => line.startsWith("@")) + .join("\n"), + log: session.lines + .filter((line) => line.startsWith("&")) + .join("\n"), + process_exit_observed: true, + process_exit_code: session.exitCode, + process_exit_signal: session.exitSignal, + output_truncated: session.recentRecordsTruncated, + completion_status: "unknown" as const, + } + : undefined; + const startupEvidenceResult = + processExit ?? + (session.lines.length > 0 + ? { + command: "GDB startup", + mi: "", + records: [...session.lines], + console: session.lines + .filter((line) => line.startsWith("~")) + .join("\n"), + target: session.lines + .filter((line) => line.startsWith("@")) + .join("\n"), + log: session.lines + .filter((line) => line.startsWith("&")) + .join("\n"), + output_truncated: session.recentRecordsTruncated, + completion_status: "unknown" as const, + } + : undefined); + const startupEvidencePayload = + startupEvidenceResult === undefined + ? undefined + : { + ...startupEvidenceResult, + ...(session.invalidUtf8Observed + ? { output_invalid_utf8: true as const } + : {}), + }; + const startupEvidence = + startupEvidencePayload === undefined + ? undefined + : createEvidence(undefined, GDB_PROVIDER_IDENTITY, { + predicateType: "rea.debugger.startup-observation", + operation: "start_gdb_session", + parameters: { + mi_version: miVersion, + command: startupEvidencePayload.command, + }, + result: { + ...startupEvidencePayload, + records: [...startupEvidencePayload.records], + }, + rawResult: { + ...startupEvidencePayload, + records: [...startupEvidencePayload.records], + }, + subjectUnavailableReason: + "No usable debugger session became ready during GDB startup.", + limitations: [ + "GDB startup failed before a usable session could be established; captured startup records are incomplete.", + ...(session.invalidUtf8Observed + ? [ + "GDB startup output contained invalid UTF-8 bytes; decoded records contain replacement characters.", + ] + : []), + ], + }); + const processOutput = session.supervisor.snapshot(); + const capturedOutput = { + stdout: processOutput.stdout.text, + stderr: processOutput.stderr.text, + truncated: + processOutput.diagnosticTruncated === true || + processOutput.stdout.observedBytes > processOutput.stdout.bytes || + processOutput.stderr.observedBytes > processOutput.stderr.bytes, + stdout_bytes: processOutput.stdout.observedBytes, + stderr_bytes: processOutput.stderr.observedBytes, + exit_code: processOutput.exitCode ?? null, + signal: processOutput.signal ?? null, + }; + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") { + this.#sessions.set(id, session); + releaseReservation(); + const cleanup = { reason: stopped.reason, resources: [session.id] }; + const details = { + cause, + capturedOutput, + cleanup, + ...(startupEvidence === undefined + ? {} + : { partialObservation: startupEvidence }), + }; + if (cause instanceof AnalysisTimeoutError) + return err( + new AnalysisTimeoutError(cause.operation, cause.timeoutMs, details), + ); + if (cause instanceof AnalysisCancelledError) + return err(new AnalysisCancelledError(cause.operation, details)); + const startupFailureMessage = gdbErrorMessage(cause); + return err( + new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + `GDB startup failed (${startupFailureMessage}) and its owned process could not be confirmed stopped: ${stopped.reason}`, + details, + ), + ); + } + const errorDetails = { + cause, + capturedOutput, + ...(startupEvidence === undefined + ? {} + : { partialObservation: startupEvidence }), + }; + const error = + signal?.aborted || cause instanceof AnalysisCancelledError + ? new AnalysisCancelledError("start_gdb_session", errorDetails) + : cause instanceof AnalysisTimeoutError + ? new AnalysisTimeoutError( + cause instanceof GdbCommandTimeoutError + ? "start_gdb_session" + : cause.operation, + cause.timeoutMs, + errorDetails, + ) + : cause instanceof AnalysisCapabilityUnavailableError + ? new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + cause.message, + errorDetails, + ) + : session.protocolError !== undefined + ? new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + session.protocolError, + errorDetails, + ) + : new AnalysisCapabilityUnavailableError( + "gdb", + "start_gdb_session", + `GDB startup failed (${gdbErrorMessage(cause)}).`, + errorDetails, + ); + return err(error); + } finally { + clearTimeout(startupTimer); + signal?.removeEventListener("abort", startupAbortListener); + } + } + + async execute( + sessionId: string, + command: string, + signal?: AbortSignal, + ): Promise< + Result< + { readonly result: GdbCommandResult; readonly evidence: Evidence }, + AnalysisError + > + > { + const session = this.#sessions.get(sessionId); + if ( + this.#closing || + session === undefined || + !session.ready || + session.exited || + session.protocolError !== undefined + ) + return err( + new AnalysisInputError("gdb_console", undefined, [ + { + path: ["session_id"], + reason: "invalid_value", + message: "GDB session is unavailable or closed.", + }, + ]), + ); + if (signal?.aborted) return err(new AnalysisCancelledError("gdb_console")); + try { + const completed = await this.#serialize( + session, + async () => { + if (session.exited || session.protocolError !== undefined) + return err(new AnalysisInputError("gdb_console")); + session.noInferiorVerified = false; + session.knownInferiorIdentities.clear(); + if (commandMayChangeInferiorOutput(command)) + session.inferiorTtyVerified = false; + try { + const result = await this.#request( + session, + `-interpreter-exec console ${miQuote(command)}`, + signal, + "gdb_console", + command, + ); + const evidence = createEvidence( + undefined, + { id: "gnu.gdb", name: "GNU GDB", version: null }, + { + predicateType: "rea.debugger.console-observation", + operation: "gdb_console", + parameters: { session_id: sessionId, command }, + result: { + command, + mi: result.mi, + records: [...result.records], + console: result.console, + target: result.target, + log: result.log, + output_truncated: result.output_truncated, + ...(result.output_invalid_utf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: result.completion_status, + }, + rawResult: { + command, + mi: result.mi, + records: [...result.records], + console: result.console, + target: result.target, + log: result.log, + output_truncated: result.output_truncated, + ...(result.output_invalid_utf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: result.completion_status, + }, + subjectUnavailableReason: + "The active GDB inferior identity is not established by the debugger session identifier.", + limitations: [ + "MI records are returned as decoded text; their semantics depend on GDB version and target.", + ...(result.output_invalid_utf8 + ? [ + "MI stream bytes were not valid UTF-8; returned text contains replacement characters.", + ] + : []), + "The unrestricted GDB console can access local shell, filesystem, network, and target controls.", + ], + }, + ); + return ok({ result, evidence }); + } catch (cause) { + if (cause instanceof AnalysisCancelledError) return err(cause); + if (cause instanceof AnalysisCapabilityUnavailableError) + return err(cause as AnalysisError); + if (cause instanceof GdbCommandTimeoutError) { + const result = cause.partial; + return ok({ result, evidence: cause.evidence }); + } + if (session.protocolError !== undefined) + return err(await this.#stopAfterProtocolFailure(session, cause)); + if (cause instanceof GdbCommandProcessExitError) { + const processExit = cause as GdbCommandProcessExitError; + const result: GdbCommandResult = { + ...processExit.partial, + command, + }; + const evidence = createEvidence( + undefined, + { id: "gnu.gdb", name: "GNU GDB", version: null }, + { + predicateType: "rea.debugger.console-observation", + operation: "gdb_console", + parameters: { session_id: sessionId, command }, + result: { ...result, records: [...result.records] }, + rawResult: { ...result, records: [...result.records] }, + subjectUnavailableReason: + "The active GDB inferior identity is not established by the debugger session identifier.", + limitations: [ + "GDB exited before returning a correlated MI result record; only records retained for the active command are returned raw.", + ...(result.output_truncated + ? [ + "Retained MI records are a truncated prefix; output completeness is unknown.", + ] + : []), + ], + }, + ); + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "gdb_console", + stopped.reason, + { cause, partialObservation: evidence }, + ), + ); + return ok({ result, evidence }); + } + return err( + new AnalysisTimeoutError("gdb_console", this.#commandTimeoutMs, { + cause, + }), + ); + } + }, + signal, + "gdb_console", + ); + if (!completed.ok && completed.error.cleanupIncomplete) + session.cleanupFailures.add(completed.error); + return completed; + } catch (cause) { + const error = + cause instanceof AnalysisError + ? cause + : new AnalysisCancelledError("gdb_console", { cause }); + if (error.cleanupIncomplete) session.cleanupFailures.add(error); + return err(error); + } + } + + status(sessionId: string): + | { + readonly session_id: string; + readonly mi_version: "mi3" | "mi2"; + readonly state: "ready" | "closed"; + readonly recent_mi_records: readonly string[]; + readonly recent_mi_records_truncated: boolean; + readonly mi_invalid_utf8: boolean; + readonly stderr: string; + readonly stderr_observed_bytes: number; + readonly stderr_truncated: boolean; + readonly stderr_invalid_utf8: boolean; + readonly diagnostics_truncated: boolean; + readonly inferior_tty: string | null; + readonly inferior_tty_verified: boolean; + } + | undefined { + const session = this.#sessions.get(sessionId); + if (session === undefined) return undefined; + const diagnostics = session.supervisor.snapshot(); + return { + session_id: session.id, + mi_version: session.miVersion, + state: + session.exited || session.protocolError !== undefined || !session.ready + ? "closed" + : "ready", + recent_mi_records: [...session.lines], + recent_mi_records_truncated: session.recentRecordsTruncated, + mi_invalid_utf8: session.invalidUtf8Observed, + stderr: diagnostics.stderr.text, + stderr_observed_bytes: diagnostics.stderr.observedBytes, + stderr_truncated: + diagnostics.stderr.observedBytes > diagnostics.stderr.bytes, + stderr_invalid_utf8: diagnostics.stderr.invalidUtf8 === true, + diagnostics_truncated: diagnostics.diagnosticTruncated === true, + inferior_tty: session.inferiorTty ?? null, + inferior_tty_verified: session.inferiorTtyVerified, + }; + } + + async close( + sessionId: string, + ): Promise< + Result< + { readonly target_state: "none_observed" | "unknown" }, + AnalysisError + > + > { + if (this.#closing) + return err(new AnalysisCancelledError("close_gdb_session")); + const session = this.#sessions.get(sessionId); + if (session === undefined) + return err(new AnalysisInputError("close_gdb_session")); + if (session.exited) { + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "close_gdb_session", + stopped.reason, + ), + ); + this.#sessions.delete(sessionId); + return ok({ target_state: "unknown" }); + } + try { + return await this.#serialize( + session, + async () => { + if (session.exited) { + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "close_gdb_session", + stopped.reason, + ), + ); + this.#sessions.delete(sessionId); + return ok({ target_state: "unknown" }); + } + let targetState: "none_observed" | "unknown" = "unknown"; + try { + const groups = await this.#request( + session, + "-list-thread-groups --recurse 1", + ); + if ( + groups.completion_status !== "complete" || + groups.output_truncated || + !/^\d+\^done(?:,|$)/u.test(groups.mi) + ) + throw new Error( + "GDB could not confirm its complete, untruncated inferior list before shutdown.", + ); + const groupCount = gdbThreadGroupCount(groups.mi); + if (groupCount === undefined) + throw new Error( + "GDB returned an unknown inferior list before shutdown.", + ); + if (groupCount > 0 && !session.inferiorTtyVerified) + return err( + new AnalysisInputError("close_gdb_session", undefined, [ + { + path: ["session_id"], + reason: "invalid_value", + message: + "Live GDB thread groups remain and no verified durable inferior TTY is configured. Select a safe disposition through the GDB console or restart with inferior_tty before closing.", + }, + ]), + ); + if (groupCount === 0) { + session.noInferiorVerified = true; + targetState = "none_observed"; + await this.#request(session, "-gdb-exit"); + } else { + session.noInferiorVerified = false; + const disposition = await this.#detachInferior(session); + targetState = + disposition.status === "no-inferior" + ? "none_observed" + : "unknown"; + } + } catch { + // If MI synchronization is lost, stop only the owned debugger process. + } + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "close_gdb_session", + stopped.reason, + ), + ); + this.#sessions.delete(sessionId); + return ok({ target_state: targetState }); + }, + undefined, + "close_gdb_session", + ); + } catch (cause) { + if (cause instanceof AnalysisError) return err(cause); + throw cause; + } + } + + async retryCleanupFailures(): Promise> { + for (const session of this.#sessions.values()) { + if (session.cleanupFailures.size === 0) continue; + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") + return err( + [...session.cleanupFailures].at(-1) ?? + this.#retainCleanupFailure( + session, + "close_gdb_session", + stopped.reason, + ), + ); + this.#sessions.delete(session.id); + } + return ok(null); + } + + async closeAll(): Promise { + this.#closing = true; + this.#closePromise ??= this.#drainStartsAndCloseSessions().catch( + (cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }, + ); + return this.#closePromise; + } + + async #drainStartsAndCloseSessions(): Promise { + const active = [...this.#activeStarts.entries()]; + for (const [controller] of active) controller.abort(); + await Promise.allSettled(active.map(([, start]) => start)); + await Promise.all( + [...this.#sessions.values()].map((session) => session.commandTail), + ); + await closeOwnedDebuggerSessions( + this.#sessions, + (session) => this.#stopDebuggerOnly(session), + "GDB", + ); + } + + async #serialize( + session: GdbSession, + operation: () => Promise, + signal?: AbortSignal, + operationName: "gdb_console" | "close_gdb_session" = "gdb_console", + ): Promise { + if (session.pendingCommandCount >= MAX_PENDING_GDB_COMMANDS) + throw new AnalysisResourceConstraintError( + operationName, + "transport", + "GDB command queue capacity reached.", + { + max_pending_commands: MAX_PENDING_GDB_COMMANDS, + active_or_queued_commands: session.pendingCommandCount, + }, + ); + session.pendingCommandCount += 1; + const previous = session.commandTail; + let release: (() => void) | undefined; + let acquired = false; + session.commandTail = new Promise((resolve) => { + release = resolve; + }); + try { + await waitForGdbCommandTurn(previous, signal); + acquired = true; + return await operation(); + } finally { + if (acquired) { + session.pendingCommandCount -= 1; + release?.(); + } else { + // The cancelled placeholder still occupies commandTail until its turn. + // Keep its reservation until then so repeated cancellations cannot + // bypass the per-session queue bound while an earlier command stalls. + void previous.then(() => { + session.pendingCommandCount -= 1; + release?.(); + }); + } + } + } + + #receive(session: GdbSession, data: Buffer): void { + if (session.startupResolve !== undefined) { + const prompt = session.promptTail + data.toString("utf8"); + if (prompt.includes("(gdb)")) { + const resolve = session.startupResolve; + session.startupResolve = undefined; + resolve(); + } + session.promptTail = prompt.slice(-4); + } + const lines: { readonly value: string; readonly invalidUtf8: boolean }[] = + []; + let offset = 0; + while (offset < data.byteLength) { + const newline = data.indexOf(0x0a, offset); + const piece = data.subarray( + offset, + newline < 0 ? data.byteLength : newline, + ); + session.bufferBytes += piece.byteLength; + const lineBytes = + session.bufferBytes - (newline >= 0 && piece.at(-1) === 0x0d ? 1 : 0); + if (lineBytes > MAX_MI_LINE_BYTES) { + this.#failProtocol( + session, + "GDB emitted an MI line exceeding the 16 MiB protocol limit.", + ); + return; + } + this.#appendLineBytes(session, piece); + if (newline < 0) break; + const record = Buffer.concat( + [ + ...session.bufferChunks, + ...(session.bufferTail === undefined + ? [] + : [session.bufferTail.subarray(0, session.bufferTailBytes)]), + ], + session.bufferBytes, + ); + const withoutCarriageReturn = + record.at(-1) === 0x0d ? record.subarray(0, -1) : record; + const invalidUtf8 = !isUtf8(withoutCarriageReturn); + const line = withoutCarriageReturn.toString("utf8"); + session.bufferChunks.length = 0; + session.bufferTail = undefined; + session.bufferTailBytes = 0; + session.bufferBytes = 0; + if (invalidUtf8) session.invalidUtf8Observed = true; + if (line.length > 0) lines.push({ value: line, invalidUtf8 }); + offset = newline + 1; + } + for (const { value: line } of lines) { + if (Buffer.byteLength(line, "utf8") > MAX_RECENT_MI_LINE_BYTES) { + session.lines.push(truncateUtf8RecordPrefix(line)); + session.recentRecordsTruncated = true; + } else session.lines.push(line); + } + if (session.lines.length > 256) { + session.lines.splice(0, session.lines.length - 256); + session.recentRecordsTruncated = true; + } + for (const { value: line, invalidUtf8 } of lines) { + for (const pending of session.pending.values()) { + const lineBytes = Buffer.byteLength(line, "utf8") + 1; + if ( + pending.outputBytes + lineBytes <= session.maxCommandOutputBytes && + pending.outputBytes + lineBytes <= MAX_COMMAND_OUTPUT_BYTES && + pending.lines.length < MAX_COMMAND_OUTPUT_RECORDS + ) { + pending.lines.push(line); + pending.outputBytes += lineBytes; + pending.outputInvalidUtf8 ||= invalidUtf8; + } else pending.outputTruncated = true; + } + if (line.startsWith("*stopped")) { + for (const [token, pending] of session.pending) { + if (!pending.awaitingInferiorStop) continue; + clearTimeout(pending.timer); + session.pending.delete(token); + pending.cleanup(); + const records = [...pending.lines]; + pending.resolve({ + command: "", + mi: pending.terminalRecord ?? "", + records, + console: records + .filter((record) => record.startsWith("~")) + .join("\n"), + target: records + .filter((record) => record.startsWith("@")) + .join("\n"), + log: records.filter((record) => record.startsWith("&")).join("\n"), + output_truncated: pending.outputTruncated, + ...(pending.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: "complete", + }); + } + continue; + } + const match = /^(\d+)\^(done|running|connected|error|exit)(?:,|$)/u.exec( + line, + ); + if (match === null) continue; + const token = Number(match[1]); + const pending = session.pending.get(token); + if (pending === undefined) continue; + if (match[2] === "running") { + pending.terminalRecord = line; + pending.awaitingInferiorStop = true; + continue; + } + clearTimeout(pending.timer); + session.pending.delete(token); + pending.cleanup(); + const records = [...pending.lines]; + pending.resolve({ + command: "", + mi: line, + records, + console: records.filter((record) => record.startsWith("~")).join("\n"), + target: records.filter((record) => record.startsWith("@")).join("\n"), + log: records.filter((record) => record.startsWith("&")).join("\n"), + output_truncated: pending.outputTruncated, + ...(pending.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: "complete", + }); + } + } + + #appendLineBytes(session: GdbSession, piece: Buffer): void { + const slabBytes = 64 * 1024; + let offset = 0; + while (offset < piece.length) { + session.bufferTail ??= Buffer.allocUnsafe(slabBytes); + const remaining = slabBytes - session.bufferTailBytes; + const copied = Math.min(remaining, piece.length - offset); + piece.copy( + session.bufferTail, + session.bufferTailBytes, + offset, + offset + copied, + ); + session.bufferTailBytes += copied; + offset += copied; + if (session.bufferTailBytes === slabBytes) { + session.bufferChunks.push(session.bufferTail); + session.bufferTail = undefined; + session.bufferTailBytes = 0; + } + } + } + + #failProtocol(session: GdbSession, message: string): void { + if (session.exited || session.protocolError !== undefined) return; + session.protocolError = message; + for (const pending of session.pending.values()) { + clearTimeout(pending.timer); + pending.cleanup(); + const records = [...pending.lines]; + pending.reject( + new GdbProtocolFailureError(message, { + command: pending.command, + mi: "", + records, + console: records.filter((line) => line.startsWith("~")).join("\n"), + target: records.filter((line) => line.startsWith("@")).join("\n"), + log: records.filter((line) => line.startsWith("&")).join("\n"), + output_truncated: pending.outputTruncated, + ...(pending.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: "unknown", + }), + ); + } + session.pending.clear(); + session.startupReject?.(new Error(message)); + } + + async #stopAfterProtocolFailure( + session: GdbSession, + cause: unknown, + ): Promise { + const stopped = await this.#stopDebuggerOnly(session); + const partial = + cause instanceof GdbProtocolFailureError ? cause.partial : undefined; + const partialObservation = + partial === undefined + ? undefined + : createEvidence(undefined, GDB_PROVIDER_IDENTITY, { + predicateType: "rea.debugger.console-observation", + operation: "gdb_console", + parameters: { + session_id: session.id, + command: partial.command, + }, + result: { ...partial, records: [...partial.records] }, + rawResult: { ...partial, records: [...partial.records] }, + subjectUnavailableReason: + "The active GDB inferior identity is not established by the debugger session identifier.", + limitations: [ + "The GDB MI protocol failed; retained records are partial and command completion is unknown.", + "The unrestricted GDB console can access local shell, filesystem, network, and target controls.", + ], + }); + return new AnalysisCapabilityUnavailableError( + "gdb", + "gdb_console", + stopped.status === "incomplete" + ? `${session.protocolError} The owned process could not be confirmed stopped: ${stopped.reason}` + : session.protocolError!, + { + cause, + ...(stopped.status === "incomplete" + ? { + cleanup: { + reason: stopped.reason, + resources: [session.id], + }, + } + : {}), + ...(partialObservation === undefined ? {} : { partialObservation }), + }, + ); + } + + #stopDebuggerOnly( + session: GdbSession, + ): ReturnType { + if (session.stopAttempt !== undefined) return session.stopAttempt; + const attempt = this.#stopProcess(session, "GDB") + .then((stopped) => { + if (stopped.status !== "incomplete") { + for (const failure of session.cleanupFailures) + failure.resolveCleanup(); + session.cleanupFailures.clear(); + } + return stopped; + }) + .finally(() => { + if (session.stopAttempt === attempt) session.stopAttempt = undefined; + }); + session.stopAttempt = attempt; + return attempt; + } + + #retainCleanupFailure( + session: GdbSession, + operation: string, + reason: string, + options: AnalysisErrorOptions = {}, + ): AnalysisError { + const failure = new AnalysisCapabilityUnavailableError( + "gdb", + operation, + `The owned GDB process could not be confirmed stopped: ${reason}`, + { ...options, cleanup: { reason, resources: [session.id] } }, + ); + session.cleanupFailures.add(failure); + return failure; + } + + async #detachInferior( + session: GdbSession, + ): Promise { + if (session.noInferiorVerified) return { status: "no-inferior" }; + if (session.exited) + throw new Error("GDB exited before inferior state could be verified."); + // A new MI inspection replaces the old snapshot. If this read is + // incomplete, malformed, or reports multiple inferiors, no earlier PID + // can stand in for the current unknown inventory. + session.knownInferiorIdentities.clear(); + if ( + session.protocolError !== undefined || + session.pending.size > 0 || + session.pendingCommandCount > 1 + ) + throw new Error( + "GDB MI state is not synchronized; inferior state is unknown.", + ); + const groups = await this.#request( + session, + "-list-thread-groups --recurse 1", + ); + if ( + groups.completion_status !== "complete" || + groups.output_truncated || + !/^\d+\^done(?:,|$)/u.test(groups.mi) + ) + throw new Error("GDB could not confirm its inferior state."); + const inferiorPids = gdbThreadGroupPids(groups.mi); + if (inferiorPids?.length === 0) { + session.knownInferiorIdentities.clear(); + session.noInferiorVerified = true; + return { status: "no-inferior" }; + } + session.noInferiorVerified = false; + if (inferiorPids?.length !== 1) + throw new Error( + inferiorPids === undefined + ? "GDB returned an unknown recursive inferior list." + : "GDB reports multiple inferiors; automatic detach cannot be confirmed safely.", + ); + if (!session.inferiorTtyVerified) + throw new Error( + "GDB cannot detach the live inferior because no durable output sink was verified at session startup.", + ); + const observedTty = await this.#request(session, "-inferior-tty-show"); + if ( + observedTty.completion_status !== "complete" || + observedTty.output_truncated || + !/^\d+\^done(?:,|$)/u.test(observedTty.mi) || + miStringField(observedTty.mi, "inferior_tty_terminal") !== + session.inferiorTty + ) + throw new Error( + "GDB no longer reports the caller-selected inferior TTY; detachment cannot be confirmed safely.", + ); + await this.#verifyInferiorOutputSink( + inferiorPids[0]!, + session.inferiorTty!, + (identity) => + session.knownInferiorIdentities.set( + `${identity.pid}:${identity.startTime}`, + identity, + ), + ); + const detached = await this.#request(session, "-target-detach"); + if ( + detached.completion_status !== "complete" || + detached.output_truncated || + !/^\d+\^done(?:,|$)/u.test(detached.mi) + ) + throw new Error("GDB did not confirm inferior detachment."); + session.knownInferiorIdentities.clear(); + return { status: "detached", outputSink: "durable" }; + } + + async #settleExitedCommands( + session: GdbSession, + code: number | null, + signalCode: NodeJS.Signals | null, + ): Promise { + await this.#waitForStdoutSettlement(session); + for (const pending of session.pending.values()) { + clearTimeout(pending.timer); + pending.reject( + new GdbCommandProcessExitError({ + command: pending.command, + mi: "", + records: [...pending.lines], + console: pending.lines + .filter((line) => line.startsWith("~")) + .join("\n"), + target: pending.lines + .filter((line) => line.startsWith("@")) + .join("\n"), + log: pending.lines.filter((line) => line.startsWith("&")).join("\n"), + process_exit_observed: true, + process_exit_code: code, + process_exit_signal: signalCode, + output_truncated: pending.outputTruncated, + ...(pending.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: "unknown", + }), + ); + } + session.pending.clear(); + } + + async #waitForStdoutSettlement(session: GdbSession): Promise { + let timer!: ReturnType; + try { + await Promise.race([ + session.stdoutSettled, + new Promise((resolve) => { + timer = setTimeout(resolve, 1_000); + timer.unref?.(); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + #request( + session: GdbSession, + command: string, + signal?: AbortSignal, + operation: "gdb_console" | "start_gdb_session" = "gdb_console", + evidenceCommand: string = command, + ): Promise { + const stdin = session.launched.process.stdin; + if (session.exited) + return Promise.reject( + new Error( + `GDB MI process has exited (exit code ${String(session.exitCode)}, signal ${String(session.exitSignal)}).`, + ), + ); + if ( + stdin === null || + stdin === undefined || + stdin.destroyed || + !stdin.writable + ) { + const message = "GDB MI input stream is closed"; + this.#failProtocol(session, message); + return Promise.reject(new Error(message)); + } + const token = session.nextToken++; + return new Promise((resolve, reject) => { + let abortListener!: () => void; + const cleanup = (): void => { + signal?.removeEventListener("abort", abortListener); + }; + let pending!: PendingCommand; + const timer = setTimeout(() => { + session.pending.delete(token); + session.protocolError = + "A GDB MI command timed out; untagged stream output can no longer be safely correlated with later commands."; + pending.cleanup(); + const partial: GdbCommandResult = { + command: pending.evidenceCommand, + mi: "", + records: [...pending.lines], + console: pending.lines + .filter((line) => line.startsWith("~")) + .join("\n"), + target: pending.lines + .filter((line) => line.startsWith("@")) + .join("\n"), + log: pending.lines.filter((line) => line.startsWith("&")).join("\n"), + output_truncated: pending.outputTruncated, + ...(pending.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: "unknown", + }; + const timeout = new AnalysisTimeoutError( + operation, + this.#commandTimeoutMs, + { cause: new Error(`GDB MI command timed out: ${command}`) }, + ); + const evidence = createEvidence(undefined, GDB_PROVIDER_IDENTITY, { + predicateType: + operation === "start_gdb_session" + ? "rea.debugger.startup-observation" + : "rea.debugger.console-observation", + operation, + parameters: { + session_id: session.id, + command: pending.evidenceCommand, + }, + result: { ...partial, records: [...partial.records] }, + rawResult: { ...partial, records: [...partial.records] }, + subjectUnavailableReason: + "The active GDB inferior identity is not established by the debugger session identifier.", + limitations: [ + "The GDB command exceeded its deadline; retained MI and stream records are partial and command completion is unknown.", + "Cleanup of the owned GDB process was requested because output correlation could no longer be guaranteed.", + "The unrestricted GDB console can access local shell, filesystem, network, and target controls.", + ], + }); + void this.#stopDebuggerOnly(session).then( + (stopped) => { + if ( + stopped.status === "incomplete" && + operation === "start_gdb_session" + ) { + pending.reject( + new AnalysisTimeoutError(operation, this.#commandTimeoutMs, { + cause: timeout, + cleanup: { reason: stopped.reason, resources: [session.id] }, + partialObservation: evidence, + }), + ); + return; + } + pending.reject( + stopped.status === "incomplete" + ? new AnalysisCapabilityUnavailableError( + "gdb", + "gdb_console", + `GDB command output could not be correlated and its owned process could not be confirmed stopped: ${stopped.reason}`, + { + cause: timeout, + cleanup: { + reason: stopped.reason, + resources: [session.id], + }, + partialObservation: evidence, + }, + ) + : new GdbCommandTimeoutError( + partial, + evidence, + this.#commandTimeoutMs, + ), + ); + }, + (cause: unknown) => + pending.reject( + new AnalysisCapabilityUnavailableError( + "gdb", + "gdb_console", + "GDB command output could not be correlated and owned process shutdown failed.", + { + cause: new AggregateError( + [timeout, cause], + "GDB timeout and cleanup both failed.", + ), + cleanup: { + reason: gdbErrorMessage(cause), + resources: [session.id], + }, + partialObservation: evidence, + }, + ), + ), + ); + }, this.#commandTimeoutMs); + pending = { + token, + command, + evidenceCommand, + lines: [], + resolve: (result) => { + cleanup(); + resolve(result); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + cleanup, + timer, + outputBytes: 0, + outputTruncated: false, + outputInvalidUtf8: false, + terminalRecord: undefined, + awaitingInferiorStop: false, + }; + session.pending.set(token, pending); + abortListener = (): void => { + clearTimeout(pending.timer); + const partial: GdbCommandResult = { + command: pending.evidenceCommand, + mi: "", + records: [...pending.lines], + console: pending.lines + .filter((line) => line.startsWith("~")) + .join("\n"), + target: pending.lines + .filter((line) => line.startsWith("@")) + .join("\n"), + log: pending.lines.filter((line) => line.startsWith("&")).join("\n"), + output_truncated: pending.outputTruncated, + ...(pending.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + completion_status: "unknown", + }; + const evidence = createEvidence(undefined, GDB_PROVIDER_IDENTITY, { + predicateType: "rea.debugger.console-observation", + operation: "gdb_console", + parameters: { + session_id: session.id, + command: pending.evidenceCommand, + }, + result: { ...partial, records: [...partial.records] }, + rawResult: { ...partial, records: [...partial.records] }, + subjectUnavailableReason: + "The active GDB inferior identity is not established by the debugger session identifier.", + limitations: [ + "The GDB command was cancelled; retained MI records are partial and command completion is unknown.", + "Shutdown of the owned GDB process was requested because output correlation could no longer be guaranteed.", + "The unrestricted GDB console can access local shell, filesystem, network, and target controls.", + ], + }); + session.pending.delete(token); + session.protocolError = + "A GDB MI command was cancelled; owned-session shutdown was requested because output correlation can no longer be guaranteed."; + void this.#stopDebuggerOnly(session).then( + (stopped) => { + pending.reject( + stopped.status === "incomplete" + ? new AnalysisCancelledError("gdb_console", { + cause: new Error(stopped.reason), + cleanup: { + reason: stopped.reason, + resources: [session.id], + }, + partialObservation: evidence, + }) + : new AnalysisCancelledError("gdb_console", { + partialObservation: evidence, + }), + ); + }, + (cause: unknown) => + pending.reject( + new AnalysisCancelledError("gdb_console", { + cause, + partialObservation: evidence, + }), + ), + ); + }; + signal?.addEventListener("abort", abortListener, { once: true }); + if (signal?.aborted) { + abortListener(); + return; + } + try { + session.launched.process.stdin?.write( + `${token}${command}\n`, + "utf8", + (cause?: Error | null) => { + if (cause) + this.#failProtocol( + session, + `GDB stdin write failed: ${cause.message}`, + ); + }, + ); + } catch (cause) { + this.#failProtocol( + session, + `GDB stdin write failed: ${cause instanceof Error ? cause.message : "unknown error"}`, + ); + } + }).then((result) => ({ ...result, command })); + } +} + +const miQuote = (value: string): string => + `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\r", "\\r").replaceAll("\n", "\\n").replaceAll("\t", "\\t")}"`; + +/** @internal Decode one unambiguous GDB MI C-string field. */ +export const miStringField = ( + record: string, + field: string, +): string | undefined => { + const escapedField = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const valuePattern = `((?:\\\\.|[^"\\\\])*)`; + const fieldPattern = new RegExp( + `(?:^|,)${escapedField}="${valuePattern}"`, + "gu", + ); + const matches = [...record.matchAll(fieldPattern)]; + if (matches.length !== 1) return undefined; + const match = new RegExp( + `^\\d+\\^done,${escapedField}="${valuePattern}"$`, + "u", + ).exec(record); + const raw = match?.[1]; + if (raw === undefined) return undefined; + const bytes: number[] = []; + for (let index = 0; index < raw.length;) { + const character = raw[index]!; + if (character !== "\\") { + const codePoint = raw.codePointAt(index); + if ( + codePoint === undefined || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) + return undefined; + const value = String.fromCodePoint(codePoint); + bytes.push(...Buffer.from(value, "utf8")); + index += value.length; + continue; + } + const escaped = raw[++index]!; + const simple: Readonly> = { + a: 7, + b: 8, + f: 12, + n: 10, + r: 13, + t: 9, + v: 11, + "\\": 92, + '"': 34, + "'": 39, + "?": 63, + }; + const simpleByte = simple[escaped]; + if (simpleByte !== undefined) { + bytes.push(simpleByte); + index += 1; + continue; + } + if (escaped === "x") { + const hex = /^[0-9a-f]+/iu.exec(raw.slice(index + 1))?.[0]; + if (hex === undefined) return undefined; + bytes.push(Number.parseInt(hex, 16) & 0xff); + index += hex.length; + index += 1; + continue; + } + if (/[0-7]/u.test(escaped)) { + const octal = `${escaped}${/^[0-7]{0,2}/u.exec(raw.slice(index + 1))![0]}`; + bytes.push(Number.parseInt(octal, 8) & 0xff); + index += octal.length; + continue; + } + return undefined; + } + const decoded = Buffer.from(bytes).toString("utf8"); + return Buffer.from(decoded, "utf8").equals(Buffer.from(bytes)) + ? decoded + : undefined; +}; + +/** @internal Classify commands that can invalidate the configured output sink. */ +export const gdbOneShotCommandMayInvalidateDetachProof = ( + command: string, +): boolean => { + if (commandMayChangeInferiorOutput(command)) return true; + const firstToken = /^\s*([^\s;]+)/u.exec(command)?.[1]?.toLowerCase(); + if ( + firstToken !== undefined && + (["source", "define", "alias"].some((name) => + name.startsWith(firstToken), + ) || + firstToken === "py" || + "python".startsWith(firstToken)) + ) + return true; + // One-shot execution cannot establish which shell/GDB context interprets + // redirections, nor safely track multiline command definitions. + return /[<>]{1,2}|&>|[\r\n;]/u.test(command); +}; + +/** @internal Classify commands that can invalidate the configured output sink. */ +export const commandMayChangeInferiorOutput = (command: string): boolean => { + const setting = /^\s*(?:set|with)\s+(\S+)/iu.exec(command)?.[1]; + const maySetInferiorTty = + setting !== undefined && "inferior-tty".startsWith(setting.toLowerCase()); + const firstToken = /^\s*([^\s;]+)/u.exec(command)?.[1]?.toLowerCase(); + const mayBeTtyCommand = + firstToken !== undefined && "tty".startsWith(firstToken); + const mayDefineAlias = + firstToken !== undefined && "alias".startsWith(firstToken); + const mayRunWithOutputRedirection = + firstToken !== undefined && + ["run", "start", "starti"].some((name) => name.startsWith(firstToken)) && + /[<>]{1,2}|&>/u.test(command.slice(firstToken.length)); + return ( + maySetInferiorTty || + mayBeTtyCommand || + mayDefineAlias || + /\binferior-tty\b/iu.test(command) || + /^\s*(?:wit(?:h)?(?:\s|$)|tty(?:\s|$)|source\b|py\w*|define\b)/iu.test( + command, + ) || + mayRunWithOutputRedirection + ); +}; + +/** Terminate only the directly owned debugger process, never its process group or inferiors. */ +export const waitForGdbCommandTurn = ( + previous: Promise, + signal?: AbortSignal, +): Promise => { + if (signal === undefined) return previous; + if (signal.aborted) + return Promise.reject(new AnalysisCancelledError("gdb_console")); + return new Promise((resolve, reject) => { + const onAbort = (): void => + reject(new AnalysisCancelledError("gdb_console")); + signal.addEventListener("abort", onAbort, { once: true }); + previous.then( + () => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, + (cause: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(cause); + }, + ); + }); +}; diff --git a/src/ghidra/GhidraPeProfile.test.ts b/src/ghidra/GhidraPeProfile.test.ts new file mode 100644 index 000000000..561aad701 --- /dev/null +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js"; + +describe("ghidraPeCodeViewLimitations", () => { + it("returns empty limitations for non-PE targets", () => { + const elfTarget: BinaryTarget = { + path: "/test/file", + sha256: "abc", + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }; + expect(ghidraPeCodeViewLimitations(elfTarget)).toEqual([]); + }); + + it("does not claim CodeView symbols were unapplied when the session loaded a PDB", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "abc", + kind: "executable", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + format: "pe", + codeView: { + pdbPath: "C:\\symbols.pdb", + pdbFileName: "symbols.pdb", + guid: "12345678-1234-1234-1234-123456789abc", + age: 1, + }, + }; + expect(ghidraPeCodeViewLimitations(peTarget, true)).toEqual([]); + }); + + it("returns empty limitations for PE target without codeView", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "def", + kind: "executable", + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + }; + expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([]); + }); + + it("returns limitation with GUID and age for RSDS CodeView records", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "def", + kind: "executable", + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + codeView: { + pdbPath: "C:\\build\\app.pdb", + pdbFileName: "app.pdb", + guid: "01234567-89ab-cdef-0123-456789abcdef", + age: 1, + }, + }; + expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([ + 'The target references debug symbols "app.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.', + ]); + }); + + it("returns limitation without GUID for NB10 CodeView records", () => { + const peTarget: BinaryTarget = { + path: "/test/file.exe", + sha256: "def", + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], + executableRole: "application", + managed: false, + codeView: { + pdbPath: "D:\\test\\legacy.pdb", + pdbFileName: "legacy.pdb", + guid: null, + age: 2, + }, + }; + expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([ + 'The target references debug symbols "legacy.pdb" (age 2) that were not applied; procedure names are analysis-generated.', + ]); + }); +}); diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts new file mode 100644 index 000000000..217b3256f --- /dev/null +++ b/src/ghidra/GhidraPeProfile.ts @@ -0,0 +1,24 @@ +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; + +/** + * Report unapplied PE companion PDB symbols when CodeView debug information is present. + * + * Ghidra does not automatically locate or apply sidecar PDBs in REA's isolated + * runtime directories without companion admission. When the PE target header + * references debug symbols, surface this limitation so callers understand that + * procedure names remain analysis-generated. + */ +export const ghidraPeCodeViewLimitations = ( + target: BinaryTarget, + pdbLoaded = false, +): readonly string[] => { + if (target.format !== "pe" || target.codeView === undefined || pdbLoaded) { + return []; + } + const { pdbFileName, guid, age } = target.codeView; + return [ + guid !== null + ? `The target references debug symbols "${pdbFileName}" (GUID ${guid}, age ${age}) that were not applied; procedure names are analysis-generated.` + : `The target references debug symbols "${pdbFileName}" (age ${age}) that were not applied; procedure names are analysis-generated.`, + ]; +}; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index ccdfd96f7..b485f6ab3 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -822,6 +822,39 @@ describe("Ghidra result projection", () => { }); }); +describe("Ghidra PE CodeView debug symbol limitations", () => { + it("projects unapplied companion PDB limitation for PE target with CodeView record", async () => { + const ghidra = provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(ok(null)), + })); + const target = { + ...peTarget("x86_64"), + codeView: { + pdbPath: "C:\\projects\\app\\build\\test.pdb", + pdbFileName: "test.pdb", + guid: "01234567-89ab-cdef-0123-456789abcdef", + age: 1, + }, + }; + const profile = await ghidra.resolveAnalysisProfile(target); + if (!profile.ok || profile.value.profile === null) + throw new Error("expected PE profile"); + const client = ghidra.createClient(target, profile.value.profile); + const health = await client.execute("health", {}); + expect(health).toMatchObject({ + ok: true, + value: { + limitations: expect.arrayContaining([ + 'The target references debug symbols "test.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.', + ]), + }, + }); + await client.close(); + }); +}); + describe("Ghidra relationship coverage", () => { it("marks the full direct call-reference response complete while keeping static caveats", async () => { const ghidra = provider(installationHost(), () => ({ diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 25838453e..9db1b0c5f 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -68,6 +68,7 @@ import { isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; +import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -530,6 +531,7 @@ export const createGhidraProviderClient = (input: { // Replaced by the reported outcome once the session handshake is checked. const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; const pdbLimitations: string[] = []; + let pdbLoaded = false; // Replaced by the reported storage outcome once the handshake is checked. const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), @@ -654,12 +656,14 @@ export const createGhidraProviderClient = (input: { operation: AnalysisOperation, info: GhidraSessionInfo, ): Promise => { - if (target.format === "pe") + if (target.format === "pe") { + pdbLoaded = info.pdb?.loaded === true; pdbLimitations.splice( 0, pdbLimitations.length, ...ghidraPdbLimitations(info.pdb), ); + } const softFloatReason = ghidraMipsSoftFloatFailure( softFloat, info.mips_soft_float_storage, @@ -712,6 +716,7 @@ export const createGhidraProviderClient = (input: { ...ghidraExtensionLimitations(extensions), ...seedLimitations, ...pdbLimitations, + ...ghidraPeCodeViewLimitations(target, pdbLoaded), ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; const nativeAotOwner = diff --git a/src/go/GoBinaryContainer.ts b/src/go/GoBinaryContainer.ts index 56fcb2b74..96b4cb146 100644 --- a/src/go/GoBinaryContainer.ts +++ b/src/go/GoBinaryContainer.ts @@ -34,6 +34,16 @@ export interface GoZeroFillMapping { readonly size: bigint; } +/** One named file section. Invalid declared ranges stay visible with `file_backed: false`. */ +export interface GoImageSection { + readonly name: string; + readonly offset: number; + readonly size: number; + readonly file_backed: boolean; + readonly read_only: boolean; + readonly compressed: boolean; +} + /** Native container facts and its producer-defined build-info search area. */ export interface GoBinaryContainer { readonly format: "elf" | "pe" | "macho"; @@ -42,6 +52,7 @@ export interface GoBinaryContainer { readonly byte_order: "little" | "big"; readonly mappings: readonly GoFileMapping[]; readonly zero_fills: readonly GoZeroFillMapping[]; + readonly sections: readonly GoImageSection[]; readonly search: GoFileMapping | null; } @@ -110,6 +121,32 @@ export class GoBinaryReader { } } +/** Admit a declared file span, or mark it unbacked when it leaves the file. */ +export const declaredFileRange = ( + fileBytes: number, + offset: bigint, + size: bigint, +): { + readonly offset: number; + readonly size: number; + readonly file_backed: boolean; +} => { + if ( + offset < 0n || + size < 0n || + offset > BigInt(Number.MAX_SAFE_INTEGER) || + size > BigInt(Number.MAX_SAFE_INTEGER) || + offset > BigInt(fileBytes) || + size > BigInt(fileBytes) - offset + ) + return { offset: 0, size: 0, file_backed: false }; + return { + offset: Number(offset), + size: Number(size), + file_backed: size > 0n, + }; +}; + /** Map a virtual span without losing precision or selecting an ambiguous alias. */ export const mappedFileOffset = ( mappings: readonly GoFileMapping[], diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 4a845c197..e94a80ab6 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -55,7 +55,7 @@ export interface GoBinaryImage { /** Inspect bounded executable bytes using native mappings, without loading or executing code. */ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { - const container = readContainer(bytes); + const container = readGoBinaryContainer(bytes); const { format, architecture, bits, byte_order } = container; const search = container.search; const header = findHeader(bytes, search); @@ -184,7 +184,8 @@ interface LocatedString { readonly location: GoStringLocation; } -const readContainer = (bytes: Buffer): GoBinaryContainer => { +/** Identify an ELF, PE, or thin Mach-O image and its named file sections. */ +export const readGoBinaryContainer = (bytes: Buffer): GoBinaryContainer => { if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return readGoElfImage(bytes); if (bytes.subarray(0, 2).equals(Buffer.from("MZ"))) diff --git a/src/go/GoElfImage.ts b/src/go/GoElfImage.ts index fae00c40c..de4388114 100644 --- a/src/go/GoElfImage.ts +++ b/src/go/GoElfImage.ts @@ -1,9 +1,11 @@ import { + declaredFileRange, GoBinaryFormatFailure, GoBinaryReader, mappedFileOffset, type GoBinaryContainer, type GoFileMapping, + type GoImageSection, type GoZeroFillMapping, } from "./GoBinaryContainer.js"; import { readElfFixedHeader } from "../domain/elfFixedHeader.js"; @@ -100,6 +102,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { ); const mappings: GoFileMapping[] = []; const zeroFills: GoZeroFillMapping[] = []; + const sections: GoImageSection[] = []; let fallback: GoFileMapping | null = null; for (let index = 0; index < phCount; index++) { const offset = phStart + index * phWidth; @@ -158,6 +161,15 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { "malformed", "ELF section name offset is outside its string table", ); + recordElfSection(sections, { + bytes, + names, + nameOffset, + reader, + bits, + wide, + header: offset, + }); // Comparing the complete fixed name avoids quadratic scans through malicious string tables. const expectedName = Buffer.from(".go.buildinfo\0"); if ( @@ -213,10 +225,105 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { byte_order: reader.little ? "little" : "big", mappings, zero_fills: zeroFills, + sections, search: search ?? fallback, }; }; +const recordElfSection = ( + sections: GoImageSection[], + input: { + readonly bytes: Buffer; + readonly names: { readonly offset: number; readonly size: number }; + readonly nameOffset: number; + readonly reader: GoBinaryReader; + readonly bits: 32 | 64; + readonly wide: boolean; + readonly header: number; + }, +): void => { + const decoded = elfSectionName(input.bytes, input.names, input.nameOffset); + const header = { + reader: input.reader, + bits: input.bits, + wide: input.wide, + header: input.header, + }; + switch (decoded.status) { + case "named": + sections.push(elfImageSection({ ...header, name: decoded.name })); + return; + case "truncated": { + // Names past this window stay out of .dep-v0 matching. Read-only + // bytes can still hold the only compiler path. + const section = elfImageSection({ ...header, name: "" }); + if (section.read_only) sections.push(section); + return; + } + case "unnamed": + return; + default: { + const unreachable: never = decoded; + return unreachable; + } + } +}; + +const elfSectionName = ( + bytes: Buffer, + names: { readonly offset: number; readonly size: number }, + nameOffset: number, +): + | { readonly status: "named"; readonly name: string } + | { readonly status: "unnamed" } + | { readonly status: "truncated" } => { + const start = names.offset + nameOffset; + const limit = Math.min(start + 64, names.offset + names.size); + const nul = bytes.subarray(start, limit).indexOf(0); + if (nul === 0) return { status: "unnamed" }; + if (nul < 0) return { status: "truncated" }; + return { + status: "named", + name: bytes.toString("latin1", start, start + nul), + }; +}; + +const elfImageSection = ({ + reader, + bits, + wide, + header, + name, +}: { + readonly reader: GoBinaryReader; + readonly bits: 32 | 64; + readonly wide: boolean; + readonly header: number; + readonly name: string; +}): GoImageSection => { + const type = reader.u32(header + 4); + const flags = reader.word(header + 8, bits); + const declared = declaredFileRange( + reader.bytes.length, + reader.word(header + (wide ? 24 : 16), bits), + reader.word(header + (wide ? 32 : 20), bits), + ); + // SHT_NOBITS offsets and sizes do not describe stored bytes. + const range = + type === SHT_NOBITS ? { ...declared, file_backed: false } : declared; + const compressed = (flags & 0x800n) !== 0n; + const readOnly = + type === 1 && + range.file_backed && + !compressed && + (flags & 1n) === 0n && + (flags & 4n) === 0n && + (flags & 2n) !== 0n; + return { name, ...range, read_only: readOnly, compressed }; +}; + +const SHT_NOBITS = 8; + const elfArchitecture = (machine: number): string => { const names = new Map([ [3, "386"], diff --git a/src/go/GoMachoImage.ts b/src/go/GoMachoImage.ts index a178a0616..5184a5829 100644 --- a/src/go/GoMachoImage.ts +++ b/src/go/GoMachoImage.ts @@ -1,9 +1,11 @@ import { + declaredFileRange, GoBinaryFormatFailure, GoBinaryReader, mappedFileOffset, type GoBinaryContainer, type GoFileMapping, + type GoImageSection, type GoZeroFillMapping, } from "./GoBinaryContainer.js"; @@ -30,6 +32,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { ); const mappings: GoFileMapping[] = []; const zeroFills: GoZeroFillMapping[] = []; + const sections: GoImageSection[] = []; const named: GoFileMapping[] = []; let fallback: GoFileMapping | null = null; let offset = commandStart; @@ -105,11 +108,25 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { fallback = mapping; for (let sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) { const section = offset + segmentHeader + sectionIndex * sectionWidth; - if ( - fixedName(reader.range(section, 16, "Mach-O section name")) !== - "__go_buildinfo" - ) - continue; + const sectionName = fixedName( + reader.range(section, 16, "Mach-O section name"), + ); + sections.push( + machoImageSection({ + reader, + bits, + wide, + header: section, + name: `${fixedName(reader.range(section + 16, 16, "Mach-O section segment"))},${sectionName}`, + protection, + unmapped: unmapped || mapping === undefined, + segmentAddress: address, + segmentMemorySize: memorySize, + segmentFileOffset: fileOffset, + segmentFileSize: fileSize, + }), + ); + if (sectionName !== "__go_buildinfo") continue; if (unmapped) throw new GoBinaryFormatFailure( "malformed", @@ -175,10 +192,74 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { byte_order: little ? "little" : "big", mappings, zero_fills: zeroFills, + sections, search: named[0] ?? fallback, }; }; +const machoImageSection = ({ + reader, + bits, + wide, + header, + name, + protection, + unmapped, + segmentAddress, + segmentMemorySize, + segmentFileOffset, + segmentFileSize, +}: { + readonly reader: GoBinaryReader; + readonly bits: 32 | 64; + readonly wide: boolean; + readonly header: number; + readonly name: string; + readonly protection: number; + readonly unmapped: boolean; + readonly segmentAddress: bigint; + readonly segmentMemorySize: bigint; + readonly segmentFileOffset: bigint; + readonly segmentFileSize: bigint; +}): GoImageSection => { + const flags = reader.u32(header + (wide ? 64 : 56)); + const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff); + const sectionAddress = reader.word(header + 32, bits); + const sectionSize = reader.word(header + (wide ? 40 : 36), bits); + const sectionOffset = BigInt(reader.u32(header + (wide ? 48 : 40))); + const addressDelta = sectionAddress - segmentAddress; + const fileDelta = sectionOffset - segmentFileOffset; + const inSegmentMemory = + addressDelta >= 0n && + addressDelta <= segmentMemorySize && + sectionSize <= segmentMemorySize - addressDelta; + const inSegmentFile = + fileDelta >= 0n && + fileDelta <= segmentFileSize && + sectionSize <= segmentFileSize - fileDelta; + const range = unmapped + ? { offset: 0, size: 0, file_backed: false } + : declaredFileRange( + reader.bytes.length, + BigInt(reader.u32(header + (wide ? 48 : 40))), + reader.word(header + (wide ? 40 : 36), bits), + ); + const fileBacked = + range.file_backed && !zeroFill && inSegmentMemory && inSegmentFile; + const readOnly = + fileBacked && + (protection & 1) !== 0 && + (protection & 2) === 0 && + (flags & 0x80000400) === 0; + return { + name, + ...range, + file_backed: fileBacked, + read_only: readOnly, + compressed: false, + }; +}; + const fixedName = (bytes: Buffer): string => { const nul = bytes.indexOf(0); return bytes.subarray(0, nul < 0 ? bytes.length : nul).toString("latin1"); diff --git a/src/go/GoPeImage.ts b/src/go/GoPeImage.ts index 3d7ad95a8..0787bc93d 100644 --- a/src/go/GoPeImage.ts +++ b/src/go/GoPeImage.ts @@ -1,9 +1,11 @@ import { observeMzWindowsHeader, parseDosMzHeader } from "../domain/dosMz.js"; import { + declaredFileRange, GoBinaryFormatFailure, GoBinaryReader, type GoBinaryContainer, type GoFileMapping, + type GoImageSection, type GoZeroFillMapping, } from "./GoBinaryContainer.js"; @@ -86,14 +88,30 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { ); const mappings: GoFileMapping[] = []; const zeroFills: GoZeroFillMapping[] = []; + const sections: GoImageSection[] = []; let search: GoFileMapping | null = null; for (let index = 0; index < count; index++) { const entry = start + index * 40; + const sectionName = peSectionName( + reader.range(entry, 8, "PE section name"), + ); const virtualSize = reader.u32(entry + 8); const virtualAddress = reader.u32(entry + 12); const size = reader.u32(entry + 16); const offset = reader.u32(entry + 20); const characteristics = reader.u32(entry + 36); + // SizeOfRawData includes file-alignment padding. VirtualSize is the content. + const contentSize = virtualSize === 0 ? size : Math.min(virtualSize, size); + if (sectionName.length > 0) + sections.push( + peImageSection({ + reader, + name: sectionName, + size: contentSize, + offset, + characteristics, + }), + ); const address = imageBase + BigInt(virtualAddress); if (address + BigInt(Math.max(size, virtualSize)) > 1n << BigInt(bits)) throw new GoBinaryFormatFailure( @@ -138,6 +156,39 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { byte_order: "little", mappings, zero_fills: zeroFills, + sections, search, }; }; + +const peSectionName = (bytes: Buffer): string => { + const nul = bytes.indexOf(0); + return bytes.toString("latin1", 0, nul < 0 ? bytes.length : nul); +}; + +const peImageSection = ({ + reader, + name, + size, + offset, + characteristics, +}: { + readonly reader: GoBinaryReader; + readonly name: string; + readonly size: number; + readonly offset: number; + readonly characteristics: number; +}): GoImageSection => { + const range = declaredFileRange( + reader.bytes.length, + BigInt(offset), + BigInt(size), + ); + const readOnly = + range.file_backed && + (characteristics & 0x40000000) !== 0 && + (characteristics & 0x80000000) === 0 && + (characteristics & 0x20000000) === 0 && + (characteristics & 0x40) !== 0; + return { name, ...range, read_only: readOnly, compressed: false }; +}; diff --git a/src/ida/IdaWorkspaceThinning.test.ts b/src/ida/IdaWorkspaceThinning.test.ts index baba6e145..3af1cd5ee 100644 --- a/src/ida/IdaWorkspaceThinning.test.ts +++ b/src/ida/IdaWorkspaceThinning.test.ts @@ -21,12 +21,13 @@ afterEach(async () => { } }); -/** Write a two-slice universal fixture and resolve it for the given host. */ -const universalTarget = async (hostArchitecture: "arm64" | "x64") => { +const resolveUniversalTarget = async ( + bytes: Buffer, + hostArchitecture: "arm64" | "x64", +) => { const directory = await createTestTempDirectory("rea-ida-thin-"); roots.push(directory); const path = join(directory, "universal"); - const bytes = fat([0x01000007, 0x0100000c]); await writeFile(path, bytes); const resolved = await parseBinaryTarget(path, { hostArchitecture }); if (!resolved.ok) throw resolved.error; @@ -35,6 +36,27 @@ const universalTarget = async (hostArchitecture: "arm64" | "x64") => { return { bytes, target: resolved.value }; }; +/** Write a two-slice universal fixture and resolve it for the given host. */ +const universalTarget = (hostArchitecture: "arm64" | "x64") => + resolveUniversalTarget(fat([0x01000007, 0x0100000c]), hostArchitecture); + +const copySelectedSlice = async ({ + bytes, + target, +}: Awaited>) => { + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + await workspace.copyInput(); + const input = await readFile(workspace.inputPath); + const { offset, size } = target.universalSlice; + expect(input.length, "no byte after the slice is copied").toBe(size); + expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + expect(workspace.inputSha256).toBe( + createHash("sha256").update(input).digest("hex"), + ); + return { workspace, input }; +}; + /** One slice of a synthetic universal file: CPU type, offset and size. */ interface Layout { readonly cpu: number; @@ -43,6 +65,12 @@ interface Layout { readonly alignmentExponent?: number; } +const multiSliceLayout: readonly Layout[] = [ + { cpu: 0x01000007, offset: 4096, size: 8480, alignmentExponent: 12 }, + { cpu: 0x0100000c, offset: 16384, size: 33464 }, + { cpu: 0x00000007, offset: 65536, size: 33464 }, +]; + /** * Write a universal file whose slices span more than one 64 KiB read, each * filled with its own byte so bytes outside the selected slice are visible. @@ -51,9 +79,6 @@ const largeUniversalTarget = async ( layout: readonly Layout[], hostArchitecture: "arm64" | "x64", ) => { - const directory = await createTestTempDirectory("rea-ida-thin-large-"); - roots.push(directory); - const path = join(directory, "universal"); const total = Math.max(...layout.map(({ offset, size }) => offset + size)); const bytes = Buffer.alloc(total); bytes.writeUInt32BE(0xcafebabe, 0); @@ -67,37 +92,27 @@ const largeUniversalTarget = async ( bytes.fill(0x11 * (index + 1), offset, offset + size); thinMach(0xfeedfacf, cpu).copy(bytes, offset); }); - await writeFile(path, bytes); - const resolved = await parseBinaryTarget(path, { hostArchitecture }); - if (!resolved.ok) throw resolved.error; - if (resolved.value.format !== "mach-o-universal") - throw new Error("the universal fixture did not resolve as a FAT container"); - return { bytes, target: resolved.value }; + return await resolveUniversalTarget(bytes, hostArchitecture); }; describe("IDA workspace input thinning", () => { + it("admits the multi-read FAT fixture on every host platform", async () => { + const { target } = await largeUniversalTarget(multiSliceLayout, "arm64"); + expect(target.universalSlice).toMatchObject({ offset: 16384, size: 33464 }); + }); + it.skipIf(process.platform === "win32")( "thins the selected arm64 slice into the private headless input", async () => { const { bytes, target } = await universalTarget("arm64"); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(offset).toBeGreaterThan(0); - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); - expect(input.length).toBe(size); + const { workspace, input } = await copySelectedSlice({ bytes, target }); + expect(target.universalSlice.offset).toBeGreaterThan(0); expect(input.readUInt32BE(0)).toBe(0xfeedfacf); expect(input.readUInt32BE(4)).toBe(0x0100000c); expect(parseExecutableHeader(input, "arm64")).toMatchObject({ ok: true, value: { format: "mach-o", architecture: "arm64" }, }); - expect(createHash("sha256").update(input).digest("hex")).toBe( - workspace.inputSha256, - ); await workspace.remove(); }, ); @@ -106,13 +121,7 @@ describe("IDA workspace input thinning", () => { "thins the x86_64 slice when the host is intel", async () => { const { bytes, target } = await universalTarget("x64"); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + const { workspace, input } = await copySelectedSlice({ bytes, target }); expect(input.readUInt32BE(4)).toBe(0x01000007); await workspace.remove(); }, @@ -122,31 +131,12 @@ describe("IDA workspace input thinning", () => { "copies exactly the selected slice when later slices follow it across read boundaries", async () => { const { bytes, target } = await largeUniversalTarget( - [ - { - cpu: 0x01000007, - offset: 4096, - size: 8480, - alignmentExponent: 12, - }, - { cpu: 0x0100000c, offset: 16384, size: 33464 }, - { cpu: 0x00000007, offset: 65536, size: 33464 }, - ], + multiSliceLayout, "arm64", ); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(size, "the arm64 slice is selected").toBe(33464); - expect(input.length, "no byte after the slice is copied").toBe(size); - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); - expect(workspace.inputSha256).toBe( - createHash("sha256") - .update(bytes.subarray(offset, offset + size)) - .digest("hex"), + const { workspace } = await copySelectedSlice({ bytes, target }); + expect(target.universalSlice.size, "the arm64 slice is selected").toBe( + 33464, ); await workspace.remove(); }, @@ -162,15 +152,10 @@ describe("IDA workspace input thinning", () => { ], "x64", ); - const workspace = await IdaWorkspace.allocate(target); - roots.push(workspace.directory); - await workspace.copyInput(); - - const input = await readFile(workspace.inputPath); - const { offset, size } = target.universalSlice; - expect(size, "the x86_64 slice is selected").toBe(37456); - expect(input.length, "no byte after the slice is copied").toBe(size); - expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true); + const { workspace } = await copySelectedSlice({ bytes, target }); + expect(target.universalSlice.size, "the x86_64 slice is selected").toBe( + 37456, + ); await workspace.remove(); }, ); diff --git a/src/objdump/ObjdumpCommand.ts b/src/objdump/ObjdumpCommand.ts new file mode 100644 index 000000000..bca39bff4 --- /dev/null +++ b/src/objdump/ObjdumpCommand.ts @@ -0,0 +1,24 @@ +import { resolve } from "node:path"; + +import type { ObjdumpInput } from "../domain/reverseEngineering.js"; + +export const OBJDUMP_PROVIDER_IDENTITY = { + id: "gnu.objdump", + name: "GNU objdump", + version: null, +} as const; + +/** Build GNU objdump argv without invoking a shell or following DWARF links by default. */ +export const objdumpCommand = (input: ObjdumpInput): readonly string[] => { + if (input.operation === "architectures") return ["-i"]; + const argumentsByOperation = { + file_headers: ["-f"], + section_headers: ["-h"], + symbols: ["-t"], + relocations: ["-r"], + dwarf: ["-W", input.follow_debug_links ? "-WK" : "-WN"], + disassemble: ["-d"], + disassemble_all: ["-D"], + } satisfies Record; + return [...argumentsByOperation[input.operation], resolve(input.path)]; +}; diff --git a/src/process/DarwinProcessRunTokenReader.timeout.test.ts b/src/process/DarwinProcessRunTokenReader.timeout.test.ts new file mode 100644 index 000000000..224b49600 --- /dev/null +++ b/src/process/DarwinProcessRunTokenReader.timeout.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveSwiftcCompileTimeoutMs } from "./DarwinProcessRunTokenReader.js"; + +describe("resolveSwiftcCompileTimeoutMs", () => { + it("keeps the historical default when unset", () => { + expect(resolveSwiftcCompileTimeoutMs({ environment: {} })).toBe(120_000); + }); + + it("prefers an explicit compileTimeoutMs over the environment", () => { + expect( + resolveSwiftcCompileTimeoutMs({ + compileTimeoutMs: 12_000, + environment: { REA_SWIFTC_COMPILE_TIMEOUT_MS: "300000" }, + }), + ).toBe(12_000); + }); + + it("reads a valid REA_SWIFTC_COMPILE_TIMEOUT_MS value", () => { + expect( + resolveSwiftcCompileTimeoutMs({ + environment: { REA_SWIFTC_COMPILE_TIMEOUT_MS: "300000" }, + }), + ).toBe(300_000); + }); + + it("falls back to the default for a malformed environment value", () => { + expect( + resolveSwiftcCompileTimeoutMs({ + environment: { REA_SWIFTC_COMPILE_TIMEOUT_MS: "loud" }, + }), + ).toBe(120_000); + }); +}); diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 335e72fbe..79e9d9c11 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -5,6 +5,10 @@ import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; +import { + DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS, + providerStartupTimeoutSchema, +} from "../config/providerStartupTimeout.js"; import { safeParseJson } from "../domain/safeJson.js"; import { boundedUtf8Text } from "./boundedUtf8Text.js"; import { execFileOutput, execFileOutputFailure } from "./ExecFileOutput.js"; @@ -73,10 +77,27 @@ const sourceFiles = [ */ export const SWIFTC_INTERRUPT = "SIGINT"; -/** Deadline for the one-shot helper compilation. */ -// A fresh Swift SDK cache took 88 seconds on the supported Intel macOS VM. -// Keep compilation bounded independently of short provider version probes. -const COMPILER_TIMEOUT_MS = 120_000; +/** + * Resolve the Darwin ownership-helper compile deadline from an explicit option + * or `REA_SWIFTC_COMPILE_TIMEOUT_MS`. The default matches the measured fresh + * Swift SDK cache on the supported Intel macOS VM (about 88s), so compilation + * stays bounded independently of short provider version probes. Invalid ambient + * values fall back to the default; `parseConfig` still rejects them at CLI/MCP + * admission. + */ +export const resolveSwiftcCompileTimeoutMs = ( + options: { + readonly compileTimeoutMs?: number; + readonly environment?: Readonly>; + } = {}, +): number => { + if (options.compileTimeoutMs !== undefined) return options.compileTimeoutMs; + const raw = (options.environment ?? process.env) + .REA_SWIFTC_COMPILE_TIMEOUT_MS; + if (raw === undefined) return DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS; + const parsed = providerStartupTimeoutSchema.safeParse(raw); + return parsed.success ? parsed.data : DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS; +}; /** Bound on compiler diagnostics embedded in the inspection failure text. */ const STDERR_EXCERPT_LIMIT = 512; @@ -257,6 +278,8 @@ export const createDarwinProcessRunTokenReader = ( readonly compiler?: DarwinProcessReaderCompiler; /** Deadline for helper compilation, independent of provider execution. */ readonly compileTimeoutMs?: number; + /** Environment consulted when `compileTimeoutMs` is omitted. */ + readonly environment?: Readonly>; /** * Caller-owned directory holding the compiler's module cache. Unset keeps * Swift's reusable default cache; a supplied directory outlives this @@ -269,7 +292,7 @@ export const createDarwinProcessRunTokenReader = ( kind: "xcrun", executable: "/usr/bin/xcrun", }; - const compilerTimeoutMs = options.compileTimeoutMs ?? COMPILER_TIMEOUT_MS; + const compilerTimeoutMs = resolveSwiftcCompileTimeoutMs(options); const moduleCachePath = options.moduleCachePath ?? ""; let root: string | undefined; let executable: string | undefined; diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 78d87800a..7d7905cc1 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -1,13 +1,49 @@ -import { access, readFile } from "node:fs/promises"; +import { access, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -import { execFileBytes, execFileOutput } from "./ExecFileOutput.js"; +import { + execFileBytes, + execFileOutput, + execFileOutputFailure, +} from "./ExecFileOutput.js"; + +const waitForFile = async (path: string): Promise => { + for (let attempt = 0; attempt < 500; attempt += 1) { + if ( + await access(path).then( + () => true, + () => false, + ) + ) + return true; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return access(path).then( + () => true, + () => false, + ); +}; describe("execFileOutput", () => { + it("ignores non-Error and incomplete failure metadata", () => { + expect(execFileOutputFailure("failure")).toBeUndefined(); + expect(execFileOutputFailure(new Error("failure"))).toBeUndefined(); + }); + + it("recognizes the execFile output limit code as truncated output", () => { + const failure = Object.assign(new Error("output limit"), { + stdout: "partial stdout", + stderr: "partial stderr", + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + }); + + expect(execFileOutputFailure(failure)?.outputTruncated).toBe(true); + }); + it("preserves captured bytes for callers that own the output format", async () => { const result = await execFileBytes(process.execPath, [ "-e", @@ -18,6 +54,20 @@ describe("execFileOutput", () => { expect(result.stderr).toEqual(Buffer.from([0xfe])); }); + it("reports raw byte counts when UTF-8 decoding changes the captured text", async () => { + const result = await execFileOutput(process.execPath, [ + "-e", + "process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac]));", + ]); + + expect(result).toMatchObject({ + stdout: "a�", + stderr: "€", + stdoutBytes: 2, + stderrBytes: 3, + }); + }); + it("captures output larger than Node's default execFile limit", async () => { const marker = "tail-marker"; const result = await execFileOutput(process.execPath, [ @@ -48,6 +98,14 @@ describe("execFileOutput", () => { ).rejects.toMatchObject({ code: 7, stdout: "out", stderr: "err" }); }); + it("uses the built-in execFile timeout when no stop signal is requested", async () => { + await expect( + execFileOutput(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + timeout: 100, + }), + ).rejects.toMatchObject({ killed: true }); + }); + it.skipIf(process.platform === "win32")( "stops an aborted process with the caller's signal and settles after it exits", async () => { @@ -75,25 +133,48 @@ describe("execFileOutput", () => { ), }), ); - for (let attempt = 0; attempt < 500; attempt += 1) { - if ( - await access(ready).then( - () => true, - () => false, - ) - ) - break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - + const readyObserved = await waitForFile(ready); controller.abort(); const outcome = await execution; + expect(readyObserved).toBe(true); expect(outcome?.cause).toMatchObject({ name: "AbortError" }); expect(outcome?.stoppedBeforeSettling).toBe("SIGINT"); }, ); + it.skipIf(process.platform === "win32")( + "keeps the first stop reason when timeout arrives during abort cleanup", + async () => { + const directory = await createTestTempDirectory("rea-exec-double-stop-"); + const ready = join(directory, "ready"); + const script = [ + 'const fs = require("node:fs");', + 'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});', + `fs.writeFileSync(${JSON.stringify(ready)}, "");`, + "setInterval(() => {}, 1000);", + ].join("\n"); + const controller = new AbortController(); + const execution = execFileOutput(process.execPath, ["-e", script], { + signal: controller.signal, + timeout: 10_000, + stopSignal: "SIGINT", + }); + const startedAt = Date.now(); + expect(await waitForFile(ready)).toBe(true); + const remainingBeforeDeadline = Math.max( + 0, + 9_000 - (Date.now() - startedAt), + ); + await new Promise((resolve) => + setTimeout(resolve, remainingBeforeDeadline), + ); + controller.abort(); + + await expect(execution).rejects.toMatchObject({ name: "AbortError" }); + }, + ); + it("rejects an already aborted stoppable command without starting it", async () => { const controller = new AbortController(); controller.abort(); @@ -149,6 +230,33 @@ describe.skipIf(process.platform === "win32")( }, ); + it("preserves a child failure's missing signal when reporting timeout", async () => { + const directory = await createTestTempDirectory( + "rea-exec-missing-signal-", + ); + const ready = join(directory, "ready"); + const script = [ + 'const fs = require("node:fs");', + 'for (const name of ["SIGINT", "SIGTERM"])', + " process.on(name, () => process.exit(1));", + `fs.writeFileSync(${JSON.stringify(ready)}, "");`, + "setInterval(() => {}, 1000);", + ].join("\n"); + try { + const execution = execFileOutput(process.execPath, ["-e", script], { + timeout: 10_000, + stopSignal: "SIGINT", + }); + expect(await waitForFile(ready)).toBe(true); + await expect(execution).rejects.toMatchObject({ + code: "ETIMEDOUT", + signal: null, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it("kills a timed-out process that ignores the stop signal", async () => { const script = [ 'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});', @@ -168,3 +276,40 @@ describe.skipIf(process.platform === "win32")( }); }, ); + +describe("execFileOutput failure metadata", () => { + it("preserves raw byte counts on a failed command with invalid UTF-8", async () => { + const failure: unknown = await execFileOutput(process.execPath, [ + "-e", + "process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac])); process.exitCode = 7;", + ]).then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(execFileOutputFailure(failure)).toMatchObject({ + stdout: "a�", + stderr: "€", + stdoutBytes: 2, + stderrBytes: 3, + code: 7, + }); + }); + it("preserves explicit truncation metadata from an owned process failure", () => { + const failure = Object.assign(new Error("owned output was truncated"), { + stdout: "partial stdout", + stderr: "partial stderr", + code: 7, + outputTruncated: true, + }); + + expect(execFileOutputFailure(failure)).toEqual({ + stdout: "partial stdout", + stderr: "partial stderr", + code: 7, + signal: null, + killed: false, + outputTruncated: true, + }); + }); +}); diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 08046d83d..b51f6e6ae 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -76,6 +76,9 @@ export interface ExecFileOutputFailure { readonly signal: string | null; readonly killed: boolean; readonly outputTruncated: boolean; + readonly stdoutBytes?: number; + readonly stderrBytes?: number; + readonly collectionFailure?: boolean; } /** Read captured output metadata from a failed execFileOutput invocation. */ @@ -88,15 +91,31 @@ export const execFileOutputFailure = ( const code = Reflect.get(cause, "code"); const signal = Reflect.get(cause, "signal"); const killed = Reflect.get(cause, "killed"); + const outputTruncated = Reflect.get(cause, "outputTruncated"); + const stdoutBytes = Reflect.get(cause, "stdoutBytes"); + const stderrBytes = Reflect.get(cause, "stderrBytes"); + const collectionFailure = Reflect.get(cause, "collectionFailure"); if (typeof stdout !== "string" || typeof stderr !== "string") return undefined; return { stdout, stderr, + ...(typeof stdoutBytes === "number" && + Number.isSafeInteger(stdoutBytes) && + stdoutBytes >= 0 + ? { stdoutBytes } + : {}), + ...(typeof stderrBytes === "number" && + Number.isSafeInteger(stderrBytes) && + stderrBytes >= 0 + ? { stderrBytes } + : {}), code: typeof code === "string" || typeof code === "number" ? code : null, signal: typeof signal === "string" ? signal : null, killed: killed === true, - outputTruncated: code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + outputTruncated: + outputTruncated === true || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + ...(collectionFailure === true ? { collectionFailure: true } : {}), }; }; @@ -150,6 +169,8 @@ export const execFileBytes = ( if (failure !== null) { Reflect.set(failure, "stdout", stdout.toString("utf8")); Reflect.set(failure, "stderr", stderr.toString("utf8")); + Reflect.set(failure, "stdoutBytes", stdout.byteLength); + Reflect.set(failure, "stderrBytes", stderr.byteLength); reject(failure); return; } @@ -179,8 +200,15 @@ export const execFileOutput = ( command: string, arguments_: readonly string[], options: ExecFileOutputOptions = {}, -): Promise<{ readonly stdout: string; readonly stderr: string }> => +): Promise<{ + readonly stdout: string; + readonly stderr: string; + readonly stdoutBytes: number; + readonly stderrBytes: number; +}> => execFileBytes(command, arguments_, options).then(({ stdout, stderr }) => ({ stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), + stdoutBytes: stdout.byteLength, + stderrBytes: stderr.byteLength, })); diff --git a/src/process/OwnedCommand.ts b/src/process/OwnedCommand.ts index d375971ae..3c2e0912d 100644 --- a/src/process/OwnedCommand.ts +++ b/src/process/OwnedCommand.ts @@ -20,18 +20,23 @@ import { /** A short-lived command failed its lifecycle or complete-output contract. */ export class OwnedCommandFailure extends Error { readonly cleanupResult: ProcessCleanupFailure | undefined; + readonly collectionFailure: boolean; constructor( readonly reason: "cancelled" | "timeout" | "output-limit" | "process", message: string, readonly snapshot: ProviderProcessSnapshot | null = null, readonly cleanupFailure: string | null = null, - options?: ErrorOptions & { readonly cleanupResult?: ProcessCleanupFailure }, + options?: ErrorOptions & { + readonly cleanupResult?: ProcessCleanupFailure; + readonly collectionFailure?: boolean; + }, readonly resources: readonly string[] = [], readonly cleanupOwner?: ProviderProcessSupervisor, ) { super(message, options); this.cleanupResult = options?.cleanupResult; + this.collectionFailure = options?.collectionFailure ?? false; } } @@ -56,7 +61,7 @@ export const runOwnedCommand = async ( readonly acceptNonZeroExit?: boolean; }, options: { - readonly signal?: AbortSignal; + readonly signal?: AbortSignal | undefined; /** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */ readonly retainCleanup?: (owner: ProviderProcessSupervisor) => void; readonly launcher?: ( @@ -156,7 +161,9 @@ const collectOwnedCommand = async ( "Command diagnostic output exceeded its complete-output budget.", ); if (processFailure !== undefined) - throw new OwnedCommandFailure("process", processFailure); + throw new OwnedCommandFailure("process", processFailure, null, null, { + collectionFailure: true, + }); await waitForAbortableDelay(25, signal); } const snapshot = supervisor.snapshot(); @@ -191,6 +198,8 @@ const collectOwnedCommand = async ( processFailure ?? "Command failed; inspect its exit status and diagnostics.", snapshot, + null, + { collectionFailure: processFailure !== undefined }, ); } catch (cause: unknown) { failure = @@ -219,6 +228,9 @@ const collectOwnedCommand = async ( ...(stopped.cleanupResult === undefined ? {} : { cleanupResult: stopped.cleanupResult }), + ...(failure?.collectionFailure === true + ? { collectionFailure: true } + : {}), }, ownedCommandResources(launched.ownership, spawn.cwd), supervisor, @@ -234,6 +246,7 @@ const collectOwnedCommand = async ( ...(failure.cleanupResult === undefined ? {} : { cleanupResult: failure.cleanupResult }), + ...(failure.collectionFailure ? { collectionFailure: true } : {}), }, failure.resources, ); diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 834335d57..5747cded8 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -225,6 +225,8 @@ export const createSystemProcessOwnershipHost = ( readonly darwinCompiler?: DarwinProcessReaderCompiler; /** Override the caller-owned module cache used compiling that helper. */ readonly darwinModuleCachePath?: string; + /** Deadline for Darwin ownership-helper compilation. */ + readonly compileTimeoutMs?: number; } = {}, ): ProcessOwnershipHost => { const darwinModuleCachePath = @@ -247,6 +249,9 @@ export const createSystemProcessOwnershipHost = ( ...(darwinModuleCachePath === undefined ? {} : { moduleCachePath: darwinModuleCachePath }), + ...(options.compileTimeoutMs === undefined + ? { environment: hostEnvironment } + : { compileTimeoutMs: options.compileTimeoutMs }), }) : undefined; const listProcesses = async (signal?: AbortSignal) => { diff --git a/src/process/ProviderDeadline.test.ts b/src/process/ProviderDeadline.test.ts index ca83d98ab..727132899 100644 --- a/src/process/ProviderDeadline.test.ts +++ b/src/process/ProviderDeadline.test.ts @@ -1,8 +1,106 @@ -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { ProviderStartupDeadline } from "./ProviderDeadline.js"; +beforeEach(() => { + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "Date", "performance"], + }); +}); + afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +it.each([-3_600_000, 3_600_000])( + "keeps the elapsed budget when the wall clock jumps by %i ms", + async (jump) => { + const deadline = new ProviderStartupDeadline(1000); + try { + await vi.advanceTimersByTimeAsync(250); + vi.setSystemTime(Date.now() + jump); + expect(deadline.remainingMs()).toBe(750); + expect(deadline.interruption).toBeUndefined(); + expect(deadline.signal.aborted).toBe(false); + const waiting = deadline.wait(2000); + await vi.advanceTimersByTimeAsync(749); + expect(deadline.interruption).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + await expect(waiting).resolves.toBe("aborted"); + expect(deadline.interruption).toBe("timeout"); + expect(deadline.remainingMs()).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + deadline.dispose(); + } + }, +); + +it("preserves caller cancellation after a forward wall-clock correction", async () => { + const controller = new AbortController(); + const reason = new Error("caller cancelled"); + const deadline = new ProviderStartupDeadline(1000, controller.signal); + try { + const waiting = deadline.wait(1000); + await vi.advanceTimersByTimeAsync(100); + vi.setSystemTime(Date.now() + 3_600_000); + controller.abort(reason); + await expect(waiting).resolves.toBe("aborted"); + expect(deadline.interruption).toBe("cancelled"); + expect(deadline.signal.reason).toBe(reason); + expect(vi.getTimerCount()).toBe(0); + } finally { + deadline.dispose(); + } +}); + +it("does not revive an expired budget after the wall clock moves backward", async () => { + const deadline = new ProviderStartupDeadline(1000); + try { + await vi.advanceTimersByTimeAsync(1000); + vi.setSystemTime(Date.now() - 3_600_000); + expect(deadline.remainingMs()).toBe(0); + expect(deadline.interruption).toBe("timeout"); + await expect(deadline.wait(1000)).resolves.toBe("aborted"); + expect(vi.getTimerCount()).toBe(0); + } finally { + deadline.dispose(); + } +}); + +it("classifies elapsed time before a delayed timer callback and a later abort", () => { + const controller = new AbortController(); + const deadline = new ProviderStartupDeadline(1000, controller.signal); + try { + // Model an event-loop stall: elapsed time advanced, but timers have not run. + vi.spyOn(performance, "now").mockReturnValue(1001); + vi.setSystemTime(Date.now() - 3_600_000); + expect(deadline.interruption).toBe("timeout"); + expect(deadline.remainingMs()).toBe(0); + controller.abort(); + expect(deadline.interruption).toBe("timeout"); + expect(deadline.signal.reason).toMatchObject({ name: "TimeoutError" }); + expect(vi.getTimerCount()).toBe(0); + } finally { + deadline.dispose(); + } +}); + +it("keeps remaining milliseconds integral for downstream request budgets", () => { + const now = vi.spyOn(performance, "now").mockReturnValue(10.25); + const deadline = new ProviderStartupDeadline(1000); + try { + now.mockReturnValue(260.5); + expect(deadline.remainingMs()).toBe(750); + now.mockReturnValue(1010.2); + expect(deadline.remainingMs()).toBe(1); + expect(deadline.interruption).toBeUndefined(); + now.mockReturnValue(1010.25); + expect(deadline.remainingMs()).toBe(0); + expect(deadline.interruption).toBe("timeout"); + } finally { + deadline.dispose(); + } }); it("keeps remaining time and cancellation classification stable across wall-clock jumps", () => { @@ -24,6 +122,7 @@ it("keeps remaining time and cancellation classification stable across wall-cloc }); it("aborts a bounded wait when its real monotonic deadline expires", async () => { + vi.useRealTimers(); const deadline = new ProviderStartupDeadline(40); try { await expect(deadline.wait(500)).resolves.toBe("aborted"); @@ -180,9 +279,9 @@ it("releases the timer and listener on dispose with a pending wait", async () => } }); -// Preserve the authored fractional-clock control while following the current -// owning deadline's re-arm contract for an early native timer callback. -it("reports zero after the re-armed deadline reaches a fractional early callback", async () => { +// Retain the authored terminal-budget regression under the newer monotonic +// expiry policy and re-arm contract for an early native timer callback. +it("reports no remaining time after terminal expiry near the monotonic boundary", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const now = vi.spyOn(performance, "now").mockReturnValue(1_000); const deadline = new ProviderStartupDeadline(40); @@ -195,6 +294,9 @@ it("reports zero after the re-armed deadline reaches a fractional early callback await vi.advanceTimersByTimeAsync(1); expect(deadline.signal.aborted).toBe(true); expect(deadline.interruption).toBe("timeout"); + // A terminal timeout stays terminal even if the monotonic source is + // observed below its deadline after the abort timer has fired. + now.mockReturnValue(1_039); expect(deadline.remainingMs()).toBe(0); } finally { deadline.dispose(); diff --git a/src/process/ProviderDeadline.ts b/src/process/ProviderDeadline.ts index 7681ecf62..26e1b0fb0 100644 --- a/src/process/ProviderDeadline.ts +++ b/src/process/ProviderDeadline.ts @@ -80,7 +80,8 @@ export class ProviderStartupDeadline { /** Milliseconds still available to the next startup phase. */ remainingMs(): number { // Node timers can fire a fraction of a millisecond before the monotonic - // deadline, so an elapsed timer must not report time left to spend. + // deadline, so an elapsed timer must not report time left to spend. A + // terminal timeout also cannot regain budget after later clock queries. if (this.#interruption === "timeout") return 0; return Math.max(0, Math.ceil(this.#deadlineAt - performance.now())); } diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 8e650898b..c5e547ac7 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -6,10 +6,14 @@ import { WindowsOwnedProcess } from "../windows/WindowsOwnedProcess.js"; import type { OwnedProcessGroup, + ProcessOwnershipBaseline, ProcessCleanupFailure, ProcessCleanupResult, } from "./ProcessOwnership.js"; -import { prepareProcessOwnershipInspection } from "./ProcessOwnershipObservation.js"; +import { + prepareProcessOwnershipInspection, + systemProcessOwnershipHost, +} from "./ProcessOwnershipObservation.js"; const DEFAULT_TERMINATION_GRACE_MS = 250; const DEFAULT_KILL_GRACE_MS = 1_000; @@ -36,6 +40,8 @@ export interface OwnedProviderProcessSpawnOptions { * Windows Job Object. Other launchers cannot enforce it and refuse it. */ readonly memoryLimitBytes?: number; + /** Sweep exact run-token descendants during POSIX cleanup. */ + readonly sweepTokenOwnedProcesses?: boolean; /** Cancels native ownership preparation before provider process creation. */ readonly signal?: AbortSignal; } @@ -95,6 +101,8 @@ export interface ProviderProcessSnapshot { readonly diagnosticTruncated?: boolean; readonly stdout: { readonly text: string; + /** True when retained bytes cannot be represented exactly as UTF-8 text. */ + readonly invalidUtf8?: true; /** Bytes retained in text, limited by maxDiagnosticBytes when configured. */ readonly bytes: number; /** All bytes drained from this stream since construction or resetOutput. */ @@ -102,6 +110,8 @@ export interface ProviderProcessSnapshot { }; readonly stderr: { readonly text: string; + /** True when retained bytes cannot be represented exactly as UTF-8 text. */ + readonly invalidUtf8?: true; /** Bytes retained in text, limited by maxDiagnosticBytes when configured. */ readonly bytes: number; readonly observedBytes: number; @@ -174,6 +184,16 @@ export const spawnOwnedProviderProcess = async ( await prepareProcessOwnershipInspection(options.signal); options.signal?.throwIfAborted(); const platform = options.platform ?? process.platform; + const sweepTokenOwnedProcesses = + options.sweepTokenOwnedProcesses === true && platform !== "win32"; + let captureBaseline: ProcessOwnershipBaseline | undefined; + if (sweepTokenOwnedProcesses) { + const capture = systemProcessOwnershipHost.captureBaseline; + if (capture === undefined) + throw new Error("Process ownership baseline is unavailable on this host"); + captureBaseline = await capture(options.signal); + options.signal?.throwIfAborted(); + } const hostEnvironment = options.hostEnvironment ?? process.env; const environment = { ...hostEnvironment, @@ -226,6 +246,12 @@ export const spawnOwnedProviderProcess = async ( leaderPid: pid, processGroupId: pid, expectedParentPid: process.pid, + ...(sweepTokenOwnedProcesses + ? { + sweepTokenOwnedProcesses: true, + captureBaseline: captureBaseline!, + } + : {}), ...(options.expectedCommand === null ? {} : { expectedCommand: options.expectedCommand ?? options.command }), @@ -282,12 +308,14 @@ export class ProviderProcessSupervisor { #exitObservation: ProviderProcessExitObservation | undefined; #stopPromise: Promise | undefined; #disposed = false; + #maxDiagnosticBytes: number | undefined; constructor( readonly launch: ProviderProcessLaunch, options: ProviderProcessSupervisorOptions = {}, ) { this.#options = options; + this.#maxDiagnosticBytes = options.maxDiagnosticBytes; if (options.captureStdout !== false) this.#attach(launch.process.stdout, "stdout"); this.#attach(launch.process.stderr, "stderr"); @@ -340,7 +368,7 @@ export class ProviderProcessSupervisor { ); } - /** Wait until the producer has closed its output streams; exit alone does not prove complete output. */ + /** Wait until output streams close; WindowsOwnedProcess closes only after its whole Job Object settles. */ async waitForOutputClose(timeoutMs: number): Promise { return waitForProcessEvent( this.#outputClosed, @@ -349,6 +377,35 @@ export class ProviderProcessSupervisor { ); } + /** Bound additional retained bytes while still draining all later output. */ + limitAdditionalOutputBytes(maxBytes: number): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) + throw new RangeError( + "Additional output limit must be a non-negative safe integer.", + ); + const retained = this.#stdout.bytes + this.#stderr.bytes; + const requestedLimit = Math.min( + Number.MAX_SAFE_INTEGER, + retained + maxBytes, + ); + this.#maxDiagnosticBytes = + this.#maxDiagnosticBytes === undefined + ? requestedLimit + : Math.min(this.#maxDiagnosticBytes, requestedLimit); + } + + /** Unreference inherited output pipes without closing streams shared with descendants. */ + unrefOutputStreams(): void { + for (const stream of [ + this.launch.process.stdout, + this.launch.process.stderr, + ]) { + const unref = (stream as (Readable & { unref?: () => void }) | null) + ?.unref; + unref?.call(stream); + } + } + /** Share each stop attempt; incomplete cleanup retains ownership and permits retry. */ stop( options: ProviderProcessStopOptions = {}, @@ -509,13 +566,11 @@ export class ProviderProcessSupervisor { if (stream === "stdout") this.#stdoutObservedBytes += bytes.byteLength; else this.#stderrObservedBytes += bytes.byteLength; const remaining = - this.#options.maxDiagnosticBytes === undefined + this.#maxDiagnosticBytes === undefined ? bytes.byteLength : Math.max( 0, - this.#options.maxDiagnosticBytes - - this.#stdout.bytes - - this.#stderr.bytes, + this.#maxDiagnosticBytes - this.#stdout.bytes - this.#stderr.bytes, ); const retained = Math.min(bytes.byteLength, remaining); if (retained < bytes.byteLength) this.#diagnosticTruncated = true; @@ -564,11 +619,19 @@ class ProcessOutputCapture { } snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] { - const text = Buffer.concat(this.#chunks, this.#bytes).toString("utf8"); + const buffer = Buffer.concat(this.#chunks, this.#bytes); + const text = buffer.toString("utf8"); + let invalidUtf8 = false; + try { + new TextDecoder("utf-8", { fatal: true }).decode(buffer); + } catch { + invalidUtf8 = true; + } return { text, bytes: this.#bytes, observedBytes, + ...(invalidUtf8 ? { invalidUtf8: true } : {}), }; } } diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts new file mode 100644 index 000000000..7fc2c0b4f --- /dev/null +++ b/src/process/ownedDebuggerSession.ts @@ -0,0 +1,311 @@ +import { readFile } from "node:fs/promises"; + +import { parseLinuxProcessStartTime } from "./ProcessOwnershipObservation.js"; + +/** Result of terminating one debugger process owned by this application. */ +export type OwnedDebuggerStopResult = + | { readonly status: "stopped" | "already_exited" } + | { readonly status: "incomplete"; readonly reason: string }; + +const DETACH_TIMEOUT_MS = 1_000; +const MAX_LATE_DEBUGGER_OUTPUT_BYTES = 64 * 1024; +export type OwnedDebuggerDetachResult = + | { readonly status: "no-inferior" } + | { readonly status: "detached"; readonly outputSink: "durable" }; +export interface OwnedInferiorIdentity { + readonly pid: string; + readonly startTime: string; +} + +export type ProcessStartTimeReader = (pid: string) => Promise; + +const readLinuxProcessStartTime: ProcessStartTimeReader = async (pid) => { + if (process.platform !== "linux") + throw new Error( + "Inferior process identity verification requires Linux /proc.", + ); + let stat: string; + try { + stat = await readFile(`/proc/${pid}/stat`, "utf8"); + } catch (cause: unknown) { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ) + return null; + throw cause; + } + const startTime = parseLinuxProcessStartTime(stat); + if (startTime === undefined) + throw new Error(`Inferior PID ${pid} has an unreadable process identity.`); + return startTime; +}; + +/** Confirm only that every previously identified inferior process has ended. */ +export const verifyOwnedInferiorsEnded = async ( + identities: Iterable, + readStartTime: ProcessStartTimeReader = readLinuxProcessStartTime, +): Promise => { + let foundIdentity = false; + for (const identity of identities) { + foundIdentity = true; + const observed = await readStartTime(identity.pid); + if (observed === identity.startTime) return false; + } + return foundIdentity; +}; + +const detachAttempts = new WeakMap< + object, + Promise +>(); +const detachOutcomes = new WeakMap< + object, + "confirmed" | "unconfirmed" | "inferior-ended" +>(); + +const isVerifiedDetachResult = ( + result: unknown, +): result is OwnedDebuggerDetachResult => + typeof result === "object" && + result !== null && + (("status" in result && result.status === "no-inferior") || + ("status" in result && + result.status === "detached" && + "outputSink" in result && + result.outputSink === "durable")); + +const waitForInferiorDetach = async ( + session: object, + detach: () => Promise, +): Promise => { + let attempt = detachAttempts.get(session); + if (attempt === undefined) { + // Providers may use `this` to access their command transport. + const pending = Promise.resolve().then(() => detach.call(session)); + const tracked = pending.then( + (result) => { + detachAttempts.delete(session); + detachOutcomes.set( + session, + isVerifiedDetachResult(result) ? "confirmed" : "unconfirmed", + ); + return result; + }, + (cause: unknown) => { + detachAttempts.delete(session); + detachOutcomes.set(session, "unconfirmed"); + throw cause; + }, + ); + attempt = tracked; + detachAttempts.set(session, tracked); + } + let timer!: NodeJS.Timeout; + try { + return await Promise.race([ + attempt, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), DETACH_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +interface OwnedDebuggerProcess { + readonly id: string; + readonly exited: boolean; + readonly inferiorStateUnknown?: () => boolean; + readonly verifyInferiorsEndedAfterExit?: () => Promise; + readonly launched: { + readonly process: { kill(signal: NodeJS.Signals): boolean }; + }; + /** Must return only after the provider verifies detachment or no inferior. */ + /** Confirms no inferior, or detaches only after verifying a durable output sink. */ + readonly detachInferior?: () => Promise; + readonly supervisor: { + waitForExit(timeoutMs: number): Promise; + /** WindowsOwnedProcess closes output only after the whole Job Object settles. */ + waitForOutputClose(timeoutMs: number): Promise; + unrefOutputStreams(): void; + /** Cap bytes retained after debugger shutdown while continuing to drain. */ + limitAdditionalOutputBytes(maxBytes: number): void; + dispose(): void; + }; +} + +const finishExitedDebuggerSession = async ( + session: OwnedDebuggerProcess, + debuggerName: string, + platform: NodeJS.Platform, + status: "stopped" | "already_exited", +): Promise => { + try { + session.supervisor.limitAdditionalOutputBytes( + MAX_LATE_DEBUGGER_OUTPUT_BYTES, + ); + if (!(await session.supervisor.waitForOutputClose(1_000))) { + return { + status: "incomplete", + reason: + platform === "win32" + ? `Owned ${debuggerName} process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.` + : `Owned ${debuggerName} process leader exited, but inherited output streams are still open or have not reported settlement.`, + }; + } + } catch (cause: unknown) { + return { + status: "incomplete", + reason: + platform === "win32" + ? `Owned ${debuggerName} Windows Job Object settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}` + : `Owned ${debuggerName} inherited output stream settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + } + if (platform !== "win32") session.supervisor.unrefOutputStreams(); + session.supervisor.dispose(); + return { status }; +}; + +/** Stop only the directly owned debugger process, never its process group or target. */ +export const stopOwnedDebuggerProcess = async ( + session: OwnedDebuggerProcess, + debuggerName: string, + options: { readonly platform?: NodeJS.Platform } = {}, +): Promise => { + const platform = options.platform ?? process.platform; + if (session.exited) { + if (detachAttempts.has(session)) + return { + status: "incomplete", + reason: `Owned ${debuggerName} inferior detach is still pending after the process exited.`, + }; + const detachOutcome = detachOutcomes.get(session); + if ( + detachOutcome === "unconfirmed" || + (detachOutcome === undefined && session.inferiorStateUnknown?.() === true) + ) { + try { + if ((await session.verifyInferiorsEndedAfterExit?.()) !== true) + return { + status: "incomplete", + reason: `Owned ${debuggerName} inferior disposition was not verified before the debugger exited, and no owner proof confirms the identified inferior processes have ended.`, + }; + detachOutcomes.set(session, "inferior-ended"); + } catch (cause: unknown) { + return { + status: "incomplete", + reason: `Owned ${debuggerName} inferior process identity could not be verified after debugger exit: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + } + } + return finishExitedDebuggerSession( + session, + debuggerName, + platform, + "already_exited", + ); + } + if (platform === "win32") + return { + status: "incomplete", + reason: `Owned ${debuggerName} process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.`, + }; + try { + if (session.detachInferior === undefined) { + detachOutcomes.set(session, "unconfirmed"); + return { + status: "incomplete", + reason: `Owned ${debuggerName} inferior detach cannot be verified because the provider has no detach capability.`, + }; + } + const detached = await waitForInferiorDetach( + session, + session.detachInferior, + ); + if (detached === undefined) + return { + status: "incomplete", + reason: `Owned ${debuggerName} inferior detach was not verified within ${DETACH_TIMEOUT_MS} ms.`, + }; + if (!isVerifiedDetachResult(detached)) { + detachOutcomes.set(session, "unconfirmed"); + return { + status: "incomplete", + reason: `Owned ${debuggerName} provider did not confirm no inferior or detachment with a durable output sink.`, + }; + } + detachOutcomes.set(session, "confirmed"); + } catch (cause: unknown) { + return { + status: "incomplete", + reason: `Owned ${debuggerName} inferior detach could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + } + session.launched.process.kill("SIGTERM"); + if (!(await session.supervisor.waitForExit(300))) { + session.launched.process.kill("SIGKILL"); + const exitedAfterKill = await session.supervisor.waitForExit(1_000); + if (exitedAfterKill) + return finishExitedDebuggerSession( + session, + debuggerName, + platform, + "stopped", + ); + return { + status: "incomplete", + reason: `Owned ${debuggerName} process did not exit after direct termination.`, + }; + } + return finishExitedDebuggerSession( + session, + debuggerName, + platform, + "stopped", + ); +}; + +/** + * Stop every owned session and retain failed sessions for possible recovery. + * Callers must block replacement admission and drain in-flight starts until + * this settles; incomplete cleanup leaves the old owner in the map for retry. + */ +export const closeOwnedDebuggerSessions = async < + Session extends { id: string }, +>( + sessions: Map, + stop: (session: Session) => Promise, + debuggerName: string, +): Promise => { + const results = await Promise.allSettled( + [...sessions.entries()].map(async ([key, session]) => ({ + key, + session, + result: await stop(session), + })), + ); + const failures: Error[] = []; + for (const settled of results) { + if (settled.status === "rejected") { + failures.push( + settled.reason instanceof Error + ? settled.reason + : new Error(String(settled.reason)), + ); + continue; + } + const { key, session, result } = settled.value; + if (result.status === "incomplete") failures.push(new Error(result.reason)); + else if (sessions.get(key) === session) sessions.delete(key); + } + if (failures.length > 0) + throw new AggregateError( + failures, + `One or more owned ${debuggerName} processes did not stop cleanly`, + ); +}; diff --git a/src/rizin/RizinCommand.ts b/src/rizin/RizinCommand.ts new file mode 100644 index 000000000..e2431da63 --- /dev/null +++ b/src/rizin/RizinCommand.ts @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; + +import type { RizinInput } from "../domain/reverseEngineering.js"; + +export const RIZIN_PROVIDER_IDENTITY = { + id: "rizin", + name: "Rizin", + version: null, +} as const; + +/** Build a one-shot rz-pipe compatible command while retaining plugin availability. */ +export const rizinCommand = (input: RizinInput): readonly string[] => [ + "-N", + "-q", + "-c", + input.command, + resolve(input.path), +]; diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts new file mode 100644 index 000000000..fbad4a9e6 --- /dev/null +++ b/src/rizin/RizinDebugSessionManager.ts @@ -0,0 +1,1590 @@ +import { randomUUID } from "node:crypto"; +import { isUtf8 } from "node:buffer"; +import { open, readFile, readlink, realpath, stat } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { isatty } from "node:tty"; +import { resolve } from "node:path"; + +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import type { + AnalysisError, + AnalysisErrorOptions, +} from "../domain/analysisErrorBase.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, + type SpawnedOwnedProviderProcess, +} from "../process/ProviderProcess.js"; +import { + closeOwnedDebuggerSessions, + stopOwnedDebuggerProcess, + verifyOwnedInferiorsEnded, + type OwnedInferiorIdentity, + type OwnedDebuggerDetachResult, +} from "../process/ownedDebuggerSession.js"; + +const MAX_SESSIONS = 8; +const MAX_PENDING_COMMANDS = 16; +const FRAME_TIMEOUT_MS = 120_000; +const CLEANUP_COMMAND_TIMEOUT_MS = 1_000; +const STARTUP_TIMEOUT_MS = 30_000; +const MAX_FRAME_BYTES = 16 * 1024 * 1024; +const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; +const MAX_QUEUED_FRAME_BYTES = 4 * 1024 * 1024; +const MAX_QUEUED_FRAMES = 4096; +const MIN_QUEUED_FRAME_CHARGE_BYTES = 64; +const MAX_COMMAND_FRAMES = 4096; +const MAX_RECENT_FRAME_BYTES = 16 * 1024; +const MAX_DIAGNOSTIC_BYTES = 64 * 1024; + +export const RIZIN_DEBUGGER_PROVIDER_IDENTITY = { + id: "rizin.debugger", + name: "Rizin debugger", + version: null, +} as const; + +interface RizinLaunchProvenance { + readonly caller_selected_path: string; + readonly resolved_path: string; +} + +const RIZIN_LAUNCH_PROVENANCE_LIMITATION = + "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands."; + +interface RizinCommandEvidenceInput { + readonly sessionId: string; + readonly command: string; + readonly result: RizinDebugCommandResult; + readonly inferiorTty: string; + readonly launchProvenance: RizinLaunchProvenance; + readonly limitations: readonly string[]; +} + +const createRizinCommandEvidence = ({ + sessionId, + command, + result, + inferiorTty, + launchProvenance, + limitations, +}: RizinCommandEvidenceInput): Evidence => + createEvidence(undefined, RIZIN_DEBUGGER_PROVIDER_IDENTITY, { + predicateType: "rea.debugger.command-observation", + operation: "rizin_debug_command", + parameters: { + session_id: sessionId, + command, + inferior_tty: inferiorTty, + launch_provenance: { ...launchProvenance }, + }, + result: { ...result }, + rawResult: { ...result }, + subjectUnavailableReason: + "The current debugger target identity is not established by the Rizin session identifier.", + limitations: [ + ...limitations, + ...(result.output_invalid_utf8 + ? [ + "Collected command frames contained invalid UTF-8 bytes; decoded text may contain replacement characters.", + ] + : []), + RIZIN_LAUNCH_PROVENANCE_LIMITATION, + ], + }); + +export class RizinCommandTimeoutError extends Error { + constructor( + readonly partialOutput: string, + readonly outputInvalidUtf8 = false, + ) { + super("Timed out waiting for Rizin's command-completion marker"); + } +} + +export const isRizinCommandDeadlineExpired = (remainingMs: number): boolean => + remainingMs <= 0; + +export const createRizinCommandTimeoutError = ( + partialFrames: readonly string[], + outputInvalidUtf8 = false, +): RizinCommandTimeoutError => + new RizinCommandTimeoutError(partialFrames.join("\n"), outputInvalidUtf8); + +interface RizinCommandTimeoutEvidenceInput { + readonly sessionId: string; + readonly command: string; + readonly result: RizinDebugCommandResult; + readonly inferiorTty: string; + readonly cleanupFailure: string | undefined; + readonly launchProvenance: RizinLaunchProvenance; +} + +export const createRizinCommandTimeoutEvidence = ({ + sessionId, + command, + result, + inferiorTty, + cleanupFailure, + launchProvenance, +}: RizinCommandTimeoutEvidenceInput): Evidence => + createRizinCommandEvidence({ + sessionId, + command, + result, + inferiorTty, + launchProvenance, + limitations: [ + "The command timed out before its completion marker; retained output is partial and completion is unknown.", + ...(result.output_invalid_utf8 + ? [ + "Collected command frame bytes did not form valid UTF-8; displayed output contains replacement characters.", + ] + : []), + ...(cleanupFailure === undefined + ? [ + "The owned debugger session was stopped because output correlation could no longer be guaranteed.", + ] + : [ + `The owned debugger session remains retained because cleanup could not be verified: ${cleanupFailure}`, + ]), + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + ], + }); + +interface RizinDebugSession { + readonly id: string; + readonly launched: SpawnedOwnedProviderProcess; + readonly supervisor: ProviderProcessSupervisor; + readonly frames: Array<{ + readonly frame: RizinFrame; + readonly queueChargeBytes: number; + }>; + readonly frameWaiters: Array<{ + readonly resolve: (frame: RizinFrame) => void; + readonly reject: (cause: Error) => void; + }>; + readonly backend: string | null; + readonly launchProvenance: RizinLaunchProvenance; + readonly inferiorTty: string; + readonly inferiorTtyDevice: number; + readonly inferiorTtyInode: number; + readonly knownInferiorIdentities: Map; + inferiorInventoryComplete: boolean; + inferiorStateUnknown: () => boolean; + verifyInferiorsEndedAfterExit: () => Promise; + readonly detachInferior: () => Promise; + cleanupCommandTurnHeld: boolean; + stopAttempt: ReturnType | undefined; + readonly cleanupFailures: Set; + pendingCommandCount: number; + invalidUtf8Observed: boolean; + closing: boolean; + readonly history: string[]; + queuedFrameBytes: number; + historyTruncated: boolean; + frameChunks: Buffer[]; + bufferedFrameBytes: number; + ready: boolean; + exited: boolean; + commandTail: Promise; + protocolError: string | undefined; + commandCaptureActive: boolean; + discardingOversizedFrame: boolean; +} + +interface RizinFrame { + readonly text: string; + readonly truncated: boolean; + readonly invalidUtf8: boolean; +} + +class RizinProcessExitError extends Error { + constructor( + readonly partialOutput: string, + readonly outputInvalidUtf8 = false, + ) { + super("Rizin process exited before completing the active command"); + } +} + +class RizinCommandCancelledError extends AnalysisCancelledError { + constructor( + readonly partialOutput: string, + readonly outputInvalidUtf8: boolean, + cause: unknown, + ) { + super("rizin_debug_command", { + cause, + capturedOutput: { + stdout: partialOutput, + stderr: "", + truncated: false, + }, + }); + } +} + +class RizinFrameTimeoutError extends Error { + constructor() { + super("Timed out waiting for the next Rizin NUL frame"); + } +} + +/** Owns persistent Rizin debugger processes independently from BinarySession. */ +export class RizinDebugSessionManager { + readonly #sessions = new Map(); + #activeStarts = 0; + #sessionReservations = 0; + readonly #environment: Readonly; + #closingAll = false; + readonly #startDrainWaiters: Array<() => void> = []; + readonly #command: string; + readonly #frameTimeoutMs: number; + readonly #startupTimeoutMs: number; + readonly #maxCommandOutputBytes: number; + readonly #platform: NodeJS.Platform; + readonly #stopProcess: typeof stopOwnedDebuggerProcess; + readonly #spawnProcess: typeof spawnOwnedProviderProcess; + + constructor( + environment: Readonly = process.env, + options: { + readonly frameTimeoutMs?: number; + /** @internal Test seam; production callers should use the 30 s default. */ + readonly startupTimeoutMs?: number; + /** @internal Test seam; production callers should use the 16 MiB default. */ + readonly maxCommandOutputBytes?: number; + readonly platform?: NodeJS.Platform; + readonly stopProcess?: typeof stopOwnedDebuggerProcess; + readonly spawnProcess?: typeof spawnOwnedProviderProcess; + } = {}, + ) { + this.#environment = { ...environment }; + this.#command = environment.REA_RIZIN_COMMAND ?? "rizin"; + this.#frameTimeoutMs = options.frameTimeoutMs ?? FRAME_TIMEOUT_MS; + this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; + this.#maxCommandOutputBytes = + options.maxCommandOutputBytes ?? MAX_COMMAND_OUTPUT_BYTES; + this.#platform = options.platform ?? process.platform; + this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess; + this.#spawnProcess = options.spawnProcess ?? spawnOwnedProviderProcess; + } + + async start( + input: { + readonly path: string; + readonly inferior_tty: string; + readonly backend?: string; + }, + signal?: AbortSignal, + ): Promise< + Result< + { readonly session_id: string; readonly backend: string | null }, + AnalysisError + > + > { + if (this.#closingAll) + return err(new AnalysisInputError("start_rizin_debug_session")); + const occupied = this.#sessions.size + this.#sessionReservations; + if (occupied >= MAX_SESSIONS) + return err( + new AnalysisResourceConstraintError( + "start_rizin_debug_session", + "memory", + "Rizin session capacity reached.", + { max_sessions: MAX_SESSIONS, active_or_reserved_sessions: occupied }, + ), + ); + this.#activeStarts += 1; + this.#sessionReservations += 1; + let reserved = true; + const releaseReservation = () => { + if (!reserved) return; + reserved = false; + this.#sessionReservations -= 1; + }; + try { + return await this.#start(input, signal, releaseReservation); + } finally { + releaseReservation(); + this.#activeStarts -= 1; + if (this.#activeStarts === 0) + for (const resolve of this.#startDrainWaiters.splice(0)) resolve(); + } + } + + async #start( + input: { + readonly path: string; + readonly inferior_tty: string; + readonly backend?: string; + }, + signal: AbortSignal | undefined, + releaseReservation: () => void, + ): Promise< + Result< + { readonly session_id: string; readonly backend: string | null }, + AnalysisError + > + > { + if (signal?.aborted) + return err(new AnalysisCancelledError("start_rizin_debug_session")); + if (this.#platform !== "linux") + return err( + new AnalysisCapabilityUnavailableError( + "rizin", + "start_rizin_debug_session", + "Persistent Rizin debugger sessions require Linux /proc descriptor readback to verify inferior detachment and output routing.", + ), + ); + if (input.inferior_tty === undefined || input.inferior_tty.length === 0) + return err(new AnalysisInputError("start_rizin_debug_session")); + let ttyPath: string; + let ttyDevice: number; + let ttyInode: number; + try { + ttyPath = await realpath(input.inferior_tty); + const ttyHandle = await open( + ttyPath, + fsConstants.O_RDWR | fsConstants.O_NOCTTY | fsConstants.O_NONBLOCK, + ); + try { + const ttyStat = await ttyHandle.stat(); + if ( + !isatty(ttyHandle.fd) || + (ttyStat.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR + ) + throw new Error("The selected inferior_tty is not a character TTY."); + ttyDevice = ttyStat.dev; + ttyInode = ttyStat.ino; + } finally { + await ttyHandle.close(); + } + } catch (cause) { + return err( + new AnalysisInputError("start_rizin_debug_session", { cause }), + ); + } + // TTY identity validation is asynchronous. Cancellation may arrive while + // realpath/open/stat are pending, and must prevent the debugger spawn. + if (signal?.aborted) + return err(new AnalysisCancelledError("start_rizin_debug_session")); + const id = randomUUID(); + const resolvedPath = resolve(input.path); + const launchProvenance: RizinLaunchProvenance = { + caller_selected_path: input.path, + resolved_path: resolvedPath, + }; + const arguments_ = [ + "-N", + "-0", + "-d", + "-R", + `stdout=${ttyPath}`, + "-R", + `stderr=${ttyPath}`, + "-e", + "dbg.exitkills=false", + ...(input.backend === undefined ? [] : ["-D", input.backend]), + resolvedPath, + ]; + let launched: SpawnedOwnedProviderProcess; + try { + launched = await this.#spawnProcess({ + command: this.#command, + arguments: arguments_, + hostEnvironment: this.#environment, + runId: id, + stdin: "pipe", + ...(signal === undefined ? {} : { signal }), + }); + } catch (cause) { + if (signal?.aborted) + return err( + new AnalysisCancelledError("start_rizin_debug_session", { cause }), + ); + return err( + new AnalysisCapabilityUnavailableError( + "rizin", + "start_rizin_debug_session", + cause instanceof Error + ? cause.message + : "Unable to start Rizin debugger", + ), + ); + } + const session: RizinDebugSession = { + id, + launched, + launchProvenance, + backend: input.backend ?? null, + inferiorTty: ttyPath, + inferiorTtyDevice: ttyDevice, + inferiorTtyInode: ttyInode, + knownInferiorIdentities: new Map(), + inferiorInventoryComplete: false, + inferiorStateUnknown: () => !session.inferiorInventoryComplete, + verifyInferiorsEndedAfterExit: async () => { + if (!session.inferiorInventoryComplete) return false; + const ended = await verifyOwnedInferiorsEnded( + session.knownInferiorIdentities.values(), + ); + if (ended) session.knownInferiorIdentities.clear(); + return ended; + }, + detachInferior: () => + session.cleanupCommandTurnHeld + ? this.#detachInferiorUnlocked(session) + : this.#detachInferior(session), + cleanupCommandTurnHeld: false, + stopAttempt: undefined, + cleanupFailures: new Set(), + pendingCommandCount: 0, + invalidUtf8Observed: false, + closing: false, + supervisor: new ProviderProcessSupervisor( + { process: launched.process, ownsProcessLifetime: false }, + { + captureStdout: false, + maxDiagnosticBytes: MAX_DIAGNOSTIC_BYTES, + }, + ), + frames: [], + frameWaiters: [], + history: [], + queuedFrameBytes: 0, + historyTruncated: false, + frameChunks: [], + bufferedFrameBytes: 0, + ready: false, + exited: false, + commandTail: Promise.resolve(), + protocolError: undefined, + commandCaptureActive: false, + discardingOversizedFrame: false, + }; + this.#sessions.set(id, session); + releaseReservation(); + launched.process.stdout?.on("data", (chunk: Buffer | string) => + this.#receive(session, Buffer.from(chunk)), + ); + launched.process.stdin?.on("error", (cause: Error) => + this.#failProtocol( + session, + `Rizin stdin stream failed: ${cause.message}`, + ), + ); + launched.process.stdin?.once("close", () => { + if (!session.exited && session.protocolError === undefined) + this.#failProtocol(session, "Rizin stdin stream closed unexpectedly."); + }); + launched.process.once("exit", () => { + session.exited = true; + for (const waiter of session.frameWaiters) + waiter.reject(new RizinProcessExitError("")); + session.frameWaiters.length = 0; + }); + try { + await waitForFrame(session, this.#startupTimeoutMs, signal); + // Let process and stream lifecycle notifications queued alongside the + // initial frame run before publishing the session as ready. + await new Promise((resolve) => setImmediate(resolve)); + if (signal?.aborted) + throw new AnalysisCancelledError("start_rizin_debug_session"); + if (session.exited) + throw new RizinProcessExitError("Rizin exited during startup."); + if (session.protocolError !== undefined) + throw new Error(session.protocolError); + session.ready = true; + return ok({ session_id: id, backend: input.backend ?? null }); + } catch (cause) { + const stopped = await this.#stopDebuggerOnly(session); + const capturedOutput = this.#startupCapturedOutput(session); + if (stopped.status !== "incomplete") this.#sessions.delete(id); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "start_rizin_debug_session", + stopped.reason, + { cause, capturedOutput }, + ), + ); + if (signal?.aborted || cause instanceof AnalysisCancelledError) + return err( + new AnalysisCancelledError("start_rizin_debug_session", { + cause, + capturedOutput, + }), + ); + if (session.protocolError !== undefined) + return err( + new AnalysisCapabilityUnavailableError( + "rizin", + "start_rizin_debug_session", + session.protocolError, + { cause, capturedOutput }, + ), + ); + if (cause instanceof RizinProcessExitError) { + return err( + new AnalysisCapabilityUnavailableError( + "rizin", + "start_rizin_debug_session", + cause.message, + { + cause, + capturedOutput, + }, + ), + ); + } + return err( + new AnalysisTimeoutError( + "start_rizin_debug_session", + this.#startupTimeoutMs, + { cause, capturedOutput }, + ), + ); + } + } + + async execute( + sessionId: string, + command: string, + signal?: AbortSignal, + ): Promise< + Result< + { readonly result: RizinDebugCommandResult; readonly evidence: Evidence }, + AnalysisError + > + > { + const session = this.#sessions.get(sessionId); + if ( + session === undefined || + !session.ready || + session.exited || + session.protocolError !== undefined || + session.closing || + this.#closingAll + ) + return err(new AnalysisInputError("rizin_debug_command")); + if (signal?.aborted) + return err(new AnalysisCancelledError("rizin_debug_command")); + if (session.pendingCommandCount >= MAX_PENDING_COMMANDS) + return err( + new AnalysisResourceConstraintError( + "rizin_debug_command", + "transport", + "Rizin command queue capacity reached.", + { + max_pending_commands: MAX_PENDING_COMMANDS, + active_or_queued_commands: session.pendingCommandCount, + }, + ), + ); + session.pendingCommandCount += 1; + let release: (() => void) | undefined; + let acquired = false; + let commandSent = false; + const previous = session.commandTail; + session.commandTail = new Promise((resolve) => { + release = resolve; + }); + try { + await waitForTurn(previous, signal); + acquired = true; + if (signal?.aborted) + return err(new AnalysisCancelledError("rizin_debug_command")); + if ( + !session.ready || + session.exited || + session.closing || + this.#closingAll || + session.protocolError !== undefined + ) + return err(new AnalysisInputError("rizin_debug_command")); + if (session.frames.length > 0) { + const message = + "Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated."; + this.#failProtocol(session, message); + return err( + await this.#stopAfterProtocolFailure(session, undefined, true), + ); + } + const marker = `__REA_COMMAND_COMPLETE_${randomUUID().replaceAll("-", "")}__`; + // Arbitrary debugger commands can create or replace inferiors. Discard + // the prior inventory before any command bytes reach the provider. + session.inferiorInventoryComplete = false; + session.knownInferiorIdentities.clear(); + commandSent = true; + const stdin = session.launched.process.stdin; + if ( + stdin === null || + stdin === undefined || + stdin.destroyed || + !stdin.writable + ) { + this.#failProtocol(session, "Rizin stdin stream is closed."); + } else { + try { + stdin.write( + `${command}\n!echo ${marker}\n`, + "utf8", + (cause?: Error | null) => { + if (cause) + this.#failProtocol( + session, + `Rizin stdin write failed: ${cause.message}`, + ); + }, + ); + } catch (cause) { + this.#failProtocol( + session, + `Rizin stdin write failed: ${cause instanceof Error ? cause.message : "unknown error"}`, + ); + } + } + if (session.protocolError !== undefined) + throw new Error(session.protocolError); + session.commandCaptureActive = true; + const captured = await waitForCommandFrames( + session, + marker, + this.#frameTimeoutMs, + signal, + this.#maxCommandOutputBytes, + ); + if (signal?.aborted) + throw new AnalysisCancelledError("rizin_debug_command"); + let cleanupFailure: string | undefined; + if (captured.outputTruncated) { + // Stryker disable next-line BooleanLiteral -- `waitForCommandFrames` sets `protocolError` before reporting truncation; both detach wrappers therefore fail closed before a queued command can be awaited, so the held-tail flag cannot change this public path. + const stopped = await this.#stopDebuggerOnly(session, true); + if (stopped.status === "incomplete") cleanupFailure = stopped.reason; + } + const result = { + command, + output: captured.output, + output_scope: "command_and_interleaved_session_output" as const, + output_truncated: captured.outputTruncated, + completion_status: captured.outputTruncated + ? ("unknown" as const) + : ("complete" as const), + backend: session.backend, + ...(captured.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + }; + const evidence = createRizinCommandEvidence({ + sessionId, + command, + result, + inferiorTty: session.inferiorTty, + launchProvenance: session.launchProvenance, + limitations: [ + "Rizin commands and debugger effects depend on the loaded IO plugin and target.", + "Output contains all NUL frames through an explicit command-completion marker; backend output interleaved before that marker cannot be distinguished from command output.", + ...(captured.outputTruncated + ? [ + `The Rizin command exceeded the output or frame limit. Retained output is partial and command completion is unknown.${cleanupFailure === undefined ? " The owned debugger session was stopped." : ` The owned debugger session remains retained because cleanup could not be verified: ${cleanupFailure}`}`, + ] + : []), + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + ], + }); + if (cleanupFailure !== undefined) + return err( + this.#retainCleanupFailure( + session, + "rizin_debug_command", + cleanupFailure, + { + partialObservation: evidence, + }, + ), + ); + return ok({ result, evidence }); + } catch (cause) { + if (signal?.aborted || cause instanceof AnalysisCancelledError) { + if (!commandSent) + return err( + new AnalysisCancelledError("rizin_debug_command", { cause }), + ); + const partialOutput = + cause instanceof RizinCommandCancelledError + ? cause.partialOutput + : undefined; + const commandInvalidUtf8 = + cause instanceof RizinCommandCancelledError && + cause.outputInvalidUtf8; + const partialEvidence = + partialOutput === undefined + ? undefined + : createRizinCommandEvidence({ + sessionId, + command, + result: { + command, + output: partialOutput, + output_scope: "command_and_interleaved_session_output", + output_truncated: false, + completion_status: "unknown", + backend: session.backend, + ...(commandInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + }, + inferiorTty: session.inferiorTty, + launchProvenance: session.launchProvenance, + limitations: [ + "The Rizin command was cancelled; collected frame output is partial and command completion is unknown.", + "The owned debugger session was stopped because command output can no longer be correlated safely.", + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + ], + }); + session.protocolError = + "A Rizin command was cancelled; the owned session was stopped because command output can no longer be correlated safely."; + const stopped = await this.#stopDebuggerOnly(session, true); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "rizin_debug_command", + stopped.reason, + { + cause: new AnalysisCancelledError("rizin_debug_command", { + cause, + ...(cause instanceof RizinCommandCancelledError + ? { capturedOutput: cause.capturedOutput } + : {}), + }), + ...(partialEvidence === undefined + ? {} + : { partialObservation: partialEvidence }), + }, + ), + ); + return err( + new AnalysisCancelledError("rizin_debug_command", { + cause, + ...(cause instanceof RizinCommandCancelledError + ? { capturedOutput: cause.capturedOutput } + : {}), + ...(partialEvidence === undefined + ? {} + : { partialObservation: partialEvidence }), + }), + ); + } + if (session.protocolError !== undefined) + return err(await this.#stopAfterProtocolFailure(session, cause, true)); + if (cause instanceof RizinCommandTimeoutError) { + session.protocolError = + "Rizin command timed out before its completion marker; output correlation is no longer safe."; + const stopped = await this.#stopDebuggerOnly(session, true); + const result: RizinDebugCommandResult = { + command, + output: cause.partialOutput, + output_scope: "command_and_interleaved_session_output", + output_truncated: true, + completion_status: "unknown", + backend: session.backend, + ...(cause.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + }; + const evidence = createRizinCommandTimeoutEvidence({ + sessionId, + command, + result, + inferiorTty: session.inferiorTty, + cleanupFailure: + stopped.status === "incomplete" ? stopped.reason : undefined, + launchProvenance: session.launchProvenance, + }); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "rizin_debug_command", + stopped.reason, + { + cause, + partialObservation: evidence, + }, + ), + ); + return ok({ result, evidence }); + } + if (cause instanceof RizinProcessExitError) { + const result: RizinDebugCommandResult = { + command, + output: cause.partialOutput, + output_scope: "command_and_interleaved_session_output", + output_truncated: false, + completion_status: "unknown", + backend: session.backend, + ...(cause.outputInvalidUtf8 + ? { output_invalid_utf8: true as const } + : {}), + }; + const evidence = createRizinCommandEvidence({ + sessionId, + command, + result, + inferiorTty: session.inferiorTty, + launchProvenance: session.launchProvenance, + limitations: [ + "Rizin exited before the command-completion marker; retained frame output is partial and command completion is unknown.", + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + ], + }); + const stopped = await this.#stopDebuggerOnly(session, true); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "rizin_debug_command", + stopped.reason, + { cause, partialObservation: evidence }, + ), + ); + return ok({ result, evidence }); + } + session.protocolError = + "Rizin did not return the current command-completion marker; the session was stopped to prevent late output from being attributed to a later command."; + const stopped = await this.#stopDebuggerOnly(session, true); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "rizin_debug_command", + stopped.reason, + { cause }, + ), + ); + return err( + new AnalysisTimeoutError("rizin_debug_command", this.#frameTimeoutMs, { + cause, + }), + ); + } finally { + if (acquired) { + session.commandCaptureActive = false; + session.pendingCommandCount -= 1; + release?.(); + } else + void previous.then(() => { + session.pendingCommandCount -= 1; + release?.(); + }); + } + } + + status(sessionId: string): + | { + readonly session_id: string; + readonly state: "ready" | "closed"; + readonly inferior_tty: string; + readonly recent_output: readonly string[]; + readonly recent_output_truncated: boolean; + readonly diagnostics_truncated: boolean; + readonly output_invalid_utf8: boolean; + } + | undefined { + const session = this.#sessions.get(sessionId); + return session === undefined + ? undefined + : { + session_id: session.id, + state: + session.exited || + session.protocolError !== undefined || + session.closing || + !session.ready + ? "closed" + : "ready", + inferior_tty: session.inferiorTty, + recent_output: [...session.history], + recent_output_truncated: session.historyTruncated, + diagnostics_truncated: + session.supervisor.snapshot().diagnosticTruncated === true, + output_invalid_utf8: session.invalidUtf8Observed, + }; + } + + async close( + sessionId: string, + ): Promise> { + if (this.#closingAll) + return err(new AnalysisInputError("close_rizin_debug_session")); + const session = this.#sessions.get(sessionId); + if (session === undefined) + return err(new AnalysisInputError("close_rizin_debug_session")); + session.closing = true; + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") + return err( + this.#retainCleanupFailure( + session, + "close_rizin_debug_session", + stopped.reason, + ), + ); + this.#sessions.delete(sessionId); + return ok({ target_state: "unknown" }); + } + + async retryCleanupFailures(): Promise> { + for (const session of this.#sessions.values()) { + if (session.cleanupFailures.size === 0) continue; + const stopped = await this.#stopDebuggerOnly(session); + if (stopped.status === "incomplete") + return err( + [...session.cleanupFailures].at(-1) ?? + this.#retainCleanupFailure( + session, + "close_rizin_debug_session", + stopped.reason, + ), + ); + this.#sessions.delete(session.id); + } + return ok(null); + } + + async closeAll(): Promise { + this.#closingAll = true; + if (this.#activeStarts > 0) + await new Promise((resolve) => + this.#startDrainWaiters.push(resolve), + ); + for (const session of this.#sessions.values()) session.closing = true; + return closeOwnedDebuggerSessions( + this.#sessions, + (session) => this.#stopDebuggerOnly(session), + "Rizin", + ); + } + + #receive(session: RizinDebugSession, chunk: Buffer): void { + let offset = 0; + while (offset < chunk.length) { + const boundary = chunk.indexOf(0, offset); + const end = boundary < 0 ? chunk.length : boundary; + const nextOffset = boundary + 1; + if (boundary >= 0 && nextOffset <= offset) { + this.#failProtocol( + session, + "Rizin frame parser failed to advance past a NUL delimiter.", + ); + return; + } + const piece = chunk.subarray(offset, end); + if (session.discardingOversizedFrame) { + if (boundary < 0) return; + session.discardingOversizedFrame = false; + this.#deliverBufferedFrame(session, true); + offset = nextOffset; + continue; + } + if (session.bufferedFrameBytes + piece.length > MAX_FRAME_BYTES) { + const message = + "Rizin emitted an NUL frame exceeding the 16 MiB protocol limit."; + if (!session.commandCaptureActive) { + this.#failProtocol(session, message); + return; + } + session.protocolError = message; + session.historyTruncated = true; + const remainingBytes = MAX_FRAME_BYTES - session.bufferedFrameBytes; + if (remainingBytes > 0) { + session.frameChunks.push( + Buffer.from(piece.subarray(0, remainingBytes)), + ); + session.bufferedFrameBytes += remainingBytes; + } + if (boundary < 0) { + session.discardingOversizedFrame = true; + return; + } + this.#deliverBufferedFrame(session, true); + offset = nextOffset; + continue; + } + if (piece.length > 0) { + session.frameChunks.push(Buffer.from(piece)); + session.bufferedFrameBytes += piece.length; + } + if (boundary < 0) return; + this.#deliverBufferedFrame(session, false); + offset = nextOffset; + } + } + + #deliverBufferedFrame(session: RizinDebugSession, truncated: boolean): void { + const frameBytes = Buffer.concat( + session.frameChunks, + session.bufferedFrameBytes, + ); + session.frameChunks = []; + session.bufferedFrameBytes = 0; + const invalidUtf8 = !isUtf8(frameBytes); + session.invalidUtf8Observed ||= invalidUtf8; + this.#deliverFrame( + session, + { text: frameBytes.toString("utf8"), truncated, invalidUtf8 }, + frameBytes.length, + ); + } + + #startupCapturedOutput(session: RizinDebugSession) { + const output = session.supervisor.snapshot(); + const partialFrame = + session.bufferedFrameBytes === 0 + ? "" + : Buffer.concat( + session.frameChunks, + session.bufferedFrameBytes, + ).toString("utf8"); + const protocolOutput = [...session.history, partialFrame] + .filter((frame) => frame.length > 0) + .join("\n"); + const protocolBytes = Buffer.from(protocolOutput, "utf8"); + let stdoutBytes = Math.min(protocolBytes.length, MAX_DIAGNOSTIC_BYTES); + while ( + stdoutBytes > 0 && + stdoutBytes < protocolBytes.length && + (protocolBytes[stdoutBytes]! & 0xc0) === 0x80 + ) + stdoutBytes -= 1; + return { + stdout: protocolBytes.subarray(0, stdoutBytes).toString("utf8"), + stderr: output.stderr.text, + truncated: + session.historyTruncated || + protocolBytes.length > stdoutBytes || + output.diagnosticTruncated === true || + output.stdout.observedBytes > output.stdout.bytes || + output.stderr.observedBytes > output.stderr.bytes, + }; + } + + #deliverFrame( + session: RizinDebugSession, + frame: RizinFrame, + frameBytes: number, + ): void { + const historyFrame = + frameBytes > MAX_RECENT_FRAME_BYTES || frame.truncated + ? `${frame.text.slice(0, MAX_RECENT_FRAME_BYTES)}[recent frame truncated]` + : frame.text; + if (frameBytes > MAX_RECENT_FRAME_BYTES || frame.truncated) + session.historyTruncated = true; + session.history.push(historyFrame); + if (session.history.length > 64) { + session.history.splice(0, session.history.length - 64); + session.historyTruncated = true; + } + const waiter = session.frameWaiters.shift(); + if (waiter !== undefined) { + waiter.resolve(frame); + return; + } + const queueChargeBytes = Math.max( + frameBytes, + MIN_QUEUED_FRAME_CHARGE_BYTES, + ); + if ( + session.frames.length >= MAX_QUEUED_FRAMES || + session.queuedFrameBytes + queueChargeBytes > MAX_QUEUED_FRAME_BYTES + ) { + this.#failProtocol( + session, + "Rizin produced unsolicited frames exceeding the bounded queue limit.", + ); + return; + } + session.frames.push({ frame, queueChargeBytes }); + session.queuedFrameBytes += queueChargeBytes; + } + + #failProtocol(session: RizinDebugSession, message: string): void { + session.protocolError = message; + session.frameChunks = []; + session.bufferedFrameBytes = 0; + for (const waiter of session.frameWaiters) + waiter.reject(new Error(message)); + session.frameWaiters.length = 0; + } + + #stopDebuggerOnly(session: RizinDebugSession, commandTurnHeld = false) { + session.closing = true; + if (session.stopAttempt !== undefined) return session.stopAttempt; + session.cleanupCommandTurnHeld = commandTurnHeld; + session.stopAttempt = Promise.resolve() + .then(() => + this.#stopProcess(session, "Rizin debugger", { + platform: this.#platform, + }), + ) + .then((stopped) => { + if (stopped.status !== "incomplete") { + for (const failure of session.cleanupFailures) + failure.resolveCleanup(); + session.cleanupFailures.clear(); + } + return stopped; + }) + .finally(() => { + session.stopAttempt = undefined; + }); + return session.stopAttempt; + } + + async #detachInferior( + session: RizinDebugSession, + ): Promise< + | { readonly status: "no-inferior" } + | { readonly status: "detached"; readonly outputSink: "durable" } + > { + if (session.exited || session.protocolError !== undefined) + throw new Error( + "Rizin protocol state is ambiguous; inferior detach is unverified.", + ); + const previous = session.commandTail; + let release!: () => void; + session.commandTail = new Promise((resolve) => (release = resolve)); + await previous; + try { + return await this.#detachInferiorUnlocked(session); + } finally { + release(); + } + } + + async #detachInferiorUnlocked( + session: RizinDebugSession, + ): Promise< + | { readonly status: "no-inferior" } + | { readonly status: "detached"; readonly outputSink: "durable" } + > { + if (session.exited || session.protocolError !== undefined) + throw new Error( + "Rizin protocol state is ambiguous; inferior detach is unverified.", + ); + { + session.inferiorInventoryComplete = false; + session.knownInferiorIdentities.clear(); + const initial = parseRizinPtraceRows( + await this.#runInternalCommand(session, "olj"), + ); + const inferior = initial; + if (inferior.length === 0) { + session.knownInferiorIdentities.clear(); + session.inferiorInventoryComplete = true; + return { status: "no-inferior" }; + } + session.knownInferiorIdentities.clear(); + for (const [, pid] of inferior) { + if (session.knownInferiorIdentities.has(pid)) continue; + const startTime = await readProcStartTime(pid); + session.knownInferiorIdentities.set(pid, { + pid, + startTime, + }); + } + session.inferiorInventoryComplete = true; + if (inferior.length !== 1) + throw new Error( + "Rizin reported multiple or ambiguous ptrace inferiors.", + ); + const [fd, pid] = inferior[0]!; + const processStartTime = await readProcStartTime(pid); + await this.#verifyInferiorOutputConfiguration(session, pid); + await this.#runInternalCommand(session, `o- ${fd}`); + const after = parseRizinPtraceRows( + await this.#runInternalCommand(session, "olj"), + ); + if ( + after.some( + ([activeFd, activePid]) => activeFd === fd || activePid === pid, + ) || + after.length !== 0 + ) + throw new Error( + "Rizin did not confirm that the selected inferior FD was detached.", + ); + assertRizinProcessStartTimeStable( + processStartTime, + await readProcStartTime(pid), + ); + await this.#verifyInferiorOutputConfiguration(session, pid); + return { status: "detached", outputSink: "durable" }; + } + } + + async #verifyInferiorOutputConfiguration( + session: RizinDebugSession, + pid: string, + ): Promise { + const exitKills = ( + await this.#runInternalCommand(session, "e dbg.exitkills") + ).trim(); + if (exitKills !== "false") + throw new Error("Rizin dbg.exitkills readback did not confirm false."); + for (const descriptor of ["1", "2"]) { + const descriptorPath = `/proc/${pid}/fd/${descriptor}`; + const destination = await readlink(descriptorPath); + const destinationStat = await stat(descriptorPath); + if ( + destinationStat.dev !== session.inferiorTtyDevice || + destinationStat.ino !== session.inferiorTtyInode || + (destinationStat.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR || + (await realpath(destination)) !== session.inferiorTty + ) + throw new Error( + `Inferior descriptor ${descriptor} is not routed to the selected TTY.`, + ); + } + } + + async #runInternalCommand( + session: RizinDebugSession, + command: string, + ): Promise { + const marker = `__REA_CLEANUP_COMPLETE_${randomUUID().replaceAll("-", "")}__`; + const stdin = session.launched.process.stdin; + if ( + stdin === null || + stdin === undefined || + stdin.destroyed || + !stdin.writable + ) + throw new Error("Rizin stdin is unavailable during cleanup."); + stdin.write(`${command}\n!echo ${marker}\n`); + const captured = await waitForCommandFrames( + session, + marker, + CLEANUP_COMMAND_TIMEOUT_MS, + undefined, + this.#maxCommandOutputBytes, + ); + if (captured.outputTruncated) + throw new Error("Rizin cleanup readback was truncated."); + return captured.output; + } + + #retainCleanupFailure( + session: RizinDebugSession, + operation: string, + reason: string, + options: AnalysisErrorOptions = {}, + ): AnalysisError { + const failure = new AnalysisCapabilityUnavailableError( + "rizin", + operation, + `The owned Rizin process could not be confirmed stopped: ${reason}`, + { ...options, cleanup: { reason, resources: [session.id] } }, + ); + session.cleanupFailures.add(failure); + return failure; + } + + async #stopAfterProtocolFailure( + session: RizinDebugSession, + cause: unknown, + commandTurnHeld = false, + ): Promise { + const stopped = await this.#stopDebuggerOnly(session, commandTurnHeld); + return stopped.status === "incomplete" + ? this.#retainCleanupFailure( + session, + "rizin_debug_command", + stopped.reason, + { cause }, + ) + : new AnalysisCapabilityUnavailableError( + "rizin", + "rizin_debug_command", + session.protocolError!, + { cause }, + ); + } +} + +export interface RizinDebugCommandResult { + readonly command: string; + readonly output: string; + readonly output_scope: "command_and_interleaved_session_output"; + readonly output_truncated: boolean; + readonly output_invalid_utf8?: true; + readonly completion_status: "complete" | "unknown"; + readonly backend: string | null; +} + +const parseRizinPtraceRows = (output: string): Array<[string, string]> => { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch (cause) { + throw new Error("Rizin returned malformed JSON for `olj`.", { cause }); + } + if (!Array.isArray(parsed)) + throw new Error("Rizin `olj` response is not an array."); + const rows: Array<[string, string]> = []; + for (const row of parsed) { + if ( + typeof row !== "object" || + row === null || + typeof (row as { fd?: unknown }).fd !== "number" || + !Number.isSafeInteger((row as { fd: number }).fd) || + (row as { fd: number }).fd < 0 || + typeof (row as { raised?: unknown }).raised !== "boolean" || + typeof (row as { uri?: unknown }).uri !== "string" + ) + throw new Error( + "Rizin `olj` response contains a malformed descriptor row.", + ); + const uri = (row as { uri: string }).uri; + const match = /^ptrace:\/\/(\d+)$/u.exec(uri); + if (match !== null) { + if ((row as { raised: boolean }).raised !== true) + throw new Error("Rizin `olj` ptrace descriptor is not active."); + rows.push([String((row as { fd: number }).fd), match[1]!]); + } else if (uri.startsWith("ptrace://")) + throw new Error("Rizin `olj` returned a malformed ptrace URI."); + } + return rows; +}; + +/** @internal Pure parser kept separately testable from live `/proc` access. */ +export const parseRizinProcStartTime = (statContents: string): string => { + const commandEnd = statContents.lastIndexOf(")"); + if (commandEnd < 0) + throw new Error("Inferior process identity is malformed."); + const fieldsAfterCommand = statContents + .slice(commandEnd + 1) + .trim() + .split(/\s+/u); + const startTime = fieldsAfterCommand[19]; + if (startTime === undefined || !/^\d+$/u.test(startTime)) + throw new Error("Inferior process start time could not be verified."); + return startTime; +}; + +/** @internal Reject a PID that changed identity during inferior detach. */ +export const assertRizinProcessStartTimeStable = ( + before: string, + after: string, +): void => { + if (after !== before) + throw new Error( + "The inferior PID disappeared or was reused during detach verification.", + ); +}; + +const readProcStartTime = async (pid: string): Promise => + parseRizinProcStartTime(await readFile(`/proc/${pid}/stat`, "utf8")); + +const waitForCommandFrames = async ( + session: RizinDebugSession, + marker: string, + timeoutMs: number, + signal?: AbortSignal, + maxCommandOutputBytes = MAX_COMMAND_OUTPUT_BYTES, +): Promise<{ + readonly output: string; + readonly outputTruncated: boolean; + readonly outputInvalidUtf8: boolean; +}> => { + const deadline = Date.now() + timeoutMs; + const output: string[] = []; + let outputInvalidUtf8 = false; + const commandTimeoutError = (): RizinCommandTimeoutError => + createRizinCommandTimeoutError(output, outputInvalidUtf8); + let outputBytes = 0; + let frameCount = 0; + for (;;) { + const remaining = deadline - Date.now(); + if (isRizinCommandDeadlineExpired(remaining)) throw commandTimeoutError(); + let received: RizinFrame; + try { + received = await waitForFrame( + session, + remaining, + signal, + commandTimeoutError, + ); + } catch (cause) { + if (cause instanceof AnalysisCancelledError) + throw new RizinCommandCancelledError( + output.join("\n"), + outputInvalidUtf8, + cause, + ); + if (cause instanceof RizinProcessExitError) + throw new RizinProcessExitError(output.join("\n"), outputInvalidUtf8); + throw cause; + } + outputInvalidUtf8 ||= received.invalidUtf8; + frameCount += 1; + if (frameCount > MAX_COMMAND_FRAMES) { + const message = + "Rizin emitted more than 4096 frames for one command; the session was stopped because output correlation is ambiguous."; + session.protocolError = message; + return { + output: output.join("\n"), + outputTruncated: true, + outputInvalidUtf8, + }; + } + if (received.truncated) { + const separatorBytes = output.length > 0 ? 1 : 0; + output.push( + truncateUtf8( + received.text, + maxCommandOutputBytes - outputBytes - separatorBytes, + ), + ); + return { + output: output.join("\n"), + outputTruncated: true, + outputInvalidUtf8, + }; + } + const frame = received.text; + const markerIndex = frame.indexOf(marker); + if (markerIndex >= 0) { + const beforeMarker = frame.slice(0, markerIndex).trimEnd(); + if (beforeMarker.length > 0) { + const beforeMarkerBytes = Buffer.byteLength(beforeMarker, "utf8"); + const separatorBytes = output.length > 0 ? 1 : 0; + if ( + outputBytes + separatorBytes + beforeMarkerBytes > + maxCommandOutputBytes + ) { + const message = + "Rizin command output exceeded the 16 MiB command limit."; + session.protocolError = message; + const retained = truncateUtf8( + beforeMarker, + maxCommandOutputBytes - outputBytes - separatorBytes, + ); + if (retained.length > 0) output.push(retained); + return { + output: output.join("\n"), + outputTruncated: true, + outputInvalidUtf8, + }; + } + outputBytes += separatorBytes + beforeMarkerBytes; + output.push(beforeMarker); + } + return { + output: output.join("\n"), + outputTruncated: false, + outputInvalidUtf8, + }; + } + const frameBytes = Buffer.byteLength(frame, "utf8"); + const separatorBytes = output.length > 0 ? 1 : 0; + if (outputBytes + separatorBytes + frameBytes > maxCommandOutputBytes) { + const message = "Rizin command output exceeded the 16 MiB command limit."; + session.protocolError = message; + output.push( + truncateUtf8( + frame, + maxCommandOutputBytes - outputBytes - separatorBytes, + ), + ); + return { + output: output.join("\n"), + outputTruncated: true, + outputInvalidUtf8, + }; + } + outputBytes += separatorBytes + frameBytes; + output.push(frame); + } +}; + +const truncateUtf8 = (value: string, maximumBytes: number): string => { + if (maximumBytes <= 0) return ""; + const bytes = Buffer.from(value, "utf8").subarray(0, maximumBytes); + return bytes.toString("utf8").replace(/\uFFFD$/u, ""); +}; + +const waitForFrame = ( + session: RizinDebugSession, + timeoutMs: number, + signal?: AbortSignal, + timeoutError?: () => Error, +): Promise => { + if (signal?.aborted) + return Promise.reject(new AnalysisCancelledError("rizin_debug_command")); + const queued = session.frames.shift(); + if (queued !== undefined) { + session.queuedFrameBytes -= queued.queueChargeBytes; + return Promise.resolve(queued.frame); + } + if (session.exited) return Promise.reject(new RizinProcessExitError("")); + return new Promise((resolve, reject) => { + // Settlement removes all three trigger paths before resolving or rejecting. + const cleanup = (): void => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + const index = session.frameWaiters.indexOf(waiter); + if (index >= 0) session.frameWaiters.splice(index, 1); + }; + const onAbort = (): void => { + cleanup(); + reject(new AnalysisCancelledError("rizin_debug_command")); + }; + const timer = setTimeout(() => { + cleanup(); + reject(timeoutError?.() ?? new RizinFrameTimeoutError()); + }, timeoutMs); + const waiter = { + resolve: (frame: RizinFrame): void => { + cleanup(); + resolve(frame); + }, + reject: (cause: Error): void => { + cleanup(); + reject(cause); + }, + }; + session.frameWaiters.push(waiter); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); +}; + +const waitForTurn = ( + previous: Promise, + signal?: AbortSignal, +): Promise => { + if (signal === undefined) return previous; + if (signal.aborted) + return Promise.reject(new AnalysisCancelledError("rizin_debug_command")); + return new Promise((resolve, reject) => { + const onAbort = (): void => + reject(new AnalysisCancelledError("rizin_debug_command")); + signal.addEventListener("abort", onAbort, { once: true }); + previous.then(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }); + }); +}; diff --git a/src/rust/RustAuditableReader.test.ts b/src/rust/RustAuditableReader.test.ts new file mode 100644 index 000000000..cb281185a --- /dev/null +++ b/src/rust/RustAuditableReader.test.ts @@ -0,0 +1,221 @@ +import { deflateSync } from "node:zlib"; +import { expect, it } from "vitest"; +import { + RUST_SECTION_BYTES, + readAuditableSection, + RustAuditableFormatFailure, + RustAuditableResourceFailure, +} from "./RustAuditableReader.js"; + +const section = (document: unknown) => + readAuditableSection( + ".dep-v0", + 0, + deflateSync(Buffer.from(JSON.stringify(document))), + ); + +it("preserves package order, omitted kind, and the root index", () => { + const parsed = section({ + packages: [ + { name: "itoa", version: "1.0.18", source: "crates.io" }, + { + name: "sample", + version: "0.1.0", + source: "local", + dependencies: [0], + root: true, + kind: "runtime", + }, + ], + format: 1, + }); + expect(parsed).toMatchObject({ + section_name: ".dep-v0", + format_version: 1, + root_package_index: 1, + compressed_bytes: parsed.section_bytes, + packages: [ + { + name: "itoa", + version: "1.0.18", + source: "crates.io", + kind: null, + dependencies: [], + root: false, + }, + { + name: "sample", + version: "0.1.0", + kind: "runtime", + dependencies: [0], + root: true, + }, + ], + dependency_edges: [{ from_index: 1, to_index: 0 }], + cyclic_dependency_indices: [], + }); + expect(parsed.uncompressed_bytes).toBeGreaterThan(parsed.compressed_bytes); +}); + +it("reports dependency cycles and keeps an omitted format version unknown", () => { + const parsed = section({ + packages: [ + { name: "a", version: "1", source: "local", dependencies: [1] }, + { name: "b", version: "1", source: "local", dependencies: [0] }, + ], + }); + expect(parsed.cyclic_dependency_indices).toEqual([0, 1]); + expect(parsed.format_version).toBeNull(); + expect(parsed.root_package_index).toBeNull(); +}); + +it("includes every package that participates in a dependency cycle", () => { + const parsed = section({ + packages: [ + { name: "a", version: "1", source: "local", dependencies: [1, 2] }, + { name: "b", version: "1", source: "local", dependencies: [0] }, + { name: "c", version: "1", source: "local", dependencies: [1] }, + ], + }); + expect(parsed.cyclic_dependency_indices).toEqual([0, 1, 2]); + const self = section({ + packages: [{ name: "a", version: "1", source: "local", dependencies: [0] }], + }); + expect(self.cyclic_dependency_indices).toEqual([0]); +}); + +it("finishes a long acyclic dependency chain", () => { + const count = 20_000; + const parsed = section({ + packages: Array.from({ length: count }, (_, index) => ({ + name: `p${index.toString(36)}`, + version: "1", + source: "local", + ...(index + 1 < count ? { dependencies: [index + 1] } : {}), + })), + }); + expect(parsed.packages).toHaveLength(count); + expect(parsed.cyclic_dependency_indices).toEqual([]); +}); + +it("rejects out-of-range indices, multiple roots, and schema drift", () => { + expect(() => + section({ + packages: [ + { name: "a", version: "1", source: "local", dependencies: [2] }, + ], + }), + ).toThrow(RustAuditableFormatFailure); + expect(() => + section({ + packages: [ + { name: "a", version: "1", source: "local", root: true }, + { name: "b", version: "1", source: "local", root: true }, + ], + }), + ).toThrow(/more than one root/); + expect(() => + section({ + packages: [{ name: "a", version: "1", source: "local" }], + extra: true, + }), + ).toThrow(/package schema/); +}); + +it("rejects invalid JSON, trailing zlib bytes, and an empty section", () => { + expect(() => + readAuditableSection(".dep-v0", 0, deflateSync(Buffer.from("{"))), + ).toThrow(/JSON is invalid/); + const trailed = Buffer.concat([ + deflateSync(Buffer.from('{"packages":[]}')), + Buffer.from([0]), + ]); + expect(() => readAuditableSection(".dep-v0", 0, trailed)).toThrow( + /zlib stream/, + ); + expect(() => readAuditableSection(".dep-v0", 0, Buffer.alloc(0))).toThrow( + /empty/, + ); + expect(() => + readAuditableSection(".dep-v0", 0, Buffer.from([1, 2, 3])), + ).toThrow(RustAuditableFormatFailure); +}); + +it("stops an inflate that exceeds the section ratio", () => { + const raw = Buffer.alloc(8000); + const compressed = deflateSync(raw); + expect(compressed.length * 64).toBeLessThan(raw.length); + expect(() => readAuditableSection(".dep-v0", 0, compressed)).toThrow( + RustAuditableResourceFailure, + ); + expect(() => + readAuditableSection(".dep-v0", 0, Buffer.alloc(RUST_SECTION_BYTES + 1)), + ).toThrow(/1 MiB/); +}); + +it.each([{ packages: [] }, { packages: [{ future_package_field: true }] }])( + "rejects an unknown format before interpreting its packages: %j", + ({ packages }) => { + expect(() => section({ format: 99, packages })).toThrow( + expect.objectContaining({ + name: "RustAuditableFormatFailure", + kind: "unsupported", + message: "Unsupported cargo-auditable format version 99", + }), + ); + }, +); + +it("rejects the first malformed package before schema issues accumulate", () => { + const stored = (document: unknown) => + deflateSync(Buffer.from(JSON.stringify(document)), { level: 0 }); + const emptyPackages = stored({ + format: 1, + packages: Array.from({ length: 80_000 }, () => ({})), + }); + expect(emptyPackages.length).toBeLessThan(RUST_SECTION_BYTES); + expect(() => readAuditableSection(".dep-v0", 0, emptyPackages)).toThrow( + /Dependency package 0 does not match the package schema/, + ); + const invalidDependencies = stored({ + format: 1, + packages: [ + { + name: "a", + version: "1", + source: "local", + dependencies: Array.from({ length: 1_000 }, () => null), + }, + ], + }); + expect(() => readAuditableSection(".dep-v0", 0, invalidDependencies)).toThrow( + /Dependency package 0 does not match the package schema/, + ); +}); + +it("admits dependency expansion before allocating normalized or reverse edges", () => { + const document = { + packages: [ + { + name: "a", + version: "1", + source: "local", + dependencies: Array(400_000).fill(0), + }, + ], + }; + // An uncompressed zlib stream clears the ratio guard while remaining below + // the one-MiB section limit, isolating the graph representation admission. + const payload = deflateSync(Buffer.from(JSON.stringify(document)), { + level: 0, + }); + expect(payload.length).toBeLessThan(RUST_SECTION_BYTES); + expect(() => readAuditableSection(".dep-v0", 0, payload)).toThrow( + expect.objectContaining({ + constraint: { + resource: "transport", + reported_limits: { maximum_output_bytes: 8 * 1024 * 1024 }, + }, + }), + ); +}); diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts new file mode 100644 index 000000000..d28b95704 --- /dev/null +++ b/src/rust/RustAuditableReader.ts @@ -0,0 +1,440 @@ +import { deflateSync, inflateSync } from "node:zlib"; +import { z } from "zod"; +import { + RUST_AUDITABLE_FORMAT_VERSION, + cyclicDependencyIndices, + type RustAuditableMetadata, + type RustPackage, +} from "../domain/rust/rustBinary.js"; + +/** Selected-file admission limit shared with the stable artifact reader. */ +export const RUST_BINARY_INPUT_BYTES = 256 * 1024 * 1024; +/** Compressed `.dep-v0` section limit. */ +export const RUST_SECTION_BYTES = 1024 * 1024; +/** Inflated JSON may grow by at most this multiple of the section size. */ +export const RUST_INFLATE_RATIO = 64; +/** Absolute inflated JSON limit, applied together with the ratio. */ +export const RUST_UNCOMPRESSED_BYTES = 8 * 1024 * 1024; +/** Complete normalized report limit. */ +export const RUST_OUTPUT_BYTES = 8 * 1024 * 1024; + +const JSON_DEPTH = 32; +const packageSchema = z.strictObject({ + name: z.string().min(1), + version: z.string().min(1), + source: z.string().min(1), + kind: z.enum(["runtime", "build"]).optional(), + dependencies: z.array(z.number().int().nonnegative()).optional(), + root: z.boolean().optional(), +}); +const documentVersionSchema = z.object({ + format: z.number().int().nonnegative().optional(), +}); +const documentSchema = documentVersionSchema + .extend({ + format: z.literal(RUST_AUDITABLE_FORMAT_VERSION).optional(), + packages: z.array(packageSchema), + }) + .strict(); + +/** Malformed or unsupported dependency-section bytes. */ +export class RustAuditableFormatFailure extends Error { + constructor( + readonly kind: "malformed" | "unsupported", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "RustAuditableFormatFailure"; + } +} + +/** A resource admission limit with explicit semantic units. */ +export type RustAuditableConstraint = + | { + readonly resource: "memory"; + readonly reported_limits: { readonly maximum_bytes: number }; + } + | { + readonly resource: "transport"; + readonly reported_limits: { readonly maximum_output_bytes: number }; + } + | { + readonly resource: "capacity"; + readonly reported_limits: { readonly maximum_rustc_hashes: number }; + }; + +/** Admission refused before retained output or graph expansion. */ +export class RustAuditableResourceFailure extends Error { + constructor( + readonly constraint: RustAuditableConstraint, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "RustAuditableResourceFailure"; + } +} + +/** Parsed section payload. Cycles are listed and are not walked. */ +export const readAuditableSection = ( + sectionName: RustAuditableMetadata["section_name"], + fileOffset: number, + bytes: Buffer, +): RustAuditableMetadata => { + if (bytes.length === 0) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section is empty", + ); + if (bytes.length > RUST_SECTION_BYTES) + throw new RustAuditableResourceFailure( + { + resource: "memory", + reported_limits: { maximum_bytes: RUST_SECTION_BYTES }, + }, + "Dependency section exceeds the 1 MiB compressed limit", + ); + const inflated = inflateSection(bytes); + const document = parseDocument(inflated); + const packages = normalizePackages(document.packages); + const root = rootIndex(packages); + return { + section_name: sectionName, + file_offset: fileOffset, + section_bytes: bytes.length, + compressed_bytes: bytes.length, + uncompressed_bytes: inflated.length, + format_version: document.format ?? null, + packages, + root_package_index: root, + dependency_edges: packages.flatMap((pkg, fromIndex) => + pkg.dependencies.map((toIndex) => ({ + from_index: fromIndex, + to_index: toIndex, + })), + ), + cyclic_dependency_indices: cyclicDependencyIndices(packages), + }; +}; + +const inflateSection = (bytes: Buffer): Buffer => { + const maximum = Math.min( + RUST_UNCOMPRESSED_BYTES, + bytes.length * RUST_INFLATE_RATIO, + ); + try { + const inflated = inflateSync(bytes, { + maxOutputLength: maximum, + ...(rejectsTrailingJunk() ? { rejectGarbageAfterEnd: true } : {}), + }); + if ( + !rejectsTrailingJunk() && + shortestPrefix(bytes, inflated, maximum) !== bytes.length + ) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section has bytes after the zlib stream", + ); + return inflated; + } catch (cause) { + if (cause instanceof RustAuditableFormatFailure) throw cause; + throw inflateFailure(cause, maximum); + } +}; + +let trailingJunkRejected: boolean | undefined; + +const rejectsTrailingJunk = (): boolean => { + if (trailingJunkRejected !== undefined) return trailingJunkRejected; + const trailed = Buffer.concat([ + deflateSync(Buffer.from("a")), + Buffer.from([0]), + ]); + try { + inflateSync(trailed, { rejectGarbageAfterEnd: true }); + trailingJunkRejected = false; + } catch (cause) { + trailingJunkRejected = + cause instanceof Error && + "code" in cause && + cause.code === "ERR_TRAILING_JUNK_AFTER_STREAM_END"; + } + return trailingJunkRejected; +}; + +const shortestPrefix = ( + input: Buffer, + output: Buffer, + maximum: number, +): number => { + let low = 0; + let high = input.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (prefixMatches(input.subarray(0, mid), output, maximum)) high = mid; + else low = mid + 1; + } + return high; +}; + +const prefixMatches = ( + prefix: Buffer, + output: Buffer, + maximum: number, +): boolean => { + try { + return inflateSync(prefix, { maxOutputLength: maximum }).equals(output); + } catch { + return false; + } +}; + +const inflateFailure = (cause: unknown, maximum: number): Error => { + const code = + cause instanceof Error && "code" in cause ? String(cause.code) : ""; + if (code === "ERR_BUFFER_TOO_LARGE") + return new RustAuditableResourceFailure( + { resource: "memory", reported_limits: { maximum_bytes: maximum } }, + "Inflated dependency JSON exceeds the size limit", + ); + if ( + code === "ERR_TRAILING_JUNK_AFTER_STREAM_END" || + code.startsWith("Z_") || + code === "ERR_INVALID_ARG_TYPE" + ) + return new RustAuditableFormatFailure( + "malformed", + "Dependency section is not a complete zlib stream", + ); + return cause instanceof Error ? cause : new Error(String(cause)); +}; + +const parseDocument = (bytes: Buffer): z.infer => { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (cause) { + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON is not valid UTF-8", + { cause }, + ); + } + assertDepth(text); + let value: unknown; + try { + value = JSON.parse(text); + } catch (cause) { + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section JSON is invalid", + { cause }, + ); + } + const version = documentVersionSchema.safeParse(value); + if (!version.success) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON does not match the format schema", + { cause: version.error }, + ); + if ( + version.data.format !== undefined && + version.data.format !== RUST_AUDITABLE_FORMAT_VERSION + ) + throw new RustAuditableFormatFailure( + "unsupported", + `Unsupported cargo-auditable format version ${version.data.format}`, + ); + admitGraphExpansion(value); + // One malformed package must fail before Zod retains an issue for every field. + rejectMalformedPackages(value); + const parsed = documentSchema.safeParse(value); + if (!parsed.success) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON does not match the package schema", + { cause: parsed.error }, + ); + return parsed.data; +}; + +// These are lower bounds on the required normalized representation, not +// guessed per-object heap sizes. Count source arrays before Zod clones them, +// normalization builds edge objects, or SCC analysis creates reverse edges. +const MIN_PACKAGE_OUTPUT_BYTES = JSON.stringify({ + name: "a", + version: "a", + source: "a", + kind: null, + dependencies: [], + root: true, +}).length; +const MIN_EDGE_OUTPUT_BYTES = JSON.stringify({ + from_index: 0, + to_index: 0, +}).length; +const admitGraphExpansion = (value: unknown): void => { + if ( + typeof value !== "object" || + value === null || + !("packages" in value) || + !Array.isArray(value.packages) + ) + return; + const packages: readonly unknown[] = value.packages; + let minimumBytes = + packages.length * MIN_PACKAGE_OUTPUT_BYTES + + Math.max(0, packages.length - 1); + let edges = 0; + const admit = () => { + const edgeBytes = edges * MIN_EDGE_OUTPUT_BYTES + Math.max(0, edges - 1); + if (minimumBytes + edgeBytes > RUST_OUTPUT_BYTES) + throw new RustAuditableResourceFailure( + { + resource: "transport", + reported_limits: { maximum_output_bytes: RUST_OUTPUT_BYTES }, + }, + "Dependency graph exceeds the complete report byte budget before expansion", + ); + }; + admit(); + for (const pkg of packages) { + // Shape and index validity remain owned by documentSchema/rootIndex. + if ( + typeof pkg !== "object" || + pkg === null || + !("dependencies" in pkg) || + !Array.isArray(pkg.dependencies) + ) + continue; + const count = pkg.dependencies.length; + edges += count; + minimumBytes += Math.max(0, count * 2 - 1); + admit(); + } +}; + +const PACKAGE_FIELDS = new Set([ + "name", + "version", + "source", + "kind", + "dependencies", + "root", +]); + +const rejectMalformedPackages = (value: unknown): void => { + if ( + typeof value !== "object" || + value === null || + !("packages" in value) || + !Array.isArray(value.packages) + ) + return; + for (const [index, pkg] of value.packages.entries()) { + if (!packageRecord(pkg)) + throw new RustAuditableFormatFailure( + "malformed", + `Dependency package ${String(index)} does not match the package schema`, + ); + } +}; + +const packageRecord = ( + pkg: unknown, +): pkg is { + readonly dependencies?: readonly unknown[]; +} => { + if (typeof pkg !== "object" || pkg === null || Array.isArray(pkg)) + return false; + const record = pkg as Record; + for (const key of Object.keys(record)) { + if (!PACKAGE_FIELDS.has(key)) return false; + } + if ( + !nonEmptyString(record.name) || + !nonEmptyString(record.version) || + !nonEmptyString(record.source) + ) + return false; + if ( + record.kind !== undefined && + record.kind !== "runtime" && + record.kind !== "build" + ) + return false; + if (record.root !== undefined && typeof record.root !== "boolean") + return false; + const dependencies = record.dependencies; + if (dependencies === undefined) return true; + return ( + Array.isArray(dependencies) && + dependencies.every( + (entry) => + typeof entry === "number" && Number.isInteger(entry) && entry >= 0, + ) + ); +}; + +const nonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +const assertDepth = (text: string): void => { + let depth = 0; + let quoted = false; + let escaped = false; + for (const char of text) { + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { + quoted = true; + continue; + } + if (char === "{" || char === "[") { + depth += 1; + if (depth > JSON_DEPTH) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON nesting exceeds 32 levels", + ); + } else if ((char === "}" || char === "]") && depth > 0) depth -= 1; + } +}; + +const normalizePackages = ( + packages: z.infer["packages"], +): RustPackage[] => + packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + source: pkg.source, + kind: pkg.kind ?? null, + dependencies: pkg.dependencies ?? [], + root: pkg.root ?? false, + })); + +const rootIndex = (packages: readonly RustPackage[]): number | null => { + const roots = packages.flatMap((pkg, index) => (pkg.root ? [index] : [])); + if (roots.length > 1) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON declares more than one root package", + ); + for (const [index, pkg] of packages.entries()) { + const outside = pkg.dependencies.find( + (target) => target >= packages.length, + ); + if (outside !== undefined) + throw new RustAuditableFormatFailure( + "malformed", + `Dependency index ${String(outside)} from package ${String(index)} is outside the package list`, + ); + } + return roots[0] ?? null; +}; diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts new file mode 100644 index 000000000..ae077d21c --- /dev/null +++ b/src/rust/RustBinaryImage.ts @@ -0,0 +1,181 @@ +import { + GoBinaryFormatFailure, + GoBinaryResourceFailure, + type GoImageSection, +} from "../go/GoBinaryContainer.js"; +import { readGoBinaryContainer } from "../go/GoBinaryImage.js"; +import type { + RustAuditableMetadata, + RustBinary, +} from "../domain/rust/rustBinary.js"; +import { + readAuditableSection, + RustAuditableFormatFailure, + RustAuditableResourceFailure, +} from "./RustAuditableReader.js"; + +const RUSTC_PREFIX = Buffer.from("/rustc/"); +const HASH_BYTES = 40; +/** One toolchain normally leaves one hash; a small cap bounds retained output. */ +const MAX_RUSTC_HASHES = 64; + +/** Container identity plus optional dependency metadata and inferred rustc commits. */ +export interface RustBinaryImage { + readonly format: RustBinary["format"]; + readonly architecture: string; + readonly bits: 32 | 64; + readonly byte_order: RustBinary["byte_order"]; + readonly auditable_metadata: RustAuditableMetadata | null; + readonly rustc_commit: RustBinary["rustc_commit"]; +} + +/** Read dependency metadata from container sections without executing the image. */ +export const readRustBinaryImage = (bytes: Buffer): RustBinaryImage => { + const container = readContainer(bytes); + const dependency = dependencySection(container.format, container.sections); + return { + format: container.format, + architecture: container.architecture, + bits: container.bits, + byte_order: container.byte_order, + auditable_metadata: + dependency === null + ? null + : readDependency(bytes, container.format, dependency), + rustc_commit: rustcCommit(bytes, container.sections), + }; +}; + +const readContainer = (bytes: Buffer) => { + try { + return readGoBinaryContainer(bytes); + } catch (cause) { + if (cause instanceof GoBinaryFormatFailure) + throw new RustAuditableFormatFailure(cause.kind, cause.message, { + cause, + }); + if (cause instanceof GoBinaryResourceFailure) + throw new RustAuditableResourceFailure( + { + resource: "memory", + reported_limits: { maximum_bytes: cause.maximum_bytes }, + }, + cause.message, + { + cause, + }, + ); + throw cause; + } +}; + +const dependencySection = ( + format: RustBinaryImage["format"], + sections: readonly GoImageSection[], +): GoImageSection | null => { + const expected = format === "macho" ? "__DATA,.dep-v0" : ".dep-v0"; + const matches = sections.filter((section) => section.name === expected); + if (matches.length > 1) + throw new RustAuditableFormatFailure( + "malformed", + "Image contains more than one dependency section", + ); + const section = matches[0]; + if (section === undefined) return null; + if (section.compressed) + throw new RustAuditableFormatFailure( + "unsupported", + "Compressed dependency sections are unsupported", + ); + if (!section.file_backed) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section is not file-backed", + ); + return section; +}; + +const readDependency = ( + bytes: Buffer, + format: RustBinaryImage["format"], + section: GoImageSection, +): RustAuditableMetadata => + readAuditableSection( + format === "macho" ? "__DATA,.dep-v0" : ".dep-v0", + section.offset, + bytes.subarray(section.offset, section.offset + section.size), + ); + +const rustcCommit = ( + bytes: Buffer, + sections: readonly GoImageSection[], +): RustBinaryImage["rustc_commit"] => { + const hashes: { hash: string; file_offset: number }[] = []; + const seen = new Set(); + // Overlapping declared sections are scanned once, so total work stays + // bounded by the file size however many sections reference a range. + for (const range of mergedReadOnlyRanges(sections)) + collectHashes(bytes, range, seen, hashes); + return hashes.length === 0 ? null : { evidence_kind: "inferred", hashes }; +}; + +const mergedReadOnlyRanges = ( + sections: readonly GoImageSection[], +): { readonly offset: number; readonly end: number }[] => { + const ranges = sections + .filter((section) => section.read_only && section.size > 0) + .map((section) => ({ + offset: section.offset, + end: section.offset + section.size, + })) + .sort((left, right) => left.offset - right.offset); + const merged: { offset: number; end: number }[] = []; + for (const range of ranges) { + const last = merged.at(-1); + if (last !== undefined && range.offset <= last.end) + last.end = Math.max(last.end, range.end); + else merged.push({ ...range }); + } + return merged; +}; + +const collectHashes = ( + bytes: Buffer, + range: { readonly offset: number; readonly end: number }, + seen: Set, + hashes: { hash: string; file_offset: number }[], +): void => { + const region = bytes.subarray(range.offset, range.end); + let cursor = 0; + while (cursor < region.length) { + const at = region.indexOf(RUSTC_PREFIX, cursor); + if (at < 0) return; + const start = at + RUSTC_PREFIX.length; + const hash = hashAt(region, start); + if (hash !== null && !seen.has(hash)) { + if (seen.size >= MAX_RUSTC_HASHES) + throw new RustAuditableResourceFailure( + { + resource: "capacity", + reported_limits: { maximum_rustc_hashes: MAX_RUSTC_HASHES }, + }, + `Read-only data contains more than ${MAX_RUSTC_HASHES} distinct rustc commit paths.`, + ); + seen.add(hash); + hashes.push({ hash, file_offset: range.offset + at }); + } + cursor = at + 1; + } +}; + +const hashAt = (region: Buffer, start: number): string | null => { + const end = start + HASH_BYTES; + if (end >= region.length || region[end] !== 0x2f) return null; + for (let index = start; index < end; index += 1) { + const byte = region[index] ?? 0; + const hex = + (byte >= 0x30 && byte <= 0x39) || (byte >= 0x61 && byte <= 0x66); + if (!hex) return null; + } + return region.toString("latin1", start, end); +}; diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts new file mode 100644 index 000000000..d0ce690f0 --- /dev/null +++ b/src/rust/RustBinaryProvider.ts @@ -0,0 +1,216 @@ +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { RustBinaryPort } from "../application/rust/RustBinaryPort.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { + rustBinarySchema, + type InspectRustBinaryInput, + type RustBinary, +} from "../domain/rust/rustBinary.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { readRustBinaryImage } from "./RustBinaryImage.js"; +import { + RUST_BINARY_INPUT_BYTES, + RUST_OUTPUT_BYTES, + RustAuditableFormatFailure, + RustAuditableResourceFailure, +} from "./RustAuditableReader.js"; + +/** Bundled reader identity. Embedded package versions are separate observed facts. */ +export const RUST_BINARY_PROVIDER_IDENTITY = { + id: "rust-auditable", + name: "REA Rust dependency reader", + version: "1", +} as const; +const OPERATION = "inspect_rust_binary"; + +/** Stable in-process dependency inspection without subprocesses, temporary files, or a Rust toolchain. */ +export class RustBinaryProvider implements RustBinaryPort { + readonly identity = RUST_BINARY_PROVIDER_IDENTITY; + + /** Read one dependency section and any inferred rustc commit from the selected file. */ + async inspect( + input: InspectRustBinaryInput, + options?: ExecutionOptions, + ): Promise> { + try { + const snapshot = await readStableArtifact( + input.path, + RUST_BINARY_INPUT_BYTES, + options?.signal, + ); + options?.signal?.throwIfAborted(); + const image = readRustBinaryImage(snapshot.bytes); + const report = { + ...image, + artifact: { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + limitations: limitationsFor(image), + }; + if (Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES) + throw outputLimit(input.path); + const parsed = rustBinarySchema.safeParse(report); + if (!parsed.success) + throw new AnalysisOutputError( + OPERATION, + "Rust reader produced an invalid dependency report.", + { cause: parsed.error }, + ); + options?.signal?.throwIfAborted(); + return ok(parsed.data); + } catch (cause: unknown) { + return err(failureFor(cause, input.path, options?.signal)); + } + } +} + +const outputLimit = (path: string) => + new AnalysisResourceConstraintError( + OPERATION, + "transport", + `Complete Rust metadata for ${path} exceeds the inline output budget; no partial success is returned.`, + { maximum_output_bytes: RUST_OUTPUT_BYTES }, + ); + +const limitationsFor = ( + image: ReturnType, +): string[] => [ + "Embedded dependency metadata is file content. It does not authenticate package names, versions, sources, or the compiler that produced the binary.", + "Only the .dep-v0 dependency section and read-only compiler path strings are inspected. Functions, types, and runtime behavior are not recovered.", + "Resource guards bound the selected file to 256 MiB, a dependency section to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the section size, and the complete report to 8 MiB. Package and dependency counts are admitted against that output budget before schema cloning, graph objects, or reverse-edge allocation.", + ...(image.auditable_metadata === null + ? [ + "No .dep-v0 section was found. Absence of .dep-v0 does not mean the binary has no dependencies.", + ] + : []), + ...(image.auditable_metadata?.packages.some((pkg) => pkg.kind === null) + ? [ + "An omitted package kind is left null. The embedded format treats an omitted kind as runtime, and that default was not stored in the section.", + ] + : []), + ...(image.auditable_metadata !== null && + image.auditable_metadata.cyclic_dependency_indices.length > 0 + ? [ + "Dependency indices contain a cycle. The cycle is reported and was not traversed.", + ] + : []), + ...rustcLimitation(image.rustc_commit), +]; + +const rustcLimitation = ( + commit: ReturnType["rustc_commit"], +): string[] => { + if (commit === null) + return [ + "No /rustc/<40-hex>/ path was found in read-only data, so a rustc commit hash is unknown.", + ]; + if (commit.hashes.length === 1) + return [ + "The rustc commit hash is inferred from a /rustc/<40-hex>/ path in read-only data. It does not by itself prove which compiler produced the binary.", + ]; + return [ + "Read-only data contains more than one /rustc/<40-hex>/ path, so a single rustc commit is unknown.", + ]; +}; + +const failureFor = ( + cause: unknown, + path: string, + signal?: AbortSignal, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (signal?.aborted) return new AnalysisCancelledError(OPERATION); + if (cause instanceof RustAuditableResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + cause.constraint.resource, + `Selected artifact ${path}: ${cause.message}`, + cause.constraint.reported_limits, + { cause }, + ); + if (cause instanceof RustAuditableFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { + cause, + }) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${path}: ${cause.message}`, + }, + ]); + return artifactFailure(cause, path); +}; + +const artifactFailure = (cause: unknown, path: string): AnalysisError => { + if (cause instanceof ArtifactReaderFailure) + return artifactReaderFailure(cause, path); + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); + if ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + } + return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, { + cause, + diagnostics: { + path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const artifactReaderFailure = ( + cause: ArtifactReaderFailure, + path: string, +): AnalysisError => { + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION); + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { maximum_input_bytes: RUST_BINARY_INPUT_BYTES }, + { cause }, + ); + if (cause.reason === "path") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, { + cause, + diagnostics: { path, reason: cause.message }, + }); +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index c73071bea..ad1c10677 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -8,6 +8,7 @@ import type { WasmArtifactService } from "../application/wasm/WasmArtifactServic import { wasmArtifactModule } from "../composition/wasm.js"; import { registerWasmTools } from "./registerWasmTools.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; +import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createPeResourcesService } from "../composition/binaryDiagnostics.js"; import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; @@ -22,6 +23,9 @@ import { createRecordedCrashService } from "../composition/binaryDiagnostics.js" import { createGoBinaryService } from "../composition/go.js"; import type { GoBinaryService } from "../application/go/GoBinaryService.js"; import { registerGoTools } from "./registerGoTools.js"; +import { createRustBinaryService } from "../composition/rust.js"; +import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; +import { registerRustTools } from "./registerRustTools.js"; import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; @@ -119,6 +123,15 @@ import { type ServerAnalysisSource, } from "./analysisAdmission.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { createReverseEngineeringService } from "../composition/reverseEngineering.js"; +import { registerReverseEngineeringTools } from "./registerReverseEngineeringTools.js"; +import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js"; +import { CutterBridgeService } from "../cutter/CutterBridgeService.js"; +import { registerCutterTools } from "./registerCutterTools.js"; +import { GdbSessionManager } from "../gdb/GdbSessionManager.js"; +import { registerGdbTools } from "./registerGdbTools.js"; +import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js"; +import { registerRizinDebugTools } from "./registerRizinDebugTools.js"; const TARGET_FREE_INSTRUCTIONS = "REA provides reverse-engineering tools for local artifacts, native binaries, managed code, browser pages, and runtimes. Use the tool that directly answers the question; discover targets or inspect inventory only when needed. Tool results include inline Evidence and report their coverage and limitations."; @@ -136,11 +149,13 @@ export interface CreateServerOptions { readonly packetCapture?: PacketCaptureService; readonly signalRecording?: SignalRecordingService; readonly wasmArtifact?: WasmArtifactService; + readonly providerEnvironment?: Readonly; readonly evmInterface?: EvmInterfaceService; readonly sqliteDatabase?: SqliteDatabaseService; readonly logger?: Logger; readonly binaryLayout?: BinaryLayoutService; readonly goBinary?: GoBinaryService; + readonly rustBinary?: RustBinaryService; readonly recordedCrash?: RecordedCrashService; readonly firmwareAnalysis?: FirmwareAnalysisPort; readonly javascriptRecovery?: JavaScriptRecoveryPort; @@ -268,6 +283,10 @@ export const createServer = ( const session = source.kind === "session" ? source.session : undefined; const analysis = source.kind === "session" ? source.session : source.analysis; const environment = snapshotEnvironment(options.environment ?? process.env); + const providerEnvironment = captureProviderEnvironment( + options.providerEnvironment, + environment, + ); const delivery = selectToolResultDelivery(environment, options.delivery); const evmInterface = options.evmInterface ?? evmInterfaceModule.create(environment); @@ -288,6 +307,8 @@ export const createServer = ( const javascriptRecovery = options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment); const artifactResources = new ArtifactResourceScope(); + const reverseEngineering = + createReverseEngineeringService(providerEnvironment); const firmwareAnalysis = new FirmwareAnalysisService( options.firmwareAnalysis ?? createFirmwareAnalysisProvider(environment), ); @@ -310,6 +331,10 @@ export const createServer = ( delivery, selectCompactInputSchemaPresentation(environment), ); + const gdbSessions = new GdbSessionManager({ + environment: providerEnvironment, + }); + const rizinDebugSessions = new RizinDebugSessionManager(providerEnvironment); const android = options.androidAnalysis ?? createAndroidAnalysisProvider(environment); const pcap = options.pcapAnalysis ?? createPcapAnalysisProvider(environment); @@ -355,7 +380,10 @@ export const createServer = ( webNetworkCapture.close(), javascriptRecovery.close?.() ?? Promise.resolve(), artifactResources.close(), + reverseEngineering.close(), firmwareAnalysis.close(), + gdbSessions.closeAll(), + rizinDebugSessions.closeAll(), ]) .then((results) => { const failures = results.flatMap((result) => @@ -401,6 +429,28 @@ export const createServer = ( webNetworkCapture, }; registerBinaryAnalysisTools(toolContext); + registerReverseEngineeringTools( + server, + reverseEngineering, + toolLogger, + delivery, + session === undefined + ? undefined + : (evidence) => session.recordEvidence(evidence), + analysisAdmission, + session === undefined + ? undefined + : () => session.invalidateSnapshotForExternalMutation(), + session === undefined + ? undefined + : (path, signal, operation, rebindFailure) => + session.withAdmittedExternalMutation( + path, + signal, + operation, + rebindFailure, + ), + ); registerFridaTools( server, fridaInstrumentation, @@ -408,7 +458,37 @@ export const createServer = ( delivery, session === undefined ? undefined - : (input) => session.recordEvidence(input), + : (evidence) => session.recordEvidence(evidence), + ); + registerCutterTools( + server, + new CutterBridgeService(new CutterBridgeClient(providerEnvironment)), + toolLogger, + delivery, + session === undefined + ? undefined + : (evidence) => session.recordEvidence(evidence), + session, + ); + registerGdbTools( + server, + gdbSessions, + toolLogger, + delivery, + session === undefined + ? undefined + : (evidence) => session.recordEvidence(evidence), + session, + ); + registerRizinDebugTools( + server, + rizinDebugSessions, + toolLogger, + delivery, + session === undefined + ? undefined + : (evidence) => session.recordEvidence(evidence), + session, ); const previousOnclose = server.server.onclose; server.server.onclose = () => { @@ -582,6 +662,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerRustTools( + server, + options.rustBinary ?? createRustBinaryService(), + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerWasmTools( server, wasmArtifact, diff --git a/src/server/externalMutationAdmission.ts b/src/server/externalMutationAdmission.ts new file mode 100644 index 000000000..b4ebd98e6 --- /dev/null +++ b/src/server/externalMutationAdmission.ts @@ -0,0 +1,96 @@ +import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Evidence } from "../domain/evidence.js"; +import { err, type Result } from "../domain/result.js"; + +export type ExternalMutationAdmission = Pick< + BinarySessionPort, + "withAdmittedExternalMutation" +>; + +/** Shared MCP admission and failure preservation for unrestricted commands. */ +export const runWithExternalMutationAdmission = async ( + session: ExternalMutationAdmission | undefined, + operationName: string, + signal: AbortSignal | undefined, + operation: () => Promise>, + evidenceOf: ((value: Value) => Evidence) | undefined, + cleanupRecovery?: ( + failure: AnalysisError | undefined, + ) => Promise>, + retainedDetails?: (value: Value) => Readonly>, +): Promise> => { + if (session === undefined) { + const recovered = await cleanupRecovery?.(undefined); + if (recovered !== undefined && !recovered.ok) return recovered; + return operation(); + } + const admitted = await session.withAdmittedExternalMutation( + undefined, + signal, + operation, + (value, cause) => + value.ok + ? new AnalysisOutputError( + operationName, + "The external command may have changed the bound artifact, but the active analysis session could not be retired.", + { + cause, + ...(evidenceOf === undefined + ? {} + : { partialObservation: evidenceOf(value.value) }), + ...(cause.cleanup === undefined + ? {} + : { cleanup: cause.cleanup }), + ...(cause.capturedOutput === undefined + ? {} + : { capturedOutput: cause.capturedOutput }), + ...(retainedDetails === undefined + ? {} + : { diagnostics: retainedDetails(value.value) }), + }, + ) + : value.error.retainSecondaryFailure(cause, { + retirement_error: projectAnalysisError(cause), + }), + operationName, + cleanupRecovery, + ); + return admitted.ok ? admitted.value : err(admitted.error); +}; + +/** Admit an owned close once, including when it is recovering its own blocker. */ +export const runOwnedCleanupWithExternalMutationAdmission = async ( + session: ExternalMutationAdmission | undefined, + operationName: string, + signal: AbortSignal | undefined, + resourceId: string, + cleanup: () => Promise>, +): Promise> => { + let completed: Result | undefined; + const cleanupOnce = async (): Promise> => { + completed ??= await cleanup(); + return completed; + }; + const admitted = await runWithExternalMutationAdmission( + session, + operationName, + signal, + cleanupOnce, + undefined, + async (failure) => { + if ( + failure === undefined || + !failure.cleanup?.resources.includes(resourceId) + ) + return failure === undefined + ? { ok: true, value: null } + : { ok: false, error: failure }; + const recovered = await cleanupOnce(); + return recovered.ok ? { ok: true, value: null } : recovered; + }, + ); + return admitted; +}; diff --git a/src/server/providerEnvironment.ts b/src/server/providerEnvironment.ts new file mode 100644 index 000000000..3d0d2c78a --- /dev/null +++ b/src/server/providerEnvironment.ts @@ -0,0 +1,8 @@ +import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; + +/** Capture the selected provider environment at server construction time. */ +export const captureProviderEnvironment = ( + providerEnvironment: Readonly | undefined, + serverEnvironment: Readonly, +): Readonly => + snapshotEnvironment(providerEnvironment ?? serverEnvironment); diff --git a/src/server/registerCutterTools.ts b/src/server/registerCutterTools.ts new file mode 100644 index 000000000..ab23feca9 --- /dev/null +++ b/src/server/registerCutterTools.ts @@ -0,0 +1,79 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import type { Logger } from "pino"; + +import type { CutterBridgeService } from "../cutter/CutterBridgeService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import { ok } from "../domain/result.js"; +import { evidenceSchema } from "../domain/evidence.js"; +import { + runWithExternalMutationAdmission, + type ExternalMutationAdmission, +} from "./externalMutationAdmission.js"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import type { ToolResultDelivery } from "./toolResult.js"; + +/** Register Cutter discovery and command tools against their named contracts. */ +export const registerCutterTools = ( + server: McpServer, + service: CutterBridgeService, + logger: Logger, + delivery: ToolResultDelivery, + recordEvidence?: EvidenceWriter["recordEvidence"], + session?: ExternalMutationAdmission, +): void => { + const list = toolContract("list_cutter_sessions"); + const command = toolContract("cutter_command"); + server.registerTool(list.name, toolRegistrationOptions(list), async () => { + const result = await logToolExecution(logger, list.name, async () => + ok(await service.listSessions()), + ); + if (!result.ok) return delivery.toCallToolResult(result, list); + return delivery.toCallToolResult( + { + ok: true, + value: { ...result.value, sessions: [...result.value.sessions] }, + }, + list, + ); + }); + server.registerTool( + command.name, + toolRegistrationOptions(command), + async (input, context) => { + const result = await runWithExternalMutationAdmission( + session, + command.name, + context.mcpReq.signal, + () => + logToolExecution(logger, command.name, () => + service.execute(input, { signal: context.mcpReq.signal }), + ), + (value) => value, + () => service.retryCleanup(), + ); + if (!result.ok) { + const partial = evidenceSchema.safeParse( + result.error.partialObservation, + ); + if (partial.success) { + const recorded = recordEvidence?.(partial.data); + if (recorded !== undefined) + return delivery.toRecordedPartialErrorToolResult( + result.error, + partial.data.evidence_id, + recorded, + command.returnsTargetContent, + ); + } + return delivery.toCallToolResult(result, command); + } + return delivery.toEvidenceToolResult( + result.value, + command, + recordEvidence?.(result.value), + ); + }, + ); +}; diff --git a/src/server/registerFridaTools.ts b/src/server/registerFridaTools.ts index 5125f105c..fb2e06bb4 100644 --- a/src/server/registerFridaTools.ts +++ b/src/server/registerFridaTools.ts @@ -136,24 +136,35 @@ export const registerFridaTools = ( server.registerTool( load.name, toolRegistrationOptions(load), - async (input) => { + async (input, context: ServerContext) => { const result = await logToolExecution(logger, load.name, () => input.source_kind === "inline" && input.source !== undefined - ? service.loadScript(input.session_id, { - sourceKind: "inline", - source: input.source, - }) + ? service.loadScript( + input.session_id, + { + sourceKind: "inline", + source: input.source, + }, + context.mcpReq.signal, + ) : input.source_kind === "file" && input.path !== undefined - ? service.loadScript(input.session_id, { - sourceKind: "file", - path: input.path, - }) + ? service.loadScript( + input.session_id, + { + sourceKind: "file", + path: input.path, + }, + context.mcpReq.signal, + ) : Promise.resolve({ ok: false as const, error: new AnalysisInputError(load.name), }), ); - if (!result.ok) return delivery.toCallToolResult(result, load); + if (!result.ok) { + recordPartialEvidence(result.error, recordEvidence); + return delivery.toCallToolResult(result, load); + } return delivery.toEvidenceToolResult( result.value, load, @@ -164,9 +175,9 @@ export const registerFridaTools = ( server.registerTool( resume.name, toolRegistrationOptions(resume), - async (input) => { + async (input, context: ServerContext) => { const result = await logToolExecution(logger, resume.name, () => - service.resumeSession(input.session_id), + service.resumeSession(input.session_id, context.mcpReq.signal), ); return result.ok ? delivery.toCallToolResult( @@ -179,9 +190,13 @@ export const registerFridaTools = ( server.registerTool( unload.name, toolRegistrationOptions(unload), - async (input) => { + async (input, context: ServerContext) => { const result = await logToolExecution(logger, unload.name, () => - service.unloadScript(input.session_id, input.script_id), + service.unloadScript( + input.session_id, + input.script_id, + context.mcpReq.signal, + ), ); return result.ok ? delivery.toCallToolResult( diff --git a/src/server/registerGdbTools.ts b/src/server/registerGdbTools.ts new file mode 100644 index 000000000..3b8fa4477 --- /dev/null +++ b/src/server/registerGdbTools.ts @@ -0,0 +1,156 @@ +import type { McpServer, ServerContext } from "@modelcontextprotocol/server"; +import type { Logger } from "pino"; + +import type { GdbSessionManager } from "../gdb/GdbSessionManager.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import { evidenceSchema } from "../domain/evidence.js"; +import { err } from "../domain/result.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { + runOwnedCleanupWithExternalMutationAdmission, + runWithExternalMutationAdmission, + type ExternalMutationAdmission, +} from "./externalMutationAdmission.js"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import type { ToolResultDelivery } from "./toolResult.js"; + +export type GdbToolManager = Pick< + GdbSessionManager, + "start" | "execute" | "status" | "close" | "retryCleanupFailures" +>; + +/** Register persistent GDB session operations on the existing REA MCP server. */ +export const registerGdbTools = ( + server: McpServer, + manager: GdbToolManager, + logger: Logger, + delivery: ToolResultDelivery, + recordEvidence?: EvidenceWriter["recordEvidence"], + session?: ExternalMutationAdmission, +): void => { + const start = toolContract("start_gdb_session"); + const consoleContract = toolContract("gdb_console"); + const status = toolContract("gdb_session_status"); + const close = toolContract("close_gdb_session"); + server.registerTool( + start.name, + toolRegistrationOptions(start), + async (input, context: ServerContext) => { + const result = await logToolExecution(logger, start.name, () => + manager.start( + context.mcpReq.signal, + input.inferior_tty === undefined + ? {} + : { inferiorTty: input.inferior_tty }, + ), + ); + if (result.ok) + return delivery.toCallToolResult( + { ok: true, value: result.value }, + start, + ); + const partial = result.error.partialObservation; + const parsed = evidenceSchema.safeParse(partial); + if (parsed.success) { + const recorded = recordEvidence?.(parsed.data); + if (recorded !== undefined) + return delivery.toRecordedPartialErrorToolResult( + result.error, + parsed.data.evidence_id, + recorded, + start.returnsTargetContent, + ); + } + return delivery.toCallToolResult(result, start); + }, + ); + server.registerTool( + consoleContract.name, + toolRegistrationOptions(consoleContract), + async (input, context: ServerContext) => { + const execute = () => + logToolExecution(logger, consoleContract.name, () => + manager.execute( + input.session_id, + input.command, + context.mcpReq.signal, + ), + ); + const result = await runWithExternalMutationAdmission( + session, + consoleContract.name, + context.mcpReq.signal, + execute, + (value) => value.evidence, + () => manager.retryCleanupFailures(), + ); + if (!result.ok) { + const partial = result.error.partialObservation; + const parsed = evidenceSchema.safeParse(partial); + if (parsed.success) { + const recorded = recordEvidence?.(parsed.data); + if (recorded !== undefined) + return delivery.toRecordedPartialErrorToolResult( + result.error, + parsed.data.evidence_id, + recorded, + consoleContract.returnsTargetContent, + ); + } + return delivery.toCallToolResult(result, consoleContract); + } + const recorded = recordEvidence?.(result.value.evidence); + return delivery.toEvidenceToolResult( + result.value.evidence, + consoleContract, + recorded, + ); + }, + ); + server.registerTool( + status.name, + toolRegistrationOptions(status), + async (input) => { + const result = manager.status(input.session_id); + if (result === undefined) + return delivery.toCallToolResult( + err(invalidSession("gdb_session_status")), + status, + ); + return delivery.toCallToolResult( + { + ok: true, + value: { + ...result, + recent_mi_records: [...result.recent_mi_records], + }, + }, + status, + ); + }, + ); + server.registerTool( + close.name, + toolRegistrationOptions(close), + async (input, context: ServerContext) => { + const result = await runOwnedCleanupWithExternalMutationAdmission( + session, + close.name, + context.mcpReq.signal, + input.session_id, + () => + logToolExecution(logger, close.name, () => + manager.close(input.session_id), + ), + ); + return result.ok + ? delivery.toCallToolResult({ ok: true, value: result.value }, close) + : delivery.toCallToolResult(result, close); + }, + ); +}; + +const invalidSession = (operation: string): AnalysisInputError => + new AnalysisInputError(operation); diff --git a/src/server/registerReverseEngineeringTools.ts b/src/server/registerReverseEngineeringTools.ts new file mode 100644 index 000000000..49ccbb1ff --- /dev/null +++ b/src/server/registerReverseEngineeringTools.ts @@ -0,0 +1,207 @@ +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { + CallToolResult, + McpServer, + ServerContext, +} from "@modelcontextprotocol/server"; +import type { Logger } from "pino"; + +import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { Evidence } from "../domain/evidence.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { ToolContract } from "../contracts/toolContractTypes.js"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import type { ToolResultDelivery } from "./toolResult.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { rizinInputSchema } from "../domain/reverseEngineering.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { Result } from "../domain/result.js"; + +/** Register objdump and Rizin operations against their exact named contracts. */ +export const registerReverseEngineeringTools = ( + server: McpServer, + service: ReverseEngineeringService, + logger: Logger, + delivery: ToolResultDelivery, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, + invalidateSnapshotForExternalMutation?: () => void, + withAdmittedExternalMutation?: ( + path: string | undefined, + signal: AbortSignal | undefined, + operation: () => Promise>, + retirementFailure: ( + value: Result, + error: AnalysisError, + ) => AnalysisError, + ) => Promise, AnalysisError>>, +): void => { + const objdump = toolContract("inspect_with_objdump"); + const rizin = toolContract("execute_rizin_command"); + server.registerTool( + objdump.name, + toolRegistrationOptions(objdump), + (input, context: ServerContext) => + runAdmitted( + objdump, + context, + withAdmittedAnalysis, + delivery, + async () => { + const result = await logToolExecution(logger, objdump.name, () => + service.inspectWithObjdump(input, { + signal: context.mcpReq.signal, + }), + ); + if (!result.ok) + return deliverFailure( + result.error, + objdump, + delivery, + recordEvidence, + ); + return delivery.toEvidenceToolResult( + result.value, + objdump, + recordEvidence?.(result.value), + ); + }, + ), + ); + server.registerTool( + rizin.name, + toolRegistrationOptions(rizin), + async (input, context: ServerContext) => { + const parsed = rizinInputSchema.safeParse(input); + if (parsed.success && withAdmittedExternalMutation !== undefined) { + const cleanup = await service.retryCleanup(rizin.name); + if (!cleanup.ok) + return deliverFailure(cleanup.error, rizin, delivery, recordEvidence); + const admitted = await withAdmittedExternalMutation( + parsed.data.path, + context.mcpReq.signal, + async () => { + invalidateSnapshotForExternalMutation?.(); + return logToolExecution(logger, rizin.name, () => + service.executeRizinCommand(parsed.data, { + signal: context.mcpReq.signal, + }), + ); + }, + (value, cause) => { + if (!value.ok) + return value.error.retainSecondaryFailure(cause, { + retirement_error: projectAnalysisError(cause), + }); + const observation = value.value; + return new AnalysisOutputError( + rizin.name, + "Rizin may have changed the bound artifact, but the active analysis session could not be retired.", + { + cause, + ...(cause.cleanup === undefined + ? {} + : { cleanup: cause.cleanup }), + ...(cause.capturedOutput === undefined + ? {} + : { capturedOutput: cause.capturedOutput }), + ...(observation === undefined + ? {} + : { partialObservation: observation }), + }, + ); + }, + ); + if (!admitted.ok) + return deliverFailure( + admitted.error, + rizin, + delivery, + recordEvidence, + ); + const result = admitted.value; + if (!result.ok) + return deliverFailure(result.error, rizin, delivery, recordEvidence); + return delivery.toEvidenceToolResult( + result.value, + rizin, + recordEvidence?.(result.value), + ); + } + return runAdmitted( + rizin, + context, + withAdmittedAnalysis, + delivery, + async () => { + if ( + invalidateSnapshotForExternalMutation !== undefined && + rizinInputSchema.safeParse(input).success + ) + invalidateSnapshotForExternalMutation(); + const result = await logToolExecution(logger, rizin.name, () => + service.executeRizinCommand(input, { + signal: context.mcpReq.signal, + }), + ); + if (!result.ok) + return deliverFailure( + result.error, + rizin, + delivery, + recordEvidence, + ); + return delivery.toEvidenceToolResult( + result.value, + rizin, + recordEvidence?.(result.value), + ); + }, + ); + }, + ); +}; + +const runAdmitted = async ( + contract: ToolContract, + context: ServerContext, + admission: WithAdmittedAnalysis | undefined, + delivery: ToolResultDelivery, + operation: () => Promise, +): Promise => { + if (admission === undefined) return operation(); + const admitted = await admission( + contract.name, + context.mcpReq.signal, + async () => operation(), + ); + return admitted.ok + ? admitted.value + : delivery.toErrorToolResult(admitted.error, contract.returnsTargetContent); +}; + +const deliverFailure = ( + error: import("../domain/analysisErrorBase.js").AnalysisError, + contract: ToolContract, + delivery: ToolResultDelivery, + recordEvidence?: EvidenceWriter["recordEvidence"], +) => { + const partial = error.partialObservation; + if (isEvidence(partial) && recordEvidence !== undefined) + return delivery.toRecordedPartialErrorToolResult( + error, + partial.evidence_id, + recordEvidence(partial), + contract.returnsTargetContent, + ); + return delivery.toErrorToolResult(error, contract.returnsTargetContent); +}; + +const isEvidence = (value: unknown): value is Evidence => + typeof value === "object" && + value !== null && + "evidence_id" in value && + typeof value.evidence_id === "string"; diff --git a/src/server/registerRizinDebugTools.ts b/src/server/registerRizinDebugTools.ts new file mode 100644 index 000000000..1c6b2b8a6 --- /dev/null +++ b/src/server/registerRizinDebugTools.ts @@ -0,0 +1,146 @@ +import type { McpServer, ServerContext } from "@modelcontextprotocol/server"; +import type { Logger } from "pino"; + +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import type { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js"; +import { evidenceSchema } from "../domain/evidence.js"; +import { + runOwnedCleanupWithExternalMutationAdmission, + runWithExternalMutationAdmission, + type ExternalMutationAdmission, +} from "./externalMutationAdmission.js"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import type { ToolResultDelivery } from "./toolResult.js"; + +export type RizinDebugToolManager = Pick< + RizinDebugSessionManager, + "start" | "execute" | "status" | "close" | "retryCleanupFailures" +>; + +/** Register persistent Rizin debugger operations on the existing REA MCP server. */ +export const registerRizinDebugTools = ( + server: McpServer, + manager: RizinDebugToolManager, + logger: Logger, + delivery: ToolResultDelivery, + recordEvidence?: EvidenceWriter["recordEvidence"], + session?: ExternalMutationAdmission, +): void => { + const start = toolContract("start_rizin_debug_session"); + const command = toolContract("rizin_debug_command"); + const status = toolContract("rizin_debug_session_status"); + const close = toolContract("close_rizin_debug_session"); + server.registerTool( + start.name, + toolRegistrationOptions(start), + async (input, context: ServerContext) => { + const result = await runWithExternalMutationAdmission( + session, + start.name, + context.mcpReq.signal, + () => + logToolExecution(logger, start.name, () => + manager.start( + { + path: input.path, + inferior_tty: input.inferior_tty, + ...(input.backend === undefined + ? {} + : { backend: input.backend }), + }, + context.mcpReq.signal, + ), + ), + undefined, + () => manager.retryCleanupFailures(), + (value) => ({ session_id: value.session_id }), + ); + return result.ok + ? delivery.toCallToolResult({ ok: true, value: result.value }, start) + : delivery.toCallToolResult(result, start); + }, + ); + server.registerTool( + command.name, + toolRegistrationOptions(command), + async (input, context: ServerContext) => { + const result = await runWithExternalMutationAdmission( + session, + command.name, + context.mcpReq.signal, + () => + logToolExecution(logger, command.name, () => + manager.execute( + input.session_id, + input.command, + context.mcpReq.signal, + ), + ), + (value) => value.evidence, + () => manager.retryCleanupFailures(), + ); + if (!result.ok) { + const partial = evidenceSchema.safeParse( + result.error.partialObservation, + ); + if (partial.success) { + const recorded = recordEvidence?.(partial.data); + if (recorded !== undefined) + return delivery.toRecordedPartialErrorToolResult( + result.error, + partial.data.evidence_id, + recorded, + command.returnsTargetContent, + ); + } + return delivery.toCallToolResult(result, command); + } + return delivery.toEvidenceToolResult( + result.value.evidence, + command, + recordEvidence?.(result.value.evidence), + ); + }, + ); + server.registerTool( + status.name, + toolRegistrationOptions(status), + async (input) => { + const result = manager.status(input.session_id); + if (result === undefined) + return delivery.toCallToolResult( + { ok: false, error: new AnalysisInputError(status.name) }, + status, + ); + return delivery.toCallToolResult( + { + ok: true, + value: { ...result, recent_output: [...result.recent_output] }, + }, + status, + ); + }, + ); + server.registerTool( + close.name, + toolRegistrationOptions(close), + async (input, context: ServerContext) => { + const result = await runOwnedCleanupWithExternalMutationAdmission( + session, + close.name, + context.mcpReq.signal, + input.session_id, + () => + logToolExecution(logger, close.name, () => + manager.close(input.session_id), + ), + ); + return result.ok + ? delivery.toCallToolResult({ ok: true, value: result.value }, close) + : delivery.toCallToolResult(result, close); + }, + ); +}; diff --git a/src/server/registerRustTools.ts b/src/server/registerRustTools.ts new file mode 100644 index 000000000..4f674cd82 --- /dev/null +++ b/src/server/registerRustTools.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind the exact Rust contract to its shared workflow and owning session Evidence ledger. */ +export const registerRustTools = ( + server: EvidenceMcpServer, + service: RustBinaryService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_rust_binary"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 428cf8cbd..7d1eca0f7 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -1,13 +1,17 @@ +import { createHash } from "node:crypto"; import { access, chmod, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { WabtArtifactProvider } from "./WabtArtifactProvider.js"; +import { wasmArtifactSchema } from "../domain/wasm/wasmArtifact.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { WABT_LIMITS } from "./WabtRelease.js"; +import { wabtFailure } from "./WabtFailures.js"; const fileCloseFailure = vi.hoisted(() => ({ selectedPath: "", @@ -269,6 +273,35 @@ it.each([false, true])( }); }, ); +const assertGlueObservations = ( + value: unknown, + inputs: readonly { readonly path: string; readonly source: string }[], + selectedPath: string, +): void => { + const glue = wasmArtifactSchema.parse(value).glue; + expect(glue).toHaveLength(inputs.length); + for (const [index, { path, source }] of inputs.entries()) { + expect(glue[index]).toMatchObject({ + artifact: { + path, + sha256: createHash("sha256").update(source).digest("hex"), + bytes: Buffer.byteLength(source), + }, + parse_status: "complete", + references: [ + { + value: "./selected.wasm", + line: source.includes("\n") ? 2 : 1, + column: source.startsWith("\uFEFFfetch") ? 7 : 6, + candidate_paths: [selectedPath], + association: "local-path-candidate", + evidence_kind: "static-literal", + }, + ], + }); + } +}; + it.each([ "success", "invalid", @@ -369,10 +402,34 @@ it.each([ }; }, ); + const literal = 'fetch("./selected.wasm");'; + const glueInputs = ( + mode === "success" + ? [ + literal, + `\uFEFF${literal}`, + `\uFEFF\n${literal}`, + `#!/usr/bin/env node\n${literal}`, + `\uFEFF#!/usr/bin/env node\n${literal}`, + ] + : [] + ).map((source, index) => ({ + path: join(selectedRoot, `glue-${index}.js`), + source, + })); + await Promise.all( + glueInputs.map(({ path, source }) => writeFile(path, source)), + ); const result = await provider.inspect( - { path, glue_paths: [], candidate_paths: [] }, + { + path, + glue_paths: glueInputs.map(({ path }) => path), + candidate_paths: [], + }, { signal: controller.signal }, ); + if (mode === "success" && result.ok) + assertGlueObservations(result.value.result, glueInputs, path); assertModeOutcome(mode, result, path); if (mode === "cleanup") { await assertCleanupRecovery({ @@ -391,3 +448,157 @@ it.each([ expect(() => process.kill(pid, 0)).toThrow(); } }); +const outputLimitFailure = ({ + stdout = "(module", + stderr = "", + cleanupFailure = null, +}: { + readonly stdout?: string; + readonly stderr?: string; + readonly cleanupFailure?: string | null; +} = {}) => + new OwnedCommandFailure( + "output-limit", + "Command diagnostic output exceeded its complete-output budget.", + { + diagnosticTruncated: true, + // Observed sizes differ from retained sizes, as after a real overflow. + stdout: { + text: stdout, + bytes: Buffer.byteLength(stdout), + observedBytes: Buffer.byteLength(stdout) + 1000, + }, + stderr: { + text: stderr, + bytes: Buffer.byteLength(stderr), + observedBytes: Buffer.byteLength(stderr) + 1000, + }, + exitCode: null, + signal: "SIGKILL", + }, + cleanupFailure, + undefined, + cleanupFailure === null ? [] : ["/owned/runtime"], + ); +it("reports WABT output beyond the budget as a resource constraint with a bounded excerpt", async () => { + const selectedRoot = await createTestTempDirectory("rea-wabt-budget-"); + const path = join(selectedRoot, "selected.wasm"); + await writeFile(path, Buffer.alloc(76, 1)); + for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile( + join(selectedRoot, tool + (process.platform === "win32" ? ".exe" : "")), + "source-owned executable seam", + { mode: 0o700 }, + ); + const dump = await readFile( + new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url), + "utf8", + ); + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: selectedRoot }, + (input) => { + const version = input.arguments[0] === "--version"; + if (!version && input.command.includes("wasm2wat")) + throw outputLimitFailure({ stdout: "(module" + "a".repeat(5000) }); + const script = version + ? 'process.stdout.write("1.0.42\\n")' + : input.command.includes("objdump") + ? `process.stdout.write(${JSON.stringify(dump)})` + : ""; + return spawnOwnedProviderProcess({ + ...input, + command: process.execPath, + arguments: ["-e", script], + expectedCommand: null, + }); + }, + ); + const result = await provider.inspect({ + path, + glue_paths: [], + candidate_paths: [], + }); + if (result.ok) throw new Error("Expected the output budget to fail"); + expect(result.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "memory", + reportedLimits: { max_output_bytes: WABT_LIMITS.outputBytes }, + }); + const projection = projectAnalysisError(result.error); + expect(projection).toMatchObject({ + code: "resource_constraint", + message: expect.stringContaining( + `${String(WABT_LIMITS.outputBytes)}-byte output budget`, + ), + remediation: { + action: expect.stringContaining("retrying with the same input"), + }, + }); + expect(projection.details?.captured_output).toEqual({ + stdout: "(module" + "a".repeat(4089), + stderr: "", + truncated: true, + stdout_bytes: 5007, + stderr_bytes: 0, + exit_code: null, + signal: "SIGKILL", + }); +}); +it("bounds an oversized stderr to an excerpt", () => { + expect( + projectAnalysisError( + wabtFailure( + outputLimitFailure({ stderr: "w".repeat(5000) }), + "producer", + "/module.wasm", + ), + ).details?.captured_output, + ).toEqual({ + stdout: "(module", + stderr: "w".repeat(4096), + truncated: true, + stdout_bytes: 7, + stderr_bytes: 5000, + exit_code: null, + signal: "SIGKILL", + }); +}); +it("cuts the excerpt on a code point boundary", () => { + const failure = wabtFailure( + outputLimitFailure({ stdout: "a".repeat(4095) + "\u{1F600}b" }), + "producer", + "/module.wasm", + ); + expect(failure.capturedOutput?.stdout).toBe("a".repeat(4095)); +}); +it("bounds the excerpt when cleanup also fails after the output budget", () => { + expect( + projectAnalysisError( + wabtFailure( + outputLimitFailure({ + stderr: "w".repeat(5000), + cleanupFailure: "Source-owned cleanup uncertainty", + }), + "producer", + "/module.wasm", + ), + ), + ).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + previous_error: { + failure_kind: "output-limit", + message: `WABT output exceeded the ${String(WABT_LIMITS.outputBytes)}-byte output budget`, + max_output_bytes: WABT_LIMITS.outputBytes, + }, + captured_output: { stdout: "(module", stderr: "w".repeat(4096) }, + }, + }, + }); +}); +it("does not blame the input when a version probe exceeds the budget", () => { + expect( + wabtFailure(outputLimitFailure(), "configuration", "/module.wasm"), + ).toMatchObject({ _tag: "AnalysisOutputError" }); +}); diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 3be21b2a1..d706c97e8 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -268,9 +268,11 @@ export class WabtArtifactProvider implements WasmArtifactPort { undefined, this.#resources, ); - const source = new TextDecoder("utf-8", { fatal: true }).decode( - bytes.bytes, - ); + // Keep source characters intact so locations match the hashed artifact. + const source = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes.bytes); glue.push({ artifact: { path, sha256: bytes.sha256, bytes: bytes.bytes.length }, ...associateWasmGlue( diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 7b6875203..b679f7640 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -1,4 +1,6 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; +import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, @@ -6,6 +8,7 @@ import { AnalysisAccessDeniedError, AnalysisInputError, AnalysisOutputError, + AnalysisResourceConstraintError, AnalysisTimeoutError, AnalysisCapabilityUnavailableError, } from "../domain/analysisErrorCore.js"; @@ -13,6 +16,21 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; import { WABT_LIMITS, WABT_PROVIDER_IDENTITY } from "./WabtRelease.js"; +const OUTPUT_LIMIT_EXCERPT_BYTES = 4096; +const OUTPUT_LIMIT_REASON = `WABT output exceeded the ${String(WABT_LIMITS.outputBytes)}-byte output budget`; +/** Past the output budget, retained text is a prefix of an oversized stream; keep a bounded excerpt of each. */ +const outputLimitExcerpt = (snapshot: ProviderProcessSnapshot) => ({ + stdout: boundedUtf8Text(snapshot.stdout.text, OUTPUT_LIMIT_EXCERPT_BYTES) + .text, + stderr: boundedUtf8Text(snapshot.stderr.text, OUTPUT_LIMIT_EXCERPT_BYTES) + .text, + // The command reports output-limit only after retention was truncated. + truncated: true, + stdout_bytes: snapshot.stdout.bytes, + stderr_bytes: snapshot.stderr.bytes, + exit_code: snapshot.exitCode ?? null, + signal: snapshot.signal ?? null, +}); /** Preserve lifecycle failures and diagnostic bytes instead of returning partial success. */ export const wabtFailure = ( cause: unknown, @@ -37,11 +55,13 @@ export const wabtFailure = ( const capturedOutput = cause.snapshot === null ? undefined - : { - stdout: cause.snapshot.stdout.text, - stderr: cause.snapshot.stderr.text, - truncated: cause.snapshot.diagnosticTruncated ?? false, - }; + : cause.reason === "output-limit" + ? outputLimitExcerpt(cause.snapshot) + : { + stdout: cause.snapshot.stdout.text, + stderr: cause.snapshot.stderr.text, + truncated: cause.snapshot.diagnosticTruncated ?? false, + }; const options = capturedOutput === undefined ? { cause } : { cause, capturedOutput }; if (cause.cleanupFailure !== null) @@ -50,7 +70,14 @@ export const wabtFailure = ( cause.resources, { reason: cause.cleanupFailure, - previous_error: reason, + previous_error: + cause.reason === "output-limit" + ? { + failure_kind: "output-limit", + message: OUTPUT_LIMIT_REASON, + max_output_bytes: WABT_LIMITS.outputBytes, + } + : reason, ...(capturedOutput === undefined ? {} : { captured_output: capturedOutput }), @@ -65,6 +92,18 @@ export const wabtFailure = ( WABT_LIMITS.timeoutMs, options, ); + if (phase !== "configuration" && cause.reason === "output-limit") + return new AnalysisResourceConstraintError( + "inspect_wasm_artifact", + "memory", + OUTPUT_LIMIT_REASON, + { max_output_bytes: WABT_LIMITS.outputBytes }, + { + ...options, + remediationAction: + "The WABT output budget is fixed, so retrying with the same input will fail the same way.", + }, + ); if (phase === "configuration" && cause.reason === "process") return new AnalysisCapabilityUnavailableError( WABT_PROVIDER_IDENTITY.id, diff --git a/src/wasm/WasmGlueAdapter.ts b/src/wasm/WasmGlueAdapter.ts index 1f8713042..91c524270 100644 --- a/src/wasm/WasmGlueAdapter.ts +++ b/src/wasm/WasmGlueAdapter.ts @@ -8,7 +8,11 @@ export const associateWasmGlue = ( path: string, candidates: readonly string[], ) => { - const file = parseJavaScriptSource(source); + // Babel requires a hashbang at offset zero. Removing only its preceding BOM + // leaves reference coordinates intact: all code starts on a later line. + const file = parseJavaScriptSource( + source.startsWith("\uFEFF#!") ? source.slice(1) : source, + ); const references: { value: string; line: number; diff --git a/src/windows/WindowsNativeLoader.test.ts b/src/windows/WindowsNativeLoader.test.ts index c0fee8273..c81e8e9a8 100644 --- a/src/windows/WindowsNativeLoader.test.ts +++ b/src/windows/WindowsNativeLoader.test.ts @@ -8,7 +8,11 @@ import { } from "./WindowsNativeLoader.js"; const fixture = ( - capabilities: { protocolStdin?: boolean; jobMemoryLimit?: unknown } = {}, + capabilities: { + protocolStdin?: boolean; + jobMemoryLimit?: unknown; + privateBridgeDescriptorAccess?: unknown; + } = {}, ) => { const bytes = Buffer.alloc(128); bytes.write("MZ"); @@ -51,6 +55,7 @@ const fixture = ( architecture: "x64", filesystem: identity, privateDacl: true, + privateBridgeDescriptorAccess: true, atomicJobAssignment: true, killOnOwnerClose: true, ...capabilities, @@ -199,4 +204,12 @@ describe("packaged Windows native admission", () => { expect(loaded.available).toBe(false); expect(loaded).not.toHaveProperty("retryable"); }); + + it("rejects Windows controls without handle-bound Cutter descriptor access", () => { + const value = fixture({ privateBridgeDescriptorAccess: undefined }); + expect(loadWindowsNativeAuthority(value.host)).toMatchObject({ + available: false, + reason: expect.stringContaining("privateBridgeDescriptorAccess"), + }); + }); }); diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts index 2ab08e1ed..13bf4e9f3 100644 --- a/src/windows/WindowsNativeLoader.ts +++ b/src/windows/WindowsNativeLoader.ts @@ -41,6 +41,7 @@ const inspectionSchema = z.strictObject({ architecture: z.literal("x64"), filesystem: windowsFileIdentitySchema, privateDacl: z.literal(true), + privateBridgeDescriptorAccess: z.literal(true), atomicJobAssignment: z.literal(true), killOnOwnerClose: z.literal(true), protocolStdin: z.boolean().optional(), diff --git a/src/windows/WindowsOwnedProcess.test.ts b/src/windows/WindowsOwnedProcess.test.ts index e9cf106fd..5beec9520 100644 --- a/src/windows/WindowsOwnedProcess.test.ts +++ b/src/windows/WindowsOwnedProcess.test.ts @@ -59,6 +59,7 @@ const authority = ( directory: true, }, privateDacl: true, + privateBridgeDescriptorAccess: true, atomicJobAssignment: true, killOnOwnerClose: true, protocolStdin: true, @@ -105,6 +106,32 @@ const launch = ( ); describe("Windows Job Object memory limits", () => { + it("emits close only after descendants leave the Job Object", async () => { + const { native, calls } = authority({ + polls: [ + { + ...settledPoll, + stdoutEnded: false, + stderrEnded: false, + activeProcesses: 1, + }, + settledPoll, + ], + }); + const child = launch(native); + const events: string[] = []; + child.once("exit", () => events.push("exit")); + child.once("close", () => events.push("close")); + + await once(child, "close"); + + expect(events).toEqual(["exit", "close"]); + expect( + calls.filter(({ operation }) => operation === "process_poll"), + ).toHaveLength(2); + expect(calls.at(-1)?.operation).toBe("process_close"); + }); + it("refuses a requested limit before launch when the artifact lacks the control", () => { const { native, calls } = authority(); expect(() => launch(native, LIMIT)).toThrow( diff --git a/stryker.config.mjs b/stryker.config.mjs new file mode 100644 index 000000000..30a04e523 --- /dev/null +++ b/stryker.config.mjs @@ -0,0 +1,235 @@ +import { mutationRanges } from "./scripts/lib/reverse-tools-mutation-targets.mjs"; + +const config = { + $schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + testRunner: "vitest", + plugins: [ + "@stryker-mutator/typescript-checker", + "@stryker-mutator/vitest-runner", + ], + vitest: { + configFile: "vitest.reverse-tools.config.ts", + related: true, + }, + checkers: ["typescript"], + tsconfigFile: "tsconfig.reverse-tools-mutation.json", + typescriptChecker: { + prioritizePerformanceOverAccuracy: true, + }, + coverageAnalysis: "perTest", + thresholds: { break: null }, + ignorePatterns: ["/.cache/rea-command-locks/**", "/coverage/**"], + concurrency: 2, + timeoutMS: 30000, + reporters: ["clear-text", "progress", "html", "json"], +}; + +export default { + ...config, + mutate: [ + ...mutationRanges("src/application/reverse/ReverseEngineeringService.ts", [ + { + kind: "method", + owner: "ReverseEngineeringService", + name: "inspectWithObjdump", + }, + { + kind: "method", + owner: "ReverseEngineeringService", + name: "executeRizinCommand", + }, + { + kind: "method", + owner: "ReverseEngineeringService", + name: "#observe", + }, + { + kind: "method", + owner: "ReverseEngineeringService", + name: "retryCleanup", + }, + { + kind: "guard", + owner: "ReverseEngineeringService", + method: "#observeProvider", + condition: "signal?.aborted", + direct: true, + }, + { + kind: "guard", + owner: "ReverseEngineeringService", + method: "#observeProvider", + condition: "!cleanup.ok", + }, + { + kind: "declaration", + owner: "ReverseEngineeringService", + method: "#observeProvider", + name: "absolutePath", + }, + { + kind: "declaration", + owner: "ReverseEngineeringService", + method: "#observeProvider", + name: "cleanup", + }, + { + kind: "try", + owner: "ReverseEngineeringService", + method: "#observeProvider", + assignment: "digest", + }, + { + kind: "try", + owner: "ReverseEngineeringService", + method: "#observeProvider", + callee: "this.#run", + }, + { + kind: "expression", + text: "snapshot.signal !== null && snapshot.signal !== undefined", + }, + ]), + ...mutationRanges("src/process/ExecFileOutput.ts", [ + { + kind: "declaration", + name: "execFileOutputFailure", + }, + ]), + ...mutationRanges("src/server/providerEnvironment.ts", [ + { + kind: "declaration", + name: "captureProviderEnvironment", + }, + ]), + ...mutationRanges("src/server/registerReverseEngineeringTools.ts", [ + { + kind: "declaration", + name: "registerReverseEngineeringTools", + }, + ]), + ...mutationRanges("src/gdb/GdbSessionManager.ts", [ + { + kind: "constructor", + owner: "GdbCommandTimeoutError", + }, + { + kind: "constructor", + owner: "GdbCommandProcessExitError", + }, + { + kind: "constructor", + owner: "GdbSessionManager", + }, + { + kind: "declaration", + name: "DEFAULT_MAX_SESSIONS", + }, + { + kind: "declaration", + owner: "GdbSessionManager", + method: "#startProtocol", + name: "session", + }, + { + kind: "declaration", + owner: "GdbSessionManager", + method: "#startProtocol", + name: "stdoutSettled", + }, + { + kind: "call", + owner: "GdbSessionManager", + method: "#startProtocol", + callee: "launched.process.stdout?.on", + event: "data", + }, + { + kind: "call", + owner: "GdbSessionManager", + method: "#startProtocol", + callee: "launched.process.stdin?.on", + event: "error", + }, + { + kind: "call", + owner: "GdbSessionManager", + method: "#startProtocol", + callee: "launched.process.stdin?.once", + event: "close", + }, + { + kind: "call", + owner: "GdbSessionManager", + method: "#startProtocol", + callee: "launched.process.once", + event: "exit", + }, + { + kind: "guard", + owner: "GdbSessionManager", + method: "#startProtocol", + condition: "launched.process.stdout === null", + }, + { + kind: "method", + owner: "GdbSessionManager", + name: "close", + }, + ]), + ...mutationRanges("src/process/ownedDebuggerSession.ts", [ + { + kind: "declaration", + name: "waitForInferiorDetach", + }, + ]), + ...mutationRanges("src/rizin/RizinDebugSessionManager.ts", [ + { + kind: "guard", + owner: "RizinDebugSessionManager", + method: "execute", + condition: "captured.outputTruncated", + }, + { + kind: "declaration", + owner: "RizinDebugSessionManager", + method: "execute", + name: "result", + withinBlockWithBinding: "captured", + }, + { + kind: "declaration", + owner: "RizinDebugSessionManager", + method: "execute", + name: "evidence", + withinBlockWithBinding: "captured", + }, + { + kind: "declaration", + owner: "RizinDebugSessionManager", + method: "#receive", + name: "nextOffset", + }, + ]), + ...mutationRanges("src/cli/reverseEngineeringCommands.ts", [ + { + kind: "command-handler", + command: "debugWithGdb", + }, + { + kind: "command-handler", + command: "debugWithRizin", + }, + ]), + ...mutationRanges("src/cutter/CutterBridgeClient.ts", [ + { + kind: "expression", + text: "status.data.current_file === undefined", + }, + { + kind: "expression", + text: "response.current_file === undefined", + }, + ]), + ], +}; diff --git a/stryker.semantics.config.mjs b/stryker.semantics.config.mjs new file mode 100644 index 000000000..84d6d649a --- /dev/null +++ b/stryker.semantics.config.mjs @@ -0,0 +1,51 @@ +import { mutationRanges } from "./scripts/lib/reverse-tools-mutation-targets.mjs"; + +const config = { + $schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + testRunner: "command", + commandRunner: { + command: "npx vitest run --config vitest.reverse-tools-semantics.config.ts", + }, + plugins: ["@stryker-mutator/typescript-checker"], + checkers: ["typescript"], + tsconfigFile: "tsconfig.reverse-tools-mutation.json", + typescriptChecker: { + prioritizePerformanceOverAccuracy: true, + }, + coverageAnalysis: "off", + thresholds: { break: null }, + ignorePatterns: ["/.cache/rea-command-locks/**", "/coverage/**"], + concurrency: 2, + timeoutMS: 30000, + reporters: ["clear-text", "progress", "html", "json"], +}; + +export default { + ...config, + mutate: [ + ...mutationRanges("src/cutter/CutterBridgeClient.ts", [ + { + kind: "expression", + text: "status.data.current_file === undefined", + }, + { + kind: "expression", + text: "response.current_file === undefined", + }, + ]), + ...mutationRanges("src/rizin/RizinDebugSessionManager.ts", [ + { + kind: "declaration", + name: "isRizinCommandDeadlineExpired", + }, + { + kind: "declaration", + name: "createRizinCommandTimeoutError", + }, + { + kind: "declaration", + name: "createRizinCommandTimeoutEvidence", + }, + ]), + ], +}; diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts new file mode 100644 index 000000000..64e438ed3 --- /dev/null +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -0,0 +1,98 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "pairs repeated factory bodies by identity when application versions retain a shared bundle", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-version-matching-"); + const factoryCount = 32; + const factories = Array.from( + { length: factoryCount }, + (_, index) => `${index}: function() {}`, + ).join(","); + const bundle = `globalThis.webpackChunkApp.push([[1], {${factories}}]);`; + const versions = await Promise.all( + [1, 2].map(async (version) => { + const application = join(root, String(version)); + await mkdir(application); + await writeFile( + join(application, "package.json"), + JSON.stringify({ name: "shared-bundle", version: `${version}.0.0` }), + ); + await writeFile(join(application, "chunk.js"), bundle); + const response = await cli.run({ + arguments: ["analyze-javascript-application", application, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return parseEvidence(response.json); + }), + ); + const [left, right] = versions; + if (left === undefined || right === undefined) + throw new Error("Missing application version Evidence"); + const analyses = versions.map(({ normalized_result }) => + javascriptApplicationAnalysisResultSchema.parse(normalized_result), + ); + const factoryNodes = analyses.map(({ graph }) => { + expect(graph.coverage.status).toBe("complete"); + const nodes = graph.nodes.filter( + ({ kind, observations }) => + kind === "javascript-module" && + observations.some( + ({ properties }) => properties.module_key !== undefined, + ), + ); + expect(nodes).toHaveLength(factoryCount); + const sourceDigests = nodes.flatMap(({ observations }) => + observations.map(({ properties }) => properties.source_sha256), + ); + expect(sourceDigests.every((digest) => typeof digest === "string")).toBe( + true, + ); + expect(new Set(sourceDigests).size).toBe(1); + return nodes; + }); + const inputPath = join(root, "comparison.json"); + await writeFile(inputPath, JSON.stringify({ left, right })); + const response = await cli.run({ + arguments: ["compare-application-versions", inputPath, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + const result = applicationVersionComparisonResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + + expect(result.left.root_artifact_sha256).not.toBe( + result.right.root_artifact_sha256, + ); + expect(result.matching.ambiguous).toBe(0); + expect(result.coverage.status).toBe("complete-within-inputs"); + expect(factoryNodes[0]?.map(({ node_id }) => node_id)).toEqual( + factoryNodes[1]?.map(({ node_id }) => node_id), + ); + for (const node of factoryNodes[0] ?? []) { + expect( + result.items.find(({ left_node_id }) => left_node_id === node.node_id), + ).toMatchObject({ + status: "unchanged", + right_node_id: node.node_id, + match: { + status: "matched", + basis: "exact-node-identity", + candidate_left_node_ids: [], + candidate_right_node_ids: [], + }, + }); + } + }, +); diff --git a/tests/acceptance/applications/artifactComparisonCoverage.test.ts b/tests/acceptance/applications/artifactComparisonCoverage.test.ts new file mode 100644 index 000000000..db8660311 --- /dev/null +++ b/tests/acceptance/applications/artifactComparisonCoverage.test.ts @@ -0,0 +1,432 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { + artifactInventoryResultSchema, + type ArtifactInventoryResult, +} from "../../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +const plist = (message: string) => + `\nmessage${message}\n`; + +const inventoryEvidence = (input: unknown): Evidence => { + const inspection = parseEvidence(input); + const inventory = artifactInspectionResultSchema + .parse(inspection.normalized_result) + .substeps.find( + ({ evidence }) => evidence.operation === "inventory_artifact", + )?.evidence; + if (inventory === undefined) throw new Error("Expected inventory Evidence"); + return parseEvidence(inventory); +}; + +const inspectPair = async ( + cli: TestCli, + leftPath: string, + rightPath: string, +) => { + const result = await cli.run({ + arguments: ["inspect-artifact", leftPath, "--json"], + }); + expect(result.exitCode, `${result.stderr}\n${result.stdout}`).toBe(0); + const left = inventoryEvidence(result.json); + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_binary", { path: rightPath }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await call("inspect_artifact", {}); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + return { left, right: inventoryEvidence(inspected.structuredContent), call }; +}; + +const assertCoverageControls = async ({ + left, + right, + inventory, + compare, +}: { + left: Evidence; + right: Evidence; + inventory: ArtifactInventoryResult; + compare: (left: Evidence, right: Evidence) => Promise; +}) => { + // Authenticated derived controls isolate missing members and genuine coverage + // gaps; they do not claim that the complete plist producer omitted children. + const derive = (result: unknown) => + createEvidence( + { + path: right.subject?.local_path ?? "", + sha256: inventory.manifest.root_sha256, + format: "plist", + }, + right.provider, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse(result), + confidence: "derived", + authority: "shipped-artifact", + }, + ); + const partial = await compare(left, derive({ ...inventory, nodes: [] })); + expect(partial.normalized_result).toMatchObject({ + status: "truncated", + summary: { unchanged: 0, unknown: 1 }, + }); + const reworded = await compare( + left, + derive({ + ...inventory, + coverage: { scope: "root", status: "complete" }, + limitations: ["Root bytes observed; no container children are modeled."], + }), + ); + expect(reworded.normalized_result).toMatchObject({ + status: "unchanged", + summary: { unchanged: 1, unknown: 0 }, + limitations: expect.arrayContaining([ + "Right: Root bytes observed; no container children are modeled.", + ]), + }); + const gap = await compare( + left, + derive({ + ...inventory, + coverage: { scope: "root", status: "unknown" }, + coverage_status: "partial", + limitations: [], + }), + ); + expect(gap.normalized_result).toMatchObject({ + status: "unknown", + summary: { unchanged: 0, unknown: 1 }, + }); + const differentScope = await compare( + left, + derive({ + ...inventory, + coverage: { scope: "container-entries", status: "complete" }, + }), + ); + expect(differentScope.normalized_result).toMatchObject({ + status: "unknown", + limitations: expect.arrayContaining(["Artifact inventory scopes differ."]), + }); + + const { coverage, coverage_status: _coverageStatus, ...legacy } = inventory; + expect(coverage).toBeDefined(); + const retained = await compare(left, derive(legacy)); + expect(retained.normalized_result).toMatchObject({ + status: "unchanged", + summary: { unchanged: 1, unknown: 0 }, + }); + for (const limitation of [ + "Child inventory is unavailable.", + "Missing child observations; 1 integrity contradiction(s) were recorded.", + ]) { + const unfamiliarLegacy = await compare( + left, + derive({ ...legacy, limitations: [limitation] }), + ); + expect(unfamiliarLegacy.normalized_result).toMatchObject({ + status: "unknown", + summary: { unchanged: 0, unknown: 1 }, + }); + } +}; + +cliTest( + "compares fully observed roots without inventing coverage gaps through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-artifact-coverage-"); + const leftPath = join(root, "left.plist"); + const rightPath = join(root, "right.plist"); + await Promise.all([ + writeFile(leftPath, plist("hello")), + writeFile(rightPath, plist("hello")), + ]); + const leftResult = await cli.run({ + arguments: ["inspect-artifact", leftPath, "--json"], + }); + expect(leftResult.exitCode, leftResult.stderr).toBe(0); + const left = inventoryEvidence(leftResult.json); + const { call } = await connectLocalToolsMcp(); + const inspect = async (path: string) => { + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const result = await call("inspect_artifact", {}); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + return inventoryEvidence(result.structuredContent); + }; + const compare = async (left: Evidence, right: Evidence) => { + const result = await call("compare_artifacts", { left, right }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + return parseEvidence(result.structuredContent); + }; + const right = await inspect(rightPath); + const inventory = artifactInventoryResultSchema.parse( + right.normalized_result, + ); + expect(inventory.coverage).toEqual({ scope: "root", status: "complete" }); + expect(inventory.manifest).toMatchObject({ + root_format: "plist", + node_count: 1, + occurrence_count: 1, + edge_count: 0, + }); + expect(inventory.limitations).toEqual([ + "Artifact has no child container entries.", + ]); + const equal = await compare(left, right); + expect(equal.normalized_result).toMatchObject({ + status: "unchanged", + summary: { unchanged: 1, unknown: 0 }, + changes: [], + limitations: [ + "Left: Artifact has no child container entries.", + "Right: Artifact has no child container entries.", + ], + }); + const behavior = await call("find_changed_behavior", { + comparisons: [equal], + }); + expect(behavior.isError, JSON.stringify(behavior)).not.toBe(true); + expect(behavior.structuredContent).toMatchObject({ + normalized_result: { + behavior_status: "unknown", + summary: { unresolved: 0 }, + findings: { items: [] }, + limitations: expect.arrayContaining([ + "No process comparison Evidence was supplied.", + ]), + }, + }); + const unknowns = await call("list_unknowns", { + domain: "artifact-comparison", + }); + expect(unknowns.isError, JSON.stringify(unknowns)).not.toBe(true); + expect(unknowns.structuredContent).toMatchObject({ + result: { items: [], total: 0 }, + }); + + await writeFile(rightPath, plist("world")); + const changed = await compare(left, await inspect(rightPath)); + expect(changed.normalized_result).toMatchObject({ + status: "changed", + summary: { unchanged: 0, changed: 1, unknown: 0 }, + changes: [ + expect.objectContaining({ logical_path: ".", dimensions: ["content"] }), + ], + }); + + const zipPaths = [join(root, "left.zip"), join(root, "right.zip")] as const; + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("same.txt", new TextReader("same")); + const bytes = await writer.close(); + await Promise.all(zipPaths.map((path) => writeFile(path, bytes))); + const zip = await compare( + await inspect(zipPaths[0]), + await inspect(zipPaths[1]), + ); + expect(zip.normalized_result).toMatchObject({ + status: "unchanged", + summary: { unchanged: 2, unknown: 0 }, + changes: [], + }); + + await assertCoverageControls({ left, right, inventory, compare }); + }, + 30_000, +); + +cliTest.skipIf(process.platform === "win32")( + "preserves executable metadata changes for fully observed plist roots", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-artifact-mode-"); + const leftPath = join(root, "left.plist"); + const rightPath = join(root, "right.plist"); + await Promise.all([ + writeFile(leftPath, plist("hello")), + writeFile(rightPath, plist("hello")), + ]); + await Promise.all([chmod(leftPath, 0o644), chmod(rightPath, 0o755)]); + const result = await cli.run({ + arguments: ["inspect-artifact", leftPath, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const left = inventoryEvidence(result.json); + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_binary", { path: rightPath }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await call("inspect_artifact", {}); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const right = inventoryEvidence(inspected.structuredContent); + expect(right.subject?.digest.sha256).toBe(left.subject?.digest.sha256); + const comparison = await call("compare_artifacts", { left, right }); + expect(comparison.isError, JSON.stringify(comparison)).not.toBe(true); + expect( + parseEvidence(comparison.structuredContent).normalized_result, + ).toMatchObject({ + status: "changed", + summary: { unchanged: 0, changed: 1, unknown: 0 }, + changes: [ + expect.objectContaining({ + logical_path: ".", + dimensions: ["executable"], + }), + ], + }); + }, + 30_000, +); + +cliTest.skipIf(process.platform === "win32")( + "retains traversal uncertainty when a directory omits special filesystem entries", + async ({ cli, processes }) => { + const root = await createTestTempDirectory("rea-gap-"); + const leftPath = join(root, "l.app"), + rightPath = join(root, "r.app"); + await Promise.all([mkdir(leftPath), mkdir(rightPath)]); + await Promise.all([ + writeFile(join(leftPath, "same.txt"), "same"), + writeFile(join(rightPath, "same.txt"), "same"), + ]); + await Promise.all( + [leftPath, rightPath].flatMap((path) => [ + writeFile( + join(path, "Info.plist"), + "CFBundleExecutablemain.js", + ), + writeFile(join(path, "main.js"), "export const value = 1;"), + ]), + ); + const fifo = await processes.run("mkfifo", [join(leftPath, "p")]); + expect(fifo.exitCode, fifo.stderr).toBe(0); + const socket = createServer(); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.listen(join(leftPath, "s"), resolve); + }); + try { + const { left, right, call } = await inspectPair(cli, leftPath, rightPath); + const inventory = artifactInventoryResultSchema.parse( + left.normalized_result, + ); + expect(inventory.coverage).toEqual({ + scope: "container-entries", + status: "unknown", + }); + expect(inventory.limitations).toEqual( + expect.arrayContaining([ + 'Artifact traversal omitted fifo at "p".', + 'Artifact traversal omitted socket at "s".', + ]), + ); + const compared = await call("compare_artifacts", { left, right }); + expect(compared.isError, JSON.stringify(compared)).not.toBe(true); + expect( + parseEvidence(compared.structuredContent).normalized_result, + ).toMatchObject({ status: "unknown", summary: { unchanged: 0 } }); + } finally { + await new Promise((resolve, reject) => + socket.close((cause) => + cause === undefined ? resolve() : reject(cause), + ), + ); + } + }, + 30_000, +); + +cliTest( + "compares fully traversed case-distinct ZIP entries while retaining materialization limits", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-case-coverage-"); + const leftPath = join(root, "left.zip"), + rightPath = join(root, "right.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("A", new TextReader("upper")); + await writer.add("a", new TextReader("lower")); + const bytes = await writer.close(); + await Promise.all([ + writeFile(leftPath, bytes), + writeFile(rightPath, bytes), + ]); + const { left, right, call } = await inspectPair(cli, leftPath, rightPath); + const inventory = artifactInventoryResultSchema.parse( + left.normalized_result, + ); + expect(inventory.coverage).toEqual({ + scope: "container-entries", + status: "complete", + }); + expect( + inventory.occurrences.map(({ logical_path }) => logical_path).sort(), + ).toEqual([".", "A", "a"]); + const compared = await call("compare_artifacts", { left, right }); + expect(compared.isError, JSON.stringify(compared)).not.toBe(true); + expect( + parseEvidence(compared.structuredContent).normalized_result, + ).toMatchObject({ + status: "unchanged", + summary: { unchanged: 3, unknown: 0 }, + changes: [], + limitations: expect.arrayContaining([ + expect.stringContaining("case-insensitive destination"), + ]), + }); + }, + 30_000, +); + +cliTest( + "keeps unreadable encrypted ZIP content unknown with complete traversal", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-encrypted-comparison-"); + const leftPath = join(root, "left.zip"); + const rightPath = join(root, "right.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("secret.txt", new TextReader("secret bytes"), { + password: "fixture-password", + }); + const bytes = await writer.close(); + await Promise.all([ + writeFile(leftPath, bytes), + writeFile(rightPath, bytes), + ]); + const { left, right, call } = await inspectPair(cli, leftPath, rightPath); + for (const evidence of [left, right]) + expect( + artifactInventoryResultSchema.parse(evidence.normalized_result), + ).toMatchObject({ + coverage: { scope: "container-entries", status: "complete" }, + occurrences: expect.arrayContaining([ + expect.objectContaining({ + logical_path: "secret.txt", + hash_status: "unavailable", + encrypted: true, + }), + ]), + }); + const compared = await call("compare_artifacts", { left, right }); + expect(compared.isError, JSON.stringify(compared)).not.toBe(true); + const result = parseEvidence(compared.structuredContent).normalized_result; + expect(result).toMatchObject({ + status: "unknown", + summary: { unknown: expect.any(Number) }, + }); + expect(result).toHaveProperty("summary.unknown", 1); + }, +); diff --git a/tests/acceptance/applications/emptyImportNameCli.test.ts b/tests/acceptance/applications/emptyImportNameCli.test.ts new file mode 100644 index 000000000..1805d8d6c --- /dev/null +++ b/tests/acceptance/applications/emptyImportNameCli.test.ts @@ -0,0 +1,70 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves empty ESM import names on local re-export origins through %s", + async (surface, { cli, processes }) => { + const root = await createTestTempDirectory("rea-empty-import-name-"); + const modulePath = join(root, "module.mjs"); + const appPath = join(root, "app.mjs"); + await writeFile( + modulePath, + 'const value = 1; export { value as "", value as "a", value as "0" };', + ); + await writeFile( + appPath, + [ + 'import { "" as empty, "a" as letter, "0" as zero } from "./module.mjs";', + "export { empty, letter, zero };", + 'export { "" as direct } from "./module.mjs";', + ].join("\n"), + ); + for (const path of [modulePath, appPath]) { + const checked = await processes.run(process.execPath, ["--check", path]); + expect(checked.exitCode, checked.stderr).toBe(0); + } + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const imports = result.graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "./module.mjs", + ); + for (const [name, local] of [ + ["", "empty"], + ["a", "letter"], + ["0", "zero"], + ]) { + for (const kind of ["import", "export"]) { + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + module_link_kind: kind, + ...(kind === "export" + ? { exported_name: local } + : { local_name: local }), + imported_name: name, + imported_path: [name], + resolved_path: "module.mjs", + resolution_status: "resolved", + }), + }), + ); + } + } + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + exported_name: "direct", + imported_name: "", + imported_path: [""], + resolution_status: "resolved", + }), + }), + ); + }, +); diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts new file mode 100644 index 000000000..2f984c618 --- /dev/null +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -0,0 +1,71 @@ +import { deflateSync } from "node:zlib"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { createRustImage } from "../fixtures/rust/image.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "reports cargo-auditable packages through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-"); + const path = join(root, "sample"); + await writeFile(path, createRustImage({ format: "macho" })); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.auditable_metadata).toMatchObject({ + section_name: "__DATA,.dep-v0", + format_version: 1, + root_package_index: 1, + packages: [ + { name: "itoa", version: "1.0.18" }, + { name: "sample", version: "0.1.0", root: true }, + ], + }); + }, +); + +cliTest("returns null metadata when .dep-v0 is absent", async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-missing-"); + const path = join(root, "plain"); + await writeFile(path, createRustImage({ format: "elf", omitSection: true })); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.auditable_metadata).toBeNull(); + expect(evidence.normalized_result.limitations.join("\n")).toContain( + "does not mean the binary has no dependencies", + ); +}); + +cliTest("reports malformed zlib as invalid input", async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-zlib-"); + const path = join(root, "broken.elf"); + await writeFile( + path, + createRustImage({ + format: "elf", + payload: deflateSync(Buffer.from("{")), + includeRustc: false, + }), + ); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_rust_binary" }, + }); +}); diff --git a/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts b/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts new file mode 100644 index 000000000..b1c5d7495 --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts @@ -0,0 +1,355 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import type { WebPageInspection } from "../../../src/domain/browserObservationSchemas.js"; +import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js"; +import { compareWebCapturesInputSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +const inspect = async (options: FakeOptions, includeFingerprints = true) => { + const browser = await startFakeCdpBrowser(options); + trackBrowser(browser); + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_storage_keys: true, + include_storage_fingerprints: includeFingerprints, + }); + const capture = async () => { + const result = await new CdpBrowserProvider().inspectPage(input); + if (!result.ok) throw result.error; + return result.value; + }; + return { capture }; +}; + +const compareStorage = (before: WebPageInspection, after: WebPageInspection) => + compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: before }, + after: { inspection: after }, + }), + ).dimensions.storage; + +it.each([ + ["CacheStorage.requestCacheNames", {}], + ["CacheStorage.requestCacheNames", { caches: null }], + ["CacheStorage.requestCacheNames", { caches: [null] }], + [ + "CacheStorage.requestCacheNames", + { caches: [{ cacheName: "assets-v1", cacheId: "secret-id" }, null] }, + ], + ["CacheStorage.requestCacheNames", { caches: [{ cacheName: "assets-v1" }] }], + ["IndexedDB.requestDatabaseNames", { databaseNames: [null] }], + ["DOMStorage.getDOMStorageItems", { entries: [null] }], +] as const)( + "preserves unknown inventory coverage from %s: %j", + async (method, reply) => { + let malformed = false; + const { capture } = await inspect({ + commandResult: (command) => + malformed && command.method === method ? reply : undefined, + }); + const before = await capture(); + malformed = true; + const after = await capture(); + expect(after.storage.fingerprints_complete).toBe(false); + expect(after.completeness.unavailable_sections).toContain("storage_keys"); + expect(after.completeness.excluded).toContainEqual({ + section: "storage_keys", + reason: "invalid_protocol_value", + count: null, + }); + expect(compareStorage(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }, +); + +it("preserves unavailable storage-key coverage without requesting fingerprints", async () => { + const { capture } = await inspect( + { unsupportedMethods: ["CacheStorage.requestCacheNames"] }, + false, + ); + const captured = await capture(); + expect(captured.completeness.excluded).toContainEqual({ + section: "storage_keys", + reason: "provider_unavailable", + count: null, + }); + expect(captured.storage.local_storage_keys).toEqual(["public-key"]); +}); + +const validKey = { type: "string", value: "row-1" }; + +it.each(["unavailable", "malformed"] as const)( + "compares shared complete values with %s cache inventory", + async (inventory) => { + let entries: unknown[][] = [["shared-key", "before"]]; + const { capture } = await inspect({ + ...(inventory === "unavailable" + ? { unsupportedMethods: ["CacheStorage.requestCacheNames"] } + : {}), + commandResult: (command) => { + if (command.method === "CacheStorage.requestCacheNames") + return { caches: [null] }; + if (command.method === "DOMStorage.getDOMStorageItems") + return { entries }; + return undefined; + }, + }); + const before = await capture(); + expect(before.completeness.unavailable_sections).toContain("storage_keys"); + expect(before.storage.fingerprints_complete).toBe(false); + expect(compareStorage(before, await capture())).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + + entries = [["shared-key", "after"]]; + const changed = await capture(); + expect(changed.storage.usage_bytes).toBe(before.storage.usage_bytes); + expect(changed.storage.quota_bytes).toBe(before.storage.quota_bytes); + for (const [left, right] of [ + [before, changed], + [changed, before], + ] as const) + expect(compareStorage(left, right)).toMatchObject({ + status: "changed", + total_changes: 2, + changes: [ + expect.objectContaining({ change: "modified" }), + expect.objectContaining({ change: "modified" }), + ], + }); + + const incompleteValues = structuredClone(changed); + for (const fingerprint of incompleteValues.storage.content_fingerprints) + if (["local_storage", "session_storage"].includes(fingerprint.scope)) + fingerprint.complete = false; + expect(compareStorage(before, incompleteValues)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + + // Missing or invalid peer values establish neither removal nor addition. + for (const partial of [[], [["shared-key", null]]]) { + entries = partial; + const incomplete = await capture(); + for (const [left, right] of [ + [before, incomplete], + [incomplete, before], + ] as const) + expect(compareStorage(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + } + }, +); + +it.each(["key", "primaryKey"])( + "requires IndexedDB %s identity evidence", + async (field) => { + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestData" + ? { + objectStoreDataEntries: [ + { + key: validKey, + primaryKey: validKey, + value: validKey, + [field]: {}, + }, + ], + hasMore: false, + } + : undefined, + }); + const captured = await capture(); + expect(captured.storage.fingerprints_complete).toBe(false); + expect( + captured.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_record", + )?.complete, + ).toBe(false); + }, +); + +it("does not fingerprint a filtered partial object-store schema as complete", async () => { + let malformed = false; + const store = { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }; + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestDatabase" + ? { + databaseWithObjectStores: { + name: "app-db", + version: 1, + objectStores: [ + store, + malformed ? null : { ...store, name: "other" }, + ], + }, + } + : undefined, + }); + const before = await capture(); + malformed = true; + const after = await capture(); + expect( + after.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + )?.complete, + ).toBe(false); + expect(compareStorage(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); +}); + +const incompleteRemoteObjects = [ + undefined, + null, + "raw", + {}, + { type: "string" }, + { type: "string", value: 1 }, + { type: "boolean", value: "false" }, + { type: "number" }, + { type: "number", unserializableValue: "invalid" }, + { type: "number", unserializableValue: ["NaN"] }, + { type: "function", description: "function f() {}" }, + { type: "symbol", description: "Symbol(x)" }, + { type: "object", subtype: "null" }, + { type: "bigint", unserializableValue: "broken" }, +] as const; + +const completeRemoteObjects = [ + { type: "string", value: "" }, + { type: "boolean", value: false }, + { type: "number", value: 0 }, + { type: "undefined" }, + { type: "number", unserializableValue: "NaN" }, + { type: "number", unserializableValue: "-0" }, + { type: "bigint", unserializableValue: "-42n" }, + { type: "object", subtype: "null", value: null }, + { type: "object", value: { nested: [false, null, 0] } }, +] as const; + +it.each([ + ...incompleteRemoteObjects.map((value) => [value, false] as const), + ...completeRemoteObjects.map((value) => [value, true] as const), +])( + "validates IndexedDB RemoteObject completeness: %j => %s", + async (value, complete) => { + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestData" + ? { + objectStoreDataEntries: [ + { key: validKey, primaryKey: validKey, value }, + ], + hasMore: false, + } + : undefined, + }); + const before = await capture(); + const after = await capture(); + const records = before.storage.content_fingerprints.filter( + ({ scope }) => scope === "indexed_db_record", + ); + expect(records).toHaveLength(1); + expect(records[0]?.complete).toBe(complete); + expect(before.storage.fingerprints_complete).toBe(complete); + expect(compareStorage(before, after)).toMatchObject({ + status: complete ? "unchanged" : "unknown", + total_changes: 0, + }); + }, +); + +it.each([ + [undefined, false], + [{}, false], + [{ type: "string" }, false], + [{ type: "array", array: [null] }, false], + [{ type: "null" }, true], + [{ type: "string", string: "id" }, true], + [{ type: "array", array: ["id", "name"] }, true], +] as const)( + "requires complete IndexedDB key paths: %j", + async (keyPath, complete) => { + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestDatabase" + ? { + databaseWithObjectStores: { + name: "app-db", + version: 1, + objectStores: [ + { + name: "records", + keyPath, + autoIncrement: false, + indexes: [ + { + name: "by-id", + keyPath, + unique: false, + multiEntry: false, + }, + ], + }, + ], + }, + } + : undefined, + }); + const captured = await capture(); + expect( + captured.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + )?.complete, + ).toBe(complete); + expect(captured.storage.fingerprints_complete).toBe(complete); + }, +); + +it("accepts genuinely empty inventories as complete", async () => { + const { capture } = await inspect({ + commandResult: (command) => { + switch (command.method) { + case "CacheStorage.requestCacheNames": + return { caches: [] }; + case "IndexedDB.requestDatabaseNames": + return { databaseNames: [] }; + case "DOMStorage.getDOMStorageItems": + return { entries: [] }; + case "Network.getCookies": + return { cookies: [] }; + default: + return undefined; + } + }, + }); + const before = await capture(); + const after = await capture(); + expect(before.storage.fingerprints_complete).toBe(true); + expect(compareStorage(before, after).status).toBe("unchanged"); +}); diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index 3680f7dfa..99f347c81 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -14,6 +14,185 @@ import { } from "../../fixtures/fakeCdpBrowser.js"; const browsers: FakeCdpBrowser[] = []; + +const inspectStorage = async ( + browser: FakeCdpBrowser, + fingerprints = false, +) => { + const captured = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_storage_keys: true, + include_storage_fingerprints: fingerprints, + }), + ); + if (!captured.ok) throw captured.error; + return captured.value; +}; + +const compareStorage = (left: WebPageInspection, right: WebPageInspection) => + compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: left }, + after: { inspection: right }, + }), + ).dimensions.storage; + +it.each([ + ["local", "DOMStorage.getDOMStorageItems", { entries: [null] }], + [ + "session", + "DOMStorage.getDOMStorageItems", + { entries: [["known", "value"], null] }, + ], + ["indexed", "IndexedDB.requestDatabaseNames", { databaseNames: [null] }], + ["cache", "CacheStorage.requestCacheNames", { caches: [null] }], +] as const)( + "does not invent membership changes after an incomplete %s inventory", + async (kind, method, malformed) => { + let failure: "unsupported" | "malformed" | undefined; + const matches = (command: { + method: string; + params: Record; + }) => { + if (command.method !== method) return false; + if (kind !== "local" && kind !== "session") return true; + return ( + (command.params.storageId as { isLocalStorage: boolean }) + .isLocalStorage === + (kind === "local") + ); + }; + const browser = await startFakeCdpBrowser({ + commandError: (command) => + failure === "unsupported" && matches(command) + ? { code: -32601, message: "Method not found" } + : undefined, + commandResult: (command) => + failure === "malformed" && matches(command) ? malformed : undefined, + }); + browsers.push(browser); + const before = await inspectStorage(browser); + expect(before.completeness.unavailable_sections).not.toContain( + "storage_keys", + ); + for (const [mode, reason] of [ + ["unsupported", "provider_unavailable"], + ["malformed", "invalid_protocol_value"], + ] as const) { + failure = mode; + const after = await inspectStorage(browser); + expect(after.completeness.excluded).toContainEqual({ + section: "storage_keys", + reason, + count: null, + }); + expect(after.completeness.unavailable_sections).toContain("storage_keys"); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compareStorage(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + if (kind === "session" && mode === "malformed") + expect(after.storage.session_storage_keys).toEqual(["known"]); + } + }, +); + +it("preserves shared content changes when a peer inventory is unavailable", async () => { + let value = "before"; + const browser = await startFakeCdpBrowser({ + unsupportedMethods: ["CacheStorage.requestCacheNames"], + commandResult: (command) => + command.method === "DOMStorage.getDOMStorageItems" + ? { entries: [["shared-key", value]] } + : undefined, + }); + browsers.push(browser); + const before = await inspectStorage(browser, true); + expect(before.completeness.unavailable_sections).toContain("storage_keys"); + expect(compareStorage(before, before)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + value = "after"; + const after = await inspectStorage(browser, true); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compareStorage(left, right)).toMatchObject({ + status: "changed", + total_changes: 2, + changes: [ + expect.objectContaining({ change: "modified" }), + expect.objectContaining({ change: "modified" }), + ], + }); + for (const fingerprint of after.storage.content_fingerprints) + if (["local_storage", "session_storage"].includes(fingerprint.scope)) + fingerprint.complete = false; + expect(compareStorage(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); +}); + +it("reports complete storage key additions and removals without content fingerprints", async () => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const before = await inspectStorage(browser); + expect(before.storage.fingerprints_complete).toBe(false); + const after = structuredClone(before); + after.storage.local_storage_keys.push("new-local"); + after.storage.session_storage_keys.push("new-session"); + after.storage.indexed_db_names.push("new-db"); + after.storage.cache_names.push("new-cache"); + const identities = [ + "storage:cache:new-cache", + "storage:indexed_db:new-db", + "storage:local_storage:new-local", + "storage:session_storage:new-session", + ]; + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) { + expect(compareStorage(left, right)).toMatchObject({ + status: "changed", + total_changes: 4, + changes: identities.map((identity) => ({ identity, change })), + }); + } + expect(compareStorage(after, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + const missingQuota = structuredClone(after); + missingQuota.storage.quota_bytes = null; + expect(compareStorage(before, missingQuota)).toMatchObject({ + status: "changed", + total_changes: 4, + }); + const partialInventory = structuredClone(after); + partialInventory.completeness.unavailable_sections.push("storage_keys"); + expect(compareStorage(before, partialInventory)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + expect(compareStorage(partialInventory, before)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); +}); + const expectInvalidDiff = (input: unknown): void => { expect(webCaptureDiffSchema.safeParse(input).success).toBe(false); }; diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 4e63358d7..d4b1f3222 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -20,6 +20,56 @@ import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture. import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; +cliTest( + "projects opted-in query names through compiled CLI without query values", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-query-names-cli-"); + const path = join(root, "view.json"); + const capture = analysisViewCaptureFixture(); + capture.records = capture.records.slice(0, 2); + capture.total_records = 2; + capture.records.forEach((record, index) => { + record.reported = { + request: { + method: "GET", + url: + index === 0 + ? "https://api.test/products?category=books&page=1&category=repeat" + : "https://api.test/products?category=games&sort=price#ignored", + }, + }; + }); + await writeFile( + path, + JSON.stringify({ + source: { + kind: "inline", + evidence: analysisViewCaptureEvidence(capture), + }, + view: { kind: "api-surface", include_query_parameter_names: true }, + }), + ); + const result = await cli.run({ + arguments: ["inspect-analysis-view", path, "--json"], + }); + expect(result.exitCode).toBe(0); + const evidence = parseEvidence(result.json); + expect(evidence.normalized_result).toMatchObject({ + groups: [ + { + query_parameter_names: [ + { name: "category", record_ordinals: [0, 1] }, + { name: "page", record_ordinals: [0] }, + { name: "sort", record_ordinals: [1] }, + ], + }, + ], + }); + for (const value of ["books", "games", "repeat", "price", "ignored"]) + expect(result.stdout).not.toContain(value); + }, +); + cliTest( "projects portable artifact inventory after the original ZIP is removed", async ({ cli }) => { diff --git a/tests/boundary/cli/cutterCommands.test.ts b/tests/boundary/cli/cutterCommands.test.ts new file mode 100644 index 000000000..e7c34ee97 --- /dev/null +++ b/tests/boundary/cli/cutterCommands.test.ts @@ -0,0 +1,183 @@ +import { createServer } from "node:net"; +import { createHmac } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Cli } from "incur"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createCli } from "../../../src/cli.js"; +import { serveCli } from "./cliServeHarness.js"; + +const SESSION_ID = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; +const registeredCommands = Cli.toCommands as WeakMap< + object, + Map +>; + +const cutterCommand = async ( + environment: Record, + args: { + readonly sessionId: string; + readonly expectedGeneration: number; + readonly command: string; + }, +): Promise => { + const cli = createCli(environment); + const entry = registeredCommands.get(cli)?.get("cutter-command") ?? {}; + const run = Reflect.get(entry, "run") as (...args: unknown[]) => unknown; + return Reflect.apply(run, undefined, [{ args, options: { json: false } }]); +}; + +const listen = async ( + server: ReturnType, +): Promise => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected an ephemeral TCP listener"); + return address.port; +}; + +describe("Cutter CLI commands", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; + server = undefined; + }); + + it("lists sessions from the selected bridge directory", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-cli-test-")); + + const result = await serveCli(["list-cutter-sessions", "--json"], { + REA_CUTTER_BRIDGE_DIR: directory, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + sessions: [], + discovery_status: "no_live_bridge_found", + bridge_directory: directory, + }); + }); + + it("reports an unavailable selected session for a command", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-cli-test-")); + + const result = await serveCli( + ["cutter-command", SESSION_ID, "0", "pdi", "--json"], + { REA_CUTTER_BRIDGE_DIR: directory }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("invalid_request"); + }); + + it("returns Evidence when the selected Cutter session completes a command", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-cli-test-")); + const token = "synthetic-cutter-cli-token-for-test"; + let nextConnectionOrdinal = 0; + server = createServer((connection) => { + const ordinal = ++nextConnectionOrdinal; + connection.setEncoding("utf8"); + let input = ""; + let clientNonce = ""; + let serverNonce = ""; + connection.on("data", (chunk: string) => { + input += chunk; + const newline = input.indexOf("\n"); + if (newline < 0) return; + const frame = JSON.parse(input.slice(0, newline)) as Record< + string, + unknown + >; + input = input.slice(newline + 1); + if (frame.kind === "rea-auth-v2") { + clientNonce = String(frame.nonce); + serverNonce = `${ordinal.toString(16).padStart(16, "0")}${"b".repeat(48)}`; + connection.write( + `${JSON.stringify({ session_id: SESSION_ID, nonce: serverNonce, proof: createHmac("sha256", token).update(`rea-cutter-server-v2:${SESSION_ID}:${clientNonce}:${serverNonce}`).digest("hex") })}\n`, + ); + return; + } + const requestBytes = String(frame.request); + const request = JSON.parse(requestBytes) as Record; + if ( + frame.proof !== + createHmac("sha256", token) + .update( + `rea-cutter-client-v2:${SESSION_ID}:${clientNonce}:${serverNonce}:${requestBytes}`, + ) + .digest("hex") + ) { + connection.destroy(); + return; + } + const status = request.kind === "status"; + const response = JSON.stringify({ + ok: true, + session_id: request.session_id, + ...(status + ? { + current_file: null, + document_generation: 0, + cutter_version: "Cutter fixture", + identity_status: "partial", + completed_connection: (ordinal - 1) + .toString(16) + .padStart(16, "0"), + completed_commands: [], + } + : request.kind === "ack" + ? { acknowledged: false } + : { + output: { answer: 42 }, + current_file: null, + document_generation: 0, + cutter_version: "Cutter fixture", + identity_status: "partial", + execution_state: "complete", + output_truncated: false, + }), + }); + const proof = createHmac("sha256", token) + .update( + `rea-cutter-response-v2:${SESSION_ID}:${clientNonce}:${serverNonce}:${response}`, + ) + .digest("hex"); + connection.end(`${JSON.stringify({ response, proof })}\n`); + }); + }); + const port = await listen(server); + await writeFile( + join(directory, `cutter-${process.pid}-${SESSION_ID}.json`), + JSON.stringify({ + session_id: SESSION_ID, + pid: process.pid, + host: "127.0.0.1", + port, + token, + document_generation: 0, + current_file: null, + cutter_version: "Cutter fixture", + identity_status: "partial", + }), + { mode: 0o600 }, + ); + + const result = await cutterCommand( + { REA_CUTTER_BRIDGE_DIR: directory }, + { sessionId: SESSION_ID, expectedGeneration: 0, command: "pdi" }, + ); + + expect(JSON.stringify(result)).toContain("rea.cutter.command-observation"); + expect(JSON.stringify(result)).toContain('"answer":42'); + }); +}); diff --git a/tests/boundary/cli/gdbStartupCleanupCli.test.ts b/tests/boundary/cli/gdbStartupCleanupCli.test.ts new file mode 100644 index 000000000..a241f9163 --- /dev/null +++ b/tests/boundary/cli/gdbStartupCleanupCli.test.ts @@ -0,0 +1,132 @@ +import pino from "pino"; +import { expect, it, vi } from "vitest"; + +import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; +import { err } from "../../../src/domain/result.js"; +import { registerReverseEngineeringCommands } from "../../../src/cli/reverseEngineeringCommands.js"; +import { CLI_COMMANDS } from "../../../src/cliCommandNames.js"; +import type { CliInstance } from "../../../src/cli/types.js"; +import type { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; + +const registerGdbCommand = ( + createManager: () => GdbSessionManager, +): { + run(input: { + args: { command: string }; + options: { inferiorTty: string }; + }): Promise; +} => { + const commands = new Map< + string, + { + run(input: { + args: { command: string }; + options: { inferiorTty: string }; + }): Promise; + } + >(); + const cli = { + command: ( + name: string, + definition: { + run(input: { + args: { command: string }; + options: { inferiorTty: string }; + }): Promise; + }, + ) => { + commands.set(name, definition); + return cli; + }, + } as unknown as CliInstance; + registerReverseEngineeringCommands(cli, pino({ enabled: false }), {}, () => + createManager(), + ); + const command = commands.get(CLI_COMMANDS.debugWithGdb); + if (command === undefined) + throw new Error("GDB CLI command was not registered"); + return command; +}; + +it.each([ + "source /tmp/gdbinit", + "python print('x')", + "def custom-command", + "run > /tmp/out", + "set inferior-t /dev/null", + "with inferior-t /dev/null -- run", +])("rejects one-shot command %s before starting GDB", async (inputCommand) => { + const manager = { + start: vi.fn(), + closeAll: vi.fn(), + } as unknown as GdbSessionManager; + const command = registerGdbCommand(() => manager); + const result = await command.run({ + args: { command: inputCommand }, + options: { inferiorTty: "/dev/pts/42" }, + }); + expect(result).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + message: expect.stringContaining("persistent GDB session workflow"), + }), + ], + }, + }); + expect(manager.start).not.toHaveBeenCalled(); + expect(manager.closeAll).not.toHaveBeenCalled(); +}); + +it("retries retained GDB cleanup when CLI session startup fails", async () => { + const closeAll = vi.fn(async () => undefined); + const manager = { + start: vi.fn(async () => + err(new AnalysisOutputError("start_gdb_session", "startup failed")), + ), + closeAll, + } as unknown as GdbSessionManager; + const originalExitCode = process.exitCode; + const command = registerGdbCommand(() => manager); + + try { + const result = await command.run({ + args: { command: "info files" }, + options: { inferiorTty: "/dev/pts/42" }, + }); + expect(result).toMatchObject({ code: "unreadable_output" }); + expect(manager.start).toHaveBeenCalledOnce(); + expect(manager.start).toHaveBeenCalledWith(expect.anything(), { + inferiorTty: "/dev/pts/42", + }); + expect(closeAll).toHaveBeenCalledOnce(); + } finally { + process.exitCode = originalExitCode; + } +}); + +it("marks the CLI failed when retained GDB cleanup remains incomplete", async () => { + const manager = { + start: vi.fn(async () => + err(new AnalysisOutputError("start_gdb_session", "startup failed")), + ), + closeAll: vi.fn(async () => { + throw new Error("process still owned"); + }), + } as unknown as GdbSessionManager; + const originalExitCode = process.exitCode; + const command = registerGdbCommand(() => manager); + + try { + const result = await command.run({ + args: { command: "info files" }, + options: { inferiorTty: "/dev/pts/42" }, + }); + expect(result).toMatchObject({ code: "unreadable_output" }); + expect(manager.closeAll).toHaveBeenCalledOnce(); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = originalExitCode; + } +}); diff --git a/tests/boundary/cli/reverseEngineeringCommands.test.ts b/tests/boundary/cli/reverseEngineeringCommands.test.ts new file mode 100644 index 000000000..9af721770 --- /dev/null +++ b/tests/boundary/cli/reverseEngineeringCommands.test.ts @@ -0,0 +1,429 @@ +import pino from "pino"; +import { Cli, z } from "incur"; +import { afterEach, expect, it, vi } from "vitest"; +import { rm } from "node:fs/promises"; + +import { registerReverseEngineeringCommands } from "../../../src/cli/reverseEngineeringCommands.js"; +import { CLI_COMMANDS } from "../../../src/cliCommandNames.js"; +import { + AnalysisCancelledError, + AnalysisTimeoutError, + AnalysisOutputError, +} from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { + createEvidence, + evidenceSchema, +} from "../../../src/domain/evidence.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { ReverseEngineeringService as CoreReverseEngineeringService } from "../../../src/application/reverse/ReverseEngineeringService.js"; +import { createReverseEngineeringCommandPort } from "../../../src/composition/reverseEngineering.js"; +import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js"; +import { + createArtifact, + providerSnapshot, +} from "../../support/reverseEngineeringCompositionFixtures.js"; +import type { CliInstance } from "../../../src/cli/types.js"; +import type { ReverseEngineeringService } from "../../../src/application/reverse/ReverseEngineeringService.js"; + +const originalExitCode = process.exitCode; + +afterEach(() => { + process.exitCode = originalExitCode; +}); + +const observation = (completionStatus: "complete" | "unknown") => + createEvidence( + undefined, + { id: "gnu.objdump", name: "GNU objdump", version: null }, + { + predicateType: "rea.reverse-engineering.command-observation", + operation: "inspect_with_objdump", + parameters: { operation: "file_headers" }, + result: { completion_status: completionStatus }, + rawResult: { completion_status: completionStatus }, + }, + ); + +const runObjdump = async ( + service: Partial, + path = "/tmp/sample", +) => { + const commands = new Map< + string, + { + run(input: { + args: { path: string; operation: string }; + options: { followDebugLinks: boolean }; + }): Promise; + } + >(); + const cli = { + command: ( + name: string, + definition: { + run(input: { + args: { path: string; operation: string }; + options: { followDebugLinks: boolean }; + }): Promise; + }, + ) => { + commands.set(name, definition); + return cli; + }, + } as unknown as CliInstance; + registerReverseEngineeringCommands( + cli, + pino({ enabled: false }), + {}, + undefined, + () => service as ReverseEngineeringService, + ); + const command = commands.get(CLI_COMMANDS.inspectWithObjdump); + if (command === undefined) + throw new Error("objdump CLI command was not registered"); + return command.run({ + args: { path, operation: "file_headers" }, + options: { followDebugLinks: false }, + }); +}; + +it("lists objdump architectures without inventing an artifact path", async () => { + const evidence = observation("complete"); + const inspectWithObjdump = vi.fn(async (_input: unknown) => ok(evidence)); + const commands = new Map< + string, + { + readonly args?: { safeParse(input: unknown): { success: boolean } }; + run(input: { args?: unknown; options?: unknown }): Promise; + } + >(); + const cli = { + command: (_name: string, definition: unknown) => { + commands.set( + _name, + definition as { + readonly args?: { + safeParse(input: unknown): { success: boolean }; + }; + run(input: { args?: unknown; options?: unknown }): Promise; + }, + ); + return cli; + }, + } as unknown as CliInstance; + registerReverseEngineeringCommands( + cli, + pino({ enabled: false }), + {}, + undefined, + () => + ({ + inspectWithObjdump, + close: vi.fn(async () => undefined), + }) as unknown as ReverseEngineeringService, + ); + const command = commands.get(CLI_COMMANDS.listObjdumpArchitectures); + if (command === undefined) + throw new Error("architecture command was not registered"); + + const result = await command.run({}); + + expect(result).toEqual(evidence); + expect(inspectWithObjdump).toHaveBeenCalledWith( + { operation: "architectures" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + const artifactCommand = commands.get(CLI_COMMANDS.inspectWithObjdump); + expect( + artifactCommand?.args?.safeParse({ + path: "/tmp/sample", + operation: "dwarf", + }).success, + ).toBe(true); + expect( + artifactCommand?.args?.safeParse({ + path: "/tmp/sample", + operation: "architectures", + }).success, + ).toBe(false); +}); + +it("promotes only complete production cleanup-only Evidence after CLI cleanup retry", async () => { + const { directory, path } = await createArtifact(); + const owner = { + stop: vi.fn(async () => ({ status: "verified-cleanup" as const })), + } as unknown as ProviderProcessSupervisor; + const service = new CoreReverseEngineeringService({ + commands: createReverseEngineeringCommandPort({}), + runOwned: vi.fn(async () => { + throw new OwnedCommandFailure( + "process", + "Cleanup after process completion was incomplete.", + providerSnapshot(), + "provider process still running", + undefined, + ["owned-run"], + owner, + ); + }), + }); + const inspect = service.inspectWithObjdump.bind(service); + let producedCleanupFailure: AnalysisOutputError | undefined; + let cleanupIncompleteBeforeClose: boolean | undefined; + let completionBeforeClose: unknown; + vi.spyOn(service, "inspectWithObjdump").mockImplementation( + async (...args) => { + const result = await inspect(...args); + if (!result.ok && result.error instanceof AnalysisOutputError) { + producedCleanupFailure = result.error; + cleanupIncompleteBeforeClose = result.error.cleanupIncomplete; + const partialEvidence = evidenceSchema.safeParse( + result.error.partialObservation, + ); + if (partialEvidence.success) + completionBeforeClose = partialEvidence.data.normalized_result; + } + return result; + }, + ); + + try { + const result = await runObjdump(service, path); + + expect(result).toMatchObject({ + provider: { id: "gnu.objdump" }, + normalized_result: { completion_status: "complete" }, + }); + expect(producedCleanupFailure?.cleanupOnly).toBe(true); + expect(cleanupIncompleteBeforeClose).toBe(true); + expect(completionBeforeClose).toMatchObject({ + completion_status: "complete", + }); + expect(producedCleanupFailure).toMatchObject({ + cleanupIncomplete: false, + cleanup: { resources: ["owned-run"] }, + partialObservation: { + normalized_result: { completion_status: "complete" }, + }, + }); + expect(owner.stop).toHaveBeenCalledOnce(); + } finally { + await service.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves an operation timeout and unknown partial Evidence after cleanup retry", async () => { + const { directory, path } = await createArtifact(); + const owner = { + stop: vi.fn(async () => ({ status: "verified-cleanup" as const })), + } as unknown as ProviderProcessSupervisor; + const service = new CoreReverseEngineeringService({ + commands: createReverseEngineeringCommandPort({}), + runOwned: vi.fn(async () => { + throw new OwnedCommandFailure( + "timeout", + "Provider command timed out.", + providerSnapshot({ exitCode: null }), + "provider process still running", + undefined, + ["owned-run"], + owner, + ); + }), + }); + let timeout: AnalysisTimeoutError | undefined; + const inspect = service.inspectWithObjdump.bind(service); + vi.spyOn(service, "inspectWithObjdump").mockImplementation( + async (...args) => { + const result = await inspect(...args); + if (!result.ok && result.error instanceof AnalysisTimeoutError) + timeout = result.error; + return result; + }, + ); + + try { + const result = await runObjdump(service, path); + + expect(result).toMatchObject({ + code: "provider_timeout", + details: { + partial_observation: { + normalized_result: { completion_status: "unknown" }, + }, + }, + }); + expect(timeout).toBeInstanceOf(AnalysisTimeoutError); + expect(timeout?.cleanupIncomplete).toBe(false); + expect(owner.stop).toHaveBeenCalledOnce(); + } finally { + await service.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves an independent timeout after cleanup succeeds", async () => { + const evidence = observation("unknown"); + const error = new AnalysisTimeoutError("inspect_with_objdump", 1_000, { + cleanup: { reason: "first stop incomplete", resources: ["owned-run"] }, + partialObservation: evidence, + }); + const service = { + inspectWithObjdump: vi.fn(async () => err(error)), + close: vi.fn(async () => { + error.resolveCleanup(); + }), + }; + + const result = await runObjdump(service); + + expect(result).toMatchObject({ + code: "provider_timeout", + details: { + partial_observation: { + normalized_result: { completion_status: "unknown" }, + }, + }, + }); + expect(error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(error).details).not.toHaveProperty("cleanup"); +}); + +it("does not project cancellation cleanup as incomplete after a successful retry", async () => { + const error = new AnalysisCancelledError("inspect_with_objdump", { + cleanup: { reason: "first stop incomplete", resources: ["owned-run"] }, + partialObservation: observation("unknown"), + }); + const service = { + inspectWithObjdump: vi.fn(async () => err(error)), + close: vi.fn(async () => { + error.resolveCleanup(); + }), + }; + + await runObjdump(service); + + expect(error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(error).details).toMatchObject({ + cleanup: "complete", + }); +}); + +it("parses the Rizin inferior TTY as an option and passes it to the manager", async () => { + const commands = new Map< + string, + { + run(input: { + args: Record; + options: Record; + }): Promise; + } + >(); + const cli = { + command: ( + name: string, + definition: { + run(input: { + args: Record; + options: Record; + }): Promise; + }, + ) => { + commands.set(name, definition); + return cli; + }, + } as unknown as CliInstance; + const evidence = createEvidence( + undefined, + { id: "rizin.debugger", name: "Rizin debugger", version: null }, + { + predicateType: "rea.debugger.command-observation", + operation: "rizin_debug_command", + parameters: { + session_id: "session-1", + command: "dr", + inferior_tty: "/dev/pts/7", + }, + result: { output: "registers" }, + rawResult: { output: "registers" }, + }, + ); + const start = vi + .spyOn(RizinDebugSessionManager.prototype, "start") + .mockResolvedValue(ok({ session_id: "session-1", backend: null })); + const execute = vi + .spyOn(RizinDebugSessionManager.prototype, "execute") + .mockResolvedValue( + ok({ + evidence, + result: { + command: "dr", + output: "registers", + output_scope: "command_and_interleaved_session_output", + output_truncated: false, + completion_status: "complete", + backend: null, + }, + }), + ); + vi.spyOn(RizinDebugSessionManager.prototype, "close").mockResolvedValue( + ok({ target_state: "unknown" }), + ); + vi.spyOn(RizinDebugSessionManager.prototype, "closeAll").mockResolvedValue( + undefined, + ); + registerReverseEngineeringCommands(cli, pino({ enabled: false }), {}); + const command = commands.get(CLI_COMMANDS.debugWithRizin); + if (command === undefined) + throw new Error("Rizin CLI command was not registered"); + + await command.run({ + args: { path: "./sample.bin", command: "dr" }, + options: { inferiorTty: "/dev/pts/7" }, + }); + + expect(start).toHaveBeenCalledWith( + { path: "./sample.bin", command: "dr", inferior_tty: "/dev/pts/7" }, + expect.any(AbortSignal), + ); + expect(execute).toHaveBeenCalledWith( + "session-1", + "dr", + expect.any(AbortSignal), + ); + + const parserCli = Cli.create("rea-test", { version: "1.0.0", sync: false }); + registerReverseEngineeringCommands( + parserCli as unknown as CliInstance, + pino({ enabled: false }), + {}, + ); + const commandContract = Cli.toCommands + .get(parserCli) + ?.get(CLI_COMMANDS.debugWithRizin); + expect(commandContract).toBeDefined(); + const argsSchema = + commandContract === undefined + ? undefined + : Reflect.get(commandContract, "args"); + const optionsSchema = + commandContract === undefined + ? undefined + : Reflect.get(commandContract, "options"); + if ( + !(argsSchema instanceof z.ZodObject) || + !(optionsSchema instanceof z.ZodObject) + ) + throw new Error("Rizin CLI args/options schemas were not registered"); + expect(argsSchema.parse({ path: "./sample.bin", command: "dr" })).toEqual({ + path: "./sample.bin", + command: "dr", + }); + expect(optionsSchema.parse({ inferiorTty: "/dev/pts/7" })).toMatchObject({ + inferiorTty: "/dev/pts/7", + }); +}); diff --git a/tests/boundary/filesystem/android/application.test.ts b/tests/boundary/filesystem/android/application.test.ts index 28fa4d8c8..01a5901c8 100644 --- a/tests/boundary/filesystem/android/application.test.ts +++ b/tests/boundary/filesystem/android/application.test.ts @@ -228,7 +228,7 @@ describe("case-distinct APK resource names", () => { await runProviderAnalysis(path, "inventory_artifact", {}), ); expect(inventory.normalized_result).toMatchObject({ - coverage_status: "partial", + coverage_status: "complete", }); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], @@ -238,11 +238,11 @@ describe("case-distinct APK resource names", () => { ); expect(projection.root_format).toBe("apk"); expect(projection.coverage).toEqual({ - status: "partial", + status: "complete-within-inventory", inventory_complete: true, }); - expect(projection.limitations).toContain( - "Source inventory coverage is partial; absence is unknown.", + expect(projection.limitations.join(" ")).toContain( + "case-insensitive destination", ); expect(projection.components.manifests).toHaveLength(1); expect(projection.components.dex).toHaveLength(1); diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts index 00ef24ba5..31c395b55 100644 --- a/tests/boundary/filesystem/appleApplication.test.ts +++ b/tests/boundary/filesystem/appleApplication.test.ts @@ -75,7 +75,7 @@ async function createCompleteAppleProjection() { } describe("Apple application projection", () => { - it("keeps a case-colliding real IPA inventory partial", async () => { + it("retains complete traversal and case-collision limits for a real IPA", async () => { const root = await createTestTempDirectory("rea-apple-case-collision-"); const path = join(root, "CaseCollision.ipa"); const writer = new ZipWriter(new Uint8ArrayWriter()); @@ -93,7 +93,7 @@ describe("Apple application projection", () => { await runProviderAnalysis(path, "inventory_artifact", {}), ); expect(inventory.normalized_result).toMatchObject({ - coverage_status: "partial", + coverage_status: "complete", }); const result = projectAppleApplicationEvidence({ inventory_evidence: [inventory], @@ -103,11 +103,11 @@ describe("Apple application projection", () => { ); expect(projection.coverage).toEqual({ - status: "partial", + status: "complete-within-inventory", inventory_complete: true, }); - expect(projection.limitations).toContain( - "Source inventory coverage is partial; absence is unknown.", + expect(projection.limitations.join(" ")).toContain( + "case-insensitive destination", ); }); diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index ca40596d3..3dc2edb66 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -2,6 +2,7 @@ import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -77,7 +78,11 @@ describe("leaf artifact comparison", () => { // Persisted inventory Evidence predates coverage_status. The known leaf // notice remains complete when normalized at the inventory boundary. const parsed = artifactInventoryResultSchema.parse(left.normalized_result); - const { coverage_status: _coverageStatus, ...legacyInventory } = parsed; + const { + coverage: _coverage, + coverage_status: _coverageStatus, + ...legacyInventory + } = parsed; const legacy = createEvidence( { path: left.subject!.local_path, @@ -157,7 +162,7 @@ describe("artifact inventory coverage", () => { const graph = artifactInventoryResultSchema.parse( complete.normalized_result, ); - const partial = createEvidence( + const contradictory = createEvidence( { path: complete.subject!.local_path, sha256: complete.subject!.digest.sha256, @@ -168,6 +173,29 @@ describe("artifact inventory coverage", () => { operation: "inventory_artifact", parameters: {}, result: jsonValueSchema.parse({ ...graph, coverage_status: "partial" }), + confidence: "derived", + authority: "shipped-artifact", + }, + ); + expect(() => parseArtifactInventoryEvidence(contradictory)).toThrow( + "Artifact inventory has conflicting coverage declarations", + ); + + const { coverage: _coverage, ...legacyStatusGraph } = graph; + const partial = createEvidence( + { + path: complete.subject!.local_path, + sha256: complete.subject!.digest.sha256, + format: complete.subject!.format, + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse({ + ...legacyStatusGraph, + coverage_status: "partial", + }), confidence: "observed", authority: "shipped-artifact", }, @@ -180,7 +208,8 @@ describe("artifact inventory coverage", () => { summary: { unchanged: 0, unknown: 1 }, }); - const { coverage_status: _coverageStatus, ...legacyGraph } = graph; + const { coverage_status: _coverageStatus, ...legacyGraph } = + legacyStatusGraph; const unknownLegacy = createEvidence( { path: complete.subject!.local_path, @@ -219,6 +248,7 @@ describe("artifact inventory coverage", () => { const fragmentGraph = artifactInventoryResultSchema.parse( fragment.normalized_result, ); + const { coverage: _coverage, ...legacyFragmentGraph } = fragmentGraph; const mixed = createEvidence( { path: fragment.subject!.local_path, @@ -230,7 +260,7 @@ describe("artifact inventory coverage", () => { operation: "inventory_artifact", parameters: fragment.parameters, result: jsonValueSchema.parse({ - ...fragmentGraph, + ...legacyFragmentGraph, coverage_status: "partial", }), confidence: "derived", @@ -239,7 +269,7 @@ describe("artifact inventory coverage", () => { ); expect(() => parseArtifactInventoryEvidence([firstFragment, mixed]), - ).toThrow("Artifact inventory pages disagree on coverage status"); + ).toThrow("Artifact inventory pages do not share one coverage declaration"); }); }); @@ -314,6 +344,61 @@ describe("artifact comparison", () => { }); describe("artifact comparison completeness", () => { + it("does not infer child changes from root-only coverage", async () => { + const root = await createTestTempDirectory("rea-artifact-root-coverage-"); + const leftPath = join(root, "left.app"); + const rightPath = join(root, "right.app"); + await Promise.all([mkdir(leftPath), mkdir(rightPath)]); + await Promise.all([ + writeFile(join(leftPath, "left-only.txt"), "left"), + writeFile(join(rightPath, "right-only.txt"), "right"), + ]); + const [left, right] = await Promise.all([ + observe(leftPath), + observe(rightPath), + ]); + const rootOnly = (evidence: Awaited>) => { + const inventory = artifactInventoryResultSchema.parse( + evidence.normalized_result, + ); + return createEvidence( + { + path: evidence.subject?.local_path ?? "", + sha256: inventory.manifest.root_sha256, + format: inventory.manifest.root_format, + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse({ + ...inventory, + coverage: { scope: "root", status: "complete" }, + }), + confidence: "derived", + authority: "shipped-artifact", + }, + ); + }; + const compared = compareArtifacts(rootOnly(left), rootOnly(right)); + expect(compared.status).toBe("unknown"); + expect(compared.summary).toMatchObject({ added: 0, removed: 0 }); + expect( + compared.changes + .filter(({ logical_path }) => logical_path !== ".") + .map(({ logical_path, classification }) => ({ + logical_path, + classification, + })), + ).toEqual([ + { logical_path: "left-only.txt", classification: "unknown" }, + { logical_path: "right-only.txt", classification: "unknown" }, + ]); + expect(compared.limitations).toContain( + "Container entry coverage is unavailable for child paths.", + ); + }); + it("compares every graph member for inventories larger than 500 entries", async () => { const root = await createTestTempDirectory("rea-artifact-pages-"); const entryCount = 501; @@ -358,7 +443,9 @@ describe("artifact comparison completeness", () => { changes: [], }); }, 15_000); +}); +describe("artifact comparison fragment integrity", () => { it("assembles real inventory fragments and retains every citation", async () => { const parent = await createTestTempDirectory("rea-artifact-fragments-"); const leftPath = join(parent, "left.app"); @@ -415,6 +502,30 @@ describe("artifact comparison completeness", () => { evidence_links: citations, })), }); + const fragment = leftFragments[0]; + if (fragment === undefined) throw new Error("Expected inventory fragments"); + const conflictingScope = createEvidence( + { + path: leftPath, + sha256: left.subject?.digest.sha256 ?? "", + format: "directory", + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse({ + ...artifactInventoryResultSchema.parse(fragment.normalized_result), + coverage: { scope: "root", status: "complete" }, + }), + }, + ); + expect(() => + compareArtifacts( + [conflictingScope, ...leftFragments.slice(1)], + rightFragments, + ), + ).toThrow(/coverage declaration/u); }, 15_000); it("rejects non-inventory and tampered Evidence", async () => { @@ -445,3 +556,90 @@ describe("artifact comparison completeness", () => { ); }); }); + +describe("ZIP non-entry coverage", () => { + it("retains pre-directory APK block changes and compares historical scope descriptions", async () => { + const root = await createTestTempDirectory("rea-apk-block-compare-"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("classes.dex", new TextReader("unchanged entry")); + const zip = Buffer.from(await writer.close()); + const eocd = zip.length - 22; + expect(zip.readUInt32LE(eocd)).toBe(0x06054b50); + const directory = zip.readUInt32LE(eocd + 16); + // APK Signing Block framing with one opaque padding pair, not a verified signature. + // https://source.android.com/docs/security/features/apksigning/v2#apk-signing-block + const block = Buffer.alloc(48); + block.writeBigUInt64LE(40n, 0); + block.writeBigUInt64LE(8n, 8); + block.writeUInt32LE(0x42726577, 16); + block.writeBigUInt64LE(40n, 24); + block.write("APK Sig Block 42", 32, "ascii"); + const paths = [join(root, "before.apk"), join(root, "after.apk")]; + for (const [index, path] of paths.entries()) { + block.writeUInt32LE(index, 20); + const apk = Buffer.concat([ + zip.subarray(0, directory), + block, + zip.subarray(directory), + ]); + apk.writeUInt32LE(directory + block.length, eocd + block.length + 16); + await writeFile(path, apk); + } + const left = await observe(paths[0]!); + const right = await observe(paths[1]!); + const inventory = artifactInventoryResultSchema.parse( + left.normalized_result, + ); + expect(inventory.limitations).toContain( + "ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.", + ); + expect( + inventory.occurrences.map(({ logical_path }) => logical_path).sort(), + ).toEqual([".", "classes.dex"]); + expect(compareArtifacts(left, right)).toMatchObject({ + status: "changed", + summary: { changed: 1, unchanged: 1, unknown: 0 }, + changes: [ + expect.objectContaining({ logical_path: ".", dimensions: ["content"] }), + ], + }); + const { + coverage: _coverage, + coverage_status: _coverageStatus, + ...legacyInventory + } = inventory; + const withLimitation = (limitation: string) => + createEvidence( + { + path: paths[0]!, + sha256: inventory.manifest.root_sha256, + format: "apk", + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse({ + ...legacyInventory, + limitations: [limitation], + }), + confidence: "observed", + authority: "shipped-artifact", + }, + ); + const historical = withLimitation( + "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences.", + ); + expect(compareArtifacts(historical, left)).toMatchObject({ + status: "unchanged", + summary: { unchanged: 2, unknown: 0 }, + changes: [], + }); + expect( + compareArtifacts( + withLimitation("ZIP entry bytes could not be read."), + left, + ).status, + ).toBe("unknown"); + }); +}); diff --git a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts index 9ea26cfda..05e6a0e1a 100644 --- a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts +++ b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts @@ -52,3 +52,22 @@ it("rejects a root path replaced between its initial stat and open", async () => message: `Root artifact changed before inventory: ${path}`, }); }); + +it("keeps unavailable container traversal distinct from observed root bytes", async () => { + const root = await createTestTempDirectory("rea-artifact-root-scope-"); + const path = join(root, "fixture.pkg"); + // The recognized XAR prefix selects PKG inventory; no child decoder is installed. + await writeFile(path, Buffer.from("xar!fixture")); + const inventory = await inventoryArtifact(path); + expect(inventory.manifest).toMatchObject({ + root_format: "pkg", + node_count: 1, + occurrence_count: 1, + edge_count: 0, + }); + expect(inventory.occurrences[0]?.hash_status).toBe("verified"); + expect(inventory.coverage).toEqual({ + scope: "container-entries", + status: "unknown", + }); +}); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index e56055069..eb5977a54 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -26,6 +26,7 @@ import { thinMach, } from "../../../src/domain/binaryTarget.fixture.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -424,6 +425,65 @@ describe("binary target I/O: explicit kinds and executable headers", () => { }); }); + it("extracts CodeView RSDS debug metadata from a PE target", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "symbols.exe"); + await writeFile( + path, + pe(0x8664, 64, 0x0002, false, { + type: "RSDS", + pdbPath: "C:\\build\\test.pdb", + guid: "12345678-abcd-ef01-2345-6789abcdef01", + age: 3, + }), + ); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + format: "pe", + architecture: "x86_64", + codeView: { + pdbPath: "C:\\build\\test.pdb", + pdbFileName: "test.pdb", + guid: "12345678-abcd-ef01-2345-6789abcdef01", + age: 3, + }, + }); + }); + + it("extracts CodeView NB10 debug metadata from a PE target", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "legacy.exe"); + await writeFile( + path, + pe(0x014c, 64, 0x0002, false, { + type: "NB10", + pdbPath: "D:\\old\\legacy.pdb", + age: 1, + }), + ); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "ia32", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + format: "pe", + architecture: "x86", + codeView: { + pdbPath: "D:\\old\\legacy.pdb", + pdbFileName: "legacy.pdb", + guid: null, + age: 1, + }, + }); + }); + it("refuses a truncated Mach-O header before any provider sees it", async () => { const directory = await createTestTempDirectory("rea-target-"); const path = join(directory, "truncated"); @@ -884,3 +944,43 @@ it.each([ }); }, ); + +describe("binary target I/O: PE CodeView header ranges", () => { + it.each(["RSDS", "NB10"] as const)( + "reads bounded %s header debug records and rejects unfinished names", + async (type) => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "header-symbols.exe"); + const bytes = pe(0x8664, 64, 0x0002, false, { + type, + pdbPath: "header.pdb", + }); + const optionalHeader = 64 + 24; + bytes.writeUInt32LE(512, optionalHeader + 60); + bytes.copy(bytes, 384, 512); + bytes.writeUInt32LE(384, optionalHeader + 112 + 6 * 8); + bytes.writeUInt32LE(412, 384 + 20); + bytes.writeUInt32LE(0, 384 + 24); + for (const complete of [true, false]) { + if (!complete) { + const dataSize = bytes.readUInt32LE(384 + 16); + bytes[412 + dataSize - 1] = 0x61; + } + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path, { + hostArchitecture: "x64", + }); + const buffered = parseExecutableHeader(bytes, "x64"); + if (!resolved.ok) throw resolved.error; + if (!buffered.ok) throw new Error(buffered.error); + for (const value of [resolved.value, buffered.value]) { + if (complete) + expect(value).toMatchObject({ + codeView: { pdbPath: "header.pdb" }, + }); + else expect(value).not.toHaveProperty("codeView"); + } + } + }, + ); +}); diff --git a/tests/boundary/filesystem/ciCachePolicy.test.ts b/tests/boundary/filesystem/ciCachePolicy.test.ts new file mode 100644 index 000000000..47e4f86dc --- /dev/null +++ b/tests/boundary/filesystem/ciCachePolicy.test.ts @@ -0,0 +1,252 @@ +import { execFileSync } from "node:child_process"; +import { chmod, readFile, readdir, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const repository = process.cwd(); +const actionPath = join(repository, ".github/actions/npm-cache/action.yml"); +const readAction = async () => + z + .object({ + outputs: z.record(z.string(), z.object({ value: z.string() })), + runs: z.object({ + steps: z.array( + z.object({ + id: z.string().optional(), + if: z.string().optional(), + uses: z.string().optional(), + with: z.record(z.string(), z.string()).optional(), + run: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + }), + ), + }), + }) + .parse(parse(await readFile(actionPath, "utf8"))); + +const readWorkflows = async () => { + const directory = join(repository, ".github/workflows"); + const files = (await readdir(directory)).filter((file) => + /\.ya?ml$/u.test(file), + ); + return Promise.all( + files.map(async (file) => ({ + file, + workflow: z + .object({ + jobs: z + .record( + z.string(), + z.object({ + steps: z.array(z.record(z.string(), z.unknown())).optional(), + }), + ) + .optional(), + }) + .parse(parse(await readFile(join(directory, file), "utf8"))), + })), + ); +}; + +describe("hosted npm download-cache policy", () => { + it("only allows shared writes for trusted original default-branch events", async () => { + const action = await readAction(); + const policy = action.runs.steps.find((step) => step.id === "context"); + const command = z.string().parse(policy?.run); + + const outputDirectory = await createTestTempDirectory( + "rea-ci-cache-policy-", + ); + const npmStub = join(outputDirectory, "npm"); + await writeFile( + npmStub, + String.raw`#!/bin/sh +printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache' +`, + ); + await chmod(npmStub, 0o755); + + for (const [event, ref, defaultBranch, expected] of [ + ["push", "refs/heads/main", "main", "true"], + ["schedule", "refs/heads/main", "main", "true"], + ["workflow_dispatch", "refs/heads/main", "main", "true"], + ["push", "refs/heads/stable", "stable", "true"], + ["pull_request", "refs/heads/main", "main", "false"], + ["pull_request_target", "refs/heads/main", "main", "false"], + ["workflow_call", "refs/heads/main", "main", "false"], + ["push", "refs/heads/feature/cache", "main", "false"], + ["push", "refs/heads/main", "stable", "false"], + ["push", "refs/heads/main", "", "false"], + ]) { + const outputPath = join( + outputDirectory, + `policy-${Date.now()}-${Math.random()}`, + ); + execFileSync("bash", ["-euo", "pipefail", "-c", command], { + env: { + ...process.env, + PATH: `${outputDirectory}${delimiter}${process.env.PATH ?? ""}`, + GITHUB_EVENT_NAME: event, + GITHUB_REF: ref, + GITHUB_OUTPUT: outputPath, + DEFAULT_BRANCH: defaultBranch, + LOCKFILES_HASH: "lock-hash", + RUNNER_OS: "Windows", + }, + }); + const output = await readFile(outputPath, "utf8"); + expect(output).toContain(`can-save=${expected}\n`); + expect(output).toContain( + `path=${String.raw`C:\Users\runner with spaces\AppData\Local\npm-cache`}\n`, + ); + expect(output).toContain( + `key=npm-download-Windows-${process.arch}-lock-hash\n`, + ); + } + }); + + it("uses a lockfile-, npmrc-, OS-, and runtime-architecture-scoped download cache", async () => { + const action = await readAction(); + const policy = action.runs.steps.find((step) => step.id === "context"); + const restoreAndSave = action.runs.steps.find((step) => + step.uses?.startsWith("actions/cache@"), + ); + const restoreOnly = action.runs.steps.find((step) => + step.uses?.startsWith("actions/cache/restore@"), + ); + + expect(action.outputs["can-save"]?.value).toBe( + "${{ steps.context.outputs.can-save }}", + ); + expect(action.outputs.path?.value).toBe( + "${{ steps.context.outputs.path }}", + ); + expect(policy?.env?.DEFAULT_BRANCH).toBe( + "${{ github.event.repository.default_branch }}", + ); + expect(policy?.env?.LOCKFILES_HASH).toBe( + "${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}", + ); + expect(policy?.run).toContain( + 'cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"', + ); + expect(restoreAndSave?.uses).toBe( + "actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830", + ); + expect(restoreAndSave?.if).toBe("steps.context.outputs.can-save == 'true'"); + expect(restoreOnly?.uses).toBe( + "actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830", + ); + expect(restoreOnly?.if).toBe("steps.context.outputs.can-save != 'true'"); + expect(restoreAndSave?.with?.path).toBe( + "${{ steps.context.outputs.path }}", + ); + expect(restoreOnly?.with?.path).toBe(restoreAndSave?.with?.path); + expect(restoreAndSave?.with?.key).toBe("${{ steps.context.outputs.key }}"); + expect(restoreOnly?.with?.key).toBe(restoreAndSave?.with?.key); + expect(restoreAndSave?.with).not.toHaveProperty("restore-keys"); + expect(restoreOnly?.with).not.toHaveProperty("restore-keys"); + }); + + it("replaces every inline setup-node npm cache immediately and preserves conditions", async () => { + const workflows = await readWorkflows(); + let npmCacheSteps = 0; + + for (const { file, workflow } of workflows) { + const source = await readFile( + join(repository, ".github/workflows", file), + "utf8", + ); + expect(source).not.toMatch(/^\s+cache:\s*npm\s*$/mu); + for (const [jobName, job] of Object.entries(workflow.jobs ?? {})) { + const steps = job.steps ?? []; + for (let index = 0; index < steps.length; index++) { + const step = steps[index]; + if (step?.uses !== "./.github/actions/npm-cache") continue; + npmCacheSteps++; + expect(step.id).toBe("npm-cache"); + const setup = steps[index - 1]; + expect( + typeof setup?.uses === "string" && + setup.uses.startsWith("actions/setup-node@"), + `${file}:${jobName} npm cache action must immediately follow setup-node`, + ).toBe(true); + expect(step.if).toBe(setup?.if); + expect(setup?.with).not.toHaveProperty("cache", "npm"); + const npmCiIndex = steps.findIndex( + (candidate, candidateIndex) => + candidateIndex > index && candidate.run === "npm ci", + ); + expect( + npmCiIndex, + `${file}:${jobName} retains npm ci after cache`, + ).toBeGreaterThan(index); + } + } + } + + expect(npmCacheSteps).toBeGreaterThan(0); + }); +}); + +it("shares static task snapshots only after validation using the npm cache owner's write authority", async () => { + const workflows = await readWorkflows(); + const ci = workflows.find(({ file }) => file === "ci.yml"); + const steps = ci?.workflow.jobs?.static?.steps ?? []; + const restore = steps.find((step) => step.id === "static-cache"); + const save = steps.find( + (step) => step.name === "Save validated static task cache", + ); + expect(restore?.uses).toBe( + "actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830", + ); + expect(restore?.if).toBe("needs.changes.outputs.code == 'true'"); + expect(restore?.with).toMatchObject({ path: ".turbo/cache/" }); + expect(save?.uses).toBe( + "actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830", + ); + expect(save?.if).toContain("success()"); + expect(save?.if).toContain("steps.npm-cache.outputs.can-save == 'true'"); + expect(save?.with).toEqual({ + path: ".turbo/cache/", + key: "${{ steps.static-cache.outputs.cache-primary-key }}", + }); + expect(steps.indexOf(save!)).toBeGreaterThan( + steps.findIndex((step) => step.run === "npm run test:python"), + ); +}); + +it("bounds the rolling static snapshot with Turbo age and size eviction", async () => { + const turbo = z + .object({ + cacheMaxAge: z.string(), + cacheMaxSize: z.string(), + tasks: z.record( + z.string(), + z.object({ inputs: z.array(z.unknown()).optional() }), + ), + }) + .parse(JSON.parse(await readFile(join(repository, "turbo.json"), "utf8"))); + // The static job restores the previous snapshot by prefix and saves the + // whole .turbo/cache directory again. Without local eviction every main + // commit carries all historical entries into a new immutable GitHub cache, + // growing transfers toward the 10 GB repository limit. + expect(turbo.cacheMaxAge).toMatch(/^[1-9]\d*(s|m|h|d|w)$/u); + expect(turbo.cacheMaxAge).not.toBe("0"); + const size = /^(\d+(?:\.\d+)?)(MB|GB|TB)$/iu.exec(turbo.cacheMaxSize); + expect(size, "cacheMaxSize must use an explicit MB/GB/TB budget").not.toBe( + null, + ); + const value = Number(size?.[1] ?? "0"); + const unit = (size?.[2] ?? "MB").toUpperCase(); + const megabytes = + unit === "TB" ? value * 1024 * 1024 : unit === "GB" ? value * 1024 : value; + expect(megabytes).toBeGreaterThan(0); + expect(megabytes).toBeLessThanOrEqual(1024); + expect(turbo.tasks["knip"]?.inputs).toContain("vitest.config.ts"); +}); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index fd7abf65b..9265e2e9b 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -215,6 +215,146 @@ it("does not charge a documentation PR for unrelated implementation updates on m }); }); +it.each([ + "src/gdb/GdbSessionManager.ts", + "src/rizin/RizinDebugSessionManager.ts", + "src/cutter/CutterBridgeClient.ts", + "src/objdump/ObjdumpCommand.ts", + "tests/boundary/providers/GdbSessionManagerCoverage.test.ts", + "tests/boundary/providers/GdbCommandQueue.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts", + "tests/boundary/providers/RizinCommandTimeout.test.ts", + "tests/boundary/providers/RizinTimeoutEvidence.test.ts", + "tests/boundary/providers/GdbSessionReviewRegressions.test.ts", + "tests/boundary/providers/gdbTestHelpers.ts", + "tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts", + "tests/boundary/mcp/gdbCancellationMcp.test.ts", + "tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts", + "tests/composition/reverseEngineeringAdmission.test.ts", + "tests/support/reverseEngineeringMcpHarness.ts", + "tests/support/reverseEngineeringCompositionEdgeCases.ts", + "tests/support/reverseEngineeringCompositionFixtures.ts", + "tests/support/reverseEngineeringService.ts", + "tests/support/ownedRizinCommandEvidenceTests.ts", + "tests/support/reverseDebuggerMcpTestHelpers.ts", + "tests/boundary/cli/gdbStartupCleanupCli.test.ts", + "vitest.reverse-tools.config.ts", + "vitest.reverse-tools-semantics.config.ts", + "stryker.config.mjs", + "stryker.semantics.config.mjs", + "tsconfig.reverse-tools-mutation.json", + "scripts/prepare-reverse-tools-mutation.mjs", + "scripts/lib/reverse-tools-mutation-targets.mjs", +])( + "selects the reverse-tool coverage and mutation gate for %s", + async (path) => { + const { directory, base } = await fixture(); + const head = await change(directory, [path]); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + reverse_tools: true, + }); + }, +); + +const reverseToolsVitestConfig = await readFile( + new URL("../../../vitest.reverse-tools.config.ts", import.meta.url), + "utf8", +); +const reverseToolsVitestInputs = [ + ...reverseToolsVitestConfig.matchAll(/"((?:\.\/)?(?:src|tests)\/[^"]+)"/gu), +].flatMap((match) => + match[1] === undefined ? [] : [match[1].replace(/^\.\//u, "")], +); +const reverseToolsMutationConfig = JSON.parse( + await readFile( + new URL("../../../tsconfig.reverse-tools-mutation.json", import.meta.url), + "utf8", + ), +) as { include: string[] }; +const reverseToolsInputs = [ + ...new Set([ + ...reverseToolsVitestInputs, + ...reverseToolsMutationConfig.include.map((path) => + path.replace(/^\.\//u, ""), + ), + ]), +]; + +it.each(reverseToolsInputs)( + "selects the reverse-tool quality gate for configured input %s", + async (path) => { + const { directory, base } = await fixture(); + const head = await change(directory, [path]); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + reverse_tools: true, + }); + }, +); + +it("requires the reverse-tool gate when its scope is selected", async () => { + const workflow = parse( + await readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ), + ); + expect(workflow.jobs["reverse-tools-quality"].if).toBe( + "needs.changes.outputs.reverse_tools == 'true'", + ); + expect(workflow.jobs.required.needs).toContain("reverse-tools-quality"); + const { CI_FLAGS } = await loadScopes(); + const outputs = { + ...Object.fromEntries(CI_FLAGS.map((flag) => [flag, "false"])), + tier: "fast", + reverse_tools: "true", + }; + const command = new URL("../../../scripts/ci/required.mjs", import.meta.url) + .pathname; + await expect( + execFileAsync(process.execPath, [command], { + env: { + ...process.env, + CI_JOBS: JSON.stringify({ + changes: { result: "success", outputs }, + static: { result: "success" }, + test: { result: "success" }, + "reverse-tools-quality": { result: "skipped" }, + }), + }, + }), + ).rejects.toMatchObject({ code: 1 }); + await expect( + execFileAsync(process.execPath, [command], { + env: { + ...process.env, + CI_JOBS: JSON.stringify({ + changes: { result: "success", outputs }, + static: { result: "success" }, + test: { result: "success" }, + "reverse-tools-quality": { result: "failure" }, + }), + }, + }), + ).rejects.toMatchObject({ code: 1 }); + await expect( + execFileAsync(process.execPath, [command], { + env: { + ...process.env, + CI_JOBS: JSON.stringify({ + changes: { result: "success", outputs }, + static: { result: "success" }, + test: { result: "success" }, + "reverse-tools-quality": { result: "success" }, + }), + }, + }), + ).resolves.toMatchObject({ + stdout: expect.stringContaining("Every selected CI lane passed"), + }); +}); + it.each([ "src/README.md", "README.md.ts", diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index 4c23844e7..d7ac1418e 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -506,3 +506,44 @@ it("rejects incomplete or malformed classification even when available jobs succ }); } }); + +it("fails fast only for ordinary PR matrices and rejects partial portable feedback", async () => { + const { jobs } = await workflow(); + expect(jobs["test-shard"]?.strategy).toMatchObject({ + "fail-fast": + "${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}", + }); + expect(jobs["package-e2e"]?.strategy).toMatchObject({ "fail-fast": false }); + const flags = await scopeFlags(); + for (const result of ["failure", "cancelled", "skipped", "missing"]) { + // Even an incorrectly successful report merge cannot hide matrix failure. + const needs: Record = { + changes: { + result: "success", + outputs: { + ...Object.fromEntries(flags.map((flag) => [flag, "false"])), + tier: "fast", + code: "true", + }, + }, + static: { result: "success" }, + build: { result: "success" }, + test: { result: "success" }, + ...(result === "missing" ? {} : { "test-shard": { result } }), + }; + await expect( + executeWorkflowFixture( + process.execPath, + [ + fileURLToPath( + new URL("../../../scripts/ci/required.mjs", import.meta.url), + ), + ], + { env: { ...process.env, CI_JOBS: JSON.stringify(needs) } }, + ), + ).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("test-shard"), + }); + } +}); diff --git a/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts b/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts index 22a218873..0abf14907 100644 --- a/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts +++ b/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts @@ -23,10 +23,27 @@ it("threads the selected host and environment into system diagnostics", async () environment, execFileOutput: async (command, arguments_, options) => { calls.push({ command, arguments: arguments_, environment: options?.env }); - if (command === "sw_vers") return { stdout: "15.0\n", stderr: "" }; - if (command === "ldd") return { stdout: "linux-vdso.so.1", stderr: "" }; + if (command === "sw_vers") + return { + stdout: "15.0\n", + stderr: "", + stdoutBytes: 5, + stderrBytes: 0, + }; + if (command === "ldd") + return { + stdout: "linux-vdso.so.1", + stderr: "", + stdoutBytes: Buffer.byteLength("linux-vdso.so.1"), + stderrBytes: 0, + }; if (command === "which") - return { stdout: "/selected/bin/rea\n", stderr: "" }; + return { + stdout: "/selected/bin/rea\n", + stderr: "", + stdoutBytes: 18, + stderrBytes: 0, + }; throw new Error(`Unexpected diagnostic command: ${command}`); }, }); @@ -88,7 +105,12 @@ it.each([ environment: { HOME: join(root, "home") }, execFileOutput: async (_command, arguments_) => { calls.push(arguments_); - return { stdout: `${caskroom}\n`, stderr: "" }; + return { + stdout: `${caskroom}\n`, + stderr: "", + stdoutBytes: Buffer.byteLength(`${caskroom}\n`), + stderrBytes: 0, + }; }, }); @@ -111,7 +133,12 @@ it("reports no Homebrew Hopper when the cask has no receipt", async () => { environment: { HOME: caskroom }, execFileOutput: async (_command, arguments_) => { calls.push(arguments_); - return { stdout: `${caskroom}\n`, stderr: "" }; + return { + stdout: `${caskroom}\n`, + stderr: "", + stdoutBytes: Buffer.byteLength(`${caskroom}\n`), + stderrBytes: 0, + }; }, }); diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index b2870e6c5..f889aaed8 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -20,7 +20,7 @@ import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGr import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ZIP_PACKAGE_FORMATS, - ZIP_NON_ENTRY_TAIL_LIMITATION, + ZIP_NON_ENTRY_LIMITATION, } from "../../../../src/domain/zipPackageFormat.js"; const { runProviderAnalysis } = createDirectAnalysis({}); @@ -214,7 +214,7 @@ describe("HarmonyOS packaging boundaries", () => { }); describe("HarmonyOS inventory coverage", () => { - it("keeps a case-colliding inventory partial in the application projection", async () => { + it("retains complete traversal and case-collision limits in the application projection", async () => { const path = await writeApplicationZip( "rea-harmony-case-collision-", "Fixture.hap", @@ -225,7 +225,7 @@ describe("HarmonyOS inventory coverage", () => { ); const inventory = await inventoryApplicationPackage(path); expect(inventory.normalized_result).toMatchObject({ - coverage_status: "partial", + coverage_status: "complete", }); const projection = requireDeterministicProjection( @@ -234,11 +234,11 @@ describe("HarmonyOS inventory coverage", () => { harmonyApplicationProjectionResultSchema, ); expect(projection.coverage).toEqual({ - status: "partial", + status: "complete-within-inventory", inventory_complete: true, }); - expect(projection.limitations).toContain( - "Source inventory coverage is partial; absence is unknown.", + expect(projection.limitations.join(" ")).toContain( + "case-insensitive destination", ); }); @@ -264,7 +264,7 @@ describe("HarmonyOS inventory coverage", () => { artifact_kind: "container", artifact_format: format, }); - expect(inventory.limitations).toContain(ZIP_NON_ENTRY_TAIL_LIMITATION); + expect(inventory.limitations).toContain(ZIP_NON_ENTRY_LIMITATION); }, ); diff --git a/tests/boundary/filesystem/productCatalog.test.ts b/tests/boundary/filesystem/productCatalog.test.ts index 2d79506f6..cabf72570 100644 --- a/tests/boundary/filesystem/productCatalog.test.ts +++ b/tests/boundary/filesystem/productCatalog.test.ts @@ -19,6 +19,9 @@ import { GHIDRA_PROVIDER_IDENTITY, GHIDRA_OPERATIONS, } from "../../../src/ghidra/GhidraProviderCapabilities.js"; +import { OBJDUMP_PROVIDER_IDENTITY } from "../../../src/objdump/ObjdumpCommand.js"; +import { RIZIN_PROVIDER_IDENTITY } from "../../../src/rizin/RizinCommand.js"; +import { GDB_PROVIDER_IDENTITY } from "../../../src/gdb/GdbSessionManager.js"; import { documentationFactIssues, skillReferenceIssues, @@ -118,6 +121,16 @@ describe("canonical product catalog", () => { expect(catalog.setup_clients.map(({ id }) => id)).toEqual( SUPPORTED_CLIENT_DEFINITIONS.map(({ name }) => name), ); + expect(catalog.cli.commands).toHaveLength(CLI_COMMAND_NAMES.length); + expect(catalog.providers.map(({ id }) => id)).toEqual( + expect.arrayContaining( + [ + OBJDUMP_PROVIDER_IDENTITY, + RIZIN_PROVIDER_IDENTITY, + GDB_PROVIDER_IDENTITY, + ].map(({ id }) => id), + ), + ); expect( catalog.providers.find(({ id }) => id === HOPPER_PROVIDER_IDENTITY.id) ?.capabilities, diff --git a/tests/boundary/filesystem/reverseToolsMutationConfig.test.ts b/tests/boundary/filesystem/reverseToolsMutationConfig.test.ts new file mode 100644 index 000000000..d6941c06c --- /dev/null +++ b/tests/boundary/filesystem/reverseToolsMutationConfig.test.ts @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const runNode = (source: string, ...arguments_: string[]): string => + execFileSync( + process.execPath, + ["--input-type=module", "-e", source, ...arguments_], + { cwd: root, encoding: "utf8" }, + ); + +describe("reverse-tools mutation configuration", () => { + it("loads nonempty targets from both actual Stryker configs", () => { + const result = runNode( + `const [mainUrl, semanticsUrl] = process.argv.slice(1); + const main = await import(mainUrl); + const semantics = await import(semanticsUrl); + process.stdout.write(JSON.stringify({ + main: main.default.mutate.length, + semantics: semantics.default.mutate.length, + }));`, + pathToFileURL(join(root, "stryker.config.mjs")).href, + pathToFileURL(join(root, "stryker.semantics.config.mjs")).href, + ); + const targets = JSON.parse(result) as { + main: number; + semantics: number; + }; + expect(targets.main).toBeGreaterThan(0); + expect(targets.semantics).toBeGreaterThan(0); + }); + + it("fails closed when an owned mutation method is missing", async () => { + const directory = await createTestTempDirectory("rea-mutation-selector-"); + const source = join(directory, "source.ts"); + await writeFile(source, "class Present { run() {} }\n"); + + const result = runNode( + `import { mutationRanges } from "./scripts/lib/reverse-tools-mutation-targets.mjs"; + const [source] = process.argv.slice(1); + try { + mutationRanges(source, [{ kind: "method", owner: "Missing", name: "run" }]); + throw new Error("missing owner unexpectedly selected"); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("found 0")) throw error; + process.stdout.write(error.message); + }`, + source, + ); + expect(result).toMatch(/found 0/u); + }); + + it("fails closed when an owned mutation method is ambiguous", async () => { + const directory = await createTestTempDirectory("rea-mutation-selector-"); + const source = join(directory, "source.ts"); + await writeFile(source, "class Owner { run() {} run(value: string) {} }\n"); + + const result = runNode( + `import { mutationRanges } from "./scripts/lib/reverse-tools-mutation-targets.mjs"; + const [source] = process.argv.slice(1); + try { + mutationRanges(source, [{ kind: "method", owner: "Owner", name: "run" }]); + throw new Error("ambiguous target unexpectedly selected"); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("found 2")) throw error; + process.stdout.write(error.message); + }`, + source, + ); + expect(result).toMatch(/found 2/u); + }); +}); diff --git a/tests/boundary/filesystem/staticTaskCache.test.ts b/tests/boundary/filesystem/staticTaskCache.test.ts new file mode 100644 index 000000000..2529f3bf4 --- /dev/null +++ b/tests/boundary/filesystem/staticTaskCache.test.ts @@ -0,0 +1,290 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { workflowGit } from "../../support/workflowGit.js"; + +const repository = fileURLToPath(new URL("../../../", import.meta.url)); +const turbo = join(repository, "node_modules/turbo/bin/turbo"); +const reportScript = join(repository, "scripts/ci/static-check-report.mjs"); +const rootConfigSchema = z.object({ + globalDependencies: z.array(z.string()).optional(), + globalPassThroughEnv: z.array(z.string()).optional(), + tasks: z.record(z.string(), z.record(z.string(), z.unknown())), +}); +const dryRunSchema = z.object({ + tasks: z.array(z.object({ taskId: z.string(), hash: z.string() })), +}); + +const execute = (command: string, args: string[], cwd: string) => + spawnSync(process.execPath, [command, ...args], { + cwd, + encoding: "utf8", + timeout: 30_000, + maxBuffer: 16 * 1024 * 1024, + env: process.env, + }); + +const readRootConfig = async () => + rootConfigSchema.parse( + JSON.parse(await readFile(join(repository, "turbo.json"), "utf8")), + ); + +const writeTaskFixture = async ( + root: string, + taskNames: string[], + scripts: Record, +) => { + const config = await readRootConfig(); + const tasks = Object.fromEntries( + taskNames.map((name) => { + const task = config.tasks[name]; + if (task === undefined) throw new Error(`Missing Turbo task ${name}`); + // These tests measure a task's own inputs. Drop dependency edges so the + // unrelated compile task cannot add fixture-specific inputs or outputs. + const { dependsOn: _dependsOn, ...definition } = task; + return [name, definition]; + }), + ); + await writeFile( + join(root, "turbo.json"), + JSON.stringify( + { + ...(config.globalDependencies === undefined + ? {} + : { globalDependencies: config.globalDependencies }), + ...(config.globalPassThroughEnv === undefined + ? {} + : { globalPassThroughEnv: config.globalPassThroughEnv }), + tasks, + }, + null, + 2, + ), + ); + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-static-task-cache-fixture", + version: "1.0.0", + private: true, + packageManager: "npm@11.16.0", + scripts, + }), + ); + await writeFile( + join(root, "package-lock.json"), + JSON.stringify({ + name: "rea-static-task-cache-fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { name: "rea-static-task-cache-fixture", version: "1.0.0" }, + }, + }), + ); +}; + +const runTurbo = ( + root: string, + task: string, + cacheDirectory: string, + dryRun = false, +) => + execute( + turbo, + [ + "run", + task, + ...(dryRun ? ["--dry=json"] : ["--output-logs=errors-only"]), + "--cache=local:rw,remote:r", + "--cache-dir", + cacheDirectory, + ], + root, + ); + +const taskHash = (root: string, task: string, cacheDirectory: string) => { + const result = runTurbo(root, task, cacheDirectory, true); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const { tasks } = dryRunSchema.parse(JSON.parse(result.stdout)); + const entry = tasks.find(({ taskId }) => taskId === task); + if (entry === undefined) throw new Error(`Turbo omitted task ${task}`); + return entry.hash; +}; + +it("restores a real lint report across cold, warm, and fresh consumer checkouts", async () => { + const producer = await createTestTempDirectory("rea-static-cache-producer-"); + const consumer = await createTestTempDirectory("rea-static-cache-consumer-"); + const cache = await createTestTempDirectory("rea-static-cache-store-"); + const script = "node scripts/ci/static-check-report.mjs lint"; + for (const root of [producer, consumer]) { + await mkdir(join(root, "scripts/ci"), { recursive: true }); + await mkdir(join(root, "src"), { recursive: true }); + await symlink( + join(repository, "node_modules"), + join(root, "node_modules"), + "junction", + ); + await writeTaskFixture(root, ["lint"], { lint: script }); + await writeFile( + join(root, ".nvmrc"), + await readFile(join(repository, ".nvmrc")), + ); + await writeFile(join(root, ".gitignore"), ".cache/\nnode_modules/\n"); + await writeFile( + join(root, ".oxlintrc.json"), + JSON.stringify({ rules: { "no-debugger": "error" } }), + ); + await writeFile(join(root, "src/check.js"), "export const ready = true;\n"); + await writeFile( + join(root, "scripts/ci/static-check-report.mjs"), + await readFile(reportScript, "utf8"), + ); + await workflowGit(root, ["init", "--initial-branch=main"]); + await workflowGit(root, ["add", "."]); + await workflowGit(root, ["commit", "-m", "test: seed static task cache"]); + } + + const cold = runTurbo(producer, "lint", cache); + expect(cold.error).toBeUndefined(); + expect(cold.status, cold.stderr).toBe(0); + expect(cold.stdout).toMatch(/Cached:\s+0 cached/u); + const report = join(producer, ".cache/static-checks/oxlint.json"); + const cleanReport = await readFile(report, "utf8"); + + await rm(join(producer, ".cache"), { recursive: true }); + const warm = runTurbo(producer, "lint", cache); + expect(warm.error).toBeUndefined(); + expect(warm.status, warm.stderr).toBe(0); + expect(warm.stdout).toMatch(/Cached:\s+1 cached/u); + expect(await readFile(report, "utf8")).toBe(cleanReport); + + const freshConsumer = runTurbo(consumer, "lint", cache); + expect(freshConsumer.error).toBeUndefined(); + expect(freshConsumer.status, freshConsumer.stderr).toBe(0); + expect(freshConsumer.stdout).toMatch(/Cached:\s+1 cached/u); + expect( + await readFile(join(consumer, ".cache/static-checks/oxlint.json"), "utf8"), + ).toBe(cleanReport); + + await writeFile(join(consumer, "src/check.js"), "debugger;\n"); + const invalid = runTurbo(consumer, "lint", cache); + expect(invalid.error).toBeUndefined(); + expect(invalid.status).not.toBe(0); + expect(invalid.stdout).toMatch(/Cached:\s+0 cached/u); + const { diagnostics } = z + .object({ + diagnostics: z.array( + z.object({ code: z.string().optional(), severity: z.string() }), + ), + }) + .parse( + JSON.parse( + await readFile( + join(consumer, ".cache/static-checks/oxlint.json"), + "utf8", + ), + ), + ); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "eslint(no-debugger)", + severity: "error", + }), + ); +}); + +it("invalidates checker task hashes for Node, ignore, and VitePress config changes", async () => { + const root = await createTestTempDirectory("rea-static-cache-inputs-"); + const cache = await createTestTempDirectory("rea-static-cache-hashes-"); + await mkdir(join(root, "docs/.vitepress"), { recursive: true }); + await mkdir(join(root, "src"), { recursive: true }); + await mkdir(join(root, "tests"), { recursive: true }); + await writeTaskFixture( + root, + ["jscpd", "knip", "metadata:check", "scan:todos"], + // This fixture probes only Turbo's real task hash; the checker commands + // are intentionally inert so the hash test cannot invoke large suites. + { + jscpd: 'node -e "process.exit(0)"', + knip: 'node -e "process.exit(0)"', + "metadata:check": 'node -e "process.exit(0)"', + "scan:todos": 'node -e "process.exit(0)"', + }, + ); + await writeFile(join(root, ".nvmrc"), "24.18.0\n"); + await writeFile(join(root, ".npmrc"), "engine-strict=true\n"); + await writeFile(join(root, ".gitignore"), ".cache/\n"); + await writeFile(join(root, ".jscpd.json"), "{}\n"); + await writeFile(join(root, "docs/.vitepress/config.ts"), "export {};\n"); + await writeFile( + join(root, "vitest.config.ts"), + 'import { CiTestSequencer } from "./tests/ciTestSequencer.js";\nexport const sequencer = CiTestSequencer;\n', + ); + await writeFile(join(root, "src/input.ts"), "export {};\n"); + await writeFile(join(root, "tests/input.ts"), "export {};\n"); + + const initial = new Map(); + for (const task of ["jscpd", "knip", "metadata:check", "scan:todos"]) + initial.set(task, taskHash(root, task, cache)); + + await writeFile(join(root, ".nvmrc"), "24.19.0\n"); + for (const task of ["jscpd", "metadata:check", "scan:todos"]) + expect( + taskHash(root, task, cache), + `${task} must hash the Node pin`, + ).not.toBe(initial.get(task)); + await writeFile(join(root, ".nvmrc"), "24.18.0\n"); + + await writeFile(join(root, ".npmrc"), "engine-strict=false\n"); + for (const task of ["jscpd", "knip", "metadata:check", "scan:todos"]) + expect( + taskHash(root, task, cache), + `${task} must hash npm settings`, + ).not.toBe(initial.get(task)); + await writeFile(join(root, ".npmrc"), "engine-strict=true\n"); + for (const task of ["jscpd", "metadata:check", "scan:todos"]) + expect(taskHash(root, task, cache), `${task} restores after Node pin`).toBe( + initial.get(task), + ); + + await writeFile(join(root, ".gitignore"), ".cache/\ncoverage/\n"); + for (const task of ["jscpd", "knip"]) + expect(taskHash(root, task, cache), `${task} must hash gitignore`).not.toBe( + initial.get(task), + ); + await writeFile(join(root, ".gitignore"), ".cache/\n"); + for (const task of ["jscpd", "knip"]) + expect( + taskHash(root, task, cache), + `${task} restores after gitignore`, + ).toBe(initial.get(task)); + + await writeFile( + join(root, "docs/.vitepress/config.ts"), + "export const configRevision = 2;\n", + ); + expect( + taskHash(root, "knip", cache), + "knip must hash its configured VitePress entry", + ).not.toBe(initial.get("knip")); + await writeFile(join(root, "docs/.vitepress/config.ts"), "export {};\n"); + + await writeFile(join(root, "vitest.config.ts"), "export {};\n"); + expect( + taskHash(root, "knip", cache), + "knip must hash vitest.config.ts so unused sequencer imports invalidate the cache", + ).not.toBe(initial.get("knip")); + for (const task of ["jscpd", "metadata:check", "scan:todos"]) + expect( + taskHash(root, task, cache), + `${task} must ignore vitest.config.ts`, + ).toBe(initial.get(task)); +}); diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index 29f0e952c..c8069be98 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -6,6 +6,7 @@ import { machoImage, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js"; +import { readGoMachoImage } from "../../../src/go/GoMachoImage.js"; import { createGoBinaryFixture, GO_MODULE_TEXT, @@ -519,6 +520,26 @@ it("rejects build-info declared inside an unmapped Mach-O segment", () => { expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/unmapped/i); }); +it("does not mark Mach-O section bytes outside their owning segment as read-only", () => { + const fixture = createGoBinaryFixture({ format: "macho" }); + const command = 32; + const section = command + 72; + fixture.bytes.write("__TEXT", command + 8, "ascii"); + fixture.bytes.writeBigUInt64LE(1024n, command + 48); + fixture.bytes.writeUInt32LE(1, command + 56); + fixture.bytes.writeUInt32LE(1, command + 60); + fixture.bytes.write("__debug_info", section, "ascii"); + fixture.bytes.write("__TEXT", section + 16, "ascii"); + fixture.bytes.writeBigUInt64LE(0x10c00n, section + 32); + fixture.bytes.writeBigUInt64LE(128n, section + 40); + fixture.bytes.writeUInt32LE(3072, section + 48); + const image = readGoMachoImage(fixture.bytes); + expect(image.sections[0]).toMatchObject({ + file_backed: false, + read_only: false, + }); +}); + it.each(["elf", "pe", "macho"] as const)( "refuses file aliases overlapping only part of a zero-filled %s module header", (format) => { diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 8fa3f0fb7..74815bc61 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -70,6 +70,57 @@ const connect = async ( return { client, session }; }; +it("projects opted-in query names identically from retained and portable MCP Evidence", async () => { + const { client, session } = await connect(); + const capture = analysisViewCaptureFixture(); + capture.records = capture.records.slice(0, 2); + capture.total_records = 2; + capture.records.forEach((record, index) => { + record.reported = { + request: { + method: "GET", + url: + index === 0 + ? "https://api.test/products?a+b=one&a%2Bb=two&=three&a+b=four" + : "https://api.test/products?a+b=five&snow=six#ignored", + }, + }; + }); + const parent = analysisViewCaptureEvidence(capture); + const retained = session.recordEvidence(parent); + if (!retained.ok) throw retained.error; + const outputs = []; + for (const source of [ + { kind: "inline", evidence: parent }, + { kind: "retained-evidence", evidence_id: parent.evidence_id }, + ]) { + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "api-surface", include_query_parameter_names: true }, + }, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + parent_evidence_id: parent.evidence_id, + groups: [ + { + query_parameter_names: [ + { name: "", record_ordinals: [0] }, + { name: "a b", record_ordinals: [0, 1] }, + { name: "a+b", record_ordinals: [0] }, + { name: "snow", record_ordinals: [1] }, + ], + }, + ], + }); + outputs.push(evidence.normalized_result); + } + expect(outputs[0]).toEqual(outputs[1]); +}); + it("discovers an oversized native dossier by exact metadata and reads its retained view", async () => { const { client, session } = await connect(); const advertised = (await client.listTools()).tools.find( @@ -278,6 +329,39 @@ it("advertises exact schemas and projects one layout section from retained Evide ); }); +it("points omitted inline messages to the caller's parent without promising session recovery", async () => { + const { client, session } = await connect(); + const messages = Array.from( + { length: 65 }, + (_, index) => `Limitation ${String(index).padStart(2, "0")}`, + ).concat("z".repeat(300_000)); + const parent = analysisViewLayoutEvidence({ + ...analysisViewLayoutFixture(), + limitations: messages, + }); + expect(session.evidenceById(parent.evidence_id)).toBeUndefined(); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }, + }); + expect(response.isError).not.toBe(true); + const view = toolContract("inspect_analysis_view").outputSchema.parse( + response.structuredContent, + ); + const projected = z + .object({ limitations: z.array(z.string()) }) + .parse(view.normalized_result); + expect(projected.limitations).toEqual([ + ...messages.slice(0, 65), + `1 additional limitations omitted from this view; consult source parent Evidence ${parent.evidence_id} for the complete list (the caller-supplied record, or its retaining session).`, + ]); + expect(session.evidenceById(parent.evidence_id)).toBeUndefined(); + expect(session.evidenceById(view.evidence_id)).toEqual(parseEvidence(view)); +}); + it("reports a tampered inline Evidence ID at its full MCP source path", async () => { const { client } = await connect(); const parent = analysisViewLayoutEvidence(); diff --git a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts index de78af9d0..fa0481ac7 100644 --- a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts +++ b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts @@ -254,8 +254,11 @@ const assertHistoricalInventory = async ( const { archive, parent, inventoryEvidence, graph } = input; // Historical records may omit a later inventory field. Input parsing must // not insert defaults inside that authenticated child Evidence. - const { integrity_contradictions: _contradictions, ...legacyInventory } = - graph; + const { + integrity_contradictions: _contradictions, + coverage: _coverage, + ...legacyInventory + } = graph; const legacyTarget = { path: archive, sha256: graph.manifest.root_sha256, @@ -275,6 +278,9 @@ const assertHistoricalInventory = async ( expect( legacyInspection.substeps[0]?.evidence.normalized_result, ).not.toHaveProperty("integrity_contradictions"); + expect( + legacyInspection.substeps[0]?.evidence.normalized_result, + ).not.toHaveProperty("coverage"); const legacyParent = createEvidence(legacyTarget, parent.provider, { operation: "inspect_artifact", parameters: {}, diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index da8a586d2..96ba07283 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -1,5 +1,7 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; import { ok as resultOk } from "../../../src/domain/result.js"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -98,20 +100,6 @@ it.each([ 20_000, ); -const artifactInventoryResultSchema = z.object({ - occurrences: z.array( - z.object({ logical_path: z.string(), hash_status: z.string() }), - ), - integrity_contradictions: z.array( - z.object({ - logical_path: z.string(), - declared_sha256: z.string(), - observed_sha256: z.string(), - trust: z.literal("observed-untrusted"), - }), - ), -}); - it("extracts an active archive through MCP when requested", async () => { const root = await createTestTempDirectory("rea-artifact-extract-mcp-"); const archive = join(root, "fixture.zip"); @@ -318,6 +306,30 @@ it("records an explicitly continued mismatch, preserves verified siblings, and n }), ]), }); + const { coverage: _coverage, ...legacyInventory } = inventory; + const historical = createEvidence( + { path: archive, sha256: inventory.manifest.root_sha256, format: "asar" }, + compact.provider, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse(legacyInventory), + }, + ); + const retainedComparison = await client.callTool({ + name: "compare_artifacts", + arguments: { left: evidence, right: historical }, + }); + expect( + retainedComparison.isError, + JSON.stringify(retainedComparison), + ).not.toBe(true); + expect( + toolEvidence(retainedComparison.structuredContent).normalized_result, + ).toMatchObject({ + status: "contradiction", + summary: { unchanged: 2, contradiction: 2, unknown: 0 }, + }); } finally { await Promise.allSettled([client.close(), server.close(), session.close()]); } diff --git a/tests/boundary/mcp/cutterCancellationMcp.test.ts b/tests/boundary/mcp/cutterCancellationMcp.test.ts new file mode 100644 index 000000000..5a4a5167e --- /dev/null +++ b/tests/boundary/mcp/cutterCancellationMcp.test.ts @@ -0,0 +1,168 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { expect, it } from "vitest"; + +import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js"; +import { registerCutterTools } from "../../../src/server/registerCutterTools.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { silentLogger } from "../../../src/logger.js"; + +it("propagates MCP cancellation into an active Cutter command and reports uncertain completion", async () => { + let markDispatched: (() => void) | undefined; + let observedAbort = false; + const dispatched = new Promise((resolve) => { + markDispatched = resolve; + }); + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + execute: async ({ signal }) => { + markDispatched?.(); + await new Promise((resolve) => { + signal?.addEventListener( + "abort", + () => { + observedAbort = true; + resolve(); + }, + { once: true }, + ); + }); + return { + requestId: "4b18ce4d-bf31-456e-ae31-e8c547b2f9db", + output: null, + currentFile: null, + documentGeneration: 1, + identityStatus: "partial" as const, + cutterVersion: "Cutter fixture version", + executionState: "unknown" as const, + error: "transport-response-missing", + message: "The command may have completed; do not retry automatically", + outputTruncated: false, + }; + }, + }); + const server = new McpServer({ name: "cutter-cancellation", version: "1" }); + registerCutterTools( + server, + service, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ name: "cutter-cancellation", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const controller = new AbortController(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const request = client.callTool( + { + name: "cutter_command", + arguments: { + session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + expected_generation: 1, + command: "Ps /tmp/possibly-saved.rzdb", + json: false, + }, + }, + { signal: controller.signal }, + ); + await dispatched; + controller.abort(); + await expect(request).rejects.toThrow(/abort/iu); + await expect.poll(() => observedAbort).toBe(true); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } +}); + +it("shares one cleanup proof across concurrent Cutter MCP commands without BinarySession", async () => { + let releaseProof!: () => void; + let enterProof!: () => void; + const proof = new Promise((resolve) => { + releaseProof = resolve; + }); + const entered = new Promise((resolve) => { + enterProof = resolve; + }); + let confirmations = 0; + let dispatches = 0; + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + confirmCommandCompletion: async () => { + confirmations += 1; + if (confirmations > 1) { + await proof; + await new Promise((resolve) => setImmediate(resolve)); + return { ok: true, value: false }; + } + enterProof(); + await proof; + return { ok: true, value: true }; + }, + execute: async ({ command }) => { + dispatches += 1; + return { + requestId: `00000000-0000-4000-8000-${String(dispatches).padStart(12, "0")}`, + output: command, + currentFile: null, + documentGeneration: 1, + identityStatus: "partial", + cutterVersion: "fixture", + executionState: dispatches === 1 ? "unknown" : "complete", + error: dispatches === 1 ? "transport-response-missing" : null, + message: null, + outputTruncated: false, + }; + }, + }); + const server = new McpServer({ name: "cutter-cleanup-race", version: "1" }); + registerCutterTools( + server, + service, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ name: "cutter-cleanup-race", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const call = (command: string) => + client.callTool({ + name: "cutter_command", + arguments: { + session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + expected_generation: 1, + command, + }, + }); + let pending: Promise[] = []; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + expect((await call("lost response")).isError).toBe(true); + const first = call("first queued command"); + const second = call("second queued command"); + pending = [first, second]; + await entered; + await new Promise((resolve) => setImmediate(resolve)); + releaseProof(); + const results = await Promise.all([first, second]); + for (const result of results) expect(result.isError).not.toBe(true); + expect(confirmations).toBe(1); + expect(dispatches).toBe(3); + } finally { + releaseProof(); + await Promise.allSettled(pending); + await Promise.allSettled([client.close(), server.close()]); + } +}); diff --git a/tests/boundary/mcp/cutterMcp.test.ts b/tests/boundary/mcp/cutterMcp.test.ts new file mode 100644 index 000000000..6827837ed --- /dev/null +++ b/tests/boundary/mcp/cutterMcp.test.ts @@ -0,0 +1,260 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { chmod, writeFile } from "node:fs/promises"; +import { createServer as createNetServer } from "node:net"; +import { createHmac } from "node:crypto"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; + +import { + createTestBinarySession, + createCacheProvider, +} from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { silentLogger } from "../../../src/logger.js"; + +const assertUncertainEvidence = async ( + client: Client, + session: ReturnType, + sessionId: string, +): Promise => { + const uncertain = await client.callTool({ + name: "cutter_command", + arguments: { + session_id: sessionId, + expected_generation: 3, + command: "Ps /tmp/uncertain.rzdb", + }, + }); + expect(uncertain.isError).toBe(true); + const uncertainText = uncertain.content + .filter( + (block): block is { type: "text"; text: string } => block.type === "text", + ) + .map((block) => block.text) + .join("\n"); + const uncertainError = JSON.parse(uncertainText) as { + error: { details: { partial_observation: { evidence_id: string } } }; + }; + const uncertainEvidence = session.evidenceById( + uncertainError.error.details.partial_observation.evidence_id, + ); + expect(uncertainEvidence).toBeDefined(); + if (uncertainEvidence === undefined) + throw new Error("partial Evidence missing"); + expect(uncertainEvidence.normalized_result).toMatchObject({ + execution_state: "unknown", + error: "transport-response-missing", + message: expect.stringContaining("do not retry automatically"), + document_generation: 3, + }); + expect(uncertainEvidence.limitations).toContain( + "Command completion or its output could not be confirmed; " + + "do not retry automatically because the command may have produced " + + "partial or persistent effects.", + ); +}; + +const assertNotStartedEvidenceAndAdmission = async ( + client: Client, + sessionId: string, +): Promise => { + const rejected = await client.callTool({ + name: "cutter_command", + arguments: { + session_id: sessionId, + expected_generation: 3, + command: "Ps /tmp/not-started.rzdb", + }, + }); + expect(rejected.isError, JSON.stringify(rejected)).not.toBe(true); + expect( + parseEvidence(rejected.structuredContent).normalized_result, + ).toMatchObject({ + execution_state: "not_started", + error: "session-poisoned-after-ui-timeout", + }); + + const admitted = await client.callTool({ + name: "cutter_command", + arguments: { + session_id: sessionId, + expected_generation: 3, + command: "iI", + }, + }); + expect(admitted.isError).not.toBe(true); +}; + +const createMcpBridge = async ( + directory: string, + sessionId: string, + token: string, +): Promise> => { + let connectionOrdinal = 0; + const bridge = createNetServer((connection) => { + const ordinal = ++connectionOrdinal; + let payload = ""; + let clientNonce = ""; + let serverNonce = ""; + connection.setEncoding("utf8"); + connection.on("data", (chunk: string) => { + payload += chunk; + const newline = payload.indexOf("\n"); + if (newline < 0) return; + const frame = JSON.parse(payload.slice(0, newline)) as Record< + string, + unknown + >; + payload = payload.slice(newline + 1); + if (frame.kind === "rea-auth-v2") { + clientNonce = String(frame.nonce); + serverNonce = `${ordinal.toString(16).padStart(16, "0")}${"c".repeat(48)}`; + const proof = createHmac("sha256", token) + .update( + `rea-cutter-server-v2:${sessionId}:${clientNonce}:${serverNonce}`, + ) + .digest("hex"); + connection.write( + `${JSON.stringify({ session_id: sessionId, nonce: serverNonce, proof })}\n`, + ); + return; + } + const requestBytes = String(frame.request); + const request = JSON.parse(requestBytes) as Record; + const expected = createHmac("sha256", token) + .update( + `rea-cutter-client-v2:${sessionId}:${clientNonce}:${serverNonce}:${requestBytes}`, + ) + .digest("hex"); + if (frame.proof !== expected) { + connection.destroy(); + return; + } + if (request.command === "Ps /tmp/uncertain.rzdb") { + connection.destroy(); + return; + } + const notStarted = request.command === "Ps /tmp/not-started.rzdb"; + const status = request.kind === "status"; + const response = JSON.stringify( + request.kind === "ack" + ? { ok: true, acknowledged: false } + : { + ok: !notStarted, + ...(notStarted + ? { + execution_state: "not_started", + error: "session-poisoned-after-ui-timeout", + } + : {}), + session_id: request.session_id, + output: status ? undefined : "synthetic Cutter command output", + current_file: "/fixtures/sample.bin", + document_generation: 3, + ...(status + ? { + completed_connection: (ordinal - 1) + .toString(16) + .padStart(16, "0"), + completed_commands: [], + } + : {}), + cutter_version: "Cutter fixture version", + identity_status: "partial", + }, + ); + const responseProof = createHmac("sha256", token) + .update( + `rea-cutter-response-v2:${sessionId}:${clientNonce}:${serverNonce}:${response}`, + ) + .digest("hex"); + connection.end(`${JSON.stringify({ response, proof: responseProof })}\n`); + }); + }); + await new Promise((resolve) => bridge.listen(0, "127.0.0.1", resolve)); + const address = bridge.address(); + if (address === null || typeof address === "string") + throw new Error("Expected an ephemeral IPv4 Cutter test bridge."); + await writeFile( + join(directory, `cutter-${process.pid}-${sessionId}.json`), + JSON.stringify({ + session_id: sessionId, + pid: process.pid, + host: "127.0.0.1", + port: address.port, + token, + document_generation: 2, + current_file: null, + cutter_version: "Cutter fixture version", + identity_status: "partial", + }), + { mode: 0o600 }, + ); + return bridge; +}; + +it("preserves live Cutter command Evidence and parity through the MCP boundary", async () => { + const directory = await createTestTempDirectory("rea-cutter-mcp-"); + if (process.platform !== "win32") await chmod(directory, 0o700); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-mcp-test"; + const bridge = await createMcpBridge(directory, sessionId, token); + onTestFinished( + async () => new Promise((resolve) => bridge.close(() => resolve())), + ); + const session = createTestBinarySession(createCacheProvider([])); + const server = createServer( + { kind: "session", session }, + { + logger: silentLogger, + providerEnvironment: { ...process.env, REA_CUTTER_BRIDGE_DIR: directory }, + }, + ); + const client = new Client({ name: "cutter-mcp-boundary", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const discovery = await client.callTool({ + name: "list_cutter_sessions", + arguments: {}, + }); + expect(discovery.isError).not.toBe(true); + expect(discovery.structuredContent).toMatchObject({ + discovery_status: "sessions_found", + sessions: [{ session_id: sessionId, document_generation: 3 }], + }); + + const command = await client.callTool({ + name: "cutter_command", + arguments: { + session_id: sessionId, + expected_generation: 3, + command: "iI", + }, + }); + expect(command.isError).not.toBe(true); + const evidence = parseEvidence(command.structuredContent); + expect(evidence).toMatchObject({ + operation: "cutter_command", + provider: { id: "cutter.python-plugin" }, + normalized_result: { + command: "iI", + output: "synthetic Cutter command output", + document_generation: 3, + cutter_version: "Cutter fixture version", + }, + }); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + + await assertNotStartedEvidenceAndAdmission(client, sessionId); + await assertUncertainEvidence(client, session, sessionId); +}); diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index e452d70c3..a08d35a34 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -313,4 +313,38 @@ describe("overview document identity through MCP", () => { .isError, ).toBe(true); }); + + it("reports document selection unavailable when neither current-document nor list-documents is supported", async () => { + const client = await connect({ + execute: (name) => { + if (name === "current_document" || name === "list_documents") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "fixture", + name, + "provider lacks document selection", + ), + ), + ); + if (name === "list_segments") return Promise.resolve(ok([])); + if (name === "list_procedures" || name === "list_strings") + return Promise.resolve(ok([])); + throw new Error(`Unexpected analysis operation: ${name}`); + }, + }); + const result = await client.callTool({ + name: "binary_overview", + arguments: {}, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "capability_unavailable", + details: { + provider_id: "fixture", + operation: "list_documents", + reason: expect.stringContaining("provider lacks document selection"), + }, + }); + }); }); diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts new file mode 100644 index 000000000..2f1853125 --- /dev/null +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -0,0 +1,643 @@ +import { createHash } from "node:crypto"; +import { setImmediate } from "node:timers/promises"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { + Device, + DeviceManager, + FridaMessage, + Script, + Session, +} from "frida"; +import { expect, it, onTestFinished } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; + +const gate = () => { + let markEntered = () => {}; + let release = () => {}; + const entered = new Promise((resolve) => { + markEntered = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + return { + entered, + release, + async wait() { + markEntered(); + await released; + }, + }; +}; + +const nativeFixture = () => { + const created: string[] = []; + const loaded: string[] = []; + const unloaded: string[] = []; + const scripts: Script[] = []; + const scriptsAtResume: string[][] = []; + const controls = { + attach: async () => {}, + create: async () => {}, + load: async () => {}, + afterLoad: () => {}, + discover: async () => {}, + unloadFailures: 0, + detachFailures: 0, + detachCalls: 0, + resumes: 0, + remoteRemovals: 0, + removalFailures: 0, + }; + let detached = false; + const nativeSession: Session = { + pid: 512, + detached: { connect() {}, disconnect() {} }, + isDetached: () => detached, + async detach() { + controls.detachCalls += 1; + if (controls.detachFailures > 0) { + controls.detachFailures -= 1; + throw new Error("transient detach failure"); + } + detached = true; + }, + async createScript(source) { + created.push(source); + await controls.create(); + let destroyed = false; + let listener: + | ((message: FridaMessage, data: Buffer | null) => void) + | undefined; + const script: Script = { + get isDestroyed() { + return destroyed; + }, + message: { + connect(handler) { + listener = handler; + }, + disconnect() { + listener = undefined; + }, + }, + async load() { + await controls.load(); + loaded.push(source); + listener?.({ type: "send", payload: source }, null); + controls.afterLoad(); + }, + async unload() { + unloaded.push(source); + if (controls.unloadFailures > 0) { + controls.unloadFailures -= 1; + throw new Error("transient unload failure"); + } + destroyed = true; + }, + }; + scripts.push(script); + return script; + }, + }; + const device: Device = { + id: "local", + name: "Local fixture", + type: "local", + async enumerateProcesses() { + await controls.discover(); + return [{ pid: 512, name: "fixture" }]; + }, + async getProcessByPid() { + return { pid: 512, name: "fixture" }; + }, + async spawn() { + return 512; + }, + async resume() { + scriptsAtResume.push( + created.filter((_, index) => scripts[index]?.isDestroyed === false), + ); + controls.resumes += 1; + }, + async attach() { + await controls.attach(); + return nativeSession; + }, + }; + const deviceManager: DeviceManager = { + async enumerateDevices() { + return [device]; + }, + async getDeviceById() { + return device; + }, + async addRemoteDevice() { + return device; + }, + async removeRemoteDevice() { + controls.remoteRemovals += 1; + if (controls.removalFailures > 0) { + controls.removalFailures -= 1; + throw new Error("transient remote removal failure"); + } + }, + }; + return { + controls, + created, + loaded, + unloaded, + scripts, + scriptsAtResume, + deviceManager, + }; +}; + +const connectedFixture = async () => { + const native = nativeFixture(); + const manager = new FridaInstrumentationManager({ + deviceManager: native.deviceManager, + }); + const session = createTestBinarySession(() => { + throw new Error("Binary provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { fridaInstrumentation: manager }, + ); + const client = new Client({ name: "frida-lifecycle", version: "1" }); + onTestFinished(async () => { + native.controls.detachFailures = 0; + native.controls.removalFailures = 0; + await client.close(); + await server.close(); + await manager.closeAll(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const start = async () => { + const result = await client.callTool({ + name: "start_frida_session", + arguments: { + mode: "spawn", + program: "/fixture", + device_id: "local", + }, + }); + return toolContract("start_frida_session").outputSchema.parse( + result.structuredContent, + ).session_id; + }; + const load = (sessionId: string, source: string, signal?: AbortSignal) => + client.callTool( + { + name: "load_frida_script", + arguments: { session_id: sessionId, source_kind: "inline", source }, + }, + signal === undefined ? undefined : { signal }, + ); + return { ...native, manager, session, server, client, start, load }; +}; + +const scriptLoadFixture = async (stage: "create" | "load" = "load") => { + const fixture = await connectedFixture(); + const id = await fixture.start(); + const original = await fixture.load(id, "original"); + const held = gate(); + onTestFinished(held.release); + fixture.controls[stage] = held.wait; + return { fixture, id, original, held }; +}; + +const pendingShutdown = async (server: { close(): Promise }) => { + let closed = false; + const closing = server + .close() + .then( + () => undefined, + (cause: unknown) => cause, + ) + .finally(() => { + closed = true; + }); + await setImmediate(); + expect(closed).toBe(false); + return { closing }; +}; + +const retryCancelledScriptUnload = async ( + fixture: Awaited>, + sessionId: string, + scriptId = fixture.manager.status(sessionId)?.scripts[1]?.scriptId, +) => { + if (scriptId === undefined) + throw new Error("Cancelled script was not retained"); + const retry = await fixture.client.callTool({ + name: "unload_frida_script", + arguments: { + session_id: sessionId, + script_id: scriptId, + }, + }); + expect(retry.isError).not.toBe(true); +}; + +const retainedScriptObservation = async ( + fixture: Awaited>, + sessionId: string, + source: string, +) => { + const response = await fixture.client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + const bundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + response.structuredContent, + ).result, + ); + const sourceSha256 = createHash("sha256").update(source).digest("hex"); + const records = bundle.records.filter((record) => { + const result = record.normalized_result; + return ( + record.operation === "load_frida_script" && + result !== null && + typeof result === "object" && + !Array.isArray(result) && + result.source_sha256 === sourceSha256 + ); + }); + expect(records).toHaveLength(1); + expect(records[0]?.parameters).toEqual({ session_id: sessionId }); + const observation = records[0]?.normalized_result; + if ( + observation === undefined || + observation === null || + typeof observation !== "object" || + Array.isArray(observation) + ) + throw new Error("Missing retained script observation"); + expect(observation).toMatchObject({ + script_id: expect.any(String), + source_kind: "inline", + source_path: null, + source_sha256: sourceSha256, + messages_truncated: false, + }); + return observation; +}; + +it.each(["load", "resume", "unload"] as const)( + "finishes an earlier MCP %s request before closing its Frida session", + async (operation) => { + const fixture = await connectedFixture(); + const id = await fixture.start(); + const original = await fixture.load(id, "original"); + const scriptId = toolContract("load_frida_script").outputSchema.parse( + original.structuredContent, + ).normalized_result.script_id; + const pending = fixture.client.callTool({ + name: { + load: "load_frida_script", + resume: "resume_frida_session", + unload: "unload_frida_script", + }[operation], + arguments: { + session_id: id, + ...(operation === "load" + ? { source_kind: "inline", source: "last-script" } + : {}), + ...(operation === "unload" ? { script_id: scriptId } : {}), + }, + }); + const closing = fixture.client.callTool({ + name: "close_frida_session", + arguments: { session_id: id }, + }); + expect((await pending).isError).not.toBe(true); + expect((await closing).isError).not.toBe(true); + expect(fixture.loaded).toEqual( + operation === "load" ? ["original", "last-script"] : ["original"], + ); + expect(fixture.unloaded).toEqual(fixture.loaded); + expect(fixture.controls.detachCalls).toBe(1); + expect(fixture.controls.resumes).toBe(1); + expect(fixture.manager.status(id)).toBeUndefined(); + }, +); + +it.each(["load", "resume", "unload"] as const)( + "does not dispatch a cancelled queued Frida %s through MCP", + async (operation) => { + const { fixture, id, original, held } = await scriptLoadFixture(); + const scriptId = toolContract("load_frida_script").outputSchema.parse( + original.structuredContent, + ).normalized_result.script_id; + const first = fixture.load(id, "blocking"); + await held.entered; + const controller = new AbortController(); + const cancelled = + operation === "load" + ? fixture.load(id, "cancelled", controller.signal) + : fixture.client.callTool( + { + name: + operation === "resume" + ? "resume_frida_session" + : "unload_frida_script", + arguments: { + session_id: id, + ...(operation === "unload" ? { script_id: scriptId } : {}), + }, + }, + { signal: controller.signal }, + ); + const rejected = expect(cancelled).rejects.toThrow( + "cancel queued operation", + ); + await fixture.client.ping(); + controller.abort(new Error("cancel queued operation")); + await fixture.client.ping(); + await rejected; + held.release(); + await first; + await fixture.load(id, "barrier"); + expect(fixture.created).toEqual(["original", "blocking", "barrier"]); + expect(fixture.unloaded).toEqual([]); + expect(fixture.controls.resumes).toBe(0); + expect(fixture.manager.status(id)?.scripts).toHaveLength(3); + }, +); + +it.each(["create", "load"] as const)( + "releases only the new script when MCP cancellation arrives during native %s", + async (stage) => { + const { fixture, id, held } = await scriptLoadFixture(stage); + const controller = new AbortController(); + const cancelled = fixture.load(id, "cancelled-native", controller.signal); + const rejected = expect(cancelled).rejects.toThrow( + "cancel native operation", + ); + await held.entered; + controller.abort(new Error("cancel native operation")); + await fixture.client.ping(); + await rejected; + held.release(); + await fixture.load(id, "barrier"); + await fixture.client.ping(); + expect(fixture.unloaded).toEqual(["cancelled-native"]); + expect(fixture.loaded).toEqual( + stage === "create" + ? ["original", "barrier"] + : ["original", "cancelled-native", "barrier"], + ); + expect(fixture.controls.detachCalls).toBe(0); + expect(fixture.controls.resumes).toBe(0); + expect(fixture.manager.status(id)?.scripts).toHaveLength(2); + const observation = await retainedScriptObservation( + fixture, + id, + "cancelled-native", + ); + expect(observation).toMatchObject({ + messages: + stage === "create" + ? [] + : [expect.objectContaining({ payload: "cancelled-native" })], + script_cleanup: "released", + cleanup_error: null, + }); + }, +); + +it("awaits an admitted attach on server shutdown and retries failed native cleanup", async () => { + const fixture = await connectedFixture(); + const held = gate(); + onTestFinished(held.release); + fixture.controls.attach = held.wait; + fixture.controls.detachFailures = 1; + const starting = fixture.client + .callTool({ + name: "start_frida_session", + arguments: { + mode: "attach", + pid: 512, + device_id: "local", + }, + }) + .catch(() => undefined); + await held.entered; + const { closing } = await pendingShutdown(fixture.server); + expect(fixture.controls.detachCalls).toBe(0); + held.release(); + expect(await closing).toBeInstanceOf(AggregateError); + await starting; + expect(fixture.controls.detachCalls).toBe(1); + await fixture.server.close(); + expect(fixture.controls.detachCalls).toBe(2); + await fixture.server.close(); + expect(fixture.controls.detachCalls).toBe(2); + const afterClose = await fixture.manager.startSession({ + mode: "attach", + pid: 512, + }); + expect(afterClose).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); +}); + +it("drains remote discovery before shutdown and retains failed connection cleanup", async () => { + const fixture = await connectedFixture(); + const held = gate(); + onTestFinished(held.release); + fixture.controls.discover = held.wait; + fixture.controls.removalFailures = 2; + const discovering = fixture.client + .callTool({ + name: "list_frida_processes", + arguments: { + remote: { address: "127.0.0.1:27042" }, + }, + }) + .catch(() => undefined); + await held.entered; + const { closing } = await pendingShutdown(fixture.server); + held.release(); + expect(await closing).toBeInstanceOf(AggregateError); + await discovering; + expect(fixture.controls.remoteRemovals).toBe(2); + await fixture.server.close(); + expect(fixture.controls.remoteRemovals).toBe(3); +}); + +it.each(["create", "load"] as const)( + "retains the identity of a cancelled native %s for an explicit MCP unload retry", + async (stage) => { + const { fixture, id, held } = await scriptLoadFixture(stage); + fixture.controls.unloadFailures = 1; + const controller = new AbortController(); + const cancelled = fixture.load(id, "cancelled-native", controller.signal); + const rejected = expect(cancelled).rejects.toThrow( + "cancel retained script", + ); + await held.entered; + controller.abort(new Error("cancel retained script")); + await fixture.client.ping(); + await rejected; + held.release(); + await fixture.load(id, "barrier"); + await fixture.client.ping(); + expect(fixture.manager.status(id)?.scripts).toHaveLength(3); + const observation = await retainedScriptObservation( + fixture, + id, + "cancelled-native", + ); + expect(observation).toMatchObject({ + messages: + stage === "create" + ? [] + : [expect.objectContaining({ payload: "cancelled-native" })], + script_cleanup: "incomplete_or_unknown", + cleanup_error: expect.stringContaining("transient unload failure"), + }); + const scriptId = observation.script_id; + if (typeof scriptId !== "string") + throw new Error("Missing cancelled script identity"); + expect(fixture.manager.status(id)?.scripts[1]?.scriptId).toBe(scriptId); + const resuming = await fixture.client.callTool({ + name: "resume_frida_session", + arguments: { session_id: id }, + }); + expect(resuming.isError).toBe(true); + expect(fixture.controls.resumes).toBe(0); + await retryCancelledScriptUnload(fixture, id, scriptId); + expect(fixture.unloaded).toEqual(["cancelled-native", "cancelled-native"]); + expect(fixture.loaded).toEqual( + stage === "create" + ? ["original", "barrier"] + : ["original", "cancelled-native", "barrier"], + ); + expect(fixture.manager.status(id)?.scripts).toHaveLength(2); + expect(fixture.controls.detachCalls).toBe(0); + }, +); + +it.each([0, 1])( + "settles cancelled native-load cleanup before a queued resume (unload failures: %i)", + async (unloadFailures) => { + const { fixture, id, held } = await scriptLoadFixture(); + fixture.controls.unloadFailures = unloadFailures; + const controller = new AbortController(); + const loading = fixture.load(id, "cancelled-native", controller.signal); + const rejected = expect(loading).rejects.toThrow( + "cancel before queued resume", + ); + await held.entered; + const resume = () => + fixture.client.callTool({ + name: "resume_frida_session", + arguments: { session_id: id }, + }); + const resuming = resume(); + await fixture.client.ping(); + controller.abort(new Error("cancel before queued resume")); + await fixture.client.ping(); + await rejected; + held.release(); + const resumed = await resuming; + await fixture.client.ping(); + expect(fixture.unloaded).toEqual(["cancelled-native"]); + expect(fixture.scriptsAtResume).toEqual( + unloadFailures === 0 ? [["original"]] : [], + ); + expect(fixture.controls.detachCalls).toBe(0); + expect(fixture.session.exportEvidenceBundle().records).toContainEqual( + expect.objectContaining({ + operation: "load_frida_script", + normalized_result: expect.objectContaining({ + messages: [expect.objectContaining({ payload: "cancelled-native" })], + script_cleanup: + unloadFailures === 0 ? "released" : "incomplete_or_unknown", + }), + }), + ); + if (unloadFailures === 0) { + expect(resumed.isError).not.toBe(true); + expect(fixture.manager.status(id)?.scripts).toHaveLength(1); + return; + } + expect(resumed.isError).toBe(true); + expect(fixture.manager.status(id)?.state).toBe("paused"); + expect((await resume()).isError).toBe(true); + await retryCancelledScriptUnload(fixture, id); + expect((await resume()).isError).not.toBe(true); + expect(fixture.scriptsAtResume).toEqual([["original"]]); + }, +); + +it.each([0, 1, 2, 3, 4, 8])( + "keeps MCP load Evidence consistent with queued resume across settlement timing %i", + async (microtasks) => { + const { fixture, id, held } = await scriptLoadFixture(); + const controller = new AbortController(); + fixture.controls.afterLoad = () => { + let remaining = microtasks; + const abort = () => { + if (remaining-- > 0) queueMicrotask(abort); + else controller.abort(new Error("cancel around load completion")); + }; + abort(); + }; + const loading = fixture.load(id, "settling-native", controller.signal); + const observed = loading.catch(() => undefined); + await held.entered; + const resuming = fixture.client.callTool({ + name: "resume_frida_session", + arguments: { session_id: id }, + }); + await fixture.client.ping(); + held.release(); + await observed; + expect((await resuming).isError).not.toBe(true); + fixture.controls.afterLoad = () => {}; + await fixture.load(id, "barrier"); + await fixture.client.ping(); + const records = fixture.session + .exportEvidenceBundle() + .records.filter( + (record) => + record.operation === "load_frida_script" && + JSON.stringify(record.normalized_result).includes("settling-native"), + ); + expect(records).toHaveLength(1); + const recorded = records[0]?.normalized_result; + if ( + recorded === null || + typeof recorded !== "object" || + Array.isArray(recorded) + ) + throw new Error("Missing script load Evidence"); + if (recorded.script_cleanup === "released") { + expect(fixture.scriptsAtResume).toEqual([["original"]]); + expect(fixture.unloaded).toEqual(["settling-native"]); + } else { + expect(recorded.script_cleanup).toBeUndefined(); + expect(fixture.scriptsAtResume).toEqual([ + ["original", "settling-native"], + ]); + expect(fixture.unloaded).toEqual([]); + } + }, +); diff --git a/tests/boundary/mcp/gdbCancellationMcp.test.ts b/tests/boundary/mcp/gdbCancellationMcp.test.ts new file mode 100644 index 000000000..79b7f1fce --- /dev/null +++ b/tests/boundary/mcp/gdbCancellationMcp.test.ts @@ -0,0 +1,532 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisTimeoutError, +} from "../../../src/domain/analysisErrorCore.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; +import { silentLogger } from "../../../src/logger.js"; +import { registerGdbTools } from "../../../src/server/registerGdbTools.js"; +import type { GdbToolManager } from "../../../src/server/registerGdbTools.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { + createBinarySessionTargets, + createCacheProvider, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { + validateDebuggerStatusResponse, + waitForStartedFile, +} from "../../support/reverseDebuggerMcpTestHelpers.js"; + +it.skipIf(process.platform === "win32")( + "propagates MCP cancellation into a running GDB command", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-gdb-mcp-cancel-")); + const provider = join(directory, "fake-gdb.mjs"); + const commandPath = join(directory, "command-started"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", async (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else await import("node:fs/promises").then((fs) => fs.writeFile(${JSON.stringify(commandPath)}, "started")); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + const server = new McpServer({ name: "gdb-cancellation", version: "1" }); + registerGdbTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ name: "gdb-cancellation", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_gdb_session", + arguments: {}, + }); + const sessionId = z + .object({ session_id: z.string().uuid() }) + .parse(started.structuredContent).session_id; + const controller = new AbortController(); + const command = client.callTool( + { + name: "gdb_console", + arguments: { session_id: sessionId, command: "shell sleep 600" }, + }, + { signal: controller.signal }, + ); + await waitForStartedFile(commandPath); + controller.abort(); + await expect(command).rejects.toThrow(/abort/iu); + const status = await validateDebuggerStatusResponse( + client, + "gdb_session_status", + sessionId, + ); + expect(status.state).toBe("closed"); + expect(status).toHaveProperty("diagnostics_truncated"); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await manager.closeAll().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "accepts caller-selected inferior_tty and exposes verified sink status through MCP", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-gdb-mcp-tty-")); + const provider = join(directory, "fake-gdb.mjs"); + const tty = `${directory}/inferior-"terminal-Ω"`; + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (!match) continue; + if (line.includes("inferior-tty-show")) process.stdout.write(match[1] + "^done,inferior_tty_terminal=" + ${JSON.stringify(JSON.stringify(tty))} + "\\n"); + else if (line.includes("list-thread-groups")) process.stdout.write(match[1] + "^done,groups=[]\\n"); + else process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + const server = new McpServer({ name: "gdb-inferior-tty", version: "1" }); + registerGdbTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ name: "gdb-inferior-tty", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const contract = toolContract("start_gdb_session"); + expect(contract.inputSchema.safeParse({}).success).toBe(true); + expect( + contract.inputSchema.safeParse({ inferior_tty: tty }).success, + ).toBe(true); + expect(contract.inputSchema.safeParse({ inferior_tty: "" }).success).toBe( + false, + ); + const listed = (await client.listTools()).tools.find( + (tool) => tool.name === "start_gdb_session", + ); + expect(listed?.inputSchema).toBeDefined(); + expect( + new Ajv2020({ strict: false, validateFormats: false }).validate( + z.record(z.string(), z.unknown()).parse(listed?.inputSchema), + { inferior_tty: tty }, + ), + ).toBe(true); + const started = await client.callTool({ + name: "start_gdb_session", + arguments: { inferior_tty: tty }, + }); + const sessionId = z + .object({ session_id: z.string().uuid() }) + .parse(started.structuredContent).session_id; + const status = await validateDebuggerStatusResponse( + client, + "gdb_session_status", + sessionId, + ); + expect(status).toMatchObject({ + inferior_tty: tty, + inferior_tty_verified: true, + }); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await manager.closeAll().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "exposes lossy GDB MI decoding through the strict status MCP contract", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-gdb-mcp-mi-utf8-")); + const provider = join(directory, "fake-gdb.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (!match) continue; + if (line.includes("auto-load off")) { + process.stdout.write(match[1] + "^done\\n"); + process.stdout.write(Buffer.from([0xff, 0x0a])); + } else if (line.includes("list-thread-groups")) process.stdout.write(match[1] + "^done,groups=[]\\n"); + else process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + const server = new McpServer({ name: "gdb-mi-utf8", version: "1" }); + registerGdbTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ name: "gdb-mi-utf8", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_gdb_session", + arguments: {}, + }); + const sessionId = z + .object({ session_id: z.string().uuid() }) + .parse(started.structuredContent).session_id; + await expect + .poll(() => manager.status(sessionId)?.mi_invalid_utf8) + .toBe(true); + const status = await validateDebuggerStatusResponse( + client, + "gdb_session_status", + sessionId, + ); + expect(status.mi_invalid_utf8).toBe(true); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await manager.closeAll().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it("records partial GDB Evidence from an MCP command error", async () => { + const evidence = createEvidence( + undefined, + { + id: "gnu.gdb", + name: "GNU GDB", + version: null, + }, + { + predicateType: "rea.debugger.console-observation", + operation: "gdb_console", + parameters: { + session_id: "00000000-0000-4000-8000-000000000001", + command: "info registers", + }, + result: { + command: "info registers", + mi: "", + records: ["~partial"], + console: "~partial", + target: "", + log: "", + process_exit_observed: true, + process_exit_code: 7, + process_exit_signal: null, + output_truncated: false, + completion_status: "unknown", + }, + rawResult: { + command: "info registers", + mi: "", + records: ["~partial"], + console: "~partial", + target: "", + log: "", + process_exit_observed: true, + process_exit_code: 7, + process_exit_signal: null, + output_truncated: false, + completion_status: "unknown", + }, + subjectUnavailableReason: + "The debugger session does not identify an inferior.", + }, + ); + expect( + toolContract("gdb_console").outputSchema.parse(evidence), + ).toMatchObject({ + normalized_result: { + process_exit_observed: true, + process_exit_code: 7, + process_exit_signal: null, + }, + }); + const manager = { + start: async () => + ok({ + session_id: "00000000-0000-4000-8000-000000000001", + mi_version: "mi3" as const, + }), + execute: async () => + err( + new AnalysisTimeoutError("gdb_console", 10, { + partialObservation: evidence, + cleanup: { + reason: "MI output was unsynchronized", + resources: [evidence.parameters.session_id as string], + }, + }), + ), + status: () => undefined, + close: async () => ok({ target_state: "unknown" as const }), + retryCleanupFailures: async () => ok(null), + } satisfies GdbToolManager; + const recorded: string[] = []; + const server = new McpServer({ name: "gdb-partial-evidence", version: "1" }); + registerGdbTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + (value) => { + recorded.push(value.evidence_id); + return ok("added"); + }, + ); + const client = new Client({ name: "gdb-partial-evidence", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const response = await client.callTool({ + name: "gdb_console", + arguments: { + session_id: "00000000-0000-4000-8000-000000000001", + command: "info registers", + }, + }); + expect(recorded).toEqual([evidence.evidence_id]); + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + error: { + details: { partial_observation: { evidence_id: evidence.evidence_id } }, + }, + }); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } +}); + +it("recovers only the owning debugger cleanup and reopens admitted analysis", async () => { + const [fixture] = await createBinarySessionTargets(); + const target = fixture.replace(/first\.hop$/u, "first.macho"); + await writeFile(target, thinMach(0xfeedfacf, 0x0100000c)); + const analysisSession = createTestBinarySession(createCacheProvider([])); + expect((await analysisSession.open(target)).ok).toBe(true); + const ownerId = "00000000-0000-4000-8000-000000000001"; + const otherId = "00000000-0000-4000-8000-000000000002"; + const closeCalls: string[] = []; + const retainedFailure = new AnalysisCapabilityUnavailableError( + "gdb", + "gdb_console", + "The owned debugger process could not be confirmed stopped.", + { + cleanup: { reason: "owned writer remains", resources: [ownerId] }, + }, + ); + const manager = { + start: async () => ok({ session_id: ownerId, mi_version: "mi3" as const }), + execute: async () => err(retainedFailure), + status: () => undefined, + close: async (sessionId: string) => { + closeCalls.push(sessionId); + if (sessionId === ownerId) retainedFailure.resolveCleanup(); + return ok({ target_state: "unknown" as const }); + }, + retryCleanupFailures: async () => ok(null), + } satisfies GdbToolManager; + const server = new McpServer({ name: "gdb-owned-cleanup", version: "1" }); + registerGdbTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + undefined, + analysisSession, + ); + const client = new Client({ name: "gdb-owned-cleanup", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const command = await client.callTool({ + name: "gdb_console", + arguments: { session_id: ownerId, command: "shell writer" }, + }); + expect(command.isError).toBe(true); + + const wrongOwner = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: otherId }, + }); + expect(wrongOwner.isError).toBe(true); + expect(closeCalls).toEqual([]); + expect((await analysisSession.open(target)).ok).toBe(false); + + const recovered = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: ownerId }, + }); + expect(recovered.isError ?? false).toBe(false); + expect(closeCalls).toEqual([ownerId]); + expect(retainedFailure.cleanupIncomplete).toBe(false); + expect((await analysisSession.open(target)).ok).toBe(true); + expect(analysisSession.activeTarget()?.path).toBe(target); + expect( + (await analysisSession.execute("address_name", { address: "0x1" })).ok, + ).toBe(true); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await analysisSession.close(); + } +}); + +it("records partial GDB startup Evidence from an MCP start error", async () => { + const evidence = createEvidence( + undefined, + { + id: "gnu.gdb", + name: "GNU GDB", + version: null, + }, + { + predicateType: "rea.debugger.startup-observation", + operation: "start_gdb_session", + parameters: { command: "GDB startup" }, + result: { + command: "GDB startup", + mi: "", + records: ["~startup output"], + console: "~startup output", + target: "", + log: "", + process_exit_observed: true, + process_exit_code: 7, + process_exit_signal: null, + output_truncated: false, + completion_status: "unknown", + }, + rawResult: { + command: "GDB startup", + mi: "", + records: ["~startup output"], + console: "~startup output", + target: "", + log: "", + process_exit_observed: true, + process_exit_code: 7, + process_exit_signal: null, + output_truncated: false, + completion_status: "unknown", + }, + subjectUnavailableReason: + "No debugger session became ready before GDB exited.", + }, + ); + const manager = { + start: async () => + err( + new AnalysisTimeoutError("start_gdb_session", 10, { + partialObservation: evidence, + }), + ), + execute: async () => + err(new AnalysisCapabilityUnavailableError("gdb", "unused", "unused")), + status: () => undefined, + close: async () => ok({ target_state: "unknown" as const }), + retryCleanupFailures: async () => ok(null), + } satisfies GdbToolManager; + const recorded: string[] = []; + const server = new McpServer({ + name: "gdb-startup-partial-evidence", + version: "1", + }); + registerGdbTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + (value) => { + recorded.push(value.evidence_id); + return ok("added"); + }, + ); + const client = new Client({ + name: "gdb-startup-partial-evidence", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const response = await client.callTool({ + name: "start_gdb_session", + arguments: {}, + }); + expect(recorded).toEqual([evidence.evidence_id]); + expect(response.isError).toBe(true); + expect(response.structuredContent).toMatchObject({ + error: { + details: { partial_observation: { evidence_id: evidence.evidence_id } }, + }, + }); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } +}); diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index ac42b9388..97ad30341 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -1,6 +1,6 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { expect, it } from "vitest"; -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; @@ -13,7 +13,6 @@ import { sessionEvidence, } from "./ghidraMcpHarness.js"; import { spawn } from "node:child_process"; -import { mkdir } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; diff --git a/tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts b/tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts new file mode 100644 index 000000000..73a4d6eb3 --- /dev/null +++ b/tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts @@ -0,0 +1,121 @@ +import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { Client } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; + +import { createProviderEnvironmentMcpHarness } from "../../support/reverseDebuggerMcpTestHelpers.js"; + +const writeProvider = async (path: string, output: string): Promise => { + await writeFile( + path, + `#!${process.execPath}\nprocess.stdout.write('${output}');\n`, + ); + await chmod(path, 0o755); +}; + +const connectProviderClient = (options: { + readonly directory: string; + readonly clientName: string; + readonly configuredProvider: string; + readonly providerEnvironment?: { REA_RIZIN_COMMAND: string }; +}) => + createProviderEnvironmentMcpHarness({ + directory: options.directory, + clientName: options.clientName, + serverOptions: { + environment: { REA_RIZIN_COMMAND: options.configuredProvider }, + ...(options.providerEnvironment === undefined + ? {} + : { providerEnvironment: options.providerEnvironment }), + }, + }); + +const executeRizinCommand = async ( + client: Client, + artifact: string, +): Promise>> => { + const response = await client.callTool({ + name: "execute_rizin_command", + arguments: { path: artifact, command: "iI" }, + }); + expect(response.isError).not.toBe(true); + return response; +}; + +it("uses the captured server environment to resolve reverse-engineering providers", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-provider-environment-")); + const provider = join(directory, "selected-rizin"); + const artifact = join(directory, "fixture.bin"); + await writeFile(artifact, "fixture"); + await writeProvider(provider, "captured-environment-provider"); + + const { client } = await connectProviderClient({ + directory, + clientName: "reverse-provider-environment", + configuredProvider: provider, + }); + const response = await executeRizinCommand(client, artifact); + expect(JSON.stringify(response.structuredContent)).toContain( + "captured-environment-provider", + ); +}); + +it("prefers an explicit provider environment over the captured server environment", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-provider-override-")); + const configured = join(directory, "captured-rizin"); + const override = join(directory, "override-rizin"); + const artifact = join(directory, "fixture.bin"); + await writeFile(artifact, "fixture"); + for (const [path, output] of [ + [configured, "captured"], + [override, "explicit-override"], + ] as const) { + await writeProvider(path, output); + } + const { client } = await connectProviderClient({ + directory, + clientName: "reverse-provider-override", + configuredProvider: configured, + providerEnvironment: { REA_RIZIN_COMMAND: override }, + }); + + const response = await executeRizinCommand(client, artifact); + expect(JSON.stringify(response.structuredContent)).toContain( + "explicit-override", + ); + expect(JSON.stringify(response.structuredContent)).not.toContain( + '"captured"', + ); +}); + +it("snapshots an explicit provider environment when the server is created", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-provider-snapshot-")); + const configured = join(directory, "captured-rizin"); + const override = join(directory, "explicit-rizin"); + const artifact = join(directory, "fixture.bin"); + await writeFile(artifact, "fixture"); + for (const [path, output] of [ + [configured, "captured"], + [override, "explicit-snapshot"], + ] as const) { + await writeProvider(path, output); + } + const providerEnvironment = { REA_RIZIN_COMMAND: override }; + const { client } = await connectProviderClient({ + directory, + clientName: "reverse-provider-snapshot", + configuredProvider: configured, + providerEnvironment, + }); + providerEnvironment.REA_RIZIN_COMMAND = configured; + + const response = await executeRizinCommand(client, artifact); + expect(JSON.stringify(response.structuredContent)).toContain( + "explicit-snapshot", + ); + expect(JSON.stringify(response.structuredContent)).not.toContain( + '"captured"', + ); +}); diff --git a/tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts b/tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts new file mode 100644 index 000000000..6a2b20409 --- /dev/null +++ b/tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts @@ -0,0 +1,507 @@ +import pino from "pino"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { expect, it, vi } from "vitest"; + +import { TestReverseEngineeringService as ReverseEngineeringService } from "../../support/reverseEngineeringService.js"; +import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; +import { + createBinarySessionTargets, + createCacheProvider, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js"; +import { err, ok } from "../../../src/domain/result.js"; +import type { WithAdmittedAnalysis } from "../../../src/server/analysisAdmission.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { registerReverseEngineeringTools } from "../../../src/server/registerReverseEngineeringTools.js"; +import { + createReverseEngineeringMcpHarness, + invokeWithLiveSignal, +} from "../../support/reverseEngineeringMcpHarness.js"; + +const createTimedOutRizinService = (): ReverseEngineeringService => + new ReverseEngineeringService({ + run: async () => { + throw Object.assign(new Error("timed out"), { + stdout: "partial provider output", + stderr: "", + code: "ETIMEDOUT", + signal: null, + killed: true, + }); + }, + }); + +it("records partial Evidence and references it in the original MCP error", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const service = createTimedOutRizinService(); + const recordEvidence = vi.fn(() => ok("added" as const)); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + recordEvidence, + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invokeWithLiveSignal(invoke, { + path: "package.json", + command: "iI", + })) as { + readonly isError?: boolean; + readonly structuredContent?: { + readonly error?: { + readonly details?: Readonly>; + }; + }; + }; + + expect(response.isError).toBe(true); + expect(recordEvidence).toHaveBeenCalledTimes(1); + expect(response.structuredContent?.error?.details).toMatchObject({ + partial_observation: { + kind: "retained-evidence", + evidence_id: expect.any(String), + }, + }); +}); + +it("admits Rizin mutation after invalidating before dispatch", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const events: string[] = []; + const service = new ReverseEngineeringService({ + run: async () => { + events.push("dispatch"); + return { stdout: "rizin output", stderr: "" }; + }, + }); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + undefined, + undefined, + () => events.push("invalidate"), + async (_path, _signal, operation) => { + events.push("admit"); + return ok(await operation()); + }, + ); + const invoke = handler("execute_rizin_command"); + + await invoke( + { path: "package.json", command: "iI" }, + { mcpReq: { signal: new AbortController().signal } }, + ); + + expect(events).toEqual(["admit", "invalidate", "dispatch"]); +}); + +it("invalidates the snapshot in the legacy Rizin admission path", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const invalidateSnapshot = vi.fn(); + const service = new ReverseEngineeringService({ + run: async () => ({ stdout: "rizin output", stderr: "" }), + }); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + undefined, + undefined, + invalidateSnapshot, + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invokeWithLiveSignal(invoke, { + path: "package.json", + command: "iI", + })) as { readonly isError?: boolean }; + + expect(response.isError).not.toBe(true); + expect(invalidateSnapshot).toHaveBeenCalledOnce(); +}); + +it("skips snapshot invalidation for malformed Rizin input", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const invalidateSnapshot = vi.fn(); + const executeRizinCommand = vi.fn(async () => + err(new AnalysisInputError("execute_rizin_command")), + ); + const service = { + executeRizinCommand, + } as unknown as ReverseEngineeringService; + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + undefined, + undefined, + invalidateSnapshot, + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invokeWithLiveSignal(invoke, { + path: "package.json", + })) as { readonly isError?: boolean }; + + expect(response.isError).toBe(true); + expect(executeRizinCommand).toHaveBeenCalledOnce(); + expect(invalidateSnapshot).not.toHaveBeenCalled(); +}); + +it("delivers failed Rizin execution after external admission", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const service = new ReverseEngineeringService({ + run: async () => { + throw new Error("Rizin command failed"); + }, + }); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + undefined, + undefined, + undefined, + async (_path, _signal, operation) => ok(await operation()), + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invokeWithLiveSignal(invoke, { + path: "package.json", + command: "iI", + })) as { readonly isError?: boolean }; + + expect(response.isError).toBe(true); +}); + +it("preserves the Rizin operation error when analysis retirement also fails", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const service = { + retryCleanup: async () => ok(null), + executeRizinCommand: async () => + err(new AnalysisInputError("execute_rizin_command")), + } as unknown as ReverseEngineeringService; + const observedOperationResult = vi.fn(); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + undefined, + undefined, + undefined, + async (_path, _signal, operation, rebindFailure) => { + const result = await operation(); + observedOperationResult(result); + return err(rebindFailure(result, new AnalysisInputError("rebind"))); + }, + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invoke( + { path: "package.json", command: "iI" }, + { mcpReq: { signal: new AbortController().signal } }, + )) as { + readonly isError?: boolean; + readonly structuredContent?: { + readonly error?: { + readonly code?: string; + readonly details?: { readonly reason?: string }; + }; + }; + }; + + expect(response.isError).toBe(true); + expect(observedOperationResult).toHaveBeenCalledWith( + expect.objectContaining({ ok: false }), + ); + expect(response.structuredContent?.error).toMatchObject({ + code: "invalid_request", + }); +}); + +it.each(["success", "timeout"])( + "retains %s output and requires reopening after a symlink command", + async (outcome) => { + const [target] = await createBinarySessionTargets(); + const alias = join(dirname(target), "active-target-alias.hop"); + await symlink(target, alias); + const session = createTestBinarySession(createCacheProvider([])); + expect((await session.open(target)).ok).toBe(true); + expect(session.activeTarget()?.path).toBe(target); + const originalDigest = session.activeTarget()?.sha256; + + const { server, handler } = createReverseEngineeringMcpHarness(); + const service = new ReverseEngineeringService({ + run: async () => { + await writeFile(alias, "changed by Rizin"); + if (outcome === "timeout") + throw Object.assign(new Error("timed out"), { + stdout: "partial analysis output", + stderr: "timeout diagnostics", + code: "ETIMEDOUT", + signal: null, + killed: true, + }); + return { stdout: "partial analysis output", stderr: "" }; + }, + }); + const recordEvidence = vi.fn(() => ok("added" as const)); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + recordEvidence, + undefined, + () => session.invalidateSnapshotForExternalMutation(), + (path, signal, operation, rebindFailure) => + session.withAdmittedExternalMutation( + path, + signal, + operation, + rebindFailure, + ), + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invoke( + { path: alias, command: "wx 00 @ 0" }, + { mcpReq: { signal: new AbortController().signal } }, + )) as { + readonly isError?: boolean; + readonly structuredContent?: { + readonly error?: { + readonly code?: string; + readonly details?: Readonly>; + }; + }; + }; + + expect(response.isError ?? false).toBe(outcome === "timeout"); + if (outcome === "timeout") { + expect(response.structuredContent?.error?.code).toBe("provider_timeout"); + expect(response.structuredContent?.error?.details).toMatchObject({ + captured_output: { + stdout: "partial analysis output", + stderr: "timeout diagnostics", + }, + }); + expect(response.structuredContent?.error?.details).toMatchObject({ + partial_observation: { + kind: "retained-evidence", + evidence_id: expect.any(String), + }, + }); + } + expect(recordEvidence).toHaveBeenCalledTimes(1); + expect(session.activeTarget()).toBeUndefined(); + expect((await session.execute("address_name", {})).ok).toBe(false); + expect((await session.open(target)).ok).toBe(true); + expect(session.activeTarget()?.sha256).not.toBe(originalDigest); + await session.close(); + await rm(dirname(target), { recursive: true, force: true }); + }, +); + +it("preserves a partial MCP error without claiming failed Evidence retention", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const service = createTimedOutRizinService(); + const recordEvidence = vi.fn((_evidence: unknown) => + err(new EvidenceIntegrityError("retention failed")), + ); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + recordEvidence, + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invokeWithLiveSignal(invoke, { + path: "package.json", + command: "iI", + })) as { + readonly isError?: boolean; + readonly structuredContent?: { + readonly error?: { + readonly details?: Readonly>; + }; + }; + }; + + expect(response.isError).toBe(true); + expect(response.structuredContent?.error?.details).toMatchObject({ + partial_observation: expect.objectContaining({ + operation: "execute_rizin_command", + }), + }); + expect( + JSON.stringify(response.structuredContent?.error?.details), + ).not.toContain("retained-evidence"); +}); + +it("delivers pathless objdump capability Evidence and input errors through its named tool", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const service = new ReverseEngineeringService({ + run: async () => ({ stdout: "objdump output", stderr: "" }), + }); + const delivery = createToolResultDelivery(2 * 1024 * 1024); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + delivery, + ); + const context = { mcpReq: { signal: new AbortController().signal } }; + const objdump = handler("inspect_with_objdump"); + const rizin = handler("execute_rizin_command"); + + const success = (await objdump( + { path: "package.json", operation: "file_headers" }, + context, + )) as { readonly isError?: boolean }; + const architectures = (await objdump( + { operation: "architectures" }, + context, + )) as { + readonly isError?: boolean; + }; + const objdumpFailure = (await objdump({}, context)) as { + readonly isError?: boolean; + }; + const failure = (await rizin({}, context)) as { + readonly isError?: boolean; + readonly structuredContent?: { + readonly error?: { readonly code?: string }; + }; + }; + const cancelled = new AbortController(); + cancelled.abort(); + const cancelledContext = { mcpReq: { signal: cancelled.signal } }; + const cancelledObjdump = (await objdump( + { path: "package.json", operation: "file_headers" }, + cancelledContext, + )) as { readonly isError?: boolean }; + const cancelledRizin = (await rizin( + { path: "package.json", command: "iI" }, + cancelledContext, + )) as { readonly isError?: boolean }; + + expect(success.isError).not.toBe(true); + expect(architectures.isError).not.toBe(true); + expect(objdumpFailure.isError).toBe(true); + expect(failure.isError).toBe(true); + expect(cancelledObjdump.isError).toBe(true); + expect(cancelledRizin.isError).toBe(true); +}); + +it("does not treat a malformed partial observation as retained Evidence", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const invalidError = Object.assign( + new AnalysisInputError("execute_rizin_command"), + { partialObservation: { evidence_id: 42 } }, + ); + const service = { + inspectWithObjdump: async () => err(invalidError), + executeRizinCommand: async () => err(invalidError), + } as unknown as ReverseEngineeringService; + const recordEvidence = vi.fn(() => ok("added" as const)); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + recordEvidence, + ); + const invoke = handler("execute_rizin_command"); + + const response = (await invoke( + { path: "package.json", command: "iI" }, + { mcpReq: { signal: new AbortController().signal } }, + )) as { readonly isError?: boolean }; + + expect(response.isError).toBe(true); + expect(recordEvidence).not.toHaveBeenCalled(); +}); + +it("keeps provider execution and Evidence recording inside analysis admission", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const events: string[] = []; + const service = new ReverseEngineeringService({ + run: async () => { + events.push("provider"); + return { stdout: "objdump output", stderr: "" }; + }, + }); + const recordEvidence = vi.fn(() => { + events.push("record"); + return ok("added" as const); + }); + const admission: WithAdmittedAnalysis = async (_name, _signal, operation) => { + events.push("admission-start"); + const result = await operation({} as AnalysisOperationPort); + events.push("admission-end"); + return ok(result); + }; + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + recordEvidence, + admission, + ); + const invoke = handler("inspect_with_objdump"); + + await invoke( + { path: "package.json", operation: "file_headers" }, + { mcpReq: { signal: new AbortController().signal } }, + ); + + expect(events).toEqual([ + "admission-start", + "provider", + "record", + "admission-end", + ]); +}); + +it("returns admission failures without invoking a reverse-engineering provider", async () => { + const { server, handler } = createReverseEngineeringMcpHarness(); + const inspectWithObjdump = vi.fn(); + const service = { + inspectWithObjdump, + executeRizinCommand: vi.fn(), + } as unknown as ReverseEngineeringService; + const admission: WithAdmittedAnalysis = async () => + err(new AnalysisInputError("inspect_with_objdump")); + registerReverseEngineeringTools( + server, + service, + pino({ enabled: false }), + createToolResultDelivery(2 * 1024 * 1024), + undefined, + admission, + ); + const invoke = handler("inspect_with_objdump"); + + const response = (await invoke( + { path: "package.json", operation: "file_headers" }, + { mcpReq: { signal: new AbortController().signal } }, + )) as { readonly isError?: boolean }; + + expect(response.isError).toBe(true); + expect(inspectWithObjdump).not.toHaveBeenCalled(); +}); diff --git a/tests/boundary/mcp/reverseEngineeringProviderEnvironmentMcp.test.ts b/tests/boundary/mcp/reverseEngineeringProviderEnvironmentMcp.test.ts new file mode 100644 index 000000000..b7b2d04a3 --- /dev/null +++ b/tests/boundary/mcp/reverseEngineeringProviderEnvironmentMcp.test.ts @@ -0,0 +1,84 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; + +import { createServer } from "../../../src/server/createServer.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { ok } from "../../../src/domain/result.js"; + +const analysis = { + execute: async () => + ok( + createAnalysisExecution({}, { id: "test", name: "Test", version: null }), + ), +}; + +const writeRizin = async (directory: string, output: string): Promise => { + const executable = join(directory, "rizin"); + await writeFile(executable, `#!/bin/sh\nprintf '%s\\n' '${output}'\n`); + await chmod(executable, 0o755); +}; + +it("uses the server-selected environment unless a provider environment overrides it", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-env-")); + const selectedPath = join(directory, "selected"); + const overridePath = join(directory, "override"); + const artifactPath = join(directory, "sample.bin"); + await Promise.all([mkdir(selectedPath), mkdir(overridePath)]); + await Promise.all([ + writeRizin(selectedPath, "selected environment"), + writeRizin(overridePath, "provider override"), + writeFile(artifactPath, "fixture"), + ]); + + try { + for (const scenario of [ + { + environment: selectedPath, + expected: "selected environment", + }, + { + environment: selectedPath, + provider: overridePath, + expected: "provider override", + }, + ]) { + const server = createServer( + { kind: "fixed", analysis }, + { + environment: { ...process.env, PATH: scenario.environment }, + ...(scenario.provider === undefined + ? {} + : { + providerEnvironment: { + ...process.env, + PATH: scenario.provider, + }, + }), + }, + ); + const client = new Client({ name: "reverse-env-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "execute_rizin_command", + arguments: { path: artifactPath, command: "iI" }, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + raw_result: { stdout: `${scenario.expected}\n` }, + }); + } finally { + await Promise.all([client.close(), server.close()]); + } + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts b/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts new file mode 100644 index 000000000..be5070301 --- /dev/null +++ b/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts @@ -0,0 +1,251 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { evidenceSchema } from "../../../src/domain/evidence.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "../providers/RizinTestInferiorTty.js"; +import { silentLogger } from "../../../src/logger.js"; +import { registerRizinDebugTools } from "../../../src/server/registerRizinDebugTools.js"; +import type { RizinDebugToolManager } from "../../../src/server/registerRizinDebugTools.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { + createBinarySessionTargets, + createCacheProvider, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { + validateDebuggerStatusResponse, + waitForStartedFile, +} from "../../support/reverseDebuggerMcpTestHelpers.js"; + +it.skipIf(process.platform === "win32")( + "reports Rizin launch provenance and lossy frame decoding through MCP", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-provenance-")); + const provider = join(directory, "fake-rizin-provenance.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line === "dr") process.stdout.write(Buffer.from([0xff, 0])); + else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager({ REA_RIZIN_COMMAND: provider }); + const server = new McpServer({ + name: "rizin-evidence-provenance", + version: "1", + }); + registerRizinDebugTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ + name: "rizin-evidence-provenance", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const callerPath = "./relative/../rizin-fixture.bin"; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_rizin_debug_session", + arguments: { + path: callerPath, + inferior_tty: await rizinTestInferiorTty(), + }, + }); + const sessionId = z + .object({ session_id: z.string().uuid() }) + .parse(started.structuredContent).session_id; + const command = await client.callTool({ + name: "rizin_debug_command", + arguments: { session_id: sessionId, command: "dr" }, + }); + expect(command.isError).not.toBe(true); + const evidence = evidenceSchema.parse(command.structuredContent); + expect(evidence.parameters.launch_provenance).toEqual({ + caller_selected_path: callerPath, + resolved_path: resolve(callerPath), + }); + expect(evidence.normalized_result).toMatchObject({ + output_invalid_utf8: true, + }); + expect(evidence.limitations).toContain( + "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.", + ); + + const status = await validateDebuggerStatusResponse( + client, + "rizin_debug_session_status", + sessionId, + ); + expect(status.output_invalid_utf8).toBe(true); + expect(status.recent_output).toContain("�"); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await manager.closeAll().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it("admits Rizin startup across a bound analysis session", async () => { + const [fixture] = await createBinarySessionTargets(); + const target = fixture.replace(/first\.hop$/u, "first.macho"); + await writeFile(target, thinMach(0xfeedfacf, 0x0100000c)); + const analysisSession = createTestBinarySession(createCacheProvider([])); + expect((await analysisSession.open(target)).ok).toBe(true); + const initialRunId = z + .object({ analysis_run: z.object({ run_id: z.string() }) }) + .parse(analysisSession.status()).analysis_run.run_id; + const manager = { + start: async () => ({ + ok: true, + value: { + session_id: "00000000-0000-4000-8000-000000000001", + backend: null, + }, + }), + execute: async () => + err(new AnalysisCapabilityUnavailableError("rizin", "unused", "unused")), + status: () => undefined, + close: async () => ({ ok: true, value: { target_state: "unknown" } }), + retryCleanupFailures: async () => ok(null), + } satisfies RizinDebugToolManager; + const server = new McpServer({ name: "rizin-start-admission", version: "1" }); + registerRizinDebugTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + undefined, + analysisSession, + ); + const client = new Client({ name: "rizin-start-admission", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_rizin_debug_session", + arguments: { path: target, inferior_tty: "/dev/null" }, + }); + expect(started.isError ?? false).toBe(false); + expect( + z + .object({ session_id: z.string().uuid() }) + .parse(started.structuredContent).session_id, + ).toBe("00000000-0000-4000-8000-000000000001"); + expect(analysisSession.activeTarget()).toBeUndefined(); + + expect((await analysisSession.open(target)).ok).toBe(true); + const currentRunId = z + .object({ analysis_run: z.object({ run_id: z.string() }) }) + .parse(analysisSession.status()).analysis_run.run_id; + expect(currentRunId).not.toBe(initialRunId); + expect(analysisSession.activeTarget()?.path).toBe(target); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await analysisSession.close(); + } +}); + +it.skipIf(process.platform === "win32")( + "propagates MCP cancellation into a running Rizin debugger command", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-mcp-cancel-")); + const provider = join(directory, "fake-rizin.mjs"); + const commandPath = join(directory, "command-started"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", async (chunk) => { + for (const line of chunk.split("\\n")) { + if (line.startsWith("!echo ")) continue; + await import("node:fs/promises").then((fs) => fs.writeFile(${JSON.stringify(commandPath)}, "started")); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + const server = new McpServer({ name: "rizin-cancellation", version: "1" }); + registerRizinDebugTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ name: "rizin-cancellation", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_rizin_debug_session", + arguments: { + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }, + }); + const sessionId = z + .object({ session_id: z.string().uuid() }) + .parse(started.structuredContent).session_id; + const controller = new AbortController(); + const command = client.callTool( + { + name: "rizin_debug_command", + arguments: { session_id: sessionId, command: "dr" }, + }, + { signal: controller.signal }, + ); + await waitForStartedFile(commandPath); + controller.abort(); + await expect(command).rejects.toThrow(/abort/iu); + const status = await validateDebuggerStatusResponse( + client, + "rizin_debug_session_status", + sessionId, + ); + expect(status.state).toBe("closed"); + expect(status).toHaveProperty("diagnostics_truncated"); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await manager.closeAll().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/tests/boundary/mcp/rustBinaryMcp.test.ts b/tests/boundary/mcp/rustBinaryMcp.test.ts new file mode 100644 index 000000000..d903affd4 --- /dev/null +++ b/tests/boundary/mcp/rustBinaryMcp.test.ts @@ -0,0 +1,122 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { + createRustImage, + createRustHashOverflowImage, +} from "../../fixtures/rust/image.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; + +const connect = async (mcp: TestMcp) => { + const session = createTestBinarySession(() => { + throw new Error("Rust metadata must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_rust_binary and reads an embedded dependency list", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_rust_binary", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-rust-mcp-"); + const path = join(root, "sample.elf"); + await writeFile(path, createRustImage({ format: "elf" })); + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + response.structuredContent, + ); + expect(evidence.normalized_result.auditable_metadata?.packages).toEqual([ + expect.objectContaining({ name: "itoa", version: "1.0.18" }), + expect.objectContaining({ + name: "sample", + version: "0.1.0", + root: true, + }), + ]); + const missing = join(root, "plain.elf"); + await writeFile( + missing, + createRustImage({ format: "pe", omitSection: true }), + ); + const absent = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path: missing }, + }); + expect(absent.isError).not.toBe(true); + const absentEvidence = toolContract( + "inspect_rust_binary", + ).outputSchema.parse(absent.structuredContent); + expect(absentEvidence.normalized_result.auditable_metadata).toBeNull(); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_rust_binary", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path: "sample" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_rust_binary" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "preserves rustc hash capacity units through the public error contract", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-rust-mcp-"); + const path = join(root, "many-hashes.elf"); + await writeFile(path, createRustHashOverflowImage()); + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "resource_constraint", + details: { + operation: "inspect_rust_binary", + resource: "capacity", + reported_limits: { maximum_rustc_hashes: 64 }, + }, + }); + expect(parseMcpToolError(response).error.details).not.toHaveProperty( + "reported_limits.maximum_bytes", + ); + } finally { + await session.close(); + } + }, +); diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index e9d5e944a..cc4293c3b 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -112,6 +112,12 @@ function expectKnownAuthorityHints(tools: readonly ToolSchemas[]): void { idempotentHint: false, openWorldHint: false, }, + start_gdb_session: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, export_web_scripts: { readOnlyHint: false, destructiveHint: false, @@ -143,6 +149,12 @@ function expectKnownAuthorityHints(tools: readonly ToolSchemas[]): void { readOnlyHint: false, destructiveHint: false, }, + execute_rizin_command: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, } as const; for (const [name, annotations] of Object.entries(expected)) diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index b921fc224..b5a66df42 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -1,5 +1,5 @@ import { parseConfig } from "../../../src/config/parseConfig.js"; -import { writeFile } from "node:fs/promises"; +import { access, chmod, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -21,6 +21,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; import { ok } from "../../../src/domain/result.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -41,6 +42,114 @@ afterEach(async () => { await Promise.all(resources.splice(0).map((resource) => resource.close())); }); +describe("MCP external command snapshot retirement", () => { + it.skipIf(process.platform === "win32")( + "rejects a queued snapshot after mutation and preserves Evidence through explicit reopening", + async () => { + const { targetPaths, snapshotPath } = await createWorkflowFiles( + "rea-mcp-admitted-reverse-", + ["target.hop"], + ); + const targetPath = targetPaths[0]; + if (targetPath === undefined) + throw new Error("Expected one workflow target"); + const fixtureDirectory = dirname(snapshotPath); + const startedPath = join(fixtureDirectory, "rizin-started"); + const releasePath = join(fixtureDirectory, "rizin-release"); + const providerPath = join(fixtureDirectory, "rizin-fixture.mjs"); + await writeFile( + providerPath, + `#!${process.execPath}\nimport { existsSync, writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(startedPath)}, "started");\nwhile (!existsSync(${JSON.stringify(releasePath)})) await new Promise((resolve) => setTimeout(resolve, 5));\nprocess.stdout.write("Rizin fixture output\\n");\n`, + ); + await chmod(providerPath, 0o755); + const session = createWorkflowSession(makeProvider([], [])); + const { mcp } = await connectWorkflowMcp( + session, + "admitted-reverse-engineering", + { REA_RIZIN_COMMAND: providerPath }, + ); + await openWorkflowTarget(mcp, targetPath); + + const analysisRequest = mcp.callTool({ + name: "execute_rizin_command", + arguments: { path: targetPath, command: "iI" }, + }); + const readyDeadline = Date.now() + 5_000; + while (Date.now() < readyDeadline) { + try { + await access(startedPath); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + await expect(access(startedPath)).resolves.toBeUndefined(); + + let closeSettled = false; + const closeRequest = mcp + .callTool({ + name: "close_binary", + arguments: { snapshot_path: snapshotPath }, + }) + .finally(() => { + closeSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(closeSettled).toBe(false); + await expect(access(snapshotPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + + await writeFile(releasePath, "release"); + const analyzed = await analysisRequest; + expect(analyzed.isError, JSON.stringify(analyzed.content)).not.toBe(true); + const evidence = toolContract("execute_rizin_command").outputSchema.parse( + analyzed.structuredContent, + ); + const closed = await closeRequest; + expect(closed.isError).toBe(true); + expect(JSON.stringify(closed.content)).toContain( + "Analysis snapshots are unavailable", + ); + expect(session.activeTarget()).toBeUndefined(); + await expect(access(snapshotPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + const exported = await mcp.callTool({ + name: "export_evidence_bundle", + arguments: { path: join(fixtureDirectory, "observations.json") }, + }); + expect(exported.isError ?? false).toBe(false); + const observations = evidenceBundleSchema.parse( + JSON.parse( + await readFile(join(fixtureDirectory, "observations.json"), "utf8"), + ), + ); + expect(observations.records).toContainEqual( + expect.objectContaining({ evidence_id: evidence.evidence_id }), + ); + + // The provider process has completed; explicitly open fresh analysis + // before saving an immutable snapshot of the new binding. + await openWorkflowTarget(mcp, targetPath); + const saved = await mcp.callTool({ + name: "close_binary", + arguments: { snapshot_path: snapshotPath }, + }); + expect(saved.isError ?? false).toBe(false); + const snapshot = await readAnalysisSnapshot(snapshotPath); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.evidence_bundle.records).toContainEqual( + expect.objectContaining({ + evidence_id: evidence.evidence_id, + operation: "execute_rizin_command", + subject: expect.objectContaining({ local_path: targetPath }), + }), + ); + }, + ); +}); + describe("MCP composed workflow target admission", () => { it("keeps a composed result and snapshot on its admitted target across queued close and open", async () => { const { targetPaths, snapshotPath } = await createWorkflowFiles( @@ -448,10 +557,14 @@ const createWorkflowSession = (provider: AnalysisProvider) => const connectWorkflowMcp = async ( session: ReturnType, clientName: string, + providerEnvironment?: Readonly, ): Promise<{ readonly mcp: Client }> => { const server = createServer( { kind: "session", session }, - { logger: silentLogger }, + { + logger: silentLogger, + ...(providerEnvironment === undefined ? {} : { providerEnvironment }), + }, ); const mcp = new Client({ name: clientName, version: "1.0.0" }); const [clientTransport, serverTransport] = diff --git a/tests/boundary/process/ownedCommand.test.ts b/tests/boundary/process/ownedCommand.test.ts index ca0cb2db6..33c1d0955 100644 --- a/tests/boundary/process/ownedCommand.test.ts +++ b/tests/boundary/process/ownedCommand.test.ts @@ -12,7 +12,10 @@ import { cleanupOwnedProcessGroup, type ProcessCleanupResult, } from "../../../src/process/ProcessOwnership.js"; -import { prepareProcessOwnershipInspection } from "../../../src/process/ProcessOwnershipObservation.js"; +import { + prepareProcessOwnershipInspection, + systemProcessOwnershipHost, +} from "../../../src/process/ProcessOwnershipObservation.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; const command = (script: string) => ({ @@ -152,6 +155,127 @@ it("fails an accepted nonzero command when an output stream errors and cleans it expect(cleanup).toHaveBeenCalledOnce(); }); +it("marks an output collection error when the process itself exits zero", async () => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + class FakeProcess extends EventEmitter { + readonly stdout = stdout; + readonly stderr = stderr; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + readonly pid = 41235; + kill(): boolean { + return true; + } + } + const child = new FakeProcess(); + const request = command("unused fake launcher command"); + const failure = runOwnedCommand( + request, + { timeoutMs: 2_000, diagnosticBytes: 1024, acceptNonZeroExit: true }, + { + launcher: async () => { + setImmediate(() => { + stdout.write("partial output"); + stdout.destroy(new Error("fixture read failure")); + stderr.end(); + child.exitCode = 0; + child.emit("exit", 0, null); + child.emit("close", 0, null); + }); + return { + process: child, + ownership: { + runId: request.runId, + leaderPid: child.pid, + processGroupId: child.pid, + }, + cleanup: async () => ({ cleaned: true, signaled: false }), + }; + }, + }, + ); + + await expect(failure).rejects.toMatchObject({ + reason: "process", + collectionFailure: true, + message: "stdout stream failed: fixture read failure", + snapshot: { stdout: { text: "partial output" }, exitCode: 0 }, + }); +}); + +it.skipIf(process.platform === "win32")( + "sweeps detached run-token descendants and preserves a pre-existing unrelated process", + async () => { + const runId = `rea-owned-descendant-test-${randomUUID()}`; + const unrelated = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: ["-e", "setInterval(() => undefined, 1000)"], + runId: `${runId}-unrelated`, + hostEnvironment: process.env, + }); + let descendantPid: number | undefined; + try { + const result = await runOwnedCommand( + { + ...command(` + const { spawn } = require("node:child_process"); + const child = spawn(process.execPath, ["-e", "setInterval(() => undefined, 1000)"], { + detached: true, + stdio: "ignore", + }); + child.unref(); + process.stdout.write(String(child.pid)); + `), + runId, + sweepTokenOwnedProcesses: true, + }, + { timeoutMs: 5_000, diagnosticBytes: 1024 }, + ); + descendantPid = Number(result.stdout.text); + expect(Number.isSafeInteger(descendantPid)).toBe(true); + let remainsLive = true; + const deadline = Date.now() + 2_000; + while (remainsLive && Date.now() < deadline) { + remainsLive = (await systemProcessOwnershipHost.listProcesses()).some( + ({ pid }) => pid === descendantPid, + ); + if (remainsLive) await delay(25); + } + expect(remainsLive).toBe(false); + expect(() => process.kill(unrelated.process.pid!, 0)).not.toThrow(); + } finally { + if (descendantPid !== undefined) { + try { + process.kill(descendantPid, "SIGKILL"); + } catch { + // The run-token sweep may already have removed it. + } + } + await cleanupOwnedProcessGroup(unrelated.ownership); + } + }, +); + +it.skipIf(process.platform === "win32")( + "fails before provider spawn when a requested ownership baseline cannot be captured", + async () => { + const baseline = vi + .spyOn(systemProcessOwnershipHost, "captureBaseline") + .mockRejectedValue(new Error("baseline unavailable")); + try { + await expect( + runOwnedCommand( + { ...command("process.exit(0)"), sweepTokenOwnedProcesses: true }, + { timeoutMs: 2_000, diagnosticBytes: 1024 }, + ), + ).rejects.toThrow("baseline unavailable"); + } finally { + baseline.mockRestore(); + } + }, +); + it("cancels a real acquired process and independently releases it", async () => { const controller = new AbortController(); let pid: number | undefined; diff --git a/tests/boundary/process/ownedDebuggerSession.test.ts b/tests/boundary/process/ownedDebuggerSession.test.ts new file mode 100644 index 000000000..c7801ba8a --- /dev/null +++ b/tests/boundary/process/ownedDebuggerSession.test.ts @@ -0,0 +1,1452 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { connect } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn as spawnPty } from "@lydell/node-pty"; + +import { expect, it, onTestFinished, vi } from "vitest"; + +import { + closeOwnedDebuggerSessions, + stopOwnedDebuggerProcess, + verifyOwnedInferiorsEnded, +} from "../../../src/process/ownedDebuggerSession.js"; +import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; + +const triggerDescendantOutput = (port: number): void => { + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => {}); + socket.on("connect", () => socket.end()); +}; + +const createFakeSupervisor = () => ({ + waitForExit: async () => true, + waitForOutputClose: async () => true, + limitAdditionalOutputBytes: vi.fn(), + dispose: vi.fn(), + unrefOutputStreams: vi.fn(), +}); + +const startStopProbe = (source: string, pipeInput = false) => { + const runId = randomUUID(); + const child = spawn(process.execPath, ["--input-type=module", "-e", source], { + detached: process.platform !== "win32", + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + stdio: [pipeInput ? "pipe" : "ignore", "pipe", "pipe"], + }); + const leaderPid = child.pid; + if (leaderPid === undefined) throw new Error("Fixture probe did not spawn"); + onTestFinished(async () => { + const cleanup = await cleanupOwnedProcessGroup({ + runId, + leaderPid, + processGroupId: leaderPid, + }); + expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true); + }); + let output = ""; + const collect = (chunk: Buffer | string): void => { + output += chunk.toString(); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + return { + child, + get output() { + return output; + }, + }; +}; + +const waitForProbeLine = ( + child: ReturnType, + initialOutput: string, + pattern: RegExp, +): Promise => + new Promise((resolve, reject) => { + let output = initialOutput; + const match = (): string | undefined => pattern.exec(output)?.[0]; + const existing = match(); + if (existing !== undefined) { + resolve(existing); + return; + } + const timeout = setTimeout( + () => + reject(new Error("probe did not report the expected cleanup state")), + 3_000, + ); + child.stdout?.on("data", (chunk: Buffer | string) => { + output += chunk.toString(); + const found = match(); + if (found !== undefined) { + clearTimeout(timeout); + resolve(found); + } + }); + child.once("error", (cause) => { + clearTimeout(timeout); + reject(cause); + }); + }); + +const startDebuggerWithInferior = (inferiorSource: string) => { + const debuggerSource = [ + 'const { spawn } = require("node:child_process");', + `spawn(process.execPath, ["-e", ${JSON.stringify(inferiorSource)}], { stdio: "inherit" });`, + "process.exit(0);", + ].join("\n"); + const child = spawn(process.execPath, ["-e", debuggerSource], { + stdio: ["ignore", "pipe", "pipe"], + }); + const supervisor = new ProviderProcessSupervisor({ + process: child, + ownsProcessLifetime: true, + }); + let readyOutput = ""; + const inferiorPort = new Promise((resolvePort) => { + child.stdout?.on("data", (chunk: Buffer | string) => { + readyOutput += chunk.toString(); + const match = /^ready:(\d+)$/mu.exec(readyOutput); + if (match !== null) resolvePort(Number(match[1])); + }); + }); + return { child, supervisor, inferiorPort }; +}; + +it("preserves the session receiver when invoking the provider detach callback", async () => { + const session = { + id: "receiver-bound-detach", + exited: false, + transport: { verified: true }, + launched: { process: { kill: vi.fn(() => true) } }, + async detachInferior(this: { transport: { verified: boolean } }) { + expect(this.transport.verified).toBe(true); + return { status: "no-inferior" } as const; + }, + supervisor: createFakeSupervisor(), + }; + + await expect( + stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).resolves.toEqual({ status: "stopped" }); + expect(session.supervisor.limitAdditionalOutputBytes).toHaveBeenCalledWith( + 64 * 1024, + ); +}); + +it("clears the detach timeout when provider detachment settles immediately", async () => { + vi.useFakeTimers(); + try { + const session = { + id: "immediate-detach", + exited: false, + launched: { process: { kill: vi.fn(() => true) } }, + detachInferior: async () => ({ status: "no-inferior" }) as const, + supervisor: createFakeSupervisor(), + }; + + await expect( + stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).resolves.toEqual({ status: "stopped" }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +it("retries provider detachment after a rejected attempt", async () => { + const detachInferior = vi + .fn<() => Promise<{ readonly status: "no-inferior" }>>() + .mockRejectedValueOnce(new Error("detach transport failed")) + .mockResolvedValueOnce({ status: "no-inferior" }); + const session = { + id: "retry-rejected-detach", + exited: false, + launched: { process: { kill: vi.fn(() => true) } }, + detachInferior, + supervisor: createFakeSupervisor(), + }; + + await expect( + stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).resolves.toMatchObject({ status: "incomplete" }); + await expect( + stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).resolves.toEqual({ status: "stopped" }); + expect(detachInferior).toHaveBeenCalledTimes(2); +}); + +it("retains ownership after stop until inherited output writers close", async () => { + let exited = false; + let outputClosed = false; + const supervisor = { + ...createFakeSupervisor(), + waitForOutputClose: async () => outputClosed, + }; + const session = { + id: "stopped-with-open-output", + get exited() { + return exited; + }, + launched: { + process: { + kill: vi.fn(() => { + exited = true; + return true; + }), + }, + }, + detachInferior: async () => ({ status: "no-inferior" }) as const, + supervisor, + }; + + await expect( + stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).resolves.toMatchObject({ status: "incomplete", reason: /output streams/i }); + expect(supervisor.unrefOutputStreams).not.toHaveBeenCalled(); + expect(supervisor.dispose).not.toHaveBeenCalled(); + + outputClosed = true; + await expect( + stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).resolves.toEqual({ status: "already_exited" }); + expect(supervisor.unrefOutputStreams).toHaveBeenCalledOnce(); + expect(supervisor.dispose).toHaveBeenCalledOnce(); +}); + +it("retains process supervision after an incomplete stop and disposes after a retry observes exit", async () => { + let waits = 0; + let disposals = 0; + let outputUnrefs = 0; + const signals: NodeJS.Signals[] = []; + const session = { + id: "debugger-session", + exited: false, + launched: { + process: { + kill: (signal: NodeJS.Signals): boolean => { + signals.push(signal); + return true; + }, + }, + }, + detachInferior: async () => + ({ + status: "detached", + outputSink: "durable", + }) as const, + supervisor: { + waitForExit: async (): Promise => { + waits += 1; + return waits === 3; + }, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async (): Promise => true, + dispose: (): void => { + disposals += 1; + }, + unrefOutputStreams: (): void => { + outputUnrefs += 1; + }, + }, + }; + + const first = await stopOwnedDebuggerProcess(session, "GDB", { + platform: "linux", + }); + expect(first).toEqual({ + status: "incomplete", + reason: "Owned GDB process did not exit after direct termination.", + }); + expect(disposals).toBe(0); + expect(outputUnrefs).toBe(0); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + + const retry = await stopOwnedDebuggerProcess(session, "GDB", { + platform: "linux", + }); + expect(retry).toEqual({ status: "stopped" }); + expect(disposals).toBe(1); + expect(waits).toBe(3); +}); + +it("refuses Windows job-backed debugger termination before signaling", async () => { + const signals: NodeJS.Signals[] = []; + let outputUnrefs = 0; + const session = { + id: "windows-debugger", + exited: false, + launched: { + process: { + kill: (signal: NodeJS.Signals): boolean => { + signals.push(signal); + return true; + }, + }, + }, + supervisor: { + waitForExit: async (): Promise => false, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async (): Promise => false, + dispose: (): void => {}, + unrefOutputStreams: (): void => { + outputUnrefs += 1; + }, + }, + }; + + await expect( + stopOwnedDebuggerProcess(session, "GDB", { platform: "win32" }), + ).resolves.toEqual({ + status: "incomplete", + reason: + "Owned GDB process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.", + }); + expect(signals).toEqual([]); + expect(outputUnrefs).toBe(0); +}); + +it("retains an already-exited Windows session while its job has descendants", async () => { + let outputUnrefs = 0; + let disposals = 0; + const result = await stopOwnedDebuggerProcess( + { + id: "windows-already-exited", + exited: true, + launched: { process: { kill: () => true } }, + supervisor: { + waitForExit: async () => false, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => false, + dispose: () => { + disposals += 1; + }, + unrefOutputStreams: () => { + outputUnrefs += 1; + }, + }, + }, + "GDB", + { platform: "win32" }, + ); + + expect(result).toEqual({ + status: "incomplete", + reason: + "Owned GDB process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.", + }); + expect(disposals).toBe(0); + expect(outputUnrefs).toBe(0); +}); + +it("retains an exited POSIX session while descendants still hold its output streams", async () => { + let outputUnrefs = 0; + let disposals = 0; + const result = await stopOwnedDebuggerProcess( + { + id: "posix-already-exited-with-descendant", + exited: true, + launched: { process: { kill: () => true } }, + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => false, + dispose: () => { + disposals += 1; + }, + unrefOutputStreams: () => { + outputUnrefs += 1; + }, + }, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ + status: "incomplete", + reason: + "Owned GDB process leader exited, but inherited output streams are still open or have not reported settlement.", + }); + expect(disposals).toBe(0); + expect(outputUnrefs).toBe(0); +}); + +it.each([ + [ + "POSIX Error", + "linux", + new Error("pipe state unknown"), + /inherited output stream settlement could not be verified: pipe state unknown/, + ], + [ + "POSIX non-Error", + "linux", + "pipe state unknown", + /inherited output stream settlement could not be verified: pipe state unknown/, + ], + [ + "Windows Error", + "win32", + new Error("job state unknown"), + /Windows Job Object settlement could not be verified: job state unknown/, + ], + [ + "Windows non-Error", + "win32", + "job state unknown", + /Windows Job Object settlement could not be verified: job state unknown/, + ], +] as const)( + "retains an exited debugger when output settlement throws (%s)", + async (_label, platform, cause, expectedReason) => { + let disposals = 0; + const result = await stopOwnedDebuggerProcess( + { + id: "output-settlement-error", + exited: true, + launched: { process: { kill: () => true } }, + supervisor: { + waitForExit: async () => true, + waitForOutputClose: async () => Promise.reject(cause), + limitAdditionalOutputBytes: () => {}, + dispose: () => { + disposals += 1; + }, + unrefOutputStreams: () => {}, + }, + }, + "GDB", + { platform }, + ); + + expect(result).toMatchObject({ + status: "incomplete", + reason: expectedReason, + }); + expect(disposals).toBe(0); + }, +); + +it("does not signal an active debugger when its provider cannot detach the inferior", async () => { + const signals: NodeJS.Signals[] = []; + const result = await stopOwnedDebuggerProcess( + { + id: "missing-detach-capability", + exited: false, + launched: { process: { kill: (signal) => (signals.push(signal), true) } }, + supervisor: { + waitForExit: async () => false, + waitForOutputClose: async () => false, + limitAdditionalOutputBytes: () => {}, + dispose: () => {}, + unrefOutputStreams: () => {}, + }, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ + status: "incomplete", + reason: + "Owned GDB inferior detach cannot be verified because the provider has no detach capability.", + }); + expect(signals).toEqual([]); +}); + +it("preserves a non-Error provider detach rejection and does not signal the debugger", async () => { + const signals: NodeJS.Signals[] = []; + const result = await stopOwnedDebuggerProcess( + { + id: "non-error-detach-failure", + exited: false, + launched: { process: { kill: (signal) => (signals.push(signal), true) } }, + detachInferior: async () => Promise.reject("detach state unknown"), + supervisor: { + waitForExit: async () => false, + waitForOutputClose: async () => false, + limitAdditionalOutputBytes: () => {}, + dispose: () => {}, + unrefOutputStreams: () => {}, + }, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ + status: "incomplete", + reason: + "Owned GDB inferior detach could not be verified: detach state unknown", + }); + expect(signals).toEqual([]); +}); + +it("does not signal a POSIX debugger when provider detach cannot be verified", async () => { + const signals: NodeJS.Signals[] = []; + const result = await stopOwnedDebuggerProcess( + { + id: "unverified-detach", + exited: false, + launched: { process: { kill: (signal) => (signals.push(signal), true) } }, + detachInferior: async () => { + throw new Error("MI detach failed"); + }, + supervisor: { + waitForExit: async () => false, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => false, + dispose: () => {}, + unrefOutputStreams: () => {}, + }, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ + status: "incomplete", + reason: "Owned GDB inferior detach could not be verified: MI detach failed", + }); + expect(signals).toEqual([]); +}); + +it("retains an exited debugger after provider detach failed", async () => { + let exited = false; + let disposals = 0; + const session = { + id: "detach-failed-then-exited", + get exited() { + return exited; + }, + launched: { process: { kill: () => true } }, + detachInferior: async () => { + throw new Error("MI detach failed"); + }, + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => true, + dispose: () => { + disposals += 1; + }, + unrefOutputStreams: () => {}, + }, + }; + const sessions = new Map([[session.id, session]]); + + expect( + await stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).toEqual({ + status: "incomplete", + reason: "Owned GDB inferior detach could not be verified: MI detach failed", + }); + + exited = true; + await expect( + closeOwnedDebuggerSessions( + sessions, + (owned) => stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" }), + "GDB", + ), + ).rejects.toThrow("One or more owned GDB processes did not stop cleanly"); + expect(sessions.get(session.id)).toBe(session); + expect(disposals).toBe(0); +}); + +it("recovers failed detach only after the identified inferior ends and output settles", async () => { + let exited = false; + let inferiorEnded = false; + const supervisor = { + ...createFakeSupervisor(), + waitForOutputClose: vi.fn(async () => true), + }; + const session = { + id: "failed-detach-recovery", + get exited() { + return exited; + }, + launched: { process: { kill: vi.fn(() => true) } }, + detachInferior: async () => { + throw new Error("detach failed"); + }, + verifyInferiorsEndedAfterExit: async () => inferiorEnded, + supervisor, + }; + const sessions = new Map([[session.id, session]]); + const stop = (owned: typeof session) => + stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" }); + expect(await stop(session)).toMatchObject({ status: "incomplete" }); + exited = true; + expect(await stop(session)).toMatchObject({ status: "incomplete" }); + expect(supervisor.waitForOutputClose).not.toHaveBeenCalled(); + expect(session.launched.process.kill).not.toHaveBeenCalled(); + inferiorEnded = true; + await closeOwnedDebuggerSessions(sessions, stop, "GDB"); + expect(supervisor.waitForOutputClose).toHaveBeenCalledOnce(); + expect(supervisor.dispose).toHaveBeenCalledOnce(); + expect(sessions.size).toBe(0); +}); + +it.each([ + { observed: "100", ended: false }, + { observed: "200", ended: true }, + { observed: null, ended: true }, +])( + "compares captured inferior identity with $observed", + async ({ observed, ended }) => { + expect( + await verifyOwnedInferiorsEnded( + [{ pid: "123", startTime: "100" }], + async () => observed, + ), + ).toBe(ended); + }, +); + +it("preserves unknown identity reads and rejects empty proof", async () => { + const read = vi.fn(async () => { + throw new Error("identity unreadable"); + }); + await expect( + verifyOwnedInferiorsEnded([{ pid: "123", startTime: "100" }], read), + ).rejects.toThrow("identity unreadable"); + read.mockClear(); + expect(await verifyOwnedInferiorsEnded([], read)).toBe(false); + expect(read).not.toHaveBeenCalled(); +}); + +it("allows closing when the provider confirms no inferior is attached", async () => { + const signals: NodeJS.Signals[] = []; + const result = await stopOwnedDebuggerProcess( + { + id: "no-inferior", + exited: false, + launched: { process: { kill: (signal) => (signals.push(signal), true) } }, + detachInferior: async () => ({ status: "no-inferior" }), + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => true, + dispose: () => {}, + unrefOutputStreams: () => {}, + }, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ status: "stopped" }); + expect(signals).toEqual(["SIGTERM"]); +}); + +it("fails closed when detach lacks durable output sink confirmation", async () => { + const signals: NodeJS.Signals[] = []; + const result = await stopOwnedDebuggerProcess( + { + id: "non-durable-sink", + exited: false, + launched: { process: { kill: (signal) => (signals.push(signal), true) } }, + detachInferior: async () => ({ status: "detached" }) as never, + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => false, + dispose: () => {}, + unrefOutputStreams: () => {}, + }, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ + status: "incomplete", + reason: + "Owned GDB provider did not confirm no inferior or detachment with a durable output sink.", + }); + expect(signals).toEqual([]); +}); + +it("retains an owned debugger when inferior detach does not settle", async () => { + vi.useFakeTimers(); + const signals: NodeJS.Signals[] = []; + let detachCalls = 0; + let resolveDetach!: (value: { + readonly status: "detached"; + readonly outputSink: "durable"; + }) => void; + let disposals = 0; + try { + const session = { + id: "detach-timeout", + exited: false, + launched: { + process: { + kill: (signal: NodeJS.Signals) => (signals.push(signal), true), + }, + }, + detachInferior: () => { + detachCalls += 1; + if (detachCalls > 1) + return Promise.resolve({ + status: "detached", + outputSink: "durable", + } as const); + return new Promise<{ + readonly status: "detached"; + readonly outputSink: "durable"; + }>((resolve) => { + resolveDetach = resolve; + }); + }, + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => true, + dispose: () => { + disposals += 1; + }, + unrefOutputStreams: () => {}, + }, + }; + + const attempt = stopOwnedDebuggerProcess(session, "GDB", { + platform: "linux", + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(await attempt).toEqual({ + status: "incomplete", + reason: "Owned GDB inferior detach was not verified within 1000 ms.", + }); + const retry = stopOwnedDebuggerProcess(session, "GDB", { + platform: "linux", + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(await retry).toEqual({ + status: "incomplete", + reason: "Owned GDB inferior detach was not verified within 1000 ms.", + }); + expect(detachCalls).toBe(1); + expect(signals).toEqual([]); + expect(disposals).toBe(0); + + resolveDetach({ status: "detached", outputSink: "durable" }); + await vi.advanceTimersByTimeAsync(0); + expect( + await stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }), + ).toEqual({ status: "stopped" }); + expect(detachCalls).toBe(2); + expect(signals).toEqual(["SIGTERM"]); + expect(disposals).toBe(1); + } finally { + vi.useRealTimers(); + } +}); + +it("retains an exited session while a timed-out detach attempt is still pending", async () => { + vi.useFakeTimers(); + let exited = false; + let resolveDetach!: (value: { readonly status: "no-inferior" }) => void; + let detachCalls = 0; + const session = { + id: "exited-with-pending-detach", + get exited() { + return exited; + }, + launched: { process: { kill: () => true } }, + detachInferior: () => { + detachCalls += 1; + return new Promise<{ readonly status: "no-inferior" }>((resolve) => { + resolveDetach = resolve; + }); + }, + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => true, + dispose: vi.fn(), + unrefOutputStreams: vi.fn(), + }, + }; + const sessions = new Map([[session.id, session]]); + + try { + const attempt = stopOwnedDebuggerProcess(session, "GDB", { + platform: "linux", + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(await attempt).toMatchObject({ status: "incomplete" }); + + exited = true; + await expect( + closeOwnedDebuggerSessions( + sessions, + (owned) => + stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" }), + "GDB", + ), + ).rejects.toThrow("One or more owned GDB processes did not stop cleanly"); + expect(sessions.get(session.id)).toBe(session); + expect(session.supervisor.dispose).not.toHaveBeenCalled(); + expect(detachCalls).toBe(1); + + resolveDetach({ status: "no-inferior" }); + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + await expect( + closeOwnedDebuggerSessions( + sessions, + (owned) => + stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" }), + "GDB", + ), + ).resolves.toBeUndefined(); + expect(sessions.size).toBe(0); + expect(session.supervisor.dispose).toHaveBeenCalledTimes(1); + expect(detachCalls).toBe(1); + } finally { + vi.useRealTimers(); + } +}); + +it("requires the Windows Job Object to close before releasing an exited session", async () => { + let closeWaits = 0; + let disposals = 0; + const session = { + id: "windows-job-settlement", + exited: true, + launched: { process: { kill: () => true } }, + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => ++closeWaits > 1, + dispose: () => { + disposals += 1; + }, + unrefOutputStreams: () => {}, + }, + }; + + expect( + await stopOwnedDebuggerProcess(session, "GDB", { platform: "win32" }), + ).toMatchObject({ status: "incomplete" }); + expect(disposals).toBe(0); + expect( + await stopOwnedDebuggerProcess(session, "GDB", { platform: "win32" }), + ).toEqual({ status: "already_exited" }); + expect(closeWaits).toBe(2); + expect(disposals).toBe(1); +}); + +it("disposes supervision when a process had already exited", async () => { + let disposals = 0; + const lifecycle: string[] = []; + let outputUnrefs = 0; + const result = await stopOwnedDebuggerProcess( + { + id: "already-exited", + exited: true, + launched: { process: { kill: () => true } }, + supervisor: { + waitForExit: async () => false, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => true, + dispose: () => { + lifecycle.push("dispose"); + disposals += 1; + }, + unrefOutputStreams: () => { + lifecycle.push("unref"); + outputUnrefs += 1; + }, + }, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ status: "already_exited" }); + expect(disposals).toBe(1); + expect(outputUnrefs).toBe(1); + expect(lifecycle).toEqual(["unref", "dispose"]); +}); + +it("uses the host platform when an exited debugger is stopped without options", async () => { + let outputCloseWaits = 0; + let outputUnrefs = 0; + const result = await stopOwnedDebuggerProcess( + { + id: "already-exited-default-platform", + exited: true, + launched: { process: { kill: () => true } }, + supervisor: { + waitForExit: async () => true, + waitForOutputClose: async (timeoutMs) => { + expect(timeoutMs).toBe(1_000); + outputCloseWaits += 1; + return true; + }, + limitAdditionalOutputBytes: () => {}, + dispose: () => {}, + unrefOutputStreams: () => { + outputUnrefs += 1; + }, + }, + }, + "GDB", + ); + + expect(result).toEqual({ status: "already_exited" }); + expect(outputCloseWaits).toBe(1); + expect(outputUnrefs).toBe(process.platform === "win32" ? 0 : 1); +}); + +it.each([ + ["linux", "inherited output stream"], + ["win32", "Windows Job Object"], +] as const)( + "retains a non-Error output-settlement failure on %s", + async (platform, resource) => { + const result = await stopOwnedDebuggerProcess( + { + id: `output-settlement-${platform}`, + exited: true, + launched: { process: { kill: () => true } }, + supervisor: { + waitForExit: async () => true, + waitForOutputClose: async () => { + throw "output status unavailable"; + }, + limitAdditionalOutputBytes: () => {}, + dispose: () => {}, + unrefOutputStreams: () => {}, + }, + }, + "GDB", + { platform }, + ); + + expect(result).toEqual({ + status: "incomplete", + reason: `Owned GDB ${resource} settlement could not be verified: output status unavailable`, + }); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps supervision until a late-writing descendant closes inherited output after leader exit", + async () => { + const inferiorSource = [ + 'const { createServer } = require("node:net");', + "const server = createServer((socket) => {", + ' process.stdout.write("late-stdout-sentinel\\n");', + ' process.stderr.write("late-stderr-sentinel\\n");', + " socket.end();", + " setImmediate(() => process.exit(0));", + "});", + 'server.listen(0, "127.0.0.1", () => process.stdout.write(`ready:${server.address().port}\\n`));', + ].join("\n"); + const { child, supervisor, inferiorPort } = + startDebuggerWithInferior(inferiorSource); + let port: number | undefined; + + try { + await once(child, "exit"); + const readyPort = await inferiorPort; + port = readyPort; + const limitAdditionalOutputBytes = + supervisor.limitAdditionalOutputBytes.bind(supervisor); + vi.spyOn(supervisor, "limitAdditionalOutputBytes").mockImplementation( + (maxBytes) => { + limitAdditionalOutputBytes(maxBytes); + triggerDescendantOutput(readyPort); + }, + ); + const result = await stopOwnedDebuggerProcess( + { + id: "leader-exited-with-inferior", + exited: true, + launched: { process: child }, + supervisor, + }, + "GDB", + { platform: "linux" }, + ); + + expect(result).toEqual({ status: "already_exited" }); + expect(supervisor.snapshot().stdout.text).toContain( + "late-stdout-sentinel", + ); + expect(supervisor.snapshot().stderr.text).toContain( + "late-stderr-sentinel", + ); + } finally { + if (port !== undefined) triggerDescendantOutput(port); + supervisor.dispose(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps late debugger output bounded and drained across cleanup retries", + async () => { + const inferiorSource = [ + 'const { createServer } = require("node:net");', + "const writePair = (stdout, stderr) => Promise.all([", + " new Promise((resolve) => process.stdout.write(stdout, resolve)),", + " new Promise((resolve) => process.stderr.write(stderr, resolve)),", + "]);", + "let outputCount = 0;", + "const server = createServer((socket) => {", + " outputCount += 1;", + ' const output = outputCount === 1 ? writePair("a".repeat(40 * 1024), "b".repeat(40 * 1024)) : writePair("c".repeat(40 * 1024), "d".repeat(40 * 1024));', + " socket.end();", + " if (outputCount === 2) void output.then(() => process.exit(0));", + "});", + 'server.listen(0, "127.0.0.1", () => process.stdout.write(`ready:${server.address().port}\\n`));', + ].join("\n"); + const { child, supervisor, inferiorPort } = + startDebuggerWithInferior(inferiorSource); + let port: number | undefined; + const session = { + id: "leader-exited-retrying-inferior", + exited: true, + launched: { process: child }, + supervisor, + }; + + try { + await once(child, "exit"); + const readyPort = await inferiorPort; + port = readyPort; + const initialStdoutBytes = supervisor.snapshot().stdout.observedBytes; + const limitAdditionalOutputBytes = + supervisor.limitAdditionalOutputBytes.bind(supervisor); + vi.spyOn(supervisor, "limitAdditionalOutputBytes").mockImplementation( + (maxBytes) => { + limitAdditionalOutputBytes(maxBytes); + triggerDescendantOutput(readyPort); + }, + ); + const first = await stopOwnedDebuggerProcess(session, "GDB", { + platform: "linux", + }); + expect(first.status).toBe("incomplete"); + expect(supervisor.snapshot().stdout.observedBytes).toBe( + initialStdoutBytes + 40 * 1024, + ); + expect(supervisor.snapshot().stderr.observedBytes).toBe(40 * 1024); + + const second = await stopOwnedDebuggerProcess(session, "GDB", { + platform: "linux", + }); + expect(second).toEqual({ status: "already_exited" }); + const snapshot = supervisor.snapshot(); + expect(snapshot.stdout.observedBytes).toBe( + initialStdoutBytes + 80 * 1024, + ); + expect(snapshot.stderr.observedBytes).toBe(80 * 1024); + expect(snapshot.stdout.bytes + snapshot.stderr.bytes).toBe( + initialStdoutBytes + 64 * 1024, + ); + expect(snapshot.diagnosticTruncated).toBe(true); + } finally { + if (port !== undefined) { + triggerDescendantOutput(port); + triggerDescendantOutput(port); + } + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + supervisor.dispose(); + } + }, +); + +it("does not escalate when the debugger exits after SIGTERM", async () => { + const signals: NodeJS.Signals[] = []; + const lifecycle: string[] = []; + let disposals = 0; + const result = await stopOwnedDebuggerProcess( + { + id: "graceful-exit", + exited: false, + launched: { + process: { + kill: (signal: NodeJS.Signals): boolean => { + signals.push(signal); + return true; + }, + }, + }, + detachInferior: async () => + ({ + status: "detached", + outputSink: "durable", + }) as const, + supervisor: { + waitForExit: async () => true, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => true, + dispose: () => { + lifecycle.push("dispose"); + disposals += 1; + }, + unrefOutputStreams: () => lifecycle.push("unref"), + }, + }, + "Rizin", + { platform: "linux" }, + ); + + expect(result).toEqual({ status: "stopped" }); + expect(signals).toEqual(["SIGTERM"]); + expect(disposals).toBe(1); + expect(lifecycle).toEqual(["unref", "dispose"]); +}); + +it("disposes supervision when the debugger exits after SIGKILL", async () => { + let waits = 0; + let disposals = 0; + const lifecycle: string[] = []; + const signals: NodeJS.Signals[] = []; + const result = await stopOwnedDebuggerProcess( + { + id: "forced-exit", + exited: false, + launched: { + process: { + kill: (signal: NodeJS.Signals): boolean => { + signals.push(signal); + return true; + }, + }, + }, + detachInferior: async () => + ({ + status: "detached", + outputSink: "durable", + }) as const, + supervisor: { + waitForExit: async () => { + waits += 1; + return waits === 2; + }, + limitAdditionalOutputBytes: vi.fn(), + waitForOutputClose: async () => true, + dispose: () => { + lifecycle.push("dispose"); + disposals += 1; + }, + unrefOutputStreams: () => lifecycle.push("unref"), + }, + }, + "Rizin", + { platform: "linux" }, + ); + + expect(result).toEqual({ status: "stopped" }); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + expect(disposals).toBe(1); + expect(lifecycle).toEqual(["unref", "dispose"]); +}); + +it.skipIf(process.platform === "win32")( + "retains the debugger owner while an unknown descendant holds output pipes open", + async () => { + const releaseDirectory = await mkdtemp( + join(tmpdir(), "rea-debugger-output-writer-"), + ); + const releaseFile = join(releaseDirectory, "release-writer"); + const writerSource = `const{watch}=require("node:fs");const watcher=watch(${JSON.stringify(releaseDirectory)},(_event,name)=>{if(name==="release-writer"){watcher.close();process.exit(0)}});process.stdout.write("writer-ready\\n");`; + const providerProcessUrl = new URL( + "../../../dist/process/ProviderProcess.js", + import.meta.url, + ).href; + const debuggerSessionUrl = new URL( + "../../../dist/process/ownedDebuggerSession.js", + import.meta.url, + ).href; + const debuggerSource = [ + 'const { spawn } = require("node:child_process");', + `spawn(process.execPath, ["-e", ${JSON.stringify(writerSource)}], { stdio: "inherit" });`, + "process.stdout.write(`debugger=${process.pid}\\n`);", + "setInterval(() => {}, 1000);", + ].join("\n"); + const probeSource = [ + 'import { spawn } from "node:child_process";', + `import { ProviderProcessSupervisor } from ${JSON.stringify(providerProcessUrl)};`, + `import { stopOwnedDebuggerProcess } from ${JSON.stringify(debuggerSessionUrl)};`, + `const child = spawn(process.execPath, ["-e", ${JSON.stringify(debuggerSource)}], { stdio: ["ignore", "pipe", "pipe"] });`, + "const supervisor = new ProviderProcessSupervisor({ process: child, ownsProcessLifetime: true });", + "let output = '';", + "await new Promise((resolve, reject) => {", + " const timer = setTimeout(() => reject(new Error('output writer did not become ready')), 2000);", + " child.stdout.on('data', (chunk) => {", + " output += chunk.toString();", + " const debuggerMatch = /^debugger=(\\d+)/mu.exec(output);", + " if (debuggerMatch) process.stderr.write(`debugger-pid=${debuggerMatch[1]}\\n`);", + " if (output.includes('writer-ready\\n')) { clearTimeout(timer); resolve(); }", + " });", + " child.once('error', reject);", + "});", + "const session = { id: 'debugger', get exited() { return child.exitCode !== null || child.signalCode !== null; }, launched: { process: child }, supervisor };", + "session.detachInferior = async () => ({ status: 'no-inferior' });", + "const result = await stopOwnedDebuggerProcess(session, 'GDB', { platform: 'linux' });", + "process.stdout.write(JSON.stringify({ result }) + '\\n');", + "await new Promise((resolve) => process.stdin.once('data', () => { process.stdin.destroy(); resolve(); }));", + "const retried = await stopOwnedDebuggerProcess(session, 'GDB', { platform: 'linux' });", + "process.stdout.write(JSON.stringify({ retried }) + '\\n');", + ].join("\n"); + const stopProbe = startStopProbe(probeSource, true); + const probe = stopProbe.child; + const probeExit = once(probe, "exit"); + + try { + const incompleteLine = await waitForProbeLine( + probe, + stopProbe.output, + /\{"result":\{"status":"incomplete"/u, + ); + expect(incompleteLine).toContain('"status":"incomplete"'); + expect(probe.exitCode).toBeNull(); + expect(probe.signalCode).toBeNull(); + expect(stopProbe.output).toContain('"status":"incomplete"'); + await writeFile(releaseFile, "release"); + probe.stdin?.write("retry\n"); + const settledLine = await waitForProbeLine( + probe, + stopProbe.output, + /\{"retried":\{"status":"already_exited"\}\}/u, + ); + expect(settledLine).toContain('"status":"already_exited"'); + await probeExit; + } finally { + await writeFile(releaseFile, "release").catch(() => undefined); + probe.stdin?.write("retry\n"); + if (probe.exitCode === null && probe.signalCode === null) + await Promise.race([ + probeExit, + new Promise((resolve) => setTimeout(resolve, 3_000)), + ]); + await rm(releaseDirectory, { recursive: true, force: true }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "preserves inferior output written to its selected PTY after the REA probe exits", + async () => { + const inferiorSource = [ + "process.stdout.write(`inferior-ready=${process.pid}\\n`);", + 'process.stdin.setEncoding("utf8");', + 'process.stdin.on("data", (input) => { if (input.includes("write-after-probe")) { process.stdout.write("stdout-after-probe-sentinel\\n"); process.stderr.write("stderr-after-probe-sentinel\\n"); } });', + "setTimeout(() => process.exit(0), 5000);", + ].join("\n"); + const terminal = spawnPty(process.execPath, ["-e", inferiorSource], { + name: "xterm", + cols: 100, + rows: 20, + }); + const terminalExit = new Promise((resolve) => { + terminal.onExit(() => resolve()); + }); + let terminalOutput = ""; + terminal.onData((data) => { + terminalOutput += data; + }); + let inferiorPid: number | undefined; + const ready = new Promise((resolve, reject) => { + let readySubscription: { dispose: () => void } | undefined; + const timeout = setTimeout(() => { + readySubscription?.dispose(); + reject(new Error("PTY inferior did not start")); + }, 2_000); + const checkReady = (): void => { + const match = /inferior-ready=(\d+)/u.exec(terminalOutput); + if (match?.[1] !== undefined) { + clearTimeout(timeout); + resolve(Number(match[1])); + } + }; + readySubscription = terminal.onData(() => { + checkReady(); + if (/inferior-ready=\d+/u.test(terminalOutput)) + readySubscription?.dispose(); + }); + }); + const providerProcessUrl = new URL( + "../../../dist/process/ProviderProcess.js", + import.meta.url, + ).href; + const debuggerSessionUrl = new URL( + "../../../dist/process/ownedDebuggerSession.js", + import.meta.url, + ).href; + const probeSource = [ + 'import { spawn } from "node:child_process";', + `import { ProviderProcessSupervisor } from ${JSON.stringify(providerProcessUrl)};`, + `import { stopOwnedDebuggerProcess } from ${JSON.stringify(debuggerSessionUrl)};`, + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: ["ignore", "pipe", "pipe"] });', + "process.stdout.write(`debugger-pid=${child.pid}\\n`);", + "const supervisor = new ProviderProcessSupervisor({ process: child, ownsProcessLifetime: true });", + "const session = { id: 'debugger-with-selected-pty-inferior', get exited() { return child.exitCode !== null || child.signalCode !== null; }, launched: { process: child }, supervisor, detachInferior: async () => ({ status: 'detached', outputSink: 'durable' }) };", + "const result = await stopOwnedDebuggerProcess(session, 'GDB', { platform: 'linux' });", + "process.stdout.write(`probe-result=${JSON.stringify(result)}\\n`);", + ].join("\n"); + const stopProbe = startStopProbe(probeSource); + const probe = stopProbe.child; + const probeExit = once(probe, "exit"); + + try { + inferiorPid = await ready; + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("REA probe did not exit")), + 3_000, + ); + void probeExit.then(() => { + clearTimeout(timeout); + resolve(); + }, reject); + }); + expect(stopProbe.output).toContain('probe-result={"status":"stopped"}'); + expect(() => process.kill(inferiorPid!, 0)).not.toThrow(); + + terminal.write("write-after-probe\n"); + await new Promise((resolve, reject) => { + let subscription: { dispose: () => void } | undefined; + const timeout = setTimeout(() => { + subscription?.dispose(); + reject( + new Error( + "PTY inferior sentinels were not observed after probe exit", + ), + ); + }, 2_000); + const finish = (): void => { + subscription?.dispose(); + clearTimeout(timeout); + resolve(); + }; + const checkOutput = (): void => { + if ( + terminalOutput.includes("stdout-after-probe-sentinel") && + terminalOutput.includes("stderr-after-probe-sentinel") + ) + finish(); + }; + subscription = terminal.onData(checkOutput); + checkOutput(); + }); + expect(terminalOutput).toContain("stdout-after-probe-sentinel"); + expect(terminalOutput).toContain("stderr-after-probe-sentinel"); + expect(() => process.kill(inferiorPid!, 0)).not.toThrow(); + } finally { + terminal.kill("SIGKILL"); + await Promise.race([ + terminalExit, + new Promise((resolve) => setTimeout(resolve, 1_000)), + ]); + } + }, +); + +it("removes stopped sessions and retains incomplete sessions when closing all", async () => { + const sessions = new Map([ + ["stopped", { id: "stopped" }], + ["incomplete", { id: "incomplete" }], + ]); + + await expect( + closeOwnedDebuggerSessions( + sessions, + async (session) => + session.id === "incomplete" + ? { status: "incomplete", reason: "process remains alive" } + : { status: "stopped" }, + "GDB", + ), + ).rejects.toMatchObject({ + errors: [expect.objectContaining({ message: "process remains alive" })], + }); + expect([...sessions.keys()]).toEqual(["incomplete"]); +}); + +it("does not remove a replacement session installed under the same key while stopping", async () => { + const original = { id: "shared-key", generation: "old" }; + const replacement = { id: "shared-key", generation: "new" }; + const sessions = new Map([["shared-key", original]]); + let finishStop!: (result: { readonly status: "stopped" }) => void; + const stopping = new Promise<{ readonly status: "stopped" }>((resolve) => { + finishStop = resolve; + }); + + const closing = closeOwnedDebuggerSessions( + sessions, + async () => stopping, + "GDB", + ); + await Promise.resolve(); + sessions.set("shared-key", replacement); + finishStop({ status: "stopped" }); + await closing; + + expect(sessions.get("shared-key")).toBe(replacement); +}); + +it("closes an empty owned-session collection", async () => { + const sessions = new Map(); + await expect( + closeOwnedDebuggerSessions( + sessions, + async () => ({ status: "stopped" }), + "GDB", + ), + ).resolves.toBeUndefined(); +}); + +it("settles every stop and retains rejected sessions while removing successful ones", async () => { + const sessions = new Map([ + ["stopped", { id: "stopped" }], + ["rejected", { id: "rejected" }], + ["string-rejected", { id: "string-rejected" }], + ["incomplete", { id: "incomplete" }], + ]); + const calls: string[] = []; + + await expect( + closeOwnedDebuggerSessions( + sessions, + async (session) => { + calls.push(session.id); + if (session.id === "rejected") throw new Error("signal failed"); + if (session.id === "string-rejected") throw "string rejection"; + if (session.id === "incomplete") + return { status: "incomplete", reason: "still alive" }; + return { status: "stopped" }; + }, + "GDB", + ), + ).rejects.toMatchObject({ + message: "One or more owned GDB processes did not stop cleanly", + errors: [ + expect.objectContaining({ message: "signal failed" }), + expect.objectContaining({ message: "string rejection" }), + expect.objectContaining({ message: "still alive" }), + ], + }); + + expect(calls).toHaveLength(4); + expect([...sessions.keys()]).toEqual([ + "rejected", + "string-rejected", + "incomplete", + ]); +}); diff --git a/tests/boundary/process/providerProcess.test.ts b/tests/boundary/process/providerProcess.test.ts index 0f0d4e7ca..b283df2a1 100644 --- a/tests/boundary/process/providerProcess.test.ts +++ b/tests/boundary/process/providerProcess.test.ts @@ -188,6 +188,40 @@ describe("provider process runtime and wait primitives", () => { }); describe("provider process output and cleanup primitives", () => { + it("marks retained provider output that cannot be represented as UTF-8", async () => { + const child = spawnProviderProcessFixture("invalid-utf8"); + const supervisor = new ProviderProcessSupervisor({ + process: child, + ownsProcessLifetime: true, + }); + + await expect(supervisor.waitForOutputClose(2_000)).resolves.toBe(true); + expect(supervisor.snapshot()).toMatchObject({ + stdout: { text: "a�b", invalidUtf8: true }, + stderr: { text: "c�d", invalidUtf8: true }, + }); + await expect(supervisor.stop()).resolves.toMatchObject({ + status: "already-exited", + }); + }); + + it("marks a retained byte prefix that cuts a valid multibyte UTF-8 character", async () => { + const child = spawnProviderProcessFixture("valid-multibyte"); + const supervisor = new ProviderProcessSupervisor( + { process: child, ownsProcessLifetime: true }, + { maxDiagnosticBytes: 2 }, + ); + + await expect(supervisor.waitForOutputClose(2_000)).resolves.toBe(true); + expect(supervisor.snapshot()).toMatchObject({ + stdout: { text: "�", bytes: 2, observedBytes: 3, invalidUtf8: true }, + diagnosticTruncated: true, + }); + await expect(supervisor.stop()).resolves.toMatchObject({ + status: "already-exited", + }); + }); + it("retains complete stdout and stderr beyond the former fixed ceiling", async () => { const outputBytes = 70_000; const child = spawnProviderProcessFixture("burst", outputBytes); diff --git a/tests/boundary/process/providerProcessOutput.test.ts b/tests/boundary/process/providerProcessOutput.test.ts index c3f4334e0..ae4c41882 100644 --- a/tests/boundary/process/providerProcessOutput.test.ts +++ b/tests/boundary/process/providerProcessOutput.test.ts @@ -1,4 +1,7 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; +import type { Socket } from "node:net"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import { ProviderProcessSupervisor, type ProviderProcessDiagnostic, @@ -8,6 +11,26 @@ import { stopProviderProcessFixture, } from "../../fixtures/providerProcess.js"; +it("unreferences inherited output streams without closing them", async () => { + const child = spawnProviderProcessFixture("stubborn"); + const supervisor = new ProviderProcessSupervisor({ + process: child, + ownsProcessLifetime: true, + }); + const stdoutUnref = vi.spyOn(child.stdout as Socket, "unref"); + const stderrUnref = vi.spyOn(child.stderr as Socket, "unref"); + try { + supervisor.unrefOutputStreams(); + expect(stdoutUnref).toHaveBeenCalledOnce(); + expect(stderrUnref).toHaveBeenCalledOnce(); + expect(child.stdout!.destroyed).toBe(false); + expect(child.stderr!.destroyed).toBe(false); + } finally { + await supervisor.stop(); + await stopProviderProcessFixture(child); + } +}); + it("bounds capture before diagnostic callbacks while draining both output streams", async () => { const child = spawnProviderProcessFixture("burst", 70_000); const diagnostics: ProviderProcessDiagnostic[] = []; @@ -46,3 +69,30 @@ it("bounds capture before diagnostic callbacks while draining both output stream await stopProviderProcessFixture(child); } }); + +it("bounds additional late output while retaining observed byte counts", () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + kill: vi.fn(() => true), + }); + const supervisor = new ProviderProcessSupervisor({ + process: child, + ownsProcessLifetime: true, + }); + + supervisor.limitAdditionalOutputBytes(5); + child.stdout.write("abc"); + supervisor.limitAdditionalOutputBytes(5); + child.stdout.write("defgh"); + child.stderr.write("ijkl"); + + const snapshot = supervisor.snapshot(); + expect(snapshot.stdout.text + snapshot.stderr.text).toHaveLength(5); + expect(snapshot.stdout.observedBytes).toBe(8); + expect(snapshot.stderr.observedBytes).toBe(4); + expect(snapshot.diagnosticTruncated).toBe(true); + supervisor.dispose(); +}); diff --git a/tests/boundary/providers/CutterBridgeCapacity.test.ts b/tests/boundary/providers/CutterBridgeCapacity.test.ts new file mode 100644 index 000000000..135444d58 --- /dev/null +++ b/tests/boundary/providers/CutterBridgeCapacity.test.ts @@ -0,0 +1,132 @@ +import { randomUUID } from "node:crypto"; +import { chmod, writeFile } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js"; +import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js"; +import { cutterDiscoverySchema } from "../../../src/contracts/cutterToolContracts.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const descriptor = async (directory: string, port: number): Promise => { + const sessionId = randomUUID(); + await writeFile( + join(directory, `cutter-${process.pid}-${sessionId}.json`), + JSON.stringify({ + session_id: sessionId, + pid: process.pid, + host: "127.0.0.1", + port, + token: "capacity-test-token-123456789", + document_generation: 0, + current_file: null, + cutter_version: null, + identity_status: "partial", + }), + { mode: 0o600 }, + ); + return sessionId; +}; + +it("reports descriptor capacity without opening candidates or claiming no live sessions", async () => { + const directory = await createTestTempDirectory("rea-cutter-capacity-"); + await chmod(directory, 0o700); + const selectedSession = await descriptor(directory, 1); + for (let index = 1; index < 257; index += 1) await descriptor(directory, 1); + const client = new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { platform: "linux" }, + ); + const result = await client.listSessions(); + expect(cutterDiscoverySchema.parse(result)).toMatchObject({ + sessions: [], + discovery_status: "bridge_directory_capacity_exceeded", + bridge_directory_security: "private_verified", + }); + const service = new CutterBridgeService(client); + expect( + await service.execute({ + session_id: selectedSession, + expected_generation: 0, + command: "iI", + json: false, + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + cleanupIncomplete: false, + }, + }); +}); + +it("bounds simultaneous socket probes while examining every admitted descriptor", async () => { + const directory = await createTestTempDirectory("rea-cutter-probes-"); + await chmod(directory, 0o700); + const sockets = new Set(); + let peak = 0; + let probes = 0; + const server = createServer((socket) => { + sockets.add(socket); + probes += 1; + peak = Math.max(peak, sockets.size); + socket.once("close", () => sockets.delete(socket)); + setTimeout(() => socket.destroy(), 20); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("listener missing"); + for (let index = 0; index < 24; index += 1) + await descriptor(directory, address.port); + const client = new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { platform: "linux" }, + ); + expect((await client.listSessions()).discovery_status).toBe( + "no_live_bridge_found", + ); + expect(probes).toBe(24); + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(8); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +it("rejects expanded authenticated frames before dispatch and retains no cleanup owner", async () => { + const directory = await createTestTempDirectory("rea-cutter-frame-"); + await chmod(directory, 0o700); + const sessionId = await descriptor(directory, 1); + const service = new CutterBridgeService( + new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { platform: "linux" }, + ), + ); + const input = { session_id: sessionId, expected_generation: 0, json: false }; + const rejected = await service.execute({ + ...input, + command: '"'.repeat(4 * 1024 * 1024 + 1024), + }); + expect(rejected).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + cleanupIncomplete: false, + }, + }); + expect(await service.retryCleanup()).toEqual({ ok: true, value: null }); + // A later valid command reaches transport, rather than replaying the resource failure. + expect(await service.execute({ ...input, command: "iI" })).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + cleanupIncomplete: false, + }, + }); +}); diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts new file mode 100644 index 000000000..a4b7fb24d --- /dev/null +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -0,0 +1,1388 @@ +import { createServer } from "node:net"; +import { createHmac } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { + chmod, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js"; +import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js"; + +const listen = async ( + server: ReturnType, +): Promise => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected an ephemeral IPv4 listener"); + return address.port; +}; + +const authenticatedServer = ( + token: string, + sessionId: string, + onRequest: ( + request: Record, + respond: ( + value: unknown, + options?: { raw?: boolean; fragment?: boolean; chunkBytes?: number }, + ) => void, + connection: import("node:net").Socket, + ) => void, + handleStatusRequests = false, +): ReturnType => { + let nextConnectionOrdinal = 0; + return createServer((connection) => { + const connectionOrdinal = ++nextConnectionOrdinal; + connection.setEncoding("utf8"); + let input = ""; + let clientNonce = ""; + let serverNonce = ""; + connection.on("data", (chunk: string) => { + input += chunk; + for (;;) { + const end = input.indexOf("\n"); + if (end < 0) return; + const frame = JSON.parse(input.slice(0, end)) as Record< + string, + unknown + >; + input = input.slice(end + 1); + if (frame.kind === "rea-auth-v2") { + clientNonce = String(frame.nonce); + serverNonce = `${connectionOrdinal.toString(16).padStart(16, "0")}${"e".repeat(48)}`; + const proof = createHmac("sha256", token) + .update( + `rea-cutter-server-v2:${sessionId}:${clientNonce}:${serverNonce}`, + ) + .digest("hex"); + connection.write( + `${JSON.stringify({ session_id: sessionId, nonce: serverNonce, proof })}\n`, + ); + continue; + } + const requestBytes = String(frame.request); + const expected = createHmac("sha256", token) + .update( + `rea-cutter-client-v2:${sessionId}:${clientNonce}:${serverNonce}:${requestBytes}`, + ) + .digest("hex"); + if (frame.proof !== expected) { + connection.destroy(); + return; + } + const respond = ( + value: unknown, + options: { + raw?: boolean; + fragment?: boolean; + chunkBytes?: number; + } = {}, + ): void => { + const response = + options.raw === true ? String(value) : JSON.stringify(value); + const responseProof = createHmac("sha256", token) + .update( + `rea-cutter-response-v2:${sessionId}:${clientNonce}:${serverNonce}:${response}`, + ) + .digest("hex"); + const frame = `${JSON.stringify({ response, proof: responseProof })}\n`; + const bytes = Buffer.from(frame); + if (options.chunkBytes !== undefined) { + const writeChunk = (offset: number): void => { + if (connection.destroyed) return; + const end = Math.min(offset + options.chunkBytes!, bytes.length); + if (offset === bytes.length) { + connection.end(); + return; + } + connection.write(bytes.subarray(offset, end)); + setImmediate(() => writeChunk(end)); + }; + setImmediate(() => writeChunk(0)); + } else if (options.fragment === true) { + const midpoint = Math.floor(bytes.length / 2); + connection.write(bytes.subarray(0, midpoint)); + setImmediate(() => connection.end(bytes.subarray(midpoint))); + } else connection.end(frame); + }; + const request = JSON.parse(requestBytes) as Record; + if (request.kind === "status" && !handleStatusRequests) { + respond({ + ok: true, + session_id: sessionId, + completed_commands: [], + completed_connection: (connectionOrdinal - 1) + .toString(16) + .padStart(16, "0"), + }); + continue; + } + onRequest(request, respond, connection); + } + }); + }); +}; + +const statusServer = ( + token: string, + currentFile: string | null, + documentGeneration: number, +): ReturnType => + authenticatedServer( + token, + "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + (request, respond) => { + respond({ + ok: true, + session_id: request.session_id, + current_file: currentFile, + document_generation: documentGeneration, + cutter_version: "Cutter fixture version", + identity_status: "partial", + }); + }, + true, + ); + +const createStatusHarness = ( + token: string, + currentFile: string | null, + documentGeneration: number, +) => { + let requestText = ""; + const server = statusServer(token, currentFile, documentGeneration); + server.on("connection", (connection) => { + connection.on("data", (chunk: Buffer) => { + requestText += chunk.toString("utf8"); + }); + }); + return { server, requestText: () => requestText }; +}; + +const writeDescriptor = async ( + directory: string, + sessionId: string, + port: number, + token: string, + options: { + readonly currentFile?: string | null; + readonly documentGeneration?: number; + } = {}, +): Promise => + writeFile( + join(directory, `cutter-${process.pid}-${sessionId}.json`), + JSON.stringify({ + session_id: sessionId, + pid: process.pid, + host: "127.0.0.1", + port, + token, + document_generation: options.documentGeneration ?? 0, + current_file: options.currentFile ?? null, + cutter_version: "Cutter fixture version", + identity_status: "partial", + }), + { mode: 0o600 }, + ); + +const expectDiscoveredSession = ( + result: unknown, + sessionId: string, + documentGeneration: number, + currentFile: string, +): void => { + expect(result).toMatchObject({ + discovery_status: "sessions_found", + bridge_directory_security: "private_verified", + sessions: [ + { + session_id: sessionId, + document_generation: documentGeneration, + current_file: currentFile, + cutter_version: "Cutter fixture version", + }, + ], + }); +}; + +const createClient = (environment: NodeJS.ProcessEnv): CutterBridgeClient => { + if (process.platform !== "win32") return new CutterBridgeClient(environment); + return new CutterBridgeClient(environment, { + windowsPrivateReader: { + verifyDirectory: () => true, + readDescriptor: (root, name, maxBytes) => { + const bytes = readFileSync(join(root, name)); + if (bytes.byteLength > maxBytes) + throw new Error("Descriptor too large"); + return bytes; + }, + }, + }); +}; + +const verifyCutterCancellationAfterDispatch = async (): Promise => { + const directory = await mkdtemp(join(tmpdir(), "rea-cutter-cancel-test-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + let markDispatched: (() => void) | undefined; + const dispatched = new Promise((resolve) => { + markDispatched = resolve; + }); + const server = authenticatedServer( + token, + sessionId, + (request, _respond, _connection) => { + expect(request.kind).toBe("command"); + markDispatched?.(); + }, + ); + try { + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token); + const controller = new AbortController(); + const execution = createClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).execute({ + sessionId, + expectedGeneration: 0, + command: "Ps /tmp/possibly-saved.rzdb", + json: false, + signal: controller.signal, + }); + await dispatched; + controller.abort(); + await expect(execution).resolves.toMatchObject({ + executionState: "unknown", + error: "transport-response-missing", + message: expect.stringContaining("do not retry automatically"), + documentGeneration: 0, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } +}; + +describe("CutterBridgeClient: scenario group 1", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("discovers a live bridge using the exact session ID from its descriptor", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-bridge-test-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + const harness = createStatusHarness(token, "/tmp/sample.bin", 4); + server = harness.server; + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token); + + const result = await createClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listSessions(); + + expectDiscoveredSession(result, sessionId, 4, "/tmp/sample.bin"); + expect( + JSON.parse(JSON.parse(harness.requestText().split("\n")[1]!).request), + ).toMatchObject({ + kind: "status", + session_id: sessionId, + }); + }); + + it("does not disclose a client proof or command to a spoofed loopback peer", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-spoofed-peer-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + let received = ""; + server = createServer((connection) => { + connection.setEncoding("utf8"); + connection.once("data", (chunk: string) => { + received += chunk; + connection.end( + `${JSON.stringify({ session_id: sessionId, nonce: "a".repeat(64), proof: "0".repeat(64) })}\n`, + ); + }); + }); + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token); + const result = createClient({ REA_CUTTER_BRIDGE_DIR: directory }).execute({ + sessionId, + expectedGeneration: 0, + command: "write /tmp/secret", + json: false, + }); + await expect(result).rejects.toThrow("peer authentication"); + expect(received).toContain("rea-auth-v2"); + expect(received).not.toContain(token); + expect(received).not.toContain("write /tmp/secret"); + }); + + it("rejects a POSIX bridge directory that is accessible to other users", async () => { + if (process.platform === "win32") return; + directory = await mkdtemp(join(tmpdir(), "rea-cutter-insecure-")); + await chmod(directory, 0o755); + + const result = await new CutterBridgeClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listSessions(); + + expect(result).toMatchObject({ + sessions: [], + discovery_status: "bridge_directory_insecure", + bridge_directory_security: "not_private", + }); + }); + + it("preserves unknown execution state when a live bridge closes after command dispatch", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-close-test-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + server = authenticatedServer( + "synthetic-cutter-bridge-token-for-test", + sessionId, + (_request, _respond, connection) => connection.destroy(), + ); + const port = await listen(server); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + ); + + await expect( + createClient({ REA_CUTTER_BRIDGE_DIR: directory }).execute({ + sessionId, + expectedGeneration: 0, + command: "Ps /tmp/possibly-saved.rzdb", + json: false, + }), + ).resolves.toMatchObject({ + executionState: "unknown", + error: "transport-response-missing", + message: expect.stringContaining("do not retry automatically"), + documentGeneration: 0, + }); + }); + + it( + "reports unknown completion when cancellation follows Cutter command dispatch", + verifyCutterCancellationAfterDispatch, + ); + + it("uses verified Windows handle reads before trusting Cutter descriptors", async () => { + directory = await mkdtemp( + join(tmpdir(), "rea-cutter-windows-bridge-test-"), + ); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + server = statusServer(token, "C:\\fixtures\\sample.exe", 1); + const port = await listen(server); + const entry = `cutter-${process.pid}-${sessionId}.json`; + const descriptor = { + session_id: sessionId, + pid: process.pid, + host: "127.0.0.1", + port, + token, + document_generation: 0, + current_file: null, + cutter_version: "Cutter fixture version", + identity_status: "partial", + }; + await writeFile( + join(directory, entry), + "native reader owns descriptor bytes", + ); + let reads = 0; + const result = await new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { + platform: "win32", + windowsPrivateReader: { + verifyDirectory: () => true, + readDescriptor: (root, name, maxBytes) => { + expect(root).toBe(directory); + expect(name).toBe(entry); + expect(maxBytes).toBe(64 * 1024); + reads += 1; + return Buffer.from(JSON.stringify(descriptor)); + }, + }, + }, + ).listSessions(); + + expect(reads).toBe(1); + expectDiscoveredSession(result, sessionId, 1, "C:\\fixtures\\sample.exe"); + }); +}); + +describe("CutterBridgeClient: scenario group 2", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("fails closed when Windows ACL verification is unavailable", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-windows-insecure-")); + + const result = await new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { + platform: "win32", + windowsPrivateReader: { + verifyDirectory: () => false, + readDescriptor: () => Buffer.from("{}"), + }, + }, + ).listSessions(); + + expect(result).toMatchObject({ + sessions: [], + discovery_status: "bridge_directory_insecure", + bridge_directory_security: "not_private", + }); + }); + + it("omits invalid, unresponsive, and mismatched bridge descriptors", async () => { + directory = await mkdtemp( + join(tmpdir(), "rea-cutter-invalid-descriptors-"), + ); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + server = createServer((connection) => { + connection.setEncoding("utf8"); + connection.once("data", () => + connection.end( + `${JSON.stringify({ ok: true, session_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" })}\n`, + ), + ); + }); + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token); + await writeFile( + join(directory, "cutter-1-11111111-1111-1111-1111-111111111111.json"), + "{", + ); + await writeFile( + join( + directory, + `cutter-${process.pid}-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.json`, + ), + "{}", + ); + await writeFile( + join( + directory, + `cutter-${process.pid}-bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.json`, + ), + JSON.stringify({ + session_id: "cccccccc-cccc-cccc-cccc-cccccccccccc", + pid: process.pid, + host: "127.0.0.1", + port, + token, + document_generation: 0, + current_file: null, + cutter_version: null, + identity_status: "partial", + }), + { mode: 0o600 }, + ); + + const result = await createClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listSessions(); + expect(result).toMatchObject({ + discovery_status: "no_live_bridge_found", + sessions: [], + }); + }); + + it("ignores a descriptor whose status endpoint is unavailable", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-dead-status-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const dead = createServer(); + const port = await listen(dead); + await new Promise((resolve) => dead.close(() => resolve())); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + ); + await expect( + createClient({ REA_CUTTER_BRIDGE_DIR: directory }).listSessions(), + ).resolves.toMatchObject({ + discovery_status: "no_live_bridge_found", + sessions: [], + }); + }); +}); + +describe("CutterBridgeClient: scenario group 3", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("reports discovery failure when reading bridge directory entries fails", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-readdir-error-")); + await vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const real = + await vi.importActual( + "node:fs/promises", + ); + return { + ...real, + opendir: async () => { + throw new Error("synthetic read failure"); + }, + }; + }); + try { + const { CutterBridgeClient: ReaddirFailureClient } = + await import("../../../src/cutter/CutterBridgeClient.js"); + await expect( + new ReaddirFailureClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listSessions(), + ).resolves.toMatchObject({ + discovery_status: "bridge_directory_unavailable", + bridge_directory_security: "not_checked", + }); + } finally { + vi.doUnmock("node:fs/promises"); + await vi.resetModules(); + } + }); + + it("reports unavailable directories and ignores a non-directory bridge path", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-path-")); + const filePath = join(directory, "not-a-directory"); + await writeFile(filePath, "x"); + await expect( + new CutterBridgeClient({ + REA_CUTTER_BRIDGE_DIR: join(directory, "missing"), + }).listSessions(), + ).resolves.toMatchObject({ + discovery_status: "no_live_bridge_found", + bridge_directory_security: "not_checked", + }); + await expect( + new CutterBridgeClient({ + REA_CUTTER_BRIDGE_DIR: filePath, + }).listSessions(), + ).resolves.toMatchObject({ + discovery_status: "bridge_directory_insecure", + bridge_directory_security: "not_private", + }); + await expect( + new CutterBridgeClient({ + REA_CUTTER_BRIDGE_DIR: join(filePath, "child"), + }).listSessions(), + ).resolves.toMatchObject({ + discovery_status: "bridge_directory_unavailable", + bridge_directory_security: "not_checked", + }); + }); + + it("uses descriptor status values when a live bridge omits optional fields", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-status-defaults-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + server = authenticatedServer( + "synthetic-cutter-bridge-token-for-test", + sessionId, + (_request, respond) => respond({ ok: true, session_id: sessionId }), + true, + ); + const port = await listen(server); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + { currentFile: "/from-descriptor", documentGeneration: 5 }, + ); + await expect( + createClient({ REA_CUTTER_BRIDGE_DIR: directory }).listSessions(), + ).resolves.toMatchObject({ + sessions: [ + { + document_generation: 5, + current_file: "/from-descriptor", + cutter_version: "Cutter fixture version", + }, + ], + }); + }); +}); + +describe("CutterBridgeClient: scenario group 4", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("covers command response defaults, failures, malformed data, and generation changes", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-command-responses-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + let response: unknown = { ok: true }; + server = authenticatedServer(token, sessionId, (request, respond) => + respond( + request.kind === "ack" ? { ok: true, acknowledged: true } : response, + ), + ); + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token, { + currentFile: "/tmp/input", + documentGeneration: 8, + }); + const client = createClient({ REA_CUTTER_BRIDGE_DIR: directory }); + const run = () => + client.execute({ + sessionId, + expectedGeneration: 8, + command: "pd 4", + json: true, + }); + await expect(run()).resolves.toMatchObject({ + executionState: "complete", + output: null, + currentFile: "/tmp/input", + documentGeneration: 8, + outputTruncated: false, + }); + response = { + ok: false, + execution_state: "failed", + error: "rejected", + output: [1], + output_truncated: true, + }; + await expect(run()).resolves.toMatchObject({ + executionState: "failed", + error: "rejected", + output: [1], + outputTruncated: true, + }); + response = { ok: false, execution_state: "running" }; + await expect(run()).resolves.toMatchObject({ + executionState: "unknown", + error: "cutter-command-failed", + }); + response = { + ok: true, + execution_state: "running", + current_file: null, + document_generation: 9, + cutter_version: "new", + }; + await expect(run()).resolves.toMatchObject({ + executionState: "unknown", + currentFile: null, + documentGeneration: 9, + cutterVersion: "new", + }); + response = { + ok: false, + execution_state: "not_started", + error: "active-document-generation-changed", + }; + await expect(run()).rejects.toThrow("List sessions again"); + response = { ok: true, unexpected: true }; + // Valid schema responses can omit all optional fields; the fallback result is nullable. + await expect(run()).resolves.toMatchObject({ + executionState: "complete", + error: null, + message: null, + }); + }); + + it("returns unknown completion for invalid JSON and rejects a pre-aborted request", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-malformed-response-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + server = authenticatedServer( + "synthetic-cutter-bridge-token-for-test", + sessionId, + (_request, respond) => respond({ ok: "invalid" }), + ); + const port = await listen(server); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + ); + const client = createClient({ REA_CUTTER_BRIDGE_DIR: directory }); + await expect( + client.execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + }), + ).resolves.toMatchObject({ + executionState: "unknown", + error: "malformed-bridge-response", + }); + const controller = new AbortController(); + controller.abort(); + await expect( + client.execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +describe("CutterBridgeClient: scenario group 5", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("handles malformed framed JSON and fragmented response frames", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-framed-response-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + let malformed = false; + server = authenticatedServer( + "synthetic-cutter-bridge-token-for-test", + sessionId, + (request, respond) => { + if (request.kind === "ack") { + respond({ ok: true, acknowledged: true }); + return; + } + if (malformed) respond("{", { raw: true }); + else respond({ ok: true, output: "split 雪 ☃️" }, { fragment: true }); + }, + ); + const port = await listen(server); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + ); + const client = createClient({ REA_CUTTER_BRIDGE_DIR: directory }); + await expect( + client.execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + }), + ).resolves.toMatchObject({ + output: "split 雪 ☃️", + executionState: "complete", + }); + malformed = true; + await expect( + client.execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + }), + ).resolves.toMatchObject({ + executionState: "unknown", + error: "transport-response-missing", + }); + }); + + it("times out a dispatched bridge request without claiming completion", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-timeout-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + let markDispatched: (() => void) | undefined; + const dispatched = new Promise((resolve) => { + markDispatched = resolve; + }); + server = authenticatedServer( + "synthetic-cutter-bridge-token-for-test", + sessionId, + (_request, _respond) => markDispatched?.(), + ); + const port = await listen(server); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + ); + vi.useFakeTimers(); + try { + const execution = createClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + }); + await dispatched; + await vi.advanceTimersByTimeAsync(25_000); + await expect(execution).resolves.toMatchObject({ + executionState: "unknown", + error: "transport-response-missing", + message: expect.stringContaining("timed out"), + }); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("CutterBridgeClient: scenario group 6", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("rejects bridge frames that exceed the bounded response size", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-large-response-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + server = authenticatedServer( + "synthetic-cutter-bridge-token-for-test", + sessionId, + (_request, respond, connection) => { + connection.on("error", (error) => { + expect((error as NodeJS.ErrnoException).code).toBe("ECONNRESET"); + }); + respond( + { ok: true, output: "雪".repeat(5_600_000) }, + { chunkBytes: 64 * 1024 }, + ); + }, + ); + const port = await listen(server); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + ); + await expect( + createClient({ REA_CUTTER_BRIDGE_DIR: directory }).execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + }), + ).resolves.toMatchObject({ + executionState: "unknown", + error: "transport-response-missing", + message: expect.stringContaining("may have completed"), + }); + }); + + it("rejects stale sessions and preserves failures before command dispatch", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-stale-session-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const client = createClient({ REA_CUTTER_BRIDGE_DIR: directory }); + await expect( + client.execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + }), + ).rejects.toThrow("unavailable or stale"); + + const unopened = createServer(); + const port = await listen(unopened); + await new Promise((resolve) => unopened.close(() => resolve())); + await writeDescriptor( + directory, + sessionId, + port, + "synthetic-cutter-bridge-token-for-test", + ); + await expect( + client.execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + }), + ).rejects.toThrow("Cutter bridge is unavailable"); + }); + + it("filters descriptors with unsafe permissions and symbolic links", async () => { + if (process.platform === "win32") return; + directory = await mkdtemp(join(tmpdir(), "rea-cutter-unsafe-descriptor-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const name = `cutter-${process.pid}-${sessionId}.json`; + const target = join(directory, "target.json"); + await writeDescriptor( + directory, + sessionId, + 1234, + "synthetic-cutter-bridge-token-for-test", + ); + await writeFile(target, await readFile(join(directory, name)), { + mode: 0o600, + }); + await chmod(join(directory, name), 0o644); + await expect( + new CutterBridgeClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listDescriptors(), + ).resolves.toEqual([]); + await rm(join(directory, name)); + await symlink(target, join(directory, name)); + await expect( + new CutterBridgeClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listDescriptors(), + ).resolves.toEqual([]); + }); +}); + +describe("CutterBridgeClient: scenario group 7", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("handles an abort detected immediately after listener registration", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-abort-race-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + await writeDescriptor( + directory, + sessionId, + 1234, + "synthetic-cutter-bridge-token-for-test", + ); + const signal = { + aborted: true, + throwIfAborted: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + } as unknown as AbortSignal; + await expect( + createClient({ REA_CUTTER_BRIDGE_DIR: directory }).execute({ + sessionId, + expectedGeneration: 0, + command: "pd 1", + json: false, + signal, + }), + ).rejects.toThrow("Cutter bridge request was cancelled"); + }); + + it("supports platforms without O_NOFOLLOW using the documented read-only flags", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-no-nofollow-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + await writeDescriptor( + directory, + sessionId, + 1234, + "synthetic-cutter-bridge-token-for-test", + ); + await vi.resetModules(); + vi.doMock("node:fs", async () => { + const real = await vi.importActual("node:fs"); + return { + ...real, + constants: { ...real.constants, O_NOFOLLOW: undefined }, + }; + }); + try { + const { CutterBridgeClient: NoNoFollowClient } = + await import("../../../src/cutter/CutterBridgeClient.js"); + await expect( + new NoNoFollowClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listDescriptors(), + ).resolves.toMatchObject([{ session_id: sessionId }]); + } finally { + vi.doUnmock("node:fs"); + await vi.resetModules(); + } + }); + + it("keeps descriptor discovery resilient when closing a read handle fails", async () => { + if (process.platform === "win32") return; + directory = await mkdtemp(join(tmpdir(), "rea-cutter-close-failure-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + await writeDescriptor( + directory, + sessionId, + 1234, + "synthetic-cutter-bridge-token-for-test", + ); + const bytes = await readFile( + join(directory, `cutter-${process.pid}-${sessionId}.json`), + ); + let position = 0; + await vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const real = + await vi.importActual( + "node:fs/promises", + ); + return { + ...real, + open: async () => ({ + stat: async () => ({ + isFile: () => true, + size: bytes.byteLength, + uid: process.getuid?.() ?? 0, + mode: 0o100600, + }), + read: async (target: Buffer, offset: number, length: number) => { + const chunk = bytes.subarray(position, position + length); + chunk.copy(target, offset); + position += chunk.byteLength; + return { bytesRead: chunk.byteLength }; + }, + close: async () => { + throw new Error("synthetic close failure"); + }, + }), + }; + }); + try { + const { CutterBridgeClient: CloseFailureClient } = + await import("../../../src/cutter/CutterBridgeClient.js"); + await expect( + new CloseFailureClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listDescriptors(), + ).resolves.toMatchObject([{ session_id: sessionId }]); + } finally { + vi.doUnmock("node:fs/promises"); + await vi.resetModules(); + } + }); +}); + +describe("CutterBridgeClient: scenario group 8", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("uses conservative platform and Windows descriptor checks", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-platform-checks-")); + const getuid = vi + .spyOn(process, "getuid") + .mockImplementation(() => undefined as never); + try { + await expect( + new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { platform: "darwin" }, + ).listSessions(), + ).resolves.toMatchObject({ + discovery_status: "bridge_directory_insecure", + bridge_directory_security: "unverified_platform_acl", + }); + } finally { + getuid.mockRestore(); + } + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + await writeDescriptor( + directory, + sessionId, + 1234, + "synthetic-cutter-bridge-token-for-test", + ); + const result = await new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { + platform: "win32", + windowsPrivateReader: { + verifyDirectory: () => true, + readDescriptor: () => Buffer.alloc(64 * 1024 + 1), + }, + }, + ).listDescriptors(); + expect(result).toEqual([]); + await expect( + new CutterBridgeClient( + { LOCALAPPDATA: "C:\\Users\\fixture\\AppData\\Local" }, + { + platform: "win32", + windowsPrivateReader: { + verifyDirectory: () => false, + readDescriptor: () => Buffer.from("{}"), + }, + }, + ).listSessions(), + ).resolves.toMatchObject({ + bridge_directory: join( + "C:\\Users\\fixture\\AppData\\Local", + "REA", + "CutterBridge", + ), + }); + await expect( + new CutterBridgeClient({}, { platform: "darwin" }).listSessions(), + ).resolves.toMatchObject({ + bridge_directory: join(homedir(), ".cache", "rea", "cutter-bridge"), + }); + }); + + it("uses the packaged Windows authority for descriptor verification and reads", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-native-reader-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const entry = `cutter-${process.pid}-${sessionId}.json`; + const descriptor = { + session_id: sessionId, + pid: process.pid, + host: "127.0.0.1", + port: 1234, + token: "synthetic-cutter-bridge-token-for-test", + document_generation: 0, + current_file: null, + cutter_version: null, + identity_status: "partial", + }; + await vi.resetModules(); + let failVerification = true; + vi.doMock("../../../src/windows/WindowsNativeLoader.js", () => ({ + requireWindowsNativeAuthority: () => ({ + call: (operation: string) => + operation === "cutter_bridge_verify_directory" + ? failVerification + ? (() => { + throw new Error("verification failure"); + })() + : { privateDacl: true } + : Buffer.from(JSON.stringify(descriptor)), + }), + })); + try { + const { CutterBridgeClient: NativeReaderClient } = + await import("../../../src/cutter/CutterBridgeClient.js"); + const client = new NativeReaderClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { platform: "win32" }, + ); + await writeFile( + join(directory, entry), + "native reader owns descriptor bytes", + ); + await expect(client.listSessions()).resolves.toMatchObject({ + discovery_status: "bridge_directory_insecure", + bridge_directory_security: "unverified_platform_acl", + }); + failVerification = false; + await expect(client.listDescriptors()).resolves.toMatchObject([ + { session_id: sessionId, pid: process.pid }, + ]); + } finally { + vi.doUnmock("../../../src/windows/WindowsNativeLoader.js"); + await vi.resetModules(); + } + }); +}); + +describe("CutterBridgeClient: scenario group 9", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it("delegates service session listing to its injected bridge", async () => { + const discovery = { + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found" as const, + bridge_directory_security: "private_verified" as const, + }; + const service = new CutterBridgeService({ + listSessions: async () => discovery, + execute: async () => { + throw new Error("unexpected command dispatch"); + }, + }); + await expect(service.listSessions()).resolves.toBe(discovery); + }); + + it("accepts only the exact completed-command receipt from status", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-completion-receipt-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const staleId = "ede4bbfd-6348-46aa-963b-0de0e78ccbdc"; + const token = "synthetic-cutter-bridge-token-for-test"; + const completedRequestIds = new Set(); + server = authenticatedServer( + token, + sessionId, + (request, respond) => { + if (request.kind === "command") { + expect(request.request_id).toMatch(/^[0-9a-f-]{36}$/iu); + completedRequestIds.add(String(request.request_id)); + respond({ + ok: false, + session_id: sessionId, + execution_state: "unknown", + error: "ui-thread-request-timeout", + }); + return; + } + if (request.kind === "ack") { + respond({ + ok: true, + acknowledged: completedRequestIds.delete( + String(request.request_id), + ), + }); + return; + } + respond({ + ok: true, + session_id: sessionId, + current_file: "/tmp/input", + document_generation: 0, + completed_connection: "0000000000000000", + completed_commands: [...completedRequestIds].map((request_id) => ({ + request_id, + execution_state: "complete", + })), + }); + }, + true, + ); + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token); + const client = createClient({ REA_CUTTER_BRIDGE_DIR: directory }); + const execution = await client.execute({ + sessionId, + expectedGeneration: 0, + command: "pd 4", + json: false, + }); + expect(execution.executionState).toBe("unknown"); + expect(execution.requestId).toBeTruthy(); + + expect( + await client.confirmCommandCompletion(sessionId, execution.requestId), + ).toEqual({ ok: true, value: true }); + expect( + await client.confirmCommandCompletion(sessionId, execution.requestId), + ).toEqual({ ok: true, value: false }); + completedRequestIds.add(staleId); + expect( + await client.confirmCommandCompletion(sessionId, execution.requestId), + ).toEqual({ ok: true, value: false }); + }); + + it("records known completion and truncated command output", async () => { + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + execute: async () => ({ + requestId: "4b18ce4d-bf31-456e-ae31-e8c547b2f9db", + output: "partial", + currentFile: null, + documentGeneration: 7, + identityStatus: "partial", + cutterVersion: null, + executionState: "complete", + error: null, + message: null, + outputTruncated: true, + }), + }); + const result = await service.execute({ + session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + expected_generation: 7, + command: "pd 4", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.limitations).toContain( + "The command completed or may have completed, but output exceeded the bridge transport limit and was omitted.", + ); + expect(result.value.limitations).not.toContain( + "Command completion or its output could not be confirmed; do not retry automatically because the command may have produced partial or persistent effects.", + ); + }); +}); diff --git a/tests/boundary/providers/CutterBridgeNullIdentity.test.ts b/tests/boundary/providers/CutterBridgeNullIdentity.test.ts new file mode 100644 index 000000000..2afb39e3d --- /dev/null +++ b/tests/boundary/providers/CutterBridgeNullIdentity.test.ts @@ -0,0 +1,177 @@ +import { createServer } from "node:net"; +import { createHmac } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, it } from "vitest"; + +import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js"; + +const SESSION_ID = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; +const TOKEN = "synthetic-cutter-bridge-token-for-test"; +let directory: string | undefined; +let server: ReturnType | undefined; + +afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; +}); + +const listen = async ( + listener: ReturnType, +): Promise => { + await new Promise((resolve) => + listener.listen(0, "127.0.0.1", resolve), + ); + const address = listener.address(); + if (address === null || typeof address === "string") + throw new Error("Expected an ephemeral IPv4 listener"); + return address.port; +}; + +const bridgeServer = ( + currentFile: string | null, + documentGeneration: number, +): ReturnType => { + let ordinal = 0; + return createServer((connection) => { + const connectionOrdinal = ++ordinal; + connection.setEncoding("utf8"); + let input = ""; + let clientNonce = ""; + let serverNonce = ""; + connection.on("data", (chunk: string) => { + input += chunk; + const newline = input.indexOf("\n"); + if (newline < 0) return; + const frame = JSON.parse(input.slice(0, newline)) as Record< + string, + unknown + >; + input = input.slice(newline + 1); + if (frame.kind === "rea-auth-v2") { + clientNonce = String(frame.nonce); + serverNonce = + connectionOrdinal.toString(16).padStart(16, "0") + "d".repeat(48); + connection.write( + `${JSON.stringify({ session_id: SESSION_ID, nonce: serverNonce, proof: createHmac("sha256", TOKEN).update(`rea-cutter-server-v2:${SESSION_ID}:${clientNonce}:${serverNonce}`).digest("hex") })}\n`, + ); + return; + } + const requestBytes = String(frame.request); + const request = JSON.parse(requestBytes) as Record; + const expected = createHmac("sha256", TOKEN) + .update( + `rea-cutter-client-v2:${SESSION_ID}:${clientNonce}:${serverNonce}:${requestBytes}`, + ) + .digest("hex"); + if (frame.proof !== expected) { + connection.destroy(); + return; + } + if (!["status", "command", "ack"].includes(String(request.kind))) + throw new Error(`Unmodeled bridge request: ${String(request.kind)}`); + const response = JSON.stringify( + request.kind === "ack" + ? { + ok: true, + acknowledged: false, + } + : { + ok: true, + session_id: request.session_id, + current_file: currentFile, + document_generation: documentGeneration, + cutter_version: "Cutter fixture version", + identity_status: "partial", + ...(request.kind === "status" + ? { + completed_connection: (connectionOrdinal - 1) + .toString(16) + .padStart(16, "0"), + completed_commands: [], + } + : { execution_state: "complete" }), + }, + ); + const proof = createHmac("sha256", TOKEN) + .update( + `rea-cutter-response-v2:${SESSION_ID}:${clientNonce}:${serverNonce}:${response}`, + ) + .digest("hex"); + connection.end(`${JSON.stringify({ response, proof })}\n`); + }); + }); +}; + +const clientFor = (bridgeDirectory: string): CutterBridgeClient => + process.platform === "win32" + ? new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: bridgeDirectory }, + { + windowsPrivateReader: { + verifyDirectory: () => true, + readDescriptor: (root, name, maxBytes) => { + const bytes = readFileSync(join(root, name)); + if (bytes.byteLength > maxBytes) + throw new Error("Descriptor too large"); + return bytes; + }, + }, + }, + ) + : new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: bridgeDirectory }); + +const writeDescriptor = async (port: number): Promise => { + if (directory === undefined) throw new Error("Test directory is unavailable"); + await writeFile( + join(directory, `cutter-${process.pid}-${SESSION_ID}.json`), + JSON.stringify({ + session_id: SESSION_ID, + pid: process.pid, + host: "127.0.0.1", + port, + token: TOKEN, + document_generation: 1, + current_file: "/old/sample.bin", + cutter_version: "Cutter fixture version", + identity_status: "partial", + }), + { mode: 0o600 }, + ); +}; + +it("preserves a live Cutter status that explicitly has no active document", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-null-status-")); + server = bridgeServer(null, 2); + await writeDescriptor(await listen(server)); + + const result = await clientFor(directory).listSessions(); + + expect(result.sessions[0]).toMatchObject({ + session_id: SESSION_ID, + document_generation: 2, + current_file: null, + }); +}); + +it("preserves a null active document returned after a Cutter command", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-null-command-")); + server = bridgeServer(null, 2); + await writeDescriptor(await listen(server)); + + const result = await clientFor(directory).execute({ + sessionId: SESSION_ID, + expectedGeneration: 2, + command: "ij", + json: true, + }); + + expect(result.currentFile).toBeNull(); +}); diff --git a/tests/boundary/providers/CutterBridgeService.test.ts b/tests/boundary/providers/CutterBridgeService.test.ts new file mode 100644 index 000000000..dd430246d --- /dev/null +++ b/tests/boundary/providers/CutterBridgeService.test.ts @@ -0,0 +1,406 @@ +import { expect, it, vi } from "vitest"; + +import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js"; + +const cutterInput = { + session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + expected_generation: 7, + command: "pd 4", +}; + +it("retains uncertain command completion as Evidence without encouraging a retry", async () => { + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + execute: async () => ({ + requestId: "4b18ce4d-bf31-456e-ae31-e8c547b2f9db", + output: null, + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial", + cutterVersion: "Cutter fixture version", + executionState: "unknown", + error: "transport-response-missing", + message: "The command may have completed; do not retry automatically", + outputTruncated: false, + }), + }); + + const result = await service.execute({ + session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + expected_generation: 7, + command: "Ps /tmp/project.rzdb", + }); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + cleanupIncomplete: true, + partialObservation: { + normalized_result: { + command: "Ps /tmp/project.rzdb", + output: null, + execution_state: "unknown", + error: "transport-response-missing", + message: expect.stringContaining("do not retry automatically"), + document_generation: 7, + cutter_version: "Cutter fixture version", + }, + limitations: expect.arrayContaining([ + "Command completion or its output could not be confirmed; do not retry automatically because the command may have produced partial or persistent effects.", + ]), + provider: { version: "Cutter fixture version" }, + }, + }, + }); +}); + +it("clears a poisoned command only after its exact UI-dispatcher receipt", async () => { + const requestId = "4b18ce4d-bf31-456e-ae31-e8c547b2f9db"; + const wrongId = "8ca93d11-107d-41ed-8b40-31dd53cd26a6"; + let receipt: string | undefined; + let uiPending = true; + let dispatches = 0; + const confirmCommandCompletion = vi.fn( + async (_session: string, id: string) => + receipt === id && !uiPending + ? { ok: true as const, value: true } + : { ok: true as const, value: false }, + ); + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + confirmCommandCompletion, + execute: async () => { + dispatches += 1; + return { + requestId, + output: null, + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial", + cutterVersion: "Cutter fixture version", + executionState: dispatches === 1 ? "unknown" : "complete", + error: dispatches === 1 ? "ui-thread-request-timeout" : null, + message: null, + outputTruncated: false, + }; + }, + }); + + const first = await service.execute(cutterInput); + expect(first).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + + for (const unconfirmed of [ + undefined, + "ede4bbfd-6348-46aa-963b-0de0e78ccbdc", + wrongId, + ]) { + receipt = unconfirmed; + const blocked = await service.execute(cutterInput); + expect(blocked).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(dispatches).toBe(1); + } + receipt = requestId; + const pending = await service.execute(cutterInput); + expect(pending).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(dispatches).toBe(1); + expect(confirmCommandCompletion).toHaveBeenLastCalledWith( + cutterInput.session_id, + requestId, + undefined, + ); + + receipt = requestId; + uiPending = false; + const recovered = await service.execute(cutterInput); + expect(recovered.ok).toBe(true); + expect(dispatches).toBe(2); +}); + +it("recovers canceled command A after B and does not retain poisoned B", async () => { + const requestA = "4b18ce4d-bf31-456e-ae31-e8c547b2f9db"; + const completionTokenA = "0000000000000007"; + const requestB = "8ca93d11-107d-41ed-8b40-31dd53cd26a6"; + const receipts = new Set(); + let dispatches = 0; + const confirmCommandCompletion = vi.fn( + async (_session: string, id: string) => + receipts.delete(id) + ? { ok: true as const, value: true } + : { ok: true as const, value: false }, + ); + const execute = async () => { + dispatches += 1; + if (dispatches === 1) { + receipts.add(requestA); + return { + requestId: requestA, + output: null, + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial" as const, + cutterVersion: null, + executionState: "unknown" as const, + error: "transport-response-missing", + message: "cancelled after dispatch", + outputTruncated: false, + completionToken: completionTokenA, + }; + } + if (dispatches === 2) + return { + requestId: requestB, + output: "B complete", + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial" as const, + cutterVersion: null, + executionState: "complete" as const, + error: null, + message: null, + outputTruncated: false, + }; + if (dispatches === 3) + return { + requestId: requestB, + output: null, + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial" as const, + cutterVersion: null, + executionState: "not_started" as const, + error: "session-poisoned-after-ui-timeout", + message: null, + outputTruncated: false, + }; + return { + requestId: "c31130c6-fc6c-46ad-949e-586724d8fd84", + output: "recovered", + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial" as const, + cutterVersion: null, + executionState: "complete" as const, + error: null, + message: null, + outputTruncated: false, + }; + }; + const client = { + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found" as const, + bridge_directory_security: "private_verified" as const, + }), + confirmCommandCompletion, + execute, + }; + const callerA = new CutterBridgeService(client); + const callerB = new CutterBridgeService(client); + + const cancelledA = await callerA.execute(cutterInput); + expect(cancelledA).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(await callerB.execute(cutterInput)).toMatchObject({ ok: true }); + expect(receipts.has(requestA)).toBe(true); + + const poisonedB = await callerA.execute(cutterInput); + expect(poisonedB).toMatchObject({ + ok: true, + value: { normalized_result: { execution_state: "not_started" } }, + }); + expect(confirmCommandCompletion).toHaveBeenCalledTimes(1); + expect(confirmCommandCompletion).toHaveBeenCalledWith( + cutterInput.session_id, + requestA, + completionTokenA, + ); + + expect(await callerA.execute(cutterInput)).toMatchObject({ ok: true }); + expect(confirmCommandCompletion).toHaveBeenCalledTimes(1); + expect(dispatches).toBe(4); +}); + +it("rejects malformed Cutter commands and pre-aborted calls", async () => { + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + execute: async () => { + throw new Error("must not dispatch"); + }, + }); + + const malformed = await service.execute({ ...cutterInput, command: "" }); + expect(malformed).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + const controller = new AbortController(); + controller.abort(); + const cancelled = await service.execute(cutterInput, { + signal: controller.signal, + }); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it.each([ + [new Error("session generation changed"), "AnalysisInputError"], + [new Error("bridge disconnected"), "AnalysisCapabilityUnavailableError"], + ["plain failure", "AnalysisCapabilityUnavailableError"], +])("maps a Cutter bridge failure to %s", async (failure, tag) => { + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + execute: async () => { + throw failure; + }, + }); + + const result = await service.execute(cutterInput); + expect(result).toMatchObject({ ok: false, error: { _tag: tag } }); +}); + +it("preserves cancellation unless the bridge reports an ambiguous completion", async () => { + const createService = (message: string, controller: AbortController) => + new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + execute: async () => { + controller.abort(); + throw new Error(message); + }, + }); + const cancelledController = new AbortController(); + + const cancelled = await createService( + "transport cancelled", + cancelledController, + ).execute(cutterInput, { signal: cancelledController.signal }); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + + const uncertainController = new AbortController(); + const uncertain = await createService( + "command may have completed", + uncertainController, + ).execute(cutterInput, { signal: uncertainController.signal }); + expect(uncertain).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); +}); + +it("shares one cleanup attempt when commands retry the same receipt together", async () => { + const requestId = "4b18ce4d-bf31-456e-ae31-e8c547b2f9db"; + let receiptPresent = false; + let releaseConfirm: (() => void) | undefined; + let confirms = 0; + const confirmCommandCompletion = vi.fn(async () => { + confirms += 1; + if (!receiptPresent) return { ok: true as const, value: false }; + if (confirms === 1) { + await new Promise((resolve) => { + releaseConfirm = resolve; + }); + } + if (!receiptPresent) return { ok: true as const, value: false }; + receiptPresent = false; + return { ok: true as const, value: true }; + }); + let dispatches = 0; + const service = new CutterBridgeService({ + listSessions: async () => ({ + sessions: [], + bridge_directory: "/private/bridge", + discovery_status: "no_live_bridge_found", + bridge_directory_security: "private_verified", + }), + confirmCommandCompletion, + execute: async () => { + dispatches += 1; + if (dispatches === 1) { + receiptPresent = true; + return { + requestId, + output: null, + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial" as const, + cutterVersion: null, + executionState: "unknown" as const, + error: "transport-response-missing", + message: "cancelled after dispatch", + outputTruncated: false, + }; + } + return { + requestId: "8ca93d11-107d-41ed-8b40-31dd53cd26a6", + output: "complete", + currentFile: "/fixtures/sample.bin", + documentGeneration: 7, + identityStatus: "partial" as const, + cutterVersion: null, + executionState: "complete" as const, + error: null, + message: null, + outputTruncated: false, + }; + }, + }); + + const blocked = await service.execute(cutterInput); + expect(blocked).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + + const concurrent = [ + service.execute(cutterInput), + service.execute(cutterInput), + ]; + if (releaseConfirm === undefined) throw new Error("cleanup did not start"); + releaseConfirm(); + const recovered = await Promise.all(concurrent); + expect(recovered.every((result) => result.ok)).toBe(true); + expect(confirms).toBe(1); + expect(dispatches).toBe(3); +}); diff --git a/tests/boundary/providers/GdbCommandQueue.test.ts b/tests/boundary/providers/GdbCommandQueue.test.ts new file mode 100644 index 000000000..f424567df --- /dev/null +++ b/tests/boundary/providers/GdbCommandQueue.test.ts @@ -0,0 +1,145 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, it } from "vitest"; + +import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; + +let directory: string | undefined; +const managers: GdbSessionManager[] = []; + +afterEach(async () => { + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +it("bounds active and queued GDB commands, including cancelled placeholders", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-command-queue-limit-")); + const provider = join(directory, "fake-gdb-queue.mjs"); + const activePath = join(directory, "active-command"); + const releasePath = join(directory, "release-command"); + await writeFile( + provider, + `#!${process.execPath} +import { access, writeFile } from "node:fs/promises"; +process.stdout.write("(gdb)\\n"); +let input = ""; +let firstConsoleCommand = true; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", async (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); + if (!match) continue; + if (line.includes("-interpreter-exec console") && firstConsoleCommand) { + firstConsoleCommand = false; + await writeFile(${JSON.stringify(activePath)}, "active"); + for (;;) { + try { await access(${JSON.stringify(releasePath)}); break; } + catch { await new Promise((resolve) => setTimeout(resolve, 10)); } + } + } + process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const active = manager.execute(started.value.session_id, "hold"); + let activeSeen = false; + for (let attempt = 0; attempt < 100 && !activeSeen; attempt += 1) { + try { + await readFile(activePath, "utf8"); + activeSeen = true; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + expect(activeSeen).toBe(true); + + const controllers = Array.from({ length: 15 }, () => new AbortController()); + const queued = controllers.map((controller, index) => + manager.execute( + started.value.session_id, + `queued-${index}`, + controller.signal, + ), + ); + const safetyRelease = setTimeout(() => { + void writeFile(releasePath, "safety release"); + }, 250); + const overflow = await manager.execute(started.value.session_id, "overflow"); + clearTimeout(safetyRelease); + expect(overflow).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + operation: "gdb_console", + resource: "transport", + reportedLimits: { + max_pending_commands: 16, + active_or_queued_commands: 16, + }, + }, + }); + + for (const controller of controllers) controller.abort(); + const cancelled = await Promise.all(queued); + expect(cancelled).toHaveLength(15); + expect( + cancelled.every( + (result) => !result.ok && result.error._tag === "AnalysisCancelledError", + ), + ).toBe(true); + const cancellationSafetyRelease = setTimeout(() => { + void writeFile(releasePath, "safety release after cancellation"); + }, 250); + try { + await expect( + manager.execute( + started.value.session_id, + "cannot-bypass-cancelled-placeholders", + ), + ).resolves.toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + reportedLimits: { active_or_queued_commands: 16 }, + }, + }); + } finally { + clearTimeout(cancellationSafetyRelease); + } + await writeFile(releasePath, "release"); + expect((await active).ok).toBe(true); + const admittedAfterDrain = await manager.execute( + started.value.session_id, + "admitted-after-drain", + ); + expect(admittedAfterDrain.ok).toBe(true); + await expect( + manager.execute(started.value.session_id, "after-drain"), + ).resolves.toMatchObject({ + ok: true, + value: { result: { completion_status: "complete" } }, + }); +}); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts new file mode 100644 index 000000000..fc5089989 --- /dev/null +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -0,0 +1,2922 @@ +import { EventEmitter } from "node:events"; +import { spawn } from "node:child_process"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; + +import { afterEach, expect, it } from "vitest"; + +import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js"; +import { + commandMayChangeInferiorOutput, + gdbErrorMessage, + gdbThreadGroupCount, + GdbSessionManager, + miStringField, + waitForGdbCommandTurn, +} from "../../../src/gdb/GdbSessionManager.js"; +import type { + OwnedProviderProcessSpawnOptions, + ProviderProcessHandle, + SpawnedOwnedProviderProcess, +} from "../../../src/process/ProviderProcess.js"; +import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; +import { parseLinuxProcessStartTime } from "../../../src/process/ProcessOwnershipObservation.js"; +import { expectGdbSessionStarted } from "./gdbTestHelpers.js"; + +let directory: string | undefined; +const managers: GdbSessionManager[] = []; + +it.each([ + ["empty list", "1^done,groups=[]", 0], + ["one group", '1^done,groups=[{id="i1",pid="123"}]', 1], + [ + "multiple groups", + '1^done,groups=[{id="i1",pid="123"},{id="i2",pid="456"}]', + 2, + ], + ["group without a live pid", '1^done,groups=[{id="i1"}]', 0], + [ + "nested group-like object", + '1^done,groups=[{id="i1",pid="123",children=[{id="i2"}]}]', + 1, + ], + [ + "quoted brackets", + '1^done,groups=[{id="i1",pid="123",description="[{}]"}]', + 1, + ], + [ + "escaped quote and backslash", + '1^done,groups=[{id="i1",pid="123",description="\\\\\\\""}]', + 1, + ], + [ + "nested groups field", + '1^done,groups=[{id="i1",extra="groups=["}]', + undefined, + ], + ["negative object depth", "1^done,groups=[}]", undefined], + ["unclosed list", '1^done,groups=[{id="i1"}', undefined], + ["missing groups field", "1^done", undefined], +] as const)("parses recursive GDB MI groups: %s", (_label, mi, expected) => { + expect(gdbThreadGroupCount(mi)).toBe(expected); +}); + +it.each([ + ['1^done,inferior_tty_terminal="/dev/pts/2"', "/dev/pts/2"], + ['1^done,inferior_tty_terminal="line\\nnext"', "line\nnext"], + ['1^done,inferior_tty_terminal="\\x41\\101"', "AA"], + ['1^done,inferior_tty_terminal="\\7"', "\u0007"], + ['1^done,inferior_tty_terminal="emoji 🚀"', "emoji 🚀"], + ['1^done,inferior_tty_terminal="\\xFF"', undefined], + ['1^done,inferior_tty_terminal="\\x"', undefined], + ['1^done,inferior_tty_terminal="\\q"', undefined], + ['1^done,inferior_tty_terminal="/dev/pts/2"junk', undefined], + ['1^done,inferior_tty_terminal="a",inferior_tty_terminal="a"', undefined], + ['1^error,msg="not done"', undefined], + ['1^done,inferior_tty_terminal="\ud800"', undefined], +] as const)("decodes GDB MI C-string fields: %s", (mi, expected) => { + expect(miStringField(mi, "inferior_tty_terminal")).toBe(expected); +}); + +it.each([ + ["show version", false], + ["set pagination off", false], + ["set inferior-t /tmp/output", true], + ["inferior-tty /tmp/output", true], + ["tty /dev/pts/2", true], + ["source commands.gdb", true], + ["python print(1)", true], + ["define start\nrun", true], + ["run > /tmp/output", true], +] as const)("classifies inferior output mutations: %s", (command, expected) => { + expect(commandMayChangeInferiorOutput(command)).toBe(expected); +}); + +it.each([ + [new Error("provider failure"), "provider failure"], + ["non-error provider failure", "non-error provider failure"], +] as const)("formats unknown GDB boundary failures", (cause, message) => { + expect(gdbErrorMessage(cause)).toBe(message); +}); + +it("rejects an invalid caller TTY and platforms without safe GDB ownership", async () => { + const invalid = await new GdbSessionManager().start(undefined, { + inferiorTty: "bad\0tty", + }); + expect(invalid).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + + let macSpawned = false; + const macos = new GdbSessionManager({ + platform: "darwin", + spawnProcess: async () => { + macSpawned = true; + throw new Error("unsupported hosts must not spawn GDB"); + }, + }); + managers.push(macos); + const macUnavailable = await macos.start(); + expect(macUnavailable).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + if (!macUnavailable.ok) + expect(macUnavailable.error.message).toContain("available only on Linux"); + expect(macSpawned).toBe(false); + + const windows = new GdbSessionManager({ platform: "win32" }); + managers.push(windows); + const unavailable = await windows.start(); + expect(unavailable).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); +}); + +class FakeGdbProcess extends EventEmitter implements ProviderProcessHandle { + readonly pid = process.pid + 1; + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly stdin: PassThrough | null; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + readonly killSignals: NodeJS.Signals[] = []; + + constructor(stdinEnabled = true) { + super(); + this.stdin = stdinEnabled ? new PassThrough() : null; + } + + kill(signal: NodeJS.Signals): boolean { + this.killSignals.push(signal); + this.exit(null, signal); + return true; + } + + exit(code: number | null, signal: NodeJS.Signals | null): void { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = code; + this.signalCode = signal; + this.stdout?.end(); + this.stderr.end(); + this.stdin?.destroy(); + this.emit("exit", code, signal); + this.emit("close", code, signal); + } + + exitLeader(code: number): void { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = code; + this.emit("exit", code, null); + } + + closeInheritedOutput(): void { + this.stdout.end(); + this.stderr.end(); + this.stdin?.destroy(); + this.emit("close", this.exitCode, this.signalCode); + } +} + +const createFakeSpawner = ( + respond: (process: FakeGdbProcess, token: string, command: string) => void = ( + child, + token, + command, + ) => + child.stdout.write( + command.includes("list-thread-groups") + ? `${token}^done,groups=[]\n` + : `${token}^done\n`, + ), + options: { + readonly stdinEnabled?: boolean; + readonly exitAfterPrompt?: boolean; + readonly exitOnFirstStdoutData?: boolean; + readonly startupExitCode?: number | null; + readonly startupExitSignal?: NodeJS.Signals | null; + readonly stdoutNull?: boolean; + readonly deferPrompt?: boolean; + readonly afterPrompt?: () => void; + readonly afterSpawn?: () => void; + } = {}, +): { + readonly spawnProcess: ( + spawnOptions: OwnedProviderProcessSpawnOptions, + ) => Promise; + readonly children: FakeGdbProcess[]; +} => { + const children: FakeGdbProcess[] = []; + return { + children, + spawnProcess: async (spawnOptions) => { + const child = new FakeGdbProcess(options.stdinEnabled ?? true); + children.push(child); + if (options.stdoutNull) + Object.defineProperty(child, "stdout", { value: null }); + if (options.deferPrompt) { + setImmediate(() => { + child.stdout.write("(gdb)\n"); + options.afterPrompt?.(); + }); + } else if (!options.stdoutNull) child.stdout.write("(gdb)\n"); + if (options.exitOnFirstStdoutData) + child.stdout.once("data", () => + queueMicrotask(() => + child.exit( + options.startupExitCode === undefined + ? 0 + : options.startupExitCode, + options.startupExitSignal === undefined + ? null + : options.startupExitSignal, + ), + ), + ); + child.stdin?.setEncoding("utf8"); + child.stdin?.on("data", (chunk: string | Buffer) => { + for (const line of String(chunk).split("\n")) { + const match = /^(\d+)(.*)$/u.exec(line); + if (match !== null) respond(child, match[1] ?? "", match[2] ?? ""); + } + }); + if (options.exitAfterPrompt) queueMicrotask(() => child.exit(0, null)); + options.afterSpawn?.(); + return { + process: child, + ownership: { + runId: spawnOptions.runId, + leaderPid: child.pid, + processGroupId: child.pid, + expectedParentPid: process.pid, + }, + }; + }, + }; +}; + +const recordingInferiorListResponder = (groups: string) => { + const commands: string[] = []; + return { + commands, + respond: (child: FakeGdbProcess, token: string, command: string): void => { + commands.push(command); + child.stdout.write( + command.includes("list-thread-groups") + ? `${token}^done,groups=${groups}\n` + : `${token}^done\n`, + ); + }, + }; +}; + +it("returns cancellation when GDB startup is aborted after spawn", async () => { + const controller = new AbortController(); + const fake = createFakeSpawner(undefined, { + afterSpawn: () => controller.abort(), + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(controller.signal); + + expect(started.ok).toBe(false); + if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError"); + expect(fake.children[0]?.signalCode).toBe("SIGTERM"); +}); + +it("cancels immediately after the startup prompt before sending configuration commands", async () => { + const controller = new AbortController(); + const commands: string[] = []; + const fake = createFakeSpawner( + (child, token, command) => { + commands.push(command); + child.stdout.write(`${token}^done\n`); + }, + { + deferPrompt: true, + afterPrompt: () => controller.abort(), + }, + ); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(controller.signal); + + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(commands).toEqual([]); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("rejects a GDB exit queued with the final startup response", async () => { + const fake = createFakeSpawner((child, token, command) => { + child.stdout.write(`${token}^done\n`); + if (command.includes("auto-load off")) + setImmediate(() => child.exit(7, null)); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await manager.start(); + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect(fake.children[0]?.exitCode).toBe(7); +}); + +it("cancels startup when aborted during the final readiness drain", async () => { + const controller = new AbortController(); + const commands: string[] = []; + const fake = createFakeSpawner((child, token, command) => { + commands.push(command); + child.stdout.write(`${token}^done\n`); + if (command.includes("auto-load off")) + setImmediate(() => controller.abort()); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(controller.signal); + + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(commands).toEqual(["-gdb-set auto-load off"]); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("preserves cancellation and cleanup details when startup stop is uncertain", async () => { + const controller = new AbortController(); + let stopCalls = 0; + const fake = createFakeSpawner(undefined, { + deferPrompt: true, + afterPrompt: () => controller.abort(), + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async (session, name) => { + stopCalls += 1; + return stopCalls === 1 + ? { + status: "incomplete", + reason: "simulated startup cancellation cleanup uncertainty", + } + : stopOwnedDebuggerProcess(session, name); + }, + }); + + const started = await manager.start(controller.signal); + + expect(started).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "start_gdb_session", + cleanup: { reason: "simulated startup cancellation cleanup uncertainty" }, + }, + }); +}); + +it("classifies cancellation while asynchronous GDB ownership preparation is pending", async () => { + const controller = new AbortController(); + let entered!: () => void; + const spawnEntered = new Promise((resolve) => { + entered = resolve; + }); + const manager = new GdbSessionManager({ + platform: "linux", + environment: {}, + spawnProcess: async (options) => + new Promise((_resolve, reject) => { + entered(); + options.signal?.addEventListener( + "abort", + () => reject(new Error("spawn preparation aborted")), + { once: true }, + ); + }), + }); + const starting = manager.start(controller.signal); + await spawnEntered; + controller.abort(); + const started = await starting; + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it("drains active GDB commands and rejects new commands before closeAll cleanup", async () => { + let heldToken = ""; + let heldCommand = ""; + const fake = createFakeSpawner((_child, token, command) => { + if (command.includes("held command")) { + heldToken = token; + heldCommand = command; + } else + _child.stdout.write( + command.includes("list-thread-groups") + ? `${token}^done,groups=[]\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const active = manager.execute(started.value.session_id, "held command"); + await expect.poll(() => heldToken).not.toBe(""); + const closing = manager.closeAll(); + const rejected = await manager.execute( + started.value.session_id, + "late command", + ); + expect(rejected.ok).toBe(false); + const duplicateClose = await manager.close(started.value.session_id); + expect(duplicateClose).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", operation: "close_gdb_session" }, + }); + expect(fake.children[0]?.killSignals).toEqual([]); + fake.children[0]?.stdout.write(`${heldToken}^done\n`); + expect((await active).ok).toBe(true); + await closing; + expect(heldCommand).toContain("held command"); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("enforces the per-session queue capacity while a GDB command is active", async () => { + let heldToken = ""; + const fake = createFakeSpawner((child, token, command) => { + if (command.includes("held-for-capacity")) heldToken = token; + else if (command.includes("list-thread-groups")) + child.stdout.write(`${token}^done,groups=[]\n`); + else child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const held = manager.execute(started.value.session_id, "held-for-capacity"); + await expect.poll(() => heldToken).not.toBe(""); + const queued = Array.from({ length: 15 }, (_, index) => + manager.execute(started.value.session_id, `queued-${index}`), + ); + const overCapacity = await manager.execute( + started.value.session_id, + "over-capacity", + ); + expect(overCapacity).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + + fake.children[0]?.stdout.write(`${heldToken}^done\n`); + expect((await held).ok).toBe(true); + expect((await Promise.all(queued)).every((result) => result.ok)).toBe(true); + expect((await manager.close(started.value.session_id)).ok).toBe(true); + expect(manager.status(started.value.session_id)).toBeUndefined(); +}); + +it("reports invalid UTF-8 in retained GDB stderr status", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + fake.children[0]?.stderr.write(Buffer.from([0xff])); + await new Promise((resolve) => setImmediate(resolve)); + expect(manager.status(started.value.session_id)).toMatchObject({ + stderr: "�", + stderr_invalid_utf8: true, + recent_mi_records_truncated: false, + }); +}); + +it("reports invalid UTF-8 in retained GDB MI status", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + fake.children[0]?.stdout.write(Buffer.from([0xff, 0x0a])); + await new Promise((resolve) => setImmediate(resolve)); + + expect(manager.status(started.value.session_id)).toMatchObject({ + recent_mi_records: ["(gdb)", "1^done", "�"], + mi_invalid_utf8: true, + }); +}); + +it.each([ + ["error", "GDB stdin stream failed: simulated stdin failure"], + ["close", "GDB stdin stream closed unexpectedly."], +] as const)( + "fails pending GDB commands when stdin emits %s", + async (event, message) => { + const fake = createFakeSpawner((child, token, command) => { + if (!command.includes("held-command")) + child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const stdin = fake.children[0]?.stdin; + if (stdin === null || stdin === undefined) return; + const commandSent = new Promise((resolve) => + stdin.once("data", () => resolve()), + ); + const pending = manager.execute(started.value.session_id, "held-command"); + await commandSent; + + if (event === "error") + stdin.emit(event, new Error("simulated stdin failure")); + else stdin.emit(event); + + let timeout: ReturnType | undefined; + const settled = await Promise.race([ + pending.then((result) => ({ result })), + new Promise<{ readonly timeout: true }>((resolve) => { + timeout = setTimeout(() => resolve({ timeout: true }), 150); + }), + ]); + clearTimeout(timeout); + if ("timeout" in settled) { + const child = fake.children[0]; + child?.exitLeader(9); + child?.closeInheritedOutput(); + await pending; + throw new Error(`GDB stdin ${event} did not settle the pending command`); + } + const failed = settled.result; + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error.message).toContain(message); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it("reports process exit rather than protocol failure when stdin closes afterward", async () => { + const fake = createFakeSpawner((child, token, command) => { + if (!command.includes("held-command")) + child.stdout.write(`${token}^done\n`); + }); + let stopCalls = 0; + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => { + stopCalls += 1; + return { status: "stopped" }; + }, + }); + const started = await expectGdbSessionStarted(manager); + const child = fake.children[0]; + const stdin = child?.stdin; + if (child === undefined || stdin === null || stdin === undefined) return; + const commandSent = new Promise((resolve) => + stdin.once("data", () => resolve()), + ); + const pending = manager.execute(started.value.session_id, "held-command"); + await commandSent; + + child.exitLeader(7); + stdin.emit("close"); + child.stdout.emit("end"); + + let timeout: ReturnType | undefined; + const settled = await Promise.race([ + pending.then((result) => ({ result })), + new Promise<{ readonly timeout: true }>((resolve) => { + timeout = setTimeout(() => resolve({ timeout: true }), 150); + }), + ]); + clearTimeout(timeout); + if ("timeout" in settled) { + child.closeInheritedOutput(); + await pending; + throw new Error("GDB process exit did not settle the command promptly"); + } + const failed = settled.result; + + expect(failed).toMatchObject({ + ok: true, + value: { + result: { + process_exit_observed: true, + process_exit_code: 7, + completion_status: "unknown", + }, + evidence: { + limitations: expect.arrayContaining([ + "GDB exited before returning a correlated MI result record; only records retained for the active command are returned raw.", + ]), + }, + }, + }); + // Process-exit Evidence is returned only after the owned debugger stop is + // confirmed; stdin closing afterward must not bypass cleanup settlement. + expect(stopCalls).toBe(1); +}); + +it("retains partial GDB command Evidence while an inherited writer remains", async () => { + let writerActive = false; + const fake = createFakeSpawner((_child, _token, command) => { + if (command.includes("held-writer-command")) return; + _child.stdout.write(`${_token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => ({ + status: "incomplete", + reason: writerActive + ? "an inherited output writer remains active" + : "the owned GDB process was not confirmed stopped", + }), + }); + const started = await expectGdbSessionStarted(manager); + const child = fake.children[0]; + if (child === undefined || child.stdin === null) return; + const commandSent = new Promise((resolve) => + child.stdin!.once("data", () => resolve()), + ); + const pending = manager.execute( + started.value.session_id, + "held-writer-command", + ); + await commandSent; + + writerActive = true; + child.stdout.write("~partial output from owned writer\n"); + child.exitLeader(7); + const result = await pending; + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + cleanupIncomplete: true, + cleanup: { + reason: "an inherited output writer remains active", + resources: [started.value.session_id], + }, + partialObservation: { + normalized_result: { + process_exit_observed: true, + completion_status: "unknown", + }, + raw_result: { process_exit_observed: true }, + }, + }, + }); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + child.stdout.write("~late writer output\n"); + writerActive = false; + child.closeInheritedOutput(); +}); + +it("converts non-Error queue cancellation failures into a tagged error", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const signal = { + aborted: false, + addEventListener: () => { + throw "opaque listener failure"; + }, + removeEventListener: () => undefined, + } as unknown as AbortSignal; + + const result = await manager.execute( + started.value.session_id, + "show version", + signal, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect((await manager.close(started.value.session_id)).ok).toBe(true); +}); + +it("waits for GDB stdout settlement after a no-inferior exit without re-entering MI", async () => { + const commands: string[] = []; + const fake = createFakeSpawner((child, token, command) => { + commands.push(command); + if (command.includes("list-thread-groups")) + child.stdout.write(`${token}^done,groups=[]\n`); + else if (command.includes("gdb-exit")) { + child.stdout.write(`${token}^exit\n`); + queueMicrotask(() => child.exit(0, null)); + } else child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const closed = await manager.close(started.value.session_id); + expect(closed).toMatchObject({ + ok: true, + value: { target_state: "none_observed" }, + }); + expect( + commands.filter((command) => command.includes("list-thread-groups")), + ).toHaveLength(1); + expect(commands).toContain("-gdb-exit"); +}); + +it("reports incomplete stop if GDB exits while close waits in the serialized queue", async () => { + let heldToken = ""; + const fake = createFakeSpawner((child, token, command) => { + if (command.includes("held-before-close")) heldToken = token; + else if (command.includes("list-thread-groups")) + child.stdout.write(`${token}^done,groups=[]\n`); + else child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => ({ + status: "incomplete", + reason: "GDB exited before queued close could verify cleanup", + }), + }); + const started = await expectGdbSessionStarted(manager); + const active = manager.execute(started.value.session_id, "held-before-close"); + await expect.poll(() => heldToken).not.toBe(""); + const closing = manager.close(started.value.session_id); + fake.children[0]?.exitLeader(9); + const activeResult = await active; + expect(activeResult).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + cleanupIncomplete: true, + partialObservation: expect.objectContaining({ + normalized_result: expect.objectContaining({ + process_exit_observed: true, + }), + }), + }, + }); + + const closed = await closing; + + expect(closed).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + if (!closed.ok) + expect(closed.error).toMatchObject({ + providerId: "gdb", + operation: "close_gdb_session", + }); +}); + +it("configures and verifies the caller-selected inferior TTY before reporting ready", async () => { + const tty = '/dev/pts/42 "🚀"'; + const miTty = tty.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + const commands: string[] = []; + const fake = createFakeSpawner((_child, token, command) => { + commands.push(command); + if (command.includes("inferior-tty-show")) + _child.stdout.write(`${token}^done,inferior_tty_terminal="${miTty}"\n`); + else if (command.includes("list-thread-groups")) + _child.stdout.write(`${token}^done,groups=[]\n`); + else _child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(undefined, { inferiorTty: tty }); + + expect(started.ok).toBe(true); + if (!started.ok) return; + const status = manager.status(started.value.session_id); + expect(status).toMatchObject({ + inferior_tty: tty, + inferior_tty_verified: true, + }); + expect( + commands.indexOf(`-inferior-tty-set ${JSON.stringify(tty)}`), + ).toBeGreaterThan(-1); + expect(commands.indexOf("-inferior-tty-show")).toBeGreaterThan( + commands.indexOf(`-inferior-tty-set ${JSON.stringify(tty)}`), + ); +}); + +it("rejects a GDB leader exit without a verified sink and retains inherited output", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const child = fake.children[0]; + if (child === undefined) return; + child.exitLeader(7); + + const closed = await manager.close(started.value.session_id); + + expect(closed.ok).toBe(false); + expect(manager.status(started.value.session_id)).toMatchObject({ + state: "closed", + inferior_tty: null, + inferior_tty_verified: false, + }); + expect(child.killSignals).toEqual([]); + child.stdout.write("=thread-group-exited,id=descendant-sentinel\n"); + child.closeInheritedOutput(); + expect(manager.status(started.value.session_id)?.recent_mi_records).toContain( + "=thread-group-exited,id=descendant-sentinel", + ); +}); + +it("does not infer detached state when the inferior verifier observes an exited GDB", async () => { + let detachFailure = ""; + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async (session) => { + const debuggerSession = session as typeof session & { + detachInferior: () => Promise; + }; + try { + await debuggerSession.detachInferior(); + } catch (cause) { + detachFailure = cause instanceof Error ? cause.message : String(cause); + } + return { + status: "incomplete", + reason: "the inferior state remained unknown", + }; + }, + }); + const started = await expectGdbSessionStarted(manager); + expect( + (await manager.execute(started.value.session_id, "set pagination off")).ok, + ).toBe(true); + fake.children[0]?.exitLeader(7); + + const closed = await manager.close(started.value.session_id); + + expect(closed.ok).toBe(false); + expect(detachFailure).toBe( + "GDB exited before inferior state could be verified.", + ); +}); + +it("retains the GDB session while an unknown descendant holds output after leader exit", async () => { + directory ??= await mkdtemp(join(tmpdir(), "rea-gdb-coverage-")); + const tty = join(directory, "inferior-output"); + await writeFile(tty, ""); + const fake = createFakeSpawner((_child, token, command) => { + if (command.includes("inferior-tty-show")) + fake.children[0]?.stdout.write( + `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`, + ); + else if (command.includes("list-thread-groups")) + fake.children[0]?.stdout.write(`${token}^done,groups=[]\n`); + else fake.children[0]?.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager, undefined, { + inferiorTty: tty, + }); + const child = fake.children[0]; + if (child === undefined) return; + expect(manager.status(started.value.session_id)?.inferior_tty_verified).toBe( + true, + ); + child.exitLeader(7); + + const firstClose = await manager.close(started.value.session_id); + expect(firstClose.ok).toBe(false); + if (!firstClose.ok) { + expect(firstClose.error.cleanupIncomplete).toBe(true); + expect(firstClose.error.message).toMatch(/inferior disposition/i); + } + expect(manager.status(started.value.session_id)).toBeDefined(); + await writeFile(tty, "descendant sentinel after GDB leader exit\n"); + child.closeInheritedOutput(); + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(await readFile(tty, "utf8")).toBe( + "descendant sentinel after GDB leader exit\n", + ); + expect(manager.status(started.value.session_id)).toBeDefined(); + expect(child.killSignals).toEqual([]); +}); + +it("retries close after an exited GDB's inherited output streams settle", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const child = fake.children[0]; + if (child === undefined) return; + + child.exitLeader(3); + const firstClose = await manager.close(started.value.session_id); + expect(firstClose.ok).toBe(false); + if (!firstClose.ok) + expect(firstClose.error.message).toMatch(/inferior disposition/i); + expect(manager.status(started.value.session_id)).toBeDefined(); + + child.closeInheritedOutput(); + await expect(manager.close(started.value.session_id)).resolves.toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(manager.status(started.value.session_id)).toBeDefined(); +}); + +it("shares a single stop attempt across concurrent closes after GDB exits", async () => { + let resolveStop!: (result: { status: "incomplete"; reason: string }) => void; + let stopCalls = 0; + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => { + stopCalls += 1; + if (stopCalls > 1) + return { + status: "incomplete", + reason: "shared stop attempt is uncertain", + }; + return await new Promise((resolve) => { + resolveStop = resolve; + }); + }, + }); + const started = await expectGdbSessionStarted(manager); + fake.children[0]?.exitLeader(7); + + const firstClose = manager.close(started.value.session_id); + await expect.poll(() => stopCalls).toBe(1); + const secondClose = manager.close(started.value.session_id); + await new Promise((resolve) => setImmediate(resolve)); + expect(stopCalls).toBe(1); + resolveStop({ + status: "incomplete", + reason: "shared stop attempt is uncertain", + }); + + const [first, second] = await Promise.all([firstClose, secondClose]); + expect(first.ok).toBe(false); + expect(second.ok).toBe(false); + expect(stopCalls).toBe(1); +}); + +it("rejects inferior TTY configuration when GDB reports a different terminal", async () => { + const fake = createFakeSpawner((child, token, command) => { + child.stdout.write( + command.includes("inferior-tty-show") + ? `${token}^done,inferior_tty_terminal="/dev/pts/other"\n` + : command.includes("list-thread-groups") + ? `${token}^done,groups=[]\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(undefined, { + inferiorTty: "/dev/pts/requested", + }); + + expect(started.ok).toBe(false); + if (!started.ok) + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect( + fake.children.every((child) => child.killSignals.includes("SIGTERM")), + ).toBe(true); +}); + +it("rejects an inferior TTY when GDB does not confirm the setting command", async () => { + const commands: string[] = []; + const fake = createFakeSpawner((child, token, command) => { + commands.push(command); + child.stdout.write( + command.includes("inferior-tty-set") + ? `${token}^error,msg="unsupported"\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(undefined, { + inferiorTty: "/dev/pts/requested", + }); + + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect(commands).toContain('-inferior-tty-set "/dev/pts/requested"'); + expect(commands).not.toContain("-inferior-tty-show"); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("rejects a truncated inferior TTY readback before publishing the session", async () => { + const tty = `/dev/pts/${"x".repeat(200)}`; + const fake = createFakeSpawner((child, token, command) => { + child.stdout.write( + command.includes("inferior-tty-show") + ? `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + maxCommandOutputBytes: 80, + }); + + const started = await manager.start(undefined, { inferiorTty: tty }); + + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it.each([ + ["trailing garbage", 'inferior_tty_terminal="/dev/pts/42"junk'], + [ + "duplicate field", + 'inferior_tty_terminal="/dev/pts/42",inferior_tty_terminal="/dev/pts/42"', + ], +])("rejects MI inferior TTY readback with %s", async (_label, fields) => { + const fake = createFakeSpawner((child, token, command) => { + child.stdout.write( + command.includes("inferior-tty-show") + ? `${token}^done,${fields}\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await manager.start(undefined, { + inferiorTty: "/dev/pts/42", + }); + expect(started.ok).toBe(false); + if (!started.ok) + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); +}); + +const managerWithFakeSpawner = ( + spawnProcess: ( + options: OwnedProviderProcessSpawnOptions, + ) => Promise, + options: ConstructorParameters[0] = {}, +): GdbSessionManager => { + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: "fake-gdb" }, + spawnProcess, + verifyInferiorOutputSink: async () => undefined, + ...options, + }); + managers.push(manager); + return manager; +}; + +const startWithVerifiedInferiorTty = async ( + tty: string, +): Promise<{ + readonly manager: GdbSessionManager; + readonly sessionId: string; + readonly commands: string[]; +}> => { + const commands: string[] = []; + const fake = createFakeSpawner((_child, token, command) => { + commands.push(command); + const response = command.includes("inferior-tty-show") + ? `^done,inferior_tty_terminal=${JSON.stringify(tty)}` + : command.includes("list-thread-groups") + ? '^done,groups=[{id="i1",pid="123"}]' + : "^done"; + fake.children[0]?.stdout.write(`${token}${response}\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await manager.start(undefined, { inferiorTty: tty }); + if (!started.ok) throw new Error("GDB did not start with its selected TTY"); + return { manager, sessionId: started.value.session_id, commands }; +}; + +const createProvider = async (body: string): Promise => { + directory ??= await mkdtemp(join(tmpdir(), "rea-gdb-coverage-")); + const provider = join(directory, `fake-gdb-${managers.length}.mjs`); + await writeFile(provider, `#!${process.execPath}\n${body}`); + await chmod(provider, 0o700); + return provider; +}; + +const managerFor = async ( + body: string, + options: ConstructorParameters[0] = {}, +): Promise => { + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: await createProvider(body) }, + ...options, + }); + managers.push(manager); + return manager; +}; + +const responsiveProvider = ( + handler = "if (match[2].includes('list-thread-groups')) process.stdout.write(match[1] + '^done,groups=[]\\n'); else process.stdout.write(match[1] + '^done\\n');", +): string => ` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + ${handler} + } +}); +`; + +afterEach(async () => { + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +it("rejects a pre-cancelled GDB start and an unconfigured executable", async () => { + const cancelled = new AbortController(); + cancelled.abort(); + const startResult = await new GdbSessionManager({ platform: "linux" }).start( + cancelled.signal, + ); + expect(startResult.ok).toBe(false); + if (!startResult.ok) + expect(startResult.error._tag).toBe("AnalysisCancelledError"); + + const missing = await new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: join(tmpdir(), "does-not-exist-gdb") }, + }).start(); + expect(missing.ok).toBe(false); + if (!missing.ok) { + expect(missing.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(missing.error.message).toContain("does-not-exist-gdb"); + } +}); + +it("retries MI2 after an MI3 process exits before its startup prompt", async () => { + const provider = await createProvider(` +if (process.argv.includes('--interpreter=mi3')) process.exit(0); +${responsiveProvider()} +`); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started.ok).toBe(true); + if (started.ok) expect(started.value.mi_version).toBe("mi2"); +}); + +it("retains ownership when GDB startup fails before its prompt and stop is incomplete", async () => { + let stopCalls = 0; + let retainedSessionId: string | undefined; + const provider = await createProvider("process.exit(0);\n"); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) { + retainedSessionId = (session as typeof session & { id: string }).id; + return { status: "incomplete", reason: "simulated stop uncertainty" }; + } + return stopOwnedDebuggerProcess(session, name); + }, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(started.error.message).toContain("simulated stop uncertainty"); + } + expect(stopCalls).toBe(1); + expect(manager.status(retainedSessionId ?? "")?.state).toBe("closed"); +}); + +it("uses a fallback message when the process launcher rejects without an Error", async () => { + let attempts = 0; + const manager = managerWithFakeSpawner(async () => { + attempts += 1; + return Promise.reject("non-error launch failure"); + }); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(started.error.message).toContain("Unable to start GDB"); + } + expect(attempts).toBe(2); +}); + +it("returns startup timeout when the MI prompt never arrives", async () => { + const provider = await createProvider( + "process.stdin.resume(); setInterval(() => {}, 1000);\n", + ); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + startupTimeoutMs: 20, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisTimeoutError"); + expect(started.error).toBeInstanceOf(AnalysisTimeoutError); + if (started.error instanceof AnalysisTimeoutError) + expect(started.error.operation).toBe("start_gdb_session"); + } +}); + +it("handles a provider with no MI stdout stream as a startup timeout", async () => { + const fake = createFakeSpawner(undefined, { stdoutNull: true }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + startupTimeoutMs: 15, + }); + + const started = await manager.start(); + + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisTimeoutError", operation: "start_gdb_session" }, + }); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("preserves a timeout while startup waits for the automatic-loading response", async () => { + const fake = createFakeSpawner((_child, _token, _command) => undefined); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + commandTimeoutMs: 25, + }); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisTimeoutError"); + expect(started.error).toBeInstanceOf(AnalysisTimeoutError); + if (started.error instanceof AnalysisTimeoutError) + expect(started.error.operation).toBe("start_gdb_session"); + } + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("maps an acknowledged startup-command timeout to the startup operation", async () => { + const fake = createFakeSpawner((_child, _token, _command) => undefined); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + commandTimeoutMs: 20, + stopProcess: async () => ({ status: "stopped" }), + }); + + const started = await manager.start(); + + expect(started).toMatchObject({ + ok: false, + error: { _tag: "AnalysisTimeoutError", operation: "start_gdb_session" }, + }); +}); + +it("retains startup timeout without partial evidence when cleanup is uncertain", async () => { + let stopCalls = 0; + const provider = await createProvider( + "process.stdin.resume(); setInterval(() => {}, 1000);\n", + ); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + startupTimeoutMs: 20, + stopProcess: async (session, name) => { + stopCalls += 1; + return stopCalls === 1 + ? { + status: "incomplete", + reason: "simulated startup timeout cleanup uncertainty", + } + : stopOwnedDebuggerProcess(session, name); + }, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisTimeoutError", + operation: "start_gdb_session", + cleanup: { reason: "simulated startup timeout cleanup uncertainty" }, + }, + }); + if (!started.ok) + expect( + (started.error as AnalysisTimeoutError & { partialObservation?: unknown }) + .partialObservation, + ).toBeUndefined(); +}); + +it("maps startup command timeout and preserves initial no-inferior evidence when cleanup is uncertain", async () => { + let detachResult: unknown; + let retainedSessionId = ""; + const fake = createFakeSpawner((_child, _token, _command) => undefined); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + commandTimeoutMs: 25, + stopProcess: async (session) => { + retainedSessionId = (session as typeof session & { id: string }).id; + const debuggerSession = session as typeof session & { + detachInferior: () => Promise; + }; + detachResult = await debuggerSession.detachInferior(); + return { + status: "incomplete", + reason: "simulated startup cleanup uncertainty", + }; + }, + }); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error).toBeInstanceOf(AnalysisTimeoutError); + if (started.error instanceof AnalysisTimeoutError) + expect(started.error.operation).toBe("start_gdb_session"); + } + expect(detachResult).toEqual({ status: "no-inferior" }); + expect(retainedSessionId).not.toBe(""); + expect(manager.status(retainedSessionId)?.state).toBe("closed"); +}); + +it("handles a start signal that becomes aborted during listener registration", async () => { + const provider = await createProvider(responsiveProvider()); + const controller = new AbortController(); + let registrations = 0; + const signal = new Proxy(controller.signal, { + get(target, property) { + if (property === "addEventListener") + return (...arguments_: Parameters) => { + registrations += 1; + if (registrations === 1) { + controller.abort(); + } + return target.addEventListener(...arguments_); + }; + const value = Reflect.get(target, property, target) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + + const started = await manager.start(signal); + + expect(registrations).toBe(1); + expect(started.ok).toBe(false); + if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError"); +}); + +it("handles cancellation after the automatic-loading response", async () => { + const controller = new AbortController(); + const fake = createFakeSpawner((child, token, command) => { + child.stdout.write( + command.includes("list-thread-groups") + ? `${token}^done,groups=[]\n` + : `${token}^done\n`, + ); + if (command.includes("auto-load off")) controller.abort(); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await manager.start(controller.signal); + + expect(started.ok).toBe(false); + if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError"); +}); + +it("reports an MI stream failure when the child has no stdin pipe", async () => { + const fake = createFakeSpawner(undefined, { stdinEnabled: false }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(started.error.message).toContain("input stream is closed"); + } + expect(fake.children[0]?.exitCode).toBeNull(); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); + expect(fake.children[0]?.signalCode).toBe("SIGTERM"); +}); + +it("preserves the protocol failure when owned-process stop is confirmed", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => ({ status: "stopped" }), + }); + const started = await expectGdbSessionStarted(manager); + const stdin = fake.children[0]?.stdin; + expect(stdin).not.toBeNull(); + if (stdin === null || stdin === undefined) return; + stdin.write = (() => { + throw new Error("injected MI stdin write failure"); + }) as typeof stdin.write; + + const result = await manager.execute(started.value.session_id, "fail-write"); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining("injected MI stdin write failure"), + }, + }); +}); + +it("converts a closed stdin guard failure without partial MI records", async () => { + const fake = createFakeSpawner((child, token, command) => { + child.stdout.write(`${token}^done\n`); + if (command.includes("auto-load off")) + Object.defineProperty(child.stdin, "destroyed", { value: true }); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => ({ status: "stopped" }), + }); + const started = await expectGdbSessionStarted(manager); + expect(fake.children[0]?.stdin?.destroyed).toBe(true); + + const result = await manager.execute(started.value.session_id, "info files"); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); +}); + +it("fails pending commands when the child closes stdin unexpectedly", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const stdin = fake.children[0]?.stdin; + expect(stdin).toBeDefined(); + if (stdin === null || stdin === undefined) return; + const inputClosed = new Promise((resolve) => + stdin.once("close", resolve), + ); + stdin.destroy(); + await inputClosed; + + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + expect( + (await manager.execute(started.value.session_id, "after-close")).ok, + ).toBe(false); +}); + +it.each([ + ["throws an Error", "error"], + ["throws a non-Error value", "non-error"], + ["reports a callback write error", "callback"], +] as const)( + "handles stdin write failure when the fake writer %s", + async (_label, failure) => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const stdin = fake.children[0]?.stdin; + expect(stdin).toBeDefined(); + if (stdin === null || stdin === undefined) return; + const originalWrite = stdin.write.bind(stdin); + Object.defineProperty(stdin, "write", { + value: (...arguments_: unknown[]) => { + const payload = String(arguments_[0]); + if (!payload.includes("-interpreter-exec")) + return Reflect.apply(originalWrite, stdin, arguments_); + if (failure === "callback") { + const callback = arguments_[2] as + | ((error?: Error | null) => void) + | undefined; + queueMicrotask(() => + callback?.(new Error("simulated callback error")), + ); + return true; + } + if (failure === "error") throw new Error("simulated write error"); + throw "unstructured write failure"; + }, + }); + + const failed = await manager.execute(started.value.session_id, "will-fail"); + + expect(failed.ok).toBe(false); + if (!failed.ok) { + expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(failed.error.message).toContain( + failure === "callback" + ? "simulated callback error" + : failure === "error" + ? "simulated write error" + : "unknown error", + ); + } + }, +); + +it("does not issue a command after an exit wins the startup prompt race", async () => { + const fake = createFakeSpawner(undefined, { exitOnFirstStdoutData: true }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); +}); + +it("preserves SIGTERM startup exit status in the capability failure and Evidence", async () => { + const fake = createFakeSpawner(undefined, { + exitOnFirstStdoutData: true, + startupExitCode: null, + startupExitSignal: "SIGTERM", + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(started.error.message).toContain("exit code null, signal SIGTERM"); + expect(started.error.partialObservation).toMatchObject({ + raw_result: { + process_exit_code: null, + process_exit_signal: "SIGTERM", + process_exit_observed: true, + }, + }); + } +}); + +it("rejects the command queued behind a timed-out GDB request", async () => { + const fake = createFakeSpawner((_child, _token, command) => { + if (command.includes("auto-load off")) + fake.children[0]?.stdout.write(`${_token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + commandTimeoutMs: 25, + }); + const started = await expectGdbSessionStarted(manager); + + const first = manager.execute(started.value.session_id, "stall"); + const queued = manager.execute(started.value.session_id, "after-timeout"); + const firstResult = await first; + expect(firstResult.ok).toBe(false); + if (!firstResult.ok) + expect(firstResult.error._tag).toBe("AnalysisCapabilityUnavailableError"); + const rejected = await queued; + + expect(rejected.ok).toBe(false); + if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisInputError"); +}); + +it("waits for the prior command and preserves cancellation and queue failures", async () => { + const resolved = Promise.resolve(); + expect(waitForGdbCommandTurn(resolved)).toBe(resolved); + + const preAborted = new AbortController(); + preAborted.abort(); + await expect( + waitForGdbCommandTurn(Promise.resolve(), preAborted.signal), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError" }); + + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const controller = new AbortController(); + const cancelledWait = waitForGdbCommandTurn(pending, controller.signal); + controller.abort(); + await expect(cancelledWait).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + }); + release(); + + const queueFailure = new Error("previous command queue failed"); + await expect( + waitForGdbCommandTurn( + Promise.reject(queueFailure), + new AbortController().signal, + ), + ).rejects.toBe(queueFailure); +}); + +it("correlates command output records and reports invalid or cancelled requests", async () => { + const manager = await managerFor( + responsiveProvider(` + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else { + process.stdout.write('=thread-created,id="1"\\n'); + process.stdout.write('~"console\\n"\\n@"target\\n"\\n&"log\\n"\\n'); + process.stdout.write(match[1] + '^running\\n'); + process.stdout.write('*stopped,reason="end-stepping-range"\\n'); + } + `), + ); + expect((await manager.execute("missing", "info registers")).ok).toBe(false); + expect((await manager.close("missing")).ok).toBe(false); + + const started = await expectGdbSessionStarted(manager); + const result = await manager.execute( + started.value.session_id, + "info registers", + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result).toMatchObject({ + mi: expect.stringMatching(/\^running/u), + console: '~"console', + target: '@"target', + log: '&"log', + }); + } + const signalled = await manager.execute( + started.value.session_id, + "info registers", + new AbortController().signal, + ); + expect(signalled.ok).toBe(true); + const cancelled = new AbortController(); + cancelled.abort(); + const rejected = await manager.execute( + started.value.session_id, + "info registers", + cancelled.signal, + ); + expect(rejected.ok).toBe(false); + if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisCancelledError"); + const closed = await manager.close(started.value.session_id); + expect(closed.ok).toBe(false); + expect(manager.status(started.value.session_id)).toBeDefined(); +}); + +it("refuses to close a session while GDB reports a live target", async () => { + const manager = await managerFor( + responsiveProvider(` + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else if (match[2].includes('list-thread-groups')) process.stdout.write(match[1] + '^done,groups=[{id="i1",pid="123"}]\\n'); + else process.stdout.write(match[1] + '^done\\n'); + `), + ); + const started = await expectGdbSessionStarted(manager); + + const closed = await manager.close(started.value.session_id); + + expect(closed.ok).toBe(false); + if (!closed.ok) + expect(JSON.stringify(closed.error)).toContain( + "Live GDB thread groups remain", + ); +}); + +it("retains ownership when recursive MI returns an unknown inferior list", async () => { + const commands: string[] = []; + const fake = createFakeSpawner((child, token, command) => { + commands.push(command); + child.stdout.write( + command.includes("list-thread-groups") + ? `${token}^done,groups=[}]\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect( + commands.filter((command) => command.includes("list-thread-groups")), + ).toHaveLength(2); + expect(manager.status(started.value.session_id)).toBeDefined(); + expect(fake.children[0]?.killSignals).toEqual([]); +}); + +it("detaches a live inferior before the shared stop helper signals GDB", async () => { + const tty = "/dev/pts/42"; + const commands: string[] = []; + let detached = false; + const fake = createFakeSpawner((_child, token, command) => { + commands.push(command); + if (command.includes("inferior-tty-show")) + fake.children[0]?.stdout.write( + `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`, + ); + else if (command.includes("list-thread-groups")) + fake.children[0]?.stdout.write( + detached + ? `${token}^done,groups=[]\n` + : `${token}^done,groups=[{id="i1",pid="123"}]\n`, + ); + else { + if (command.includes("target-detach")) detached = true; + fake.children[0]?.stdout.write(`${token}^done\n`); + } + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager, undefined, { + inferiorTty: tty, + }); + + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: true, + value: { target_state: "unknown" }, + }); + expect(commands).toContain("-list-thread-groups --recurse 1"); + expect(commands).toContain("-target-detach"); + expect( + commands.filter((command) => command.includes("target-detach")), + ).toHaveLength(1); + expect(commands).not.toContain("-gdb-exit"); + expect(commands.indexOf("-inferior-tty-show")).toBeLessThan( + commands.indexOf("-target-detach"), + ); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("reports none observed when the inferior disappears before detach", async () => { + const tty = "/dev/pts/42"; + const commands: string[] = []; + let groupQueries = 0; + const fake = createFakeSpawner((child, token, command) => { + commands.push(command); + if (command.includes("inferior-tty-show")) + child.stdout.write( + `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`, + ); + else if (command.includes("list-thread-groups")) { + groupQueries += 1; + child.stdout.write( + groupQueries === 1 + ? `${token}^done,groups=[{id="i1",pid="123"}]\n` + : `${token}^done,groups=[]\n`, + ); + } else child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager, undefined, { + inferiorTty: tty, + }); + + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: true, + value: { target_state: "none_observed" }, + }); + expect(groupQueries).toBe(2); + expect(commands).not.toContain("-target-detach"); +}); + +it("retains GDB ownership when MI does not confirm target detachment", async () => { + const tty = "/dev/pts/42"; + const commands: string[] = []; + const fake = createFakeSpawner((child, token, command) => { + commands.push(command); + child.stdout.write( + command.includes("inferior-tty-show") + ? `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n` + : command.includes("list-thread-groups") + ? `${token}^done,groups=[{id="i1",pid="123"}]\n` + : command.includes("target-detach") + ? `${token}^error,msg="detach rejected"\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager, undefined, { + inferiorTty: tty, + }); + + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect( + commands.filter((command) => command.includes("target-detach")), + ).toHaveLength(2); + expect(fake.children[0]?.killSignals).toEqual([]); + expect(manager.status(started.value.session_id)).toBeDefined(); +}); + +it.each([ + ["releases cleanup after the identified inferior ends", false], + ["invalidates old identity after a newer ambiguous inventory", true], +])("%s", async (_title, invalidateAfterFirstInspection) => { + const inferior = spawn( + process.execPath, + ["-e", "process.stdout.write('ready\\n'); setInterval(() => {}, 1_000)"], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + let inferiorAlive = true; + let capturedStartTime: string | undefined; + try { + if (inferior.pid === undefined) throw new Error("Inferior did not spawn"); + await new Promise((resolve, reject) => { + inferior.stdout.once("data", () => resolve()); + inferior.once("error", reject); + }); + const pid = String(inferior.pid); + const tty = "/dev/pts/42"; + let groupQueries = 0; + const fake = createFakeSpawner((_child, token, command) => { + let response: string; + if (command.includes("inferior-tty-show")) + response = `^done,inferior_tty_terminal=${JSON.stringify(tty)}`; + else if (command.includes("list-thread-groups")) { + groupQueries += 1; + response = + invalidateAfterFirstInspection && groupQueries > 1 + ? '^done,groups=[{id="i1",pid="123"},{id="i2",pid="456"}]' + : `^done,groups=[{id="i1",pid="${pid}"}]`; + } else if (command.includes("target-detach")) + response = '^error,msg="detach reply lost"'; + else response = "^done"; + fake.children[0]?.stdout.write(`${token}${response}\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + verifyInferiorOutputSink: async (observedPid, _tty, rememberIdentity) => { + expect(observedPid).toBe(pid); + if (process.platform === "linux") { + const stat = await readFile(`/proc/${observedPid}/stat`, "utf8"); + capturedStartTime = parseLinuxProcessStartTime(stat); + } else capturedStartTime = `test-inferior-${observedPid}`; + if (capturedStartTime === undefined) + throw new Error("Controlled inferior identity was unreadable"); + rememberIdentity({ pid: observedPid, startTime: capturedStartTime }); + }, + readInferiorProcessStartTime: async (observedPid) => { + if (observedPid !== pid || !inferiorAlive) return null; + if (process.platform === "linux") { + const stat = await readFile(`/proc/${observedPid}/stat`, "utf8"); + return parseLinuxProcessStartTime(stat) ?? null; + } + return capturedStartTime ?? null; + }, + }); + const started = await expectGdbSessionStarted(manager, undefined, { + inferiorTty: tty, + }); + const firstClose = await manager.close(started.value.session_id); + expect(firstClose.ok).toBe(false); + if (!firstClose.ok) expect(firstClose.error.cleanupIncomplete).toBe(true); + + const gdbProcess = fake.children[0]; + if (gdbProcess === undefined) throw new Error("Fake GDB did not spawn"); + expect(groupQueries).toBeGreaterThanOrEqual(2); + gdbProcess.exitLeader(7); + gdbProcess.closeInheritedOutput(); + + if (invalidateAfterFirstInspection) { + const inferiorExit = new Promise((resolve, reject) => { + inferior.once("exit", () => resolve()); + inferior.once("error", reject); + }); + inferior.kill("SIGTERM"); + await inferiorExit; + inferiorAlive = false; + const invalidated = await manager.close(started.value.session_id); + expect(invalidated.ok).toBe(false); + expect(manager.status(started.value.session_id)).toBeDefined(); + return; + } + + const whileInferiorLives = await manager.close(started.value.session_id); + expect(whileInferiorLives.ok).toBe(false); + expect(manager.status(started.value.session_id)).toBeDefined(); + + const inferiorExit = new Promise((resolve, reject) => { + inferior.once("exit", () => resolve()); + inferior.once("error", reject); + }); + inferior.kill("SIGTERM"); + await inferiorExit; + inferiorAlive = false; + await expect( + manager.close(started.value.session_id), + ).resolves.toMatchObject({ + ok: true, + value: { target_state: "unknown" }, + }); + expect(manager.status(started.value.session_id)).toBeUndefined(); + } finally { + if (inferior.exitCode === null && inferior.signalCode === null) { + const inferiorExit = new Promise((resolve) => + inferior.once("exit", () => resolve()), + ); + inferior.kill("SIGKILL"); + await inferiorExit; + } + } +}); + +it("revokes the verified sink after an abbreviated with inferior-tty run command", async () => { + const tty = "/dev/pts/42"; + const commands: string[] = []; + const fake = createFakeSpawner((_child, token, command) => { + commands.push(command); + if (command.includes("inferior-tty-show")) + fake.children[0]?.stdout.write( + `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`, + ); + else if (command.includes("list-thread-groups")) + fake.children[0]?.stdout.write( + `${token}^done,groups=[{id="i1",pid="123"}]\n`, + ); + else fake.children[0]?.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager, undefined, { + inferiorTty: tty, + }); + + const overridden = await manager.execute( + started.value.session_id, + "with inferior-t /tmp/other-output -- run", + ); + + expect(overridden.ok).toBe(true); + expect(manager.status(started.value.session_id)).toMatchObject({ + inferior_tty: tty, + inferior_tty_verified: false, + }); + const closed = await manager.close(started.value.session_id); + expect(closed.ok).toBe(false); + expect(commands).not.toContain("-target-detach"); + expect(manager.status(started.value.session_id)).toBeDefined(); +}); + +const expectDetachProofRevoked = async ( + commandsToRun: readonly string[], + expectedVerifiedBeforeLastCommand?: boolean, +): Promise => { + const { manager, sessionId, commands } = + await startWithVerifiedInferiorTty("/dev/pts/42"); + for (const [index, command] of commandsToRun.entries()) { + await manager.execute(sessionId, command); + if (index === 0 && expectedVerifiedBeforeLastCommand) + expect(manager.status(sessionId)?.inferior_tty_verified).toBe(true); + } + + expect(manager.status(sessionId)?.inferior_tty_verified).toBe(false); + const closed = await manager.close(sessionId); + expect(closed.ok).toBe(false); + expect(commands).not.toContain("-target-detach"); +}; + +it.each([ + { + title: "an abbreviated TTY change followed by restoring the original TTY", + commands: [ + "set pagination off", + "set inferior-t /tmp/other", + "set inferior-t /dev/pts/42", + "start", + ], + expectedVerified: true, + }, + { + title: "redirection attached to a run argument", + commands: ["start arg>/dev/null"], + expectedVerified: false, + }, + { + title: "a Python abbreviation that changes inferior TTY", + commands: [ + 'py gdb.execute("set " + chr(45).join(["inferior", "tty"]) + " /dev/null")', + ], + expectedVerified: false, + }, + { + title: "a GDB alias that can change inferior output", + commands: ["alias redirect-output = run > /tmp/gdb-redirected-output"], + expectedVerified: false, + }, + { + title: "an abbreviated TTY command", + commands: ["tt /tmp/gdb-alternate-inferior-output"], + expectedVerified: false, + }, + { + title: "an abbreviated run command", + commands: ["ru > /tmp/gdb-alternate-inferior-output"], + expectedVerified: false, + }, + { + title: "Python nested in an abbreviated with command", + commands: [ + 'with confirm off -- py gdb.execute("set " + chr(45).join(["inferior", "tty"]) + " /dev/null")', + ], + expectedVerified: false, + }, + { + title: "Python nested in an abbreviated wit command", + commands: [ + 'wit confirm off -- py gdb.execute("set " + chr(45).join(["inferior", "tty"]) + " /dev/null")', + ], + expectedVerified: false, + }, +])( + "revokes detach proof for $title", + async ({ commands, expectedVerified }) => { + await expectDetachProofRevoked(commands, expectedVerified); + }, +); + +it("does not detach a live inferior when no durable TTY was selected", async () => { + const responder = recordingInferiorListResponder('[{id="i1",pid="123"}]'); + const fake = createFakeSpawner(responder.respond); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + await expect(manager.closeAll()).rejects.toMatchObject({ + errors: [ + expect.objectContaining({ + message: expect.stringMatching(/durable output sink/i), + }), + ], + }); + + expect(responder.commands).not.toContain("-target-detach"); + expect(responder.commands).not.toContain("-gdb-exit"); + expect(manager.status(started.value.session_id)).toBeDefined(); + expect(fake.children[0]?.killSignals).toEqual([]); +}); + +it("retains ownership without signaling when recursive MI reports multiple inferiors", async () => { + const responder = recordingInferiorListResponder( + '[{id="i1",pid="123"},{id="i2",pid="456"}]', + ); + const fake = createFakeSpawner(responder.respond); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + await expect(manager.closeAll()).rejects.toMatchObject({ + errors: [ + expect.objectContaining({ + message: expect.stringMatching(/multiple inferiors/iu), + }), + ], + }); + + expect(responder.commands).toContain("-list-thread-groups --recurse 1"); + expect(responder.commands).not.toContain("-target-detach"); + expect(manager.status(started.value.session_id)).toBeDefined(); + expect(fake.children[0]?.killSignals).toEqual([]); +}); + +it("confirms no inferior before the shared stop helper signals GDB", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: true, + value: { target_state: "none_observed" }, + }); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + +it("retains ownership when manual close receives a truncated empty inferior list", async () => { + const commands: string[] = []; + const fake = createFakeSpawner((_child, token, command) => { + commands.push(command); + if (command.includes("auto-load off")) { + fake.children[0]?.stdout.write(`${token}^done\n`); + return; + } + if (command.includes("list-thread-groups")) { + const child = fake.children[0]; + if (child === undefined) return; + // Keep each MI record below the per-line ceiling while exceeding the + // per-command byte budget before the apparently empty result arrives. + for (let index = 0; index < 17; index += 1) + child.stdout.write(`~"${"x".repeat(1_000_000)}"\n`); + child.stdout.write(`${token}^done,groups=[]\n`); + return; + } + fake.children[0]?.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const closed = await manager.close(started.value.session_id); + + expect(closed.ok).toBe(false); + expect(commands).toContain("-list-thread-groups --recurse 1"); + expect(commands).not.toContain("-gdb-exit"); + expect(commands).not.toContain("-target-detach"); + expect(manager.status(started.value.session_id)).toBeDefined(); + expect(fake.children[0]?.killSignals).toEqual([]); +}); + +it("retains ownership and sends no signal when MI state is saturated or unknown", async () => { + const fake = createFakeSpawner((_child, token, command) => { + if (command.includes("auto-load off")) + fake.children[0]?.stdout.write(`${token}^done\n`); + else if (command.includes("list-thread-groups")) + fake.children[0]?.stdout.write(`${token}^error,msg="unknown"\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + commandTimeoutMs: 20, + }); + const started = await expectGdbSessionStarted(manager); + + const timedOut = await manager.execute(started.value.session_id, "saturated"); + expect(timedOut.ok).toBe(false); + await expect(manager.closeAll()).rejects.toMatchObject({ + errors: [ + expect.objectContaining({ + message: expect.stringMatching(/inferior detach/i), + }), + ], + }); + + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + expect(fake.children[0]?.killSignals).toEqual([]); +}); + +it("routes protocol failure cleanup through detach verification without direct signals", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + const child = fake.children[0]; + if (child?.stdin === null || child?.stdin === undefined) return; + Object.defineProperty(child.stdin, "write", { + value: ( + _chunk: string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): boolean => { + callback(new Error("synthetic protocol failure")); + return false; + }, + }); + + const failed = await manager.execute(started.value.session_id, "will-fail"); + expect(failed.ok).toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + expect(child.killSignals).toEqual([]); +}); + +it("retains ownership without signaling when detach confirmation fails", async () => { + const fake = createFakeSpawner((_child, token, command) => { + fake.children[0]?.stdout.write( + command.includes("list-thread-groups") + ? `${token}^error,msg="state unavailable"\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + await expect(manager.closeAll()).rejects.toThrow(/did not stop cleanly/i); + + expect(manager.status(started.value.session_id)?.state).toBe("ready"); + expect(fake.children[0]?.killSignals).toEqual([]); +}); + +it("closes with unknown target state when MI cannot confirm thread groups", async () => { + const manager = await managerFor( + responsiveProvider(` + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else if (match[2].includes('list-thread-groups')) process.stdout.write(match[1] + '^error,msg="not supported"\\n'); + else process.stdout.write(match[1] + '^done\\n'); + `), + ); + const started = await expectGdbSessionStarted(manager); + const closed = await manager.close(started.value.session_id); + expect(closed.ok).toBe(false); + if (!closed.ok) { + expect(closed.error.message).toContain("inferior state"); + expect(closed.error._tag).toBe("AnalysisCapabilityUnavailableError"); + } + expect(manager.status(started.value.session_id)).toBeDefined(); +}); + +it("retains unknown inferior ownership after GDB exits during a command", async () => { + const manager = await managerFor( + responsiveProvider(` + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else process.stdout.write('~leaving\\n', () => process.exit(2)); + `), + ); + const started = await expectGdbSessionStarted(manager); + + const executing = manager.execute( + started.value.session_id, + "exit during command", + ); + const closing = manager.close(started.value.session_id); + const executed = await executing; + expect(executed).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + partialObservation: { + normalized_result: { + process_exit_observed: true, + completion_status: "unknown", + }, + }, + }, + }); + expect(await closing).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(manager.status(started.value.session_id)).toBeDefined(); +}); + +it("retains an exited GDB session when no inferior identity was captured", async () => { + const manager = await managerFor( + responsiveProvider(` + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else process.exit(3); + `), + ); + const started = await expectGdbSessionStarted(manager); + + const executed = await manager.execute(started.value.session_id, "exit now"); + expect(executed).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + for (let attempt = 0; attempt < 50; attempt += 1) { + if (manager.status(started.value.session_id)?.state === "closed") break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + await expect(manager.close(started.value.session_id)).resolves.toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(manager.status(started.value.session_id)).toBeDefined(); +}); + +it("retains a session when closing cannot confirm the debugger process stopped", async () => { + let stopCalls = 0; + const provider = await createProvider(responsiveProvider()); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) + return { status: "incomplete", reason: "simulated close uncertainty" }; + return stopOwnedDebuggerProcess(session, name); + }, + }); + managers.push(manager); + const started = await expectGdbSessionStarted(manager); + + const closed = await manager.close(started.value.session_id); + + expect(closed.ok).toBe(false); + if (!closed.ok) { + expect(closed.error.message).toContain("simulated close uncertainty"); + expect(closed.error).toMatchObject({ + providerId: "gdb", + operation: "close_gdb_session", + }); + } + expect(manager.status(started.value.session_id)?.state).toBe("ready"); +}); + +it("retains an exited GDB session when provider stop verification is incomplete", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => ({ + status: "incomplete", + reason: "simulated exited debugger cleanup uncertainty", + }), + }); + const started = await expectGdbSessionStarted(manager); + fake.children[0]?.exitLeader(9); + + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); +}); + +it("preserves a command timeout when stopping the owned process rejects", async () => { + let stopCalls = 0; + const provider = await createProvider( + responsiveProvider(` + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + `), + ); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + commandTimeoutMs: 25, + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) throw new Error("simulated stop failure"); + return stopOwnedDebuggerProcess(session, name); + }, + }); + managers.push(manager); + const started = await expectGdbSessionStarted(manager); + + const timedOut = await manager.execute(started.value.session_id, "stall"); + + expect(timedOut.ok).toBe(false); + if (!timedOut.ok) { + expect(timedOut.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(timedOut.error.message).toContain("shutdown failed"); + } +}); + +it("reports cancellation if owned-process stop rejects during an active command", async () => { + const controller = new AbortController(); + let stopCalls = 0; + const fake = createFakeSpawner((child, token, command) => { + if (command.includes("auto-load off")) + child.stdout.write(`${token}^done\n`); + else controller.abort(); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) throw new Error("simulated stop rejection"); + return stopOwnedDebuggerProcess(session, name); + }, + }); + const started = await expectGdbSessionStarted(manager); + + const cancelled = await manager.execute( + started.value.session_id, + "cancel me", + controller.signal, + ); + + expect(cancelled.ok).toBe(false); + if (!cancelled.ok) + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + expect(stopCalls).toBe(1); +}); + +it("returns a tagged cleanup failure thrown while closing", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => { + throw new AnalysisTimeoutError("close_gdb_session", 17); + }, + }); + const started = await expectGdbSessionStarted(manager); + + const closed = await manager.close(started.value.session_id); + + expect(closed).toMatchObject({ + ok: false, + error: { _tag: "AnalysisTimeoutError", operation: "close_gdb_session" }, + }); +}); + +it("rethrows an unexpected non-domain cleanup failure while closing", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => { + throw new Error("unexpected cleanup failure"); + }, + }); + const started = await expectGdbSessionStarted(manager); + + await expect(manager.close(started.value.session_id)).rejects.toThrow( + "unexpected cleanup failure", + ); +}); + +it("returns partial command evidence when the active MI request times out after stop", async () => { + const fake = createFakeSpawner((_child, token, command) => { + if (command.includes("hang-for-timeout")) return; + if (command.includes("list-thread-groups")) + _child.stdout.write(`${token}^done,groups=[]\n`); + else _child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + commandTimeoutMs: 20, + stopProcess: async () => ({ status: "stopped" }), + }); + const started = await expectGdbSessionStarted(manager); + + const result = await manager.execute( + started.value.session_id, + "hang-for-timeout", + ); + + expect(result).toMatchObject({ + ok: true, + value: { + result: { + completion_status: "unknown", + output_truncated: false, + }, + }, + }); +}); + +it("preserves cancellation without cleanup details when stop is confirmed", async () => { + const controller = new AbortController(); + let requestToken = ""; + const fake = createFakeSpawner((_child, token, command) => { + if (command.includes("cancel-confirmed")) requestToken = token; + else _child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => ({ status: "stopped" }), + }); + const started = await expectGdbSessionStarted(manager); + + const active = manager.execute( + started.value.session_id, + "cancel-confirmed", + controller.signal, + ); + await expect.poll(() => requestToken).not.toBe(""); + controller.abort(); + const cancelled = await active; + + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", operation: "gdb_console" }, + }); + if (!cancelled.ok) expect(cancelled.error.cleanup).toBeUndefined(); +}); + +it("rechecks cancellation after registering the active MI request listener", async () => { + const controller = new AbortController(); + let registrations = 0; + const signal = new Proxy(controller.signal, { + get(target, property) { + if (property === "addEventListener") + return (...arguments_: Parameters) => { + registrations += 1; + const result = target.addEventListener(...arguments_); + if (registrations === 2) controller.abort(); + return result; + }; + const value = Reflect.get(target, property, target) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const commands: string[] = []; + const fake = createFakeSpawner((child, token, command) => { + commands.push(command); + child.stdout.write(`${token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => ({ status: "stopped" }), + }); + const started = await expectGdbSessionStarted(manager); + + const cancelled = await manager.execute( + started.value.session_id, + "must-not-be-sent", + signal, + ); + + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(commands).not.toContain(expect.stringContaining("must-not-be-sent")); + expect(registrations).toBeGreaterThanOrEqual(2); +}); + +it("classifies timeout and unexpected failures raised while quoting console commands", async () => { + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const timeoutCommand = Object.assign(new String("timeout"), { + replaceAll() { + throw new AnalysisTimeoutError("test_command_quote", 17); + }, + }) as unknown as string; + const timeout = await manager.execute( + started.value.session_id, + timeoutCommand, + ); + expect(timeout.ok).toBe(false); + if (!timeout.ok) expect(timeout.error._tag).toBe("AnalysisTimeoutError"); + + const unexpectedCommand = Object.assign(new String("unexpected"), { + replaceAll() { + throw new Error("command quoting failed"); + }, + }) as unknown as string; + const unexpected = await manager.execute( + started.value.session_id, + unexpectedCommand, + ); + expect(unexpected.ok).toBe(false); + if (!unexpected.ok) + expect(unexpected.error._tag).toBe("AnalysisTimeoutError"); +}); + +it("marks retained exit output as truncated when command records exceed the byte cap", async () => { + const manager = await managerFor(` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else { + const record = 'x'.repeat(6 * 1024 * 1024); + process.stdout.write('~' + record + '\\n~' + record + '\\n~' + record + '\\n', () => process.exit(7)); + } + } +}); +`); + const started = await expectGdbSessionStarted(manager); + + const exited = await manager.execute(started.value.session_id, "large exit"); + + expect(exited.ok).toBe(false); + if (!exited.ok) { + expect(exited.error.cleanupIncomplete).toBe(true); + expect(exited.error.partialObservation).toMatchObject({ + normalized_result: { + process_exit_observed: true, + output_truncated: true, + completion_status: "unknown", + }, + }); + expect(exited.error.partialObservation).toMatchObject({ + limitations: expect.arrayContaining([ + expect.stringContaining("truncated prefix"), + ]), + }); + } +}); + +it("retains split MI records while bounding recent and command output", async () => { + const manager = await managerFor(` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else { + process.stdout.write('=split-record'); + setTimeout(() => { + process.stdout.write('\\n999^done\\n=unmatched-token\\n' + 'x'.repeat(70_000) + '\\n'); + const largeRecord = 'a'.repeat(6 * 1024 * 1024); + process.stdout.write('~' + largeRecord + '\\n~' + largeRecord + '\\n~' + largeRecord + '\\n'); + process.stdout.write(match[1] + '^done\\n'); + }, 5); + } + } +}); + +`); + const started = await expectGdbSessionStarted(manager); + + const result = await manager.execute( + started.value.session_id, + "large-output", + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result.output_truncated).toBe(true); + expect(result.value.result.records).toHaveLength(7); + } + expect( + manager.status(started.value.session_id)?.recent_mi_records_truncated, + ).toBe(true); +}); + +it("truncates retained MI records on UTF-8 code point boundaries", async () => { + const manager = await managerFor(` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else process.stdout.write('~' + '💩'.repeat(20_000) + '\\n' + match[1] + '^done\\n'); + } +}); +`); + const started = await expectGdbSessionStarted(manager); + await manager.execute(started.value.session_id, "large unicode output"); + + const retained = manager + .status(started.value.session_id) + ?.recent_mi_records.find((record) => record.startsWith("~")); + expect(retained).toBeDefined(); + if (retained !== undefined) { + expect(Buffer.byteLength(retained, "utf8")).toBeLessThanOrEqual(64 * 1024); + expect(retained).not.toContain("�"); + expect(retained.endsWith("[recent record truncated]")).toBe(true); + } +}); + +it("stops the GDB session when a complete MI record exceeds the protocol limit", async () => { + const manager = await managerFor(` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else process.stdout.write('x'.repeat(16 * 1024 * 1024 + 1) + '\\n'); + } +}); +`); + const started = await expectGdbSessionStarted(manager); + + const failed = await manager.execute(started.value.session_id, "bad-record"); + + expect(failed.ok).toBe(false); + if (!failed.ok) + expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError"); +}); + +it("rejects a partial MI line that exceeds the protocol limit", async () => { + const manager = await managerFor(` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else process.stdout.write('x'.repeat(16 * 1024 * 1024 + 1)); + } +}); +`); + const started = await expectGdbSessionStarted(manager); + + const failed = await manager.execute( + started.value.session_id, + "partial-line", + ); + + expect(failed.ok).toBe(false); + if (!failed.ok) + expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError"); +}); + +it("caps the number of MI records retained for a single command", async () => { + const recordCount = 16_385; + const manager = await managerFor(` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); + else process.stdout.write(Array.from({ length: ${recordCount} }, () => '~"x"').join('\\n') + '\\n' + match[1] + '^done\\n'); + } +}); +`); + const started = await expectGdbSessionStarted(manager); + + const result = await manager.execute( + started.value.session_id, + "many-records", + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result.records).toHaveLength(16_384); + expect(result.value.result.output_truncated).toBe(true); + } +}); + +it("completes an MI record split after a chunk without a newline", async () => { + const fake = createFakeSpawner((child, token, command) => { + if (command.includes("auto-load off")) { + child.stdout.write(`${token}^done\n`); + return; + } + child.stdout.write("=thread-created"); + setImmediate(() => child.stdout.write(`,id="1"\n${token}^done\n`)); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const result = await manager.execute(started.value.session_id, "thread-info"); + + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.result.records).toContain('=thread-created,id="1"'); +}); + +it("preserves the protocol failure when an incomplete stop adds uncertainty", async () => { + let stopCalls = 0; + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) { + return { + status: "incomplete", + reason: "simulated protocol stop uncertainty", + }; + } + return stopOwnedDebuggerProcess(session, name); + }, + }); + const started = await expectGdbSessionStarted(manager); + const stdin = fake.children[0]?.stdin; + if (stdin === null || stdin === undefined) return; + Object.defineProperty(stdin, "write", { + value: ( + _chunk: string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): boolean => { + fake.children[0]?.stdout.write('~"protocol output before failure"\n'); + callback(new Error("simulated protocol write failure")); + return false; + }, + }); + + const failed = await manager.execute(started.value.session_id, "will-fail"); + + expect(failed.ok).toBe(false); + if (!failed.ok) { + expect(failed.error.message).toContain( + "GDB stdin write failed: simulated protocol write failure", + ); + expect(failed.error.message).toContain( + "simulated protocol stop uncertainty", + ); + expect(failed.error.cleanup).toEqual({ + reason: "simulated protocol stop uncertainty", + resources: [started.value.session_id], + }); + expect(failed.error.partialObservation).toMatchObject({ + predicate_type: "rea.debugger.console-observation", + raw_result: { + records: ['~"protocol output before failure"'], + completion_status: "unknown", + }, + }); + } + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + const subsequent = await manager.execute( + started.value.session_id, + "must-not-run", + ); + expect(subsequent).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); + +it("drains buffered startup stdout before capturing early-exit Evidence", async () => { + const children: FakeGdbProcess[] = []; + const manager = new GdbSessionManager({ + platform: "linux", + environment: {}, + startupTimeoutMs: 500, + spawnProcess: async (options) => { + const child = new FakeGdbProcess(); + children.push(child); + setImmediate(() => { + child.exitLeader(7); + setImmediate(() => { + child.stdout.write('~"late startup record"\n'); + child.closeInheritedOutput(); + }); + }); + return { + process: child, + ownership: { + runId: options.runId, + leaderPid: child.pid, + processGroupId: child.pid, + expectedParentPid: process.pid, + }, + }; + }, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + expect(children).toHaveLength(2); + if (!started.ok) + expect(started.error.partialObservation).toMatchObject({ + raw_result: { + process_exit_code: 7, + records: ['~"late startup record"'], + completion_status: "unknown", + }, + }); +}); + +it("reports command timeout when an incomplete stop cannot confirm process exit", async () => { + let stopCalls = 0; + const fake = createFakeSpawner((_child, _token, command) => { + if (!command.includes("auto-load off")) return; + fake.children[0]?.stdout.write(`${_token}^done\n`); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + commandTimeoutMs: 20, + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) + return { + status: "incomplete", + reason: "simulated timeout stop uncertainty", + }; + return stopOwnedDebuggerProcess(session, name); + }, + }); + const started = await expectGdbSessionStarted(manager); + + const failed = await manager.execute(started.value.session_id, "stall"); + + expect(failed.ok).toBe(false); + if (!failed.ok) { + expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(failed.error.message).toContain( + "simulated timeout stop uncertainty", + ); + } +}); diff --git a/tests/boundary/providers/GdbSessionManagers.test.ts b/tests/boundary/providers/GdbSessionManagers.test.ts new file mode 100644 index 000000000..c70c4c4a2 --- /dev/null +++ b/tests/boundary/providers/GdbSessionManagers.test.ts @@ -0,0 +1,109 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; + +describe("persistent debugger session managers", () => { + let directory: string | undefined; + const managers: Array<{ closeAll(): Promise }> = []; + + afterEach(async () => { + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; + }); + + it("runs a correlated GDB MI command, reports status, and closes its owned process", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-manager-")); + const provider = join(directory, "fake-gdb.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); + input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); + if (match && line.includes("list-thread-groups")) + process.stdout.write(match[1] + "^done,groups=[]\\n"); + else if (match) process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + expect(manager.status(started.value.session_id)?.state).toBe("ready"); + + const result = await manager.execute( + started.value.session_id, + "show version", + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.result.mi).toMatch(/^\d+\^done/u); + + const closed = await manager.close(started.value.session_id); + expect(closed.ok).toBe(true); + expect(manager.status(started.value.session_id)).toBeUndefined(); + }); + + it("does not start GDB when it rejects the required auto-load setting", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-autoload-")); + const provider = join(directory, "fake-gdb-reject.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); + input = input.slice(boundary + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (match) process.stdout.write(match[1] + (match[2].includes("-gdb-set auto-load off") ? "^error,msg=\\"rejected\\"\\n" : "^done\\n")); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) + expect(JSON.stringify(started.error)).toContain( + "automatic-loading disable command", + ); + }); +}); diff --git a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts new file mode 100644 index 000000000..534963916 --- /dev/null +++ b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts @@ -0,0 +1,1204 @@ +import { readFileSync } from "node:fs"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; + +import { afterEach, expect, it } from "vitest"; + +import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; +import { evidenceSchema } from "../../../src/domain/evidence.js"; +import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; + +// These tests launch controlled fake providers; Linux admission is injected so +// lifecycle semantics remain testable on hosts that correctly reject GDB. +const createGdbManagerForInjectedPlatform = ( + options: ConstructorParameters[0] = {}, +): GdbSessionManager => + new GdbSessionManager({ platform: "linux", ...options }); + +let directory: string | undefined; +const managers: Array<{ closeAll(): Promise }> = []; + +const expectOwnedProcessExited = async (pidText: string): Promise => { + const pid = Number(pidText); + expect(Number.isInteger(pid)).toBe(true); + await expect + .poll(() => { + try { + if (process.platform === "linux") { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const state = stat.slice( + stat.lastIndexOf(")") + 2, + stat.lastIndexOf(")") + 3, + ); + if (state === "Z") return false; + } + process.kill(pid, 0); + return true; + } catch { + return false; + } + }) + .toBe(false); +}; + +it("keeps persistent GDB sessions unavailable on Windows", async () => { + const gdb = await createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: "/missing/gdb" }, + platform: "win32", + }).start(); + + expect(gdb.ok).toBe(false); + if (!gdb.ok) { + expect(gdb.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(gdb.error.message).toContain("Job Object"); + } +}); + +it.skipIf(process.platform === "win32")( + "spawns GDB from the provider environment snapshot", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-environment-snapshot-")); + const selectedDirectory = join(directory, "selected-bin"); + await import("node:fs/promises").then(({ mkdir }) => + mkdir(selectedDirectory), + ); + const selectedPath = [selectedDirectory, process.env.PATH ?? ""].join( + delimiter, + ); + const provider = join(selectedDirectory, "gdb"); + const markerPath = join(directory, "selected-provider-started"); + await writeFile( + provider, + `#!${process.execPath} +await import("node:fs/promises").then(({ writeFile }) => writeFile(${JSON.stringify(markerPath)}, "started")); +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (match) process.stdout.write(match[1] + (line.includes("list-thread-groups") ? "^done,groups=[]\\n" : "^done\\n")); + } +}); +`, + ); + await chmod(provider, 0o700); + const suppliedEnvironment: NodeJS.ProcessEnv = { PATH: selectedPath }; + const manager = createGdbManagerForInjectedPlatform({ + environment: suppliedEnvironment, + }); + managers.push(manager); + suppliedEnvironment.PATH = join(directory, "mutated-bin"); + + const started = await manager.start(); + expect(started.ok, started.ok ? undefined : started.error.message).toBe( + true, + ); + await expect(readFile(markerPath, "utf8")).resolves.toBe("started"); + if (started.ok) { + const closed = await manager.close(started.value.session_id); + expect(closed.ok).toBe(true); + } + }, +); + +it.skipIf(process.platform === "win32")( + "classifies an early GDB startup exit as a process failure with diagnostics", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-startup-early-exit-")); + const provider = join(directory, "fake-gdb-exits-during-config.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stderr.write("GDB startup stderr diagnostic\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (match && line.includes("auto-load off")) { + process.stdout.write("~\\"GDB startup diagnostic\\n\\"\\n"); + process.exit(7); + } + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + startupTimeoutMs: 1_000, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + operation: "start_gdb_session", + }); + expect(started.error).not.toMatchObject({ _tag: "AnalysisTimeoutError" }); + expect(JSON.stringify(started.error)).toContain("GDB startup diagnostic"); + expect(started.error.message).toContain("exit code 7, signal null"); + expect(JSON.stringify(started.error)).toContain("process_exit_observed"); + expect(started.error.partialObservation).toMatchObject({ + parameters: { + command: "-gdb-set auto-load off", + }, + raw_result: { + process_exit_code: 7, + process_exit_observed: true, + completion_status: "unknown", + }, + }); + expect(started.error.capturedOutput).toMatchObject({ + stderr: "GDB startup stderr diagnostic\n", + exit_code: 7, + }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "verifies inferior state after an automatic-loading rejection before cleanup", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-autoload-reject-")); + const provider = join(directory, "fake-gdb-autoload-reject.mjs"); + let detachChecked = false; + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write("~\\"automatic loading rejected\\n\\"\\n" + match[1] + "^error,msg=\\"unsupported\\"\\n"); + else if (line.includes("list-thread-groups")) process.stdout.write(match[1] + "^done,groups=[]\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + stopProcess: async (session) => { + detachChecked = true; + await session.detachInferior?.(); + return { status: "stopped" }; + }, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + operation: "start_gdb_session", + }, + }); + if (!started.ok) + expect(started.error.partialObservation).toMatchObject({ + predicate_type: "rea.debugger.startup-observation", + raw_result: { + records: expect.arrayContaining([ + '~"automatic loading rejected', + '"', + '1^error,msg="unsupported"', + ]), + completion_status: "unknown", + }, + }); + expect(detachChecked).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves invalid UTF-8 in startup Evidence when cleanup remains incomplete", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-startup-invalid-utf8-")); + const provider = join(directory, "fake-gdb-startup-invalid-utf8.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (match && line.includes("auto-load off")) { + const output = Buffer.concat([ + Buffer.from("~startup invalid byte "), + Buffer.from([0xff]), + Buffer.from("\\n"), + ]); + process.stdout.write(output, () => process.exit(7)); + } + } +}); +`, + ); + await chmod(provider, 0o700); + let retainedSessionId: string | undefined; + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + stopProcess: async (session) => { + retainedSessionId = session.id; + return { status: "incomplete", reason: "startup cleanup is retained" }; + }, + }); + managers.push(manager); + + const started = await manager.start(); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error.cleanupIncomplete).toBe(true); + expect(started.error.partialObservation).toMatchObject({ + normalized_result: { output_invalid_utf8: true }, + raw_result: { + output_invalid_utf8: true, + process_exit_observed: true, + }, + limitations: expect.arrayContaining([ + expect.stringContaining("invalid UTF-8 bytes"), + ]), + }); + } + expect(manager.status(retainedSessionId ?? "")?.mi_invalid_utf8).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "bounds persistent GDB sessions before spawning another process", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-session-limit-")); + const provider = join(directory, "fake-gdb.mjs"); + const countPath = join(directory, "spawn-count"); + await writeFile( + provider, + `#!${process.execPath} +import { appendFile } from "node:fs/promises"; +await appendFile(${JSON.stringify(countPath)}, "spawn\\n"); +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); + if (match) process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + maxSessions: 1, + }); + managers.push(manager); + + const started = await manager.start(); + expect(started.ok).toBe(true); + const overflow = await manager.start(); + expect(overflow).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + resource: "memory", + reportedLimits: { + max_sessions: 1, + active_or_reserved_sessions: 1, + }, + }, + }); + expect( + (await readFile(countPath, "utf8")).trim().split("\\n"), + ).toHaveLength(1); + const cancelled = new AbortController(); + cancelled.abort(); + const cancelledAtCapacity = await manager.start(cancelled.signal); + expect(cancelledAtCapacity).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + }, +); + +it.skipIf(process.platform === "win32")( + "counts established GDB sessions and in-flight reservations together", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-reservations-")); + const provider = join(directory, "fake-gdb.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (match) process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + let spawnCalls = 0; + let enterSecondSpawn: (() => void) | undefined; + let releaseSecondSpawn: (() => void) | undefined; + const secondSpawnEntered = new Promise((resolve) => { + enterSecondSpawn = resolve; + }); + const secondSpawnGate = new Promise((resolve) => { + releaseSecondSpawn = resolve; + }); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + maxSessions: 2, + spawnProcess: async (options) => { + spawnCalls += 1; + if (spawnCalls === 2) { + enterSecondSpawn?.(); + await secondSpawnGate; + } + return spawnOwnedProviderProcess(options); + }, + }); + managers.push(manager); + + const first = await manager.start(); + expect(first.ok).toBe(true); + const secondStart = manager.start(); + await secondSpawnEntered; + const third = await manager.start(); + expect(third).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + reportedLimits: { + max_sessions: 2, + active_or_reserved_sessions: 2, + }, + }, + }); + expect(spawnCalls).toBe(2); + releaseSecondSpawn?.(); + const second = await secondStart; + expect(second.ok).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels and drains GDB starts before closeAll resolves", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-start-shutdown-")); + const provider = join(directory, "fake-gdb-blocked.mjs"); + const pidPath = join(directory, "pid"); + const signalPath = join(directory, "signal"); + const releasePath = join(directory, "release"); + await writeFile( + provider, + `#!${process.execPath} +import { readFile, writeFile } from "node:fs/promises"; +process.on("SIGTERM", async () => { + await writeFile(${JSON.stringify(signalPath)}, "SIGTERM"); + while (true) { + try { + await readFile(${JSON.stringify(releasePath)}, "utf8"); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + process.exit(0); +}); +await writeFile(${JSON.stringify(pidPath)}, String(process.pid)); +process.stdin.resume(); +`, + ); + await chmod(provider, 0o700); + let enterStop!: () => void; + const stopEntered = new Promise((resolve) => { + enterStop = resolve; + }); + let releaseStop!: () => void; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + startupTimeoutMs: 30_000, + stopProcess: async (session, name, options) => { + enterStop(); + await stopGate; + return stopOwnedDebuggerProcess(session, name, options); + }, + }); + managers.push(manager); + + const starting = manager.start(); + let pidText: string | undefined; + for ( + let attempt = 0; + attempt < 100 && pidText === undefined; + attempt += 1 + ) { + try { + pidText = await readFile(pidPath, "utf8"); + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + if (pidText === undefined) throw new Error("fake GDB did not start"); + + let closeSettled = false; + const closing = manager.closeAll().then(() => { + closeSettled = true; + }); + try { + await stopEntered; + expect(closeSettled).toBe(false); + await writeFile(releasePath, "release"); + releaseStop(); + } finally { + await writeFile(releasePath, "release"); + releaseStop(); + } + const started = await starting; + await closing; + expect(started.ok).toBe(false); + if (started.ok) throw new Error("GDB unexpectedly started"); + expect(started.error._tag).toBe("AnalysisCancelledError"); + expect(await readFile(signalPath, "utf8")).toBe("SIGTERM"); + await expectOwnedProcessExited(pidText); + expect(manager.status("unknown-session")).toBeUndefined(); + await expect(manager.start()).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + }, +); + +afterEach(async () => { + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +it.skipIf(process.platform === "win32")( + "marks GDB status history truncated when old MI records are discarded", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-history-truncated-")); + const provider = join(directory, "fake-gdb-history-truncated.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n" + Array.from({ length: 300 }, (_, index) => + "=thread-group-added,id=\\"i" + index + "\\"\\n").join("")); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (match) process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + expect( + manager.status(started.value.session_id)?.recent_mi_records_truncated, + ).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "returns partial GDB observations with unknown completion on command timeout", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-command-timeout-")); + const provider = join(directory, "fake-gdb-command-timeout.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +await import("node:fs/promises").then((fs) => fs.writeFile(${JSON.stringify(join(directory ?? "", "pid"))}, String(process.pid))); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); + if (match && line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else if (match) process.stdout.write("~\\"partial output\\n\\"\\n"); + } +}); +setTimeout(() => process.exit(0), 250).unref(); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + commandTimeoutMs: 40, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const result = await manager.execute( + started.value.session_id, + "shell sleep 5", + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(result.error.partialObservation).toMatchObject({ + operation: "gdb_console", + normalized_result: { + completion_status: "unknown", + records: ['~"partial output', '"'], + }, + }); + expect(result.error.cleanupIncomplete).toBe(true); + } + await expectOwnedProcessExited( + await readFile(join(directory, "pid"), "utf8"), + ); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "retains timed-out GDB records and cleanup uncertainty when stopping is incomplete", + async () => { + directory = await mkdtemp( + join(tmpdir(), "rea-gdb-timeout-cleanup-incomplete-"), + ); + const provider = join(directory, "fake-gdb-timeout-cleanup.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); + if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else process.stdout.write("~\\"partial console record\\n\\"\\n@target partial\\n&log partial\\n"); + } +}); +setTimeout(() => process.exit(0), 250); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + commandTimeoutMs: 40, + stopProcess: async () => ({ + status: "incomplete", + reason: "simulated incomplete stop", + }), + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const result = await manager.execute( + started.value.session_id, + "shell sleep 5", + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + cleanup: { + reason: "simulated incomplete stop", + resources: [started.value.session_id], + }, + partialObservation: { + predicate_type: "rea.debugger.console-observation", + raw_result: { + records: [ + '~"partial console record', + '"', + "@target partial", + "&log partial", + ], + completion_status: "unknown", + }, + }, + }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps queued GDB commands serialized when a waiting request is cancelled", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-queue-cancel-")); + const provider = join(directory, "fake-gdb-queue-cancel.mjs"); + const overlapPath = join(directory, "overlap"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +let input = ""; +let firstActive = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else if (line.includes("first")) { + firstActive = true; + setTimeout(() => { firstActive = false; process.stdout.write(match[1] + "^done\\n"); }, 100); + } else { + if (line.includes("third") && firstActive) import("node:fs/promises").then((fs) => fs.writeFile(${JSON.stringify(overlapPath)}, "overlap")); + process.stdout.write(match[1] + "^done\\n"); + } + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + const first = manager.execute(started.value.session_id, "first"); + const controller = new AbortController(); + const second = manager.execute( + started.value.session_id, + "second", + controller.signal, + ); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 20)); + controller.abort(); + const third = manager.execute(started.value.session_id, "third"); + expect((await second).ok).toBe(false); + expect((await first).ok).toBe(true); + expect((await third).ok).toBe(true); + await expect(readFile(overlapPath, "utf8")).rejects.toThrow(); + }, +); + +it.skipIf(process.platform === "win32")( + "marks GDB exit-fallback output truncated when MI history was bounded", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-exit-truncated-")); + const provider = join(directory, "fake-gdb-exit-truncated.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else { + process.stdout.write(Array.from({ length: 300 }, (_, index) => "=thread-created,id=\\"" + index + "\\"\\n").join("")); + process.exit(0); + } + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const result = await manager.execute( + started.value.session_id, + "info threads", + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + process_exit_observed: true, + output_truncated: false, + completion_status: "unknown", + records: expect.arrayContaining([ + expect.stringContaining("thread-created"), + ]), + }, + }); + const parsedEvidence = evidenceSchema.safeParse( + result.error.partialObservation, + ); + expect(parsedEvidence.success).toBe(true); + if (parsedEvidence.success) { + const rawResult = parsedEvidence.data.raw_result; + expect(rawResult).toMatchObject({ records: expect.any(Array) }); + if ( + typeof rawResult === "object" && + rawResult !== null && + !Array.isArray(rawResult) && + "records" in rawResult + ) + expect(rawResult.records).toHaveLength(300); + } + } + }, +); + +it.skipIf(process.platform === "win32")( + "cancels a running GDB command and retains its closed status", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-command-cancel-")); + const provider = join(directory, "fake-gdb-command-cancel.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); + if (match && line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else if (match) process.stdout.write("~\\"partial before cancellation\\n\\"\\n@target before cancellation\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + const result = await manager.execute( + started.value.session_id, + "shell sleep 600", + controller.signal, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + predicate_type: "rea.debugger.console-observation", + parameters: { command: "shell sleep 600" }, + raw_result: { + command: "shell sleep 600", + records: [ + '~"partial before cancellation', + '"', + "@target before cancellation", + ], + completion_status: "unknown", + }, + }); + } + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "re-reads the configured inferior TTY before detaching after a console alias change", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-tty-readback-")); + const provider = join(directory, "fake-gdb-tty-readback.mjs"); + const detachPath = join(directory, "detached"); + await writeFile( + provider, + `#!${process.execPath} +let tty = "/tmp/selected-tty"; +process.stdout.write("(gdb)\\n"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + for (const line of chunk.split("\\n")) { + const match = /^(\\d+)/.exec(line); if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else if (line.includes("inferior-tty-set")) process.stdout.write(match[1] + "^done\\n"); + else if (line.includes("inferior-tty-show")) process.stdout.write(match[1] + "^done,inferior_tty_terminal=\\"" + tty + "\\"\\n"); + else if (line.includes("list-thread-groups")) process.stdout.write(match[1] + "^done,groups=[{id=\\"i1\\"}]\\n"); + else if (line.includes("interpreter-exec")) { tty = "/tmp/changed-by-abbreviated-alias"; process.stdout.write(match[1] + "^done\\n"); } + else if (line.includes("target-detach")) { import("node:fs/promises").then((fs) => fs.writeFile(${JSON.stringify(detachPath)}, "detached")); process.stdout.write(match[1] + "^done\\n"); } + } +}); +setTimeout(() => process.exit(0), 500); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + stopProcess: async () => ({ + status: "incomplete", + reason: "test cleanup", + }), + }); + managers.push(manager); + const started = await manager.start(undefined, { + inferiorTty: "/tmp/selected-tty", + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const changed = await manager.execute( + started.value.session_id, + "inferior-t /tmp/changed-by-abbreviated-alias", + ); + expect(changed.ok).toBe(true); + + const closed = await manager.close(started.value.session_id); + + expect(closed.ok).toBe(false); + await expect(readFile(detachPath, "utf8")).rejects.toThrow(); + }, +); + +it.skipIf(process.platform === "win32")( + "reports bounded GDB stderr retention in session status", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-diagnostic-limit-")); + const provider = join(directory, "fake-gdb-diagnostic-limit.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stderr.write("x".repeat(100000)); +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); + if (match) process.stdout.write(match[1] + "^done\\n"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + expect( + manager.status(started.value.session_id)?.diagnostics_truncated, + ).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "converts a GDB stdin pipe failure into a provider session error", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-stdin-error-")); + const provider = join(directory, "fake-gdb-stdin-error.mjs"); + const stdinClosed = join(directory, "stdin-closed"); + await writeFile( + provider, + `#!${process.execPath} +import { closeSync, writeFileSync } from "node:fs"; +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); + if (match && line.includes("auto-load off")) { + process.stdout.write(match[1] + "^done\\n"); + closeSync(0); + writeFileSync(${JSON.stringify(stdinClosed)}, "closed"); + } + } +}); +setInterval(() => {}, 1000); +`, + ); + await chmod(provider, 0o700); + let ownedLaunch: + | Awaited> + | undefined; + let ownedProcessExit: Promise | undefined; + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + spawnProcess: async (options) => { + ownedLaunch = await spawnOwnedProviderProcess(options); + ownedProcessExit = new Promise((resolve) => { + const child = ownedLaunch!.process; + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once("exit", () => resolve()); + }); + return ownedLaunch; + }, + stopProcess: async (session, name, options) => + session.exited + ? stopOwnedDebuggerProcess(session, name, options) + : { + status: "incomplete", + reason: "fixture-owned process awaits test teardown", + }, + }); + managers.push(manager); + let sessionId: string | undefined; + let originalError: { cleanupIncomplete: boolean } | undefined; + try { + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + sessionId = started.value.session_id; + + await expect + .poll(() => readFile(stdinClosed, "utf8").catch(() => undefined)) + .toBe("closed"); + const result = await manager.execute(sessionId, "info files"); + + expect(result.ok).toBe(false); + expect(manager.status(sessionId)?.state).toBe("closed"); + const closed = await manager.close(sessionId); + expect(closed.ok).toBe(false); + if (!closed.ok) { + originalError = closed.error; + expect(closed.error.cleanupIncomplete).toBe(true); + } + expect(manager.status(sessionId)?.state).toBe("closed"); + await expect(manager.closeAll()).rejects.toThrow(); + expect(manager.status(sessionId)?.state).toBe("closed"); + } finally { + if (ownedLaunch !== undefined) { + const cleanup = await cleanupOwnedProcessGroup(ownedLaunch.ownership); + if (!cleanup.cleaned) { + const child = ownedLaunch.process; + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + } + await ownedProcessExit; + await expectOwnedProcessExited(String(ownedLaunch.process.pid)); + } + const retried = await manager.retryCleanupFailures(); + expect(retried.ok).toBe(false); + if (originalError !== undefined) + expect(originalError.cleanupIncomplete).toBe(true); + if (sessionId !== undefined) + expect(manager.status(sessionId)?.state).toBe("closed"); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps GDB exit-fallback Evidence scoped to the active command", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-gdb-command-exit-scope-")); + const provider = join(directory, "fake-gdb-command-exit-scope.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + else if (line.includes("first command")) process.stdout.write("~first-command-only\\n" + match[1] + "^done\\n"); + else process.stdout.write("~second-command-partial\\n@target-partial\\n@target-tail\\n&log-partial\\n&log-tail\\n~second-command-tail\\n", () => process.exit(7)); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + expect( + (await manager.execute(started.value.session_id, "first command")).ok, + ).toBe(true); + const exited = await manager.execute( + started.value.session_id, + "second command", + ); + + expect(exited.ok).toBe(false); + if (!exited.ok) { + expect(exited.error.cleanupIncomplete).toBe(true); + expect(exited.error.partialObservation).toMatchObject({ + normalized_result: { + command: "second command", + mi: "", + process_exit_observed: true, + records: [ + "~second-command-partial", + "@target-partial", + "@target-tail", + "&log-partial", + "&log-tail", + "~second-command-tail", + ], + console: ["~second-command-partial", "~second-command-tail"].join( + "\n", + ), + target: ["@target-partial", "@target-tail"].join("\n"), + log: ["&log-partial", "&log-tail"].join("\n"), + output_truncated: false, + completion_status: "unknown", + }, + }); + expect(exited.error.partialObservation).toMatchObject({ + parameters: { + command: "second command", + session_id: started.value.session_id, + }, + normalized_result: { + command: "second command", + records: [ + "~second-command-partial", + "@target-partial", + "@target-tail", + "&log-partial", + "&log-tail", + "~second-command-tail", + ], + completion_status: "unknown", + }, + raw_result: { + command: "second command", + records: expect.not.arrayContaining(["~first-command-only"]), + completion_status: "unknown", + }, + limitations: expect.arrayContaining([ + expect.stringContaining( + "only records retained for the active command", + ), + expect.stringContaining( + "The active GDB inferior identity is not established by the debugger session identifier.", + ), + ]), + }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "preserves the GDB cleanup failure when a running command is cancelled", + async () => { + directory = await mkdtemp( + join(tmpdir(), "rea-gdb-cancel-cleanup-failure-"), + ); + const provider = join(directory, "fake-gdb-cancel-cleanup-failure.mjs"); + await writeFile( + provider, + `#!${process.execPath} +process.stdout.write("(gdb)\\n"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + const match = /^(\\d+)/.exec(line); if (!match) continue; + if (line.includes("auto-load off")) process.stdout.write(match[1] + "^done\\n"); + } +}); +setTimeout(() => process.exit(0), 250); +`, + ); + await chmod(provider, 0o700); + const manager = createGdbManagerForInjectedPlatform({ + environment: { REA_GDB_COMMAND: provider }, + stopProcess: async () => ({ + status: "incomplete", + reason: "simulated debugger cleanup failure", + }), + }); + managers.push(manager); + const started = await manager.start(); + expect(started.ok).toBe(true); + if (!started.ok) return; + const controller = new AbortController(); + const execution = manager.execute( + started.value.session_id, + "shell sleep 600", + controller.signal, + ); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 30)); + controller.abort(); + + const cancelled = await execution; + + expect(cancelled.ok).toBe(false); + if (!cancelled.ok) { + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + expect(cancelled.error.cleanup?.reason).toContain( + "simulated debugger cleanup failure", + ); + } + }, +); diff --git a/tests/boundary/providers/RizinCommandTimeout.test.ts b/tests/boundary/providers/RizinCommandTimeout.test.ts new file mode 100644 index 000000000..908e36cff --- /dev/null +++ b/tests/boundary/providers/RizinCommandTimeout.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { + createRizinCommandTimeoutError, + isRizinCommandDeadlineExpired, + RizinCommandTimeoutError, +} from "../../../src/rizin/RizinDebugSessionManager.js"; + +describe("Rizin command timeout semantics", () => { + it("treats zero and negative remaining time as expired", () => { + expect(isRizinCommandDeadlineExpired(1)).toBe(false); + expect(isRizinCommandDeadlineExpired(0)).toBe(true); + expect(isRizinCommandDeadlineExpired(-1)).toBe(true); + }); + + it("retains partial frames separated by newlines in the timeout error", () => { + const error = createRizinCommandTimeoutError([ + "first frame", + "second frame", + ]); + + expect(error).toBeInstanceOf(RizinCommandTimeoutError); + expect(error.partialOutput).toBe("first frame\nsecond frame"); + expect(error.message).toBe( + "Timed out waiting for Rizin's command-completion marker", + ); + }); +}); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts b/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts new file mode 100644 index 000000000..093ab8c68 --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts @@ -0,0 +1,245 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { EventEmitter } from "node:events"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Writable } from "node:stream"; + +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; +import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; +import type { + ProviderProcessHandle, + SpawnedOwnedProviderProcess, +} from "../../../src/process/ProviderProcess.js"; + +interface InjectedProcessControl { + output(chunk: Buffer | string): void; + stdinError(): void; + closeStdin(): void; + exitLeader(signal?: NodeJS.Signals): void; + closeOutput(): void; + exit(signal?: NodeJS.Signals): void; +} + +type InjectedSpawnProcess = NonNullable< + NonNullable< + ConstructorParameters[1] + >["spawnProcess"] +>; + +export const incompleteStop: typeof stopOwnedDebuggerProcess = async ( + session, + name, +) => { + await stopOwnedDebuggerProcess(session, name); + return { status: "incomplete", reason: "injected stop uncertainty" }; +}; + +export const abortOnCheck = (targetCheck: number): AbortSignal => { + let checks = 0; + let aborted = false; + type Listener = Parameters[1]; + const listeners = new Set(); + return { + get aborted() { + checks += 1; + if (checks >= targetCheck) aborted = true; + return aborted; + }, + addEventListener: ( + _type: Parameters[0], + listener: Listener, + ) => { + if (listener !== null) listeners.add(listener); + }, + removeEventListener: ( + _type: Parameters[0], + listener: Parameters[1], + ) => { + if (listener !== null) listeners.delete(listener); + }, + dispatchEvent: () => true, + onabort: null, + reason: undefined, + throwIfAborted: () => undefined, + } as AbortSignal; +}; + +export const createInjectedProcess = ( + options: { + readonly stdin?: + | "normal" + | "null" + | "destroyed" + | "throws" + | "throws-opaque" + | "write-error"; + readonly onWrite?: (text: string, control: InjectedProcessControl) => void; + readonly startup?: (control: InjectedProcessControl) => void; + readonly beforeSpawnReturns?: () => void; + readonly spawnError?: unknown; + } = {}, +): { + readonly spawnProcess: InjectedSpawnProcess; + readonly control: InjectedProcessControl; +} => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let exitCode: number | null = null; + let signalCode: NodeJS.Signals | null = null; + const processHandle = new EventEmitter() as EventEmitter & { + pid: number; + stdout: PassThrough; + stdin: Writable | null; + stderr: PassThrough; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill(signal?: NodeJS.Signals | number): boolean; + }; + processHandle.pid = process.pid; + processHandle.stdout = stdout; + processHandle.stderr = stderr; + processHandle.exitCode = exitCode; + processHandle.signalCode = signalCode; + + const control: InjectedProcessControl = { + output: (chunk) => stdout.emit("data", Buffer.from(chunk)), + stdinError: () => stdin?.emit("error", new Error("injected stdin error")), + closeStdin: () => stdin?.destroy(), + exitLeader: (signal = "SIGTERM") => { + exitCode = null; + signalCode = signal; + processHandle.exitCode = exitCode; + processHandle.signalCode = signalCode; + processHandle.emit("exit", exitCode, signalCode); + }, + closeOutput: () => { + stdout.destroy(); + stderr.destroy(); + processHandle.emit("close", exitCode, signalCode); + }, + exit: (signal = "SIGTERM") => { + control.exitLeader(signal); + control.closeOutput(); + }, + }; + const stdin = + options.stdin === "null" + ? null + : new Writable({ + write(chunk, _encoding, callback) { + options.onWrite?.(Buffer.from(chunk).toString("utf8"), control); + callback( + options.stdin === "write-error" + ? new Error("injected write callback failure") + : undefined, + ); + }, + }); + if (stdin !== null) { + if (options.stdin === "destroyed") + Object.defineProperty(stdin, "destroyed", { value: true }); + if (options.stdin === "throws" || options.stdin === "throws-opaque") + stdin.write = (() => { + if (options.stdin === "throws-opaque") + throw "injected opaque write failure"; + throw new Error("injected synchronous write failure"); + }) as typeof stdin.write; + processHandle.stdin = stdin; + } else processHandle.stdin = null; + processHandle.kill = (signal = "SIGTERM") => { + if (exitCode !== null || signalCode !== null) return false; + control.exit(typeof signal === "string" ? signal : "SIGTERM"); + return true; + }; + + const spawnProcess: InjectedSpawnProcess = async (spawnOptions) => { + options.beforeSpawnReturns?.(); + if (options.spawnError !== undefined) throw options.spawnError; + setImmediate(() => { + if (options.startup !== undefined) options.startup(control); + else control.output(Buffer.from([0])); + }); + return { + process: processHandle as unknown as ProviderProcessHandle, + ownership: { + runId: spawnOptions.runId, + leaderPid: process.pid, + processGroupId: process.pid, + expectedParentPid: process.pid, + }, + } satisfies SpawnedOwnedProviderProcess; + }; + return { spawnProcess, control }; +}; + +export const createRizinCoverageHarness = () => { + let directory: string | undefined; + const managers: RizinDebugSessionManager[] = []; + const track = (manager: RizinDebugSessionManager): void => { + managers.push(manager); + }; + const makeProvider = async ( + body: string, + shebang = "#!/usr/bin/env node", + ): Promise => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-coverage-")); + const provider = join(directory, "fake-rizin.mjs"); + await writeFile(provider, `${shebang}\n${body}\n`); + await chmod(provider, 0o700); + return provider; + }; + const start = async ( + provider: string, + options: ConstructorParameters[1] = {}, + ): Promise<{ manager: RizinDebugSessionManager; sessionId: string }> => { + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: provider }, + options, + ); + track(manager); + const result = await manager.start({ + path: "/tmp/coverage-target", + inferior_tty: await rizinTestInferiorTty(), + }); + if (!result.ok) throw result.error; + return { manager, sessionId: result.value.session_id }; + }; + const startInjected = async ( + spawnProcess: InjectedSpawnProcess, + options: ConstructorParameters[1] = {}, + ) => { + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { ...options, spawnProcess }, + ); + track(manager); + const started = await manager.start({ + path: "/tmp/injected", + inferior_tty: await rizinTestInferiorTty(), + }); + return { manager, started }; + }; + const cleanup = async (): Promise => { + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; + }; + const missingProviderPath = (): string => join(directory!, "does-not-exist"); + return { + cleanup, + makeProvider, + missingProviderPath, + start, + startInjected, + track, + }; +}; diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts new file mode 100644 index 000000000..1f5c8e49c --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts @@ -0,0 +1,422 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + createRizinTestManager, + stopFakeNoInferior, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; +import { rizinCommand } from "../../../src/rizin/RizinCommand.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { + createRizinCoverageHarness, + createInjectedProcess, + abortOnCheck, + incompleteStop, +} from "./RizinDebugSessionCoverage.fixtures.js"; + +const { + cleanup, + makeProvider, + missingProviderPath, + start, + startInjected, + track, +} = createRizinCoverageHarness(); +afterEach(cleanup); + +it.skipIf(process.platform === "win32")( + "builds the one-shot command with the caller's path and command", + () => { + expect(rizinCommand({ path: "./sample file", command: "izz" })).toEqual([ + "-N", + "-q", + "-c", + "izz", + expect.stringMatching(/sample file/u), + ]); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects unsupported, pre-cancelled, and failed startup requests", + async () => { + const windows = createRizinTestManager({}, { platform: "win32" }); + expect( + ( + await windows.start({ + path: "/tmp/a", + inferior_tty: "unused-before-platform-admission", + }) + ).ok, + ).toBe(false); + const aborted = new AbortController(); + aborted.abort(); + expect( + ( + await windows.start( + { path: "/tmp/a", inferior_tty: await rizinTestInferiorTty() }, + aborted.signal, + ) + ).ok, + ).toBe(false); + + const failed = await makeProvider("process.exit(9);"); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: failed, + }); + track(manager); + const result = await manager.start({ + path: "/tmp/a", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(result.ok).toBe(false); + + const missing = createRizinTestManager({ + REA_RIZIN_COMMAND: missingProviderPath(), + }); + track(missing); + const spawnFailure = await missing.start({ + path: "/tmp/a", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(spawnFailure.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "returns a startup timeout when Rizin exits before its initial frame", + async () => { + const provider = await makeProvider("process.exit(0);"); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + track(manager); + const result = await manager.start({ + path: "/tmp/early-exit", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "times out startup through the frame wait when Rizin never emits a frame", + async () => { + let resolveSpawned!: () => void; + const spawned = new Promise((resolve) => { + resolveSpawned = resolve; + }); + const injected = createInjectedProcess({ + startup: () => undefined, + beforeSpawnReturns: resolveSpawned, + }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { + spawnProcess: injected.spawnProcess, + stopProcess: async () => ({ status: "stopped" }), + }, + ); + track(manager); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const starting = manager.start({ + path: "/tmp/no-startup-frame", + inferior_tty: await rizinTestInferiorTty(), + }); + await spawned; + await vi.advanceTimersByTimeAsync(30_001); + const result = await starting; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain("timed out"); + } finally { + vi.useRealTimers(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "does not spawn Rizin when cancellation arrives during TTY validation", + async () => { + let spawned = false; + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { + spawnProcess: async () => { + spawned = true; + throw new Error("must not spawn after cancellation"); + }, + }, + ); + const result = await manager.start( + { + path: "/tmp/cancel-before-spawn", + inferior_tty: await rizinTestInferiorTty(), + }, + abortOnCheck(2), + ); + expect(result.ok).toBe(false); + expect(spawned).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects a non-TTY path before spawning Rizin", + async () => { + let spawned = false; + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { + spawnProcess: async () => { + spawned = true; + throw new Error("must not spawn with an invalid TTY"); + }, + }, + ); + const result = await manager.start({ + path: "/tmp/not-a-tty-target", + inferior_tty: "/dev/null", + }); + expect(result.ok).toBe(false); + expect(spawned).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "closeAll drains concurrent startups and blocks later admission", + async () => { + let spawnCount = 0; + let resolveSpawnCalls!: () => void; + const bothSpawnsCalled = new Promise((resolve) => { + resolveSpawnCalls = resolve; + }); + let releaseSpawns!: () => void; + const spawnGate = new Promise((resolve) => { + releaseSpawns = resolve; + }); + const injected = createInjectedProcess(); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { + spawnProcess: async (options) => { + spawnCount += 1; + if (spawnCount === 2) resolveSpawnCalls(); + await spawnGate; + return injected.spawnProcess(options); + }, + stopProcess: async () => ({ status: "stopped" }), + }, + ); + const tty = await rizinTestInferiorTty(); + const starts = [ + manager.start({ path: "/tmp/start-one", inferior_tty: tty }), + manager.start({ path: "/tmp/start-two", inferior_tty: tty }), + ]; + await bothSpawnsCalled; + let closeAllResolved = false; + const closing = manager.closeAll().then(() => { + closeAllResolved = true; + }); + await Promise.resolve(); + expect(closeAllResolved).toBe(false); + releaseSpawns(); + const started = await Promise.all(starts); + expect(started.every((result) => result.ok)).toBe(true); + await closing; + expect( + manager.status(started[0]!.ok ? started[0]!.value.session_id : "missing"), + ).toBeUndefined(); + expect( + (await manager.start({ path: "/tmp/after-close-all", inferior_tty: tty })) + .ok, + ).toBe(false); + expect((await manager.close("missing")).ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects an oversized initial frame instead of marking the session ready", + async () => { + const provider = await makeProvider( + 'process.stdout.write("x".repeat(16 * 1024 * 1024 + 1) + "\\0"); process.stdin.resume();', + ); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + track(manager); + + const result = await manager.start({ + path: "/tmp/oversized-startup", + inferior_tty: await rizinTestInferiorTty(), + }); + + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels startup while waiting for the initial NUL frame", + async () => { + let resolveSpawned!: () => void; + const spawned = new Promise((resolve) => { + resolveSpawned = resolve; + }); + const provider = await makeProvider( + 'setTimeout(() => process.stdout.write("\\0"), 500); process.stdin.resume();', + ); + const manager = createRizinTestManager( + { + REA_RIZIN_COMMAND: provider, + }, + { + spawnProcess: async (options) => { + const launched = await spawnOwnedProviderProcess(options); + resolveSpawned(); + return launched; + }, + }, + ); + track(manager); + const abort = new AbortController(); + const starting = manager.start( + { path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() }, + abort.signal, + ); + await spawned; + abort.abort(); + expect((await starting).ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves startup cleanup uncertainty after cancellation", + async () => { + let resolveSpawned!: () => void; + const spawned = new Promise((resolve) => { + resolveSpawned = resolve; + }); + const provider = await makeProvider( + "process.stdin.resume(); setInterval(() => {}, 1000);", + ); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: provider }, + { + stopProcess: incompleteStop, + spawnProcess: async (options) => { + const launched = await spawnOwnedProviderProcess(options); + resolveSpawned(); + return launched; + }, + }, + ); + track(manager); + const abort = new AbortController(); + const starting = manager.start( + { path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() }, + abort.signal, + ); + await spawned; + abort.abort(); + const result = await starting; + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.message).toContain("injected stop uncertainty"); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects commands for missing and pre-cancelled sessions", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.resume();', + ); + const { manager, sessionId } = await start(provider); + expect((await manager.execute("missing", "dr")).ok).toBe(false); + const aborted = new AbortController(); + aborted.abort(); + expect((await manager.execute(sessionId, "dr", aborted.signal)).ok).toBe( + false, + ); + expect(manager.status(sessionId)?.state).toBe("ready"); + expect((await manager.close("missing")).ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "retains close failures and resolves their owner after cleanup retry", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.resume();', + ); + let stopCalls = 0; + const { manager, sessionId } = await start(provider, { + stopProcess: async (session, name, options) => { + stopCalls += 1; + if (stopCalls === 1) { + session.launched.process.kill("SIGTERM"); + return { + status: "incomplete", + reason: "first stop result is uncertain", + }; + } + await session.supervisor.waitForExit(1000); + return stopFakeNoInferior(session, name, options); + }, + }); + const closed = await manager.close(sessionId); + expect(closed.ok).toBe(false); + if (!closed.ok) { + expect(closed.error.cleanupIncomplete).toBe(true); + expect(closed.error.cleanup?.resources).toEqual([sessionId]); + const retainedFailure = closed.error; + expect(await manager.retryCleanupFailures()).toEqual({ + ok: true, + value: null, + }); + expect(retainedFailure.cleanupIncomplete).toBe(false); + } + expect(stopCalls).toBe(2); + expect(manager.status(sessionId)).toBeUndefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "retains a Rizin close owner until inherited output writers close", + async () => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + const sessionId = started.value.session_id; + injected.control.exitLeader(); + + const firstClose = await manager.close(sessionId); + expect(firstClose.ok).toBe(false); + if (!firstClose.ok) { + expect(firstClose.error.cleanupIncomplete).toBe(true); + expect(firstClose.error.message).toMatch(/inherited output streams/i); + } + expect(manager.status(sessionId)).toBeDefined(); + + injected.control.closeOutput(); + const retried = await manager.retryCleanupFailures(); + + expect(retried).toEqual({ ok: true, value: null }); + expect(manager.status(sessionId)).toBeUndefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "closes a session after unsolicited frames make command correlation unsafe", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); setTimeout(() => process.stdout.write("unexpected\\0"), 20); process.stdin.resume();', + ); + const { manager, sessionId } = await start(provider); + await new Promise((resolve) => setTimeout(resolve, 50)); + const result = await manager.execute(sessionId, "dr"); + expect(result.ok).toBe(false); + expect(manager.status(sessionId)?.state).toBe("closed"); + expect((await manager.close(sessionId)).ok).toBe(true); + }, +); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts new file mode 100644 index 000000000..13b6c6dcf --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts @@ -0,0 +1,210 @@ +import { afterEach, expect, it } from "vitest"; +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; +import { + createRizinCoverageHarness, + abortOnCheck, + createInjectedProcess, + incompleteStop, +} from "./RizinDebugSessionCoverage.fixtures.js"; + +const { cleanup, makeProvider, start, startInjected, track } = + createRizinCoverageHarness(); +afterEach(cleanup); + +it.skipIf(process.platform === "win32")( + "stops the session when the provider closes its stdin pipe", + async () => { + const provider = await makeProvider( + 'printf "\\000"; exec 0<&-; while :; do :; done', + "#!/bin/sh", + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 500, + }); + const result = await manager.execute(sessionId, "dr"); + expect(result.ok).toBe(false); + expect(manager.status(sessionId)?.state).toBe("closed"); + }, +); + +it + .skipIf(process.platform === "win32") + .each([ + "null", + "destroyed", + "throws", + "throws-opaque", + "write-error", + ] as const)( + "fails safely when injected Rizin stdin is %s", + async (stdinMode) => { + const injected = createInjectedProcess({ stdin: stdinMode }); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute(started.value.session_id, "dr"); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "fails an active command when the injected stdin stream closes", + async () => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + injected.control.closeStdin(); + const result = await manager.execute(started.value.session_id, "dr"); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32").each([false, true])( + "uses protocol cleanup when an injected write callback fails (incomplete=%s)", + async (incomplete) => { + const injected = createInjectedProcess({ stdin: "write-error" }); + const { manager, started } = await startInjected(injected.spawnProcess, { + stopProcess: incomplete + ? incompleteStop + : async () => ({ status: "stopped" }), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute(started.value.session_id, "dr"); + expect(result.ok).toBe(false); + if (!result.ok && incomplete) + expect(result.error.message).toContain("injected stop uncertainty"); + }, +); + +it.skipIf(process.platform === "win32")( + "maps injected spawn errors and startup protocol failure", + async () => { + for (const spawnError of [new Error("injected spawn failure"), "opaque"]) { + const injected = createInjectedProcess({ spawnError }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess }, + ); + const result = await manager.start({ + path: "/tmp/injected", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(result.ok).toBe(false); + } + + const injected = createInjectedProcess({ + startup: (control) => control.closeStdin(), + }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess }, + ); + const result = await manager.start({ + path: "/tmp/injected", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.message).toContain("stdin stream closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "maps spawn rejection as cancellation when the signal changes during spawn", + async () => { + const abort = new AbortController(); + const injected = createInjectedProcess({ + beforeSpawnReturns: () => abort.abort(), + spawnError: new Error("spawn interrupted"), + }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess }, + ); + const result = await manager.start( + { path: "/tmp/injected", inferior_tty: await rizinTestInferiorTty() }, + abort.signal, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain("cancelled"); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels startup after receiving its initial frame", + async () => { + const abort = new AbortController(); + const injected = createInjectedProcess({ + startup: (control) => { + control.output(Buffer.from([0])); + abort.abort(); + }, + }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess }, + ); + const result = await manager.start( + { path: "/tmp/injected", inferior_tty: await rizinTestInferiorTty() }, + abort.signal, + ); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "observes startup cancellation before the first frame waiter is installed", + async () => { + const abort = new AbortController(); + const injected = createInjectedProcess({ + beforeSpawnReturns: () => abort.abort(), + }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess }, + ); + const result = await manager.start( + { path: "/tmp/injected", inferior_tty: await rizinTestInferiorTty() }, + abort.signal, + ); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels a command after its turn is granted", + async () => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute( + started.value.session_id, + "dr", + abortOnCheck(3), + ); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels while waiting for the previous command turn", + async () => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute( + started.value.session_id, + "dr", + abortOnCheck(2), + ); + expect(result.ok).toBe(false); + }, +); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts new file mode 100644 index 000000000..393c0f4a5 --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts @@ -0,0 +1,291 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; +import { + createRizinCoverageHarness, + abortOnCheck, + createInjectedProcess, +} from "./RizinDebugSessionCoverage.fixtures.js"; + +const { cleanup, startInjected, track } = createRizinCoverageHarness(); +afterEach(cleanup); + +it.skipIf(process.platform === "win32")( + "times out when an active command produces no frame", + async () => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess, { + frameTimeoutMs: 10, + stopProcess: async () => ({ status: "stopped" }), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute(started.value.session_id, "wait"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result.completion_status).toBe("unknown"); + expect(result.value.evidence.raw_result).toEqual(result.value.result); + expect(result.value.evidence.parameters.launch_provenance).toEqual({ + caller_selected_path: "/tmp/injected", + resolved_path: "/tmp/injected", + }); + expect(result.value.evidence.limitations).toEqual([ + "The current debugger target identity is not established by the Rizin session identifier.", + "The command timed out before its completion marker; retained output is partial and completion is unknown.", + "The owned debugger session was stopped because output correlation could no longer be guaranteed.", + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.", + ]); + } + }, +); + +it.skipIf(process.platform === "win32")( + "preserves frame separators in partial output when a command times out", + async () => { + const injected = createInjectedProcess({ + onWrite: (_text, control) => { + control.output("first frame\0second frame\0"); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess, { + frameTimeoutMs: 1000, + stopProcess: async () => ({ status: "stopped" }), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const result = await manager.execute(started.value.session_id, "wait"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result.output).toBe("first frame\nsecond frame"); + expect(result.value.result.completion_status).toBe("unknown"); + } + }, +); + +it.skipIf(process.platform === "win32")( + "preserves queued partial frames when the command deadline reaches zero", + async () => { + const injected = createInjectedProcess({ + onWrite: (_text, control) => { + control.output("first frame\0second frame\0third frame\0"); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess, { + frameTimeoutMs: 1000, + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const clock = vi.spyOn(Date, "now"); + clock + .mockReturnValueOnce(100) + .mockReturnValueOnce(100) + .mockReturnValueOnce(100) + .mockReturnValue(1100); + let result; + try { + result = await manager.execute(started.value.session_id, "wait"); + } finally { + clock.mockRestore(); + } + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result.output).toBe("first frame\nsecond frame"); + expect(result.value.result.completion_status).toBe("unknown"); + } + }, +); + +it.skipIf(process.platform === "win32")( + "does not convert an unexpected frame waiter error into a timeout", + async () => { + const injected = createInjectedProcess({ + onWrite: (_text, control) => { + setImmediate(() => control.stdinError()); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess, { + frameTimeoutMs: 100, + stopProcess: async () => ({ status: "stopped" }), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const result = await manager.execute(started.value.session_id, "wait"); + + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.message).toContain("Rizin stdin stream failed"); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels a command after capturing its completion frame", + async () => { + let signal: AbortSignal; + const injected = createInjectedProcess({ + onWrite: (text, control) => { + const marker = /!echo (.+)\n/u.exec(text)?.[1]; + if (marker !== undefined) control.output(Buffer.from(`${marker}\0`)); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + signal = abortOnCheck(5); + const result = await manager.execute( + started.value.session_id, + "dr", + signal, + ); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "handles cancellation observed immediately after installing the frame waiter", + async () => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess, { + stopProcess: async () => ({ status: "stopped" }), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute( + started.value.session_id, + "wait", + abortOnCheck(5), + ); + expect(result.ok).toBe(false); + }, +); + +it.skipIf(process.platform === "win32").each([false, true])( + "maps a frame-wait listener installation failure without losing cleanup (incomplete=%s)", + async (incomplete) => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess, { + stopProcess: async () => + incomplete + ? { status: "incomplete", reason: "injected stop uncertainty" } + : { status: "stopped" }, + frameTimeoutMs: 1000, + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const controller = new AbortController(); + let registrations = 0; + const signal = { + get aborted() { + return false; + }, + addEventListener( + type: Parameters[0], + listener: Parameters[1], + options?: Parameters[2], + ) { + registrations += 1; + if (registrations === 2) + throw new Error("injected frame waiter registration failure"); + controller.signal.addEventListener(type, listener, options); + }, + removeEventListener: controller.signal.removeEventListener.bind( + controller.signal, + ), + dispatchEvent: controller.signal.dispatchEvent.bind(controller.signal), + onabort: null, + reason: undefined, + throwIfAborted: () => undefined, + } as AbortSignal; + const result = await manager.execute( + started.value.session_id, + "wait", + signal, + ); + expect(result.ok).toBe(false); + if (!result.ok && incomplete) + expect(result.error.message).toContain("injected stop uncertainty"); + }, +); + +it.skipIf(process.platform === "win32")( + "correlates an injected command frame with its generated marker", + async () => { + const injected = createInjectedProcess({ + onWrite: (text, control) => { + const marker = /!echo (.+)\n/u.exec(text)?.[1]; + if (marker !== undefined) + setImmediate(() => control.output(Buffer.from(`output${marker}\0`))); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute(started.value.session_id, "dr"); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.result.output).toBe("output"); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves process-exit evidence when exit occurs before frame waiting", + async () => { + const injected = createInjectedProcess({ + onWrite: (_text, control) => control.exit(), + }); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute(started.value.session_id, "dr"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result.completion_status).toBe("unknown"); + expect(result.value.result).toMatchObject({ + command: "dr", + output: "", + output_truncated: false, + completion_status: "unknown", + backend: null, + }); + expect(result.value.evidence.raw_result).toEqual(result.value.result); + expect(result.value.evidence.limitations).toEqual([ + "The current debugger target identity is not established by the Rizin session identifier.", + "Rizin exited before the command-completion marker; retained frame output is partial and command completion is unknown.", + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.", + ]); + } + }, +); + +it.skipIf(process.platform === "win32")( + "rejects a queued command when the process exits before its frame wait begins", + async () => { + const writes: string[] = []; + const injected = createInjectedProcess({ + onWrite: (text, control) => { + writes.push(text); + const marker = /!echo (.+)\n/u.exec(text)?.[1]; + if (marker !== undefined) setImmediate(() => control.exit()); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + const first = manager.execute(started.value.session_id, "first"); + const queued = manager.execute(started.value.session_id, "second"); + expect((await first).ok).toBe(true); + const second = await queued; + expect(second.ok).toBe(false); + if (!second.ok) expect(second.error).toBeInstanceOf(AnalysisInputError); + expect(writes.some((text) => text.includes("second\n"))).toBe(false); + }, +); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts new file mode 100644 index 000000000..e063fbea5 --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts @@ -0,0 +1,266 @@ +import { afterEach, expect, it } from "vitest"; +import { evidenceSchema } from "../../../src/domain/evidence.js"; +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; +import { + createRizinCoverageHarness, + createInjectedProcess, + incompleteStop, +} from "./RizinDebugSessionCoverage.fixtures.js"; + +const { cleanup, makeProvider, start, startInjected, track } = + createRizinCoverageHarness(); +afterEach(cleanup); + +it.skipIf(process.platform === "win32")( + "assembles a multi-chunk NUL frame once and preserves its command output", + async () => { + const chunk = Buffer.alloc(64 * 1024, 0x61); + const chunkCount = 32; + const outputBytes = chunk.length * chunkCount; + const injected = createInjectedProcess({ + onWrite: (text, control) => { + const marker = /!echo (.+)\n/u.exec(text)?.[1]; + if (marker !== undefined) + setImmediate(() => { + for (let index = 0; index < chunkCount; index += 1) + control.output(chunk); + control.output(Buffer.from([0])); + control.output(Buffer.from(`${marker}\0`)); + }); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess, { + frameTimeoutMs: 1000, + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const result = await manager.execute(started.value.session_id, "chunked"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(Buffer.byteLength(result.value.result.output)).toBe(outputBytes); + expect(result.value.result.output).toBe("a".repeat(outputBytes)); + expect(result.value.result.completion_status).toBe("complete"); + } + }, +); + +it.skipIf(process.platform === "win32")( + "discards an oversized frame split across deterministic stream chunks", + async () => { + const injected = createInjectedProcess({ + onWrite: (text, control) => { + const marker = /!echo (.+)\n/u.exec(text)?.[1]; + if (marker !== undefined) + setImmediate(() => { + control.output(Buffer.alloc(16 * 1024 * 1024 + 1, 0x61)); + control.output(Buffer.from("discard tail")); + control.output(Buffer.from([0])); + control.output(Buffer.from(`${marker}\0`)); + }); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess, { + frameTimeoutMs: 1000, + stopProcess: async () => ({ status: "stopped" }), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const result = await manager.execute(started.value.session_id, "large"); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.result.output_truncated).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "closes an injected session on an oversized unsolicited frame", + async () => { + const injected = createInjectedProcess(); + const { manager, started } = await startInjected(injected.spawnProcess); + expect(started.ok).toBe(true); + if (!started.ok) return; + injected.control.output(Buffer.alloc(16 * 1024 * 1024 + 1, 0x61)); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves unsolicited history and marks oversized recent frames", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.resume(); setTimeout(() => process.stdout.write("x".repeat(17000) + "\\0"), 20);', + ); + const { manager, sessionId } = await start(provider); + await new Promise((resolve) => setTimeout(resolve, 100)); + const status = manager.status(sessionId); + expect(status?.recent_output_truncated).toBe(true); + expect(status?.recent_output.at(-1)).toContain("[recent frame truncated]"); + }, +); + +it.skipIf(process.platform === "win32")( + "bounds recent history after many unsolicited frames", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0" + Array.from({ length: 70 }, (_, i) => "frame" + i + "\\0").join("")); process.stdin.resume();', + ); + const { manager, sessionId } = await start(provider); + const status = manager.status(sessionId); + expect(status?.recent_output).toHaveLength(64); + expect(status?.recent_output_truncated).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "returns partial unknown evidence when the process exits mid-command", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.setEncoding("utf8"); process.stdin.on("data", () => { process.stdout.write("partial\\0"); process.exit(0); });', + ); + const { manager, sessionId } = await start(provider); + const result = await manager.execute(sessionId, "dr"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result).toMatchObject({ + output: "partial", + completion_status: "unknown", + }); + expect(result.value.evidence.limitations.join(" ")).toContain( + "Rizin exited before", + ); + } + }, +); + +it.skipIf(process.platform === "win32")( + "cancels a command after sending it and stops its owned session", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.resume();', + ); + const { manager, sessionId } = await start(provider); + const abort = new AbortController(); + const execution = manager.execute(sessionId, "slow", abort.signal); + setTimeout(() => abort.abort(), 30); + expect((await execution).ok).toBe(false); + expect(manager.status(sessionId)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves partial timeout evidence when cleanup cannot be confirmed", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { if (chunk.includes("slow")) process.stdout.write("partial\\0"); });', + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 25, + stopProcess: incompleteStop, + }); + const result = await manager.execute(sessionId, "slow"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([sessionId]); + const evidence = evidenceSchema.parse(result.error.partialObservation); + expect(evidence.normalized_result).toMatchObject({ + completion_status: "unknown", + }); + expect(evidence.limitations.join(" ")).toContain( + "injected stop uncertainty", + ); + } + }, +); + +it.skipIf(process.platform === "win32")( + "preserves truncated output when cleanup cannot be confirmed", + async () => { + const provider = await makeProvider( + `process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "many") process.stdout.write(Array.from({ length: 4100 }, (_, i) => "frame" + i + "\\0").join("")); + else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + } +});`, + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 1000, + stopProcess: incompleteStop, + }); + const result = await manager.execute(sessionId, "many"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([sessionId]); + const evidence = evidenceSchema.parse(result.error.partialObservation); + expect(evidence.normalized_result).toMatchObject({ + completion_status: "unknown", + }); + expect(evidence.limitations.join(" ")).toContain( + "injected stop uncertainty", + ); + } + }, +); + +it.skipIf(process.platform === "win32")( + "preserves the cleanup uncertainty when cancellation cannot confirm process stop", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.resume();', + ); + const { manager, sessionId } = await start(provider, { + stopProcess: incompleteStop, + }); + const abort = new AbortController(); + const execution = manager.execute(sessionId, "slow", abort.signal); + setTimeout(() => abort.abort(), 30); + const result = await execution; + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.message).toContain("injected stop uncertainty"); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels a queued command without sending it after the prior command completes", + async () => { + const provider = await makeProvider( + `process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "first") setTimeout(() => {}, 100); + else if (line.startsWith("!echo ")) setTimeout(() => process.stdout.write(line.slice(6) + "\\0"), 100); + } +});`, + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 300, + }); + const first = manager.execute(sessionId, "first"); + const abort = new AbortController(); + const queued = manager.execute(sessionId, "second", abort.signal); + setTimeout(() => abort.abort(), 20); + expect((await queued).ok).toBe(false); + expect((await first).ok).toBe(true); + }, +); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts new file mode 100644 index 000000000..af043e12e --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts @@ -0,0 +1,198 @@ +import { afterEach, expect, it } from "vitest"; +import { createRizinCoverageHarness } from "./RizinDebugSessionCoverage.fixtures.js"; + +const { cleanup, makeProvider, start } = createRizinCoverageHarness(); +afterEach(cleanup); + +it.skipIf(process.platform === "win32")( + "keeps command output bounded when many frames arrive for a command", + async () => { + const provider = await makeProvider( + `process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "many") process.stdout.write(Array.from({ length: 4100 }, (_, i) => "frame" + i + "\\0").join("")); + else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + } +});`, + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 1000, + }); + const result = await manager.execute(sessionId, "many"); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.result).toMatchObject({ + output_truncated: true, + completion_status: "unknown", + }); + }, +); + +it.skipIf(process.platform === "win32")( + "returns partial evidence when the command frame deadline is already expired", + async () => { + const provider = await makeProvider( + 'process.stdout.write("\\0"); process.stdin.resume();', + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 0, + }); + const result = await manager.execute(sessionId, "dr"); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.result).toMatchObject({ + output: "", + output_truncated: true, + completion_status: "unknown", + }); + }, +); + +it.skipIf(process.platform === "win32")( + "does not exceed the exact command byte cap when marker output follows a full frame", + async () => { + const provider = await makeProvider( + `process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "full") process.stdout.write(Buffer.alloc(16 * 1024 * 1024, 0x61).toString() + "\\0"); + else if (line.startsWith("!echo ")) process.stdout.write("x" + line.slice(6) + "\\0"); + } +});`, + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 3000, + }); + const result = await manager.execute(sessionId, "full"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(Buffer.byteLength(result.value.result.output, "utf8")).toBe( + 16 * 1024 * 1024, + ); + expect(result.value.result).toMatchObject({ + output_truncated: true, + completion_status: "unknown", + }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "caps aggregate output across multiple valid NUL frames", + async () => { + const provider = await makeProvider( + `process.stdout.write("\\0"); +let input = ""; +let aggregatePending = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "aggregate") { + process.stdout.write("a".repeat(10 * 1024 * 1024) + "\\0"); + aggregatePending = true; + } else if (line.startsWith("!echo ")) { + process.stdout.write((aggregatePending ? "b".repeat(7 * 1024 * 1024) : "") + line.slice(6) + "\\0"); + aggregatePending = false; + } + } +});`, + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 3000, + }); + const result = await manager.execute(sessionId, "aggregate"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(Buffer.byteLength(result.value.result.output, "utf8")).toBe( + 16 * 1024 * 1024, + ); + expect(result.value.result.completion_status).toBe("unknown"); + } + }, +); + +it.skipIf(process.platform === "win32")( + "truncates an oversized NUL frame without decoding a split UTF-8 character", + async () => { + const provider = await makeProvider( + `process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "large") { + process.stdout.write("prefix\\0"); + process.stdout.write(Buffer.alloc(16 * 1024 * 1024 + 1, 0xF0)); + } else if (line.startsWith("!echo ")) process.stdout.write("\\0" + line.slice(6) + "\\0"); + } +});`, + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 2000, + }); + const result = await manager.execute(sessionId, "large"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result).toMatchObject({ + output_truncated: true, + completion_status: "unknown", + }); + expect(result.value.result.output.startsWith("prefix\n")).toBe(true); + } + }, +); + +it.skipIf(process.platform === "win32")( + "truncates an oversized frame when its NUL boundary arrives with the crossing chunk", + async () => { + const provider = await makeProvider( + `process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "large") { + const frame = Buffer.alloc(16 * 1024 * 1024 + 2, 0x61); + frame[frame.length - 1] = 0; + process.stdout.write(frame); + } else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + } +});`, + ); + const { manager, sessionId } = await start(provider, { + frameTimeoutMs: 2000, + }); + const result = await manager.execute(sessionId, "large"); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.result).toMatchObject({ + output_truncated: true, + completion_status: "unknown", + }); + }, +); diff --git a/tests/boundary/providers/RizinDebugSessionManagers.test.ts b/tests/boundary/providers/RizinDebugSessionManagers.test.ts new file mode 100644 index 000000000..3ccc55877 --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionManagers.test.ts @@ -0,0 +1,196 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; + +describe.skipIf(process.platform === "win32")( + "persistent Rizin debugger session manager", + () => { + let directory: string | undefined; + const managers: Array<{ closeAll(): Promise }> = []; + + afterEach(async () => { + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; + }); + + it("uses an absolute target path and waits for Rizin's completion marker", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-manager-")); + const provider = join(directory, "fake-rizin.mjs"); + const argsFile = join(directory, "args.json"); + await writeFile( + provider, + `#!/usr/bin/env node +const fs = await import("node:fs"); +fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2))); +process.stdout.write("\\0"); +let input = ""; +let stalled = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); + input = input.slice(boundary + 1); + if (line === "dr") process.stdout.write("backend-event\\0rax=0x1234\\0"); + else if (line === "olj") process.stdout.write("[]\\0"); + else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\n\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + managers.push(manager); + + const started = await manager.start({ + path: "-target.bin", + inferior_tty: await rizinTestInferiorTty(), + backend: "test-backend", + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const providerArgs = await import("node:fs/promises").then( + ({ readFile }) => readFile(argsFile, "utf8"), + ); + expect(JSON.parse(providerArgs)).toEqual([ + "-N", + "-0", + "-d", + "-R", + `stdout=${await rizinTestInferiorTty()}`, + "-R", + `stderr=${await rizinTestInferiorTty()}`, + "-e", + "dbg.exitkills=false", + "-D", + "test-backend", + resolve("-target.bin"), + ]); + + const result = await manager.execute(started.value.session_id, "dr"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result.output).toBe("backend-event\nrax=0x1234"); + expect(result.value.result.output_scope).toBe( + "command_and_interleaved_session_output", + ); + expect(result.value.result.backend).toBe("test-backend"); + } + expect((await manager.close(started.value.session_id)).ok).toBe(true); + expect(manager.status(started.value.session_id)).toBeUndefined(); + }); + + it("stops and invalidates a Rizin session after its completion-marker timeout", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-timeout-")); + const provider = join(directory, "fake-rizin-stall.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +let stalled = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); + input = input.slice(boundary + 1); + if (line === "dr") process.stdout.write("rax=0x1234\\0"); + else if (line === "slow") { + stalled = true; + process.stdout.write("partial-timeout\\0"); + } + else if (line.startsWith("!echo ") && !stalled) process.stdout.write(line.slice(6) + "\\n\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: provider }, + { frameTimeoutMs: 50 }, + ); + managers.push(manager); + + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const timedOut = await manager.execute(started.value.session_id, "slow"); + expect(timedOut.ok).toBe(true); + if (timedOut.ok) { + expect(timedOut.value.result).toMatchObject({ + output: "partial-timeout", + output_truncated: true, + completion_status: "unknown", + }); + expect(timedOut.value.evidence.limitations.join(" ")).toContain( + "timed out", + ); + } + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + expect((await manager.execute(started.value.session_id, "dr")).ok).toBe( + false, + ); + expect((await manager.close(started.value.session_id)).ok).toBe(true); + }); + }, +); + +it.skipIf(process.platform === "win32")( + "stops Rizin when empty unsolicited NUL frames exhaust the bounded queue", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-empty-frames-")); + const provider = join(directory, "fake-rizin-empty.mjs"); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + try { + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +setTimeout(() => process.stdout.write("\\0".repeat(5000)), 10); +process.stdin.resume(); +`, + ); + await chmod(provider, 0o700); + + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + expect((await manager.close(started.value.session_id)).ok).toBe(true); + } finally { + await manager.closeAll().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts b/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts new file mode 100644 index 000000000..749995a3d --- /dev/null +++ b/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts @@ -0,0 +1,894 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, it } from "vitest"; + +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + spawnOwnedProviderProcess, + type ProviderProcessHandle, +} from "../../../src/process/ProviderProcess.js"; +import { evidenceSchema } from "../../../src/domain/evidence.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; +import { createInjectedProcess } from "./RizinDebugSessionCoverage.fixtures.js"; + +let directory: string | undefined; +const managers: Array<{ closeAll(): Promise }> = []; +const inferiorProcesses: ChildProcess[] = []; + +const startRizinWithProvider = async ( + provider: string, +): Promise<{ + manager: RizinDebugSessionManager; + sessionId: string | undefined; +}> => { + const manager = createRizinTestManager({ REA_RIZIN_COMMAND: provider }); + managers.push(manager); + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + return { + manager, + sessionId: started.ok ? started.value.session_id : undefined, + }; +}; + +const startRizinWithOwnedStop = async (provider: string) => { + let debuggerProcess: ProviderProcessHandle | undefined; + const manager = new RizinDebugSessionManager( + { ...process.env, REA_RIZIN_COMMAND: provider }, + { + platform: "linux", + spawnProcess: async (options) => { + const launched = await spawnOwnedProviderProcess(options); + debuggerProcess = launched.process; + return launched; + }, + }, + ); + managers.push(manager); + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + if (!started.ok) throw started.error; + return { + manager, + sessionId: started.value.session_id, + exitDebugger: async () => { + if (debuggerProcess === undefined) + throw new Error("Provider did not spawn"); + const exited = new Promise((resolve) => + debuggerProcess?.once("exit", () => resolve()), + ); + debuggerProcess.kill("SIGTERM"); + await exited; + }, + }; +}; + +const expectOwnedProcessExited = async (pidText: string): Promise => { + const pid = Number(pidText); + expect(Number.isInteger(pid)).toBe(true); + expect(() => process.kill(pid, 0)).toThrow(); +}; + +afterEach(async () => { + for (const inferior of inferiorProcesses.splice(0)) { + if (inferior.exitCode !== null || inferior.signalCode !== null) continue; + const exited = new Promise((resolveExit) => + inferior.once("exit", () => resolveExit()), + ); + inferior.kill("SIGTERM"); + await exited; + } + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const spawnTestInferior = async (): Promise => { + const inferior = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { + stdio: "ignore", + }, + ); + inferiorProcesses.push(inferior); + await new Promise((resolveSpawn, rejectSpawn) => { + inferior.once("spawn", resolveSpawn); + inferior.once("error", rejectSpawn); + }); + return inferior; +}; + +const createInferiorInventoryProvider = async ( + rows: readonly { readonly fd: number; readonly pid: number }[], +): Promise => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-owned-inferior-")); + const provider = join(directory, "fake-rizin-owned-inferior.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line === "olj") process.stdout.write(JSON.stringify(${JSON.stringify(rows.map(({ fd, pid }) => ({ fd, raised: true, uri: `ptrace://${pid}` })))} ) + "\\0"); + else if (line === "e dbg.exitkills") process.stdout.write("true\\0"); + else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + else process.stdout.write("\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + return provider; +}; + +it("keeps persistent Rizin sessions unavailable on Windows", async () => { + const rizin = await createRizinTestManager( + { REA_RIZIN_COMMAND: "/missing/rizin" }, + { platform: "win32" }, + ).start({ + path: "/tmp/fixture.bin", + inferior_tty: "unused-before-platform-admission", + }); + expect(rizin.ok).toBe(false); + if (!rizin.ok) { + expect(rizin.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(rizin.error.message).toContain("Linux /proc descriptor readback"); + } +}); + +it.skipIf(process.platform === "win32")( + "rejects startup when Rizin exits immediately after its initial frame", + async () => { + const injected = createInjectedProcess({ + startup: (control) => { + control.output(Buffer.from("startup diagnostic\0partial trailing")); + control.exit(); + }, + }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess }, + ); + managers.push(manager); + + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(started.error.message).toContain("exited"); + expect(started.error.message).not.toContain("timed out"); + expect(started.error.capturedOutput).toMatchObject({ + stdout: "startup diagnostic\npartial trailing", + stderr: "", + truncated: false, + }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "preserves partial Rizin stdout when startup times out before its first frame", + async () => { + const injected = createInjectedProcess({ + startup: (control) => control.output("partial startup diagnostic"), + }); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess, startupTimeoutMs: 25 }, + ); + managers.push(manager); + + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + + expect(started.ok).toBe(false); + if (!started.ok) { + expect(started.error._tag).toBe("AnalysisTimeoutError"); + expect(started.error.capturedOutput).toMatchObject({ + stdout: expect.stringContaining("partial startup diagnostic"), + stderr: "", + truncated: false, + }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps session UTF-8 status cumulative while command evidence reports only its frames", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-command-utf8-scope-")); + const provider = join(directory, "fake-rizin-command-utf8-scope.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write(Buffer.from([0xff, 0])); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line === "invalid-output") process.stdout.write(Buffer.from([0xfe, 0])); + else if (!line.startsWith("!echo ")) process.stdout.write("valid output\\0"); + else process.stdout.write(line.slice(6) + "\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const { manager, sessionId } = await startRizinWithProvider(provider); + if (sessionId === undefined) return; + + expect(manager.status(sessionId)?.output_invalid_utf8).toBe(true); + const firstValid = await manager.execute(sessionId, "valid-before-invalid"); + expect(firstValid.ok).toBe(true); + if (firstValid.ok) { + expect(firstValid.value.result).not.toHaveProperty("output_invalid_utf8"); + expect(firstValid.value.evidence.raw_result).not.toHaveProperty( + "output_invalid_utf8", + ); + expect(firstValid.value.evidence.normalized_result).not.toHaveProperty( + "output_invalid_utf8", + ); + expect(firstValid.value.evidence.limitations.join(" ")).not.toContain( + "Collected command frames contained invalid UTF-8 bytes", + ); + } + + const invalid = await manager.execute(sessionId, "invalid-output"); + expect(invalid.ok).toBe(true); + if (invalid.ok) { + expect(invalid.value.result.output_invalid_utf8).toBe(true); + expect(invalid.value.evidence.raw_result).toMatchObject({ + output_invalid_utf8: true, + }); + expect(invalid.value.evidence.normalized_result).toMatchObject({ + output_invalid_utf8: true, + }); + expect(invalid.value.evidence.limitations.join(" ")).toContain( + "Collected command frames contained invalid UTF-8 bytes; decoded text may contain replacement characters.", + ); + } + + const laterValid = await manager.execute(sessionId, "valid-after-invalid"); + expect(laterValid.ok).toBe(true); + if (laterValid.ok) { + expect(laterValid.value.result).not.toHaveProperty("output_invalid_utf8"); + expect(laterValid.value.evidence.raw_result).not.toHaveProperty( + "output_invalid_utf8", + ); + expect(laterValid.value.evidence.normalized_result).not.toHaveProperty( + "output_invalid_utf8", + ); + expect(laterValid.value.evidence.limitations.join(" ")).not.toContain( + "Collected command frames contained invalid UTF-8 bytes", + ); + } + expect(manager.status(sessionId)?.output_invalid_utf8).toBe(true); + }, +); + +it.skipIf(process.platform !== "linux")( + "clears retained Rizin cleanup after its identified inferior exits", + async () => { + const inferior = await spawnTestInferior(); + const pid = inferior.pid; + expect(pid).toBeDefined(); + if (pid === undefined) return; + const provider = await createInferiorInventoryProvider([{ fd: 12, pid }]); + const { manager, sessionId, exitDebugger } = + await startRizinWithOwnedStop(provider); + if (sessionId === undefined) return; + + const firstClose = await manager.close(sessionId); + + expect(firstClose.ok, JSON.stringify(firstClose)).toBe(false); + if (!firstClose.ok) expect(firstClose.error.cleanupIncomplete).toBe(true); + await exitDebugger(); + expect((await manager.retryCleanupFailures()).ok).toBe(false); + expect(manager.status(sessionId)?.state).toBe("closed"); + + const inferiorExited = new Promise((resolveExit) => + inferior.once("exit", () => resolveExit()), + ); + inferior.kill("SIGTERM"); + await inferiorExited; + const retried = await manager.retryCleanupFailures(); + + expect(retried).toEqual({ ok: true, value: null }); + if (!firstClose.ok) expect(firstClose.error.cleanupIncomplete).toBe(false); + expect(manager.status(sessionId)).toBeUndefined(); + }, +); + +it.skipIf(process.platform !== "linux")( + "keeps Rizin cleanup unknown when an inferior inventory is only partially captured", + async () => { + const inferior = await spawnTestInferior(); + const pid = inferior.pid; + expect(pid).toBeDefined(); + if (pid === undefined) return; + const provider = await createInferiorInventoryProvider([ + { fd: 12, pid }, + { fd: 13, pid: 999_999_999 }, + ]); + const { manager, sessionId, exitDebugger } = + await startRizinWithOwnedStop(provider); + if (sessionId === undefined) return; + + expect((await manager.close(sessionId)).ok).toBe(false); + await exitDebugger(); + const closed = await manager.close(sessionId); + + expect(closed.ok, JSON.stringify(closed)).toBe(false); + if (!closed.ok) + expect(closed.error.message).toContain("no owner proof confirms"); + expect(manager.status(sessionId)).toBeDefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "retains Rizin cleanup when target state is unknown at debugger exit", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-unknown-target-")); + const provider = join(directory, "fake-rizin-unknown-target.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line === "olj") process.stdout.write("not-json\\0"); + else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const { manager, sessionId, exitDebugger } = + await startRizinWithOwnedStop(provider); + if (sessionId === undefined) return; + + expect((await manager.close(sessionId)).ok).toBe(false); + await exitDebugger(); + const closed = await manager.close(sessionId); + + expect(closed.ok, JSON.stringify(closed)).toBe(false); + if (!closed.ok) + expect(closed.error.message).toContain("no owner proof confirms"); + expect(manager.status(sessionId)).toBeDefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves command parameters in Rizin observation evidence", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-command-evidence-")); + const provider = join(directory, "fake-rizin-command-evidence.mjs"); + const inferiorTty = await rizinTestInferiorTty(); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (!line.startsWith("!echo ")) process.stdout.write("command output\\0"); + else process.stdout.write(line.slice(6) + "\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager({ REA_RIZIN_COMMAND: provider }); + managers.push(manager); + const callerSelectedPath = "./relative/../fixture.bin"; + const resolvedPath = resolve(callerSelectedPath); + const started = await manager.start({ + path: callerSelectedPath, + inferior_tty: inferiorTty, + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const executed = await manager.execute(started.value.session_id, "dr"); + + expect(executed.ok).toBe(true); + if (executed.ok) + expect(executed.value.evidence.parameters).toEqual({ + session_id: started.value.session_id, + command: "dr", + inferior_tty: inferiorTty, + launch_provenance: { + caller_selected_path: callerSelectedPath, + resolved_path: resolvedPath, + }, + }); + if (executed.ok) + expect(executed.value.evidence.limitations).toContain( + "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.", + ); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps queued Rizin commands serialized when a waiting request is cancelled", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-queue-cancel-")); + const provider = join(directory, "fake-rizin-queue-cancel.mjs"); + const overlapPath = join(directory, "overlap"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +let firstActive = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line === "first") firstActive = true; + if (line === "third" && firstActive) import("node:fs/promises").then((fs) => fs.writeFile(${JSON.stringify(overlapPath)}, "overlap")); + if (line.startsWith("!echo ")) { + const marker = line.slice(6); + if (firstActive) setTimeout(() => { firstActive = false; process.stdout.write(marker + "\\0"); }, 100); + else process.stdout.write(marker + "\\0"); + } + } +}); +`, + ); + await chmod(provider, 0o700); + const { manager, sessionId } = await startRizinWithProvider(provider); + if (sessionId === undefined) return; + const first = manager.execute(sessionId, "first"); + const controller = new AbortController(); + const second = manager.execute(sessionId, "second", controller.signal); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 20)); + controller.abort(); + const third = manager.execute(sessionId, "third"); + expect((await second).ok).toBe(false); + expect((await first).ok).toBe(true); + expect((await third).ok).toBe(true); + await expect(readFile(overlapPath, "utf8")).rejects.toThrow(); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves a truncated single Rizin frame that exceeds the protocol limit", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-frame-overflow-")); + const provider = join(directory, "fake-rizin-frame-overflow.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +let sent = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (!line.startsWith("!echo ") && !sent) { + sent = true; + process.stdout.write("z".repeat(16 * 1024 * 1024 + 32) + "\\0"); + } + } +}); +`, + ); + await chmod(provider, 0o700); + const { manager, sessionId } = await startRizinWithProvider(provider); + if (sessionId === undefined) return; + + const result = await manager.execute(sessionId, "px"); + + expect(result.ok, result.ok ? "" : result.error.message).toBe(true); + if (result.ok) { + expect( + Buffer.byteLength(result.value.result.output, "utf8"), + ).toBeLessThanOrEqual(16 * 1024 * 1024); + expect(result.value.result).toMatchObject({ + output_truncated: true, + completion_status: "unknown", + }); + expect(result.value.evidence.parameters).toMatchObject({ + session_id: sessionId, + command: "px", + inferior_tty: expect.any(String), + }); + } + expect(manager.status(sessionId)).toBeDefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "returns bounded partial Rizin output with unknown completion after overflow", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-output-overflow-")); + const provider = join(directory, "fake-rizin-output-overflow.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + else if (line === "olj") process.stdout.write(JSON.stringify([{ fd: 12, raised: true, uri: "ptrace://" + process.ppid }]) + "\\0"); + else if (line === "px") process.stdout.write("x".repeat(10 * 1024 * 1024) + "\\0" + "y".repeat(10 * 1024 * 1024) + "\\0"); + else process.stdout.write("\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const { manager, sessionId } = await startRizinWithProvider(provider); + if (sessionId === undefined) return; + + const result = await manager.execute(sessionId, "px"); + + expect(result.ok, result.ok ? "" : result.error.message).toBe(true); + if (result.ok) { + expect( + Buffer.byteLength(result.value.result.output, "utf8"), + ).toBeLessThanOrEqual(16 * 1024 * 1024); + expect(result.value.result).toMatchObject({ + output_truncated: true, + completion_status: "unknown", + }); + } + expect(manager.status(sessionId)).toBeDefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "waits for the Rizin process to exit when command correlation times out", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-command-timeout-")); + const provider = join(directory, "fake-rizin-command-timeout.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +await import("node:fs/promises").then((fs) => fs.writeFile(${JSON.stringify(join(directory ?? "", "pid"))}, String(process.pid))); +process.stdout.write("\\0"); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", () => process.stdout.write(Buffer.from([0xff, 0]))); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: provider }, + { frameTimeoutMs: 40 }, + ); + managers.push(manager); + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const result = await manager.execute(started.value.session_id, "dr"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result).toMatchObject({ + output: "�", + output_truncated: true, + completion_status: "unknown", + output_invalid_utf8: true, + }); + expect(result.value.evidence.limitations).toContain( + "Collected command frames contained invalid UTF-8 bytes; decoded text may contain replacement characters.", + ); + } + await expectOwnedProcessExited( + await readFile(join(directory, "pid"), "utf8"), + ); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels a running Rizin command and retains its closed status", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-command-cancel-")); + const provider = join(directory, "fake-rizin-command-cancel.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let input = ""; +let stalled = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line === "slow") { + stalled = true; + process.stdout.write(Buffer.from([0xff, 0])); + } + else if (line.startsWith("!echo ") && !stalled) process.stdout.write(line.slice(6) + "\\n\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: provider }, + { frameTimeoutMs: 5_000 }, + ); + managers.push(manager); + const callerSelectedPath = "./cancelled/../fixture.bin"; + const resolvedPath = resolve(callerSelectedPath); + const started = await manager.start({ + path: callerSelectedPath, + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + const result = await manager.execute( + started.value.session_id, + "slow", + controller.signal, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { output_invalid_utf8: true }, + limitations: expect.arrayContaining([ + "Collected command frames contained invalid UTF-8 bytes; decoded text may contain replacement characters.", + ]), + parameters: { + launch_provenance: { + caller_selected_path: callerSelectedPath, + resolved_path: resolvedPath, + }, + }, + }); + } + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "reports bounded Rizin stderr retention in session status", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-diagnostic-limit-")); + const provider = join(directory, "fake-rizin-diagnostic-limit.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stderr.write("x".repeat(100000)); +process.stdout.write("\\0"); +process.stdin.resume(); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + managers.push(manager); + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + + expect( + manager.status(started.value.session_id)?.diagnostics_truncated, + ).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "returns cancellation when Rizin startup is cancelled after spawn", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-start-cancel-")); + const provider = join(directory, "fake-rizin-start-cancel.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node\nprocess.stdin.resume();\nsetInterval(() => {}, 1000);\n`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + managers.push(manager); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + const started = await manager.start( + { path: "/tmp/fixture.bin", inferior_tty: await rizinTestInferiorTty() }, + controller.signal, + ); + + expect(started.ok).toBe(false); + if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError"); + }, +); + +it.skipIf(process.platform === "win32")( + "converts a Rizin stdin pipe failure into a provider session error", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-stdin-error-")); + const provider = join(directory, "fake-rizin-stdin-error.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +setTimeout(() => import("node:fs").then((fs) => fs.closeSync(0)), 40); +setInterval(() => {}, 1000); +`, + ); + await chmod(provider, 0o700); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + }); + managers.push(manager); + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 80)); + + const result = await manager.execute(started.value.session_id, "dr"); + + expect(result.ok).toBe(false); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves Rizin process-exit reason and partial command output", + async () => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-command-exit-")); + const provider = join(directory, "fake-rizin-command-exit.mjs"); + const writerPidPath = join(directory, "writer.pid"); + await writeFile( + provider, + `#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +process.stdout.write("\\0"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const boundary = input.indexOf("\\n"); + if (boundary < 0) break; + const line = input.slice(0, boundary); input = input.slice(boundary + 1); + if (line === "exit-now") { + const writer = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" }); + writeFileSync(${JSON.stringify(writerPidPath)}, String(writer.pid)); + process.stdout.write(Buffer.concat([Buffer.from("partial-before-exit\\0second-partial\\0"), Buffer.from([0xff, 0])]), () => process.exit(7)); + } + } +}); +`, + ); + await chmod(provider, 0o700); + let writerObserved = false; + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: provider }, + { + stopProcess: async () => { + const writerPid = Number(await readFile(writerPidPath, "utf8")); + try { + process.kill(writerPid, 0); + writerObserved = true; + } catch { + writerObserved = false; + } + return { + status: "incomplete", + reason: "owned output writer is still active", + }; + }, + }, + ); + managers.push(manager); + const callerSelectedPath = "./exit/../fixture.bin"; + const resolvedPath = resolve(callerSelectedPath); + const started = await manager.start({ + path: callerSelectedPath, + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const exited = await manager.execute(started.value.session_id, "exit-now"); + + expect(exited.ok).toBe(false); + expect(writerObserved).toBe(true); + if (!exited.ok) { + expect(exited.error.cleanup).toEqual({ + reason: "owned output writer is still active", + resources: [started.value.session_id], + }); + expect(exited.error.partialObservation).toMatchObject({ + normalized_result: { + output: ["partial-before-exit", "second-partial", "�"].join("\n"), + completion_status: "unknown", + output_invalid_utf8: true, + }, + parameters: { + command: "exit-now", + session_id: started.value.session_id, + launch_provenance: { + caller_selected_path: callerSelectedPath, + resolved_path: resolvedPath, + }, + }, + }); + const partialEvidence = evidenceSchema.parse( + exited.error.partialObservation, + ); + expect(partialEvidence.limitations.join(" ")).toContain( + "Rizin exited before the command-completion marker; retained frame output is partial and command completion is unknown.", + ); + } + const writerPid = Number(await readFile(writerPidPath, "utf8")); + process.kill(writerPid, "SIGKILL"); + }, +); diff --git a/tests/boundary/providers/RizinInferiorTty.test.ts b/tests/boundary/providers/RizinInferiorTty.test.ts new file mode 100644 index 000000000..4c19760d5 --- /dev/null +++ b/tests/boundary/providers/RizinInferiorTty.test.ts @@ -0,0 +1,645 @@ +import { execFileSync, spawn as spawnChild } from "node:child_process"; +import { + chmod, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "@lydell/node-pty"; +import { afterEach, expect, it, vi } from "vitest"; + +import { + assertRizinProcessStartTimeStable, + parseRizinProcStartTime, + RizinDebugSessionManager, +} from "../../../src/rizin/RizinDebugSessionManager.js"; +import { evidenceSchema } from "../../../src/domain/evidence.js"; +import { TOOL_EFFECTS } from "../../../src/contracts/toolEffects.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { + createRizinTestManager, + rizinTestInferiorTty, +} from "./RizinTestInferiorTty.js"; +import { createInjectedProcess } from "./RizinDebugSessionCoverage.fixtures.js"; + +let terminal: ReturnType | undefined; +let manager: RizinDebugSessionManager | undefined; +let testDirectory: string | undefined; +let inferiorPid: number | undefined; +let providerPid: number | undefined; +let providerWatchdogPid: number | undefined; +const rizinBinary = findRizinBinary(); + +afterEach(async () => { + await manager?.closeAll().catch(() => undefined); + manager = undefined; + if (providerPid !== undefined && testDirectory !== undefined) { + try { + const commandLine = await readFile( + `/proc/${providerPid}/cmdline`, + "utf8", + ); + if (commandLine.includes(testDirectory)) + process.kill(providerPid, "SIGKILL"); + } catch { + // The owned fake provider may already have exited. + } + } + providerPid = undefined; + if (providerWatchdogPid !== undefined) { + try { + process.kill(providerWatchdogPid, "SIGTERM"); + } catch { + // The watchdog may already have completed. + } + } + providerWatchdogPid = undefined; + terminal?.kill(); + terminal = undefined; + let ownedPid = inferiorPid; + if (ownedPid === undefined && testDirectory !== undefined) { + try { + ownedPid = Number( + await readFile(join(testDirectory, "target.pid"), "utf8"), + ); + } catch { + // The target may not have started before the test failed. + } + } + if ( + ownedPid !== undefined && + Number.isSafeInteger(ownedPid) && + ownedPid > 0 + ) { + try { + const commandLine = await readFile(`/proc/${ownedPid}/cmdline`, "utf8"); + if (testDirectory !== undefined && commandLine.includes(testDirectory)) + process.kill(ownedPid, "SIGKILL"); + } catch { + // The test target may already have exited. + } + } + inferiorPid = undefined; + if (testDirectory !== undefined) + await rm(testDirectory, { recursive: true, force: true }); + testDirectory = undefined; +}); + +it("requires an explicit inferior TTY before spawning Rizin", async () => { + let spawned = false; + const manager = new RizinDebugSessionManager( + { REA_RIZIN_COMMAND: "rizin" }, + { + spawnProcess: async () => { + spawned = true; + throw new Error("must not spawn"); + }, + }, + ); + + const result = await manager.start({ path: "/tmp/fixture.bin" } as never); + + expect(result.ok).toBe(false); + expect(spawned).toBe(false); +}); + +it.skipIf(process.platform === "win32")( + "fails the protocol when a NUL delimiter does not advance the frame parser", + async () => { + const injected = createInjectedProcess({ + startup: (control) => control.output(Buffer.from([0, 0])), + }); + manager = createRizinTestManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { spawnProcess: injected.spawnProcess }, + ); + const originalIndexOf = Buffer.prototype.indexOf; + let forcedNonAdvancingBoundary = false; + const indexOf = vi + .spyOn(Buffer.prototype, "indexOf") + .mockImplementation(function (this: Buffer, value, byteOffset, encoding) { + if ( + this.length === 2 && + this[0] === 0 && + this[1] === 0 && + byteOffset === 1 + ) { + forcedNonAdvancingBoundary = true; + return 0; + } + return originalIndexOf.call(this, value, byteOffset, encoding); + }); + try { + const started = await manager.start({ + path: "/tmp/injected", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(forcedNonAdvancingBoundary).toBe(true); + expect(started.ok).toBe(false); + if (!started.ok) + expect(started.error.message).toContain( + "frame parser failed to advance past a NUL delimiter", + ); + } finally { + indexOf.mockRestore(); + } + }, +); + +it("declares that Rizin close can resume target code and mutate target state", () => { + expect(TOOL_EFFECTS.close_rizin_debug_session).toMatchObject({ + mutatesTarget: true, + executesForeignCode: true, + }); +}); + +it.skipIf(process.platform !== "linux").each([ + { name: "empty inferior list", response: "[]", closeSucceeds: true }, + { name: "malformed JSON", response: "not-json", closeSucceeds: false }, + { name: "non-array JSON", response: "{}", closeSucceeds: false }, + { + name: "valid non-ptrace descriptor", + response: '[{"raised":true,"fd":3,"uri":"file:///tmp/target"}]', + closeSucceeds: true, + }, + { + name: "malformed descriptor row", + response: "[null]", + closeSucceeds: false, + }, + { + name: "inactive ptrace row", + response: '[{"raised":false,"fd":3,"uri":"ptrace://1"}]', + closeSucceeds: false, + }, + { + name: "malformed ptrace URI", + response: '[{"raised":true,"fd":3,"uri":"ptrace://bad"}]', + closeSucceeds: false, + }, + { + name: "multiple ptrace rows", + response: + '[{"raised":true,"fd":3,"uri":"ptrace://1"},{"raised":true,"fd":4,"uri":"ptrace://2"}]', + closeSucceeds: false, + }, +])( + "validates Rizin `olj` close response: $name", + async ({ response, closeSucceeds }) => { + const tty = await rizinTestInferiorTty(); + testDirectory = await mkdtemp(join(tmpdir(), "rea-rizin-olj-")); + const provider = join(testDirectory, "fake-rizin.cjs"); + await writeFile( + provider, + `#!/usr/bin/env node +process.stdout.write("\\0"); +let oljCalls = 0; +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "olj") process.stdout.write((++oljCalls === 1 ? ${JSON.stringify(response)} : "[]") + "\\0"); + else if (line === "e dbg.exitkills") process.stdout.write("false\\0"); + else if (line.startsWith("!echo ")) process.stdout.write(line.slice(6) + "\\0"); + } +}); +`, + ); + execFileSync(process.execPath, ["--check", provider]); + await chmod(provider, 0o700); + manager = new RizinDebugSessionManager({ REA_RIZIN_COMMAND: provider }); + const started = await manager.start({ + path: "/tmp/fake-target", + inferior_tty: tty, + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const closed = await manager.close(started.value.session_id); + expect(closed.ok).toBe(closeSucceeds); + if (!closeSucceeds) { + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + await manager.closeAll(); + } + }, +); + +it.skipIf(process.platform !== "linux").each([ + { + name: "same descriptor remains listed after detach", + afterDetach: (pid: number) => + `[{"raised":true,"fd":3,"uri":"ptrace://${pid}"}]`, + }, + { + name: "same inferior remains listed under a different FD", + afterDetach: (pid: number) => + `[{"raised":true,"fd":4,"uri":"ptrace://${pid}"}]`, + }, + { + name: "an unexpected ptrace inferior remains listed", + afterDetach: () => '[{"raised":true,"fd":4,"uri":"ptrace://999999999"}]', + }, +])( + "retains ownership when o- readback finds $name", + async ({ afterDetach }) => { + const sessionId = await startFakeInferiorSession({ afterDetach }); + await expectCloseRejectedAndReleased(sessionId); + }, +); + +it.skipIf(process.platform !== "linux")( + "detaches a verified fake inferior before stopping the Rizin debugger", + async () => { + const sessionId = await startFakeInferiorSession(); + + const closed = await manager!.close(sessionId); + + expect(closed.ok).toBe(true); + if (!closed.ok) return; + expect(manager!.status(sessionId)).toBeUndefined(); + expect(inferiorPid).toBeDefined(); + expect(() => process.kill(inferiorPid!, 0)).not.toThrow(); + const stat = await readFile(`/proc/${inferiorPid!}/stat`, "utf8"); + const commandEnd = stat.lastIndexOf(") "); + expect(commandEnd).toBeGreaterThan(0); + expect(stat.slice(commandEnd + 2).trimStart()[0]).not.toBe("Z"); + }, +); + +it.skipIf(process.platform !== "linux")( + "refuses detach when Rizin reports dbg.exitkills=true", + async () => { + const sessionId = await startFakeInferiorSession({ exitKills: "true" }); + await expectCloseRejectedAndReleased(sessionId); + }, +); + +it.skipIf(process.platform !== "linux")( + "refuses detach when the inferior stderr is not routed to the selected TTY", + async () => { + const sessionId = await startFakeInferiorSession({ + stderrPath: "/dev/null", + }); + await expectCloseRejectedAndReleased(sessionId); + }, +); + +it.skipIf(process.platform !== "linux")( + "retains ownership when the ptrace PID has no proc identity", + async () => { + const sessionId = await startFakeInferiorSession({ + launchTarget: false, + initialRows: '[{"raised":true,"fd":3,"uri":"ptrace://999999999"}]', + startupWatchdogMs: 2_000, + }); + await expectCloseRejectedAndReleased(sessionId); + }, + 4_000, +); + +it("parses Rizin's Linux proc stat start time and rejects malformed identity data", () => { + const fields = Array.from({ length: 20 }, (_, index) => String(index + 1)); + expect( + parseRizinProcStartTime(`123 (target ) name) ${fields.join(" ")}`), + ).toBe("20"); + expect(() => parseRizinProcStartTime("malformed stat contents")).toThrow( + "Inferior process identity is malformed", + ); + expect(() => parseRizinProcStartTime("123 (target) short")).toThrow( + "Inferior process start time could not be verified", + ); + expect(() => assertRizinProcessStartTimeStable("11", "12")).toThrow( + "disappeared or was reused", + ); + expect(() => assertRizinProcessStartTimeStable("11", "11")).not.toThrow(); +}); + +it.skipIf(process.platform !== "linux")( + "retains ownership when cleanup cannot read Rizin stdin", + async () => { + const injected = createInjectedProcess({ stdin: "null" }); + manager = new RizinDebugSessionManager( + { REA_RIZIN_COMMAND: "injected-rizin" }, + { + spawnProcess: injected.spawnProcess, + stopProcess: async (session) => { + try { + await session.detachInferior?.(); + } catch { + // This fixture verifies the manager's fail-closed read path only. + } + return { status: "incomplete", reason: "unreachable" }; + }, + }, + ); + const started = await manager.start({ + path: "/tmp/fake-target", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + expect((await manager.close(started.value.session_id)).ok).toBe(false); + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + }, +); + +it.skipIf(process.platform !== "linux")( + "retains ownership when an internal Rizin frame is truncated", + async () => { + const sessionId = await startFakeInferiorSession({ + truncateCleanupFrame: true, + maxCommandOutputBytes: 1_024, + }); + expect((await manager!.close(sessionId)).ok).toBe(false); + expect(manager!.status(sessionId)?.state).toBe("closed"); + await expect(manager!.closeAll()).rejects.toThrow("did not stop cleanly"); + }, +); + +it.skipIf(process.platform !== "linux")( + "retains a verified active inferior after public command output truncation makes protocol state ambiguous", + async () => { + const sessionId = await startFakeInferiorSession({ + overflowPxCommand: true, + maxCommandOutputBytes: 1_024, + }); + let watchdog: ReturnType | undefined; + const completed = await Promise.race([ + manager!.execute(sessionId, "px"), + new Promise((_, reject) => { + watchdog = setTimeout( + () => reject(new Error("detach watchdog expired")), + 3_000, + ); + }), + ]) + .catch(async (error: unknown) => { + if (providerPid !== undefined) { + try { + process.kill(providerPid, "SIGKILL"); + } catch { + // The watchdog may race with provider exit. + } + } + // The mutated path can leave commandTail permanently held. Prevent cleanup + // from awaiting that same tail; afterEach still kills both child processes. + manager = undefined; + throw error; + }) + .finally(() => { + if (watchdog !== undefined) clearTimeout(watchdog); + }); + + expect(completed.ok).toBe(false); + if (completed.ok) return; + expect(completed.error.cleanupIncomplete).toBe(true); + expect(completed.error.cleanupResources).toEqual([sessionId]); + const evidence = evidenceSchema.parse(completed.error.partialObservation); + expect(evidence.normalized_result).toMatchObject({ + output_truncated: true, + }); + expect(evidence.limitations.join(" ")).toContain( + "The owned debugger session remains retained because cleanup could not be verified", + ); + expect(manager!.status(sessionId)?.state).toBe("closed"); + }, +); + +it.skipIf( + process.platform !== "linux" || + rizinBinary === undefined || + !rizinAvailable(rizinBinary), +)( + "routes the inferior to the selected TTY and verifies detach with Rizin 0.7.4 protocol", + async () => { + let terminalOutput = ""; + terminal = spawn("sh", ["-c", "tty; exec sleep 60"], { + name: "xterm", + cols: 80, + rows: 24, + encoding: "utf8", + }); + const ttyPath = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("TTY path not printed")), + 2_000, + ); + terminal!.onData((data) => { + terminalOutput += data; + const match = /\/dev\/pts\/\d+/u.exec(terminalOutput); + if (match) { + clearTimeout(timer); + resolve(match[0]); + } + }); + }); + const tty = await realpath(ttyPath); + testDirectory = await mkdtemp(join(tmpdir(), "rea-rizin-tty-")); + const wrapper = join(testDirectory, "rizin-with-long-lived-inferior.sh"); + const pidPath = join(testDirectory, "target.pid"); + const releasePath = join(testDirectory, "emit-output"); + const target = join(testDirectory, "target.py"); + await writeFile( + target, + `import os, sys, time\nopen(${JSON.stringify(pidPath)}, "w").write(str(os.getpid()))\nwhile not os.path.exists(${JSON.stringify(releasePath)}): time.sleep(0.02)\nprint("REA_RIZIN_STDOUT_MARKER", flush=True)\nprint("REA_RIZIN_STDERR_MARKER", file=sys.stderr, flush=True)\ntime.sleep(60)\n`, + ); + await writeFile( + wrapper, + `#!/bin/sh\nexec ${shellQuote(rizinBinary!)} -R arg1=${target} "$@"\n`, + ); + await chmod(wrapper, 0o700); + manager = new RizinDebugSessionManager({ REA_RIZIN_COMMAND: wrapper }); + const started = await manager.start({ + path: "/usr/bin/python3", + inferior_tty: tty, + }); + expect(started.ok, started.ok ? "" : started.error.message).toBe(true); + if (!started.ok) return; + const closing = manager.close(started.value.session_id); + expect((await manager.execute(started.value.session_id, "dr")).ok).toBe( + false, + ); + const closed = await closing; + expect(closed.ok, closed.ok ? "" : closed.error.message).toBe(true); + const deadline = Date.now() + 5_000; + let pidText = ""; + while (Date.now() < deadline) { + try { + pidText = await readFile(pidPath, "utf8"); + break; + } catch { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + } + } + inferiorPid = Number(pidText); + expect(Number.isSafeInteger(inferiorPid) && inferiorPid > 0).toBe(true); + expect(() => process.kill(inferiorPid!, 0)).not.toThrow(); + await writeFile(releasePath, "release after close"); + const outputDeadline = Date.now() + 3_000; + while ( + Date.now() < outputDeadline && + (!terminalOutput.includes("REA_RIZIN_STDOUT_MARKER") || + !terminalOutput.includes("REA_RIZIN_STDERR_MARKER")) + ) + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + expect(terminalOutput).toContain("REA_RIZIN_STDOUT_MARKER"); + expect(terminalOutput).toContain("REA_RIZIN_STDERR_MARKER"); + expect(() => process.kill(inferiorPid!, 0)).not.toThrow(); + }, +); + +function findRizinBinary(): string | undefined { + try { + return execFileSync("which", ["rizin"], { encoding: "utf8" }).trim(); + } catch { + return undefined; + } +} + +function rizinAvailable(binary: string): boolean { + try { + return execFileSync(binary, ["-v"], { encoding: "utf8" }).includes("0.7.4"); + } catch { + return false; + } +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +async function startFakeInferiorSession( + options: { + readonly launchTarget?: boolean; + readonly initialRows?: string; + readonly afterDetach?: (pid: number) => string; + readonly exitKills?: string; + readonly stderrPath?: string; + readonly truncateOlj?: boolean; + readonly truncateCleanupFrame?: boolean; + readonly overflowPxCommand?: boolean; + readonly startupWatchdogMs?: number; + readonly maxCommandOutputBytes?: number; + } = {}, +): Promise { + const tty = await realpath(await rizinTestInferiorTty()); + testDirectory = await mkdtemp(join(tmpdir(), "rea-rizin-protocol-")); + const targetPath = join(testDirectory, "inferior.mjs"); + const pidPath = join(testDirectory, "target.pid"); + const provider = join(testDirectory, "fake-rizin.cjs"); + const launchTarget = options.launchTarget ?? true; + await writeFile(targetPath, "setInterval(() => {}, 1000);\n"); + const initialRows = options.initialRows ?? null; + const afterDetach = options.afterDetach ?? (() => "[]"); + const stderrPath = options.stderrPath ?? tty; + await writeFile( + provider, + `#!/usr/bin/env node +const fs = require("node:fs"); +const { spawn } = require("node:child_process"); +process.stdout.write("\\0"); +${launchTarget ? `const ttyFlags = fs.constants.O_WRONLY | fs.constants.O_NOCTTY | fs.constants.O_NONBLOCK; const stdoutFd = fs.openSync(${JSON.stringify(tty)}, ttyFlags); const stderrFd = fs.openSync(${JSON.stringify(stderrPath)}, ttyFlags); const target = spawn(process.execPath, [${JSON.stringify(targetPath)}], { detached: true, stdio: ["ignore", stdoutFd, stderrFd] }); fs.closeSync(stdoutFd); fs.closeSync(stderrFd); target.unref(); const inferiorPid = target.pid; fs.writeFileSync(${JSON.stringify(pidPath)}, String(inferiorPid));` : "const inferiorPid = 999999999;"} +const initialRows = ${JSON.stringify(initialRows)} ?? JSON.stringify([{ raised: true, fd: 3, uri: "ptrace://" + inferiorPid }]); +const afterDetach = ${JSON.stringify(afterDetach(inferiorPid ?? 999999999))}; +const exitKills = ${JSON.stringify(options.exitKills ?? "false")}; +const truncateOlj = ${JSON.stringify(options.truncateOlj ?? false)}; +const truncateCleanupFrame = ${JSON.stringify(options.truncateCleanupFrame ?? false)}; +const overflowPxCommand = ${JSON.stringify(options.overflowPxCommand ?? false)}; +let oljCalls = 0; +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf("\\n"); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + if (line === "olj") { + oljCalls += 1; + if (truncateOlj && oljCalls === 1) process.stdout.write("x".repeat(16 * 1024 * 1024 + 1) + "\\0"); + else process.stdout.write((oljCalls === 1 ? initialRows : oljCalls === 2 ? afterDetach : "[]") + "\\0"); + } + else if (overflowPxCommand && line === "px") process.stdout.write("x".repeat(2_048) + "\\0"); + else if (line === "e dbg.exitkills") process.stdout.write(exitKills + "\\0"); + else if (line.startsWith("!echo ")) process.stdout.write((truncateCleanupFrame ? "x".repeat(2_048) : "") + line.slice(6) + "\\0"); + } +}); +`, + ); + await chmod(provider, 0o700); + let providerStderr = ""; + manager = new RizinDebugSessionManager( + { REA_RIZIN_COMMAND: provider }, + { + spawnProcess: async (spawnOptions) => { + const launched = await spawnOwnedProviderProcess(spawnOptions); + providerPid = launched.process.pid; + if (options.startupWatchdogMs !== undefined) { + const watchdog = spawnChild( + process.execPath, + [ + "-e", + `const fs = require("node:fs"); const provider = Number(process.argv[1]); const directory = process.argv[2]; const targetFile = process.argv[3]; const delay = Number(process.argv[4]); setTimeout(() => { let target; try { target = Number(fs.readFileSync(targetFile, "utf8")); } catch {} for (const pid of [provider, target]) { if (!Number.isSafeInteger(pid) || pid <= 0) continue; try { const commandLine = fs.readFileSync("/proc/" + pid + "/cmdline", "utf8"); if (commandLine.includes(directory)) process.kill(pid, "SIGKILL"); } catch {} } }, delay);`, + String(launched.process.pid), + testDirectory!, + join(testDirectory!, "target.pid"), + String(options.startupWatchdogMs), + ], + { detached: true, stdio: "ignore" }, + ); + providerWatchdogPid = watchdog.pid; + watchdog.unref(); + } + launched.process.stderr?.on("data", (chunk) => { + providerStderr += Buffer.from(chunk).toString("utf8"); + }); + return launched; + }, + ...(options.maxCommandOutputBytes === undefined + ? {} + : { maxCommandOutputBytes: options.maxCommandOutputBytes }), + }, + ); + const started = await manager.start({ + path: "/tmp/fake-target", + inferior_tty: tty, + }); + expect( + started.ok, + started.ok ? "" : `${started.error.message} ${providerStderr}`, + ).toBe(true); + if (!started.ok) throw started.error; + if (launchTarget) { + let pidText: string | undefined; + const deadline = Date.now() + 2_000; + while (pidText === undefined && Date.now() < deadline) { + try { + pidText = await readFile(pidPath, "utf8"); + } catch { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); + } + } + const parsedInferiorPid = + pidText === undefined ? Number.NaN : Number(pidText); + if (!Number.isSafeInteger(parsedInferiorPid) || parsedInferiorPid <= 0) + throw new Error("The test inferior did not publish a valid PID."); + inferiorPid = parsedInferiorPid; + } + return started.value.session_id; +} + +async function expectCloseRejectedAndReleased( + sessionId: string, +): Promise { + expect((await manager!.close(sessionId)).ok).toBe(false); + expect(manager!.status(sessionId)?.state).toBe("closed"); + await manager!.closeAll(); + expect(manager!.status(sessionId)).toBeUndefined(); +} diff --git a/tests/boundary/providers/RizinTestInferiorTty.ts b/tests/boundary/providers/RizinTestInferiorTty.ts new file mode 100644 index 000000000..dc8fd1b3d --- /dev/null +++ b/tests/boundary/providers/RizinTestInferiorTty.ts @@ -0,0 +1,73 @@ +import { afterAll } from "vitest"; +import { spawn } from "@lydell/node-pty"; +import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; + +let ttyPathPromise: Promise | undefined; +let terminal: ReturnType | undefined; + +export const rizinTestInferiorTty = (): Promise => { + if (process.platform === "win32") + return Promise.reject( + new Error("The Rizin test TTY requires a POSIX host."), + ); + ttyPathPromise ??= new Promise((resolve, reject) => { + terminal = spawn("sh", ["-c", "tty; exec sleep 600"], { + name: "xterm", + cols: 80, + rows: 24, + encoding: "utf8", + }); + let output = ""; + const timer = setTimeout( + () => reject(new Error("test TTY path timeout")), + 2_000, + ); + terminal.onData((chunk) => { + output += chunk; + const pattern = + process.platform === "darwin" + ? /\/dev\/ttys[0-9a-z]+/u + : /\/dev\/pts\/\d+/u; + const match = pattern.exec(output); + if (match !== null) { + clearTimeout(timer); + resolve(match[0]); + } + }); + }); + return ttyPathPromise; +}; + +afterAll(() => terminal?.kill()); + +/** These fixture processes do not launch or attach an inferior. */ +export const stopFakeNoInferior = async ( + session: Parameters[0], + debuggerName: string, + options?: Parameters[2], +) => + stopOwnedDebuggerProcess( + { + ...session, + inferiorStateUnknown: () => false, + detachInferior: async () => ({ status: "no-inferior" }), + }, + debuggerName, + options, + ); + +export const createRizinTestManager = ( + environment: Readonly = process.env, + options: ConstructorParameters[1] = {}, +): RizinDebugSessionManager => + new RizinDebugSessionManager( + { ...process.env, ...environment }, + { + // These fake-provider tests model Linux protocol behavior independently of + // the host running the suite. Real-provider coverage checks the host platform. + platform: "linux", + stopProcess: stopFakeNoInferior, + ...options, + }, + ); diff --git a/tests/boundary/providers/RizinTimeoutEvidence.test.ts b/tests/boundary/providers/RizinTimeoutEvidence.test.ts new file mode 100644 index 000000000..fc2c91c2d --- /dev/null +++ b/tests/boundary/providers/RizinTimeoutEvidence.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from "vitest"; + +import { createRizinCommandTimeoutEvidence } from "../../../src/rizin/RizinDebugSessionManager.js"; + +it("preserves every field of partial Rizin timeout Evidence", () => { + const result = { + command: "dr", + output: "partial register output", + output_scope: "command_and_interleaved_session_output" as const, + output_truncated: true, + completion_status: "unknown" as const, + backend: null, + }; + + const evidence = createRizinCommandTimeoutEvidence({ + sessionId: "session-123", + command: "dr", + result, + inferiorTty: "/dev/pts/7", + cleanupFailure: undefined, + launchProvenance: { + caller_selected_path: "./sample/../program", + resolved_path: "/workspace/program", + }, + }); + + expect(evidence.parameters).toEqual({ + session_id: "session-123", + command: "dr", + inferior_tty: "/dev/pts/7", + launch_provenance: { + caller_selected_path: "./sample/../program", + resolved_path: "/workspace/program", + }, + }); + expect(evidence.raw_result).toEqual(result); + expect(evidence.normalized_result).toEqual(result); + expect(evidence.limitations).toEqual([ + "The current debugger target identity is not established by the Rizin session identifier.", + "The command timed out before its completion marker; retained output is partial and completion is unknown.", + "The owned debugger session was stopped because output correlation could no longer be guaranteed.", + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.", + ]); +}); diff --git a/tests/boundary/providers/adb/adbBugreportPublication.test.ts b/tests/boundary/providers/adb/adbBugreportPublication.test.ts index b99799127..8578f1dce 100644 --- a/tests/boundary/providers/adb/adbBugreportPublication.test.ts +++ b/tests/boundary/providers/adb/adbBugreportPublication.test.ts @@ -47,9 +47,12 @@ const fixture = async (duringCollection?: (root: string) => Promise) => { let reportedPath = ""; vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => { expect(args).toEqual(["version"]); + const stdout = `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`; return { - stdout: `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`, + stdout, stderr: "", + stdoutBytes: Buffer.byteLength(stdout), + stderrBytes: 0, }; }); vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { diff --git a/tests/boundary/providers/cutterBridgeProducer.test.ts b/tests/boundary/providers/cutterBridgeProducer.test.ts new file mode 100644 index 000000000..50a7e9aa1 --- /dev/null +++ b/tests/boundary/providers/cutterBridgeProducer.test.ts @@ -0,0 +1,264 @@ +import { execFileSync } from "node:child_process"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { expect, it } from "vitest"; + +import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js"; +import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js"; +import { + createTestProcessRegistry, + waitForExit, +} from "../../../tests/support/process/processFixture.js"; + +const startPythonBridge = async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-cutter-receipt-")); + await chmod(directory, 0o700); + const processes = createTestProcessRegistry(); + const child = processes.spawn( + "python3", + [resolve("tests/fixtures/cutter_bridge_protocol_test.py"), "--serve"], + { env: { ...process.env, REA_CUTTER_BRIDGE_DIR: directory } }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + try { + await new Promise((resolveReady, rejectReady) => { + const timeout = setTimeout( + () => + rejectReady( + new Error(`Python bridge did not become ready: ${stderr}`), + ), + 5_000, + ); + const onData = (_chunk: string): void => { + if (!stdout.includes("ready\n")) return; + clearTimeout(timeout); + child.stdout.off("data", onData); + resolveReady(); + }; + child.stdout.on("data", onData); + child.once("error", (error) => { + clearTimeout(timeout); + rejectReady(error); + }); + child.once("exit", (code, signal) => { + if (stdout.includes("ready\n")) return; + clearTimeout(timeout); + rejectReady( + new Error( + `Python bridge exited before ready (${code ?? signal}): ${stderr}`, + ), + ); + }); + if (stdout.includes("ready\n")) onData(""); + }); + const client = new CutterBridgeClient({ + ...process.env, + REA_CUTTER_BRIDGE_DIR: directory, + }); + const discovery = await client.listSessions(); + const session = discovery.sessions[0]; + if (session === undefined) throw new Error("Python bridge session missing"); + return { + client, + directory, + session, + stderr: () => stderr, + async close(): Promise { + child.stdin.end("shutdown\n"); + const exited = await waitForExit(child, 3_000); + try { + await processes.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + if (!exited) throw new Error(`Python bridge did not exit: ${stderr}`); + }, + }; + } catch (error) { + child.stdin.end("shutdown\n"); + await waitForExit(child, 3_000); + await processes.shutdown(); + await rm(directory, { recursive: true, force: true }); + throw error; + } +}; + +it("authenticates bridge peers before payloads and permits status recovery after a poisoned UI timeout", () => { + execFileSync( + "python3", + [resolve("tests/fixtures/cutter_bridge_protocol_test.py")], + { + encoding: "utf8", + timeout: 10_000, + }, + ); +}); + +it("round-trips authenticated status and command bytes across the Python producer boundary", async () => { + const bridge = await startPythonBridge(); + try { + const { client, session } = bridge; + expect(session.document_generation).toBe(4); + expect(session.current_file).toBe("/tmp/cutter-interop-雪.bin"); + + for (let index = 0; index <= 64; index += 1) { + await expect( + client.execute({ + sessionId: session.session_id, + expectedGeneration: 3, + command: "iI", + json: false, + }), + ).rejects.toThrow("List sessions again"); + } + + const result = await client.execute({ + sessionId: session.session_id, + expectedGeneration: session.document_generation, + command: 'print("雪 ☃️ \\\"quoted\\\" \\\\ slash\\nline\\ttab")', + json: false, + }); + expect(result.output).toBe( + 'round-trip: 雪 ☃️ quote=" slash=\\\\ line=\\n tab=\\t', + ); + expect(result.executionState).toBe("complete"); + expect(result.documentGeneration).toBe(4); + expect(result.currentFile).toBe("/tmp/cutter-interop-雪.bin"); + expect(result.outputTruncated).toBe(false); + } finally { + await bridge.close(); + } +}); + +it("preserves caller A service cleanup proof after caller B acknowledges A", async () => { + const bridge = await startPythonBridge(); + try { + const commandInput = { + session_id: bridge.session.session_id, + expected_generation: bridge.session.document_generation, + command: "fixture-unknown", + json: false, + }; + const callerA = new CutterBridgeService(bridge.client); + const executionA = await callerA.execute(commandInput); + expect(executionA).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + + const callerB = new CutterBridgeService( + new CutterBridgeClient({ + ...process.env, + REA_CUTTER_BRIDGE_DIR: bridge.directory, + }), + ); + await expect( + callerB.execute({ + ...commandInput, + command: "iI", + json: false, + }), + ).resolves.toMatchObject({ ok: true }); + await expect(callerA.retryCleanup()).resolves.toEqual({ + ok: true, + value: null, + }); + await expect( + callerA.execute({ ...commandInput, command: "iI" }), + ).resolves.toMatchObject({ ok: true }); + } finally { + await bridge.close(); + } +}); + +it("recovers orphan receipts across more than 64 fresh CLI clients", async () => { + const bridge = await startPythonBridge(); + try { + for (let index = 0; index < 65; index += 1) { + const freshClient = new CutterBridgeClient({ + ...process.env, + REA_CUTTER_BRIDGE_DIR: bridge.directory, + }); + const execution = await freshClient.execute({ + sessionId: bridge.session.session_id, + expectedGeneration: bridge.session.document_generation, + command: "fixture-unknown", + json: false, + }); + expect(execution.executionState, `orphan ${index}`).toBe("unknown"); + } + const nextInvocation = new CutterBridgeClient({ + ...process.env, + REA_CUTTER_BRIDGE_DIR: bridge.directory, + }); + await expect( + nextInvocation.execute({ + sessionId: bridge.session.session_id, + expectedGeneration: bridge.session.document_generation, + command: "iI", + json: false, + }), + ).resolves.toMatchObject({ executionState: "complete" }); + } finally { + await bridge.close(); + } +}); + +it("retains failed receipt acknowledgements and retries before another command", async () => { + const bridge = await startPythonBridge(); + try { + await expect( + bridge.client.execute({ + sessionId: bridge.session.session_id, + expectedGeneration: bridge.session.document_generation, + command: "fixture-fail-ack", + json: false, + }), + ).resolves.toMatchObject({ executionState: "complete" }); + await expect( + bridge.client.execute({ + sessionId: bridge.session.session_id, + expectedGeneration: bridge.session.document_generation, + command: "iI", + json: false, + }), + ).resolves.toMatchObject({ executionState: "complete" }); + } finally { + await bridge.close(); + } +}); + +it("retains a receipt when an ACK reply omits its acknowledgement field", async () => { + const bridge = await startPythonBridge(); + try { + await expect( + bridge.client.execute({ + sessionId: bridge.session.session_id, + expectedGeneration: bridge.session.document_generation, + command: "fixture-malformed-ack", + json: false, + }), + ).resolves.toMatchObject({ executionState: "complete" }); + await expect( + bridge.client.execute({ + sessionId: bridge.session.session_id, + expectedGeneration: bridge.session.document_generation, + command: "iI", + json: false, + }), + ).resolves.toMatchObject({ executionState: "complete" }); + } finally { + await bridge.close(); + } +}); diff --git a/tests/boundary/providers/gdbTestHelpers.ts b/tests/boundary/providers/gdbTestHelpers.ts new file mode 100644 index 000000000..7597cba50 --- /dev/null +++ b/tests/boundary/providers/gdbTestHelpers.ts @@ -0,0 +1,16 @@ +import { expect } from "vitest"; + +import type { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; + +type GdbSessionStart = Awaited>; +type StartedGdbSession = Extract; + +export const expectGdbSessionStarted = async ( + manager: GdbSessionManager, + ...arguments_: Parameters +): Promise => { + const started = await manager.start(...arguments_); + expect(started.ok).toBe(true); + if (!started.ok) throw new Error(started.error.message); + return started; +}; diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 56014fbd4..7866b978b 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -116,6 +116,63 @@ describe("managed member comparison", () => { }); }); +describe("managed member comparison with equal decoded fingerprints", () => { + it.each([ + { name: "Main", basis: "exact-signature" }, + { name: "Renamed", basis: "structural-method-shape" }, + ])("detects reordered switch targets in $name", ({ name, basis }) => { + const observe = (targets: readonly number[], methodName: string) => { + // ldarg.0; switch (case 0, case 1); ldc.i4.0; ret; ldc.i4.1; ret. + // Both cases target instruction starts, relative to the end of the table. + const il = Buffer.alloc(18); + il[0] = 0x02; + il[1] = 0x45; + il.writeUInt32LE(2, 2); + targets.forEach((target, index) => + il.writeInt32LE(target, 6 + index * 4), + ); + Buffer.from([0x16, 0x2a, 0x17, 0x2a]).copy(il, 14); + return inspect( + buildManagedPeFixture({ + methodName, + methodSignature: Buffer.from([0x00, 0x01, 0x08, 0x08]), + ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]), + }), + `/tmp/${methodName}-${targets.join("-")}.dll`, + ); + }; + const left = observe([0, 2], "Main"); + const right = observe([2, 0], name); + const leftBody = left.result.methods[0]?.body; + const rightBody = right.result.methods[0]?.body; + expect(leftBody?.status).toBe("present"); + expect(rightBody?.status).toBe("present"); + expect(leftBody?.normalized_il_sha256).toBe( + rightBody?.normalized_il_sha256, + ); + expect(leftBody?.il_sha256).not.toBe(rightBody?.il_sha256); + + const result = compareManagedMembers(left, right); + expect(result.matching.exact_il_signature).toBe(0); + expect(result.methods[0]).toMatchObject({ + status: "changed", + match: { status: "matched", basis }, + dimensions: ["cil"], + }); + expect( + compareManagedMembers(right, observe([2, 0], name)).methods[0], + ).toMatchObject({ + status: "unchanged", + match: { + status: "matched", + basis: "exact-il-signature", + confidence: "exact", + }, + dimensions: [], + }); + }); +}); + describe("managed member comparison floating-point constants", () => { it.each([ { @@ -313,6 +370,30 @@ describe("managed member comparison uncertainty", () => { }); }); +describe("managed member comparison beside user strings", () => { + it("keeps member deltas and coverage when only #US literals are nonconforming", () => { + // The method loads "ok", stored with flag 1 where ECMA-335 expects 0. + const options = { + ilBody: Buffer.from([(7 << 2) | 2, 0x72, 1, 0, 0, 0x70, 0x26, 0x2a]), + userStringHeap: Buffer.from([0, 5, 0x6f, 0, 0x6b, 0, 1]), + }; + const left = inspect(buildManagedPeFixture(options), "/tmp/left-us.dll"); + const right = inspect(buildManagedPeFixture(options), "/tmp/right-us.dll"); + const result = compareManagedMembers( + { evidenceId: left.evidenceId, result: { ...left.result, fields: [] } }, + { + evidenceId: right.evidenceId, + result: { ...right.result, methods: [] }, + }, + ); + + expect(left.result.metadata.status).toBe("complete"); + expect(left.result.coverage.state).toBe("complete"); + expect(left.result.user_strings?.state).toBe("partial"); + expect(result.summary).toMatchObject({ added: 1, removed: 1, unknown: 0 }); + }); +}); + describe("managed member comparison of undecoded signatures", () => { it("remaps renamed methods only when raw signatures and complete normalized IL match", () => { const signature = Buffer.from([0x00, 0x00, 0x7f]); diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 5d6af987d..72d11abec 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -17,6 +17,7 @@ import { execFileOutput } from "../../../../src/process/ExecFileOutput.js"; import { ArtifactReaderFailure, type ArtifactEntry, + type ArtifactTraversalOmission, } from "../../../../src/artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; import { DirectoryArtifactReader } from "../../../../src/artifacts/DirectoryArtifactReader.js"; @@ -473,10 +474,10 @@ describe("native DMG traversal shutdown", () => { .mockImplementation(async function* ( this: DirectoryArtifactReader, signal?: AbortSignal, - descendInto?: (path: string) => boolean, + selection?: Parameters[1], ) { const iterator = originalEntries - .call(this, signal, descendInto) + .call(this, signal, selection) [Symbol.asyncIterator](); try { for (;;) { @@ -606,9 +607,20 @@ describe("native DMG artifact reader", () => { undefined, host, ); + if (mountPoint === undefined) throw new Error("expected mounted directory"); + if (process.platform !== "win32") + await execFileOutput("mkfifo", [join(mountPoint, "special.pipe")]); const entries = []; - for await (const entry of reader.entries()) entries.push(entry.path); + const omissions: ArtifactTraversalOmission[] = []; + for await (const entry of reader.entries(undefined, { + onOmission: (omission) => omissions.push(omission), + })) + entries.push(entry.path); expect(entries).toContain("image.dmg/Fixture/hello.txt"); + if (process.platform !== "win32") + expect(omissions).toEqual([ + { path: "image.dmg/Fixture/special.pipe", kind: "fifo" }, + ]); const provenance = reader.provenance(); const first = provenance[0]; if (first === undefined) throw new Error("expected attachment provenance"); diff --git a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts index ea81dacaa..8a1151df3 100644 --- a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts +++ b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts @@ -42,7 +42,7 @@ it("retains the delayed dependency in the complete real framed otool output", as it("rejects a repeated options field even when the duplicate has no value", async () => { const output = (await nativeFixture("otool-dylib-use.txt")).replace( - " options delay-init ", + " options delay-init", " options delay-init\n options", ); expect(() => parseOtoolLoadCommands(output)).toThrow(/otool/u); diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts new file mode 100644 index 000000000..d4aa12283 --- /dev/null +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -0,0 +1,141 @@ +import { expect, it } from "vitest"; +import { fat } from "../../../src/domain/binaryTarget.fixture.js"; +import { + createRustImage, + createRustHashOverflowImage, + RUSTC_HASH, + rustDependencyPayload, +} from "../../fixtures/rust/image.js"; +import { readRustBinaryImage } from "../../../src/rust/RustBinaryImage.js"; +import { RustAuditableFormatFailure } from "../../../src/rust/RustAuditableReader.js"; + +const packages = [ + { + name: "itoa", + version: "1.0.18", + source: "crates.io", + kind: null, + dependencies: [], + root: false, + }, + { + name: "sample", + version: "0.1.0", + source: "local", + kind: null, + dependencies: [0], + root: true, + }, +]; + +it.each(["elf", "pe"] as const)( + "reads a .dep-v0 section and an inferred rustc hash from %s", + (format) => { + const image = readRustBinaryImage(createRustImage({ format })); + expect(image.format).toBe(format); + expect(image.auditable_metadata).toMatchObject({ + section_name: ".dep-v0", + file_offset: 1024, + section_bytes: rustDependencyPayload().length, + format_version: 1, + root_package_index: 1, + packages, + dependency_edges: [{ from_index: 1, to_index: 0 }], + }); + expect(image.rustc_commit).toEqual({ + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }); + }, +); + +it("reads a Mach-O __DATA,.dep-v0 section", () => { + const image = readRustBinaryImage(createRustImage({ format: "macho" })); + expect(image).toMatchObject({ + format: "macho", + architecture: "arm64", + auditable_metadata: { + section_name: "__DATA,.dep-v0", + file_offset: 1024, + root_package_index: 1, + packages, + }, + rustc_commit: { + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }, + }); +}); + +it("ignores PE file-alignment padding after the dependency content", () => { + const bytes = createRustImage({ format: "pe", includeRustc: false }); + const payloadBytes = rustDependencyPayload().length; + const entry = 264; + expect(bytes.readUInt32LE(entry + 8)).toBe(payloadBytes); + bytes.writeUInt32LE(512, entry + 16); + const image = readRustBinaryImage(bytes); + expect(image.auditable_metadata).toMatchObject({ + section_name: ".dep-v0", + section_bytes: payloadBytes, + packages, + }); +}); + +it("returns null metadata when the dependency section is absent", () => { + const image = readRustBinaryImage( + createRustImage({ format: "elf", omitSection: true }), + ); + expect(image.auditable_metadata).toBeNull(); + expect(image.rustc_commit?.hashes).toEqual([ + { hash: RUSTC_HASH, file_offset: 2048 }, + ]); +}); + +it("refuses a universal Mach-O container", () => { + expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow( + RustAuditableFormatFailure, + ); + expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow(/thin slice/); +}); + +it("does not read SHT_NOBITS dependency sections as stored bytes", () => { + const bytes = createRustImage({ format: "elf" }); + // The fixture writes the .dep-v0 section header at byte 384. + bytes.writeUInt32LE(8, 384 + 4); + let metadata: unknown; + try { + metadata = readRustBinaryImage(bytes).auditable_metadata; + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(RustAuditableFormatFailure); + return; + } + expect(metadata).toBeNull(); +}); + +it("reads a rustc path from a read-only ELF section whose name exceeds 64 bytes", () => { + const bytes = createRustImage({ format: "elf", omitSection: true }); + // omitSection places the rustc section header at 384 and shstrtab at 320. + const nameOffset = bytes.readUInt32LE(384); + const tableOffset = Number(bytes.readBigUInt64LE(320 + 24)); + const longName = Buffer.concat([Buffer.alloc(65, 0x61), Buffer.from([0])]); + longName.copy(bytes, tableOffset + nameOffset); + bytes.writeBigUInt64LE(BigInt(nameOffset + longName.length), 320 + 32); + const image = readRustBinaryImage(bytes); + expect(image.auditable_metadata).toBeNull(); + expect(image.rustc_commit).toEqual({ + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }); +}); + +it("refuses read-only data with more distinct rustc paths than it retains", () => { + const bytes = createRustHashOverflowImage(); + expect(() => readRustBinaryImage(bytes)).toThrow( + expect.objectContaining({ + constraint: { + resource: "capacity", + reported_limits: { maximum_rustc_hashes: 64 }, + }, + }), + ); +}); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 092f869e2..c5412f32c 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -14,6 +14,7 @@ import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.j import { err, ok as resultOk } from "../../../src/domain/result.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { createEvidence } from "../../../src/domain/evidence.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import { ControllableAnalysisClient, @@ -23,9 +24,16 @@ import { createTestBinarySession, } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { copyFile, writeFile } from "node:fs/promises"; -import { extname, join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { + copyFile, + link, + mkdir, + realpath, + symlink, + writeFile, +} from "node:fs/promises"; +import { dirname, extname, join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; const client = (fail = false): AnalysisClient => ({ execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)), @@ -165,6 +173,247 @@ describe("detached provider and target metadata", () => { }); }); +it("drains session calls and retires analysis after an admitted external mutation", async () => { + const [target] = await createBinarySessionTargets(); + const alias = join(dirname(target), "first-alias.hop"); + await symlink(target, alias); + const calls: string[] = []; + const clients: { closed: number; targetSha256: string }[] = []; + const base = createCacheProvider(calls); + const session = createTestBinarySession({ + ...base, + createClient: (...arguments_) => { + const client = base.createClient(...arguments_); + const tracked = { closed: 0, targetSha256: arguments_[0].sha256 }; + clients.push(tracked); + return { + ...client, + close: async () => { + tracked.closed += 1; + return client.close(); + }, + }; + }, + }); + expect((await session.open(target)).ok).toBe(true); + expect(session.activeTarget()?.path).toBe(target); + const originalDigest = session.activeTarget()?.sha256; + const started = createDeferred(); + const release = createDeferred(); + + const mutation = session.withAdmittedExternalMutation( + alias, + undefined, + async () => { + expect(session.activeTarget()?.path).toBe(target); + started.resolve(); + await release.promise; + await writeFile(alias, "changed by external provider"); + return resultOk("provider finished"); + }, + (_value, error) => error, + ); + await started.promise; + const queuedAnalysis = session.execute("address_name", {}); + expect(calls).toEqual([]); + + release.resolve(); + await expect(mutation).resolves.toMatchObject({ + ok: true, + value: { ok: true, value: "provider finished" }, + }); + expect((await queuedAnalysis).ok).toBe(false); + expect(session.activeTarget()).toBeUndefined(); + expect(calls).toEqual([]); + expect(clients).toHaveLength(0); + expect((await session.open(target)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + expect(session.activeTarget()?.sha256).not.toBe(originalDigest); + expect(clients.at(-1)?.targetSha256).toBe(session.activeTarget()?.sha256); + expect(calls).toEqual(["address_name"]); + await session.close(); +}); + +it("leaves analysis unbound when a command returns before its writer finishes", async () => { + const [target] = await createBinarySessionTargets(); + const session = createTestBinarySession(createCacheProvider([])); + expect((await session.open(target)).ok).toBe(true); + const originalDigest = session.activeTarget()?.sha256; + const releaseWriter = createDeferred(); + let writer: Promise | undefined; + const result = await session.withAdmittedExternalMutation( + target, + undefined, + async () => { + writer = releaseWriter.promise.then(() => + writeFile(target, "delayed write"), + ); + return resultOk("command replied"); + }, + (_value, error) => error, + ); + expect(result).toEqual(resultOk(resultOk("command replied"))); + expect(session.activeTarget()).toBeUndefined(); + releaseWriter.resolve(); + await writer; + expect((await session.execute("address_name", {})).ok).toBe(false); + expect((await session.open(target)).ok).toBe(true); + expect(session.activeTarget()?.sha256).not.toBe(originalDigest); + await session.close(); +}); + +it("retires analysis when an external mutation uses a hard link", async () => { + const [target] = await createBinarySessionTargets(); + const alias = join(dirname(target), "hard-link-alias.hop"); + await link(target, alias); + const session = createTestBinarySession(createCacheProvider([])); + expect((await session.open(target)).ok).toBe(true); + const before = session.activeTarget()?.sha256; + + const result = await session.withAdmittedExternalMutation( + alias, + undefined, + async () => { + await writeFile(alias, "changed through hard link"); + return resultOk("changed"); + }, + (_value, error) => error, + ); + + expect(result).toMatchObject({ + ok: true, + value: { ok: true, value: "changed" }, + }); + expect(session.activeTarget()).toBeUndefined(); + expect((await session.open(target)).ok).toBe(true); + expect(session.activeTarget()?.sha256).not.toBe(before); + await session.close(); +}); + +it("preserves Evidence records while retiring the client after an external mutation", async () => { + const [target] = await createBinarySessionTargets(); + const session = createTestBinarySession(createCacheProvider([])); + expect((await session.open(target)).ok).toBe(true); + expect( + (await session.execute("address_name", { address: "0x1000" })).ok, + ).toBe(true); + recordNetworkReachabilityUnknown(session, "medium"); + const before = session.exportEvidenceBundle(); + + const result = await session.withAdmittedExternalMutation( + target, + undefined, + async () => resultOk("observed"), + (_value, error) => error, + ); + + expect(result.ok).toBe(true); + expect(session.exportEvidenceBundle().records).toEqual(before.records); + expect(session.listUnknowns()).toHaveLength(1); + await session.close(); +}); + +it("requires explicitly reopening an app bundle after a session mutation", async () => { + const directory = await createTestTempDirectory("rea-session-app-rebind-"); + const app = join(directory, "Fixture.app"); + const executable = join(app, "Fixture"); + await mkdir(app, { recursive: true }); + await writeFile( + join(app, "Info.plist"), + "CFBundleExecutableFixture", + ); + await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); + const session = createTestBinarySession(createCacheProvider([])); + expect((await session.open(app)).ok).toBe(true); + const initial = session.activeTarget(); + expect(initial?.sourcePath).toBe(await realpath(app)); + + const result = await session.withAdmittedExternalMutation( + initial?.path ?? executable, + undefined, + async () => resultOk("observed"), + (_value, error) => error, + ); + + if (!result.ok) throw result.error; + expect(result).toMatchObject({ + ok: true, + value: { ok: true, value: "observed" }, + }); + expect(session.activeTarget()).toBeUndefined(); + expect((await session.open(app)).ok).toBe(true); + expect(session.activeTarget()).toMatchObject({ + path: await realpath(executable), + sourcePath: await realpath(app), + bundleInfoPlist: await realpath(join(app, "Info.plist")), + }); + await session.close(); +}); + +it("retires an auxiliary-only session after an external mutation", async () => { + const [target] = await createBinarySessionTargets(); + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(resultOk(null)), + })); + expect((await session.open(target)).ok).toBe(true); + + const result = await session.withAdmittedExternalMutation( + target, + undefined, + async () => resultOk("observed"), + (_value, error) => error, + ); + + expect(result).toMatchObject({ + ok: true, + value: { ok: true, value: "observed" }, + }); + expect(session.activeTarget()).toBeUndefined(); + await session.close(); +}); + +it("keeps an externally mutated session unbound until cleanup is retried", async () => { + const [target] = await createBinarySessionTargets(); + const cleanupError = new ProviderCleanupError("fixture", ["provider-child"], { + reason: "process group is still alive", + }); + let created = 0; + let closeAttempts = 0; + const session = createTestBinarySession(() => { + created += 1; + return { + execute: () => Promise.resolve(ok(null)), + close: () => + Promise.resolve( + ++closeAttempts === 1 ? err(cleanupError) : resultOk(null), + ), + }; + }); + expect((await session.open(target)).ok).toBe(true); + expect(session.activeTarget()).toBeDefined(); + expect(created).toBe(1); + + const mutation = await session.withAdmittedExternalMutation( + target, + undefined, + async () => resultOk("dispatched"), + (_value, error) => error, + ); + + expect(closeAttempts).toBe(1); + expect(mutation.ok).toBe(false); + if (mutation.ok) throw new Error("Expected cleanup failure"); + expect(mutation.error).toBeInstanceOf(ProviderCleanupError); + expect(mutation.error.cleanupIncomplete).toBe(true); + expect(session.activeTarget()).toBeUndefined(); + expect((await session.execute("address_name", {})).ok).toBe(false); + expect(created).toBe(1); + expect((await session.open(target)).ok).toBe(true); + expect(created).toBe(2); + await session.close(); +}); + describe("opening previewed targets", () => { it("rechecks the active target after another serialized transition", async () => { const [first, second] = await createBinarySessionTargets(); @@ -1279,6 +1528,77 @@ describe("selector origin forwarding", () => { }); }); +it("blocks analysis rebinding until the external writer cleanup settles", async () => { + const [target] = await createBinarySessionTargets(); + const session = createTestBinarySession(createCacheProvider([])); + expect((await session.open(target)).ok).toBe(true); + const failure = new ProviderCleanupError("rizin", ["writer"], { + reason: "writer remains alive", + }); + const result = await session.withAdmittedExternalMutation( + target, + undefined, + async () => err(failure), + (_value, error) => error, + ); + expect(result).toEqual(resultOk(err(failure))); + expect(session.activeTarget()).toBeUndefined(); + expect(await session.open(target)).toEqual(err(failure)); + const anotherMutation = vi.fn(async () => resultOk("unrelated command")); + expect( + await session.withAdmittedExternalMutation( + target, + undefined, + anotherMutation, + (_value, error) => error, + ), + ).toEqual(err(failure)); + expect(anotherMutation).not.toHaveBeenCalled(); + // Only the resource owner may acknowledge completed cleanup. + failure.resolveCleanup(); + expect((await session.open(target)).ok).toBe(true); + await session.close(); +}); + +it("requires explicit close before an external mutation of an active Ghidra session", async () => { + const [target] = await createBinarySessionTargets(); + const base = createCacheProvider([]); + const identity = { id: "ghidra", name: "Ghidra", version: "fixture" }; + const session = createTestBinarySession({ + ...base, + identity: () => identity, + resolveAnalysisProfile: async () => + resultOk({ + profile: createAnalysisProfile(identity, { fixture: true }), + }), + capabilities: () => + base.capabilities().map((capability) => ({ + ...capability, + provider: identity, + })), + }); + expect((await session.open(target)).ok).toBe(true); + expect(session.providerIdentity().id).toBe("ghidra"); + const mutate = vi.fn(async () => resultOk("mutated")); + const result = await session.withAdmittedExternalMutation( + target, + undefined, + mutate, + (_value, error) => error, + ); + expect(result).toMatchObject({ + ok: false, + error: { + message: expect.stringContaining( + "Close the active Ghidra session explicitly", + ), + }, + }); + expect(mutate).not.toHaveBeenCalled(); + expect(session.activeTarget()?.path).toBe(target); + await session.close(); +}); + describe("reference truncation forwarding", () => { const referenceProvider = ( referencesTruncated: boolean | undefined, diff --git a/tests/composition/callPathParserOperation.test.ts b/tests/composition/callPathParserOperation.test.ts new file mode 100644 index 000000000..893f5956e --- /dev/null +++ b/tests/composition/callPathParserOperation.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; +import { traceCallPath } from "../../src/application/CallPathTracing.js"; +import { ok } from "../../src/domain/result.js"; + +it("attributes malformed relationship output to the originating provider operation", async () => { + const result = await traceCallPath( + async (operation) => + ok(operation === "procedure_address" ? "0x1000" : "0x1000"), + async () => + ok( + createAnalysisExecution( + { callees: [123] }, + { id: "test", name: "test", version: null }, + ), + ), + { start: "entry", direction: "forward" }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + const failures = z + .object({ + failures: z.array( + z.object({ + error: z.object({ + details: z.object({ operation: z.string() }), + }), + }), + ), + }) + .parse(result.value).failures; + expect(failures[0]?.error.details.operation).toBe("procedure_callees"); + } +}); diff --git a/tests/composition/idaProvider.test.ts b/tests/composition/idaProvider.test.ts index 1fd17171d..538c9d30e 100644 --- a/tests/composition/idaProvider.test.ts +++ b/tests/composition/idaProvider.test.ts @@ -14,6 +14,7 @@ import { import { createEvidence } from "../../src/domain/evidence.js"; import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js"; import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; +import { EnhancedTools } from "../../src/application/EnhancedTools.js"; import { IDA_PROVIDER_IDENTITY } from "../../src/ida/IdaProviderCapabilities.js"; const roots: string[] = []; @@ -183,3 +184,35 @@ describe("IDA provider composition", () => { expect(producer.closes).toBe(1); }); }); + +describe("IDA binary_overview execution", () => { + it("reports document selection unavailable when binary_overview executes against an IDA session", async () => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const path = join(root, "registration.json"); + await writeFile(path, JSON.stringify({ command: "fixture" })); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const producer = new RecordingIdaMcp(target); + const session = createTestBinarySession( + new IdaProvider(config.value, {}, () => producer), + ); + expect((await session.open(target.path)).ok).toBe(true); + const tools = new EnhancedTools(session); + const overview = await tools.execute("binary_overview", {}); + expect(overview).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + providerId: "ida", + operation: "list_documents", + message: expect.stringContaining( + "operation is not declared by this provider", + ), + }, + }); + const procedures = await session.execute("list_procedures", {}); + expect(procedures.ok).toBe(true); + expect((await session.close()).ok).toBe(true); + }); +}); diff --git a/tests/composition/resolveWindowsProviderExecutable.test.ts b/tests/composition/resolveWindowsProviderExecutable.test.ts new file mode 100644 index 000000000..95ea2b144 --- /dev/null +++ b/tests/composition/resolveWindowsProviderExecutable.test.ts @@ -0,0 +1,95 @@ +import { win32 } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveWindowsProviderExecutable } from "../../src/composition/resolveWindowsProviderExecutable.js"; +import { createReverseEngineeringCommandPort } from "../../src/composition/reverseEngineering.js"; + +const windowsLookup = (cwd: string, files: readonly string[]) => { + const existingFiles = new Set( + files.map((path) => win32.normalize(path).toLowerCase()), + ); + return { + cwd, + isFile: (path: string): boolean => + existingFiles.has(win32.normalize(path).toLowerCase()), + }; +}; + +describe("resolveWindowsProviderExecutable", () => { + it("resolves a bare name from quoted and relative Windows PATH entries", () => { + const cwd = "C:\\work"; + const expected = "C:\\work\\Program Files\\REA Tools\\objdump.EXE"; + expect( + resolveWindowsProviderExecutable( + "objdump", + { + PATH: `.;".\\Program Files\\REA Tools";D:\\unused`, + PATHEXT: ".COM;.EXE;.BAT", + }, + windowsLookup(cwd, [expected]), + ), + ).toBe(expected); + }); + + it("resolves a relative configured path against the caller's Windows CWD", () => { + expect( + resolveWindowsProviderExecutable( + ".\\tools\\objdump.exe", + {}, + windowsLookup("D:\\analysis", []), + ), + ).toBe("D:\\analysis\\tools\\objdump.exe"); + }); + + it("resolves relative PATH hits to absolute drive paths", () => { + const expected = "D:\\analysis\\bin\\rizin.EXE"; + expect( + resolveWindowsProviderExecutable( + "rizin", + { PATH: ".\\bin", PATHEXT: ".EXE" }, + windowsLookup("D:\\analysis", [expected]), + ), + ).toBe(expected); + }); + + it("skips script extensions and preserves a caller-selected absolute path", () => { + const expected = "C:\\tools\\rizin.EXE"; + const selected = "C:\\tools\\rizin.exe"; + expect( + resolveWindowsProviderExecutable( + "rizin", + { PATH: "C:\\tools", PATHEXT: ".CMD;.BAT;.EXE;.COM" }, + windowsLookup("C:\\work", ["C:\\tools\\rizin.CMD", expected]), + ), + ).toBe(expected); + expect( + resolveWindowsProviderExecutable( + selected, + {}, + windowsLookup("D:\\work", []), + ), + ).toBe(selected); + }); + + it("keeps unresolved bare command names unchanged", () => { + expect( + resolveWindowsProviderExecutable( + "missing-tool", + { PATH: ".\\tools", PATHEXT: ".EXE" }, + windowsLookup("C:\\work", []), + ), + ).toBe("missing-tool"); + }); + + it("passes an absolute selected command through provider composition", () => { + const commands = createReverseEngineeringCommandPort( + { REA_OBJDUMP_COMMAND: "C:\\tools\\objdump.exe" }, + "win32", + ); + expect( + commands.objdump({ path: "artifact", operation: "file_headers" }) + .executable, + ).toBe("C:\\tools\\objdump.exe"); + }); +}); diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts new file mode 100644 index 000000000..00fbb2944 --- /dev/null +++ b/tests/composition/reverseEngineering.test.ts @@ -0,0 +1,564 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js"; +import { createReverseEngineeringService } from "../../src/composition/reverseEngineering.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js"; + +describe("reverse engineering command services", () => { + it("documents the shared combined capture limit for both command tools", () => { + for (const contract of REVERSE_ENGINEERING_TOOL_CONTRACTS) + expect(contract.description).toContain("shared 16 MiB combined limit"); + expect(REVERSE_ENGINEERING_TOOL_CONTRACTS[0]?.description).toContain( + "does not follow local debug links by default", + ); + }); + + it("uses compatible local DWARF link flags and disables debuginfod through the environment", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-tools-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + const calls: { + command: string; + args: readonly string[]; + env: NodeJS.ProcessEnv | undefined; + }[] = []; + const service = new ReverseEngineeringService({ + environment: { + REA_OBJDUMP_COMMAND: "/tools/objdump", + DEBUGINFOD_URLS: "https://debug.example.invalid", + }, + run: async (command, args, options) => { + calls.push({ command, args, env: options?.env }); + return { stdout: "DWARF output\n", stderr: "diagnostic\n" }; + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "dwarf", + follow_debug_links: true, + }); + if (!result.ok) throw result.error; + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(calls).toEqual([ + { + command: "/tools/objdump", + args: ["-W", "-WK", path], + env: expect.objectContaining({ DEBUGINFOD_URLS: "" }), + }, + ]); + expect(result.value.raw_result).toMatchObject({ + stdout: "DWARF output\n", + stderr: "diagnostic\n", + exit_code: 0, + output_truncated: false, + }); + expect(result.value.provider.id).toBe("gnu.objdump"); + expect(result.value.subject?.local_path).toBe(path); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("rejects non-regular artifacts without launching the provider", async () => { + const directory = await mkdtemp( + join(tmpdir(), "rea-reverse-special-file-"), + ); + let invoked = false; + const service = new ReverseEngineeringService({ + run: async () => { + invoked = true; + return { stdout: "", stderr: "" }; + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path: directory, + operation: "file_headers", + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.cause).toMatchObject({ + code: "EINVAL", + message: "Artifact digest requires a regular file", + }); + expect(invoked).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("classifies captured spawn failures instead of reporting provider evidence", async () => { + const directory = await mkdtemp( + join(tmpdir(), "rea-objdump-spawn-failure-"), + ); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const service = new ReverseEngineeringService({ + run: async () => { + const failure = Object.assign(new Error("not found"), { + stdout: "", + stderr: "", + code: "ENOENT", + signal: null, + killed: false, + }); + throw failure; + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(result.error.message).toContain("ENOENT"); + expect(result.error.message).not.toContain("stdout"); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("marks max-buffer termination as unknown completion while retaining output", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-objdump-max-buffer-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const service = new ReverseEngineeringService({ + run: async () => { + throw Object.assign(new Error("max buffer exceeded"), { + stdout: "captured partial output", + stderr: "", + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + signal: "SIGTERM", + killed: true, + }); + }, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "wx 00 @ 0", + }); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.raw_result).toMatchObject({ + stdout: "captured partial output", + exit_code: null, + output_truncated: true, + completion_status: "unknown", + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +it("uses one selected environment snapshot for provider selection and execution", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-provider-environment-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + const selectedEnvironment = { + REA_RIZIN_COMMAND: "/selected/rizin", + PATH: "/selected/bin", + SELECTED_VALUE: "captured", + }; + const calls: { command: string; env: NodeJS.ProcessEnv | undefined }[] = []; + const service = createReverseEngineeringService(selectedEnvironment, { + run: async (command, _args, options) => { + calls.push({ command, env: options?.env }); + return { stdout: "headers", stderr: "" }; + }, + }); + selectedEnvironment.REA_RIZIN_COMMAND = "/changed/rizin"; + selectedEnvironment.SELECTED_VALUE = "changed after construction"; + + try { + const result = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(result.ok).toBe(true); + expect(calls).toEqual([ + { + command: "/selected/rizin", + env: { + REA_RIZIN_COMMAND: "/selected/rizin", + PATH: "/selected/bin", + SELECTED_VALUE: "captured", + }, + }, + ]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("marks signal-terminated provider commands as unknown completion", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-provider-signal-exit-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + let invocation = 0; + const service = new ReverseEngineeringService({ + run: async () => { + invocation += 1; + if (invocation === 1) + throw Object.assign(new Error("provider killed by signal"), { + stdout: "partial output", + stderr: "", + code: null, + signal: "SIGKILL", + killed: true, + }); + throw Object.assign(new Error("provider exited with status 2"), { + stdout: "ordinary partial output", + stderr: "ordinary diagnostic", + code: 2, + signal: null, + killed: false, + }); + }, + }); + + try { + const signaled = await service.executeRizinCommand({ + path, + command: "wx 00 @ 0", + }); + expect(signaled.ok).toBe(true); + if (signaled.ok) { + expect(signaled.value.raw_result).toMatchObject({ + stdout: "partial output", + exit_code: null, + signal: "SIGKILL", + completion_status: "unknown", + }); + expect(signaled.value.limitations.join(" ")).toContain("signal"); + } + const ordinaryExit = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(ordinaryExit.ok).toBe(true); + if (ordinaryExit.ok) { + expect(ordinaryExit.value.raw_result).toMatchObject({ + stdout: "ordinary partial output", + exit_code: 2, + signal: null, + completion_status: "complete", + }); + expect(ordinaryExit.value.limitations.join(" ")).not.toContain( + "terminated by signal", + ); + expect(ordinaryExit.value.limitations).toContain( + "Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.", + ); + expect(ordinaryExit.value.limitations).toHaveLength(4); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +describe("Rizin command service", () => { + it("runs Rizin with user scripts suppressed while retaining plugins and preserves nonzero output", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const calls: { command: string; args: readonly string[] }[] = []; + const service = new ReverseEngineeringService({ + environment: { REA_RIZIN_COMMAND: "/tools/rizin" }, + run: async (command, args) => { + calls.push({ command, args }); + const failure = new Error("command exited with status 2") as Error & { + stdout: string; + stderr: string; + code: number; + signal: null; + killed: boolean; + }; + Object.assign(failure, { + stdout: "partial data", + stderr: "command error", + code: 2, + signal: null, + killed: false, + }); + throw failure; + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(calls).toEqual([ + { + command: "/tools/rizin", + args: ["-N", "-q", "-c", "iI", path], + }, + ]); + expect(result.value.raw_result).toMatchObject({ + stdout: "partial data", + stderr: "command error", + exit_code: 2, + completion_status: "complete", + }); + expect(result.value.limitations.join(" ")).not.toContain( + "terminated by signal", + ); + expect(result.value.limitations).toContain( + "Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.", + ); + expect(result.value.limitations).toHaveLength(4); + expect(result.value.parameters).toMatchObject({ + command: "iI", + provider_executable: "/tools/rizin", + }); + expect(result.value.provider.id).toBe("rizin"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("rejects multiline Rizin command input before launching the provider", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-input-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + let invoked = false; + const service = new ReverseEngineeringService({ + run: async () => { + invoked = true; + return { stdout: "", stderr: "" }; + }, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "iI\nq", + }); + expect(result.ok).toBe(false); + expect(invoked).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("preserves pre and post artifact digests when an unrestricted Rizin command changes the file", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-mutation-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "before"); + const service = new ReverseEngineeringService({ + run: async () => { + await writeFile(path, "after"); + return { stdout: "changed", stderr: "" }; + }, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "wx 00 @ 0", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.subject?.digest.sha256).toBe( + createHash("sha256").update("before").digest("hex"), + ); + expect(result.value.raw_result).toMatchObject({ + artifact_sha256_after: createHash("sha256") + .update("after") + .digest("hex"), + artifact_changed: true, + }); + expect(result.value.limitations).toContain( + "The provider command may have changed the artifact while analysis was running; pre-run and post-run digests are both reported.", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +it("retains partial Evidence when a provider command times out", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-timeout-partial-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "before"); + const service = new ReverseEngineeringService({ + run: async () => { + const failure = Object.assign(new Error("timeout"), { + stdout: "captured before timeout", + stderr: "still running", + code: "ETIMEDOUT", + signal: "SIGTERM", + killed: true, + }); + throw failure; + }, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "wx 00 @ 0", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisTimeoutError"); + expect(result.error.partialObservation).toMatchObject({ + operation: "execute_rizin_command", + raw_result: { + stdout: "captured before timeout", + stderr: "still running", + completion_status: "unknown", + signal: "SIGTERM", + artifact_sha256_after: expect.any(String), + }, + }); + expect(JSON.stringify(projectAnalysisError(result.error))).toContain( + "captured before timeout", + ); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves raw byte counts when cancellation rejects invalid UTF-8 output", async () => { + const directory = await mkdtemp( + join(tmpdir(), "rea-rizin-abort-invalid-utf8-"), + ); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const controller = new AbortController(); + const failure = Object.assign(new Error("The operation was aborted"), { + stdout: "�", + stderr: "€", + stdoutBytes: 1, + stderrBytes: 3, + code: "ABORT_ERR", + signal: null, + killed: true, + }); + const service = new ReverseEngineeringService({ + run: async () => { + controller.abort(); + throw failure; + }, + }); + + try { + const result = await service.executeRizinCommand( + { path, command: "iI" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.capturedOutput).toMatchObject({ + stdout: "�", + stderr: "€", + stdout_bytes: 1, + stderr_bytes: 3, + }); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves provider facts when cancellation interrupts the post-run digest", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-digest-cancel-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const controller = new AbortController(); + const service = new ReverseEngineeringService({ + run: async () => { + controller.abort(); + return { + stdout: "�", + stderr: "�", + stdoutInvalidUtf8: true, + stderrInvalidUtf8: true, + stdoutBytes: 1, + stderrBytes: 1, + }; + }, + }); + + try { + const result = await service.executeRizinCommand( + { path, command: "iI" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.capturedOutput).toMatchObject({ + stdout: "�", + stderr: "�", + stdout_bytes: 1, + stderr_bytes: 1, + exit_code: 0, + signal: null, + }); + expect(result.error.partialObservation).toMatchObject({ + raw_result: { + stdout: "�", + exit_code: null, + completion_status: "unknown", + artifact_sha256_after: null, + }, + limitations: expect.arrayContaining([ + expect.stringContaining("cancellation interrupted the digest"), + ]), + }); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("marks signal-terminated provider output incomplete", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-signal-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + const service = new ReverseEngineeringService({ + run: async () => { + throw Object.assign(new Error("terminated"), { + stdout: "partial", + stderr: "", + code: null, + signal: "SIGSEGV", + killed: false, + }); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.raw_result).toMatchObject({ + signal: "SIGSEGV", + exit_code: null, + completion_status: "unknown", + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/composition/reverseEngineeringAdmission.test.ts b/tests/composition/reverseEngineeringAdmission.test.ts new file mode 100644 index 000000000..1b92f587a --- /dev/null +++ b/tests/composition/reverseEngineeringAdmission.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { TestReverseEngineeringService } from "../support/reverseEngineeringService.js"; + +it("bounds concurrent reverse-engineering provider calls", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-capacity-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + let mode: "success" | "failure" | "timeout" = "success"; + const service = new TestReverseEngineeringService({ + run: async () => { + calls += 1; + if (mode === "failure") + throw Object.assign(new Error("provider failed"), { code: "ENOENT" }); + if (mode === "timeout") + throw Object.assign(new Error("provider timed out"), { + code: "ETIMEDOUT", + }); + await gate; + return { stdout: "", stderr: "" }; + }, + }); + + try { + const active = Array.from({ length: 8 }, () => + service.inspectWithObjdump({ path, operation: "file_headers" }), + ); + const preAborted = new AbortController(); + preAborted.abort(); + await expect( + service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal: preAborted.signal }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + await expect( + service.inspectWithObjdump({ path, operation: "file_headers" }), + ).resolves.toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + resource: "transport", + reportedLimits: { + max_concurrent_operations: 8, + active_operations: 8, + }, + }, + }); + release(); + const results = await Promise.all(active); + expect(results.every((result) => result.ok)).toBe(true); + expect(calls).toBe(8); + + mode = "failure"; + const failed = await Promise.all( + Array.from({ length: 8 }, () => + service.inspectWithObjdump({ path, operation: "file_headers" }), + ), + ); + expect(failed.every((result) => !result.ok)).toBe(true); + + mode = "timeout"; + const timedOut = await Promise.all( + Array.from({ length: 8 }, () => + service.inspectWithObjdump({ path, operation: "file_headers" }), + ), + ); + expect(timedOut.every((result) => !result.ok)).toBe(true); + + const aborted = new AbortController(); + aborted.abort(); + const cancelled = await Promise.all( + Array.from({ length: 8 }, () => + service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal: aborted.signal }, + ), + ), + ); + expect(cancelled.every((result) => !result.ok)).toBe(true); + + mode = "success"; + await expect( + service.inspectWithObjdump({ path, operation: "file_headers" }), + ).resolves.toMatchObject({ ok: true }); + } finally { + release(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/composition/reverseEngineeringCancellation.test.ts b/tests/composition/reverseEngineeringCancellation.test.ts new file mode 100644 index 000000000..927064610 --- /dev/null +++ b/tests/composition/reverseEngineeringCancellation.test.ts @@ -0,0 +1,288 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js"; + +type PromiseOutcome = + | { readonly status: "fulfilled"; readonly value: Value } + | { readonly status: "rejected"; readonly reason: unknown } + | { readonly status: "timeout" }; + +const settleWithin = async ( + promise: Promise, + timeoutMs: number, +): Promise> => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + (value): PromiseOutcome => ({ status: "fulfilled", value }), + (reason: unknown): PromiseOutcome => ({ + status: "rejected", + reason, + }), + ), + new Promise>((resolve) => { + timer = setTimeout(() => resolve({ status: "timeout" }), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +}; + +const processIsRunning = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + return !( + cause instanceof Error && + "code" in cause && + cause.code === "ESRCH" + ); + } +}; + +const waitForProcessExit = async ( + pid: number, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!processIsRunning(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return !processIsRunning(pid); +}; + +const killProcessAndWait = async ( + pid: number, + timeoutMs: number, +): Promise => { + try { + process.kill(pid, "SIGKILL"); + } catch (cause) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) + throw cause; + } + return waitForProcessExit(pid, timeoutMs); +}; + +type CancellationResult = Awaited< + ReturnType +>; + +interface CancellationProcessMarkers { + readonly providerPid: string; + readonly descendantPid: string; + readonly descendantStarted: string; +} + +interface CancellationProcessIds { + readonly providerPid: number; + readonly descendantPid: number; +} + +const readProcessMarker = async (path: string): Promise => { + const text = await readFile(path, "utf8").catch(() => ""); + const pid = Number(text); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; +}; + +const waitForCancellationProcesses = async ( + markers: CancellationProcessMarkers, +): Promise => { + for (let attempt = 0; attempt < 500; attempt += 1) { + const [providerPid, descendantPid, descendantStarted] = await Promise.all([ + readProcessMarker(markers.providerPid), + readProcessMarker(markers.descendantPid), + readFile(markers.descendantStarted).then( + () => true, + () => false, + ), + ]); + if ( + providerPid !== undefined && + descendantPid !== undefined && + descendantStarted + ) + return { providerPid, descendantPid }; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); + } + throw new Error("Rizin provider and descendant did not start in time."); +}; + +const killCancellationProcesses = async ( + ids: CancellationProcessIds, +): Promise => { + expect(await killProcessAndWait(ids.providerPid, 2_000)).toBe(true); + expect(await killProcessAndWait(ids.descendantPid, 2_000)).toBe(true); +}; + +const settleCancelledProvider = async ( + pending: Promise, +): Promise => { + const outcome = await settleWithin(pending, 7_000); + if (outcome.status === "timeout") + throw new Error("Rizin cancellation did not settle before its deadline"); + if (outcome.status === "rejected") throw outcome.reason; + return outcome.value; +}; + +const expectCancelledResult = (result: CancellationResult): void => { + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cleanup).toBeUndefined(); + expect(result.error.capturedOutput).toMatchObject({ + stdout: "provider output before cancellation", + stderr: "", + truncated: false, + exit_code: null, + signal: "SIGKILL", + }); + expect(result.error.cause).toBeInstanceOf(Error); + expect(result.error.partialObservation).toMatchObject({ + raw_result: { + stdout: "provider output before cancellation", + completion_status: "unknown", + }, + }); +}; + +const cleanupCancellationScenario = async (options: { + readonly controller: AbortController; + readonly pending: Promise | undefined; + readonly service: ReverseEngineeringService; + readonly processIds: CancellationProcessIds | undefined; + readonly directory: string; +}): Promise => { + options.controller.abort(); + try { + if (options.pending !== undefined) { + try { + if (options.processIds !== undefined) + await settleCancelledProvider(options.pending); + else await settleWithin(options.pending, 10_000); + } catch { + // Main assertions report operation failures; continue cleanup. + } + } + let cleanup = await settleWithin(options.service.close(), 7_000); + if (cleanup.status === "timeout" && options.processIds !== undefined) { + await killCancellationProcesses(options.processIds); + cleanup = await settleWithin(options.service.close(), 3_000); + } + if (cleanup.status === "timeout") + throw new Error("Service cleanup did not settle within its deadline."); + if (cleanup.status === "rejected") throw cleanup.reason; + if (options.processIds !== undefined) { + for (const pid of [ + options.processIds.providerPid, + options.processIds.descendantPid, + ]) { + if (processIsRunning(pid)) await killProcessAndWait(pid, 2_000); + } + } + } finally { + await rm(options.directory, { recursive: true, force: true }); + } +}; + +it("uses stoppable process mode for caller-selected provider commands", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-stoppable-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "fixture"); + let received: Record | undefined; + const service = new ReverseEngineeringService({ + run: async (_command, _args, options) => { + received = options as Record; + return { stdout: "", stderr: "" }; + }, + }); + + try { + await service.executeRizinCommand({ path, command: "iI" }); + expect(received?.stopSignal).toBe("SIGTERM"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it.skipIf(process.platform === "win32")( + "waits for a cancelled Rizin process to exit before returning", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-cancel-settle-")); + const path = join(directory, "fixture.bin"); + const providerPidMarker = join(directory, "provider-pid"); + const descendantStartedMarker = join(directory, "descendant-started"); + const descendantPidMarker = join(directory, "descendant-pid"); + const provider = join(directory, "slow-rizin.mjs"); + const descendantScript = [ + "const fs = require('node:fs');", + "process.on('SIGTERM', () => {});", + "fs.writeFileSync(process.env.REA_TEST_DESCENDANT_STARTED, 'started');", + "setInterval(() => {}, 1000);", + ].join("\n"); + await writeFile(path, "before"); + await writeFile( + provider, + `#!${process.execPath} +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +writeFileSync(process.env.REA_TEST_PROVIDER_PID, String(process.pid)); +process.stdout.write("provider output before cancellation"); +const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendantScript)}], { env: process.env, stdio: "ignore" }); +writeFileSync(process.env.REA_TEST_DESCENDANT_PID, String(child.pid)); +setInterval(() => {}, 1000); +`, + ); + await chmod(provider, 0o755); + const controller = new AbortController(); + const service = new ReverseEngineeringService({ + environment: { + REA_RIZIN_COMMAND: provider, + REA_TEST_ARTIFACT: path, + REA_TEST_PROVIDER_PID: providerPidMarker, + REA_TEST_DESCENDANT_STARTED: descendantStartedMarker, + REA_TEST_DESCENDANT_PID: descendantPidMarker, + }, + }); + + let pending: ReturnType | undefined; + let processIds: CancellationProcessIds | undefined; + try { + pending = service.executeRizinCommand( + { path, command: "sleep" }, + { signal: controller.signal }, + ); + processIds = await waitForCancellationProcesses({ + providerPid: providerPidMarker, + descendantPid: descendantPidMarker, + descendantStarted: descendantStartedMarker, + }); + controller.abort(); + + expectCancelledResult(await settleCancelledProvider(pending)); + expect(await readFile(path, "utf8")).toBe("before"); + expect(await waitForProcessExit(processIds.descendantPid, 2_000)).toBe( + true, + ); + expect(await waitForProcessExit(processIds.providerPid, 2_000)).toBe( + true, + ); + } finally { + await cleanupCancellationScenario({ + controller, + pending, + service, + processIds, + directory, + }); + } + }, +); diff --git a/tests/composition/reverseEngineeringCommandPort.test.ts b/tests/composition/reverseEngineeringCommandPort.test.ts new file mode 100644 index 000000000..e5c5a3142 --- /dev/null +++ b/tests/composition/reverseEngineeringCommandPort.test.ts @@ -0,0 +1,114 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, it, vi } from "vitest"; + +import { ReverseEngineeringService } from "../../src/application/reverse/ReverseEngineeringService.js"; +import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js"; +import { execFileOutput } from "../../src/process/ExecFileOutput.js"; + +type ExecFileOutputOptions = NonNullable[2]>; + +it("documents the shared combined capture limit for both command tools", () => { + for (const contract of REVERSE_ENGINEERING_TOOL_CONTRACTS) + expect(contract.description).toContain("shared 16 MiB combined limit"); +}); + +it("uses provider profiles supplied through the application port", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-port-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + const run = vi.fn( + async ( + _command: string, + _args: readonly string[], + _options?: ExecFileOutputOptions, + ) => ({ stdout: "injected", stderr: "" }), + ); + const service = new ReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: "ambient-objdump" }, + commands: { + objdump: () => ({ + executable: "injected-objdump", + arguments: ["--injected", path], + identity: { + id: "test.objdump", + name: "Injected objdump", + version: null, + }, + environmentOverrides: { INJECTED_PROFILE: "yes" }, + }), + rizin: () => ({ + executable: "injected-rizin", + arguments: ["--injected", path], + identity: { id: "test.rizin", name: "Injected Rizin", version: null }, + }), + }, + run, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result.ok).toBe(true); + const call = run.mock.calls[0]; + expect(call?.[0]).toBe("injected-objdump"); + expect(call?.[1]).toEqual(["--injected", path]); + expect(call?.[2]?.env?.INJECTED_PROFILE).toBe("yes"); + if (result.ok) expect(result.value.provider.id).toBe("test.objdump"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("does not reintroduce ambient variables absent from the captured provider environment", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-env-")); + const path = join(directory, "fixture.bin"); + const variable = "REA_TEST_LATE_PROVIDER_ENV"; + const previous = process.env[variable]; + await writeFile(path, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + process.env[variable] = "late-ambient-value"; + const run = vi.fn( + async ( + _command: string, + _args: readonly string[], + _options?: ExecFileOutputOptions, + ) => ({ stdout: "captured", stderr: "" }), + ); + const service = new ReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: "captured-objdump" }, + commands: { + objdump: () => ({ + executable: "captured-objdump", + arguments: [path], + identity: { + id: "test.objdump", + name: "Captured objdump", + version: null, + }, + environmentOverrides: { PROFILE_VALUE: "explicit" }, + }), + rizin: () => ({ + executable: "captured-rizin", + arguments: [path], + identity: { id: "test.rizin", name: "Captured Rizin", version: null }, + }), + }, + run, + }); + + try { + await service.inspectWithObjdump({ path, operation: "file_headers" }); + expect(run.mock.calls[0]?.[2]?.env).toEqual({ + REA_OBJDUMP_COMMAND: "captured-objdump", + PROFILE_VALUE: "explicit", + }); + } finally { + if (previous === undefined) delete process.env[variable]; + else process.env[variable] = previous; + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/composition/reverseEngineeringEdgeCases.test.ts b/tests/composition/reverseEngineeringEdgeCases.test.ts new file mode 100644 index 000000000..3b748fff0 --- /dev/null +++ b/tests/composition/reverseEngineeringEdgeCases.test.ts @@ -0,0 +1,1047 @@ +import { rm } from "node:fs/promises"; +import type { Stats } from "node:fs"; + +import { expect, it, vi } from "vitest"; +import { registerSharedReverseEngineeringEdgeCaseTests } from "../support/reverseEngineeringCompositionEdgeCases.js"; +import { registerOwnedRizinCommandEvidenceTests } from "../support/ownedRizinCommandEvidenceTests.js"; +import { + createArtifact, + providerSnapshot, +} from "../support/reverseEngineeringCompositionFixtures.js"; + +import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js"; +import { AnalysisCapabilityUnavailableError } from "../../src/domain/analysisErrorCore.js"; +import { ReverseEngineeringService as CoreReverseEngineeringService } from "../../src/application/reverse/ReverseEngineeringService.js"; +import { createReverseEngineeringCommandPort } from "../../src/composition/reverseEngineering.js"; +import { AnalysisResourceConstraintError } from "../../src/domain/analysisErrorCore.js"; +import { objdumpCommand } from "../../src/objdump/ObjdumpCommand.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../../src/process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../../src/process/ProviderProcess.js"; +import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js"; + +registerSharedReverseEngineeringEdgeCaseTests(); +registerOwnedRizinCommandEvidenceTests({ sweepTokenOwnedProcesses: true }); + +it("records pathless objdump architecture support as provider capability evidence", async () => { + const { directory, path } = await createArtifact(); + expect(objdumpCommand({ operation: "architectures" })).toEqual(["-i"]); + const digest = vi.fn(async () => "a".repeat(64)); + const service = new CoreReverseEngineeringService({ + commands: createReverseEngineeringCommandPort(process.env), + digest, + run: async () => ({ stdout: "elf64-x86-64", stderr: "" }), + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + if (!result.ok) throw result.error; + expect(result.value.subject).toBeNull(); + expect(result.value.parameters).not.toHaveProperty("path"); + expect(result.value.parameters).not.toHaveProperty( + "artifact_sha256_before", + ); + expect(result.value.normalized_result).toMatchObject({ + artifact_sha256_after: null, + artifact_changed: null, + }); + const contract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find( + ({ name }) => name === "inspect_with_objdump", + ); + expect(contract?.outputSchema.safeParse(result.value).success).toBe(true); + expect(digest).not.toHaveBeenCalled(); + expect( + await service.inspectWithObjdump({ path, operation: "architectures" }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + const ordinaryInspection = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(ordinaryInspection.ok).toBe(true); + expect(digest).toHaveBeenCalledTimes(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("uses the host environment when the service environment is omitted", async () => { + const { directory, path } = await createArtifact(); + const key = "REA_REVERSE_TOOLS_ENV_FALLBACK_TEST"; + const previous = process.env[key]; + process.env[key] = "host-value"; + let observedEnvironment: NodeJS.ProcessEnv | undefined; + const service = new CoreReverseEngineeringService({ + commands: createReverseEngineeringCommandPort(process.env), + run: async (_command, _args, options) => { + observedEnvironment = options?.env; + return { stdout: "ok", stderr: "" }; + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result.ok).toBe(true); + expect(observedEnvironment?.[key]).toBe("host-value"); + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each(["cancellation", "deadline"] as const)( + "interrupts a pending stat on %s and observes its eventual rejection", + async (interruption) => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + let rejectStat: ((cause: Error) => void) | undefined; + let announceStat: (() => void) | undefined; + const statStarted = new Promise((resolve) => { + announceStat = resolve; + }); + const service = new ReverseEngineeringService({ + stat: async () => { + announceStat?.(); + return await new Promise((_resolve, reject) => { + rejectStat = reject; + }); + }, + run: async () => ({ stdout: "", stderr: "" }), + }); + + try { + if (interruption === "deadline") vi.useFakeTimers(); + const resultPromise = service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal: controller.signal }, + ); + await statStarted; + if (interruption === "cancellation") controller.abort(); + else await vi.advanceTimersByTimeAsync(120_000); + await expect(resultPromise).resolves.toMatchObject({ + ok: false, + error: { + _tag: + interruption === "cancellation" + ? "AnalysisCancelledError" + : "AnalysisTimeoutError", + }, + }); + rejectStat?.(new Error("late stat failure")); + await Promise.resolve(); + } finally { + vi.useRealTimers(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it("handles cancellation that races with stat listener registration", async () => { + const { directory, path } = await createArtifact(); + let aborted = false; + const signal = { + get aborted() { + return aborted; + }, + addEventListener: ( + _type: string, + listener: Parameters[1], + ) => { + aborted = true; + if (typeof listener === "function") listener(new Event("abort")); + else listener.handleEvent(new Event("abort")); + }, + removeEventListener: () => {}, + dispatchEvent: () => true, + onabort: null, + reason: undefined, + throwIfAborted: () => {}, + } as AbortSignal; + const stat = vi.fn(async () => ({ isFile: () => true }) as unknown as Stats); + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ stat, run }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(stat).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("handles a synchronous stat provider throw as an artifact input failure", async () => { + const { directory, path } = await createArtifact(); + const stat = vi.fn(() => { + throw Object.assign(new Error("stat failed before returning a promise"), { + code: "ENOENT", + }); + }); + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ + stat: stat as unknown as (path: string) => Promise, + run, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(stat).toHaveBeenCalledTimes(1); + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("cancels an artifact stream when cancellation races with its listener registration", async () => { + const { directory, path } = await createArtifact(); + let aborted = false; + let registrations = 0; + const signal = { + get aborted() { + return aborted; + }, + addEventListener: ( + _type: string, + listener: Parameters[1], + ) => { + registrations += 1; + if (registrations === 2) { + aborted = true; + if (typeof listener === "function") listener(new Event("abort")); + else listener.handleEvent(new Event("abort")); + } + }, + removeEventListener: () => {}, + dispatchEvent: () => true, + onabort: null, + reason: undefined, + throwIfAborted: () => {}, + } as AbortSignal; + const stat = vi.fn(async () => ({ isFile: () => true }) as unknown as Stats); + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ stat, run }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(registrations).toBe(2); + expect(stat).toHaveBeenCalledTimes(1); + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("cancels before stream listeners when the signal changes after stat", async () => { + const { directory, path } = await createArtifact(); + let abortedReads = 0; + const signal = { + get aborted() { + abortedReads += 1; + return abortedReads >= 5; + }, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onabort: null, + reason: undefined, + throwIfAborted: () => {}, + } as AbortSignal; + const stat = vi.fn(async () => ({ isFile: () => true }) as unknown as Stats); + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ stat, run }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + // Cancellation checks may be repeated by the digest cleanup path. + expect(abortedReads).toBeGreaterThanOrEqual(5); + expect(stat).toHaveBeenCalledTimes(1); + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("rejects malformed Rizin requests before touching the provider", async () => { + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ run }); + + const result = await service.executeRizinCommand({ path: "sample.bin" }); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError", operation: "execute_rizin_command" }, + }); + expect(run).not.toHaveBeenCalled(); +}); + +it("rejects directories as artifacts before invoking the provider", async () => { + const { directory } = await createArtifact(); + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ run }); + + try { + const result = await service.inspectWithObjdump({ + path: directory, + operation: "file_headers", + }); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError", operation: "inspect_with_objdump" }, + }); + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("rejects new operations after service shutdown begins", async () => { + const { directory, path } = await createArtifact(); + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ run }); + + try { + await service.close(); + const result = await service.executeRizinCommand({ path, command: "iI" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisOutputError"); + expect(result.error.message).toContain( + "Reverse-engineering service is closed.", + ); + } + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("rejects work after reaching the concurrent provider-operation limit", async () => { + const { directory, path } = await createArtifact(); + let releaseProviders: (() => void) | undefined; + const providerGate = new Promise((resolve) => { + releaseProviders = resolve; + }); + let providerCalls = 0; + const service = new ReverseEngineeringService({ + run: async () => { + providerCalls += 1; + await providerGate; + return { stdout: "ok", stderr: "" }; + }, + }); + + try { + const admitted = Array.from({ length: 8 }, () => + service.executeRizinCommand({ path, command: "iI" }), + ); + const rejected = await service.executeRizinCommand({ path, command: "iI" }); + + expect(rejected.ok).toBe(false); + if (!rejected.ok) { + expect(rejected.error._tag).toBe("AnalysisResourceConstraintError"); + expect(rejected.error).toBeInstanceOf(AnalysisResourceConstraintError); + if (rejected.error instanceof AnalysisResourceConstraintError) + expect(rejected.error.reportedLimits).toMatchObject({ + max_concurrent_operations: 8, + active_operations: 8, + }); + expect(rejected.error.message).toContain( + "Concurrent reverse-engineering operation capacity reached.", + ); + } + expect(providerCalls).toBe(0); + + releaseProviders?.(); + const results = await Promise.all(admitted); + expect(results.every((result) => result.ok)).toBe(true); + expect(providerCalls).toBe(8); + } finally { + releaseProviders?.(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("cancels when an AbortSignal becomes aborted during listener registration", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + let abortedReads = 0; + const signal = new Proxy(controller.signal, { + get(target, property) { + if (property === "aborted") return ++abortedReads > 1; + const value = Reflect.get(target, property, target) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ run }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(abortedReads).toBe(2); + expect(run).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("drains an active provider operation before service shutdown resolves", async () => { + const { directory, path } = await createArtifact(); + let signal: AbortSignal | undefined; + let releaseRun: (() => void) | undefined; + let runStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + runStarted = resolve; + }); + const service = new ReverseEngineeringService({ + run: async (_command, _args, options) => { + signal = options?.signal; + runStarted?.(); + await new Promise((resolve) => { + releaseRun = resolve; + }); + return { stdout: "finished", stderr: "" }; + }, + }); + + try { + const operation = service.executeRizinCommand({ path, command: "iI" }); + await started; + let closed = false; + const closing = service.close().then(() => { + closed = true; + }); + expect(signal?.aborted).toBe(true); + expect(closed).toBe(false); + releaseRun?.(); + await closing; + expect(closed).toBe(true); + await operation; + } finally { + releaseRun?.(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("relays caller cancellation to the admitted provider signal", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + const addAbortListener = vi.spyOn(controller.signal, "addEventListener"); + let announceStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + announceStarted = resolve; + }); + let releaseRun: (() => void) | undefined; + let providerSignal: AbortSignal | undefined; + const service = new CoreReverseEngineeringService({ + commands: createReverseEngineeringCommandPort(process.env), + run: async (_command, _args, options) => { + providerSignal = options?.signal; + announceStarted?.(); + await new Promise((resolve) => { + releaseRun = resolve; + }); + return { stdout: "captured", stderr: "" }; + }, + }); + let operation: ReturnType | undefined; + + try { + operation = service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal: controller.signal }, + ); + await started; + controller.abort(); + expect(addAbortListener).toHaveBeenCalledWith( + "abort", + expect.any(Function), + { once: true }, + ); + expect(providerSignal?.aborted).toBe(true); + releaseRun?.(); + await expect(operation).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + } finally { + releaseRun?.(); + if (operation !== undefined) await operation.catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("passes an AbortSignal-like caller value through without relaying it", async () => { + const { directory, path } = await createArtifact(); + const signal = { + aborted: false, + addEventListener: vi.fn(() => { + throw new Error( + "A non-native signal must not register a relay listener.", + ); + }), + removeEventListener: vi.fn(), + } as unknown as AbortSignal; + const digest = vi.fn(async (_path: string, receivedSignal?: AbortSignal) => { + expect(receivedSignal).toBe(signal); + return "a".repeat(64); + }); + const run = vi.fn(async (_command, _args, options) => { + expect(options?.signal).toBe(signal); + return { stdout: "ok", stderr: "" }; + }); + const service = new CoreReverseEngineeringService({ + commands: createReverseEngineeringCommandPort(process.env), + digest, + run, + }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal }, + ); + + expect(result.ok).toBe(true); + expect(signal.addEventListener).not.toHaveBeenCalled(); + expect(signal.removeEventListener).toHaveBeenCalledTimes(1); + expect(digest).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenCalledTimes(1); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("cancels after operation admission and before starting the artifact digest", async () => { + const { directory, path } = await createArtifact(); + let reads = 0; + const signal = { + get aborted() { + reads += 1; + return reads > 1; + }, + addEventListener() {}, + removeEventListener() {}, + throwIfAborted() {}, + onabort: null, + reason: undefined, + dispatchEvent: () => true, + } as AbortSignal; + let digestStarted = false; + const service = new ReverseEngineeringService({ + digest: async () => { + digestStarted = true; + return "a".repeat(64); + }, + }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(reads).toBeGreaterThanOrEqual(2); + expect(digestStarted).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves raw retained byte counts when output text replaces invalid UTF-8", async () => { + const { directory, path } = await createArtifact(); + const snapshot = providerSnapshot({ + stdout: { text: "�", bytes: 1, observedBytes: 1, invalidUtf8: true }, + stderr: { text: "�", bytes: 2, observedBytes: 2, invalidUtf8: true }, + exitCode: 7, + }); + const service = new ReverseEngineeringService({ + runOwned: async () => { + throw new OwnedCommandFailure( + "cancelled", + "Command was cancelled after output capture", + snapshot, + ); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + capturedOutput: { + stdout: "�", + stderr: "�", + stdout_bytes: 1, + stderr_bytes: 2, + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves the provider remediation when owned process preparation fails", async () => { + const { directory, path } = await createArtifact(); + const remediation = new AnalysisCapabilityUnavailableError( + "rizin", + "execute_rizin_command", + "Rizin needs permission to inspect this process", + { userMessage: "Grant process inspection permission, then retry." }, + ); + const service = new ReverseEngineeringService({ + runOwned: async () => { + throw new OwnedCommandFailure( + "process", + "Could not prepare process ownership", + null, + null, + { cause: remediation }, + ); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + providerId: "rizin", + reason: remediation.reason, + userMessage: remediation.userMessage, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + ["Error", new Error("cleanup state unknown"), "cleanup state unknown"], + ["non-Error", "cleanup state unknown", "cleanup state unknown"], +] as const)( + "preserves a thrown %s cleanup reason while blocking new provider work", + async (_label, cause, expectedReason) => { + const { directory, path } = await createArtifact(); + const cleanupOwner = { + stop: vi.fn(async () => Promise.reject(cause)), + } as unknown as ProviderProcessSupervisor; + const runOwned = vi.fn(async () => { + throw new OwnedCommandFailure( + "cancelled", + "command cancelled", + providerSnapshot({ exitCode: null }), + "cleanup pending", + undefined, + ["run-id", "pid:42"], + cleanupOwner, + ); + }); + const service = new ReverseEngineeringService({ runOwned }); + + try { + const first = await service.executeRizinCommand({ path, command: "iI" }); + expect(first.ok).toBe(false); + + const blocked = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(blocked).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + cleanup: { reason: expectedReason, resources: ["run-id", "pid:42"] }, + }, + }); + + await expect(service.close()).rejects.toMatchObject({ + cleanup: { reason: expectedReason, resources: ["run-id", "pid:42"] }, + }); + expect(cleanupOwner.stop).toHaveBeenCalledTimes(2); + expect(runOwned).toHaveBeenCalledTimes(1); + } finally { + await service.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it("releases a retained provider owner after cleanup succeeds", async () => { + const { directory, path } = await createArtifact(); + const cleanupOwner = { + stop: vi.fn(async () => ({ status: "verified-cleanup" as const })), + } as unknown as ProviderProcessSupervisor; + const runOwned = vi + .fn() + .mockRejectedValueOnce( + new OwnedCommandFailure( + "cancelled", + "command cancelled", + providerSnapshot({ exitCode: null }), + "cleanup pending", + undefined, + ["run-id", "pid:42"], + cleanupOwner, + ), + ) + .mockResolvedValue(providerSnapshot()); + const service = new ReverseEngineeringService({ runOwned }); + + try { + const first = await service.executeRizinCommand({ path, command: "iI" }); + expect(first.ok).toBe(false); + const recovered = await service.executeRizinCommand({ + path, + command: "iI", + }); + + expect(recovered.ok).toBe(true); + expect(cleanupOwner.stop).toHaveBeenCalledTimes(1); + expect(runOwned).toHaveBeenCalledTimes(2); + } finally { + await service.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("retains a capability failure when its injected owned runner also reports incomplete cleanup", async () => { + const { directory, path } = await createArtifact(); + const capability = new AnalysisCapabilityUnavailableError( + "rizin", + "execute_rizin_command", + "Ownership inspection is unavailable.", + { diagnostics: { source: "owned-runner" } }, + ); + const cleanupOwner = { + stop: vi.fn(async () => ({ status: "verified-cleanup" as const })), + } as unknown as ProviderProcessSupervisor; + const runOwned = vi + .fn() + .mockRejectedValueOnce( + new OwnedCommandFailure( + "process", + "Ownership inspection failed after launch.", + null, + "owned descendant remains", + { cause: capability }, + ["run-id", "pid:42"], + cleanupOwner, + ), + ) + .mockResolvedValue(providerSnapshot()); + const service = new ReverseEngineeringService({ runOwned }); + + try { + const first = await service.executeRizinCommand({ path, command: "iI" }); + if (first.ok) throw new Error("Expected the capability failure"); + expect(first.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(first.error).toBeInstanceOf(AnalysisCapabilityUnavailableError); + expect(first.error.diagnostics).toEqual({ source: "owned-runner" }); + expect(first.error.cleanupIncomplete).toBe(true); + expect(first.error.cleanup).toMatchObject({ + reason: "owned descendant remains", + resources: ["run-id", "pid:42"], + }); + + const recovered = await service.executeRizinCommand({ + path, + command: "iI", + }); + if (!recovered.ok) throw recovered.error; + + expect(cleanupOwner.stop).toHaveBeenCalledOnce(); + expect(first.error.cleanupIncomplete).toBe(false); + expect(runOwned).toHaveBeenCalledTimes(2); + } finally { + await service.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves a provider timeout when cancellation arrives during the final digest", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + let digests = 0; + const service = new ReverseEngineeringService({ + digest: async () => { + digests += 1; + if (digests === 2) controller.abort(); + return "a".repeat(64); + }, + run: async () => { + throw Object.assign(new Error("provider timed out"), { + stdout: "partial timeout output", + stderr: "timeout diagnostic", + code: "ETIMEDOUT", + signal: null, + killed: true, + }); + }, + }); + + try { + const result = await service.executeRizinCommand( + { path, command: "iI" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisTimeoutError", + capturedOutput: { + stdout: "partial timeout output", + stderr: "timeout diagnostic", + }, + partialObservation: { + raw_result: { + stdout: "partial timeout output", + completion_status: "unknown", + }, + limitations: expect.arrayContaining([ + expect.stringContaining("AnalysisTimeoutError"), + ]), + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("classifies E2BIG as a provider command resource constraint", async () => { + const { directory, path } = await createArtifact(); + const cleanupOwner = { + stop: vi.fn(async () => ({ status: "verified-cleanup" as const })), + } as unknown as ProviderProcessSupervisor; + const runOwned = vi + .fn() + .mockRejectedValueOnce( + new OwnedCommandFailure( + "process", + "Command launch failed.", + null, + "owned descendant remains", + { + cause: Object.assign(new Error("argument list too long"), { + code: "E2BIG", + }), + }, + ["run-id", "pid:42"], + cleanupOwner, + ), + ) + .mockResolvedValue(providerSnapshot()); + const service = new ReverseEngineeringService({ + runOwned, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + resource: "transport", + operation: "execute_rizin_command", + }, + }); + if (!result.ok) + expect(result.error.message).toContain( + "The host rejected the provider command because its argument list exceeds the process launch limit.", + ); + if (!result.ok) { + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanup?.resources).toEqual(["run-id", "pid:42"]); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + if (result.error instanceof AnalysisResourceConstraintError) + expect(result.error.reportedLimits).toMatchObject({ + argument_bytes: expect.any(Number), + }); + } + const retried = await service.executeRizinCommand({ path, command: "iI" }); + expect(retried.ok).toBe(true); + if (!result.ok) expect(result.error.cleanupIncomplete).toBe(false); + expect(cleanupOwner.stop).toHaveBeenCalledOnce(); + expect(runOwned).toHaveBeenCalledTimes(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves completed command Evidence while its post-run digest awaits cleanup", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async () => { + throw new OwnedCommandFailure( + "process", + "Owned process cleanup remains incomplete.", + providerSnapshot({ exitCode: 0 }), + "descendant remains active", + undefined, + ["run-id", "pid:42"], + ); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + partialObservation: { + raw_result: { + exit_code: 0, + completion_status: "complete", + artifact_sha256_after: null, + artifact_changed: null, + }, + limitations: expect.arrayContaining([ + expect.stringContaining( + "owned provider cleanup was incomplete at observation time", + ), + ]), + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("records an explicit loss marker when provider bytes are invalid UTF-8", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async () => + providerSnapshot({ + stdout: { + text: "captured � output", + bytes: 17, + observedBytes: 17, + invalidUtf8: true, + }, + }), + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toMatchObject({ + raw_result: { stdout: "captured � output" }, + }); + expect(result.value.limitations).toContain( + "Captured stdout bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.", + ); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + [ + "stderr only", + { + stderr: { + text: "diagnostic � output", + bytes: 22, + observedBytes: 22, + invalidUtf8: true, + }, + }, + "stderr", + ], + [ + "both streams", + { + stdout: { + text: "captured � output", + bytes: 17, + observedBytes: 17, + invalidUtf8: true, + }, + stderr: { + text: "diagnostic � output", + bytes: 22, + observedBytes: 22, + invalidUtf8: true, + }, + }, + "stdout and stderr", + ], +] as const)( + "records invalid UTF-8 loss for %s", + async (_label, overrides, streams) => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async () => providerSnapshot(overrides), + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.limitations).toContain( + `Captured ${streams} bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.`, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/tests/composition/reverseEngineeringLocalInspectionEdgeCases.test.ts b/tests/composition/reverseEngineeringLocalInspectionEdgeCases.test.ts new file mode 100644 index 000000000..4915cf743 --- /dev/null +++ b/tests/composition/reverseEngineeringLocalInspectionEdgeCases.test.ts @@ -0,0 +1,31 @@ +import { rm } from "node:fs/promises"; +import { createArtifact } from "../support/reverseEngineeringCompositionFixtures.js"; + +import { expect, it, vi } from "vitest"; + +import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js"; + +it("rejects NUL in a Rizin command before touching the provider", async () => { + const { directory, path } = await createArtifact(); + let invoked = false; + const service = new ReverseEngineeringService({ + run: async () => { + invoked = true; + return { stdout: "", stderr: "" }; + }, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "wx\u0000 00", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError", operation: "execute_rizin_command" }, + }); + expect(invoked).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/conformance/ghidra/mips-relocatable-checks.test.ts b/tests/conformance/ghidra/mips-relocatable-checks.test.ts index 9ee2edef0..990a94c7d 100644 --- a/tests/conformance/ghidra/mips-relocatable-checks.test.ts +++ b/tests/conformance/ghidra/mips-relocatable-checks.test.ts @@ -27,7 +27,7 @@ Relocation section '.rel.data' at offset 0x118 contains 2 entries: Symbol table '.symtab' contains 4 entries: Num: Value Size Type Bind Vis Ndx Name - 0: 00000000 0 NOTYPE LOCAL DEFAULT UND + 0: 00000000 0 NOTYPE LOCAL DEFAULT UND 1: 00000000 16 FUNC GLOBAL DEFAULT 1 func 2: 00008000 4 OBJECT GLOBAL DEFAULT 3 far_data 3: 00000000 0 NOTYPE GLOBAL DEFAULT UND ext_data diff --git a/tests/evaluation/package/packagePlatformDiagnostics.test.ts b/tests/evaluation/package/packagePlatformDiagnostics.test.ts index 3eb752d86..d4f88b875 100644 --- a/tests/evaluation/package/packagePlatformDiagnostics.test.ts +++ b/tests/evaluation/package/packagePlatformDiagnostics.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { assertLinuxPackageProviderFailure } from "../../../scripts/verify-package-platform.mjs"; +import { + assertLinuxPackageProviderFailure, + verifyPackagePlatform, +} from "../../../scripts/verify-package-platform.mjs"; const missingDisplay = { code: "provider_unavailable", @@ -22,6 +25,40 @@ const execution = (failure: unknown, status = 1) => ({ stdout: JSON.stringify(failure), }); +const overview = ( + procedure_count?: unknown, + operation = "binary_overview", +) => ({ + operation, + ...(procedure_count === undefined + ? {} + : { normalized_result: { procedure_count } }), +}); + +const verifyMacOS = async ( + analyze: unknown, + inspect: unknown, +): Promise => { + const outputs: Readonly> = { + analyze, + inspect, + function: { + operation: "analyze_function", + provider: { id: "hopper" }, + normalized_result: { procedure: { address: "0x1000" } }, + }, + xrefs: { operation: "xrefs", normalized_result: ["0x1000"] }, + trace: { operation: "trace_feature" }, + }; + await verifyPackagePlatform({ + cli: "fixture-cli", + environment: {}, + platform: "darwin", + runCommand: async (_cli, arguments_) => + JSON.stringify(outputs[arguments_[0]!]) ?? "", + }); +}; + describe("packaged Linux failure verification", () => { it("accepts the unsupported fixture build or an exact missing display dependency", () => { expect(() => @@ -71,3 +108,29 @@ describe("packaged Linux failure verification", () => { }, ); }); + +describe("packaged analyze and inspect result guards", () => { + it("accepts positive integer procedure counts for both commands", async () => { + await verifyMacOS(overview(1), overview(2)); + }); + + const malformed = [ + ["missing result", undefined], + ["null result", null], + ["missing normalized count", overview()], + ["zero count", overview(0)], + ["non-integer count", overview(1.5)], + ["wrong operation", overview(2, "other_operation")], + ] as const; + + it.each(["analyze", "inspect"] as const)( + "rejects malformed %s results before trusting the package", + async (command) => { + for (const [label, invalid] of malformed) { + const analyze = command === "analyze" ? invalid : overview(4); + const inspect = command === "inspect" ? invalid : overview(4); + await expect(verifyMacOS(analyze, inspect), label).rejects.toThrow(); + } + }, + ); +}); diff --git a/tests/evaluation/package/packageSetupSupport.test.ts b/tests/evaluation/package/packageSetupSupport.test.ts new file mode 100644 index 000000000..2103e5f95 --- /dev/null +++ b/tests/evaluation/package/packageSetupSupport.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { packageSetupSupport } from "../../../scripts/verify-package-discovery.mjs"; +import { parseLinuxDistribution } from "../../../src/application/LinuxHopper.js"; + +const osRelease = (fields: Record) => + parseLinuxDistribution( + Object.entries(fields) + .map(([key, value]) => `${key}="${value}"`) + .join("\n"), + ); + +/** A Debian-family host REA supports that the Hopper package never tests. */ +const KALI = osRelease({ ID: "kali", VERSION_ID: "2026.3", ID_LIKE: "debian" }); +const UBUNTU_24 = osRelease({ ID: "ubuntu", VERSION_ID: "24.04" }); + +const checks = ({ node = true, host = true } = {}) => [ + { name: "node", ok: node }, + { name: "host", ok: host }, +]; + +describe("packaged setup support derivation", () => { + it("keeps an installer-untested REA-supported distribution out of the automated Hopper path", () => { + // The doctor host check passes on Kali, so reading installer applicability + // from it promised an automated install the packaged setup never offers. + expect( + packageSetupSupport(checks(), { + platform: "linux", + linuxDistribution: KALI, + }), + ).toEqual({ + supportedSetupHost: true, + hopperReadinessRequired: true, + hopperSetupSupported: false, + }); + }); + + it("follows the distribution's own applicability, not host readiness", () => { + expect( + packageSetupSupport(checks({ host: false }), { + platform: "linux", + linuxDistribution: UBUNTU_24, + }), + ).toEqual({ + supportedSetupHost: true, + hopperReadinessRequired: false, + hopperSetupSupported: true, + }); + }); + + it("keeps unreadable Linux applicability unknown", () => { + expect( + packageSetupSupport(checks(), { platform: "linux" }).hopperSetupSupported, + ).toBeUndefined(); + }); + + it("uses the owner's macOS predicate and excludes Windows", () => { + expect( + packageSetupSupport(checks(), { platform: "darwin" }) + .hopperSetupSupported, + ).toBe(true); + expect( + packageSetupSupport(checks({ host: false }), { platform: "darwin" }) + .hopperSetupSupported, + ).toBe(false); + expect( + packageSetupSupport([], { platform: "darwin" }).hopperSetupSupported, + ).toBeUndefined(); + expect( + packageSetupSupport(checks(), { platform: "win32" }).hopperSetupSupported, + ).toBe(false); + }); + + it("reports agent setup support from the node check alone", () => { + expect( + packageSetupSupport(checks({ node: false }), { + platform: "linux", + linuxDistribution: UBUNTU_24, + }), + ).toEqual({ + supportedSetupHost: false, + hopperReadinessRequired: true, + hopperSetupSupported: true, + }); + }); +}); diff --git a/tests/fixtures/cutter_bridge_protocol_test.py b/tests/fixtures/cutter_bridge_protocol_test.py new file mode 100644 index 000000000..ae9876919 --- /dev/null +++ b/tests/fixtures/cutter_bridge_protocol_test.py @@ -0,0 +1,454 @@ +"""Producer-level regression tests for Cutter's authenticated local protocol.""" + +import hashlib +import hmac +import importlib.util +import json +import os +import socket +import sys +import tempfile +import threading +import time +import types +import unittest +import uuid +from pathlib import Path + + +def _install_host_stubs(): + cutter = types.ModuleType("cutter") + cutter.CutterPlugin = type("CutterPlugin", (), {}) + sys.modules["cutter"] = cutter + qtcore = types.ModuleType("PySide6.QtCore") + qtcore.QObject = type("QObject", (), {}) + qtcore.Signal = lambda *_args: object() + pyside = types.ModuleType("PySide6") + pyside.QtCore = qtcore + sys.modules["PySide6"] = pyside + sys.modules["PySide6.QtCore"] = qtcore + + +_install_host_stubs() +MODULE_PATH = Path(__file__).parents[2] / "bridge" / "cutter" / "rea_bridge.py" +spec = importlib.util.spec_from_file_location("rea_cutter_bridge_test", MODULE_PATH) +bridge = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = bridge +spec.loader.exec_module(bridge) + + +def _frame(connection): + data = bytearray() + while b"\n" not in data: + data.extend(connection.recv(4096)) + if len(data) > bridge.MAX_FRAME_BYTES: + raise AssertionError("oversized test frame") + line, remainder = bytes(data).split(b"\n", 1) + if remainder: + raise AssertionError("unexpected pipelined frame") + return json.loads(line) + + +def _send(connection, value): + connection.sendall(json.dumps(value, separators=(",", ":")).encode() + b"\n") + + +class Dispatcher: + generation = 4 + current_file = "/tmp/sample.bin" + cutter_version = "test" + + def __init__(self): + self.dispatch = self + self.commands = [] + self.completed_commands = {} + self.lock = threading.Lock() + self.command_started = threading.Event() + self.finish_command = threading.Event() + self.status_queued = threading.Event() + + def _read_current_file(self): + return self.current_file + + def can_accept_command(self): + return len(self.completed_commands) < bridge.MAX_COMPLETION_RECEIPTS + + def emit(self, request, reply): + def run(): + kind = request.get("kind") + if kind == "command": + self.command_started.set() + elif kind == "status": + self.status_queued.set() + with self.lock: + if kind == "command": + self.finish_command.wait(2) + self.commands.append(kind) + if kind == "status": + reply.update({"ok": True, "session_id": request["session_id"], "document_generation": self.generation, + "current_file": self.current_file, "cutter_version": self.cutter_version, + "identity_status": "partial", "completed_commands": [ + {"request_id": request_id, "execution_state": state} + for request_id, state in self.completed_commands.items() + ]}) + elif kind == "ack": + reply.update({"ok": True, "acknowledged": self.completed_commands.pop(request["request_id"], None) is not None}) + else: + reply.update({"ok": True, "execution_state": "complete"}) + if kind == "command" and isinstance(request.get("request_id"), str): + self.completed_commands[request["request_id"]] = "complete" + request["_event"].set() + threading.Thread(target=run, daemon=True).start() + + +class ServeDispatcher: + """Small UI-dispatcher stand-in for real producer/client interoperability.""" + + generation = 4 + current_file = "/tmp/cutter-interop-雪.bin" + cutter_version = "fixture" + + def __init__(self): + self.dispatch = self + self.completed_commands = {} + self.fail_next_ack = False + self.malformed_next_ack = False + self.hide_receipts_after_ack_failure = False + + def can_accept_command(self): + return len(self.completed_commands) < bridge.MAX_COMPLETION_RECEIPTS + + def _read_current_file(self): + return self.current_file + + def emit(self, request, reply): + if request.get("kind") == "status": + reply.update({ + "ok": True, + "session_id": request["session_id"], + "document_generation": self.generation, + "current_file": self.current_file, + "cutter_version": self.cutter_version, + "identity_status": "partial", + "completed_commands": [ + {"request_id": request_id, "execution_state": state} + for request_id, state in self.completed_commands.items() + ] if not self.hide_receipts_after_ack_failure else [], + }) + elif request.get("kind") == "ack": + if self.malformed_next_ack: + self.malformed_next_ack = False + reply.update({"ok": True, "session_id": request["session_id"]}) + request["_event"].set() + return + if self.fail_next_ack: + self.fail_next_ack = False + self.hide_receipts_after_ack_failure = True + reply.update({"ok": False, "acknowledged": False}) + request["_event"].set() + return + reply.update({ + "ok": True, + "acknowledged": self.completed_commands.pop(request["request_id"], None) is not None, + }) + self.hide_receipts_after_ack_failure = False + elif request.get("kind") == "command" and request.get("expected_generation") != self.generation: + reply.update({ + "ok": False, + "error": "active-document-generation-changed", + "execution_state": "not_started", + }) + self.completed_commands[request["request_id"]] = "not_started" + elif request.get("kind") == "command" and request.get("command") == "fixture-unknown": + reply.update({ + "ok": False, + "error": "fixture-response-lost-after-completion", + "execution_state": "unknown", + }) + self.completed_commands[request["request_id"]] = "complete" + elif request.get("kind") == "command" and request.get("command") == "fixture-fail-ack": + reply.update({ + "ok": True, + "output": "ack will fail once", + "current_file": self.current_file, + "document_generation": self.generation, + "cutter_version": self.cutter_version, + "identity_status": "partial", + "execution_state": "complete", + }) + self.completed_commands[request["request_id"]] = "complete" + self.fail_next_ack = True + elif request.get("kind") == "command" and request.get("command") == "fixture-malformed-ack": + reply.update({ + "ok": True, + "output": "ack reply will be malformed once", + "current_file": self.current_file, + "document_generation": self.generation, + "cutter_version": self.cutter_version, + "identity_status": "partial", + "execution_state": "complete", + }) + self.completed_commands[request["request_id"]] = "complete" + self.malformed_next_ack = True + else: + reply.update({ + "ok": True, + "output": 'round-trip: 雪 ☃️ quote=" slash=\\\\ line=\\n tab=\\t', + "current_file": self.current_file, + "document_generation": self.generation, + "cutter_version": self.cutter_version, + "identity_status": "partial", + "execution_state": "complete", + }) + if request.get("kind") == "command": + self.completed_commands[request["request_id"]] = "complete" + request["_event"].set() + + +def run_serve_mode(): + server = bridge._BridgeServer(ServeDispatcher()) + server.start() + try: + deadline = time.monotonic() + 5 + while not server.descriptor.exists() and time.monotonic() < deadline: + time.sleep(0.005) + if not server.descriptor.exists(): + raise RuntimeError("bridge descriptor was not published") + print("ready", flush=True) + sys.stdin.buffer.readline() + finally: + server.stop() + server.join(timeout=3) + + +class ProtocolProducerTest(unittest.TestCase): + def test_rejects_legacy_peers_before_ui_dispatch(self): + with socket.create_connection(("127.0.0.1", self.server.port), timeout=2) as connection: + connection.sendall((json.dumps({"kind": "rea-auth-v1", "nonce": "a" * 64}) + "\n").encode()) + with connection.makefile("rb") as reader: + reply = json.loads(reader.readline()) + self.assertEqual(reply, {"ok": False, "error": "authentication-failed"}) + self.assertEqual(self.dispatcher.commands, []) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + os.environ["REA_CUTTER_BRIDGE_DIR"] = self.temp.name + self.dispatcher = Dispatcher() + self.server = bridge._BridgeServer(self.dispatcher) + bridge.SOCKET_TIMEOUT_SECONDS = 0.1 + self.server.start() + deadline = time.monotonic() + 2 + while not self.server.descriptor.exists() and time.monotonic() < deadline: + time.sleep(0.005) + self.assertTrue(self.server.descriptor.exists()) + + def tearDown(self): + self.server.stop() + self.server.join(timeout=2) + os.environ.pop("REA_CUTTER_BRIDGE_DIR", None) + self.temp.cleanup() + + def exchange(self, request): + token = self.server.token.encode() + client_nonce = os.urandom(32).hex() + with socket.create_connection(("127.0.0.1", self.server.port), timeout=1) as connection: + _send(connection, {"kind": "rea-auth-v2", "nonce": client_nonce}) + challenge = _frame(connection) + self.assertEqual(challenge["session_id"], self.server.session_id) + server_nonce = challenge["nonce"] + expected_server = hmac.new(token, f"rea-cutter-server-v2:{self.server.session_id}:{client_nonce}:{server_nonce}".encode(), hashlib.sha256).hexdigest() + self.assertTrue(hmac.compare_digest(challenge["proof"], expected_server)) + request_bytes = json.dumps(request, separators=(",", ":")) + proof = hmac.new(token, f"rea-cutter-client-v2:{self.server.session_id}:{client_nonce}:{server_nonce}:{request_bytes}".encode(), hashlib.sha256).hexdigest() + _send(connection, {"request": request_bytes, "proof": proof}) + frame = _frame(connection) + if "response" not in frame: + return frame, client_nonce + response = frame["response"] + expected_response = hmac.new(token, f"rea-cutter-response-v2:{self.server.session_id}:{client_nonce}:{server_nonce}:{response}".encode(), hashlib.sha256).hexdigest() + self.assertTrue(hmac.compare_digest(frame["proof"], expected_response)) + return json.loads(response), client_nonce + + def test_request_session_uuid_is_checked_after_secret_proof(self): + spoofed = {"kind": "status", "session_id": "client-chosen-session"} + response, _ = self.exchange(spoofed) + self.assertEqual(response["error"], "authentication-failed") + self.assertEqual(self.dispatcher.commands, []) + + def test_client_chosen_session_uuid_without_secret_proof_is_not_authenticated(self): + client_nonce = os.urandom(32).hex() + with socket.create_connection(("127.0.0.1", self.server.port), timeout=1) as connection: + _send(connection, {"kind": "rea-auth-v2", "nonce": client_nonce}) + _frame(connection) + request_bytes = json.dumps({"kind": "status", "session_id": "client-chosen-session"}, separators=(",", ":")) + _send(connection, {"request": request_bytes, "proof": "0" * 64}) + self.assertEqual(_frame(connection)["error"], "authentication-failed") + self.assertEqual(self.dispatcher.commands, []) + + def test_cancelled_command_receipt_survives_later_command_until_acknowledged(self): + dispatcher = object.__new__(bridge._RequestDispatcher) + dispatcher.generation = 4 + dispatcher.current_file = "/tmp/sample.bin" + dispatcher.cutter_version = "test" + dispatcher.completed_commands = {} + dispatcher._receipt_lock = threading.Lock() + dispatcher._refresh_document_identity = lambda: None + bridge.cutter.cmd = lambda command: f"output:{command}" + + def handle(value): + request = { + **value, + "_event": threading.Event(), + "_cancelled": threading.Event(), + } + reply = {} + bridge._RequestDispatcher._handle(dispatcher, request, reply) + self.assertTrue(request["_event"].is_set()) + return reply + + request_a = "4b18ce4d-bf31-456e-ae31-e8c547b2f9db" + request_b = "8ca93d11-107d-41ed-8b40-31dd53cd26a6" + for request_id, command in ((request_a, "A"), (request_b, "B")): + result = handle({ + "kind": "command", + "request_id": request_id, + "expected_generation": 4, + "command": command, + }) + self.assertTrue(result["ok"]) + + status = handle({"kind": "status"}) + self.assertEqual( + {receipt["request_id"] for receipt in status["completed_commands"]}, + {request_a, request_b}, + ) + acknowledged = handle({"kind": "ack", "request_id": request_a}) + self.assertTrue(acknowledged["acknowledged"]) + after_ack = handle({"kind": "status"}) + self.assertEqual( + [receipt["request_id"] for receipt in after_ack["completed_commands"]], + [request_b], + ) + + def test_stale_generation_rejections_are_terminal_and_acknowledged(self): + dispatcher = object.__new__(bridge._RequestDispatcher) + dispatcher.generation = 4 + dispatcher.current_file = "/tmp/sample.bin" + dispatcher.cutter_version = "test" + dispatcher.completed_commands = {} + dispatcher._receipt_lock = threading.Lock() + dispatcher._refresh_document_identity = lambda: None + + def handle(value): + request = { + **value, + "_event": threading.Event(), + "_cancelled": threading.Event(), + } + reply = {} + bridge._RequestDispatcher._handle(dispatcher, request, reply) + self.assertTrue(request["_event"].is_set()) + return reply + + invalid_id = str(uuid.UUID(int=bridge.MAX_COMPLETION_RECEIPTS + 10)) + invalid = handle({ + "kind": "command", + "request_id": invalid_id, + "expected_generation": 4, + "command": "", + }) + self.assertEqual(invalid["execution_state"], "not_started") + self.assertTrue(handle({"kind": "ack", "request_id": invalid_id})["acknowledged"]) + + for index in range(bridge.MAX_COMPLETION_RECEIPTS + 1): + request_id = str(uuid.UUID(int=index + 1)) + response = handle({ + "kind": "command", + "request_id": request_id, + "expected_generation": 3, + "command": "iI", + }) + self.assertEqual(response["execution_state"], "not_started") + self.assertEqual(response["error"], "active-document-generation-changed") + self.assertTrue(handle({"kind": "ack", "request_id": request_id})["acknowledged"]) + + self.assertEqual(dispatcher.completed_commands, {}) + self.assertTrue(dispatcher.can_accept_command()) + + def test_poisoned_rejection_is_definitively_not_started_without_dispatch(self): + self.server.poisoned = True + response, _ = self.exchange({ + "kind": "command", + "session_id": self.server.session_id, + "request_id": "4b18ce4d-bf31-456e-ae31-e8c547b2f9db", + }) + self.assertEqual(response["error"], "session-poisoned-after-ui-timeout") + self.assertEqual(response["execution_state"], "not_started") + self.assertEqual(self.dispatcher.commands, []) + + def test_receipt_capacity_rejects_before_ui_dispatch(self): + self.dispatcher.can_accept_command = lambda: False + response, _ = self.exchange({ + "kind": "command", + "session_id": self.server.session_id, + "request_id": "4b18ce4d-bf31-456e-ae31-e8c547b2f9db", + }) + self.assertEqual(response["error"], "completion-receipt-capacity-exceeded") + self.assertEqual(response["execution_state"], "not_started") + self.assertEqual(self.dispatcher.commands, []) + + def test_status_probe_recovers_only_after_timed_out_ui_operation_finishes(self): + timed_out, _ = self.exchange({"kind": "command", "session_id": self.server.session_id}) + self.assertEqual(timed_out["error"], "ui-thread-request-timeout") + self.assertTrue(self.server.poisoned) + self.assertTrue(self.dispatcher.command_started.wait(1)) + result = {} + status_thread = threading.Thread(target=lambda: result.setdefault( + "response", self.exchange({"kind": "status", "session_id": self.server.session_id})[0] + )) + status_thread.start() + self.assertTrue(self.dispatcher.status_queued.wait(1)) + self.dispatcher.finish_command.set() + status_thread.join(timeout=2) + self.assertFalse(status_thread.is_alive()) + response = result["response"] + self.assertTrue(response["ok"]) + self.assertEqual(response["session_id"], self.server.session_id) + self.assertEqual(self.dispatcher.commands, ["command", "status"]) + + def test_escape_expansion_uses_a_signed_truthful_truncation_fallback(self): + sender, receiver = socket.socketpair() + try: + self.server._send_authenticated(sender, { + "ok": True, + "output": '"' * 6_000_000, + "execution_state": "complete", + "current_file": "/tmp/sample.bin", + "document_generation": 4, + "identity_status": "partial", + }, "a" * 64, "b" * 64) + frame_bytes = receiver.recv(bridge.MAX_FRAME_BYTES + 1) + self.assertLessEqual(len(frame_bytes), bridge.MAX_FRAME_BYTES) + frame = json.loads(frame_bytes) + response = frame["response"] + expected = hmac.new( + self.server.token.encode(), + f"rea-cutter-response-v2:{self.server.session_id}:{'a' * 64}:{'b' * 64}:{response}".encode(), + hashlib.sha256, + ).hexdigest() + self.assertTrue(hmac.compare_digest(frame["proof"], expected)) + value = json.loads(response) + self.assertEqual(value["ok"], True) + self.assertEqual(value["execution_state"], "complete") + self.assertTrue(value["output_truncated"]) + self.assertIsNone(value["output"]) + finally: + sender.close() + receiver.close() + + +if __name__ == "__main__": + if sys.argv[1:] == ["--serve"]: + run_serve_mode() + else: + unittest.main() diff --git a/tests/fixtures/native-macos/otool-dylib-use.txt b/tests/fixtures/native-macos/otool-dylib-use.txt index 28d40b4df..3c269e717 100644 --- a/tests/fixtures/native-macos/otool-dylib-use.txt +++ b/tests/fixtures/native-macos/otool-dylib-use.txt @@ -240,7 +240,7 @@ Load command 16 cmd LC_LOAD_DYLIB cmdsize 56 name @rpath/libdelay.dylib (offset 28) - options delay-init + options delay-init current version 0.0.0 compatibility version 1.0.0 Load command 17 diff --git a/tests/fixtures/providerProcess.mjs b/tests/fixtures/providerProcess.mjs index 3be02a7cf..91f183bb1 100644 --- a/tests/fixtures/providerProcess.mjs +++ b/tests/fixtures/providerProcess.mjs @@ -32,6 +32,13 @@ switch (mode) { process.exitCode = 23; break; } + case "invalid-utf8": + await write(process.stdout, Buffer.from([0x61, 0xff, 0x62])); + await write(process.stderr, Buffer.from([0x63, 0xfe, 0x64])); + break; + case "valid-multibyte": + await write(process.stdout, Buffer.from([0xe2, 0x82, 0xac])); + break; case "exit": process.exitCode = Number(value); break; diff --git a/tests/fixtures/providerProcess.ts b/tests/fixtures/providerProcess.ts index 48bd94c3f..ab7f5f74b 100644 --- a/tests/fixtures/providerProcess.ts +++ b/tests/fixtures/providerProcess.ts @@ -8,7 +8,14 @@ const fixturePath = fileURLToPath( /** Spawn one deterministic process-lifecycle fixture with piped diagnostics. */ export const spawnProviderProcessFixture = ( - mode: "burst" | "detached-child" | "exit" | "graceful" | "stubborn", + mode: + | "burst" + | "detached-child" + | "exit" + | "graceful" + | "invalid-utf8" + | "valid-multibyte" + | "stubborn", value?: number, ): ChildProcess => spawn( diff --git a/tests/fixtures/rust/image.ts b/tests/fixtures/rust/image.ts new file mode 100644 index 000000000..6bbac25ab --- /dev/null +++ b/tests/fixtures/rust/image.ts @@ -0,0 +1,263 @@ +import { deflateSync } from "node:zlib"; + +/** zlib payload matching the embedded dependency JSON shape. */ +export const rustDependencyPayload = ( + document: unknown = { + packages: [ + { name: "itoa", version: "1.0.18", source: "crates.io" }, + { + name: "sample", + version: "0.1.0", + source: "local", + dependencies: [0], + root: true, + }, + ], + format: 1, + }, +): Buffer => deflateSync(Buffer.from(JSON.stringify(document))); + +export const RUSTC_HASH = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const RUSTC_PATH = `/rustc/${RUSTC_HASH}/library/core`; + +/** Synthetic ELF, PE, or thin Mach-O with an optional dependency section. */ +export const createRustImage = (options: { + readonly format: "elf" | "pe" | "macho"; + readonly payload?: Buffer; + readonly omitSection?: boolean; + readonly includeRustc?: boolean; +}): Buffer => { + const payload = options.payload ?? rustDependencyPayload(); + const rustc = options.includeRustc === false ? null : Buffer.from(RUSTC_PATH); + const bytes = Buffer.alloc(4096); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const little = (offset: number, value: number, width: 16 | 32 | 64) => { + if (width === 16) view.setUint16(offset, value, true); + else if (width === 32) view.setUint32(offset, value, true); + else view.setBigUint64(offset, BigInt(value), true); + }; + const image = { + bytes, + little, + payload, + omitSection: options.omitSection === true, + rustc, + }; + if (options.format === "elf") writeElf(image); + else if (options.format === "pe") writePe(image); + else writeMacho(image); + return bytes; +}; + +interface RustImageWrite { + readonly bytes: Buffer; + readonly little: (offset: number, value: number, width: 16 | 32 | 64) => void; + readonly payload: Buffer; + readonly omitSection: boolean; + readonly rustc: Buffer | null; +} + +const writeElf = ({ + bytes, + little, + payload, + omitSection, + rustc, +}: RustImageWrite): void => { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]); + little(16, 2, 16); + little(18, 62, 16); + little(20, 1, 32); + little(32, 128, 64); + little(40, 256, 64); + little(52, 64, 16); + little(54, 56, 16); + little(56, 1, 16); + little(58, 64, 16); + const names = omitSection + ? Buffer.from("\0.shstrtab\0.rodata\0") + : Buffer.from("\0.shstrtab\0.dep-v0\0.rodata\0"); + little(60, rustc === null && omitSection ? 0 : omitSection ? 3 : 4, 16); + little(62, rustc === null && omitSection ? 0 : 1, 16); + little(128, 1, 32); + little(132, 6, 32); + little(144, 0x400000, 64); + little(160, bytes.length, 64); + little(168, bytes.length, 64); + if (rustc === null && omitSection) return; + names.copy(bytes, 768); + const shstr = 320; + little(shstr, 1, 32); + little(shstr + 4, 3, 32); + little(shstr + 24, 768, 64); + little(shstr + 32, names.length, 64); + let next = 384; + if (!omitSection) { + little(next, 11, 32); + little(next + 4, 1, 32); + little(next + 24, 1024, 64); + little(next + 32, payload.length, 64); + payload.copy(bytes, 1024); + next += 64; + } + if (rustc !== null) { + const name = omitSection ? 11 : 19; + little(next, name, 32); + little(next + 4, 1, 32); + little(next + 8, 2, 64); + little(next + 16, 0x400000 + 2048, 64); + little(next + 24, 2048, 64); + little(next + 32, rustc.length, 64); + rustc.copy(bytes, 2048); + } +}; + +const writePe = ({ + bytes, + little, + payload, + omitSection, + rustc, +}: RustImageWrite): void => { + bytes.write("MZ", 0, "ascii"); + little(60, 128, 32); + bytes.write("PE\0\0", 128, "ascii"); + little(132, 0x8664, 16); + const count = Number(!omitSection) + Number(rustc !== null); + little(134, count, 16); + little(148, 112, 16); + little(152, 0x20b, 16); + little(176, 0x140000000, 64); + const writeSection = (section: { + readonly entry: number; + readonly name: string; + readonly offset: number; + readonly body: Buffer; + readonly address: number; + }) => { + bytes.write(section.name, section.entry, "ascii"); + little(section.entry + 8, section.body.length, 32); + little(section.entry + 12, section.address, 32); + little(section.entry + 16, section.body.length, 32); + little(section.entry + 20, section.offset, 32); + little(section.entry + 36, 0x40000040, 32); + section.body.copy(bytes, section.offset); + }; + let entry = 264; + if (!omitSection) { + writeSection({ + entry, + name: ".dep-v0", + offset: 1024, + body: payload, + address: 0x1000, + }); + entry += 40; + } + if (rustc !== null) + writeSection({ + entry, + name: ".rdata", + offset: 2048, + body: rustc, + address: 0x2000, + }); +}; + +const writeMacho = ({ + bytes, + little, + payload, + omitSection, + rustc, +}: RustImageWrite): void => { + const commands = Number(rustc !== null) + Number(!omitSection); + little(0, 0xfeedfacf, 32); + little(4, 0x0100000c, 32); + little(12, 2, 32); + little(16, commands, 32); + little(20, commands * 152, 32); + let command = 32; + if (rustc !== null) { + writeMachoSegment({ + bytes, + little, + command, + segment: "__TEXT", + section: "__cstring", + body: rustc, + fileOffset: 2048, + protection: 5, + flags: 2, + }); + command += 152; + } + if (!omitSection) + writeMachoSegment({ + bytes, + little, + command, + segment: "__DATA", + section: ".dep-v0", + body: payload, + fileOffset: 1024, + protection: 3, + flags: 0, + }); +}; + +const writeMachoSegment = ({ + bytes, + little, + command, + segment, + section, + body, + fileOffset, + protection, + flags, +}: { + readonly bytes: Buffer; + readonly little: RustImageWrite["little"]; + readonly command: number; + readonly segment: string; + readonly section: string; + readonly body: Buffer; + readonly fileOffset: number; + readonly protection: number; + readonly flags: number; +}): void => { + little(command, 0x19, 32); + little(command + 4, 152, 32); + bytes.write(segment, command + 8, "ascii"); + little(command + 24, 0x100000000, 64); + little(command + 32, 0x2000, 64); + little(command + 40, fileOffset, 64); + little(command + 48, body.length, 64); + little(command + 56, protection, 32); + little(command + 60, protection, 32); + little(command + 64, 1, 32); + const header = command + 72; + bytes.write(section, header, "ascii"); + bytes.write(segment, header + 16, "ascii"); + little(header + 32, 0x100000000, 64); + little(header + 40, body.length, 64); + little(header + 48, fileOffset, 32); + little(header + 64, flags, 32); + body.copy(bytes, fileOffset); +}; + +/** Read-only ELF data exceeding the distinct rustc-hash retention capacity. */ +export const createRustHashOverflowImage = (): Buffer => { + const bytes = createRustImage({ format: "elf", omitSection: true }); + // Without .dep-v0 the read-only section header is the second one, at 384. + // Section names end before byte 800, so the hash block starts there. + bytes.writeBigUInt64LE(800n, 384 + 24); + bytes.writeBigUInt64LE(3296n, 384 + 32); + bytes.fill(0, 800); + for (let index = 0; index < 65; index += 1) { + const hash = index.toString(16).padStart(40, "0"); + bytes.write(`/rustc/${hash}/`, 800 + index * 48, "latin1"); + } + return bytes; +}; diff --git a/tests/process-global/adbStartMcp.test.ts b/tests/process-global/adbStartMcp.test.ts index ac34b61e6..f1d069671 100644 --- a/tests/process-global/adbStartMcp.test.ts +++ b/tests/process-global/adbStartMcp.test.ts @@ -26,9 +26,13 @@ it.each(["start_adb_app", "start_adb_activity"] as const)( "Starting: Intent { act=android.intent.action.MAIN cmp=com.example/.Main }\n"; let stdout = `${starting}Status: ok\nActivity: com.example/.Main\nWaitTime: 315\nComplete\n`; let stderr = ""; + const version = + "Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\n"; vi.mocked(execFileOutput).mockResolvedValue({ - stdout: "Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\n", + stdout: version, stderr: "", + stdoutBytes: Buffer.byteLength(version), + stderrBytes: 0, }); const calls: string[][] = []; vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index 8b7be17da..fdd19e8ee 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -41,6 +41,9 @@ it } // A rejection must come from the fixture we are about to launch. An // existing Xvfb would otherwise mask a broken executable-identity check. + // The copied launcher has a unique file identity and has not run yet. + // The synthetic proc root isolates the positive identity check from + // unrelated restricted host processes while retaining display-name checks. await assertCleanLinuxHopperRuntime( alternateMode ? [launcher] : [], procRoot, diff --git a/tests/support/ownedRizinCommandEvidenceTests.ts b/tests/support/ownedRizinCommandEvidenceTests.ts new file mode 100644 index 000000000..8686ef2bb --- /dev/null +++ b/tests/support/ownedRizinCommandEvidenceTests.ts @@ -0,0 +1,204 @@ +import { rm } from "node:fs/promises"; + +import { expect, it, vi } from "vitest"; + +import { OwnedCommandFailure } from "../../src/process/OwnedCommand.js"; +import { TestReverseEngineeringService } from "./reverseEngineeringService.js"; +import { + createArtifact, + providerSnapshot, +} from "./reverseEngineeringCompositionFixtures.js"; + +const registerOwnedRizinCommandOutcomeTests = (options: { + readonly sweepTokenOwnedProcesses: boolean; +}): void => { + it.each([ + ["normal exit", providerSnapshot(), "complete", 0, false], + [ + "non-zero exit", + providerSnapshot({ exitCode: 7, diagnosticTruncated: true }), + "complete", + 7, + true, + ], + [ + "signal termination", + providerSnapshot({ + exitCode: null, + signal: "SIGSEGV", + diagnosticTruncated: true, + }), + "unknown", + null, + true, + ], + [ + "signal termination with a numeric exit snapshot", + providerSnapshot({ exitCode: 7, signal: "SIGSEGV" }), + "unknown", + null, + false, + ], + [ + "signal termination without truncation", + providerSnapshot({ exitCode: null, signal: "SIGTERM" }), + "unknown", + null, + false, + ], + [ + "unknown exit status", + providerSnapshot({ exitCode: null }), + "unknown", + null, + false, + ], + ] as const)( + "maps an owned command %s into provider Evidence", + async (_name, snapshot, completion, exitCode, outputTruncated) => { + const { directory, path } = await createArtifact(); + const runOwned = vi.fn(async () => snapshot); + const service = new TestReverseEngineeringService({ + runOwned, + environment: { REA_RIZIN_COMMAND: "rizin-test" }, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(runOwned).toHaveBeenCalledOnce(); + expect(runOwned).toHaveBeenCalledWith( + expect.objectContaining({ + command: "rizin-test", + arguments: expect.arrayContaining(["-q", "-c", "iI"]), + expectedCommand: null, + env: expect.objectContaining({ REA_RIZIN_COMMAND: "rizin-test" }), + hostEnvironment: {}, + ...(options.sweepTokenOwnedProcesses + ? { sweepTokenOwnedProcesses: true } + : {}), + }), + { + timeoutMs: 120_000, + diagnosticBytes: 16 * 1024 * 1024, + acceptNonZeroExit: true, + }, + { signal: expect.any(AbortSignal) }, + ); + expect(result).toMatchObject({ + ok: true, + value: { + raw_result: { + stdout: "captured owned output", + completion_status: completion, + exit_code: exitCode, + output_truncated: outputTruncated, + }, + }, + }); + if (!result.ok) throw new Error("Expected provider Evidence"); + expect(result.value.parameters).toMatchObject({ + command: "iI", + provider_executable: "rizin-test", + command_arguments: expect.arrayContaining(["-q", "-c", "iI"]), + }); + expect(result.value.locations).toContainEqual({ + kind: "artifact-path", + path, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); +}; + +const registerOwnedRizinCommandFailureTests = (): void => { + it.each([ + ["cancelled", "AnalysisCancelledError", "ABORT_ERR"], + ["timeout", "AnalysisTimeoutError", "ETIMEDOUT"], + ["output-limit", "evidence", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"], + ["process", "evidence", 7], + ] as const)( + "maps an owned command %s failure while retaining its snapshot", + async (reason, expected, mappedCode) => { + const { directory, path } = await createArtifact(); + const snapshot = providerSnapshot({ + ...(reason === "process" ? { exitCode: 7 } : {}), + ...(reason === "output-limit" + ? {} + : reason === "cancelled" + ? {} + : { diagnosticTruncated: true }), + }); + const service = new TestReverseEngineeringService({ + runOwned: async () => { + throw new OwnedCommandFailure( + reason, + "owned command failed", + snapshot, + reason === "timeout" ? "cleanup pending" : null, + undefined, + reason === "timeout" ? ["run-id", "pid:7"] : [], + ); + }, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "iI", + }); + if (expected === "evidence") { + expect(result).toMatchObject({ + ok: true, + value: { + raw_result: { + stdout: "captured owned output", + exit_code: mappedCode === 7 ? 7 : null, + completion_status: + reason === "output-limit" ? "unknown" : "complete", + output_truncated: true, + }, + }, + }); + } else { + expect(result).toMatchObject({ + ok: false, + error: { + _tag: expected, + partialObservation: { + raw_result: { + stdout: "captured owned output", + exit_code: null, + completion_status: "unknown", + output_truncated: reason !== "cancelled", + }, + }, + ...(reason === "timeout" + ? { + cleanup: { + reason: "cleanup pending", + resources: ["run-id", "pid:7"], + }, + } + : {}), + }, + }); + if (result.ok) throw new Error("Expected a tagged command failure"); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); +}; + +export const registerOwnedRizinCommandEvidenceTests = (options: { + readonly sweepTokenOwnedProcesses: boolean; +}): void => { + registerOwnedRizinCommandOutcomeTests(options); + registerOwnedRizinCommandFailureTests(); +}; diff --git a/tests/support/reverseDebuggerMcpTestHelpers.ts b/tests/support/reverseDebuggerMcpTestHelpers.ts new file mode 100644 index 000000000..5b7762325 --- /dev/null +++ b/tests/support/reverseDebuggerMcpTestHelpers.ts @@ -0,0 +1,70 @@ +import { readFile, rm } from "node:fs/promises"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; + +export const waitForStartedFile = async (path: string): Promise => { + await expect + .poll(() => readFile(path, "utf8").catch(() => "")) + .toBe("started"); +}; + +export const validateDebuggerStatusResponse = async ( + client: Client, + name: "gdb_session_status" | "rizin_debug_session_status", + sessionId: string, +): Promise> => { + const listed = (await client.listTools()).tools.find( + (tool) => tool.name === name, + ); + if (listed?.outputSchema === undefined) + throw new Error(`Missing advertised ${name} output schema`); + const response = await client.callTool({ + name, + arguments: { session_id: sessionId }, + }); + expect(response.isError).not.toBe(true); + const parsed = toolContract(name).outputSchema.parse( + response.structuredContent, + ); + expect( + new Ajv2020({ strict: false, validateFormats: false }).validate( + z.record(z.string(), z.unknown()).parse(listed.outputSchema), + parsed, + ), + ).toBe(true); + return z.record(z.string(), z.unknown()).parse(parsed); +}; + +export const createProviderEnvironmentMcpHarness = async (options: { + readonly directory: string; + readonly clientName: string; + readonly serverOptions: NonNullable[1]>; +}) => { + const session = createTestBinarySession(() => { + throw new Error("No binary analysis provider should start"); + }); + const server = createServer( + { kind: "session", session }, + options.serverOptions, + ); + const client = new Client({ name: options.clientName, version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + await rm(options.directory, { recursive: true, force: true }); + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client }; +}; diff --git a/tests/support/reverseEngineeringCompositionEdgeCases.ts b/tests/support/reverseEngineeringCompositionEdgeCases.ts new file mode 100644 index 000000000..de29ecbc6 --- /dev/null +++ b/tests/support/reverseEngineeringCompositionEdgeCases.ts @@ -0,0 +1,800 @@ +import { rm, truncate, writeFile } from "node:fs/promises"; + +import { expect, it, vi } from "vitest"; + +import { TestReverseEngineeringService as ReverseEngineeringService } from "./reverseEngineeringService.js"; +import { + createArtifact, + providerSnapshot, +} from "./reverseEngineeringCompositionFixtures.js"; +import { objdumpCommand } from "../../src/objdump/ObjdumpCommand.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../../src/process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../../src/process/ProviderProcess.js"; + +const registerSharedReverseEngineeringEdgeCasesPart1 = (): void => { + it("uses the local-only DWARF option when debug links are disabled", () => { + expect( + objdumpCommand({ + path: "./sample.bin", + operation: "dwarf", + follow_debug_links: false, + }), + ).toEqual(["-W", "-WN", expect.stringMatching(/sample\.bin$/u)]); + }); + + it("does not follow DWARF debug links by default", async () => { + const { directory, path } = await createArtifact(); + const calls: string[][] = []; + const service = new ReverseEngineeringService({ + run: async (_command, args) => { + calls.push([...args]); + return { stdout: "DWARF output", stderr: "" }; + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "dwarf", + }); + expect(result.ok).toBe(true); + expect(calls[0]).toEqual(["-W", "-WN", path]); + if (result.ok) + expect(result.value.parameters.follow_debug_links).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("marks opted-in external DWARF debug files as unbound Evidence sources", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + run: async () => ({ stdout: "DWARF output", stderr: "" }), + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "dwarf", + follow_debug_links: true, + }); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.limitations).toContain( + "DWARF output may include local debug-link files whose identities are not bound to Evidence.", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("rejects malformed objdump requests before touching the provider", async () => { + let invoked = false; + const service = new ReverseEngineeringService({ + run: async () => { + invoked = true; + return { stdout: "", stderr: "" }; + }, + }); + + const result = await service.inspectWithObjdump({ path: "/tmp/a" }); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError", operation: "inspect_with_objdump" }, + }); + expect(invoked).toBe(false); + }); + + it("returns cancellation that arrived before the artifact digest starts", async () => { + const controller = new AbortController(); + controller.abort(); + const service = new ReverseEngineeringService(); + + const result = await service.inspectWithObjdump( + { path: "/tmp/a", operation: "file_headers" }, + { signal: controller.signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + }); + + it.each([ + ["EACCES", "AnalysisAccessDeniedError"], + ["EPERM", "AnalysisAccessDeniedError"], + ["ETIMEDOUT", "AnalysisTimeoutError"], + ["EINVAL", "AnalysisInputError"], + ])( + "maps artifact digest failure %s to its tagged analysis error", + async (code, tag) => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + digest: async () => { + throw Object.assign(new Error("digest failure"), { code }); + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result).toMatchObject({ ok: false, error: { _tag: tag } }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); +}; + +const registerSharedReverseEngineeringEdgeCasesPart2 = (): void => { + it("retains and retries an owned cleanup after an output-limit failure", async () => { + const { directory, path } = await createArtifact(); + const stop = vi + .fn() + .mockResolvedValueOnce({ status: "incomplete", reason: "still running" }) + .mockResolvedValueOnce({ status: "incomplete", reason: "still running" }) + .mockResolvedValueOnce({ status: "incomplete", reason: "still running" }) + .mockResolvedValueOnce({ status: "verified-cleanup" }); + await stop(); // The owned-command runner's first cleanup attempt failed. + const cleanupOwner = { stop } as unknown as ProviderProcessSupervisor; + const runOwned = vi.fn(async () => { + throw new OwnedCommandFailure( + "output-limit", + "output limit", + providerSnapshot({ diagnosticTruncated: true }), + "still running", + undefined, + ["run-id", "pid:42"], + cleanupOwner, + ); + }); + const service = new ReverseEngineeringService({ + runOwned, + }); + + try { + const result = await service.executeRizinCommand({ + path, + command: "wx 00", + }); + expect(result).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanup: { reason: "still running", resources: ["run-id", "pid:42"] }, + partialObservation: { + raw_result: { + stdout: "captured owned output", + output_truncated: true, + completion_status: "unknown", + }, + }, + }, + }); + expect(stop).toHaveBeenCalledTimes(1); + const blocked = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(blocked).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(stop).toHaveBeenCalledTimes(2); + expect(runOwned).toHaveBeenCalledTimes(1); + expect(blocked).toMatchObject({ + ok: false, + error: { + message: expect.stringContaining( + "A previous provider process still requires cleanup.", + ), + }, + }); + await expect(service.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + cleanup: { reason: "still running", resources: ["run-id", "pid:42"] }, + }); + expect(stop).toHaveBeenCalledTimes(3); + await service.close(); + expect(stop).toHaveBeenCalledTimes(4); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("maps cancellation during the initial digest to a cancellation error", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + const service = new ReverseEngineeringService({ + digest: async () => { + controller.abort(); + throw new Error("digest cancelled"); + }, + }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}; + +const registerSharedReverseEngineeringEdgeCasesPart3 = (): void => { + it("cancels an in-progress file digest through its abort listener", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + Object.defineProperty(controller.signal, "addEventListener", { + value: ( + type: string, + listener: Parameters[1], + options?: boolean | AddEventListenerOptions, + ): void => { + EventTarget.prototype.addEventListener.call( + controller.signal, + type, + listener, + options, + ); + if (type === "abort") { + controller.abort(); + } + }, + }); + const service = new ReverseEngineeringService(); + + try { + const resultPromise = service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal: controller.signal }, + ); + const result = await resultPromise; + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("maps the artifact digest deadline to a timeout error", async () => { + const { directory, path } = await createArtifact(); + await truncate(path, 64 * 1024 * 1024); + const service = new ReverseEngineeringService(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + try { + const resultPromise = service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(120_000); + const result = await resultPromise; + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisTimeoutError" }, + }); + } finally { + vi.useRealTimers(); + await rm(directory, { recursive: true, force: true }); + } + }); + + it.each([ + ["ETIMEDOUT", "AnalysisTimeoutError"], + ["EACCES", "AnalysisAccessDeniedError"], + ["EPERM", "AnalysisAccessDeniedError"], + ["ABORT_ERR", "AnalysisCancelledError"], + ["unknown", "AnalysisCapabilityUnavailableError"], + ])( + "maps provider failure %s to its tagged analysis error", + async (code, tag) => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + run: async () => { + if (code === "unknown") throw new Error("provider failure"); + throw Object.assign(new Error("provider failure"), { code }); + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: tag, + operation: "inspect_with_objdump", + cause: { message: "provider failure" }, + ...(code === "ETIMEDOUT" ? { timeoutMs: 120_000 } : {}), + ...(code === "EACCES" || code === "EPERM" + ? { path: "objdump", systemCode: code } + : {}), + ...(code === "unknown" + ? { + providerId: "objdump", + reason: "Unable to run configured command (unknown error)", + } + : {}), + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); +}; + +const registerSharedReverseEngineeringEdgeCasesPart4 = (): void => { + it("records a changed artifact and unknown post-run digest separately", async () => { + const { directory, path } = await createArtifact(); + const changed = new ReverseEngineeringService({ + run: async () => { + await writeFile(path, "changed during provider execution"); + return { stdout: "observed", stderr: "" }; + }, + }); + const removed = new ReverseEngineeringService({ + run: async () => { + await rm(path); + return { stdout: "observed", stderr: "" }; + }, + }); + + try { + const changedResult = await changed.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(changedResult).toMatchObject({ + ok: true, + value: { + raw_result: { artifact_changed: true }, + limitations: expect.arrayContaining([ + expect.stringContaining("may have changed"), + ]), + }, + }); + + await writeFile(path, "restore for second digest"); + const removedResult = await removed.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(removedResult).toMatchObject({ + ok: true, + value: { + raw_result: { artifact_sha256_after: null, artifact_changed: null }, + limitations: expect.arrayContaining([ + expect.stringContaining("post-run artifact digest"), + ]), + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it.each(["EACCES", "EPERM"] as const)( + "retains captured output on a permission-denied provider result (%s)", + async (code) => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + run: async () => { + throw Object.assign(new Error("provider permission denied"), { + stdout: "partial provider output", + stderr: "permission diagnostic", + code, + signal: null, + killed: false, + }); + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisAccessDeniedError", + capturedOutput: { + stdout: "partial provider output", + stderr: "permission diagnostic", + }, + partialObservation: { + raw_result: { stdout: "partial provider output" }, + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); +}; + +const registerSharedReverseEngineeringEdgeCasesPart5 = (): void => { + it("cancels after captured output when a provider reports a non-numeric status", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + let digests = 0; + const service = new ReverseEngineeringService({ + digest: async () => { + digests += 1; + if (digests === 2) controller.abort(); + return digests === 1 ? "a".repeat(64) : "b".repeat(64); + }, + run: async () => { + throw Object.assign(new Error("provider status unavailable"), { + stdout: "captured output", + stderr: "", + code: "provider-status-unknown", + signal: null, + killed: false, + }); + }, + }); + + try { + const result = await service.inspectWithObjdump( + { path, operation: "file_headers" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + partialObservation: { + raw_result: { exit_code: null, completion_status: "unknown" }, + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("maps an unavailable Rizin command to the Rizin capability error", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + run: async () => { + throw Object.assign(new Error("not found"), { code: "ENOENT" }); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + providerId: "rizin", + operation: "execute_rizin_command", + reason: "Unable to run configured command (ENOENT)", + cause: { message: "not found" }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("classifies a captured ENOENT as provider unavailability", async () => { + const { directory, path } = await createArtifact(); + const cause = Object.assign(new Error("launch failed"), { + stdout: "", + stderr: "", + code: "ENOENT", + signal: null, + killed: false, + }); + const service = new ReverseEngineeringService({ + run: async () => { + throw cause; + }, + }); + + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + providerId: "objdump", + reason: "Unable to run configured command (ENOENT)", + cause: { message: "launch failed" }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}; + +const registerSharedReverseEngineeringEdgeCasesPart6 = (): void => { + it("waits for active provider cleanup during shutdown and rejects new work", async () => { + const { directory, path } = await createArtifact(); + let signal: AbortSignal | undefined; + let announceStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + announceStarted = resolve; + }); + let releaseProvider: (() => void) | undefined; + const providerGate = new Promise((resolve) => { + releaseProvider = resolve; + }); + const stop = vi + .fn() + .mockResolvedValueOnce({ status: "incomplete", reason: "still running" }) + .mockResolvedValueOnce({ status: "verified-cleanup" }); + const cleanupOwner = { stop } as unknown as ProviderProcessSupervisor; + const runOwned = vi.fn( + async (_spawn, _limits, options) => { + signal = options?.signal; + announceStarted?.(); + await providerGate; + throw new OwnedCommandFailure( + "cancelled", + "Command cancelled during shutdown.", + providerSnapshot({ exitCode: null }), + "still running", + undefined, + ["run-id", "pid:42"], + cleanupOwner, + ); + }, + ); + const service = new ReverseEngineeringService({ runOwned }); + + try { + const operation = service.executeRizinCommand({ path, command: "iI" }); + await started; + let closeSettled = false; + const closing = service.close().finally(() => { + closeSettled = true; + }); + expect(signal?.aborted).toBe(true); + await Promise.resolve(); + expect(closeSettled).toBe(false); + releaseProvider?.(); + + await expect(operation).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cleanupIncomplete: true }, + }); + await expect(closing).rejects.toMatchObject({ + cleanupIncomplete: true, + cleanup: { reason: "still running" }, + }); + expect(stop).toHaveBeenCalledTimes(1); + await expect( + service.executeRizinCommand({ path, command: "iI" }), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + expect(runOwned).toHaveBeenCalledTimes(1); + + await service.close(); + expect(stop).toHaveBeenCalledTimes(2); + } finally { + releaseProvider?.(); + await rm(directory, { recursive: true, force: true }); + } + }); +}; + +const registerSharedReverseEngineeringEdgeCasesPart7 = (): void => { + it("maps an uncaptured owned launcher error to provider unavailability", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async () => { + throw Object.assign(new Error("missing provider"), { code: "ENOENT" }); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + providerId: "rizin", + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}; + +const registerSharedReverseEngineeringEdgeCasesPart8 = (): void => { + it("forwards a caller abort signal to the owned command supervisor", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + const runOwned = vi.fn(async () => + providerSnapshot(), + ); + const service = new ReverseEngineeringService({ runOwned }); + + try { + const result = await service.executeRizinCommand( + { path, command: "iI" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(true); + expect(runOwned.mock.calls[0]?.[2]).toMatchObject({ + signal: expect.any(AbortSignal), + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("does not classify a completed provider call as cancelled for a live signal", async () => { + const { directory, path } = await createArtifact(); + const controller = new AbortController(); + const service = new ReverseEngineeringService({ + run: async () => ({ stdout: "completed output", stderr: "" }), + }); + + try { + const result = await service.executeRizinCommand( + { path, command: "iI" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: true, + value: { + raw_result: { + stdout: "completed output", + completion_status: "complete", + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("classifies an owned launcher failure without a process snapshot", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async () => { + throw new OwnedCommandFailure("process", "no process snapshot"); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + providerId: "rizin", + partialObservation: undefined, + reason: "Unable to run configured command (ERR_PROVIDER_LAUNCH)", + cause: { + message: + "Owned provider command did not produce a complete result.", + stdout: "", + stderr: "", + cause: { reason: "process", message: "no process snapshot" }, + }, + capturedOutput: undefined, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("retains an owned process failure snapshot with an unknown exit code", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async () => { + throw new OwnedCommandFailure( + "process", + "process ended before reporting an exit code", + providerSnapshot({ exitCode: null }), + ); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: true, + value: { + raw_result: { + stdout: "captured owned output", + exit_code: null, + completion_status: "unknown", + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}; + +const registerSharedReverseEngineeringEdgeCasesPart9 = (): void => { + it("fails with partial unknown Evidence when owned output collection fails after exit 0", async () => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async () => { + throw Object.assign( + new OwnedCommandFailure( + "process", + "stdout stream failed: fixture read failure", + providerSnapshot({ exitCode: 0 }), + ), + { collectionFailure: true }, + ); + }, + }); + + try { + const result = await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + partialObservation: { + raw_result: { + stdout: "captured owned output", + stderr: "diagnostic", + exit_code: null, + completion_status: "unknown", + }, + }, + capturedOutput: { exit_code: null }, + }, + }); + if (result.ok) throw new Error("Expected collection failure"); + expect(result.error.cause).toMatchObject({ + collectionFailure: true, + cause: { reason: "process" }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}; + +export const registerSharedReverseEngineeringEdgeCaseTests = (): void => { + registerSharedReverseEngineeringEdgeCasesPart1(); + registerSharedReverseEngineeringEdgeCasesPart2(); + registerSharedReverseEngineeringEdgeCasesPart3(); + registerSharedReverseEngineeringEdgeCasesPart4(); + registerSharedReverseEngineeringEdgeCasesPart5(); + registerSharedReverseEngineeringEdgeCasesPart6(); + registerSharedReverseEngineeringEdgeCasesPart7(); + registerSharedReverseEngineeringEdgeCasesPart8(); + registerSharedReverseEngineeringEdgeCasesPart9(); +}; diff --git a/tests/support/reverseEngineeringCompositionFixtures.ts b/tests/support/reverseEngineeringCompositionFixtures.ts new file mode 100644 index 000000000..b96233dcb --- /dev/null +++ b/tests/support/reverseEngineeringCompositionFixtures.ts @@ -0,0 +1,24 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import type { ProviderProcessSnapshot } from "../../src/process/ProviderProcess.js"; +import { join } from "node:path"; + +export const createArtifact = async (): Promise<{ + directory: string; + path: string; +}> => { + const directory = await mkdtemp(join(tmpdir(), "rea-reverse-edge-")); + const path = join(directory, "sample.bin"); + await writeFile(path, "initial artifact"); + return { directory, path }; +}; + +export const providerSnapshot = ( + overrides: Partial = {}, +): ProviderProcessSnapshot => ({ + stdout: { text: "captured owned output", bytes: 21, observedBytes: 21 }, + stderr: { text: "diagnostic", bytes: 10, observedBytes: 10 }, + exitCode: 0, + signal: null, + ...overrides, +}); diff --git a/tests/support/reverseEngineeringMcpHarness.ts b/tests/support/reverseEngineeringMcpHarness.ts new file mode 100644 index 000000000..e58f0486c --- /dev/null +++ b/tests/support/reverseEngineeringMcpHarness.ts @@ -0,0 +1,33 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +type RegisteredHandler = (input: unknown, context: unknown) => Promise; + +export const invokeWithLiveSignal = ( + handler: RegisteredHandler, + input: unknown, +): Promise => + handler(input, { mcpReq: { signal: new AbortController().signal } }); + +export const createReverseEngineeringMcpHarness = (): { + server: McpServer; + handler: (name: string) => RegisteredHandler; +} => { + const registrations = new Map(); + const server = { + registerTool: ( + name: string, + _options: unknown, + handler: RegisteredHandler, + ) => registrations.set(name, handler), + } as unknown as McpServer; + + return { + server, + handler: (name) => { + const registered = registrations.get(name); + if (registered === undefined) + throw new Error(`${name} MCP handler was not registered`); + return registered; + }, + }; +}; diff --git a/tests/support/reverseEngineeringService.ts b/tests/support/reverseEngineeringService.ts new file mode 100644 index 000000000..ebeb3c202 --- /dev/null +++ b/tests/support/reverseEngineeringService.ts @@ -0,0 +1,21 @@ +import { createReverseEngineeringCommandPort } from "../../src/composition/reverseEngineering.js"; +import { + ReverseEngineeringService, + type ReverseEngineeringServiceOptions, +} from "../../src/application/reverse/ReverseEngineeringService.js"; + +type TestServiceOptions = Omit & + Partial>; + +/** Compose the production provider profiles while allowing service-boundary fakes. */ +export class TestReverseEngineeringService extends ReverseEngineeringService { + constructor(options: TestServiceOptions = {}) { + const environment = options.environment ?? process.env; + super({ + ...options, + environment, + commands: + options.commands ?? createReverseEngineeringCommandPort(environment), + }); + } +} diff --git a/tsconfig.reverse-tools-mutation.json b/tsconfig.reverse-tools-mutation.json new file mode 100644 index 000000000..93e730ea2 --- /dev/null +++ b/tsconfig.reverse-tools-mutation.json @@ -0,0 +1,50 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/frida/frida-bindings.d.ts", + "src/application/reverse/ReverseEngineeringService.ts", + "src/cli/reverseEngineeringCommands.ts", + "src/cutter/CutterBridgeClient.ts", + "src/cutter/CutterBridgeService.ts", + "src/gdb/GdbSessionManager.ts", + "src/objdump/ObjdumpCommand.ts", + "src/process/ownedDebuggerSession.ts", + "src/process/ExecFileOutput.ts", + "src/server/registerReverseEngineeringTools.ts", + "src/server/providerEnvironment.ts", + "src/rizin/RizinCommand.ts", + "src/rizin/RizinDebugSessionManager.ts", + "tests/boundary/providers/GdbSessionManagerCoverage.test.ts", + "tests/boundary/providers/RizinTimeoutEvidence.test.ts", + "tests/boundary/providers/RizinInferiorTty.test.ts", + "tests/boundary/providers/RizinTestInferiorTty.ts", + "tests/boundary/providers/RizinCommandTimeout.test.ts", + "tests/boundary/providers/CutterBridgeClient.test.ts", + "tests/boundary/providers/CutterBridgeNullIdentity.test.ts", + "tests/boundary/providers/CutterBridgeService.test.ts", + "tests/boundary/process/ownedDebuggerSession.test.ts", + "tests/boundary/mcp/cutterMcp.test.ts", + "tests/boundary/mcp/cutterCancellationMcp.test.ts", + "tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts", + "tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts", + "tests/boundary/cli/gdbStartupCleanupCli.test.ts", + "tests/composition/reverseEngineering.test.ts", + "tests/composition/reverseEngineeringAdmission.test.ts", + "tests/composition/reverseEngineeringEdgeCases.test.ts", + "tests/composition/reverseEngineeringCancellation.test.ts", + "src/process/ExecFileOutput.test.ts", + "tests/boundary/providers/GdbSessionManagers.test.ts", + "tests/boundary/providers/RizinDebugSessionManagers.test.ts", + "tests/boundary/providers/GdbCommandQueue.test.ts", + "tests/boundary/providers/GdbSessionReviewRegressions.test.ts", + "tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts", + "tests/boundary/mcp/gdbCancellationMcp.test.ts", + "tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts" + ], + "exclude": ["dist", "node_modules"] +} diff --git a/turbo.json b/turbo.json index 758f31e32..ffee2a070 100644 --- a/turbo.json +++ b/turbo.json @@ -1,11 +1,13 @@ { "$schema": "https://turborepo.dev/schema.json", + "cacheMaxAge": "7d", + "cacheMaxSize": "500MB", + "globalDependencies": [".nvmrc", ".npmrc"], "globalPassThroughEnv": ["GOMAXPROCS", "GOMEMLIMIT", "UV_THREADPOOL_SIZE"], "tasks": { "compile": { "dependsOn": ["metadata:check"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/check-dependency-install.mjs", @@ -22,7 +24,6 @@ }, "typecheck": { "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "src/**/*.ts", @@ -49,7 +50,6 @@ "outputs": [".cache/static-checks/oxlint.json"], "inputs": [ ".gitignore", - ".nvmrc", ".oxlintrc.json", "package.json", "package-lock.json", @@ -63,6 +63,8 @@ "!docs/.vitepress/dist/**", "!website/_site/**", "!experiments/analysis-efficiency/results/**", + "vitest.reverse-tools.config.ts", + "vitest.reverse-tools-semantics.config.ts", "vitest.config.ts" ] }, @@ -71,7 +73,6 @@ "outputs": [".cache/static-checks/oxlint.json"], "inputs": [ ".gitignore", - ".nvmrc", ".oxlintrc.json", "package.json", "package-lock.json", @@ -85,6 +86,8 @@ "!docs/.vitepress/dist/**", "!website/_site/**", "!experiments/analysis-efficiency/results/**", + "vitest.reverse-tools.config.ts", + "vitest.reverse-tools-semantics.config.ts", "vitest.config.ts" ] }, @@ -92,7 +95,7 @@ "knip": { "dependsOn": ["compile"], "inputs": [ - ".nvmrc", + ".gitignore", "knip.json", "package.json", "package-lock.json", @@ -100,7 +103,9 @@ "src/**/*.ts", "tests/**/*.ts", "tsconfig.json", - "tsconfig.build.json" + "tsconfig.build.json", + "vitest.config.ts", + "docs/.vitepress/config.ts" ] }, "jscpd": { @@ -139,7 +144,6 @@ "artifacts:skills" ], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-*.mjs", @@ -171,7 +175,6 @@ "artifacts:mcp-catalog": { "dependsOn": ["compile"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-mcp-tool-catalog.mjs", @@ -182,7 +185,6 @@ "artifacts:skills": { "dependsOn": ["compile"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-skill-metadata.mjs", @@ -194,7 +196,6 @@ "artifacts:product-catalog": { "dependsOn": ["compile"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-product-catalog.mjs", @@ -206,7 +207,6 @@ "artifacts:managed-evidence": { "dependsOn": ["artifacts:skills"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-completion-ledger.mjs", @@ -222,7 +222,6 @@ "artifacts:error-schema": { "dependsOn": ["compile"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-error-schema.mjs", @@ -235,7 +234,6 @@ "mcp-catalog:check:built": { "dependsOn": ["artifacts:mcp-catalog"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-mcp-tool-catalog.mjs", @@ -249,7 +247,6 @@ "evidence:check:built": { "dependsOn": ["artifacts:managed-evidence"], "inputs": [ - ".nvmrc", "package.json", "package-lock.json", "scripts/generate-completion-ledger.mjs", diff --git a/vitest.config.ts b/vitest.config.ts index bfef8e139..a8bf6b045 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -51,7 +51,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts", + "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS, diff --git a/vitest.reverse-tools-semantics.config.ts b/vitest.reverse-tools-semantics.config.ts new file mode 100644 index 000000000..003580658 --- /dev/null +++ b/vitest.reverse-tools-semantics.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: [ + "tests/boundary/providers/CutterBridgeClient.test.ts", + "tests/boundary/providers/RizinCommandTimeout.test.ts", + "tests/boundary/providers/RizinTimeoutEvidence.test.ts", + ], + pool: "forks", + maxWorkers: 2, + retry: 0, + testTimeout: 60_000, + runner: "./tests/processOwnershipRunner.ts", + }, +}); diff --git a/vitest.reverse-tools.config.ts b/vitest.reverse-tools.config.ts new file mode 100644 index 000000000..08cbcf410 --- /dev/null +++ b/vitest.reverse-tools.config.ts @@ -0,0 +1,72 @@ +import { defineConfig } from "vitest/config"; + +const REVERSE_TOOL_MODULES = [ + "src/application/reverse/ReverseEngineeringService.ts", + "src/application/binary/BinarySession.ts", + "src/cutter/CutterBridgeClient.ts", + "src/cutter/CutterBridgeService.ts", + "src/gdb/GdbSessionManager.ts", + "src/objdump/ObjdumpCommand.ts", + "src/process/ownedDebuggerSession.ts", + "src/process/ExecFileOutput.ts", + "src/server/registerReverseEngineeringTools.ts", + "src/server/externalMutationAdmission.ts", + "src/server/providerEnvironment.ts", + "src/rizin/RizinCommand.ts", + "src/rizin/RizinDebugSessionManager.ts", +]; + +export default defineConfig({ + test: { + include: [ + "tests/composition/analysis-sessions/binarySession.test.ts", + "tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts", + "tests/boundary/providers/GdbSessionManagers.test.ts", + "tests/boundary/providers/RizinDebugSessionManagers.test.ts", + "tests/boundary/providers/RizinInferiorTty.test.ts", + "tests/boundary/providers/GdbSessionManagerCoverage.test.ts", + "tests/boundary/providers/GdbCommandQueue.test.ts", + "tests/boundary/providers/GdbSessionReviewRegressions.test.ts", + "tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts", + "tests/boundary/providers/RizinTimeoutEvidence.test.ts", + "tests/boundary/providers/RizinCommandTimeout.test.ts", + "tests/boundary/providers/CutterBridgeClient.test.ts", + "tests/boundary/providers/CutterBridgeCapacity.test.ts", + "tests/boundary/providers/cutterBridgeProducer.test.ts", + "tests/boundary/providers/CutterBridgeNullIdentity.test.ts", + "tests/boundary/providers/CutterBridgeService.test.ts", + "tests/boundary/process/ownedDebuggerSession.test.ts", + "tests/boundary/mcp/gdbCancellationMcp.test.ts", + "tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts", + "tests/boundary/mcp/cutterMcp.test.ts", + "tests/boundary/mcp/cutterCancellationMcp.test.ts", + "tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts", + "tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts", + "tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts", + "tests/boundary/cli/gdbStartupCleanupCli.test.ts", + "tests/composition/reverseEngineering.test.ts", + "tests/composition/reverseEngineeringAdmission.test.ts", + "tests/composition/reverseEngineeringEdgeCases.test.ts", + "tests/boundary/cli/reverseEngineeringCommands.test.ts", + "tests/composition/reverseEngineeringCancellation.test.ts", + "src/process/ExecFileOutput.test.ts", + ], + pool: "forks", + maxWorkers: 2, + retry: 0, + testTimeout: 60_000, + runner: "./tests/processOwnershipRunner.ts", + coverage: { + enabled: false, + provider: "v8", + reportsDirectory: "coverage/reverse-tools", + include: REVERSE_TOOL_MODULES, + reporter: ["text", "text-summary", "json"], + }, + }, +});