diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..782f0ad --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "idac", + "interface": { + "displayName": "idac" + }, + "plugins": [ + { + "name": "idac", + "source": { + "source": "local", + "path": "./plugins/idac" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.github/scripts/sync_agent_plugin_version.py b/.github/scripts/sync_agent_plugin_version.py new file mode 100644 index 0000000..9439af2 --- /dev/null +++ b/.github/scripts/sync_agent_plugin_version.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {Path(sys.argv[0]).name} VERSION", file=sys.stderr) + return 2 + + manifest_path = Path("plugins/idac/plugin.json") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = sys.argv[1] + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aecdb03..b1f30f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,35 +28,57 @@ jobs: - name: Lint run: make lint - test: - name: Test (IDA ${{ matrix.ida-version }}) - # Steps run only in the merge queue and only for non-docs changes. The - # skips are per-step because a job-level `if` suppresses matrix expansion - # and the required per-version checks would never report. + unit: + name: Unit tests (Python 3.11, no IDA) runs-on: ubuntu-latest permissions: contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.11" + + - name: Test without IDA + run: make test-unit + + test: + name: Test (IDA ${{ matrix.ida-version }}) + # Steps run for manual dispatches and for non-docs merge-queue changes. The + # skips remain per-step so this required check still reports for pull requests + # and docs-only merges. + runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - - ida-version: "9.3" - ida-installer: release/9.3/ida-pro/ida-pro_93_x64linux.run - ida-version: "9.4" - ida-installer: release/9.4/ida-pro/ida-pro_94_x64linux.run + installer-id: release/9.4/ida-pro/ida-pro_94_x64linux.run + permissions: + contents: read steps: - name: Check out repository - if: github.event_name == 'merge_group' + if: github.event_name != 'pull_request' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Detect code changes - if: github.event_name == 'merge_group' + if: github.event_name != 'pull_request' id: changes env: BASE_SHA: ${{ github.event.merge_group.base_sha }} + EVENT_NAME: ${{ github.event_name }} run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + echo "code=true" >>"$GITHUB_OUTPUT" + exit 0 + fi git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" # markdown under src/ and tests/ is test-covered, so it counts as code if git diff --name-only "$BASE_SHA" HEAD | grep -qvE '^([^/]+\.md|docs/.+|LICENSE|\.gitignore)$'; then @@ -65,22 +87,26 @@ jobs: echo "code=false" >>"$GITHUB_OUTPUT" fi - - name: Install IDA Pro - if: github.event_name == 'merge_group' && steps.changes.outputs.code == 'true' - id: ida - uses: HexRaysSA/ida-hcli-actions/install-ida@2ff7f4e7c466809cfae6ea20c81264bdb6a33d49 # v1.1.0 - with: - installer-id: ${{ matrix.ida-installer }} - license-id: ${{ secrets.IDA_LICENSE_ID }} - api-key: ${{ secrets.HCLI_API_KEY }} - python-version: "3.12" - - name: Install uv - if: github.event_name == 'merge_group' && steps.changes.outputs.code == 'true' + if: github.event_name != 'pull_request' && steps.changes.outputs.code == 'true' uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - - name: Test - if: github.event_name == 'merge_group' && steps.changes.outputs.code == 'true' - run: make test + - name: Install IDA Pro + if: github.event_name != 'pull_request' && steps.changes.outputs.code == 'true' + env: + IDA_INSTALLER_ID: ${{ matrix.installer-id }} + IDA_LICENSE_ID: ${{ secrets.IDA_LICENSE_ID }} + HCLI_API_KEY: ${{ secrets.HCLI_API_KEY }} + run: | + export IDADIR="$RUNNER_TEMP/opt/ida" + echo "IDADIR=$IDADIR" >>"$GITHUB_ENV" + uv run --locked hcli ida install --yes --download-id "$IDA_INSTALLER_ID" \ + --license-id "$IDA_LICENSE_ID" --install-dir "$IDADIR" + + - name: Test through ida-nexus + if: github.event_name != 'pull_request' && steps.changes.outputs.code == 'true' + run: | + uv run --locked ida-nexus worker --probe + make test-integration diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 55cc7db..8dcce53 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -47,6 +47,7 @@ jobs: fi release_version=$(uv version --short) + uv run .github/scripts/sync_agent_plugin_version.py "$release_version" release_tags=$(git ls-remote --tags origin 'v*' | cut -f2 | sed -e 's|^refs/tags/||' -e '/\^{}$/d') RELEASE_VERSION="$release_version" RELEASE_TAGS="$release_tags" uv run .github/scripts/check_release_version.py @@ -59,10 +60,10 @@ jobs: echo "PREVIOUS_TAG=$(printf '%s\n' "$release_tags" | sort -V | tail -1)" >> "$GITHUB_ENV" - name: Write changelog entry - uses: anthropics/claude-code-action@1f291e1cfe0f5fc21db2aef19af844591600ade7 # v1.0.206 + uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ github.token }} + openai-api-key: ${{ secrets.OPENAI_CODEX_API_KEY }} + permission-profile: ":workspace" prompt: | Write the CHANGELOG.md entry for idac ${{ env.RELEASE_VERSION }}. Publishing the release copies this section verbatim into the @@ -73,8 +74,8 @@ jobs: `git show` on individual commits for detail. Only changes someone installing idac would notice belong in the - entry: commands, flags, output, behavior and bug fixes, GUI bridge - and idalib backend behavior, packaging, and the bundled skill, + entry: commands, flags, output, behavior and bug fixes, ida-nexus + target and database behavior, packaging, and the bundled skill, docs, and workspace templates. Mention runtime dependency changes only when they fix a vulnerability or change behavior. @@ -94,8 +95,6 @@ jobs: the release theme, then short bullets in the user's language, no PR numbers or commit hashes. Do not modify already-released sections or any other file. - claude_args: | - --allowedTools "Read,Edit,Write,Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git tag:*)" - name: Create release branch id: commit @@ -116,7 +115,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add pyproject.toml uv.lock CHANGELOG.md + git add pyproject.toml uv.lock CHANGELOG.md plugins/idac/plugin.json git commit -m "Prepare release v$RELEASE_VERSION" release_commit=$(git rev-parse HEAD) diff --git a/AGENTS.md b/AGENTS.md index fba5723..70da828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,22 +2,20 @@ ## Repo Overview -`idac` is a CLI for IDA Pro with two execution paths: - -- `gui`: talks to a live IDA desktop session through the bridge plugin -- `idalib`: opens `.i64` / `.idb` files in a short-lived headless worker +`idac` is an ida-nexus-backed CLI for IDA Pro. The same public Nexus API attaches +to live GUI databases and opens `.i64` databases or binaries in managed headless +workers. There is no alternate transport or compatibility fallback. Most implementation lives under `src/idac`: -- `src/idac/cli2/`: command registration and argument parsing -- `src/idac/ops/`: typed operation families, manifest, dispatch, preview execution, runtime helpers, and shared helper modules -- `src/idac/cli2/renderers/`: text rendering -- `src/idac/transport/schema.py`: wire request/response schema -- `src/idac/transport/`: GUI bridge transport and `idalib` worker transport -- `src/idac/ida_plugin/`: IDA GUI bridge plugin code +- `src/idac/cli/`: command registration, argument parsing, batch/preview orchestration, and text rendering +- `src/idac/nexus.py`: public ida-nexus discovery, selection, lifecycle, compatibility, and execution boundary +- `src/idac/remote_ops.py`: the self-contained operation module uploaded through `ida_nexus.RemoteModule` +- `src/idac/operations.py`: the retained public operation inventory +- `src/idac/doctor.py` and `src/idac/setup.py`: environment diagnostics and GUI installation - `tests/`: CLI and backend coverage - `fixtures/`: committed binaries, databases, logs, and source used by tests -- `docs/` and `src/idac/skills/idac/`: user-facing command docs and agent-oriented usage guidance +- `docs/` and `plugins/idac/skills/idac/`: user-facing command docs and agent-oriented usage guidance ## Working Style @@ -25,8 +23,9 @@ Most implementation lives under `src/idac`: - Prefer targeted tests first, then broader validation if the change touches shared behavior. - Treat committed fixture artifacts as part of the product surface. If you change fixture symbols, fixture source, or docs/examples that depend on them, regenerate the fixture outputs too. - Do not revert unrelated worktree changes. This repo may contain user-owned untracked recovery artifacts and local editor files. -- In `src/idac/cli2`, keep `argparse.Namespace` at the parser boundary. Use direct `args.foo` access for fields guaranteed by that subcommand, and reserve `vars(args).get(...)` for wrapper or `argparse.SUPPRESS` cases. -- For command-local argument normalization in `src/idac/cli2/commands/`, prefer a focused `_foo_params(args) -> dict[str, object]` builder rather than spreading selector/default coercion through handlers. +- In `src/idac/cli`, keep `argparse.Namespace` at the parser boundary. Use direct `args.foo` access for fields guaranteed by that subcommand, and reserve `vars(args).get(...)` for wrapper or `argparse.SUPPRESS` cases. +- For command-local argument normalization in `src/idac/cli/commands/`, prefer a focused `_foo_params(args) -> dict[str, object]` builder rather than spreading selector/default coercion through handlers. +- Do not add tiny one- to three-expression helpers unless they own a meaningful responsibility or remove real duplication. - When request-building logic becomes nontrivial, add a focused unit test for the builder itself in addition to end-to-end CLI coverage. ## Reverse-Engineering Defaults @@ -57,8 +56,10 @@ make format make lint make test make check -uv run pytest -q tests/test_idalib_classes.py -IDAC_RUN_LIVE_GUI_TESTS=1 uv run pytest -q -m gui_live tests/test_gui_transport_live.py +uv run pytest -q -m "not requires_ida" +uv run pytest -q -m requires_ida +IDAC_RUN_NEXUS_GUI_TESTS=1 IDAC_NEXUS_GUI_RECORD_ID='' \ + uv run pytest -q -m nexus_gui_live ``` Prefer targeted `idac --help` when you already know the likely command family. Use `idac --full-help` when you need the full command tree in one pass. @@ -67,33 +68,26 @@ Prefer targeted `idac --help` when you already know the likely command When changing commands or request/response shapes: -- update CLI wiring in `src/idac/cli.py` -- update the operation implementation in `src/idac/ops/` -- update renderers/schema if output shape changed -- update tests and any affected docs under `README.md`, `docs/`, or `src/idac/skills/idac/` +- update CLI wiring in `src/idac/cli/` +- update the operation implementation and registry in `src/idac/remote_ops.py` +- update `src/idac/operations.py` and renderers if the operation/output surface changed +- update tests and any affected docs under `README.md`, `docs/`, or `plugins/idac/skills/idac/` When changing the operation layer, keep these boundaries in mind: -- `src/idac/ops/runtime.py` is the shared toolkit for reusable IDA-facing helpers. Prefer adding cross-operation lookup, normalization, and readback helpers there instead of duplicating them across op modules. -- keep `src/idac/ops/families/` focused on command-family orchestration, typed request/result models, and user-facing error messages -- `src/idac/ops/manifest.py` is the source of truth for supported ops, mutation flags, and preview metadata -- `src/idac/ops/dispatch.py` should derive handler registration from the manifest and registry, not maintain a parallel operation list -- `src/idac/ops/preview.py` should stay thin. If preview behavior changes, prefer encoding defaults and policy in `PreviewSpec` rather than branching in wrappers. -- `src/idac/cli2/renderers/__init__.py` owns text rendering. Before adding another formatter, look for an existing helper or adjacent renderer that can absorb the behavior. +- `src/idac/remote_ops.py` must remain importable outside IDA, self-contained, JSON-native, and free of imports from the local `idac` package. +- `remote_ops.dispatch(db, op, params, preview)` is the only uploaded dispatch entrypoint; do not add alternate wire paths or per-operation uploads. +- Keep the remote operation registry immutable and request state local. Do not add process-global mutable caches or compatibility handlers. +- `src/idac/cli/renderers/__init__.py` owns text rendering. Before adding another formatter, look for an existing helper or adjacent renderer that can absorb the behavior. - for `type declare`, keep `DeclarationChunk` as the internal representation through parse / diagnose / bisect flows and only convert to plain dicts at the API boundary when needed by tests or wire output - if you are tempted to add a module-level wrapper around an `IdaRuntime` method, prefer calling the runtime instance method directly unless tests or external callers genuinely need the free function -- shared non-runtime helpers should live under `src/idac/ops/helpers/` - -When changing GUI bridge behavior: - -- check both `plugin/` and `src/idac/transport/gui.py` -- keep protocol expectations aligned across the plugin and the CLI transport -- add or update the optional `gui_live` test when the Unix socket contract changes -When changing `idalib` behavior: +When changing Nexus behavior: -- inspect `src/idac/transport/idalib.py` and `src/idac/transport/idalib_worker.py` -- use targeted `idalib` tests before running the whole suite +- use only supported public `ida_nexus` Python exports; do not read its private registry, issue raw HTTP requests, or add MCP/legacy fallbacks +- preserve exact READY-record selection, one session per top-level command, a 300-second lease keepalive, headless autoanalysis, and headless save-on-successful-mutation semantics +- validate the remote environment against declared dependency requirements before dispatch; let the package installer enforce local dependencies +- add or update `tests/test_nexus_session.py` and the optional `nexus_gui_live` coverage when lifecycle behavior changes ## Fixtures @@ -121,7 +115,7 @@ When running fixture-generation commands or any test flow that opens IDA or `ida Reason: -- the installed `~/.idapro/plugins/idac_bridge_plugin.py` can import the current checkout and break batch runs if the repo is mid-change +- globally installed IDA plugins can import local packages or otherwise change batch behavior while the checkout is mid-change - fixture regeneration should not depend on whatever plugins happen to be installed globally - tests and fixture refreshes should not mutate the user's real IDA profile @@ -158,16 +152,23 @@ bash fixtures/scripts/build_tiny.sh bash fixtures/scripts/make_idbs.sh ``` -If `idat` logs show plugin import errors from `~/.idapro/plugins/idac_bridge_plugin.py`, rerun with an isolated `IDAUSR` before assuming the fixture or code under test is broken. +If `idat` logs show plugin import errors, rerun with an isolated `IDAUSR` before assuming the fixture or code under test is broken. ## Test Guidance Normal repo tests can run without `IDAUSR`, but keep the isolated directory exported when you are doing fixture refreshes or any workflow that may spawn `idat`. +Test durable behavior, not implementation shape. Prefer public CLI/API results, persisted +state, wire contracts, and required lifecycle or safety invariants. Do not assert private +helper boundaries, incidental call order, mock choreography, source layout, or constants +that have no user-visible effect. Avoid duplicating the same behavior at several layers; +keep the lowest-cost test that exercises the real contract. A refactor that preserves +behavior should not require rewriting tests. + Typical commands: ```bash -uv run pytest -q tests/test_idalib_classes.py +uv run pytest -q tests/test_nexus_classes.py uv run pytest -q ``` @@ -176,9 +177,9 @@ Useful targeted suites for operation-layer work: ```bash uv run pytest -q tests/test_ops_helpers.py uv run pytest -q tests/test_preview.py -uv run pytest -q tests/test_idalib_types.py -uv run pytest -q tests/test_idalib_name_locals_semantics.py -uv run pytest -q tests/test_idalib_struct_enum_semantics.py +uv run pytest -q tests/test_nexus_types.py +uv run pytest -q tests/test_nexus_name_locals_semantics.py +uv run pytest -q tests/test_nexus_struct_enum_semantics.py uv run pytest -q tests/test_vtable_helpers.py ``` @@ -189,24 +190,26 @@ These are especially useful when editing: - local-variable mutation and preview behavior - class / vtable helper logic -Optional live GUI transport coverage is marked with `@pytest.mark.gui_live` and skipped unless `IDAC_RUN_LIVE_GUI_TESTS=1` is set. - -## Skill Install Targets +Optional live GUI Nexus coverage is marked with `@pytest.mark.nexus_gui_live` and +skipped unless `IDAC_RUN_NEXUS_GUI_TESTS=1` and an exact +`IDAC_NEXUS_GUI_RECORD_ID` are set. Use only a disposable GUI database: the test +explicitly saves, verifies an on-disk snapshot, and then restores a temporary comment. -The bundled `idac` skill supports both Claude Code and Codex equally. +## Agent Plugin Distribution -- default install targets: - - `~/.claude/skills/idac` - - `~/.codex/skills/idac` -- custom install destination: - - `idac misc skill install --dest /custom/path/idac` +`plugins/idac` is the canonical Agent Plugins v1 package, separate from the Python +package. Its manifest is `plugins/idac/plugin.json`, and its skill is +`plugins/idac/skills/idac`. The repository catalog at +`.agents/plugins/marketplace.json` must point directly to `./plugins/idac`. Do not add +client-specific manifests or fallback copies. The CLI has no command that installs or +prints plugin content, so do not add runtime package code that reads these assets. For fixture-driven class tests, prefer updating and validating: - `tests/conftest.py` -- `tests/test_idalib_classes.py` +- `tests/test_nexus_classes.py` - `README.md` -- `src/idac/skills/idac/` +- `plugins/idac/skills/idac/` ## Release Process @@ -231,11 +234,10 @@ To cut a release: git ls-remote origin 'refs/heads/release/*' ``` -3. Open a pull request from the release branch: +3. Review the pull request opened automatically from the release branch: ```bash - gh pr create --head release/vX.Y.Z --title "Prepare release vX.Y.Z" \ - --body "Merging this pull request publishes vX.Y.Z automatically." + gh pr view release/vX.Y.Z ``` 4. Merge the PR through the merge queue like any other PR. Once a `release/*` @@ -245,8 +247,8 @@ To cut a release: Notes: -- The prepare workflow has Claude draft the `CHANGELOG.md` section for the new - version on the release branch (requires the `ANTHROPIC_API_KEY` actions +- The prepare workflow has Codex draft the `CHANGELOG.md` section for the new + version on the release branch (requires the `OPENAI_CODEX_API_KEY` Actions secret); review and edit that entry as part of the release PR. The changelog covers user-facing changes only, so CI, release tooling, tests, and internal refactoring stay out of it even when they dominate the release. diff --git a/CHANGELOG.md b/CHANGELOG.md index bede308..3d37646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## Unreleased + +This release moves live and headless execution to ida-nexus and changes how users +select databases, install integrations, and run mutation passes. + +- Replaced the bundled GUI bridge and headless daemon with ida-nexus. The supported + stack now requires Python 3.11+, IDA Pro 9.4+, `ida-nexus>=0.13.2`, and + `ida-domain>=0.5.1`. Run `idac setup gui` to install the Nexus GUI component + matching the client, then restart IDA or load the component and run `idac doctor`. +- Changed `-c/--context` to accept an existing `.i64` or binary path directly, such + as `-c sample.i64`, replacing `db:`, `pid:`, and `module:` locators and bare module + selectors. Use `--instance ` from `targets list --json` for one exact + READY instance; without a selector, exactly one READY instance is required. + Paths can attach to a matching live GUI or reuse or start a managed headless + worker. Legacy `.idb` databases must be converted to `.i64`. +- Removed `database open`, `database close`, and `targets cleanup`; Nexus manages + opening and worker lifetime. `database save` checkpoints the selected database + and no longer accepts a destination path. `targets list --json` now reports Nexus + records with `record_id` and discovery `state`; update scripts using old selectors. +- Headless opens wait for auto-analysis, and workers remain warm for five idle + minutes. Successful headless mutations save before the next remote request or + session release, so earlier successful batch steps remain on disk if a later + step fails. GUI changes still require an explicit `database save`. +- Failed previews or locally interrupted requests discard the affected headless + worker without saving uncertain state. Commands fail explicitly on target or + compatibility errors and do not retry on another instance. GUI preview failures + require inspecting or undoing the in-memory state before saving. +- `batch` and `preview` now own one shared target and timeout; child commands cannot + specify `-c`, `--instance`, or `--timeout`. Mutating batch children cannot set their + own `--out`. Batch logs record progress after each line and report save or session + close failures and interruptions before claiming success. Preview artifacts belong + to the wrapper, and output files cannot overwrite the selected database or inputs. +- Made `py exec` stateless and removed `--persist`. Put dependent Python work in one + invocation; script files are read locally and retain their filename and `__file__`. +- Added `function prototype set --preserve-cc` to retain the stored calling + convention while editing parameter names, types, or the return type. +- Improved `type check` to validate dependent declarations together in a temporary + type library without importing or replacing database types. Unfiltered `type + struct list` and `type enum list` now require `--out`, like `type list`. +- Enabled `misc rename` in ordered batches and previews so symbol renames can share + a session with prototype edits, comments, and readbacks. +- Moved agent guidance out of the Python package into an Agent Plugins v1 package. + Install it separately through a compatible client's marketplace; Codex users run + `codex plugin marketplace add trailofbits/idac` followed by + `codex plugin add idac@idac`. This replaces `misc skill install`; `misc plugin + install` is replaced by `setup gui`. +- `doctor` now compares installed `idac` Agent Plugin versions with the CLI version + through available Codex and Claude clients, warning when guidance and commands + come from different releases. +- Removed `idac docs`; use subcommand `--help`, `--full-help`, and the installed + plugin's skill references. `workspace init` now points at that skill instead of + copying its reference docs into each workspace, and generates one shared + `AGENTS.md` for Claude and Codex instead of a separate `CLAUDE.md`. + ## 0.19.1 This patch release fixes the version that `idac` reports about itself. diff --git a/README.md b/README.md index 733598a..0211059 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # idac [![version](https://img.shields.io/pypi/v/idac?color=blue)](https://pypi.org/project/idac/) -![python](https://img.shields.io/badge/python-3.10%2B-blue) +![python](https://img.shields.io/badge/python-3.11%2B-blue) ![status](https://img.shields.io/badge/status-alpha-orange) -The IDA Pro CLI built for agents and humans. One Unix socket — no JSON-RPC framing, no sidecar daemon, no MCP server. Just `idac decompile "sub_08041337"` from any shell or agent. +The IDA Pro CLI built for agents and humans, powered by +[`ida-nexus`](https://github.com/HexRaysSA/ida-nexus). Run +`idac decompile "sub_08041337"` from any shell or agent against either a live IDA +session or a headless database. > `idac` is in early alpha and actively developed. It is already useful day to day, but the CLI surface may still change between releases. @@ -14,28 +17,36 @@ The IDA Pro CLI built for agents and humans. One Unix socket — no JSON-RPC fra - [Demo](#demo) - [Quick start](#quick-start) - [Requirements](#requirements) +- [Upgrading from 0.19](#upgrading-from-019) - [How it works](#how-it-works) - [Agent sandbox setup](#agent-sandbox-setup) - [Usage](#usage) - [Highlights](#highlights) -- [Skill](#skill) +- [Agent plugin](#agent-plugin) - [Development](#development) - [Credits](#credits) ## Why idac - **Not an MCP server** — compose with the shell you already have: pipes, `xargs`, `jq`, and your agent's existing tool-use loop. No server to run, no protocol to babysit. -- **Agent-native by default** — every command can emit structured JSON (`-j`), and a bundled skill teaches Claude Code and Codex to drive `idac` instead of guessing at raw IDAPython. -- **Safe mutations** — every mutation supports `preview`, which applies the change under IDA's undo, captures the before/after, and rolls it back. Dry-run any rename, retype, or prototype change before committing it. +- **Agent-native by default** — every command can emit structured JSON (`-j`), and the + `idac` Agent Plugin teaches compatible agents to drive `idac` instead of guessing at + raw IDAPython. +- **Safe mutations** — supported mutations offer `preview`, which applies the change, + captures the before/after, and restores it with IDA undo or an operation-specific rollback. Dry-run retypes, + prototype changes, and other preview-capable edits before committing them. - **Built for batches** — recover an entire class hierarchy, retype a hundred locals, or decompile every `Handler_*` in one invocation against a shared context. -- **Live or headless** — the same commands work against a running IDA GUI session or a saved `.i64`/`.idb`. Switch targets with `-c`; with one GUI open, omit it entirely. +- **Live or headless** — the same commands work against a running IDA GUI session, + a saved `.i64`, or a binary that IDA can open. Select a path with `-c`, an exact + running instance with `--instance`, or omit both when exactly one instance is ready. ## Demo Run this against the fixture committed in this repo: ```bash -idac decompilemany "CreateHandler_" --out-dir decomp/ -c "db:fixtures/idb/handler_hierarchy.i64" +idac decompilemany "CreateHandler_" --out-dir decomp/ \ + -c fixtures/idb/handler_hierarchy.i64 ``` Every matching function is decompiled into its own `.c` file (named `_0x
`) alongside a `manifest.json` index: @@ -86,19 +97,33 @@ Handler *__cdecl CreateHandler_Text() } ``` -The same command works against a live GUI session — drop `-c` and `idac` auto-targets the only open instance. +The same command works against a live GUI session — drop `-c` when it is the only +READY Nexus instance. ## Quick start -Install the CLI from [PyPI](https://pypi.org/project/idac/), then wire up the GUI plugin and agent skill: +Install the CLI from [PyPI](https://pypi.org/project/idac/). For live desktop work, +install the matching GUI integration: ```bash uv tool install idac # installs the `idac` command on your PATH -idac doctor # verify IDA install, license, and bridge -idac misc plugin install # GUI bridge plugin -idac misc skill install # Claude Code + Codex skill +idac setup gui # GUI plugin matching the installed ida-nexus client +idac doctor # report local versions and check the IDA environment ``` +Restart IDA or load the installed Nexus component, then rerun `idac doctor` and +`idac targets list --json`. Headless work does not require the GUI component; +select an existing database or binary with `-c` to start a managed worker. + +For the agent guidance, install the Agent Plugin (see [Agent plugin](#agent-plugin)). + +`ida-hcli>=0.24.0` is an `idac` runtime dependency. Setup and diagnostics +run it through `idac`'s Python environment; they do not require `uvx` or a +separately installed HCLI executable. Runtime dependencies use minimum versions; +`uv.lock` records the resolved versions for reproducible development installs. +`setup gui` selects the release matching the installed Nexus client and lets the +installer resolve ida-domain within the declared requirement. + To install the latest development version straight from git instead: ```bash @@ -111,36 +136,94 @@ Talk to a live GUI session: idac targets list --json idac decompile "sub_08041337" idac decompile "sub_08041337" --f5 # force a fresh Hex-Rays pass -idac decompile "sub_08041337" -c "pid:1234" +idac decompile "sub_08041337" --instance "" ``` Work headless against an existing database: ```bash -idac database show -c "db:sample.i64" -idac decompile "ExampleClass::method_1" -c "db:sample.i64" +idac database show -c sample.i64 +idac decompile "ExampleClass::method_1" -c sample.i64 ``` To run from a checkout without installing globally, use `uv run idac --help`. ## Requirements -- **Python 3.10+** and [`uv`](https://docs.astral.sh/uv/). -- **IDA Pro** with the **Hex-Rays decompiler** (required for `decompile`, `ctree`, and class recovery). -- A valid IDA license. Headless work uses `idalib`, which requires `idapro` to be installed and importable. - -`idac` discovers your IDA install from the user config automatically. On macOS it also falls back to the standard IDA 9.3 layout at `/Applications/IDA Professional 9.3.app/Contents/MacOS`. Run `idac doctor` to confirm what was detected. +- **Python 3.11+** and [`uv`](https://docs.astral.sh/uv/). +- **IDA Pro 9.4+** with the **Hex-Rays decompiler** for `decompile`, `ctree`, + and class recovery. +- A valid IDA license and an IDA Python environment on Python 3.11 or newer. +- `ida-nexus>=0.13.2` and `ida-domain>=0.5.1`, installed as dependencies of `idac`. + Install the matching GUI component with + `idac setup gui`. + +Run `idac doctor` to report CLI package versions, check the configured IDA version +through HCLI without starting IDA, and check the installed GUI +component, Nexus discovery, and the runtime inside every ready IDA instance. +When Codex or Claude is on `PATH`, it also compares installed `idac` Agent Plugin +versions with the CLI version and warns if they differ. Each optional agent check +has a two-second default timeout; `--timeout` overrides it. +Remote runtimes must satisfy the package requirements, and Nexus enforces protocol +compatibility. `idac` does not fall back to another backend. + +## Upgrading from 0.19 + +The rewrite requires Python 3.11+ and IDA Pro 9.4+. Install the matching Nexus GUI +component with `idac setup gui` for live desktop work, and install the Agent Plugin +separately for agent guidance. + +If uv's `exclude-newer` policy makes `ida-nexus>=0.13.2` unavailable, installation +fails during dependency resolution. Use a policy cutoff that admits the required +Nexus release before upgrading; the resolver cannot satisfy this dependency with +an older release. + +Update existing commands and scripts as follows: + +| Previous usage | Current usage | +|----------------|---------------| +| `-c db:sample.i64` | `-c sample.i64`; existing binary paths work too | +| `-c pid:1234`, `-c module:NAME`, or a bare module selector | `--instance RECORD_ID` from `idac targets list --json` | +| A legacy `.idb` database | Convert it to `.i64` in IDA first | +| `misc plugin install` | `setup gui`, followed by restarting IDA or loading the Nexus component | +| `misc skill install` | Install the [Agent Plugin](#agent-plugin) through your client's marketplace | +| `idac docs` | Subcommand `--help`, `--full-help`, and the installed skill's references | +| `database open`, `database close`, or `targets cleanup` | Select the database with `-c`; Nexus manages workers and their lifetime | +| `database save DESTINATION` | `database save` checkpoints the selected database; it accepts no destination path | +| `py exec --persist` | Combine dependent Python work in one invocation; each execution has a fresh namespace | + +`targets list --json` returns Nexus records with `record_id`, `state`, `detail`, +`backend`, `pid`, `idb_path`, `exe_path`, `managed`, and `started_at`. Update scripts +that read the old target fields. Omitted selectors require exactly one READY +instance, including managed headless workers. `-c` can attach to a matching GUI +database, so it does not guarantee headless execution. + +Put target and timeout options on `batch` and `preview`, not on their child +commands. Successful headless mutations save automatically; batches keep earlier +successful changes even if a later step fails. GUI saves remain explicit. See +[How it works](#how-it-works) and [batch usage](#run-an-ordered-mutation-pass-with-batch) +for execution and journal behavior. ## How it works -`idac` has two execution paths, and the same command surface drives both: +Every IDA operation goes through the public ida-nexus Python API. `-c/--context PATH` +opens or attaches to an `.i64` or input binary. Nexus prefers a matching live GUI +database, reuses a matching managed worker, or starts a headless worker. Use +`--instance RECORD_ID` to attach to one exact record from `idac targets list`. -- **`gui`** connects to a running IDA desktop over a Unix-socket bridge plugin. With exactly one GUI open, most commands need no `-c` at all. Use `-c pid:` or `-c ` to pick one of several open sessions. -- **`idalib`** opens a `.i64`/`.idb`/binary in a short-lived headless worker. Passing `-c "db:"` starts or reuses a per-database `idalib` process automatically; those rows show `backend: "idalib"` in `targets list --json`. +With neither selector, `idac` proceeds only when discovery finds exactly one READY +instance. Ambiguous, missing, blocked, disconnected, timed-out, or version-mismatched +targets fail explicitly. An operation is never retried on a different instance. -Discover everything that's reachable with `idac targets list --json`. Checkpoint headless state with `idac database save -c "db:"`; `idac database close -c "db:"` saves before closing by default, and `--discard` abandons pending changes. +Headless opens enable auto-analysis and wait for it to finish. A released worker stays +warm for five idle minutes, and successful headless mutations are checkpointed before +another remote request or lease release. A failed preview or locally interrupted +request retires that exact headless worker with `save=False`; the operation is never +retried. Live GUI commands do not force analysis or save; checkpoint GUI changes +explicitly with `idac database save`. -For bridge socket and sandbox diagnostics, run `idac docs troubleshooting`. +Use `idac targets list --json` to inspect discovery records and `idac doctor` to +diagnose the local and remote runtime stack. ## Agent sandbox setup @@ -150,19 +233,26 @@ Scaffold a project-local reversing workspace for sandboxed agents: idac workspace init reversing-workspace ``` -That creates workspace-local `.claude/` and `.codex/` config, agent guidance files, prompt templates, a `references/` copy of the bundled skill docs, and a git-backed directory layout for RE work. The generated sandbox settings are intentionally broad so sandboxed agents can reach the `idac` Unix socket bridge. +That creates workspace-local `.claude/` and `.codex/` config, agent guidance files, +prompt templates, and a git-backed directory layout for RE work. Nexus discovery and +execution are local to the host; the generated workspace allows the local access +needed by `idac`. Install the Agent Plugin in the client separately; workspace +initialization does not copy the skill or its references. + +Claude and Codex share `AGENTS.md` directly; the workspace does not generate a +separate `CLAUDE.md`. To customize the generated files, see the templates under [src/idac/workspace_template/default](src/idac/workspace_template/default). ## Usage -Use `idac --help` for one subcommand, `idac --full-help` for the complete CLI surface, and `idac docs` for an index of bundled command, workflow, and IDA reference material (`idac docs guide`, `idac docs cli`, `idac docs workflows`, `idac docs class-recovery`, ...). +Use `idac --help` for one subcommand and `idac --full-help` for the complete CLI surface. Command grammar, workflow, and IDA reference material ships with the plugin's skill and is loaded by agents that have it installed. ### Command families | Family | Commands | |--------|----------| -| Discovery | `doctor`, `docs`, `targets list`, `database show`, `segment list`, `bookmark list/show`, `comment show` | +| Discovery | `doctor`, `targets list`, `database show`, `segment list`, `bookmark list/show`, `comment show` | | Functions | `function list`, `metadata`, `frame`, `stackvars`, `callees`, `callers`, `prototype`, `locals` | | Decompilation | `decompile`, `decompilemany`, `disasm`, `disasm --start/--end`, `ctree` | | Search | `search bytes`, `search strings`, `xrefs`, `imports` | @@ -172,7 +262,7 @@ Use `idac --help` for one subcommand, `idac --full-help` for the compl | Batch | `batch`, `batch --lint`, `preview` | | IDAPython | `py exec` | | Workspace | `workspace init` | -| Maintenance | `misc reanalyze`, `database open/save/close`, `targets cleanup`, `misc plugin`, `misc skill` | +| Maintenance | `misc reanalyze`, `database save`, `setup gui` | ### Output @@ -180,11 +270,11 @@ Most read commands default to `--format text`. Use `--format json` (or `-j`) or ## Highlights -A few of the commands that make `idac` worth reaching for. See `idac docs cli` and `idac docs workflows` for the full reference. +A few of the commands that make `idac` worth reaching for. Install the plugin for the full command and workflow reference. ### Preview a mutation before committing -`preview` is a wrapper that runs the real mutation under IDA undo and rolls it back, returning the before/after so you (or an agent) can verify the change first: +`preview` is a wrapper that runs the real mutation and restores it with IDA undo or an operation-specific rollback, returning the before/after so you (or an agent) can verify the change first: ```bash idac preview -o "/tmp/preview.json" \ @@ -192,6 +282,16 @@ idac preview -o "/tmp/preview.json" \ --decl "int __fastcall sub_08041337(void *ctx, const unsigned char *buf, unsigned int len)" ``` +The wrapper owns the preview artifact. A wrapped command cannot set `--out`, +`--out-file`, or `--out-dir`; put `--out` on `preview` itself. Output paths are +also rejected when they alias the selected binary/database or any command input. +Put `-c`, `--instance`, and `--timeout` on the wrapper too; child commands cannot +override them. + +Unfiltered type, struct, and enum lists can use the preview or batch wrapper's +`--out` to preserve their full results. File output checks use the selected +Nexus database and input paths, including GUI databases saved under another name. + ### Recover C++ class hierarchies Walk vtables, flattened layouts, and inheritance straight from the database: @@ -208,7 +308,7 @@ idac type class vtable "ExampleDerived" --runtime Select by name filter or by reading exact identifiers from a file; emit one combined file or one `.c` per function plus a `manifest.json`: ```bash -idac decompilemany "Handler_" --out-dir "decomp/" -c "db:sample.i64" +idac decompilemany "Handler_" --out-dir "decomp/" -c sample.i64 idac decompilemany "Handler_.*" --regex --out-dir "decomp/" --disasm --ctree printf '%s\n' main sub_401000 0x401234 > funcs.txt @@ -218,9 +318,17 @@ idac decompilemany --functions-file "funcs.txt" --out-dir "decomp-exact/" Pass `--f5` after type or prototype changes so each function reflects the latest state. With `--out-dir`, the manifest records each function's `name`, exact `address`, and artifact paths. +For prototype edits that must retain the existing calling convention, use +`function prototype set --preserve-cc`. This preserves IDA's stored convention +when the declaration parser would otherwise normalize it. `type check` validates +dependent declarations together in a temporary type library, without importing +or replacing database types. +`misc rename` is batch-safe, so related renames, prototype edits, comments, and +readbacks can share one Nexus session. + ### Run an ordered mutation pass with batch -Run many subcommands against one shared context, leaving behind a stable ordered log. Batch files use one subcommand per line (drop the leading `idac`) and inherit `-c` and `--timeout` from the `batch` call: +Run many subcommands against one shared context, leaving behind a stable ordered log. Batch files use one subcommand per line (drop the leading `idac`) and inherit `-c` and `--timeout` from the `batch` call. Child commands cannot set their own target or timeout because the wrapper owns one Nexus session for the entire run: ```bash idac batch "recovery.idac" --out "/tmp/recovery_batch.json" @@ -238,7 +346,12 @@ function locals rename "0x100000000" 5 --new-name header_size function locals rename "0x100000000" 6 --new-name record_type ``` -Mutating batches require `--out` so the result log is preserved before any change runs. `batch --lint` parses child commands, resolves relative input paths, rejects unsupported batch commands, and warns on risky local selectors before execution. Setup `misc` commands are intentionally rejected from `batch`; `misc reanalyze` is batch-safe and belongs between type/prototype changes and local cleanup. +Mutating batches require `--out` so the result log is preserved before any change runs. `batch --lint` parses child commands, resolves relative input paths, rejects unsupported batch commands, and warns on risky local selectors before execution. Setup commands are intentionally rejected from `batch`; `misc reanalyze` is batch-safe and belongs between type/prototype changes and local cleanup. +Before dispatching the first command, `batch` writes a `pending` journal and checkpoints it after every line. It closes the shared Nexus session before replacing that journal with a terminal `ok`, `failed`, or `interrupted` record; Ctrl-C returns 130 without discarding the lifecycle record. Mutating child commands cannot set their own `--out`—the wrapper artifact is the mutation log—while read-only children may still write separate artifacts. + +Use `--fail-fast` when later steps depend on earlier ones or local selectors must +stop on the first miss. A batch does not roll back earlier successful mutations; +headless checkpoints survive a later failure or interruption. ### Address locals three ways @@ -261,15 +374,40 @@ When no first-class command fits, drop to IDAPython against the same target: idac py exec --code "result = {'entry': hex(idc.get_inf_attr(idc.INF_START_EA))}" ``` -## Skill +Each execution uses a fresh namespace; `--persist` is no longer available. Use +`--script PATH` or `--stdin` for multiline code, and keep dependent work in one +invocation. Script contents are read locally and execute inside IDA with the local +filename as `__file__`. Assign JSON-native data to `result`; structured output +includes `result`, `result_repr`, `stdout`, and `stderr`. `py exec` is treated as +mutating for saving and cannot be previewed. + +## Agent plugin -A bundled skill in [src/idac/skills/idac](src/idac/skills/idac) teaches Claude Code and Codex to prefer `idac` commands over ad hoc shell or raw IDAPython for RE work. +The [Agent Plugins v1](https://agent-plugins.org/) package in +[plugins/idac](plugins/idac) teaches compatible agents to prefer `idac` commands over +ad hoc shell or raw IDAPython for RE work. The repository catalog at +[.agents/plugins/marketplace.json](.agents/plugins/marketplace.json) points directly to +that package; [plugins/idac/plugin.json](plugins/idac/plugin.json) is its manifest and +[plugins/idac/skills/idac](plugins/idac/skills/idac) contains its `idac` skill. The +plugin is not included in the `idac` Python package. + +Install it through an Agent Plugins-compatible client. With Codex: ```bash -idac misc skill install +codex plugin marketplace add trailofbits/idac +codex plugin add idac@idac ``` -This installs into both `~/.claude/skills/idac` and `~/.codex/skills/idac`; both agents auto-discover skills from their `skills/` directories. Once installed, the skill loads automatically when relevant. For a ready-to-fill task prompt covering anything from a light analysis pass to class-family recovery, run `idac workspace init ` to scaffold a workspace containing `prompts/recovery-pass.md`. +Once installed, the skill loads automatically when relevant. The repository contains +one canonical plugin package, with no client-specific compatibility package or manual +skill-link fallback. + +Run `idac doctor` after updating the CLI or plugin to check that their versions match. +It reads the installed plugin inventories through `codex plugin list --json` and +`claude plugin list --json` when those clients are available on `PATH`. Version +mismatches are warnings; the Agent Plugin is optional and does not affect CLI health. + +For a ready-to-fill task prompt covering anything from a light analysis pass to class-family recovery, run `idac workspace init ` to scaffold a workspace containing `prompts/recovery-pass.md`. ## Development @@ -287,9 +425,12 @@ To put an `idac` on your PATH that tracks your checkout, install it as editable: uv tool install -e . ``` -See [docs/development.md](docs/development.md) for fixture regeneration, live GUI tests, and local tooling details. +See [docs/development.md](docs/development.md) for fixture regeneration, Nexus +integration tests, and local tooling details. ## Credits Inspired by [@banteg's `bn` Binary Ninja CLI tool](https://github.com/banteg/bn). +Backend integration is provided by +[`ida-nexus`](https://github.com/HexRaysSA/ida-nexus). Written by [Codex](https://openai.com/codex)/gpt-5.3-codex/gpt-5.4/gpt-5.5. diff --git a/docs/development.md b/docs/development.md index b6e7339..2ecc0bf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,6 +2,11 @@ ## Local setup +`idac` requires Python 3.11 or newer, `ida-nexus>=0.13.2`, and +`ida-domain>=0.5.1`; GUI installation uses `ida-hcli>=0.24.0`. These packages are +installed as runtime dependencies. IDA-backed work requires IDA Pro 9.4+. +`uv.lock` records the resolved development dependencies. + ```bash uv sync ``` @@ -17,95 +22,109 @@ make audit uv run idac --full-help ``` -## Testing - -Run the test suite: - -```bash -uv sync -uv run pytest -q -``` - -The suite has two layers, split by the `requires_ida` marker: - -- `make test-unit` — no IDA required; runs in a few seconds. -- `make test-integration` — talks to a real idalib daemon against the committed - fixture databases; needs a licensed local IDA install. These tests are - auto-skipped when no install is discovered. -- `make coverage` — full suite with line coverage. Note that integration tests - execute the CLI in subprocesses, so their coverage is not attributed; judge - modules with dedicated `test_idalib_*` files by those tests, not the percentage. - -When changing the operation layer, start with targeted suites before broad runs: +Install the matching IDA GUI component when testing a live desktop session: ```bash -uv run pytest -q tests/test_ops_helpers.py -uv run pytest -q tests/test_preview.py -uv run pytest -q tests/test_idalib_types.py -uv run pytest -q tests/test_idalib_name_locals_semantics.py -uv run pytest -q tests/test_idalib_struct_enum_semantics.py -uv run pytest -q tests/test_vtable_helpers.py +uv run idac setup gui +uv run idac doctor ``` -These cover most of the high-churn operation surfaces: - -- operation manifest / dispatch / preview plumbing -- `type declare` parsing, diagnostics, alias rewrites, and bisect behavior -- local variable mutation semantics -- struct / enum preview round-trips -- vtable slot and pointer-width helpers - -## Operation Layer Structure - -`src/idac/ops/runtime.py` is the shared toolkit layer. Put reusable IDA lookups, normalization, and helper reads there when they are likely to be shared across multiple command families. - -The live operation modules in `src/idac/ops/families/` should stay focused on: - -- command-specific orchestration -- request validation and user-facing error messages -- typed request/result models and result shaping - -For operation metadata: - -- `src/idac/ops/manifest.py` is the source of truth for supported operations, mutation flags, and preview metadata -- `src/idac/ops/dispatch.py` should derive handler registration from the operation manifest and typed registry, not maintain a parallel operation list -- `src/idac/ops/preview.py` should remain a thin wrapper around `PreviewSpec` behavior -- `src/idac/ops/helpers/` should hold shared parameter parsing and matching helpers that do not belong on `IdaRuntime` +`setup gui` delegates installation to HCLI and selects the GUI release matching +the installed Nexus client. It passes the declared ida-domain requirement to the +installer. Its JSON result reports `ida_domain_requirement` rather than claiming +a particular ida-domain version was installed. Do not copy integration files into +IDA by hand. `doctor` reports local package versions; remote IDA environments must +satisfy the runtime requirements declared in package metadata. +It also uses available Codex and Claude CLIs to compare installed `idac` Agent +Plugin versions with the CLI version. Missing plugins are optional; mismatches or +unavailable plugin inventories produce warnings rather than runtime errors. -For `type declare` internals: - -- keep `DeclarationChunk` as the internal representation through parse, diagnostics, and bisect flows -- convert to plain dicts only at boundaries that actually need serialized output -- prefer small helpers for optional bisect / trial-parse control flow instead of growing `_type_declare` inline - -For preview behavior: - -- preview mode performs a real mutation under IDA undo, then captures the before/after state and undoes it -- remember that previewed mutating operations apply the change before undoing it -- if preview formatting changes, prefer putting defaults on `PreviewSpec` rather than duplicating fallback logic in the wrapper - -For output and transport boundaries: - -- `src/idac/cli2/renderers/__init__.py` owns text rendering -- `src/idac/transport/schema.py` owns wire request/response schema definitions - -### Fixture binaries - -The repo includes fixture binaries and IDA databases under [fixtures](../fixtures). -The committed fixture binaries and `.i64` databases are Mach-O ARM64 artifacts generated on macOS/Apple Silicon. +## Testing -Representative class-recovery fixture artifacts: +The suite has two main layers, split by the `requires_ida` marker: -- source: [fixtures/src/handler_hierarchy.cpp](../fixtures/src/handler_hierarchy.cpp) -- importable type header: [fixtures/src/handler_hierarchy.hpp](../fixtures/src/handler_hierarchy.hpp) -- build helper: [fixtures/scripts/build_handler_hierarchy.sh](../fixtures/scripts/build_handler_hierarchy.sh) -- database helper: [fixtures/scripts/make_handler_hierarchy_idbs.sh](../fixtures/scripts/make_handler_hierarchy_idbs.sh) +- `make test-unit` runs parser, client-session, remote-bundle, renderer, and helper + tests without IDA. +- `make test-integration` drives real Nexus-managed IDA 9.4+ workers against copied + fixture databases. The tests are skipped when a supported licensed IDA installation + is unavailable. +- `make coverage` runs the full suite with line coverage. Work executed inside IDA is + outside the local Python coverage process, so use the integration assertions—not + the local percentage—to judge the remote operation bundle. -### IDA batch runs and fixture regeneration +Start with focused tests for the surface being changed, then run the broader suite: -When regenerating fixture `.i64` files or running any workflow that invokes `idat`, avoid using the live `~/.idapro` directly. A globally installed `idac_bridge_plugin.py` can import the current checkout and break batch analysis if the repo is mid-change. +```bash +uv run pytest -q tests/test_nexus_session.py tests/test_remote_ops.py +uv run pytest -q tests/test_ops_helpers.py tests/test_preview.py +uv run pytest -q -m requires_ida +uv run pytest -q +``` -Use an isolated `IDAUSR` that keeps the license/config files but leaves `plugins/` empty: +## Nexus execution structure + +`src/idac/nexus.py` is the sole client-side IDA integration boundary. It uses public +ida-nexus APIs for discovery, selection, database handles, leases, analysis waits, +execution, and saves. Keep these invariants when changing it: + +- `-c/--context` is a filesystem path to an `.i64` or input binary; + `--instance` is an exact Nexus discovery record ID. +- A top-level invocation owns one lazily opened handle. Batch and preview children reuse + it and cannot set their own target or timeout options. +- Headless opens request auto-analysis and wait for completion; attaching to a live GUI + does not force analysis. The default headless analysis wait is finite (120 seconds), + and an explicit `--timeout` overrides it. Analysis or compatibility failure retires + the newly opened headless worker with `save=False`. +- Headless leases use a 300-second keepalive. Successful headless mutations are saved + before another remote request or release; GUI saves remain explicit. +- Selection, timeout, disconnect, remote, and version failures propagate without retry, + target switching, or alternate execution paths. +- Retiring poisoned headless state may briefly retry only Nexus `instance_busy` or + `instance_shared` responses on the same exact worker while its prior lease callback + finishes. This cleanup never redispatches the interrupted idac operation. +- A failed mutating dispatch or arbitrary Python execution has an uncertain IDB outcome. + Remote failures are treated as dirty so headless finalization attempts one checkpoint; + a failed headless save poisons the session and retires its exact worker with + `save=False`, preventing an implicit retry at keepalive expiry. A failed preview or + locally interrupted request follows the same discard path after prior completed steps + have been checkpointed. +- Import only supported ida-nexus exports. Do not depend on its private registry, + authentication, or HTTP implementation. + +`src/idac/remote_ops.py` is uploaded as one self-contained `RemoteModule` source asset. +IDA does not import the local `idac` package. Keep request state and IDA objects out of +module globals, accept and return JSON-native values, and keep the source comfortably +below Nexus's 4 MiB upload ceiling. + +The registry in the remote source owns IDA handlers, mutation flags, and preview +support. Keep its exported operation set in parity with the client operation list and +renderer coverage. Preview applies a real mutation, reads the temporary state, and +restores it through IDA undo or an operation-specific rollback within one remote execution. + +For type declaration internals, keep `DeclarationChunk` through parsing, diagnosis, +and bisect flows; convert it to plain dictionaries only at serialization boundaries. +For reusable remote behavior, add a meaningful helper only when it reduces genuine +duplication or complexity. Keep one-off operations inline instead of introducing tiny +forwarding functions. + +## Fixture binaries + +The repository includes fixture binaries and `.i64` databases under +[fixtures](../fixtures). The committed class-recovery artifacts are Mach-O ARM64 files +generated on macOS/Apple Silicon. + +Representative sources and helpers: + +- [fixtures/src/handler_hierarchy.cpp](../fixtures/src/handler_hierarchy.cpp) +- [fixtures/src/handler_hierarchy.hpp](../fixtures/src/handler_hierarchy.hpp) +- [fixtures/scripts/build_handler_hierarchy.sh](../fixtures/scripts/build_handler_hierarchy.sh) +- [fixtures/scripts/make_handler_hierarchy_idbs.sh](../fixtures/scripts/make_handler_hierarchy_idbs.sh) + +### IDA isolation + +Fixture generation and tests that start IDA must not use the live `~/.idapro` profile. +Create an isolated `IDAUSR`, copy only the license/configuration files that IDA needs, +and leave its integration directory empty unless the test explicitly installs Nexus: ```bash tmpdir=$(mktemp -d /tmp/idac-test-idapro.XXXXXX) @@ -116,34 +135,68 @@ mkdir -p "$tmpdir/plugins" export IDAUSR="$tmpdir" ``` -Then regenerate the committed class fixture artifacts: +Then regenerate the class fixture artifacts: ```bash bash fixtures/scripts/build_handler_hierarchy.sh bash fixtures/scripts/make_handler_hierarchy_idbs.sh ``` -If `idat` logs mention plugin import errors from `~/.idapro/plugins/idac_bridge_plugin.py`, rerun with the isolated `IDAUSR` before assuming the fixture or CLI code is at fault. +The shared pytest fixture creates equivalent per-test isolation. When a live-GUI test +needs Nexus installed, point `IDAUSR` at that isolated profile before running +`idac setup gui`. + +## Live Nexus GUI tests -### Test coverage +Optional live desktop coverage is skipped by default. Start IDA 9.4+ with the matching +Nexus component loaded, use `idac targets list --json` to obtain the exact record ID +of a disposable database, then run: -Current suite covers: +```bash +IDAC_RUN_NEXUS_GUI_TESTS=1 \ +IDAC_NEXUS_GUI_RECORD_ID='' \ +uv run pytest -q -m nexus_gui_live +``` -- GUI transport -- `idalib` backend reads -- type/struct/enum commands -- class commands and `type declare --replace` -- local variable commands -- preview behavior -- timeout handling -- `doctor` -- `ctree` -- `reanalyze` +This test previews a comment mutation, commits it without saving, proves a copied on-disk +database is unchanged, reattaches to prove the edit remains live in GUI memory, explicitly +saves it, checks that copy in a fresh headless worker, then restores and saves the original +comment. It never auto-selects a GUI target. Because an explicit +database save also checkpoints unrelated pending GUI edits, do not point it at a working +database. -### Live GUI tests +The normal integration suite uses Nexus-managed headless workers and does not require a +desktop session. -Optional live GUI transport coverage uses a real Unix socket bridge service and is skipped by default: +## Continuous integration + +Pull requests run lint and the no-IDA unit suite. Merge-queue runs additionally install +each IDA version in the CI matrix and execute the complete Nexus integration suite. +The matrix starts at IDA 9.4, the minimum required by Nexus. Add newer versions and +their installer IDs as they become available. Refresh the GUI component +with `idac setup gui` when updating Nexus, and validate changes to the resolved stack +with the integration suite. + +## Releases + +Merge feature changes into `main` before preparing their release. Versions are +managed by GitHub Actions; do not edit `pyproject.toml`, `uv.lock`, or the Agent +Plugin version manually to prepare a release. ```bash -IDAC_RUN_LIVE_GUI_TESTS=1 uv run pytest -q -m gui_live tests/test_gui_transport_live.py +gh workflow run prepare-release.yml --ref main ``` + +This bumps the minor version by default; add `-f version=X.Y.Z` to choose an +explicit version. The workflow updates the Python package and Agent Plugin +versions, uses Codex to turn the `Unreleased` changelog into a versioned entry, +creates a `release/vX.Y.Z` branch, and opens its PR. Review the generated changelog and +merge that PR through the merge queue. The publish workflow then tags the merge +commit, creates the GitHub release from that changelog section, and publishes to +PyPI. Do not create release tags or publish manually. + +Changelog generation uses the +[Codex GitHub Action](https://learn.chatgpt.com/docs/github-action) with the +`OPENAI_CODEX_API_KEY` Actions secret. Make that secret available to the repository +before preparing a release. Codex edits `CHANGELOG.md` in the checkout; review the +generated entry in the release PR. diff --git a/fixtures/README.md b/fixtures/README.md index 967c8ea..7dca42a 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -6,7 +6,7 @@ Current committed fixture artifacts are Mach-O ARM64 binaries and `.i64` databas ### `tiny` -Small C fixture used for lightweight CLI, transport, and backend checks. +Small C fixture used for lightweight CLI and Nexus integration checks. - source: `src/tiny.c` - build helper: `scripts/build_tiny.sh` diff --git a/fixtures/idb/handler_hierarchy.i64 b/fixtures/idb/handler_hierarchy.i64 index 6868705..42bc2fa 100644 Binary files a/fixtures/idb/handler_hierarchy.i64 and b/fixtures/idb/handler_hierarchy.i64 differ diff --git a/fixtures/scripts/make_handler_hierarchy_idbs.sh b/fixtures/scripts/make_handler_hierarchy_idbs.sh index 1acbe03..c79c0a5 100644 --- a/fixtures/scripts/make_handler_hierarchy_idbs.sh +++ b/fixtures/scripts/make_handler_hierarchy_idbs.sh @@ -4,7 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BIN_DIR="${FIXTURES_BUILD_DIR:-$ROOT_DIR/fixtures/build}" IDB_DIR="${FIXTURES_IDB_DIR:-$ROOT_DIR/fixtures/idb}" -IDA_BIN="${IDA_IDAT:-/Applications/IDA Professional 9.3.app/Contents/MacOS/idat}" +IDA_BIN="${IDA_IDAT:-/Applications/IDA Professional 9.4.app/Contents/MacOS/idat}" mkdir -p "$IDB_DIR" diff --git a/fixtures/scripts/make_idbs.sh b/fixtures/scripts/make_idbs.sh index 376e629..03f35ea 100755 --- a/fixtures/scripts/make_idbs.sh +++ b/fixtures/scripts/make_idbs.sh @@ -4,7 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BIN_DIR="${FIXTURES_BUILD_DIR:-$ROOT_DIR/fixtures/build}" IDB_DIR="${FIXTURES_IDB_DIR:-$ROOT_DIR/fixtures/idb}" -IDA_BIN="${IDA_IDAT:-/Applications/IDA Professional 9.3.app/Contents/MacOS/idat}" +IDA_BIN="${IDA_IDAT:-/Applications/IDA Professional 9.4.app/Contents/MacOS/idat}" mkdir -p "$IDB_DIR" diff --git a/plugins/idac/plugin.json b/plugins/idac/plugin.json new file mode 100644 index 0000000..013fb33 --- /dev/null +++ b/plugins/idac/plugin.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "idac", + "version": "0.19.1", + "description": "Drive IDA Pro through the idac CLI for decompilation, inspection, type recovery, and database mutation workflows", + "author": { + "name": "Trail of Bits", + "email": "opensource@trailofbits.com", + "url": "https://github.com/trailofbits" + }, + "homepage": "https://github.com/trailofbits/idac", + "repository": "https://github.com/trailofbits/idac", + "license": "Apache-2.0", + "keywords": [ + "ida-pro", + "reverse-engineering", + "binary-analysis" + ] +} diff --git a/plugins/idac/skills/idac/SKILL.md b/plugins/idac/skills/idac/SKILL.md new file mode 100644 index 0000000..e384ba3 --- /dev/null +++ b/plugins/idac/skills/idac/SKILL.md @@ -0,0 +1,94 @@ +--- +name: idac +description: Analyze binaries and inspect or edit IDA Pro databases with the local idac CLI. Use for IDA decompilation, xrefs, type or class recovery, and database edits, rather than general RE explanations or source-only work. +--- + +# idac + +Use `idac` against a live IDA GUI, an existing `.i64`, or a binary IDA can open. +Every operation uses ida-nexus. Prefer first-class commands; use `idac py exec` +when they cannot express the required IDA operation. + +Follow the user's target, scope, and deliverables. Explicit user instructions take +precedence over this skill's defaults. Inspection requests stay read-only; recovery, +annotations, and workspace creation belong only where the task calls for them. + +## Operating defaults + +- Work from the binary and database evidence. Consult the web or external source + trees only when requested or when external correlation is part of the task. +- Keep the selected target on subsequent commands: `-c/--context PATH` for a + database or binary, or `--instance RECORD_ID` for an exact READY row from + `targets list --json`. Omit both only when exactly one READY instance exists. + Put target and timeout options on the wrapper for `batch` and `preview`. +- Run one `idac` command at a time per target. Write function/type lists and + family reads to `--out` even with filters; a filtered list can exceed the inline + limit. Use `decompilemany` for several functions and ordered `batch` files for + related edits and readbacks in one session. +- During type or prototype recovery, use `decompile --f5` or `decompilemany --f5` + (`--no-cache`) so readback reflects current types. +- For edits, read [mutation workflows](references/workflows.md). Inspect the + current prototype before setting it, declare support types before dependent + prototypes, and reanalyze after meaningful type or prototype changes before + calibrating local selectors from fresh JSON. Preview parser-risky type changes + or uncertain selectors and inspect the artifact before committing. Confirmed + symbol renames, comments, and parameter-name edits with `--preserve-cc` can be + committed directly with readback. Continue authorized edits through verification. +- Run `idac doctor` when the runtime stack is uncertain. For a Nexus failure, + diagnose the reported cause using [troubleshooting](references/troubleshooting.md). + Read affected state before retrying a mutation with an uncertain outcome. + +## Read what the task needs + +Common command paths: + +```text +function list FILTER --demangle --json --out functions.json +function prototype show FUNC +function prototype set FUNC --preserve-cc --decl 'RETURN CONVENTION FUNC(ARGS);' +misc rename FUNC NAME +misc reanalyze FUNC +misc reanalyze START --end END +comment show FUNC --scope function +comment set FUNC TEXT --scope function +type show NAME +type check --decl-file types.h +type declare --replace --decl-file types.h +``` + +For parameter names or a return type, edit the declaration from `function +prototype show` and use `set`, preserving the other types, parameters, and calling +convention. Add `--preserve-cc` to keep IDA's existing calling convention even when +its parser normalizes the declaration to another convention. This does not require +an IDAPython script. Use the known paths directly; +consult targeted `--help` for unfamiliar flags or subcommands. Use `--full-help` +when the command family is unclear. Read only the relevant reference sections. + +Reanalysis requires a function name or address. Use `misc reanalyze START --end END` +for a bounded address range. + +| Task | Reference | +|------|-----------| +| Command grammar, reads, filters, output formats | [CLI quick reference](references/cli.md) | +| Select a GUI or headless target; understand saves and runtime requirements | [Targets and backends](references/targets-and-backends.md) | +| Prototypes, locals, type edits, annotations, preview, batch, IDAPython | [Mutation workflows](references/workflows.md) | +| Recover C++ layouts, inheritance, or virtual targets | [Class recovery](references/class-recovery.md) | +| Write IDA-compatible C++ class or vtable declarations | [C++ type details](references/ida-cpp-type-details.md) | +| Custom calling conventions or IDA declaration keywords | [IDA type syntax](references/ida-set-types.md) | +| Shifted pointers, scattered arguments, or display annotations | [Advanced type annotations](references/ida-advanced-type-annotations.md) | +| Discovery, import, preview, or stale-readback failures | [Troubleshooting](references/troubleshooting.md) | + +## Artifacts and completion + +Use the user's existing artifact locations and workspace conventions. When a +recovery workspace is requested or useful for sustained work, `idac workspace init` +provides headers, audit notes, and temporary artifact directories; follow its +installed conventions. Keep existing audit logs append-only. Optional +[templates](references/templates/README.md) cover prototype and local-edit passes. + +For analysis, ground findings in function names or addresses and state unresolved +uncertainty. For edits, verify the affected database state and fresh pseudocode +where relevant. Report artifact paths and whether changes were saved: successful +headless mutations are checkpointed automatically; live GUI saves are explicit. +Stop when the requested result is supported by evidence; cosmetic pseudocode +cleanup is useful only when it helps that result. diff --git a/plugins/idac/skills/idac/agents/openai.yaml b/plugins/idac/skills/idac/agents/openai.yaml new file mode 100644 index 0000000..ff6e351 --- /dev/null +++ b/plugins/idac/skills/idac/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "idac" + short_description: "Analyze binaries and edit IDA databases with idac" + default_prompt: "Use $idac to inspect this IDA target and complete the requested analysis or database edits with verified readback." + +policy: + allow_implicit_invocation: true diff --git a/plugins/idac/skills/idac/references/class-recovery.md b/plugins/idac/skills/idac/references/class-recovery.md new file mode 100644 index 0000000..6b904f1 --- /dev/null +++ b/plugins/idac/skills/idac/references/class-recovery.md @@ -0,0 +1,161 @@ +# Class Recovery + +Read this for C++ layouts, inheritance, vtables, and virtual-target prototypes. +Match the work to the request: a hierarchy report can use symbols and RTTI without +importing types or renaming locals. For database edits, use the +[mutation workflow](workflows.md#safe-mutation-loop). + +## Contents + +- [Discover the family](#discover-the-family) +- [Recover a layout](#recover-a-layout) +- [Vtable guidance](#vtable-guidance) +- [Apply types to runtime functions](#apply-types-to-runtime-functions) +- [Verification and completion](#verification-and-completion) + +## Discover the family + +Start with the strongest available evidence: demangled symbols, RTTI, constructor +vtable stores, existing local types, and field accesses. Scope discovery to the +requested family; widen to callers or adjacent classes when evidence requires it. + +```bash +idac function list "Example" --demangle --json --out "family.functions.json" +idac type list "Example" --json --out "family.types.json" +idac type class candidates "Example" --json --out "class_candidates.json" +``` + +Choose the reads that address a gap: + +- `type list` finds local types even when they are opaque structs. +- `type class candidates` finds local types and symbol evidence before concrete + class layouts exist. Its rows mix `local_type`, `symbol`, `vtable_symbol`, + `typeinfo_symbol`, `typeinfo_name_symbol`, and `function_symbol`; use `--kind` + to select a category. Skip it when symbols and RTTI already identify the family. +- `type class show` and `type class vtable` inspect materialized local layouts. + If a type exists but is not class-materialized, inspect `type show` and runtime + evidence; repeating class queries on the opaque type will not recover it. +- Use addresses, mangled names, or full signatures for overloaded functions. + Candidate, type, and function JSON lists are top-level arrays. + +Decompile representative constructors, destructors, accessors, or parsers with +`--f5` until the relevant offsets and relationships are justified. For a broad +family capture, use `decompilemany` and its manifest as described in +[broad discovery](workflows.md#broad-discovery-defaults). Avoid a family-wide dump +when a few representatives answer the question. + +## Recover a layout + +When the task requires recovered declarations, read +[C++ type details](ida-cpp-type-details.md) for IDA parser and vtable conventions. +Declare support structs and directly referenced neighbors before dependent classes. +Reuse existing, evidence-consistent layouts and inheritance instead of recovering +their fields again. Read only the declarations needed for the requested family. +For local-type imports, flatten namespace names consistently in the header (for +example `Group__Item`) and record the mapping; avoid namespace blocks in the default +parser. Keep original demangled names in the evidence report. + +Start with minimal plain `struct` declarations: observed vtable pointer, directly +evidenced fields, and blob padding for unknown regions. Preserve existing opaque +types unless replacing them is needed for the requested layout; record a deliberate +size-only replacement as a loss of type detail. + +- Use neutral offset-based names such as `field_8`; mark inferred semantics in + notes or provisional names, such as `count_maybe`. +- Use one byte array for a contiguous unknown region instead of guessed scalars. +- Add `__attribute__((packed))` only when observed offsets prove packed layout. + Keep real gaps explicit. `__cppobj` is an optional refinement whose effect on + layout must be checked. +- A derived class may reuse base tail padding. Field accesses and constructor + evidence take precedence over assuming derived fields start after base size. +- Keep a derived class empty unless field accesses or constructor evidence prove + additional state. Estimate embedded opaque-member sizes from neighboring + offsets, then corroborate them in constructors. + +For a recovered header replacing existing types, preview the replacement itself: + +```bash +idac preview -o "classes.preview.json" type declare --replace --decl-file "recovered_classes.h" +jq . classes.preview.json +``` + +After inspecting the intended layout changes, commit and read back: + +```bash +idac type declare --replace --decl-file "recovered_classes.h" +idac type deps "ExampleDerived" +idac type class show "ExampleDerived" +idac type class fields "ExampleDerived" --derived-only +idac type class hierarchy "ExampleBase" +``` + +Keep the selected target on every invocation. Separate imports when a later header +depends on support types; an earlier preview does not retain imported types. +Use [import troubleshooting](troubleshooting.md) for `--bisect`, parser changes +with `--clang`, or namespace flattening with `--alias OLD=NEW`. +For isolated member corrections, use [narrow type edits](workflows.md#narrow-type-edits). + +## Vtable guidance + +Add vtable declarations only with virtual-dispatch evidence: a constructor store, +runtime vtable symbol, or confirmed `__vftable` member. For class-helper compatibility, +name the callable-slot type `ClassName_vtbl`, declare it as +`struct /*VFT*/ ClassName_vtbl`, and attach it as `ClassName_vtbl *__vftable;`. + +```bash +idac type class vtable "ExampleDerived" --runtime +``` + +This combines local slot types and runtime targets once a class is materialized. +Before then, `type class candidates --kind vtable_symbol` can locate a symbol; +raw slot reads need `py exec` if no first-class command covers them. A missing +runtime symbol limits that lookup rather than disproving the class family. + +For Itanium-style ABIs, distinguish emitted vtable data from its address point: +header words can precede the callable slots. Keep those words in a separate scratch +layout instead of treating them as functions. For multiple inheritance, use the +IDA `ClassName_XXXX_vtbl` convention for secondary-base overrides. Offset and +declaration examples are in [C++ type details](ida-cpp-type-details.md). +Confirm the target ABI from database metadata and binary evidence before relying +on its layout rules. If the compiler ID itself is needed, use the verified +[IDAPython recipe](workflows.md#idapython-escape-hatch). + +## Apply types to runtime functions + +Local vtable-slot types and runtime function prototypes are separate. Apply +evidence-backed signatures to the actual virtual targets when the task requires +better caller decompilation. An inherited slot can retain a base `this` type while +the implementation belongs to a derived class; use the implementation's evidence +when choosing its prototype. + +Read the current signature with `function prototype show`, validate custom +conventions with `function prototype check` when needed, then preview +and commit as described in [the mutation loop](workflows.md#safe-mutation-loop). +Use `prototype set --preserve-cc` when retyping a factory return or parameter while +retaining the existing calling convention. +Reanalyze affected functions and callers before fresh `--f5` readback. Calibrate +[local cleanup](workflows.md#selector-calibration) only after that phase. + +Destructor bodies often restore a vtable pointer and then lose precise derived +type propagation in Hex-Rays. A function-local retype may help after prototype +cleanup and reanalysis; avoid forcing broader type changes solely to improve +presentation. + +## Verification and completion + +Verify the aspects the request depends on: + +- Object size, bases, and important field offsets agree with binary evidence; + `type class fields --derived-only` distinguishes subclass fields. +- Local vtable slots and runtime targets agree where runtime evidence is available. +- Changed runtime prototypes have the expected `this` type in fresh pseudocode, + and an affected caller reflects the intended propagation. +- Committed local plans match fresh locals readback. +- Reports or recovered headers identify supporting addresses and unresolved + hypotheses; workspace audit notes record actual changes and failures. + +Choose representative readbacks based on the changes, such as a constructor, an +override, and an affected caller. Stop when the requested layout or relationships +are supported and relevant callers are readable. Large-stack AArch64 prologue +noise, cleanup casts, and unnamed spill temporaries warrant more work only when +they obscure the requested result. diff --git a/plugins/idac/skills/idac/references/cli.md b/plugins/idac/skills/idac/references/cli.md new file mode 100644 index 0000000..3e45dfd --- /dev/null +++ b/plugins/idac/skills/idac/references/cli.md @@ -0,0 +1,214 @@ +# idac Quick Reference + +Use targeted `idac --help` for the installed command's exact grammar. +This reference highlights useful reads and non-obvious CLI behavior. For persistent +edits, follow [mutation workflows](workflows.md#safe-mutation-loop). + +## Conventions + +- list commands use one optional positional filter such as `NAME_FILTER` or `TYPE_FILTER` +- `--regex` enables regular-expression matching +- matching is case-sensitive by default; `-i` makes it case-insensitive +- `function list` text output includes the containing section, such as `.plt` or `.text` +- `function list --demangle` matches and renders demangled display names +- `function metadata` and JSON `function list` rows include `display_name` when available; JSON `function list` rows also include `section` +- `function list` also accepts `--segment` to scope by segment and `--limit` to cap returned rows; `search bytes` accepts `--limit` too +- function-taking commands can resolve a unique demangled C++ name such as `ExampleClass::method_1`; if multiple functions match, use a mangled name, full signature, or address +- `segment list` lists database segments +- maintenance operations live under `misc`; installation commands live under `setup`; workspace scaffolding lives under `workspace` + +## Context selection + +- `-c/--context PATH` opens or attaches to an `.i64` or input binary through Nexus. +- `--instance RECORD_ID` attaches to one exact READY discovery record from + `targets list`. +- The selectors are mutually exclusive. With neither, exactly one READY Nexus instance + must exist. +- Context options may be placed before the command or on a context-aware subcommand. +- Headless opens wait for analysis and use a five-minute idle keepalive. Successful + headless mutations are checkpointed before the next remote request or lease + release; live GUI saves are explicit. +- Discovery, timeout, connection, and version failures have no fallback or automatic + retry. + +## Common reads + +```bash +idac database show --json +idac function list +idac function list --demangle +idac function list "init|open|close" --demangle --regex -i +idac function list "sub_08041337" +idac function list "sub_.*" --regex +idac function list "sub_08041337" -i +idac segment list +idac segment list "__TEXT|__cstring" --regex +idac function metadata "sub_08041337" +idac function frame "sub_08041337" +idac function stackvars "sub_08041337" +idac function callees "sub_08041337" +idac function callers "sub_08041337" +idac function prototype show "sub_08042010" +idac function locals list "sub_08041337" +idac decompile "sub_08041337" +idac decompile "ExampleClass::method_1" +idac decompile "sub_08041337" -o "/tmp/sub_08041337.txt" +idac decompilemany "sub_08041337" --out-file "/tmp/sub_08041337.c" +idac decompilemany --functions-file "funcs.txt" --out-dir "/tmp/decomp" +idac decompilemany --functions-file "funcs.txt" --out-dir "/tmp/decomp" --disasm --ctree +idac disasm "sub_08041337" +idac disasm --start "0x100000460" --end "0x1000004a0" +idac ctree "sub_08041337" +idac xrefs "sub_08041337" +idac imports +idac search bytes "74 69 6e 79" --segment "__cstring" --timeout 30 +idac search strings "tiny" --segment "__cstring" --timeout 30 +idac type show "ExampleStruct" +idac type deps "ExampleStruct" +idac type check --decl-file "recovered_types.h" --json +idac function prototype check "sub_08041337" --decl-file "sub_08041337_proto.h" --json +idac comment show "sub_08041337" --scope function +idac bookmark list +``` + +`xrefs` is a top-level command; there is no `function xrefs` command. +For broad function discovery, prefer `function list "name1|name2" --regex -i` so IDA filters before rendering. Add `--demangle` when the filter should match demangled display names. Avoid producing a full function list just to pipe it into `rg`; add `--out ` if the filtered result is still too large for inline output. +`search strings` and `search bytes` require both `--timeout` and `--segment`. +String listing reads already-defined strings; `--scan` reads string-like bytes +without defining them. On dyld shared caches, string listing is disabled: use +`--scan` with explicit `--start` / `--end` bounds totaling at most 16 MiB. + +## Comments and bookmarks + +```bash +idac comment set "sub_08041337" "parses the record header" --scope function +idac comment set "0x100000460" "length check before the copy" --scope line +idac comment delete "sub_08041337" --scope function +idac bookmark add "sub_08041337" --comment "entry parser" +idac bookmark set 3 "0x100000460" --comment "length check" +idac bookmark show 3 +idac bookmark delete 3 +``` + +Comment scopes are `line`, `function`, `anterior`, and `posterior`. `--anterior` and +`--posterior` are shorthands for the matching scope, and `--repeatable` selects the +repeatable slot for `line` and `function` comments. `bookmark add` takes the first free +slot; `bookmark set` writes an explicit slot number. + +`comment set`, `comment delete`, and the mutating `bookmark` commands are +preview-capable and batch-safe. Prefer them over local-only notes when a finding belongs +with the address it describes. + +## Struct field and enum member edits + +Edit one member of an existing local type without re-declaring the whole type: + +```bash +idac type struct field set "ExampleStruct" "entry_count" --offset 0x18 --decl "unsigned int" +idac type struct field rename "ExampleStruct" "field_20" "flags" +idac type struct field delete "ExampleStruct" "field_28" +idac type enum member set "ExampleEnum" "EXAMPLE_FLAG_RETRY" --value 0x4 +idac type enum member rename "ExampleEnum" "member_2" "EXAMPLE_FLAG_ASYNC" +idac type enum member delete "ExampleEnum" "member_3" +``` + +- `--offset` is a byte offset. A field already at that exact offset is retyped and, if + needed, renamed; otherwise a new field is added there. +- `type struct field set --decl` accepts bare type text such as `unsigned int` or a full + member declaration such as `unsigned int entry_count;`. Use `--decl-file` for array or + function-pointer members. +- `type enum member set --value` is required and adds the member when it does not exist; + `--mask` applies to bitfield enums. +- All six commands are mutating, preview-capable, and batch-safe, and they default to + JSON output. They return the refreshed struct or enum, so the command output is the + readback. +- Prefer these for small corrections to a type that is already close. Re-import through + `type declare --replace` when the layout changes broadly. + +## Preview + +`function prototype set --preserve-cc` retains the current database calling +convention when editing a return type, parameter type, or parameter name. IDA's +parser can otherwise normalize the convention even when the declaration repeats +the displayed keyword. Use the flag for ABI-preserving edits; omit it for a +deliberate calling-convention change. + +`idac` preview is always a wrapper and always writes JSON or JSONL. +The payload includes `command`, `status`, `before`, `after`, `result`, `readback`, `undo`, `artifacts`, and `stderr`. + +```bash +idac function prototype show "sub_08042010" +idac function prototype check "sub_08042010" --decl "long long __cdecl sub_08042010(long long lhs, long long rhs)" +idac preview -o "/tmp/preview.json" \ + function prototype set "sub_08042010" --decl "long long __cdecl sub_08042010(long long lhs, long long rhs)" +``` + +The wrapper owns the preview artifact. Wrapped commands cannot set `--out`, +`--out-file`, or `--out-dir`; put `--out` on `preview`. Output paths cannot +alias the selected binary/database or a command input file. Insert `--` before the +wrapped command when its first token starts with a dash. + +Preview-capable read-only commands are treated as no-op previews with identical `before` and `after` payloads. Commands not marked preview-capable remain rejected by the wrapper. + +For unfiltered type, struct, and enum lists, the preview or batch wrapper's +`--out` satisfies the artifact requirement. File output checks protect the +resolved Nexus database and original input, even when a GUI database was saved +under another name. + +## Batch + +Batch accepts one subcommand per line, without a leading `idac`. Blank lines and +`#` comments are allowed. Put target and timeout options on the wrapper. Persistent +mutations require wrapper `--out`; mutating children cannot set their own output +paths. Read-only children may write artifacts, whose relative paths resolve from +the batch file's directory. + +Use `--lint --out ` before mutation batches, and `--fail-fast` when later +steps depend on earlier success or local selectors must stop on the first miss. +Keep previews and matching commits in separate runs so you can inspect the preview +journal between them. See [batch workflows](workflows.md#batch) for complete +examples, supported commands, and journal lifecycle. + +## Setup and misc commands + +Installation commands live under `setup` and are rejected from `batch`: + +- `setup gui` — use HCLI to install the GUI plugin matching the installed Nexus + client, with ida-domain resolved from the declared dependency requirement. + +IDA maintenance commands live under `misc`: + +- `misc rename` — rename a function or global symbol. Preview-capable and + batch-safe; use an address when later batch steps must survive the rename. +- `misc reanalyze` — re-run IDA analysis on a function or range. Batch-safe, not preview-capable; place it between type/prototype mutations and local cleanup. Add `--end` for a range instead of a single function. + +Workspace scaffolding lives under `workspace`: + +- `workspace init [DEST]` — create a recovery workspace with `audit/`, `headers/`, + `scripts/`, `prompts/`, and `.idac/tmp/`, with shared `AGENTS.md` guidance for + Claude and Codex. Reuse existing workspace conventions. + `--force` overwrites user-tunable config; use it only when replacing that config + is intended. + +## Output notes + +- terminal output still enforces the inline size limit +- large inline results print a truncated prefix first, then error +- `type declare --clang` uses IDA's clang parser for more complex C/C++ declarations +- `type declare --bisect` isolates the first declaration IDA rejects when a multi-declaration import fails; it reports the failing line range, whether that declaration imports on its own, and any by-value members whose types are still opaque. It needs IDA undo support and is rejected by `type check`. +- `type declare --alias OLD=NEW` rewrites identifiers before import; use it to flatten namespace-qualified names +- `type check` validates the whole header in a temporary type library, resolving + dependencies between declarations and existing local types without importing + or replacing database types. For an import replacing existing layouts, preview + `type declare --replace` directly to inspect the actual replacement. +- `function prototype check` validates a function declaration without applying it +- `type deps NAME` prints an existing type with IDA dependency expansion when available +- `type list`, `type struct list`, and `type enum list` require `--out` when no pattern is given +- for `function locals retype`, `--type` is shorthand for simple type text; use `--decl` or `--decl-file` for a full declaration, such as arrays or function pointers +- `decompile` uses `-o/--out` for a single rendered result; `decompilemany` uses `--out-file` or `--out-dir` for bulk artifacts +- `decompilemany FUNCTION_FILTER` selects functions by name substring; it is not a list of exact functions +- for multiple explicit functions, write one function name or address per line and pass `decompilemany --functions-file ` +- `decompilemany --out-file` writes combined text +- `decompilemany --out-dir` writes one file per function plus `manifest.json` +- `decompilemany --disasm` and `--ctree` require `--out-dir` and add per-function `.asm` and `.ctree` artifacts to the manifest +- long `decompilemany --out-dir` artifact names are shortened with a stable digest; use `manifest.json` `.functions[].address` as the stable exact lookup key, and `.functions[].artifact_path` / `.functions[].artifacts` for file paths diff --git a/src/idac/skills/idac/references/ida-advanced-type-annotations.md b/plugins/idac/skills/idac/references/ida-advanced-type-annotations.md similarity index 97% rename from src/idac/skills/idac/references/ida-advanced-type-annotations.md rename to plugins/idac/skills/idac/references/ida-advanced-type-annotations.md index dc48bfe..db5aa13 100644 --- a/src/idac/skills/idac/references/ida-advanced-type-annotations.md +++ b/plugins/idac/skills/idac/references/ida-advanced-type-annotations.md @@ -4,6 +4,12 @@ IDA extends standard C/C++ type declarations with specialized annotations that p For a complete list of all type system keywords, see [ida-set-types.md](ida-set-types.md). +## Contents + +- [Shifted pointers](#shifted-pointers) +- [Scattered argument locations](#scattered-argument-locations) +- [Data representation annotations](#data-representation-annotations) + ## Shifted Pointers Sometimes in binary code we can encounter a pointer to the middle of a structure. Such pointers usually do not exist in the source code but an optimizing compiler may introduce them to make the code shorter or faster. @@ -74,8 +80,8 @@ Since compilers can use such complex calling conventions, IDA needs some mechani It reads: - **1 byte** at offset **0** of the argument is passed in **byte 0 of RDI** -- **2 bytes** at offset **2** of the argument are passed in **bytes 1–2 of RDI** -- **1 byte** at offset **4** of the argument is passed in **byte 3 of RDI** +- **2 bytes** at offset **2** of the argument are passed in **bytes 2–3 of RDI** +- **1 byte** at offset **4** of the argument is passed in **byte 4 of RDI** - **4 bytes** at offset **8** of the argument are passed starting from **byte 0 of RSI** In other words, the following syntax is used: @@ -315,7 +321,7 @@ The value 2 will be represented as `mystruct.d-1` __custom(dtid, fid) ``` -where dtid is the name of a custom data type and fid is the name of a custom data format. The custom type and format must be registered by a plugin beforehand, at the database opening time. Otherwise, custom data type and format ids will be displayed instead of names. +where dtid is the name of a custom data type and fid is the name of a custom data format. The custom type and format must be registered by an IDA extension before the database is loaded. Otherwise, custom data type and format ids will be displayed instead of names. ### Data representation: tabular form diff --git a/src/idac/skills/idac/references/ida-cpp-type-details.md b/plugins/idac/skills/idac/references/ida-cpp-type-details.md similarity index 94% rename from src/idac/skills/idac/references/ida-cpp-type-details.md rename to plugins/idac/skills/idac/references/ida-cpp-type-details.md index 6e16fbd..79667b7 100644 --- a/src/idac/skills/idac/references/ida-cpp-type-details.md +++ b/plugins/idac/skills/idac/references/ida-cpp-type-details.md @@ -1,6 +1,6 @@ # C++ Type Details -IDA can parse and handle simple C++ class declarations. It cannot parse templates and other complex constructs but simple standard cases can be parsed. +IDA's default type parser can handle simple C++ class declarations but not templates and some other complex constructs. For `idac type declare`, use `--clang` when the default parser rejects template-heavy or newer C++ syntax. For `idac type declare`, treat the forms in this file as IDA-specific details, not as the mandatory first pass. In practice, a minimal plain-`struct` import with `ClassName_vtbl` and `__vftable` is often the safest starting point; add `__cppobj` and other refinements only after the minimal form imports cleanly. @@ -112,4 +112,4 @@ That means a local IDA type such as `ClassName_vtbl` should still model the call }; ``` -Use a wrapper like `ClassName_vtbl_layout` when the apparent vtable base looks shifted by the ABI header. On common 64-bit Itanium-style layouts, the callable slots then begin at `+0x10` from the raw symbol base because `offset_to_top` and `typeinfo` occupy the first two machine words. The address actually written into an object's `__vftable` points at the address point, not necessarily at the beginning of the emitted symbol. \ No newline at end of file +Use a wrapper like `ClassName_vtbl_layout` when the apparent vtable base looks shifted by the ABI header. On common 64-bit Itanium-style layouts, the callable slots then begin at `+0x10` from the raw symbol base because `offset_to_top` and `typeinfo` occupy the first two machine words. The address actually written into an object's `__vftable` points at the address point, not necessarily at the beginning of the emitted symbol. diff --git a/src/idac/skills/idac/references/ida-set-types.md b/plugins/idac/skills/idac/references/ida-set-types.md similarity index 100% rename from src/idac/skills/idac/references/ida-set-types.md rename to plugins/idac/skills/idac/references/ida-set-types.md diff --git a/plugins/idac/skills/idac/references/targets-and-backends.md b/plugins/idac/skills/idac/references/targets-and-backends.md new file mode 100644 index 0000000..1502314 --- /dev/null +++ b/plugins/idac/skills/idac/references/targets-and-backends.md @@ -0,0 +1,137 @@ +# Nexus Targets and Contexts + +Read this when choosing a live GUI or headless context, opening a binary, or resolving +Nexus discovery state. + +Every `idac` operation uses the public ida-nexus API. The supported stack is Python +3.11+, IDA 9.4+, `ida-nexus>=0.13.2`, and `ida-domain>=0.5.1`. +There is no alternate execution path when discovery, startup, analysis, or execution +fails. + +## Select by path + +Use `-c/--context PATH` with an `.i64` database or a binary that IDA can open. +Pass the path directly; legacy `db:` locators are no longer accepted: + +```bash +idac database show -c sample.i64 --json +idac decompile "sub_08041337" -c sample.i64 --f5 +idac database show -c /path/to/firmware.bin --json +idac decompile "main" -c /path/to/firmware.bin --f5 +``` + +Nexus resolves the path to a matching live GUI database when available, otherwise to a +matching managed worker, and otherwise starts a managed headless worker. Binary input +uses the corresponding `.i64` database identity. Only `.i64` database files are +accepted. + +Headless starts enable auto-analysis and wait for completion before the requested +operation runs. This can take a long time for a first import, so choose an explicit, +deliberate `--timeout` when the Nexus defaults may be too short. Live GUI attachment +uses the current analysis state and does not force analysis. + +For source-level entry behavior, prefer `main_ea` from `database show --json`. If IDA +cannot determine `main`, use `start_ea`, `entry_ea`, or an address from +`function list --json`. If a raw input requires an interactive loader or architecture +choice, import it in IDA first, make the choice there, and save an `.i64`. + +## Select a running instance + +Exact Nexus record IDs replace `pid:`, `module:`, and bare module-name selectors. +List discovery records, then pass the exact `record_id`: + +```bash +idac targets list --json +idac decompile "sub_08041337" --instance "" +``` + +`--instance` selects one READY record exactly; it does not perform fuzzy matching. +`targets list` reports `record_id`, `state`, `detail`, `backend`, `pid`, `idb_path`, +`exe_path`, `managed`, and `started_at`. The backend field is descriptive; users do not +choose it independently of the record. + +If an exact record is missing or not READY, the command fails and reports discovery +state. It never attaches to a different record. + +## Omitted context + +With neither `-c` nor `--instance`, `idac` attaches only when discovery returns exactly +one READY Nexus instance: + +```bash +idac decompile "sub_08041337" +``` + +Zero READY instances is an error. Multiple READY instances is also an error; select one +with `--instance` or select a database/input with `-c`. + +`-c` and `--instance` are mutually exclusive. Either can appear before the command or +on a context-aware subcommand; a command-local option overrides an inherited root +option. `--timeout` supplies an explicit deadline for Nexus startup, headless analysis, +compatibility checks, and remote operation execution. It does not bound database save +or session finalization. Without it, remote execution receives no explicit idac +deadline; headless analysis alone falls back to 120 seconds. + +## Saves and worker lifetime + +One top-level invocation owns one Nexus lease. `batch`, previews, and +`decompilemany` reuse that context for all child work. + +Selecting a path opens or attaches automatically; there are no `database open` +or `database close` commands. `database save` checkpoints the selected database +and accepts no destination path. + +`batch` and `preview` own the target and timeout for that lease. Put `-c`, +`--instance`, and `--timeout` on the wrapper; child commands that specify any of +those options are rejected. The wrapper timeout is inherited for child validation, +including commands that require an explicit timeout. + +- A released managed headless worker remains available for five idle minutes. +- A successful headless mutation is checkpointed before the next remote request or + before the lease is released. If a later batch step fails or is interrupted, + earlier successful mutations remain on disk. +- A successful preview is undone and does not mark the database dirty. If preview + execution or rollback raises, idac poisons the shared session and shuts down the + headless worker with `save=False`; it never saves uncertain preview-only state. +- Live GUI mutations stay in the current IDA session without an automatic save. Run + `idac database save --instance ""` or select its path explicitly when a + checkpoint is desired. +- A failed GUI preview or interrupted GUI request cannot be discarded by terminating + the desktop session. idac releases without saving and reports that the in-memory + state is uncertain; inspect or undo it in IDA before an explicit save. +- A timeout or disconnect is reported without retrying an operation whose outcome may + be uncertain. +- A failed headless save is never retried, including by the worker's later idle + shutdown. idac poisons and discards that worker; because the failure may have arrived + after disk I/O, reread the database from a fresh worker before deciding what persisted. +- Ctrl-C exits 130 without a traceback. When it interrupts an in-flight headless + request, idac discards and retires that uncertain worker before returning; rerun the + command deliberately if the interrupted change is still wanted. Cleanup stays on the + exact worker and may wait through transient lease-release state; it never reruns the + interrupted operation or selects another target. +- A failed or interrupted mutating operation, including arbitrary `py exec`, may have + changed IDA before the error arrived. idac does not retry it. Remote failures such as + a Nexus timeout are checkpointed once for headless sessions; a local Ctrl-C follows + the discard rule above. Reread the affected state before continuing. + +## Setup and diagnosis + +Inspect the stack and discover targets with: + +```bash +idac doctor +idac targets list --json +``` + +When GUI installation or repair is part of the task, use `idac setup gui`. +It uses HCLI to install the GUI release matching the installed Nexus +client and passes the declared ida-domain requirement to the installer. Restart +IDA or load the installed Nexus component as required by IDA, then rerun discovery. +`doctor` reports local package versions, checks HCLI's configured IDA version +without starting IDA, and checks the independently installed GUI +component and READY instances against the declared runtime requirements. Nexus +discovery enforces protocol compatibility. +When Codex or Claude is available on `PATH`, `doctor` also checks installed `idac` +Agent Plugin versions against the CLI version. Update the CLI or plugin if they +do not match; this optional guidance check produces a warning, not a runtime error. +Each agent inventory has a two-second default timeout, overridden by `--timeout`. diff --git a/plugins/idac/skills/idac/references/templates/README.md b/plugins/idac/skills/idac/references/templates/README.md new file mode 100644 index 0000000..89b8aca --- /dev/null +++ b/plugins/idac/skills/idac/references/templates/README.md @@ -0,0 +1,19 @@ +# Template Files + +These templates are generic starting points for common `idac` mutation passes. + +- `prototype-preview.idac` and `prototype-pass.idac` + - Use for a small cluster of related prototype edits after support types already exist. + - Lint and run the preview file with `--fail-fast`, inspect every before/after result in its journal, then lint and run the commit file with `--fail-fast`. +- `rename-preview.idac` and `rename-pass.idac` + - Use with `locals-plan.json` for coordinated local renames after reanalysis and a fresh locals dump. + - Replace every sample selector in the plan, lint and run the preview with `--fail-fast`, inspect the full before/after local lists, then lint and run the commit with `--fail-fast` so the pass stops on the first miss. +- `locals-plan.json` + - Starting point shared by the `function locals apply` preview and commit files. + - Copy exact `index` or `local_id` selectors from the fresh locals JSON; do not reuse the sample indices blindly. +- `checkpoint-note.md` + - Use for per-target `audit/-recovery.md` entries that separate confirmed changes, failures, open questions, and next steps. +- `locals-jq-snippets.sh` + - Use for inspecting the wrapped `function locals list --json --out ...` artifact shape without rediscovering the `jq` filters. + +These are examples, not canonical declarations. In the `.idac` and JSON files, replace the placeholder types, addresses, selectors, and names with evidence from the current target. `checkpoint-note.md` is a fill-in skeleton: replace every `{{...}}` slot and delete sections with nothing to report. diff --git a/src/idac/skills/idac/references/templates/checkpoint-note.md b/plugins/idac/skills/idac/references/templates/checkpoint-note.md similarity index 100% rename from src/idac/skills/idac/references/templates/checkpoint-note.md rename to plugins/idac/skills/idac/references/templates/checkpoint-note.md diff --git a/src/idac/skills/idac/references/templates/locals-jq-snippets.sh b/plugins/idac/skills/idac/references/templates/locals-jq-snippets.sh similarity index 100% rename from src/idac/skills/idac/references/templates/locals-jq-snippets.sh rename to plugins/idac/skills/idac/references/templates/locals-jq-snippets.sh diff --git a/plugins/idac/skills/idac/references/templates/locals-plan.json b/plugins/idac/skills/idac/references/templates/locals-plan.json new file mode 100644 index 0000000..f26e51d --- /dev/null +++ b/plugins/idac/skills/idac/references/templates/locals-plan.json @@ -0,0 +1,6 @@ +[ + {"index": 5, "rename": "header_size"}, + {"index": 6, "rename": "record_type"}, + {"index": 7, "rename": "entry_count"}, + {"index": 12, "rename": "parse_status"} +] diff --git a/plugins/idac/skills/idac/references/templates/prototype-pass.idac b/plugins/idac/skills/idac/references/templates/prototype-pass.idac new file mode 100644 index 0000000..504fe7d --- /dev/null +++ b/plugins/idac/skills/idac/references/templates/prototype-pass.idac @@ -0,0 +1,16 @@ +# Generic prototype commit pass +# +# Run only after prototype-preview.idac has completed against the same target and you +# have inspected every preview's before/after data. +# Prefer addresses or mangled names for overloaded functions. +# Then run: +# idac batch prototype-pass.idac --lint --out /tmp/prototype-pass.lint.json +# idac batch prototype-pass.idac --fail-fast --out /tmp/prototype-pass.json +# idac decompile --f5 + +function prototype set 0x100000000 --decl "int __fastcall ExampleClass__parseHeader(ExampleClass *__hidden this, const unsigned __int8 *buf, unsigned int len)" +misc reanalyze 0x100000000 +function prototype set 0x100000100 --decl "void *__fastcall ExampleClass__buildResult(ExampleClass *__hidden this, InputContext *ctx, const ExampleOptions *options)" +misc reanalyze 0x100000100 +function prototype set 0x100000200 --decl "unsigned int __fastcall ExampleClass__getCount(const ExampleClass *__hidden this)" +misc reanalyze 0x100000200 diff --git a/plugins/idac/skills/idac/references/templates/prototype-preview.idac b/plugins/idac/skills/idac/references/templates/prototype-preview.idac new file mode 100644 index 0000000..8ce3d9d --- /dev/null +++ b/plugins/idac/skills/idac/references/templates/prototype-preview.idac @@ -0,0 +1,18 @@ +# Generic prototype preview pass +# +# Support types must already exist. Replace every placeholder declaration and address, +# then run: +# idac batch prototype-preview.idac --lint --out /tmp/prototype-preview.lint.json +# idac batch prototype-preview.idac --fail-fast --out /tmp/prototype-preview.json +# Inspect every preview's before/after data in that journal. Only then run the matching +# prototype-pass.idac commit file against the same target. + +function prototype show 0x100000000 +function prototype check 0x100000000 --decl "int __fastcall ExampleClass__parseHeader(ExampleClass *__hidden this, const unsigned __int8 *buf, unsigned int len)" +preview function prototype set 0x100000000 --decl "int __fastcall ExampleClass__parseHeader(ExampleClass *__hidden this, const unsigned __int8 *buf, unsigned int len)" +function prototype show 0x100000100 +function prototype check 0x100000100 --decl "void *__fastcall ExampleClass__buildResult(ExampleClass *__hidden this, InputContext *ctx, const ExampleOptions *options)" +preview function prototype set 0x100000100 --decl "void *__fastcall ExampleClass__buildResult(ExampleClass *__hidden this, InputContext *ctx, const ExampleOptions *options)" +function prototype show 0x100000200 +function prototype check 0x100000200 --decl "unsigned int __fastcall ExampleClass__getCount(const ExampleClass *__hidden this)" +preview function prototype set 0x100000200 --decl "unsigned int __fastcall ExampleClass__getCount(const ExampleClass *__hidden this)" diff --git a/plugins/idac/skills/idac/references/templates/rename-pass.idac b/plugins/idac/skills/idac/references/templates/rename-pass.idac new file mode 100644 index 0000000..d784aa4 --- /dev/null +++ b/plugins/idac/skills/idac/references/templates/rename-pass.idac @@ -0,0 +1,9 @@ +# Generic local rename commit pass +# +# Run only after rename-preview.idac has completed against the same target and you have +# inspected its full before/after local lists. Then run: +# idac batch rename-pass.idac --lint --out /tmp/rename-pass.lint.json +# idac batch rename-pass.idac --fail-fast --out /tmp/rename-pass.json +# Stop on the first miss, refresh locals, and recalibrate before retrying. + +function locals apply 0x100000000 --json-file "locals-plan.json" diff --git a/plugins/idac/skills/idac/references/templates/rename-preview.idac b/plugins/idac/skills/idac/references/templates/rename-preview.idac new file mode 100644 index 0000000..6a62e1d --- /dev/null +++ b/plugins/idac/skills/idac/references/templates/rename-preview.idac @@ -0,0 +1,13 @@ +# Generic local rename preview pass +# +# Run only after prototype fixes and reanalysis. Copy this file, rename-pass.idac, and +# locals-plan.json into the chosen artifact directory, then refresh locals: +# idac function locals list --json --out /tmp/locals.json +# Replace the sample selectors in locals-plan.json with exact index or local_id values +# from that fresh output. Then run: +# idac batch rename-preview.idac --lint --out /tmp/rename-preview.lint.json +# idac batch rename-preview.idac --fail-fast --out /tmp/rename-preview.json +# Inspect the preview's full before/after local lists. Only then run rename-pass.idac +# against the same target; refresh and recalibrate if anything changed. + +preview function locals apply 0x100000000 --json-file "locals-plan.json" diff --git a/plugins/idac/skills/idac/references/troubleshooting.md b/plugins/idac/skills/idac/references/troubleshooting.md new file mode 100644 index 0000000..8a50302 --- /dev/null +++ b/plugins/idac/skills/idac/references/troubleshooting.md @@ -0,0 +1,152 @@ +# Troubleshooting + +Read this when Nexus discovery, target selection, runtime compatibility, mutations, or +decompiler readback is unclear. + +## No READY Nexus instances found + +Diagnose before installing anything: + +```bash +idac doctor +idac targets list --json +``` + +For a live GUI, `idac setup gui` installs the matching Nexus component. Use it when +installation or repair is part of the task, then restart IDA or load the component +as required by IDA. See [runtime requirements](targets-and-backends.md) for supported +versions. For an inspection-only request, report a missing component after diagnosis. + +For headless work, pass the `.i64` or binary path directly: + +```bash +idac database show -c /path/to/sample.i64 --json +``` + +Nexus will reuse a matching instance or start a managed worker. If startup fails, fix +the reported IDA, license, path, or version problem; idac does not switch execution +mechanisms. + +## A record is BLOCKED or unavailable + +`targets list --json` preserves the Nexus `state` and `detail`. A command attaches only +to a READY record. BLOCKED, stale, busy, or disconnected records are not silently +replaced, and an operation is not retried after a connection failure. Resolve the +reported owner/version/liveness problem or select another known target explicitly. + +## Multiple READY instances are open + +Use the exact record ID from `targets list`: + +```bash +idac targets list --json +idac decompile "sub_08041337" --instance "" +``` + +Alternatively, use `-c PATH` to select an `.i64` or binary identity. With no selector, +idac proceeds only when exactly one READY instance exists. + +## Headless analysis takes a long time + +Headless opens request auto-analysis and wait for it before dispatch. A first binary +import may therefore take much longer than later commands. Supply a deliberate Nexus +timeout when its defaults are too short. Live GUI attachment never forces analysis. + +## Changes did not reach disk + +Successful headless mutations are checkpointed before the next remote request or +lease release; the managed worker remains warm for five idle minutes. A later batch +failure does not undo earlier checkpoints. If a save fails, reread the database from +a fresh worker before deciding what persisted; see [save behavior](targets-and-backends.md#saves-and-worker-lifetime). + +Live GUI mutations deliberately remain unsaved. Checkpoint them explicitly with +`idac database save`, using `--instance RECORD_ID` or `-c PATH` when selection would +otherwise be ambiguous. + +## `function prototype set` reports unknown type(s) + +Declare the missing support or placeholder types first, then retry the prototype. See +[the mutation workflows](workflows.md#safe-mutation-loop) for the safe loop and +[class recovery](class-recovery.md) for support-type ordering. + +Before retrying, use: + +```bash +idac type check --decl-file "support_types.h" +idac function prototype check "sub_08041337" --decl-file "sub_08041337_proto.h" +``` + +If a local type exists but its dependencies are unclear, use `type deps ` to ask IDA to print the type with dependencies when possible. + +## `type declare` rejects a header and the error does not say which declaration + +Rerun the same import once with `--bisect`: + +```bash +idac type declare --replace --bisect --decl-file "recovered_classes.h" +``` + +It reports the first declaration IDA rejects by line range, says whether that declaration imports on its own, and names by-value members whose types are still opaque. If it imports alone, the problem is ordering: move or add the missing support type earlier in the file. If it fails alone too, simplify that one declaration — plain `struct`, blob padding for unknown regions — and retry. `--bisect` needs IDA undo support and is rejected by `type check`. + +For template-heavy or newer C++ syntax, retry the import with `--clang`. For namespace-qualified names that the local-type parser will not take, use `--alias old=new`. +Namespace blocks should be normalized to flat local-type identifiers before using +the default parser. When replacing existing types, preview `type declare --replace` +to inspect the actual changes; a separate `type check` repeats validation without +providing replacement readback. + +## Preview did not persist + +That is expected. `preview` applies the mutation, captures the result, and restores the prior state before returning. + +```bash +idac preview -o "/tmp/preview.json" comment set "sub_08041337" "entry point" +``` + +Preview performs the real mutation before restoring it, so the readback reflects the temporary changed state. + +For `function locals update`, `function locals rename`, and `function locals retype`, preview always returns the full before/after local list. +For `function locals apply`, preview also returns before/after local lists, so use it when a single function has many coordinated local changes. + +Preview payloads are structured JSON or JSONL objects; see [cli.md](cli.md#preview) for the top-level key list. For mutating commands, `before` and `after` capture the temporary state around the rollback cycle, and `result` contains the command-specific return payload. + +For `type declare` previews, `replaced_types` is the list of local types whose declarations changed in the preview. It is informational, not a failure signal. If a familiar framework typedef such as `CFDateRef` appears there, verify the local type directly with `type show` before treating it as a regression. + +## `type class show` says the type is not class-materialized + +That means the local type exists, but not yet as a C++ class that `idac` can flatten or inspect as a vtable-backed object. + +Run: + +```bash +idac type show "ExampleClass" +idac type class candidates "ExampleClass" --json --out "/tmp/class_candidates.json" +idac preview -o "/tmp/type_preview.json" type declare --replace --decl-file "recovered_classes.h" +``` + +If the candidates show vtable, RTTI, or family function symbols, switch to recovered-header import instead of probing more `type class show` or `type class vtable` commands on the opaque type. + +## Decompiler, local, or type results look stale + +Run: + +```bash +idac misc reanalyze "sub_08041337" +idac decompile "sub_08041337" --f5 +idac function locals list "sub_08041337" --json --out "/tmp/sub_08041337.locals.json" +``` + +`--f5` forces a fresh Hex-Rays pass instead of reusing cached pseudocode. +If the issue appears related to the selected instance or runtime, rerun `doctor` first. + +## Large readback is hard to inspect inline + +If a function, local-variable list, or decompile result is too large for the terminal, write it to a file instead of relying on inline output: + +```bash +idac decompile "sub_08041337" --f5 --out "/tmp/sub_08041337.json" +idac function locals list "sub_08041337" --json --out "/tmp/sub_08041337.locals.json" +idac decompilemany "Example_" --out-dir "/tmp/example_family" +idac disasm --start "0x100000460" --end "0x1000004a0" --out "/tmp/range.asm" +``` + +When the output is mostly for later inspection, prefer a file artifact from the start. That keeps the readback stable across reanalysis and avoids truncation. diff --git a/plugins/idac/skills/idac/references/workflows.md b/plugins/idac/skills/idac/references/workflows.md new file mode 100644 index 0000000..2ababbc --- /dev/null +++ b/plugins/idac/skills/idac/references/workflows.md @@ -0,0 +1,292 @@ +# Mutation and Recovery Workflows + +Read this for database edits, selector calibration, batch authoring, and IDAPython. +For target selection and save behavior, use [targets and backends](targets-and-backends.md). + +## Contents + +- [Safe mutation loop](#safe-mutation-loop) +- [Selector calibration](#selector-calibration) +- [Narrow type edits](#narrow-type-edits) +- [Record findings in the database](#record-findings-in-the-database) +- [Batch](#batch) +- [Broad discovery defaults](#broad-discovery-defaults) +- [Structural inspection and reanalysis](#structural-inspection-and-reanalysis) +- [IDAPython escape hatch](#idapython-escape-hatch) + +## Safe mutation loop + +Apply the parts relevant to the requested edit: + +1. Read the current state and establish the evidence for the change. Before + `function prototype set`, run `function prototype show`. Validate custom + calling conventions with `function prototype check`, or use it when validation + itself is requested. A prototype preview already validates ordinary type edits. + For headers replacing existing types, preview `type declare --replace` directly; + it validates the actual replacement and provides before/after data. A separate + `type check` is useful when validation itself is requested or before a new import. +2. Declare missing support types before dependent prototypes. Start uncertain + types with minimal structs or placeholders. Importing a type in a preview does + not make it available to a later prototype preview: preview rolls it back. +3. Preview parser-risky type/prototype edits and uncertain local selectors, then + inspect `before`, `after`, and `undo`. Confirmed symbol renames, comments, and + parameter-name edits with `--preserve-cc` can be committed directly and read + back. Standalone `preview` requires `-o/--out` and prints + only the artifact location. Commit the inspected change within the user's + existing authorization; preview inspection is an agent verification step. +4. After meaningful type or prototype changes, run `misc reanalyze` on affected + functions, then reread pseudocode with `--f5`. Reanalyze callers too when their + casts or `this` types still look stale. +5. Build local cleanup from fresh locals JSON after reanalysis; see + [selector calibration](#selector-calibration). Verify the committed state. + +Prototype example, with support types already available: + +```bash +idac function prototype show "sub_08041337" +idac function prototype check "sub_08041337" --decl-file "sub_08041337_proto.h" +idac preview -o "proto.preview.json" function prototype set "sub_08041337" --decl-file "sub_08041337_proto.h" +jq . proto.preview.json +``` + +After inspecting the preview: + +```bash +idac function prototype set "sub_08041337" --decl-file "sub_08041337_proto.h" +idac misc reanalyze "sub_08041337" +idac function prototype show "sub_08041337" +idac decompile "sub_08041337" --f5 +``` + +Keep the chosen `-c` or `--instance` on these invocations; examples omit it for +readability. On `preview` and `batch`, put it on the wrapper. + +Use `--propagate-callers` on `function prototype set` when the task calls for +applying the callee type at matching caller call sites. Return-type changes need +body or caller evidence; retain a generic type when that evidence is insufficient. + +For declaration failures, use [troubleshooting](troubleshooting.md). In particular, +an unexplained multi-declaration `type declare` failure warrants one `--bisect` +diagnostic before hand-editing the header; `type check` does not accept `--bisect`. + +For straightforward coordinated function renames, prototype edits, and comments, put the known +commands and final readbacks in an ordered `batch --fail-fast --out` file. +Use addresses for functions whose names change during the pass. +Read-only inspections and plain prototype/name changes do not need duplicate +IDAPython verification. Preserve the prototype's calling convention and parameter +types by editing its displayed declaration. Fresh pseudocode is needed when the +task depends on decompiler propagation; a comment-only edit needs comment readback. +Use `function prototype set --preserve-cc` for a parameter-name or return-type edit +that must keep the current ABI. IDA can normalize a parsed `__cdecl` declaration +to `__fastcall`; this flag copies the existing convention into the parsed type +before applying it, avoiding a scripted type edit. Omit it when changing the +calling convention is part of the request. + +## Selector calibration + +Capture locals after the last prototype/type change and reanalysis: + +```bash +idac function locals list "sub_08041337" --json --out "locals.json" +jq -r '.locals[] | [.index, .local_id, .display_name, .type] | @tsv' locals.json +idac preview -o "local.preview.json" function locals rename "sub_08041337" --index 3 --new-name "value_count" +``` + +Replace the sample index with one from the fresh JSON. Alternatively copy the exact +`local_id` in `@` form and use `--local-id`. Do not combine either +flag with a positional selector. Current names are suitable for one-off edits +before the local set shifts; use explicit IDs or indices for batches and after +prototype changes or reanalysis. Neither selector is guaranteed to survive another +reanalysis. + +For coordinated edits to one function, prefer `function locals apply --json-file` +with a plan derived from that snapshot: + +```json +[ + {"local_id": "stack(16)@0x100000460", "rename": "value_count", "decl": "unsigned int value_count;"}, + {"index": 7, "type": "ExampleStruct *"} +] +``` + +Replace every sample selector and type before use: + +```bash +idac preview -o "locals.preview.json" function locals apply "sub_08041337" --json-file "locals-plan.json" +``` + +Inspect the full before/after lists, then commit and reread `function locals list +--json` to confirm the selected locals. Stop on the first miss, refresh locals, and +recalibrate before continuing. For individual renames, verify each commit; for an +apply plan, verify the full resulting list. Work one function at a time. + +Use `function locals update` for a single rename plus retype. `retype --type` +accepts simple spellings; use `--decl` or `--decl-file` for arrays, function pointers, +or full declarations. More examples are in the optional +[templates](templates/README.md). + +## Narrow type edits + +For a small correction to an existing struct or enum, edit the member directly: + +```bash +idac type struct show "ExampleStruct" +idac preview -o "field.preview.json" type struct field set "ExampleStruct" "entry_count" --offset 0x18 --decl "unsigned int" +``` + +Inspect the preview, then commit the same field edit. `--offset` is a byte offset: +an existing field there is retyped and renamed; another offset adds a field. +Member edits return the refreshed type as readback. Reanalyze functions that use +the changed type. For a broad layout change or a maintained recovered header, use +`type declare --replace` instead. See [member command syntax](cli.md#struct-field-and-enum-member-edits). + +## Record findings in the database + +When annotation is part of the task, associate evidence with its address using +`comment set` or `bookmark add`. These are preview-capable and batch-safe: + +```bash +idac comment set "sub_08041337" "parses the record header" --scope function +idac comment show "sub_08041337" --scope function +``` + +For a confirmed address and annotation, commit and read back directly; use preview +when the selector or mutation scope is uncertain. Mark inferred semantics explicitly. +For read-only analysis, place findings in the requested report or notes. + +## Batch + +Use one ordered batch for related operations on the same target. Lint mutation +batches before execution and fix reported issues. When previews are needed, +separate preview and commit files so the journal can be inspected first. For +confirmed renames, comments, and parameter-name edits, one commit batch with +final readbacks is sufficient. + +With support types already imported: + +```text +# recovery-prototype-preview.idac +function prototype show "ExampleDerived__method_1" +function prototype check "ExampleDerived__method_1" --decl-file "example_method_1.h" +preview function prototype set "ExampleDerived__method_1" --decl-file "example_method_1.h" +``` + +```text +# recovery-prototype-commit.idac +function prototype set "ExampleDerived__method_1" --decl-file "example_method_1.h" +misc reanalyze "ExampleDerived__method_1" +function locals list "ExampleDerived__method_1" --json --out "example_method_1.locals.json" +``` + +```bash +idac batch "recovery-prototype-preview.idac" --lint --out "prototype-preview.lint.json" +idac batch "recovery-prototype-preview.idac" --fail-fast --out "prototype-preview.json" +jq . prototype-preview.json +``` + +After inspecting every preview, lint and run the commit file with `--fail-fast` +and a separate `--out`. Build the local plan from the commit's fresh locals +artifact, then preview and commit that cleanup in separate batches. + +Batch grammar and lifecycle: + +- One subcommand per line, without the leading `idac`; blank lines and `#` + comments are allowed. +- `-c`, `--instance`, and `--timeout` belong on the wrapper and are rejected on + child commands. The wrapper timeout is inherited by child validation. +- Wrapper `--out` is required for persistent mutations. Mutating children cannot + set `--out`; read-only children may write their own artifacts. +- Relative `--decl-file`, `--json-file`, `--functions-file`, and child output paths + resolve from the batch file's directory. Keep related files together. +- Child `--json` / `--format` controls read-only artifact serialization unless a + `.json` or `.jsonl` suffix selects the structured format. +- Use `--fail-fast` for dependencies and local cleanup so execution stops on a + failed check or selector miss. Lint catches parse errors, missing input files, + unsupported commands, and risky name-only local selectors. +- `misc rename` is batch-safe and preview-capable; use addresses when names change + during the batch. `setup gui` is rejected. `misc reanalyze` is batch-safe and + not preview-capable. +- Batch reuses one Nexus session. Its journal starts `pending`, checkpoints each + completed line, and becomes terminal after session close. Ctrl-C records + `interrupted` and exits 130. +- A batch is not a transaction: earlier successful headless mutations remain saved + if a later step fails. Reread state before resuming an interrupted pass. + +## Broad discovery defaults + +Use filtered reads to obtain enough evidence for the task: + +- `function list "name1|name2" --regex -i --json --out ` filters in IDA. + Add `--demangle` when matching display names. +- `type list [TYPE_FILTER]` and `type class candidates [CANDIDATE_FILTER]` support + filters too. Use `--kind` on class candidates when only one row category matters. + An unfiltered type list requires `--out`. +- Search already-defined strings with `search strings`. Use `--scan` when + string-like bytes are not defined in the database; scanning does not define + them. Both string and byte searches require `--segment` and `--timeout`. + Dyld shared caches require a bounded string scan of at most 16 MiB; see + [CLI search notes](cli.md#common-reads). +- For a family you need to inspect locally, use `decompilemany "" --f5 + --out-dir ...`. For an exact selection, use `--functions-file` with one name or + address per line. Add `--disasm` or `--ctree` when those artifacts help. +- `manifest.json` records exact addresses, full names, failures, and artifact + paths. Use `.functions[].address` for exact lookups when long filenames are + shortened. After mutations, redecompile the functions needed for verification; + repeat the family capture only if its scope or evidence needs updating. +- Type, function, and candidate lists are top-level JSON arrays (`.[]`); locals + are wrapped under `.locals[]`. + +## Structural inspection and reanalysis + +Use `ctree ` for Hex-Rays tree inspection, or `ctree --level +micro --maturity generated` for microcode. After type changes, reanalysis and +fresh pseudocode distinguish a propagation problem from presentation noise. +Continue until the requested data flow or type relationships are supported; avoid +cosmetic cleanup that does not improve the result. + +## IDAPython escape hatch + +Use a small explicit script when first-class commands do not cover the operation. +Use `--script` or a quoted heredoc with `--stdin` for multiline Python; keep +`--code` to simple expressions so shell escaping does not become Python syntax. + +```bash +idac py exec --code "result = {'imagebase': hex(ida_nalt.get_imagebase())}" +idac py exec --script "inspect_slots.py" --json --out "slots.json" +``` + +Supported inputs are `--code`, `--stdin`, and `--script`, with a fresh namespace +per execution. `--persist` is no longer available; combine work that shares Python +variables in one invocation. Core `ida*` modules, `idautils`, `idc`, and `result` +are available when their imports succeed; import other required IDA modules +explicitly. Assign JSON-native data to `result` for structured output. `--script` sets `__file__` to +the local script path, but execution happens inside IDA; that path is not evidence +that sibling files exist in the remote environment. The local `idac` package is +not part of the execution scope. + +For IDA 9.4, the following read-only API recipe was verified against the runtime: + +```python +import ida_ida, ida_nalt, ida_typeinf + +tif = ida_typeinf.tinfo_t() +assert ida_nalt.get_tinfo(tif, ea) +details = ida_typeinf.func_type_data_t() +assert tif.get_func_details(details) +result = { + "calling_convention": details.get_cc(), + "arguments": [{"name": arg.name, "type": str(arg.type)} for arg in details], + "compiler": ida_typeinf.get_compiler_name(ida_ida.inf_get_cc_id()), +} +``` + +Replace `ea` with a confirmed function address. Type retrieval is in `ida_nalt`, +the calling convention is `get_cc()` rather than a `.cc` field, and compiler ID +comes from `ida_ida`. For another runtime version or an unfamiliar API, inspect +its available methods/docstrings before building the full script. Use +`function prototype show` when the displayed signature already answers the question. + +`py exec` is not preview-capable and is treated as mutating for save purposes. +Keep inspection scripts read-only when the request is read-only, and verify any +scripted edit explicitly. Do not retry a failed or timed-out scripted mutation +without reading affected state. diff --git a/pyproject.toml b/pyproject.toml index ea019ab..828e73d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,20 @@ [project] name = "idac" version = "0.19.1" -description = "Agent-friendly CLI for IDA with GUI and idalib backends" +description = "Agent-friendly CLI for IDA powered by ida-nexus" readme = "README.md" license = "Apache-2.0" license-files = ["LICENSE"] authors = [ { name = "Jay Little / Trail of Bits", email = "jay@trailofbits.com" } ] -requires-python = ">=3.10" -dependencies = [] +requires-python = ">=3.11" +dependencies = [ + "ida-domain>=0.5.1", + "ida-hcli>=0.24.0", + "ida-nexus>=0.13.2", + "packaging", +] [project.scripts] idac = "idac.cli:main" @@ -34,11 +39,10 @@ build-backend = "uv_build" [tool.uv] default-groups = ["dev"] -exclude-newer = "1 week" [tool.ruff] line-length = 120 -target-version = "py310" +target-version = "py311" src = ["src", "tests"] [tool.ruff.lint] @@ -46,7 +50,6 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] -"src/idac/ida_plugin/idac_bridge_plugin.py" = ["E402"] [tool.ruff.format] docstring-code-format = true @@ -60,8 +63,8 @@ addopts = [ "--strict-markers", ] markers = [ - "gui_live: optional Unix-socket GUI transport integration tests", - "requires_ida: needs a local IDA install with idalib; auto-skipped when unavailable", + "nexus_gui_live: optional live IDA GUI integration tests through ida-nexus", + "requires_ida: needs a local IDA installation usable by ida-nexus; auto-skipped when unavailable", ] [tool.coverage.run] diff --git a/scripts/install_ida_plugin.sh b/scripts/install_ida_plugin.sh deleted file mode 100755 index ba172eb..0000000 --- a/scripts/install_ida_plugin.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IDAUSR_DIR="${IDAUSR:-$HOME/.idapro}" -PLUGINS_DIR="$IDAUSR_DIR/plugins" - -mkdir -p "$PLUGINS_DIR" - -BOOTSTRAP_SRC="$ROOT_DIR/src/idac/ida_plugin/idac_bridge_plugin.py" -PACKAGE_SRC="$ROOT_DIR/src/idac/ida_plugin/idac_bridge" -BOOTSTRAP_DST="$PLUGINS_DIR/idac_bridge_plugin.py" -PACKAGE_DST="$PLUGINS_DIR/idac_bridge" - -ln -sfn "$BOOTSTRAP_SRC" "$BOOTSTRAP_DST" -rm -rf "$PACKAGE_DST" -ln -s "$PACKAGE_SRC" "$PACKAGE_DST" - -echo "installed bootstrap: $BOOTSTRAP_DST -> $BOOTSTRAP_SRC" -echo "installed package: $PACKAGE_DST -> $PACKAGE_SRC" -echo "start IDA with this repo available at: $ROOT_DIR" diff --git a/src/idac/__init__.py b/src/idac/__init__.py index d4683c0..d2d24bc 100644 --- a/src/idac/__init__.py +++ b/src/idac/__init__.py @@ -1,4 +1,3 @@ -from .metadata import BRIDGE_PLUGIN_NAME, SKILL_NAME from .version import VERSION -__all__ = ["BRIDGE_PLUGIN_NAME", "SKILL_NAME", "VERSION"] +__all__ = ["VERSION"] diff --git a/src/idac/cli.py b/src/idac/cli.py deleted file mode 100644 index 7ec6e5b..0000000 --- a/src/idac/cli.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from .cli2 import build_parser, main - -__all__ = ["build_parser", "main"] diff --git a/src/idac/cli2/__init__.py b/src/idac/cli/__init__.py similarity index 100% rename from src/idac/cli2/__init__.py rename to src/idac/cli/__init__.py diff --git a/src/idac/cli2/argparse_utils.py b/src/idac/cli/argparse_utils.py similarity index 57% rename from src/idac/cli2/argparse_utils.py rename to src/idac/cli/argparse_utils.py index 103664c..93e07df 100644 --- a/src/idac/cli2/argparse_utils.py +++ b/src/idac/cli/argparse_utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import math import sys from collections.abc import Callable from pathlib import Path @@ -13,8 +14,8 @@ def positive_timeout(value: str) -> float: timeout = float(value) except ValueError as exc: raise argparse.ArgumentTypeError("--timeout must be a number") from exc - if timeout <= 0: - raise argparse.ArgumentTypeError("--timeout must be greater than 0") + if not math.isfinite(timeout) or timeout <= 0: + raise argparse.ArgumentTypeError("--timeout must be a positive finite number") return timeout @@ -28,26 +29,17 @@ def positive_int(value: str) -> int: return parsed -def _children(parser: argparse.ArgumentParser) -> list[tuple[argparse.ArgumentParser, bool]]: +def _children(parser: argparse.ArgumentParser) -> list[argparse.ArgumentParser]: return list(getattr(parser, "_idac_children", [])) -def _append_child(parent: argparse.ArgumentParser, child: argparse.ArgumentParser, *, hidden: bool) -> None: - children = _children(parent) - children.append((child, hidden)) - parent._idac_children = children # ty: ignore[unresolved-attribute] # dynamic attr on stdlib parser - - -def render_full_help(parser: argparse.ArgumentParser, *, include_hidden: bool = False) -> str: +def render_full_help(parser: argparse.ArgumentParser) -> str: sections: list[str] = [] queue = [parser] while queue: current = queue.pop(0) sections.append(f"# {current.prog}\n\n{current.format_help().rstrip()}") - for child, hidden in _children(current): - if hidden and not include_hidden: - continue - queue.append(child) + queue.extend(_children(current)) return "\n\n".join(sections) + "\n" @@ -56,7 +48,7 @@ def __init__(self, option_strings, dest, **kwargs): super().__init__(option_strings, dest, nargs=0, **kwargs) def __call__(self, parser, namespace, values, option_string=None) -> None: - sys.stdout.write(render_full_help(parser, include_hidden=False)) + sys.stdout.write(render_full_help(parser)) parser.exit(0) @@ -87,11 +79,16 @@ def create_parser( parser.set_defaults( _selected_parser=parser, _uses_context=False, - _hidden_command=False, _accepts_timeout=False, run=None, + allow_batch=False, + allow_preview=False, + _input_path_attrs=(), + _request_validator=None, + _mutating_command=False, _preview_wrapper=False, _batch_mode=False, + _nexus_session=None, ) add_full_help_option(parser) return parser @@ -103,25 +100,29 @@ def add_command( name: str, *, help_text: str, - description: str | None = None, - hidden: bool = False, ) -> argparse.ArgumentParser: parser = subparsers.add_parser( name, - help=argparse.SUPPRESS if hidden else help_text, - description=description or help_text, + help=help_text, + description=help_text, ) parser.set_defaults( _selected_parser=parser, _uses_context=False, - _hidden_command=hidden, _accepts_timeout=False, run=None, + allow_batch=False, + allow_preview=False, + _input_path_attrs=(), + _request_validator=None, + _mutating_command=False, _preview_wrapper=False, _batch_mode=False, ) add_full_help_option(parser) - _append_child(parent_parser, parser, hidden=hidden) + children = _children(parent_parser) + children.append(parser) + parent_parser._idac_children = children # ty: ignore[unresolved-attribute] # dynamic attr on stdlib parser return parser @@ -132,51 +133,50 @@ def add_standard_command( *, help_text: str, run: Callable[[argparse.Namespace], object], - mutating: bool = False, default_format: str = "text", - description: str | None = None, - hidden: bool = False, require_timeout: bool = False, timeout_requirement_label: str | None = None, - require_out: bool = False, ) -> argparse.ArgumentParser: - """add_command + context/output options + the standard defaults block.""" - parser = add_command(parent_parser, subparsers, name, help_text=help_text, description=description, hidden=hidden) + parser = add_command(parent_parser, subparsers, name, help_text=help_text) add_context_options(parser, require_timeout=require_timeout, timeout_requirement_label=timeout_requirement_label) - add_output_options(parser, default_format=default_format, require_out=require_out) + add_output_options(parser, default_format=default_format) parser.set_defaults( run=run, - context_policy="standard", allow_batch=True, allow_preview=True, - _mutating_command=mutating, ) return parser def finalize_help_tree(parser: argparse.ArgumentParser) -> None: - visible_names = [child.prog.split()[-1] for child, hidden in _children(parser) if not hidden] + child_names = [child.prog.split()[-1] for child in _children(parser)] for action in parser._actions: if not isinstance(action, argparse._SubParsersAction): continue - action._choices_actions = [item for item in action._choices_actions if item.help != argparse.SUPPRESS] - if visible_names: - action.metavar = "{" + ",".join(visible_names) + "}" - for child, _hidden in _children(parser): + if child_names: + action.metavar = "{" + ",".join(child_names) + "}" + for child in _children(parser): finalize_help_tree(child) def _add_context_arguments(parser: argparse.ArgumentParser, *, timeout_help: str) -> None: - parser.add_argument( + context_group = parser.add_mutually_exclusive_group() + context_group.add_argument( "-c", "--context", - metavar="LOCATOR", + metavar="PATH", default=argparse.SUPPRESS, help=( - "Execution context: a live GUI selector such as pid:1234 or tiny, " - "or an idalib database locator such as db:sample.i64" + "Open or attach to an .i64 database or input binary through ida-nexus; " + "legacy db:, pid:, module, and .idb locators are not supported" ), ) + context_group.add_argument( + "--instance", + metavar="RECORD_ID", + default=argparse.SUPPRESS, + help="Attach to one exact READY ida-nexus discovery record", + ) parser.add_argument( "--timeout", type=positive_timeout, @@ -188,11 +188,11 @@ def _add_context_arguments(parser: argparse.ArgumentParser, *, timeout_help: str def add_root_context_options(parser: argparse.ArgumentParser) -> None: _add_context_arguments( parser, - timeout_help="Backend request timeout in seconds; forwarded to the selected command", + timeout_help="Nexus startup, analysis, and operation timeout in seconds; forwarded to the selected command", ) -def set_context_defaults( +def add_context_options( parser: argparse.ArgumentParser, *, require_timeout: bool = False, @@ -200,31 +200,15 @@ def set_context_defaults( ) -> None: parser.set_defaults( _uses_context=True, - backend=None, - target=None, - database=None, _require_timeout=require_timeout, _timeout_requirement_label=timeout_requirement_label, ) - - -def add_context_options( - parser: argparse.ArgumentParser, - *, - require_timeout: bool = False, - timeout_requirement_label: str | None = None, -) -> None: - set_context_defaults( - parser, - require_timeout=require_timeout, - timeout_requirement_label=timeout_requirement_label, - ) _add_context_arguments( parser, timeout_help=( - "Backend request timeout in seconds; required for this command" + "Nexus operation timeout in seconds; required for this command" if require_timeout - else "Backend request timeout in seconds; omit to wait indefinitely" + else "Nexus startup, analysis, and operation timeout in seconds; omit to use Nexus defaults" ), ) @@ -233,7 +217,6 @@ def add_output_options( parser: argparse.ArgumentParser, *, default_format: str = "text", - require_out: bool = False, ) -> None: parser.add_argument( "--format", @@ -253,12 +236,11 @@ def add_output_options( "-o", "--out", type=Path, - required=require_out, help="Write command output to a file and keep stdout empty", ) -def add_pattern_options(parser: argparse.ArgumentParser, *, label: str = "the pattern") -> None: +def add_pattern_options(parser: argparse.ArgumentParser, *, label: str) -> None: parser.add_argument( "--regex", action="store_true", @@ -289,80 +271,33 @@ def add_decl_input( mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--decl", help=help_text) mode.add_argument("--decl-file", dest="decl_file", type=Path, help=file_help) + parser.set_defaults(_input_path_attrs=("decl_file",)) -def add_retype_input(parser: argparse.ArgumentParser) -> None: - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument( - "--type", - dest="type_text", - help="Local type text shorthand, for example `unsigned int`; use --decl or --decl-file for complex declarators", - ) - mode.add_argument( - "--decl", - help="Full local variable declaration text, for example `unsigned int value;`", - ) - mode.add_argument( - "--decl-file", - dest="decl_file", - type=Path, - help="Read full local variable declaration text from this file", - ) - - -def read_decl_text(args: argparse.Namespace, *, attr: str = "decl", file_attr: str = "decl_file") -> str: - decl = getattr(args, attr, None) - if decl not in (None, ""): - return str(decl) - decl_file = getattr(args, file_attr, None) - if decl_file is not None: - return Path(decl_file).read_text(encoding="utf-8") - raise CliUserError("missing declaration input") +def read_decl_text(args: argparse.Namespace) -> str: + decl = read_decl_text_if_present(args) + if decl is None: + raise CliUserError("missing declaration input") + return decl -def read_decl_text_if_present( - args: argparse.Namespace, - *, - attr: str = "decl", - file_attr: str = "decl_file", -) -> str | None: - decl = getattr(args, attr, None) +def read_decl_text_if_present(args: argparse.Namespace) -> str | None: + decl = getattr(args, "decl", None) if decl not in (None, ""): return str(decl) - decl_file = getattr(args, file_attr, None) + decl_file = getattr(args, "decl_file", None) if decl_file is not None: return Path(decl_file).read_text(encoding="utf-8") return None -def read_decl_or_type_text( - args: argparse.Namespace, - *, - attr: str = "decl", - file_attr: str = "decl_file", - type_attr: str = "type_text", - placeholder_name: str = "__idac_local", -) -> str: - decl = read_decl_text_if_present(args, attr=attr, file_attr=file_attr) +def read_decl_or_type_text(args: argparse.Namespace) -> str: + decl = read_decl_text_if_present(args) if decl is not None: return decl - type_text = getattr(args, type_attr, None) + type_text = getattr(args, "type_text", None) if type_text not in (None, ""): normalized = str(type_text).strip().rstrip(";").rstrip() if normalized: - return f"{normalized} {placeholder_name};" + return f"{normalized} __idac_local;" raise CliUserError("missing declaration or type input") - - -def add_install_options(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--mode", - choices=("copy", "symlink"), - default="symlink", - help="Installation mode", - ) - parser.add_argument( - "--force", - action="store_true", - help="Replace an existing destination if it already exists", - ) diff --git a/src/idac/cli/batch.py b/src/idac/cli/batch.py new file mode 100644 index 0000000..cdef79a --- /dev/null +++ b/src/idac/cli/batch.py @@ -0,0 +1,834 @@ +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import shlex +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..nexus import NexusSessionError +from ..output import write_output_result +from .argparse_utils import add_command, add_context_options, bind_root_handler +from .context import merge_parent_context, require_timeout_if_needed +from .errors import CliUserError +from .execute import execute_parsed, protect_output_context, reject_unsupported_forwarded_context +from .path_resolution import reject_output_aliases, resolve_relative_paths +from .preview import normalize_wrapped_command_tokens +from .renderers import TEXT_RENDERERS +from .result import CommandResult +from .serialize import emit_result, json_or_jsonl_from_path + +_LOCAL_SELECTOR_OPERATIONS = frozenset({"local_rename", "local_retype", "local_update"}) +_LOCAL_LAYOUT_INVALIDATORS = frozenset({"proto_set", "reanalyze", "type_declare"}) + + +class BatchParseError(RuntimeError): + def __init__(self, message: str, *, exit_code: int) -> None: + super().__init__(message) + self.exit_code = exit_code + + +@dataclass +class PreparedBatchLine: + line_number: int + command: str + parsed: argparse.Namespace | BatchParseError | CliUserError + wrapped: argparse.Namespace | BatchParseError | None = None + + +def _line_record( + *, + line: int, + command: str, + status: str, + exit_code: int, + stderr: str | None = None, + result: Any = None, + timing_ms: float, + artifacts: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "line": line, + "command": command, + "status": status, + "exit_code": exit_code, + "timing_ms": round(timing_ms, 3), + "artifacts": list(artifacts or []), + } + if stderr: + payload["stderr"] = stderr + if result is not None: + payload["result"] = result + return payload + + +def _serialize_child_if_needed(result, args) -> list[dict[str, Any]]: + artifacts = list(result.artifacts) + arg_map = vars(args) + out_path = arg_map.get("out") + if out_path is None: + return artifacts + artifacts.extend(emit_result(result, fmt=arg_map.get("format", "text"), out_path=out_path)) + return artifacts + + +def _fallback_child_failure(value: Any) -> str | None: + if isinstance(value, str): + return value + try: + return json.dumps(value, indent=2, sort_keys=True) + except TypeError: + return str(value) + + +def _render_child_failure(result: CommandResult) -> str | None: + if result.stderr_lines: + stderr_text = "\n".join(line for line in result.stderr_lines if line.strip()).strip() + if stderr_text: + return stderr_text + renderer = TEXT_RENDERERS.get(result.render_op) + if renderer is not None: + try: + rendered_text = renderer(result.value).strip() + if rendered_text: + return rendered_text + except Exception as exc: + fallback_text = (_fallback_child_failure(result.value) or "").strip() + failure_prefix = f"(renderer failure while formatting {result.render_op}: {exc.__class__.__name__}: {exc})" + return failure_prefix if not fallback_text else f"{failure_prefix}\n{fallback_text}" + fallback_text = (_fallback_child_failure(result.value) or "").strip() + return fallback_text or None + + +def _parse_batch_args(root_parser: argparse.ArgumentParser, argv: list[str]) -> argparse.Namespace: + stdout_buffer = io.StringIO() + stderr_buffer = io.StringIO() + try: + with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer): + return root_parser.parse_args(argv) + except SystemExit as exc: + output = (stdout_buffer.getvalue(), stderr_buffer.getvalue()) + message = "\n".join(part.strip() for part in output if part.strip()) or "parse failed" + exit_code = int(exc.code) if isinstance(exc.code, int) else 1 + raise BatchParseError(message, exit_code=exit_code) from exc + + +def _reject_handlerless_command(parsed: argparse.Namespace) -> None: + if getattr(parsed, "run", None) is not None: + return + selected_parser = parsed._selected_parser + message = selected_parser.format_help().strip() or "missing subcommand" + raise BatchParseError(message, exit_code=2) + + +def _execute_batch_args(parsed: argparse.Namespace) -> CommandResult: + stdout_buffer = io.StringIO() + stderr_buffer = io.StringIO() + try: + with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer): + return execute_parsed(parsed) + except SystemExit as exc: + output = (stdout_buffer.getvalue(), stderr_buffer.getvalue()) + message = "\n".join(part.strip() for part in output if part.strip()) or "command exited" + exit_code = int(exc.code) if isinstance(exc.code, int) else 1 + raise BatchParseError(message, exit_code=exit_code) from exc + + +def _prepare_batch_lines(root_parser: argparse.ArgumentParser, batch_path: Path) -> list[PreparedBatchLine]: + prepared: list[PreparedBatchLine] = [] + for line_number, raw_line in enumerate(batch_path.read_text(encoding="utf-8").splitlines(), start=1): + command = raw_line.strip() + if not command or command.startswith("#"): + continue + try: + argv = shlex.split(command, comments=True, posix=True) + except ValueError as exc: + prepared.append(PreparedBatchLine(line_number, command, BatchParseError(str(exc), exit_code=2))) + continue + if argv[:1] == ["idac"]: + argv = argv[1:] + if not argv: + prepared.append(PreparedBatchLine(line_number, command, CliUserError("empty command"))) + continue + try: + parsed = _parse_batch_args(root_parser, argv) + except BatchParseError as exc: + prepared.append(PreparedBatchLine(line_number, command, exc)) + continue + + line = PreparedBatchLine(line_number, command, parsed) + if vars(parsed).get("command") == "preview": + wrapped_tokens = normalize_wrapped_command_tokens(vars(parsed).get("command_tokens")) + if wrapped_tokens: + try: + line.wrapped = _parse_batch_args(root_parser, wrapped_tokens) + parsed._wrapped_args = line.wrapped + except BatchParseError as exc: + line.wrapped = exc + prepared.append(line) + return prepared + + +def _reject_mutating_batch_without_out( + *, + command_lines: list[PreparedBatchLine], + out_path: Path | None, +) -> None: + if out_path is not None: + return + for line in command_lines: + if not isinstance(line.parsed, argparse.Namespace): + continue + if bool(vars(line.parsed).get("_mutating_command", False)): + raise CliUserError( + "mutating batch commands require `--out ` so the ordered " + f"result log is preserved before changes run; first mutating line is " + f"{line.line_number}: {line.command}" + ) + + +def _prepare_batch_command(line: PreparedBatchLine, parent_args: argparse.Namespace) -> argparse.Namespace: + if isinstance(line.parsed, BatchParseError | CliUserError): + raise line.parsed + parsed = line.parsed + parsed_map = vars(parsed) + if not parsed_map.get("allow_batch", False): + raise CliUserError("command is not available in batch mode") + _reject_handlerless_command(parsed) + if bool(parsed_map.get("_mutating_command", False)) and parsed_map.get("out") is not None: + raise CliUserError( + "mutating batch child commands cannot set --out; use the batch wrapper --out for mutation logging" + ) + if parsed_map.get("command") == "preview": + if isinstance(line.wrapped, BatchParseError): + raise line.wrapped + wrapped_map = vars(line.wrapped) if isinstance(line.wrapped, argparse.Namespace) else {} + if bool(wrapped_map.get("_mutating_command", False)) and wrapped_map.get("out") is not None: + raise CliUserError( + "mutating commands wrapped by batch preview cannot set --out; " + "put --out on preview before the wrapped command" + ) + merge_parent_context(parsed, parent_args) + + batch_path = Path(parent_args.batch_file) + resolve_relative_paths(parsed, base_dir=batch_path.parent.resolve(strict=False)) + context_value = parsed_map.get("context") + protected_outputs = [ + ("batch file", batch_path), + ("Nexus context", Path(context_value) if context_value is not None else None), + ("batch wrapper output", parent_args.out), + ] + for output_key in ("out", "out_file"): + child_output = parsed_map.get(output_key) + if child_output is not None: + reject_output_aliases( + Path(child_output), + protected_outputs, + option_label=f"child --{output_key.replace('_', '-')}", + ) + return parsed + + +def _batch_input_paths( + command_lines: list[PreparedBatchLine], + *, + batch_dir: Path, +) -> list[tuple[str, Path | None]]: + """Collect child input files before the wrapper journal can overwrite them.""" + + protected: list[tuple[str, Path | None]] = [] + for line in command_lines: + if not isinstance(line.parsed, argparse.Namespace): + continue + parsed_commands = [line.parsed] + if isinstance(line.wrapped, argparse.Namespace): + parsed_commands.append(line.wrapped) + for command_args in parsed_commands: + resolve_relative_paths(command_args, base_dir=batch_dir) + for key in command_args._input_path_attrs: + value = getattr(command_args, key, None) + if isinstance(value, Path): + protected.append((f"line {line.line_number} --{key.replace('_', '-')} input", value)) + return protected + + +def _lint_missing_input_paths(parsed: argparse.Namespace) -> list[str]: + missing: list[str] = [] + for key in parsed._input_path_attrs: + value = getattr(parsed, key, None) + if isinstance(value, Path) and not value.exists(): + missing.append(f"{key} path does not exist: {value}") + return missing + + +def _lint_local_selector_warning(parsed: argparse.Namespace, *, after_type_or_reanalysis: bool) -> str | None: + parsed_map = vars(parsed) + binding = parsed_map.get("_operation_binding") + if getattr(binding, "operation", None) not in _LOCAL_SELECTOR_OPERATIONS: + return None + if parsed_map.get("local_id") or parsed_map.get("index") is not None: + return None + selector = str(parsed_map.get("selector") or "").strip() + if not selector: + return None + if selector.isdigit() or "@" in selector: + return None + if after_type_or_reanalysis: + return "name-only local selector after type/prototype/reanalysis work; prefer --local-id or --index" + return "name-only local selector; prefer --local-id or --index for batch updates" + + +def _lint_command_local_errors(parsed: argparse.Namespace) -> list[str]: + try: + binding = getattr(parsed, "_operation_binding", None) + build_request = getattr(binding, "build_request", None) + if build_request is not None: + build_request(parsed) + validator = parsed._request_validator + if validator is not None: + validator(parsed) + except CliUserError as exc: + return [str(exc) or exc.__class__.__name__] + return [] + + +def _lint_preview_wrapped_command( + *, + line: PreparedBatchLine, + batch_dir: Path, +) -> tuple[argparse.Namespace, list[str]]: + if isinstance(line.wrapped, BatchParseError): + raise line.wrapped + if not isinstance(line.wrapped, argparse.Namespace): + raise CliUserError("preview requires a command to wrap") + + assert isinstance(line.parsed, argparse.Namespace) + wrapped = line.wrapped + wrapped_map = vars(wrapped) + if not wrapped_map.get("allow_preview", False): + raise CliUserError("command is not available in preview mode") + _reject_handlerless_command(wrapped) + + merge_parent_context(wrapped, line.parsed) + reject_unsupported_forwarded_context(wrapped._selected_parser, wrapped) + require_timeout_if_needed(wrapped) + resolve_relative_paths(wrapped, base_dir=batch_dir) + line_errors = _lint_missing_input_paths(wrapped) + if not line_errors: + line_errors.extend(_lint_command_local_errors(wrapped)) + return wrapped, line_errors + + +def _lint_batch( + *, + command_lines: list[PreparedBatchLine], + batch_path: Path, + out_path: Path | None, + parent_args: argparse.Namespace, +) -> dict[str, Any]: + rows: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + batch_dir = batch_path.parent.resolve(strict=False) + after_type_or_reanalysis = False + for line in command_lines: + line_number = line.line_number + stripped = line.command + try: + parsed = _prepare_batch_command(line, parent_args) + parsed_map = vars(parsed) + reject_unsupported_forwarded_context(parsed._selected_parser, parsed) + if parsed_map.get("command") == "preview": + lint_target, line_errors = _lint_preview_wrapped_command( + line=line, + batch_dir=batch_dir, + ) + else: + lint_target = parsed + require_timeout_if_needed(parsed) + line_errors = _lint_missing_input_paths(parsed) + if bool(parsed_map.get("_mutating_command", False)) and out_path is None: + line_errors.append("mutating batch command requires wrapper --out") + if not line_errors: + line_errors.extend(_lint_command_local_errors(parsed)) + warning = _lint_local_selector_warning(lint_target, after_type_or_reanalysis=after_type_or_reanalysis) + if warning is not None: + warnings.append({"line": line_number, "command": stripped, "message": warning}) + binding = parsed_map.get("_operation_binding") + if getattr(binding, "operation", None) in _LOCAL_LAYOUT_INVALIDATORS: + after_type_or_reanalysis = True + if line_errors: + for message in line_errors: + errors.append({"line": line_number, "command": stripped, "message": message}) + rows.append( + _line_record( + line=line_number, + command=stripped, + status="failed", + exit_code=1, + stderr="; ".join(line_errors), + timing_ms=0.0, + ) + ) + continue + rows.append( + _line_record( + line=line_number, + command=stripped, + status="ok", + exit_code=0, + result={"lint": "ok"}, + timing_ms=0.0, + ) + ) + except (BatchParseError, CliUserError) as exc: + message = str(exc) or exc.__class__.__name__ + errors.append({"line": line_number, "command": stripped, "message": message}) + rows.append( + _line_record( + line=line_number, + command=stripped, + status="failed", + exit_code=int(getattr(exc, "exit_code", 1)), + stderr=message, + timing_ms=0.0, + ) + ) + return { + "ok": not errors, + "mode": "lint", + "batch_file": str(batch_path), + "commands_total": len(rows), + "commands_linted": sum(1 for row in rows if row["exit_code"] == 0), + "errors_total": len(errors), + "warnings_total": len(warnings), + "errors": errors, + "warnings": warnings, + "results": rows, + } + + +def failure_lines(payload: Any) -> list[str]: + if not isinstance(payload, dict): + return [] + lint_errors = payload.get("errors") + if isinstance(lint_errors, list) and lint_errors: + lines: list[str] = [] + for item in lint_errors: + if not isinstance(item, dict): + continue + lines.append(f"batch line {item.get('line', '?')}: {item.get('command', '')}") + lines.append(f" {item.get('message', 'lint failed')}") + return lines + lines: list[str] = [] + finalization = payload.get("finalization") + if isinstance(finalization, dict) and finalization.get("status") in {"failed", "interrupted"}: + message = str(finalization.get("error") or "database persistence or session close failed").strip() + if finalization.get("status") == "interrupted": + lines.append(f"batch interrupted: {message}") + else: + lines.append(f"batch finalization failed: {message}") + notes = finalization.get("notes") + if isinstance(notes, list): + lines.extend(f"batch finalization detail: {note}" for note in notes if str(note).strip()) + + for key, fallback, failure_prefix, detail_prefix in ( + ( + "session_finalization", + "database persistence or session close failed", + "batch session finalization also failed", + "batch session finalization detail", + ), + ( + "output_finalization", + "writing the terminal batch result failed", + "writing the terminal batch result also failed", + None, + ), + ( + "checkpoint_finalization", + "writing a batch checkpoint failed", + "writing the batch checkpoint also failed", + None, + ), + ): + detail = finalization.get(key) + if not isinstance(detail, dict) or detail.get("status") != "failed": + continue + message = str(detail.get("error") or fallback).strip() + lines.append(f"{failure_prefix}: {message}") + notes = detail.get("notes") + if detail_prefix is not None and isinstance(notes, list): + lines.extend(f"{detail_prefix}: {note}" for note in notes if str(note).strip()) + + rows = payload.get("results") + if not isinstance(rows, list): + return lines + for row in rows: + if not isinstance(row, dict) or row.get("exit_code") == 0: + continue + lines.append(f"batch line {row.get('line', '?')}: {row.get('command', '')}") + message = str(row.get("stderr") or "").strip() + if not message: + message = "step failed" + for item in message.splitlines(): + lines.append(f" {item}") + return lines + + +def _batch_payload( + rows: list[dict[str, Any]], + *, + batch_path: Path, + finalization: dict[str, Any], +) -> dict[str, Any]: + commands_ok = all(row["exit_code"] == 0 for row in rows) + return { + "ok": commands_ok and finalization.get("status") == "ok", + "batch_file": str(batch_path), + "commands_total": len(rows), + "commands_succeeded": sum(1 for row in rows if row["exit_code"] == 0), + "commands_failed": sum(1 for row in rows if row["exit_code"] != 0), + "finalization": finalization, + "results": rows, + } + + +def _write_batch_payload( + payload: dict[str, Any], + *, + out_path: Path | None, +) -> list[dict[str, Any]]: + if out_path is None: + return [] + fmt = json_or_jsonl_from_path(out_path) + if fmt == "jsonl": + value = list(payload["results"]) + value.append( + { + "record_type": "batch_finalization", + **payload["finalization"], + "ok": payload["ok"], + } + ) + else: + value = payload + output = write_output_result(value, fmt=fmt, out_path=Path(out_path), stem="batch") + return [] if output.artifact is None else [output.artifact] + + +def run(args: argparse.Namespace, *, root_parser: argparse.ArgumentParser) -> CommandResult: + rows: list[dict[str, Any]] = [] + batch_path = Path(args.batch_file) + batch_dir = batch_path.parent.resolve(strict=False) + command_lines = _prepare_batch_lines(root_parser, batch_path) + context_value = vars(args).get("context") + reject_output_aliases( + args.out, + [ + ("batch file", batch_path), + ("Nexus context", Path(context_value) if context_value is not None else None), + *_batch_input_paths(command_lines, batch_dir=batch_dir), + ], + option_label="batch --out", + ) + if args.out is not None: + uses_context = any( + isinstance(line.parsed, argparse.Namespace) and line.parsed.allow_batch and line.parsed._uses_context + for line in command_lines + ) + protect_output_context(args, discover_only=args.lint or not uses_context) + if args.lint: + payload = _lint_batch( + command_lines=command_lines, + batch_path=batch_path, + out_path=args.out, + parent_args=args, + ) + artifacts: list[dict[str, Any]] = [] + if args.out is not None: + fmt = json_or_jsonl_from_path(args.out) + path = Path(args.out) + value = payload["results"] if fmt == "jsonl" else payload + output = write_output_result(value, fmt=fmt, out_path=path, stem="batch") + if output.artifact is not None: + artifacts.append(output.artifact) + return CommandResult( + render_op="batch", + value=payload, + exit_code=0 if payload["ok"] else 1, + stderr_lines=failure_lines(payload), + artifacts=artifacts, + ) + _reject_mutating_batch_without_out(command_lines=command_lines, out_path=args.out) + terminal_error: BaseException | None = None + terminal_stage: str | None = None + checkpoint_error: BaseException | None = None + initial_checkpoint = _batch_payload( + rows, + batch_path=batch_path, + finalization={"status": "pending"}, + ) + try: + _write_batch_payload(initial_checkpoint, out_path=args.out) + except (Exception, KeyboardInterrupt) as exc: + terminal_error = exc + terminal_stage = "batch_initialization" + command_lines = [] + + for line in command_lines: + line_number = line.line_number + stripped = line.command + started = time.perf_counter() + rows_before = len(rows) + try: + parsed = _prepare_batch_command(line, args) + parsed._relative_path_base_dir = batch_dir + parsed._batch_mode = True + result = _execute_batch_args(parsed) + record = _line_record( + line=line_number, + command=stripped, + status="ok" if result.exit_code == 0 else "failed", + exit_code=result.exit_code, + stderr=_render_child_failure(result) if result.exit_code != 0 else None, + result=result.value, + timing_ms=(time.perf_counter() - started) * 1000.0, + artifacts=list(result.artifacts), + ) + # Record the row before serializing a read-only child's own --out + # file, so a write failure cannot erase the fact that it ran. + rows.append(record) + try: + record["artifacts"] = _serialize_child_if_needed(result, parsed) + except OSError as exc: + record["status"] = "failed" + if record["exit_code"] == 0: + record["exit_code"] = 1 + output_message = f"command ran but writing its --out file failed: {exc}" + existing_message = str(record.get("stderr") or "").strip() + record["stderr"] = f"{existing_message}\n{output_message}" if existing_message else output_message + except KeyboardInterrupt as exc: + terminal_error = exc + terminal_stage = "child_output" + record["status"] = "interrupted" + record["exit_code"] = 130 + output_message = "command ran but writing its --out file was interrupted" + existing_message = str(record.get("stderr") or "").strip() + record["stderr"] = f"{existing_message}\n{output_message}" if existing_message else output_message + except Exception as exc: + terminal_error = exc + terminal_stage = "child_output" + record["status"] = "failed" + if record["exit_code"] == 0: + record["exit_code"] = 1 + detail = str(exc).strip() or exc.__class__.__name__ + output_message = f"command ran but rendering or writing its --out file failed: {detail}" + existing_message = str(record.get("stderr") or "").strip() + record["stderr"] = f"{existing_message}\n{output_message}" if existing_message else output_message + if record["exit_code"] != 0 and args.fail_fast: + break + except (BatchParseError, NexusSessionError, CliUserError) as exc: + rows.append( + _line_record( + line=line_number, + command=stripped, + status="failed", + exit_code=int(getattr(exc, "exit_code", 1)), + stderr=str(exc) or exc.__class__.__name__, + timing_ms=(time.perf_counter() - started) * 1000.0, + ) + ) + if args.fail_fast: + break + except KeyboardInterrupt as exc: + terminal_error = exc + terminal_stage = "batch_execution" + rows.append( + _line_record( + line=line_number, + command=stripped, + status="interrupted", + exit_code=130, + stderr="interrupted by user", + timing_ms=(time.perf_counter() - started) * 1000.0, + ) + ) + except Exception as exc: + terminal_error = exc + terminal_stage = "batch_execution" + message = str(exc).strip() or exc.__class__.__name__ + rows.append( + _line_record( + line=line_number, + command=stripped, + status="failed", + exit_code=1, + stderr=f"unexpected {exc.__class__.__name__}: {message}", + timing_ms=(time.perf_counter() - started) * 1000.0, + ) + ) + finally: + # Checkpoint after every line so an interrupted mutating batch leaves + # an ordered record of which commands ran. Stop if the journal can no + # longer be updated; continuing would create unlogged mutations. + if len(rows) != rows_before: + try: + checkpoint = _batch_payload( + rows, + batch_path=batch_path, + finalization={"status": "pending"}, + ) + _write_batch_payload(checkpoint, out_path=args.out) + except (Exception, KeyboardInterrupt) as exc: + checkpoint_error = exc + if terminal_error is None: + terminal_error = exc + terminal_stage = "batch_checkpoint" + if terminal_error is not None: + break + + close_error: BaseException | None = None + session = getattr(args, "_nexus_session", None) + if session is not None: + try: + session.close() + except BaseException as exc: + close_error = exc + if isinstance(exc, KeyboardInterrupt) and terminal_error is None: + terminal_error = exc + terminal_stage = "database_save_or_session_close" + + close_failure: dict[str, Any] | None = None + if close_error is not None: + close_failure = { + "status": "failed", + "stage": "database_save_or_session_close", + "persistence": "unconfirmed", + "error_kind": ( + close_error.kind if isinstance(close_error, NexusSessionError) else close_error.__class__.__name__ + ), + "error": str(close_error).strip() or close_error.__class__.__name__, + } + raw_notes = getattr(close_error, "__notes__", None) + if isinstance(raw_notes, list): + notes = [str(note).strip() for note in raw_notes if str(note).strip()] + if notes: + close_failure["notes"] = notes + + finalization: dict[str, Any] + if terminal_error is not None: + interrupted = isinstance(terminal_error, KeyboardInterrupt) + error_message = str(terminal_error).strip() + if not error_message: + error_message = "interrupted by user" if interrupted else terminal_error.__class__.__name__ + finalization = { + "status": "interrupted" if interrupted else "failed", + "stage": terminal_stage or "batch_execution", + "error_kind": "keyboard_interrupt" if interrupted else terminal_error.__class__.__name__, + "error": error_message, + "session_finalization": {"status": "ok"}, + } + if close_failure is not None: + finalization["persistence"] = "unconfirmed" + finalization["session_finalization"] = close_failure + elif close_failure is not None: + finalization = close_failure + else: + finalization = {"status": "ok"} + + if checkpoint_error is not None and checkpoint_error is not terminal_error: + finalization["checkpoint_finalization"] = { + "status": "failed", + "stage": "batch_checkpoint", + "error_kind": checkpoint_error.__class__.__name__, + "error": str(checkpoint_error).strip() or checkpoint_error.__class__.__name__, + } + + payload = _batch_payload(rows, batch_path=batch_path, finalization=finalization) + artifacts: list[dict[str, Any]] = [] + try: + artifacts = _write_batch_payload(payload, out_path=args.out) + except (Exception, KeyboardInterrupt) as exc: + output_failure = { + "status": "failed", + "stage": "batch_output", + "error_kind": "output_write_failed", + "exception_type": exc.__class__.__name__, + "error": str(exc) or exc.__class__.__name__, + } + if isinstance(exc, KeyboardInterrupt) and terminal_error is None: + terminal_error = exc + terminal_stage = "batch_output" + prior_finalization = finalization + finalization = { + "status": "interrupted", + "stage": "batch_output", + "error_kind": "keyboard_interrupt", + "error": "interrupted by user", + "session_finalization": ( + {"status": "ok"} if prior_finalization["status"] == "ok" else prior_finalization + ), + } + elif finalization["status"] == "ok": + finalization = { + "status": "failed", + "stage": "batch_output", + "error_kind": "output_write_failed", + "error": output_failure["error"], + "session_finalization": {"status": "ok"}, + } + else: + finalization["output_finalization"] = output_failure + payload = _batch_payload(rows, batch_path=batch_path, finalization=finalization) + + exit_code = 130 if isinstance(terminal_error, KeyboardInterrupt) else (0 if payload["ok"] else 1) + return CommandResult( + render_op="batch", + value=payload, + exit_code=exit_code, + stderr_lines=failure_lines(payload), + artifacts=artifacts, + ) + + +def register( + root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] +) -> None: + parser = add_command( + root_parser, subparsers, "batch", help_text="Apply one idac subcommand per line from a batch file" + ) + parser.formatter_class = argparse.RawDescriptionHelpFormatter + parser.epilog = """batch file format: + - one shell-like idac subcommand per line + - omit the leading `idac`; a leading `idac` is also accepted + - blank lines and lines starting with # are ignored + - relative child paths such as --decl-file, --functions-file, and --out resolve from the batch file directory + - mutating children cannot set --out; use the batch wrapper artifact for mutation logging + - preview lines are allowed, for example `preview function prototype set ...` + +example recovery.idac: + type declare --replace --decl-file recovered_types.h + preview function prototype set sub_401000 --decl-file sub_401000.h + function prototype set sub_401000 --decl-file sub_401000.h + function locals update sub_401000 --local-id 'stack(16)@0x1000' --rename count +""" + add_context_options(parser) + parser.add_argument( + "batch_file", + type=Path, + metavar="BATCH_FILE", + help="Read one shell-like idac subcommand per line from this file", + ) + parser.add_argument("-o", "--out", type=Path, help="Write ordered batch results to a JSON or JSONL file") + parser.add_argument("--fail-fast", action="store_true", help="Stop after the first failing command") + parser.add_argument("--lint", action="store_true", help="Parse and validate batch commands without executing them") + parser.set_defaults( + run=bind_root_handler(root_parser, run), + allow_batch=False, + allow_preview=False, + ) diff --git a/src/idac/cli2/commands/__init__.py b/src/idac/cli/commands/__init__.py similarity index 94% rename from src/idac/cli2/commands/__init__.py rename to src/idac/cli/commands/__init__.py index f10daf6..b1f1451 100644 --- a/src/idac/cli2/commands/__init__.py +++ b/src/idac/cli/commands/__init__.py @@ -3,13 +3,13 @@ comment, common, database, - docs, doctor, function, misc, python_exec, search, segment, + setup, targets, top_level, type_commands, @@ -21,13 +21,13 @@ "comment", "common", "database", - "docs", "doctor", "function", "misc", "python_exec", "search", "segment", + "setup", "targets", "top_level", "type_commands", diff --git a/src/idac/cli2/commands/bookmark.py b/src/idac/cli/commands/bookmark.py similarity index 57% rename from src/idac/cli2/commands/bookmark.py rename to src/idac/cli/commands/bookmark.py index ec7c6ab..0e6f122 100644 --- a/src/idac/cli2/commands/bookmark.py +++ b/src/idac/cli/commands/bookmark.py @@ -3,11 +3,10 @@ import argparse from ..argparse_utils import add_command, add_standard_command -from ..commands.common import parse_bookmark_slot, send_op -from ..result import CommandResult +from ..commands.common import OperationBinding, bind_operation, parse_bookmark_slot, run_bound_operation -def _bookmark_show_params(args: argparse.Namespace) -> dict[str, object]: +def _bookmark_slot_params(args: argparse.Namespace) -> dict[str, object]: return {"slot": parse_bookmark_slot(args.slot)} @@ -25,39 +24,29 @@ def _bookmark_set_params(args: argparse.Namespace) -> dict[str, object]: return params -def _bookmark_delete_params(args: argparse.Namespace) -> dict[str, object]: - return {"slot": parse_bookmark_slot(args.slot)} - - -def _list(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="bookmark_get", params={}) - - -def _show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="bookmark_get", params=_bookmark_show_params(args)) - - -def _add(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="bookmark_add", params=_bookmark_add_params(args)) - - -def _set(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="bookmark_set", params=_bookmark_set_params(args)) - - -def _delete(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="bookmark_delete", params=_bookmark_delete_params(args)) - - def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: parser = add_command(root_parser, subparsers, "bookmark", help_text="Bookmark operations") bookmark_subparsers = parser.add_subparsers(dest="bookmark_command") - add_standard_command(parser, bookmark_subparsers, "list", help_text="List bookmarks", run=_list) + child = add_standard_command( + parser, + bookmark_subparsers, + "list", + help_text="List bookmarks", + run=run_bound_operation, + ) + bind_operation(child, OperationBinding("bookmark_get")) - child = add_standard_command(parser, bookmark_subparsers, "show", help_text="Show one bookmark", run=_show) + child = add_standard_command( + parser, + bookmark_subparsers, + "show", + help_text="Show one bookmark", + run=run_bound_operation, + ) + bind_operation(child, OperationBinding("bookmark_get", params_builder=_bookmark_slot_params)) child.add_argument("slot", help="Bookmark slot number") child = add_standard_command( @@ -65,16 +54,22 @@ def register( bookmark_subparsers, "add", help_text="Add a bookmark in the first free slot", - run=_add, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("bookmark_add", params_builder=_bookmark_add_params)) child.add_argument("identifier", help="Address or symbol") child.add_argument("--comment", help="Optional bookmark comment") child = add_standard_command( - parser, bookmark_subparsers, "set", help_text="Set a bookmark", run=_set, mutating=True, default_format="json" + parser, + bookmark_subparsers, + "set", + help_text="Set a bookmark", + run=run_bound_operation, + default_format="json", ) + bind_operation(child, OperationBinding("bookmark_set", params_builder=_bookmark_set_params)) child.add_argument("slot", help="Bookmark slot number") child.add_argument("identifier", help="Address or symbol") child.add_argument("--comment", help="Optional bookmark comment") @@ -84,8 +79,8 @@ def register( bookmark_subparsers, "delete", help_text="Delete a bookmark", - run=_delete, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("bookmark_delete", params_builder=_bookmark_slot_params)) child.add_argument("slot", help="Bookmark slot number") diff --git a/src/idac/cli2/commands/comment.py b/src/idac/cli/commands/comment.py similarity index 78% rename from src/idac/cli2/commands/comment.py rename to src/idac/cli/commands/comment.py index 3a06c17..e884f86 100644 --- a/src/idac/cli2/commands/comment.py +++ b/src/idac/cli/commands/comment.py @@ -3,9 +3,8 @@ import argparse from ..argparse_utils import add_command, add_standard_command -from ..commands.common import send_op +from ..commands.common import OperationBinding, bind_operation, run_bound_operation from ..errors import CliUserError -from ..result import CommandResult def _comment_scope(args: argparse.Namespace) -> str: @@ -64,31 +63,32 @@ def _add_comment_target_options(parser: argparse.ArgumentParser) -> None: ) -def _show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="comment_get", params=_comment_lookup_params(args)) - - -def _set(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="comment_set", params=_comment_change_params(args)) - - -def _delete(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="comment_delete", params=_comment_lookup_params(args)) - - def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: parser = add_command(root_parser, subparsers, "comment", help_text="Comment operations") comment_subparsers = parser.add_subparsers(dest="comment_command") - child = add_standard_command(parser, comment_subparsers, "show", help_text="Show a comment", run=_show) + child = add_standard_command( + parser, + comment_subparsers, + "show", + help_text="Show a comment", + run=run_bound_operation, + ) + bind_operation(child, OperationBinding("comment_get", params_builder=_comment_lookup_params)) child.add_argument("identifier", help="Address or symbol") _add_comment_target_options(child) child = add_standard_command( - parser, comment_subparsers, "set", help_text="Set a comment", run=_set, mutating=True, default_format="json" + parser, + comment_subparsers, + "set", + help_text="Set a comment", + run=run_bound_operation, + default_format="json", ) + bind_operation(child, OperationBinding("comment_set", params_builder=_comment_change_params)) child.add_argument("identifier", help="Address or symbol") child.add_argument("text", help="Comment text") _add_comment_target_options(child) @@ -98,9 +98,9 @@ def register( comment_subparsers, "delete", help_text="Delete a comment", - run=_delete, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("comment_delete", params_builder=_comment_lookup_params)) child.add_argument("identifier", help="Address or symbol") _add_comment_target_options(child) diff --git a/src/idac/cli/commands/common.py b/src/idac/cli/commands/common.py new file mode 100644 index 0000000..578735f --- /dev/null +++ b/src/idac/cli/commands/common.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import argparse +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +from ...operations import MUTATING_OPERATIONS +from ..argparse_utils import read_decl_or_type_text, read_decl_text_if_present +from ..errors import CliUserError +from ..result import CommandResult + +_INFERRED_LOCAL_ID_RE = re.compile( + # Stable local ids from `function locals list --json` encode storage plus a versioned slot identifier. + r"^(?:stack\([^)]*\)|reg\([^)]*\)|regpair\([^)]*\)|unknown)@(?:0x[0-9a-fA-F]+|\d+)$", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class OperationBinding: + operation: str | None = None + parameter_attrs: tuple[tuple[str, str], ...] = () + params_builder: Callable[[argparse.Namespace], dict[str, Any]] | None = None + request_builder: Callable[[argparse.Namespace], tuple[str, dict[str, Any]]] | None = None + + def build_request(self, args: argparse.Namespace) -> tuple[str, dict[str, Any]]: + if self.request_builder is not None: + if self.operation is not None or self.params_builder is not None or self.parameter_attrs: + raise RuntimeError("dynamic operation binding has conflicting request metadata") + return self.request_builder(args) + if self.operation is None: + raise RuntimeError("operation binding is missing an operation name") + if self.params_builder is not None and self.parameter_attrs: + raise RuntimeError(f"operation {self.operation} has conflicting parameter bindings") + params = ( + self.params_builder(args) + if self.params_builder is not None + else {parameter: getattr(args, attribute) for parameter, attribute in self.parameter_attrs} + ) + return self.operation, params + + +@dataclass(frozen=True) +class LocalCommandBinding: + render_op: str + command: Callable[..., Any] + positional_attrs: tuple[str, ...] = () + keyword_attrs: tuple[tuple[str, str], ...] = () + + +def bind_operation(parser: argparse.ArgumentParser, binding: OperationBinding) -> None: + parser.set_defaults( + _operation_binding=binding, + _mutating_command=binding.operation in MUTATING_OPERATIONS if binding.operation is not None else False, + ) + + +def command_result( + render_op: str, + value: Any, + *, + exit_code: int = 0, + stderr_lines: list[str] | None = None, +) -> CommandResult: + return CommandResult( + render_op=render_op, + value=value, + exit_code=exit_code, + stderr_lines=list(stderr_lines or []), + ) + + +def send_op( + args: argparse.Namespace, + *, + op: str, + params: dict[str, Any], + render_op: str | None = None, + preview: bool | None = None, +) -> CommandResult: + payload = dict(params) + operation_mutates = op in MUTATING_OPERATIONS + if bool(args._mutating_command) != operation_mutates: + raise RuntimeError(f"CLI mutation metadata is inconsistent for operation {op}") + preview_requested = bool(args._preview_wrapper and operation_mutates) if preview is None else preview + session = getattr(args, "_nexus_session", None) + if session is None: + raise CliUserError(f"{args._selected_parser.prog} requires a Nexus context") + value = session.execute_operation( + op, + payload, + preview=preview_requested, + operation_label=f"idac: {args._selected_parser.prog.removeprefix('idac ').strip()}", + ) + return command_result(render_op or op, value) + + +def run_bound_operation(args: argparse.Namespace) -> CommandResult: + binding = getattr(args, "_operation_binding", None) + if not isinstance(binding, OperationBinding): + raise RuntimeError("operation command is missing its parser binding") + operation, params = binding.build_request(args) + return send_op(args, op=operation, params=params) + + +def run_bound_local_command(args: argparse.Namespace) -> CommandResult: + binding = getattr(args, "_local_command_binding", None) + if not isinstance(binding, LocalCommandBinding): + raise RuntimeError("local command is missing its parser binding") + positional = [getattr(args, attribute) for attribute in binding.positional_attrs] + keywords = {parameter: getattr(args, attribute) for parameter, attribute in binding.keyword_attrs} + return command_result(binding.render_op, binding.command(*positional, **keywords)) + + +def parse_alias_list(values: list[str] | None) -> list[dict[str, str]]: + aliases: list[dict[str, str]] = [] + for value in values or []: + text = str(value).strip() + if "=" not in text: + raise CliUserError(f"invalid alias `{text}`; expected OLD=NEW") + source, destination = (part.strip() for part in text.split("=", 1)) + if not source or not destination: + raise CliUserError(f"invalid alias `{text}`; expected OLD=NEW") + aliases.append({"from": source, "to": destination}) + return aliases + + +def _parse_cli_int_text(value: Any, *, label: str, minimum: int | None = None) -> int: + try: + parsed = int(str(value).strip(), 0) + except ValueError as exc: + raise CliUserError(f"{label} must be an integer") from exc + if minimum is not None and parsed < minimum: + raise CliUserError(f"{label} must be greater than or equal to {minimum}") + return parsed + + +def parse_bookmark_slot(value: Any) -> int: + return _parse_cli_int_text(value, label="bookmark slot", minimum=0) + + +def _infer_local_selector(token: str) -> tuple[str, Any]: + text = str(token).strip() + if re.fullmatch(r"-?\d+", text): + return "index", _parse_cli_int_text(text, label="local index", minimum=0) + if _INFERRED_LOCAL_ID_RE.match(text): + return "local_id", text + return "old_name", text + + +def _local_selector_params( + args: argparse.Namespace, + *, + name_param: Literal["old_name", "local_name"], +) -> dict[str, Any]: + if args.local_id and args.index is not None: + raise CliUserError("--local-id and --index are mutually exclusive") + selector_text = str(args.selector or "").strip() + if (args.local_id or args.index is not None) and selector_text: + raise CliUserError("do not combine a positional selector with --local-id or --index") + if args.local_id: + return {"local_id": str(args.local_id)} + if args.index is not None: + return {"index": _parse_cli_int_text(args.index, label="local index", minimum=0)} + if not selector_text: + raise CliUserError("local selector is required via selector, --local-id, or --index") + selector_kind, selector_value = _infer_local_selector(selector_text) + if selector_kind == "old_name": + return {name_param: str(selector_value)} + if selector_kind == "local_id": + return {"local_id": str(selector_value)} + return {"index": int(selector_value)} + + +def local_rename_params(args: argparse.Namespace) -> dict[str, Any]: + params: dict[str, Any] = {"identifier": str(args.function), "new_name": str(args.new_name)} + params.update(_local_selector_params(args, name_param="old_name")) + return params + + +def local_retype_params(args: argparse.Namespace) -> dict[str, Any]: + params: dict[str, Any] = {"identifier": str(args.function), "decl": read_decl_or_type_text(args)} + params.update(_local_selector_params(args, name_param="local_name")) + return params + + +def local_update_params(args: argparse.Namespace) -> dict[str, Any]: + new_name = str(args.rename or "").strip() or None + decl = read_decl_text_if_present(args) + if new_name is None and decl is None: + raise CliUserError("at least one of --rename or declaration input is required") + params: dict[str, Any] = {"identifier": str(args.function)} + params.update(_local_selector_params(args, name_param="local_name")) + if new_name is not None: + params["new_name"] = new_name + if decl is not None: + params["decl"] = decl + return params diff --git a/src/idac/cli/commands/database.py b/src/idac/cli/commands/database.py new file mode 100644 index 0000000..e898602 --- /dev/null +++ b/src/idac/cli/commands/database.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import argparse + +from ..argparse_utils import add_command, add_context_options, add_output_options +from ..errors import CliUserError +from ..result import CommandResult +from .common import OperationBinding, bind_operation, command_result, run_bound_operation + + +def _save(args: argparse.Namespace) -> CommandResult: + session = getattr(args, "_nexus_session", None) + if session is None: + raise CliUserError("database save requires a Nexus context") + return command_result("db_save", session.save_database()) + + +def register( + root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] +) -> None: + parser = add_command(root_parser, subparsers, "database", help_text="Inspect or checkpoint database state") + db_subparsers = parser.add_subparsers(dest="database_command") + + child = add_command(parser, db_subparsers, "show", help_text="Show database info") + add_context_options(child) + add_output_options(child, default_format="text") + child.set_defaults( + run=run_bound_operation, + allow_batch=True, + allow_preview=False, + ) + bind_operation(child, OperationBinding("database_info")) + + child = add_command(parser, db_subparsers, "save", help_text="Checkpoint the current database") + add_context_options(child) + add_output_options(child, default_format="json") + child.set_defaults(run=_save, allow_batch=True, allow_preview=False) + + +__all__ = ["register"] diff --git a/src/idac/cli2/commands/doctor.py b/src/idac/cli/commands/doctor.py similarity index 82% rename from src/idac/cli2/commands/doctor.py rename to src/idac/cli/commands/doctor.py index 7f05f31..89f81f0 100644 --- a/src/idac/cli2/commands/doctor.py +++ b/src/idac/cli/commands/doctor.py @@ -10,10 +10,7 @@ def _check(args: argparse.Namespace) -> CommandResult: - result = run_doctor( - scope="all", - timeout=getattr(args, "timeout", None), - ) + result = run_doctor(timeout=getattr(args, "timeout", None)) healthy = bool(result.get("healthy")) return command_result( "doctor", @@ -43,19 +40,19 @@ def _doctor_failure_lines(result: dict[str, Any]) -> list[str]: def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: - parser = add_command(root_parser, subparsers, "doctor", help_text="Inspect local GUI and idalib health") + parser = add_command( + root_parser, subparsers, "doctor", help_text="Inspect the Nexus environment and installed agent skill versions" + ) parser.add_argument( "--timeout", type=positive_timeout, default=argparse.SUPPRESS, - help="Backend request timeout in seconds; omit to wait indefinitely", + help="Diagnostic subprocess, Nexus discovery, and probe timeout in seconds", ) add_output_options(parser, default_format="text") parser.set_defaults( run=_check, _accepts_timeout=True, - context_policy="none", allow_batch=True, allow_preview=False, - _mutating_command=False, ) diff --git a/src/idac/cli2/commands/function.py b/src/idac/cli/commands/function.py similarity index 68% rename from src/idac/cli2/commands/function.py rename to src/idac/cli/commands/function.py index 92129b9..c69d03e 100644 --- a/src/idac/cli2/commands/function.py +++ b/src/idac/cli/commands/function.py @@ -11,13 +11,19 @@ add_decl_input, add_output_options, add_pattern_options, - add_retype_input, add_segment_option, add_standard_command, positive_int, read_decl_text, ) -from ..commands.common import local_rename_params, local_retype_params, local_update_params, send_op +from ..commands.common import ( + OperationBinding, + bind_operation, + local_rename_params, + local_retype_params, + local_update_params, + run_bound_operation, +) from ..errors import CliUserError from ..result import CommandResult @@ -43,10 +49,8 @@ def _list_params(args: argparse.Namespace) -> dict[str, object]: - if args.pattern and args.query: - raise CliUserError("function list accepts either positional pattern or --query, not both") params: dict[str, object] = { - "pattern": args.pattern if args.pattern is not None else args.query, + "pattern": args.pattern, "regex": args.regex, "ignore_case": args.ignore_case, "demangle": args.demangle, @@ -58,46 +62,6 @@ def _list_params(args: argparse.Namespace) -> dict[str, object]: return params -def run_list(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="function_list", params=_list_params(args)) - - -def run_metadata(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="function_show", params={"identifier": args.function}) - - -def run_frame(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="function_frame", params={"identifier": args.function}) - - -def run_stackvars(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="function_stackvars", params={"identifier": args.function}) - - -def run_callees(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="function_callees", params={"identifier": args.function}) - - -def run_callers(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="function_callers", params={"identifier": args.function}) - - -def run_locals_list(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="local_list", params={"identifier": args.function}) - - -def run_locals_rename(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="local_rename", params=local_rename_params(args)) - - -def run_locals_retype(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="local_retype", params=local_retype_params(args)) - - -def run_locals_update(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="local_update", params=local_update_params(args)) - - def _locals_apply_plan_params(args: argparse.Namespace) -> dict[str, object]: path = Path(args.json_file) try: @@ -108,18 +72,9 @@ def _locals_apply_plan_params(args: argparse.Namespace) -> dict[str, object]: raw = json.loads(raw_text) except json.JSONDecodeError as exc: raise CliUserError(f"invalid local apply JSON: {exc}") from exc - items = (raw.get("items") or raw.get("locals")) if isinstance(raw, dict) else raw - if not isinstance(items, list): - raise CliUserError("local apply JSON must be a list or an object with an items list") - return {"identifier": args.function, "items": items} - - -def run_locals_apply_plan(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="local_apply_plan", params=_locals_apply_plan_params(args)) - - -def run_prototype_show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="proto_get", params={"identifier": args.function}) + if not isinstance(raw, list): + raise CliUserError("local apply JSON must be a list") + return {"identifier": args.function, "items": raw} def _prototype_set_params(args: argparse.Namespace) -> dict[str, object]: @@ -129,16 +84,13 @@ def _prototype_set_params(args: argparse.Namespace) -> dict[str, object]: } if args.propagate_callers: params["propagate_callers"] = True + if args.preserve_cc: + params["preserve_cc"] = True if args._preview_wrapper: params["preview_decompile"] = True return params -def run_prototype_set(args: argparse.Namespace) -> CommandResult: - params = _prototype_set_params(args) - return send_op(args, op="proto_set", params=params) - - def _prototype_check_failure_lines(value: dict[str, Any]) -> list[str]: diagnostics = [str(item) for item in value.get("diagnostics") or [] if str(item)] lines = ["function prototype check failed:"] @@ -149,21 +101,16 @@ def _prototype_check_failure_lines(value: dict[str, Any]) -> list[str]: return lines +def _prototype_check_params(args: argparse.Namespace) -> dict[str, object]: + return {"identifier": args.function, "decl": read_decl_text(args)} + + def run_prototype_check(args: argparse.Namespace) -> CommandResult: - result = send_op(args, op="proto_check", params={"identifier": args.function, "decl": read_decl_text(args)}) - exit_code = 0 - stderr_lines: list[str] = [] + result = run_bound_operation(args) if isinstance(result.value, dict) and result.value.get("success") is False: - exit_code = 1 - stderr_lines = _prototype_check_failure_lines(result.value) - return CommandResult( - render_op=result.render_op, - value=result.value, - exit_code=exit_code, - warnings=list(result.warnings), - stderr_lines=stderr_lines, - artifacts=list(result.artifacts), - ) + result.exit_code = 1 + result.stderr_lines = _prototype_check_failure_lines(result.value) + return result def register( @@ -184,7 +131,6 @@ def register( "This is a filter, not a list of function names." ), ) - child.add_argument("--query", help=argparse.SUPPRESS) child.add_argument("--limit", type=positive_int, help="Maximum number of functions to return") child.add_argument( "--demangle", @@ -194,47 +140,75 @@ def register( add_segment_option(child) add_pattern_options(child, label="NAME_FILTER") child.set_defaults( - run=run_list, - context_policy="standard", + run=run_bound_operation, allow_batch=True, allow_preview=True, - _mutating_command=False, - query=None, limit=None, demangle=False, ) + bind_operation(child, OperationBinding("function_list", params_builder=_list_params)) child = add_standard_command( - parser, parser_subparsers, "metadata", help_text="Show function metadata", run=run_metadata + parser, + parser_subparsers, + "metadata", + help_text="Show function metadata", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("function_show", parameter_attrs=(("identifier", "function"),))) child.add_argument("function", help="Function name or address") child = add_standard_command( - parser, parser_subparsers, "frame", help_text="Show raw function frame layout", run=run_frame + parser, + parser_subparsers, + "frame", + help_text="Show raw function frame layout", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("function_frame", parameter_attrs=(("identifier", "function"),))) child.add_argument("function", help="Function name or address") child = add_standard_command( - parser, parser_subparsers, "stackvars", help_text="Show stack variables and xrefs", run=run_stackvars + parser, + parser_subparsers, + "stackvars", + help_text="Show stack variables and xrefs", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("function_stackvars", parameter_attrs=(("identifier", "function"),))) child.add_argument("function", help="Function name or address") child = add_standard_command( - parser, parser_subparsers, "callees", help_text="Show called functions and call sites", run=run_callees + parser, + parser_subparsers, + "callees", + help_text="Show called functions and call sites", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("function_callees", parameter_attrs=(("identifier", "function"),))) child.add_argument("function", help="Function name or address") child = add_standard_command( - parser, parser_subparsers, "callers", help_text="Show callers and call sites", run=run_callers + parser, + parser_subparsers, + "callers", + help_text="Show callers and call sites", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("function_callers", parameter_attrs=(("identifier", "function"),))) child.add_argument("function", help="Function name or address") locals_parser = add_command(parser, parser_subparsers, "locals", help_text="Decompiler local variable operations") locals_subparsers = locals_parser.add_subparsers(dest="locals_command") child = add_standard_command( - locals_parser, locals_subparsers, "list", help_text="List decompiler locals", run=run_locals_list + locals_parser, + locals_subparsers, + "list", + help_text="List decompiler locals", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("local_list", parameter_attrs=(("identifier", "function"),))) child.add_argument("function", help="Function name or address") child = add_standard_command( @@ -242,10 +216,10 @@ def register( locals_subparsers, "rename", help_text="Rename one local variable", - run=run_locals_rename, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("local_rename", params_builder=local_rename_params)) child.formatter_class = argparse.RawDescriptionHelpFormatter child.epilog = LOCAL_SELECTOR_EPILOG child.add_argument("function", help="Function name or address") @@ -264,10 +238,10 @@ def register( locals_subparsers, "retype", help_text="Retype one local variable", - run=run_locals_retype, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("local_retype", params_builder=local_retype_params)) child.formatter_class = argparse.RawDescriptionHelpFormatter child.epilog = LOCAL_SELECTOR_EPILOG child.add_argument("function", help="Function name or address") @@ -279,17 +253,33 @@ def register( ) child.add_argument("--local-id", dest="local_id", help="Stable local id from `function locals list --json`") child.add_argument("--index", help="Decompiler local index from `function locals list --json`") - add_retype_input(child) + retype_input = child.add_mutually_exclusive_group(required=True) + retype_input.add_argument( + "--type", + dest="type_text", + help="Local type text shorthand, for example `unsigned int`; use --decl or --decl-file for complex declarators", + ) + retype_input.add_argument( + "--decl", + help="Full local variable declaration text, for example `unsigned int value;`", + ) + retype_input.add_argument( + "--decl-file", + dest="decl_file", + type=Path, + help="Read full local variable declaration text from this file", + ) + child.set_defaults(_input_path_attrs=("decl_file",)) child = add_standard_command( locals_parser, locals_subparsers, "update", help_text="Rename and/or retype one local variable", - run=run_locals_update, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("local_update", params_builder=local_update_params)) child.formatter_class = argparse.RawDescriptionHelpFormatter child.epilog = LOCAL_SELECTOR_EPILOG child.add_argument("function", help="Function name or address") @@ -305,16 +295,17 @@ def register( type=Path, help="Read full local variable declaration text from this file", ) + child.set_defaults(_input_path_attrs=("decl_file",)) child = add_standard_command( locals_parser, locals_subparsers, "apply", help_text="Apply local updates from JSON", - run=run_locals_apply_plan, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("local_apply_plan", params_builder=_locals_apply_plan_params)) child.formatter_class = argparse.RawDescriptionHelpFormatter child.epilog = """plan JSON: [ @@ -324,13 +315,19 @@ def register( """ child.add_argument("function", help="Function name or address") child.add_argument("--json-file", required=True, type=Path, help="Read local update plan JSON from this file") + child.set_defaults(_input_path_attrs=("json_file",)) proto_parser = add_command(parser, parser_subparsers, "prototype", help_text="Prototype operations") proto_subparsers = proto_parser.add_subparsers(dest="prototype_command") child = add_standard_command( - proto_parser, proto_subparsers, "show", help_text="Show a prototype", run=run_prototype_show + proto_parser, + proto_subparsers, + "show", + help_text="Show a prototype", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("proto_get", parameter_attrs=(("identifier", "function"),))) child.add_argument("function", help="Function name or address") child = add_standard_command( @@ -341,6 +338,7 @@ def register( run=run_prototype_check, default_format="json", ) + bind_operation(child, OperationBinding("proto_check", params_builder=_prototype_check_params)) child.add_argument("function", help="Function name or address") add_decl_input(child, help_text="Prototype declaration text") @@ -354,11 +352,15 @@ def register( action="store_true", help="Also apply the new callee type at matching caller call sites", ) + child.add_argument( + "--preserve-cc", + action="store_true", + help="Keep the existing calling convention when editing parameter names or types and the return type", + ) child.set_defaults( - run=run_prototype_set, - context_policy="standard", + run=run_bound_operation, allow_batch=True, allow_preview=True, - _mutating_command=True, propagate_callers=False, ) + bind_operation(child, OperationBinding("proto_set", params_builder=_prototype_set_params)) diff --git a/src/idac/cli/commands/misc.py b/src/idac/cli/commands/misc.py new file mode 100644 index 0000000..b893362 --- /dev/null +++ b/src/idac/cli/commands/misc.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import argparse + +from ..argparse_utils import add_command, add_context_options, add_output_options +from .common import OperationBinding, bind_operation, run_bound_operation + + +def _reanalyze_params(args: argparse.Namespace) -> dict[str, object]: + params: dict[str, object] = {"identifier": args.identifier} + if args.end: + params["end"] = args.end + return params + + +def register( + root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] +) -> None: + parser = add_command(root_parser, subparsers, "misc", help_text="Miscellaneous IDA operations") + misc_subparsers = parser.add_subparsers(dest="misc_command") + + child = add_command(parser, misc_subparsers, "rename", help_text="Rename an item") + add_context_options(child) + add_output_options(child, default_format="json") + child.add_argument("identifier", help="Function name, symbol, or address") + child.add_argument("new_name", help="Replacement name") + child.set_defaults( + run=run_bound_operation, + allow_batch=True, + allow_preview=True, + ) + bind_operation( + child, + OperationBinding("name_set", parameter_attrs=(("identifier", "identifier"), ("new_name", "new_name"))), + ) + + child = add_command(parser, misc_subparsers, "reanalyze", help_text="Re-run IDA analysis on a function or range") + add_context_options(child) + add_output_options(child, default_format="json") + child.add_argument("identifier", help="Function name, symbol, or address") + child.add_argument("--end", help="Optional end address for range reanalysis") + child.set_defaults( + run=run_bound_operation, + allow_batch=True, + allow_preview=False, + ) + bind_operation(child, OperationBinding("reanalyze", params_builder=_reanalyze_params)) + + +__all__ = ["register"] diff --git a/src/idac/cli/commands/python_exec.py b/src/idac/cli/commands/python_exec.py new file mode 100644 index 0000000..ca3be4b --- /dev/null +++ b/src/idac/cli/commands/python_exec.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from ..argparse_utils import add_command, add_context_options, add_output_options +from ..errors import CliUserError +from ..result import CommandResult +from .common import command_result + +_CONVENIENCE_MODULES = ( + "idaapi", + "ida_auto", + "ida_bytes", + "ida_entry", + "ida_frame", + "ida_funcs", + "ida_hexrays", + "ida_ida", + "ida_idc", + "ida_idp", + "ida_kernwin", + "ida_lines", + "ida_loader", + "ida_moves", + "ida_name", + "ida_nalt", + "ida_range", + "ida_segment", + "ida_strlist", + "ida_typeinf", + "ida_ua", + "ida_undo", + "ida_xref", + "idautils", + "idc", +) + + +def _nexus_python_source(user_code: str, filename: str) -> str: + """Wrap user code in a stateless JSON-safe Nexus call.""" + + return f"""\ +import importlib as _idac_importlib +import json as _idac_json +for _idac_module_name in {_CONVENIENCE_MODULES!r}: + try: + globals()[_idac_module_name] = _idac_importlib.import_module(_idac_module_name) + except (ImportError, OSError): + pass +__file__ = {filename!r} +result = None +exec(compile({user_code!r}, {filename!r}, "exec"), globals(), globals()) +_idac_result = result +try: + _idac_json.dumps(_idac_result) +except (TypeError, ValueError): + _idac_json_result = None +else: + _idac_json_result = _idac_result +{{"result": _idac_json_result, "result_repr": repr(_idac_result)}} +""" + + +def _exec(args: argparse.Namespace) -> CommandResult: + filename = "" + if args.code is not None: + code = str(args.code) + elif args.stdin: + code = sys.stdin.read() + else: + path = Path(args.script) + if not path.is_file(): + raise CliUserError(f"script file not found: {path}") + path = path.resolve() + filename = str(path) + code = path.read_text(encoding="utf-8") + if not code.strip(): + raise CliUserError("Python input must not be empty") + + session = getattr(args, "_nexus_session", None) + if session is None: + raise CliUserError("py exec requires a Nexus context") + execution = session.execute_python( + _nexus_python_source(code, filename), + filename=filename, + operation_label="idac: py exec", + ) + value = execution.get("result") + if not isinstance(value, dict): + raise CliUserError("Nexus returned an invalid Python result") + value = dict(value) + value["stdout"] = str(execution.get("stdout") or "") + value["stderr"] = str(execution.get("stderr") or "") + return command_result("python_exec", value) + + +def register( + root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] +) -> None: + parser = add_command(root_parser, subparsers, "py", help_text="Execute stateless Python through ida-nexus") + py_subparsers = parser.add_subparsers(dest="py_command") + + child = add_command(parser, py_subparsers, "exec", help_text="Execute Python code") + add_context_options(child) + add_output_options(child, default_format="text") + mode = child.add_mutually_exclusive_group(required=True) + mode.add_argument("--code", help="Execute inline Python code") + mode.add_argument("--stdin", action="store_true", help="Read Python code from stdin") + mode.add_argument("--script", type=Path, help="Read Python code from this local file") + child.set_defaults( + run=_exec, + allow_batch=True, + allow_preview=False, + _mutating_command=True, + _input_path_attrs=("script",), + ) + + +__all__ = ["register"] diff --git a/src/idac/cli2/commands/search.py b/src/idac/cli/commands/search.py similarity index 90% rename from src/idac/cli2/commands/search.py rename to src/idac/cli/commands/search.py index fed1b1b..4833774 100644 --- a/src/idac/cli2/commands/search.py +++ b/src/idac/cli/commands/search.py @@ -9,9 +9,8 @@ add_standard_command, positive_int, ) -from ..commands.common import send_op +from ..commands.common import OperationBinding, bind_operation, run_bound_operation from ..errors import CliUserError -from ..result import CommandResult def _bytes_params(args: argparse.Namespace) -> dict[str, object]: @@ -27,10 +26,6 @@ def _bytes_params(args: argparse.Namespace) -> dict[str, object]: return params -def _bytes(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="search_bytes", params=_bytes_params(args)) - - def _strings_params(args: argparse.Namespace) -> dict[str, object]: if not args.scan and (args.start is not None or args.end is not None): raise CliUserError("`--start` and `--end` are only valid with `search strings --scan`") @@ -49,10 +44,6 @@ def _strings_params(args: argparse.Namespace) -> dict[str, object]: return params -def _strings(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="strings", params=_strings_params(args)) - - def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: @@ -64,14 +55,15 @@ def register( search_subparsers, "bytes", help_text="Search for a byte pattern", - run=_bytes, + run=run_bound_operation, require_timeout=True, timeout_requirement_label="`idac search bytes`", ) + bind_operation(child, OperationBinding("search_bytes", params_builder=_bytes_params)) child.formatter_class = argparse.RawDescriptionHelpFormatter child.epilog = """examples: # Search defined bytes with IDA byte-pattern syntax, not regex/text matching - idac search bytes '48 8B ?? ??' --segment __TEXT --timeout 30 -c db:sample.i64 + idac search bytes '48 8B ?? ??' --segment __TEXT --timeout 30 -c sample.i64 # Restrict the search to an address range inside the segment idac search bytes 'DE AD BE EF' --segment __TEXT --start 0x401000 --end 0x402000 --timeout 30 @@ -91,14 +83,15 @@ def register( search_subparsers, "strings", help_text="List defined strings", - run=_strings, + run=run_bound_operation, require_timeout=True, timeout_requirement_label="`idac search strings`", ) + bind_operation(child, OperationBinding("strings", params_builder=_strings_params)) child.formatter_class = argparse.RawDescriptionHelpFormatter child.epilog = """examples: # Filter already-defined strings by text - idac search strings error --segment __cstring --timeout 30 -c db:sample.i64 + idac search strings error --segment __cstring --timeout 30 -c sample.i64 # Regex-filter defined strings idac search strings 'error|warning' --regex --segment __cstring --timeout 30 diff --git a/src/idac/cli2/commands/segment.py b/src/idac/cli/commands/segment.py similarity index 58% rename from src/idac/cli2/commands/segment.py rename to src/idac/cli/commands/segment.py index a982f62..e15533c 100644 --- a/src/idac/cli2/commands/segment.py +++ b/src/idac/cli/commands/segment.py @@ -3,20 +3,7 @@ import argparse from ..argparse_utils import add_command, add_pattern_options, add_standard_command -from ..commands.common import send_op -from ..result import CommandResult - - -def _list_params(args: argparse.Namespace) -> dict[str, object]: - return { - "pattern": args.pattern, - "regex": args.regex, - "ignore_case": args.ignore_case, - } - - -def _list(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="segment_list", params=_list_params(args)) +from ..commands.common import OperationBinding, bind_operation, run_bound_operation def register( @@ -25,7 +12,20 @@ def register( parser = add_command(root_parser, subparsers, "segment", help_text="Segment operations") segment_subparsers = parser.add_subparsers(dest="segment_command") - child = add_standard_command(parser, segment_subparsers, "list", help_text="List segments", run=_list) + child = add_standard_command( + parser, + segment_subparsers, + "list", + help_text="List segments", + run=run_bound_operation, + ) + bind_operation( + child, + OperationBinding( + "segment_list", + parameter_attrs=(("pattern", "pattern"), ("regex", "regex"), ("ignore_case", "ignore_case")), + ), + ) child.add_argument( "pattern", nargs="?", diff --git a/src/idac/cli/commands/setup.py b/src/idac/cli/commands/setup.py new file mode 100644 index 0000000..83190f0 --- /dev/null +++ b/src/idac/cli/commands/setup.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import argparse + +from ...setup import setup_gui +from ..argparse_utils import add_command, add_output_options, positive_timeout +from .common import LocalCommandBinding, run_bound_local_command + + +def register( + root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] +) -> None: + parser = add_command(root_parser, subparsers, "setup", help_text="Install idac integrations") + setup_subparsers = parser.add_subparsers(dest="setup_command") + + child = add_command( + parser, setup_subparsers, "gui", help_text="Install the matching ida-nexus plugin through ida-hcli" + ) + add_output_options(child, default_format="json") + child.add_argument("--timeout", type=positive_timeout, help="Installer timeout in seconds") + child.set_defaults( + run=run_bound_local_command, + _local_command_binding=LocalCommandBinding("setup_gui", setup_gui, keyword_attrs=(("timeout", "timeout"),)), + allow_batch=False, + allow_preview=False, + _accepts_timeout=True, + ) + + +__all__ = ["register"] diff --git a/src/idac/cli/commands/targets.py b/src/idac/cli/commands/targets.py new file mode 100644 index 0000000..709b006 --- /dev/null +++ b/src/idac/cli/commands/targets.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import argparse + +from ... import nexus +from ..argparse_utils import add_command, add_output_options, positive_timeout +from .common import LocalCommandBinding, run_bound_local_command + + +def register( + root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] +) -> None: + parser = add_command(root_parser, subparsers, "targets", help_text="List ida-nexus database instances") + targets_subparsers = parser.add_subparsers(dest="targets_command") + + child = add_command(parser, targets_subparsers, "list", help_text="List discovered Nexus instances") + add_output_options(child, default_format="text") + child.add_argument("--timeout", type=positive_timeout, help="Discovery probe timeout in seconds") + child.set_defaults( + run=run_bound_local_command, + _local_command_binding=LocalCommandBinding( + "list_targets", nexus.list_targets, keyword_attrs=(("timeout", "timeout"),) + ), + allow_batch=True, + allow_preview=False, + _accepts_timeout=True, + ) + + +__all__ = ["register"] diff --git a/src/idac/cli2/commands/top_level.py b/src/idac/cli/commands/top_level.py similarity index 68% rename from src/idac/cli2/commands/top_level.py rename to src/idac/cli/commands/top_level.py index eacd9e1..a1cb1bf 100644 --- a/src/idac/cli2/commands/top_level.py +++ b/src/idac/cli/commands/top_level.py @@ -7,10 +7,10 @@ from pathlib import Path from typing import Any +from ...nexus import NexusSessionError from ...output import write_output_result -from ...transport import BackendError from ..argparse_utils import add_standard_command -from ..commands.common import send_op +from ..commands.common import OperationBinding, bind_operation, run_bound_operation, send_op from ..errors import CliUserError from ..result import CommandResult @@ -38,18 +38,11 @@ class ArtifactStem: truncated: bool -@dataclass(frozen=True) -class DisasmRequest: - op: str - render_op: str - params: dict[str, object] - - def _decompilemany_request(args: argparse.Namespace) -> DecompileManyRequest: patterns = tuple(item for item in args.patterns if item) pattern = patterns[0] if patterns else None extra_patterns = patterns[1:] - return DecompileManyRequest( + request = DecompileManyRequest( pattern=pattern, extra_patterns=extra_patterns, file=args.file, @@ -61,29 +54,33 @@ def _decompilemany_request(args: argparse.Namespace) -> DecompileManyRequest: include_disasm=bool(args.disasm), include_ctree=bool(args.ctree), ) - - -def _safe_filename_name(name: str) -> str: - return _SAFE_FILENAME_RE.sub("_", name).strip("._-") or "function" - - -def _safe_address(address: str) -> str: - return _SAFE_FILENAME_RE.sub("_", address).strip("._-") or "ea" - - -def _identity_digest(*, name: str, address: str, identifier: str) -> str: - text = "\0".join((name, address, identifier)) - return f"{zlib.crc32(text.encode('utf-8')) & 0xFFFFFFFF:08x}" + if request.extra_patterns: + examples = " ".join((request.pattern or "", *request.extra_patterns[:3])).strip() + suffix = f": {examples}" if examples else "" + raise CliUserError( + "decompilemany accepts one FUNCTION_FILTER, not multiple exact function names" + f"{suffix}. For multiple exact functions, write one function name or address per line " + "and pass --functions-file/--file ." + ) + if request.pattern not in (None, "") and request.file is not None: + raise CliUserError("decompilemany accepts either FUNCTION_FILTER or --functions-file/--file, not both") + if request.pattern in (None, "") and request.file is None: + raise CliUserError("decompilemany requires either FUNCTION_FILTER or --functions-file/--file") + if request.out_file is not None and (request.include_disasm or request.include_ctree): + raise CliUserError("decompilemany --disasm/--ctree require --out-dir") + return request def _stem_for_function(name: str, address: str, identifier: str) -> ArtifactStem: - safe_name = _safe_filename_name(name) - safe_address = _safe_address(address) + safe_name = _SAFE_FILENAME_RE.sub("_", name).strip("._-") or "function" + safe_address = _SAFE_FILENAME_RE.sub("_", address).strip("._-") or "ea" full_stem = f"{safe_name}_{safe_address}" if len(full_stem) <= _MAX_FILENAME_STEM: return ArtifactStem(full_stem, False) - suffix = f"_{_identity_digest(name=name, address=address, identifier=identifier)}_{safe_address}" + identity = "\0".join((name, address, identifier)).encode("utf-8") + digest = f"{zlib.crc32(identity) & 0xFFFFFFFF:08x}" + suffix = f"_{digest}_{safe_address}" available = max(24, _MAX_FILENAME_STEM - len(suffix) - 1) head_len = max(12, (available * 2) // 3) tail_len = max(8, available - head_len) @@ -92,20 +89,7 @@ def _stem_for_function(name: str, address: str, identifier: str) -> ArtifactStem return ArtifactStem(f"{head}_{tail}{suffix}", True) -def _decompilemany_targets(args: argparse.Namespace) -> list[dict[str, Any]]: - request = _decompilemany_request(args) - if request.extra_patterns: - examples = " ".join((request.pattern or "", *request.extra_patterns[:3])).strip() - suffix = f": {examples}" if examples else "" - raise CliUserError( - "decompilemany accepts one FUNCTION_FILTER, not multiple exact function names" - f"{suffix}. For multiple exact functions, write one function name or address per line " - "and pass --functions-file/--file ." - ) - if request.pattern not in (None, "") and request.file is not None: - raise CliUserError("decompilemany accepts either FUNCTION_FILTER or --functions-file/--file, not both") - if request.pattern in (None, "") and request.file is None: - raise CliUserError("decompilemany requires either FUNCTION_FILTER or --functions-file/--file") +def _decompilemany_targets(args: argparse.Namespace, request: DecompileManyRequest) -> list[dict[str, Any]]: if request.file is not None: rows: list[str] = [] for raw_line in request.file.read_text(encoding="utf-8").splitlines(): @@ -160,34 +144,16 @@ def _decompilemany_targets(args: argparse.Namespace) -> list[dict[str, Any]]: ] -def _run_single_decompile( - args: argparse.Namespace, - *, - identifier: str, -) -> dict[str, Any]: - request = _decompilemany_request(args) - result = send_op( - args, - op="decompile", - params={"identifier": identifier, "no_cache": request.no_cache}, - preview=False, - ) - value = result.value - if not isinstance(value, dict) or not isinstance(value.get("text"), str): - raise RuntimeError("decompile returned an unexpected result shape") - return value - - -def _run_single_text_op( +def _run_text_operation( args: argparse.Namespace, *, op: str, - identifier: str, + params: dict[str, object], ) -> dict[str, Any]: result = send_op( args, op=op, - params={"identifier": identifier}, + params=params, preview=False, ) value = result.value @@ -196,55 +162,28 @@ def _run_single_text_op( return value -def run_decompile(args: argparse.Namespace) -> CommandResult: - return send_op( - args, - op="decompile", - params={"identifier": args.function, "no_cache": bool(args.no_cache)}, - ) - - -def disasm_request(args: argparse.Namespace) -> DisasmRequest: +def disasm_request(args: argparse.Namespace) -> tuple[str, dict[str, object]]: if args.start is not None or args.end is not None: if args.start is None or args.end is None: raise CliUserError("disasm range requires both --start and --end") if args.function: raise CliUserError("disasm range uses --start/--end; omit the function argument") - return DisasmRequest( - op="disasm_range", - render_op="disasm_range", - params={"start": args.start, "end": args.end}, - ) + return "disasm_range", {"start": args.start, "end": args.end} if not args.function: raise CliUserError("disasm requires a function or --start/--end") - return DisasmRequest(op="disasm", render_op="disasm", params={"identifier": args.function}) - - -def run_disasm(args: argparse.Namespace) -> CommandResult: - request = disasm_request(args) - return send_op(args, op=request.op, params=request.params, render_op=request.render_op) + return "disasm", {"identifier": args.function} -def run_ctree(args: argparse.Namespace) -> CommandResult: +def _ctree_params(args: argparse.Namespace) -> dict[str, Any]: params: dict[str, Any] = {"identifier": args.function, "level": args.level} if args.maturity: params["maturity"] = args.maturity - return send_op(args, op="ctree", params=params) - - -def run_xrefs(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="xrefs", params={"identifier": args.identifier}) - - -def run_imports(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="imports", params={}) + return params def run_decompilemany(args: argparse.Namespace) -> CommandResult: request = _decompilemany_request(args) - if request.out_file is not None and (request.include_disasm or request.include_ctree): - raise CliUserError("decompilemany --disasm/--ctree require --out-dir") - targets = _decompilemany_targets(args) + targets = _decompilemany_targets(args, request) if not targets: raise CliUserError("no functions matched") @@ -259,15 +198,21 @@ def run_decompilemany(args: argparse.Namespace) -> CommandResult: out_dir.mkdir(parents=True, exist_ok=True) for item in targets: identifier = str(item["identifier"]) - disasm_payload: dict[str, Any] | None = None - ctree_payload: dict[str, Any] | None = None try: - payload = _run_single_decompile(args, identifier=identifier) - if request.include_disasm: - disasm_payload = _run_single_text_op(args, op="disasm", identifier=identifier) - if request.include_ctree: - ctree_payload = _run_single_text_op(args, op="ctree", identifier=identifier) - except (BackendError, CliUserError) as exc: + payload = _run_text_operation( + args, + op="decompile", + params={"identifier": identifier, "no_cache": request.no_cache}, + ) + extra_payloads = { + op: _run_text_operation(args, op=op, params={"identifier": identifier}) + for op, enabled in ( + ("disasm", request.include_disasm), + ("ctree", request.include_ctree), + ) + if enabled + } + except (NexusSessionError, CliUserError) as exc: failed += 1 entries.append( { @@ -304,31 +249,33 @@ def run_decompilemany(args: argparse.Namespace) -> CommandResult: entry["artifact_stem"] = stem if stem_info.truncated: entry["filename_truncated"] = True - if disasm_payload is not None: - disasm_text = str(disasm_payload["text"]) - disasm_path = out_dir / f"{stem}.asm" - output = write_output_result(disasm_text, fmt="text", out_path=disasm_path, stem="disasm") - artifact = dict(output.artifact or {}) - artifact.update({"kind": "disasm", "identifier": identifier, "chars": len(disasm_text)}) - artifacts.append(artifact) - entry_artifacts["disasm"] = str(disasm_path) - entry["disasm_chars"] = len(disasm_text) - if ctree_payload is not None: - ctree_text = str(ctree_payload["text"]) - ctree_path = out_dir / f"{stem}.ctree" - output = write_output_result(ctree_text, fmt="text", out_path=ctree_path, stem="ctree") + for kind, suffix in (("disasm", "asm"), ("ctree", "ctree")): + extra_payload = extra_payloads.get(kind) + if extra_payload is None: + continue + extra_text = str(extra_payload["text"]) + extra_path = out_dir / f"{stem}.{suffix}" + output = write_output_result(extra_text, fmt="text", out_path=extra_path, stem=kind) artifact = dict(output.artifact or {}) - artifact.update({"kind": "ctree", "identifier": identifier, "chars": len(ctree_text)}) + artifact.update({"kind": kind, "identifier": identifier, "chars": len(extra_text)}) artifacts.append(artifact) - entry_artifacts["ctree"] = str(ctree_path) - entry["ctree_chars"] = len(ctree_text) + entry_artifacts[kind] = str(extra_path) + entry[f"{kind}_chars"] = len(extra_text) entry["artifacts"] = entry_artifacts entries.append(entry) + summary = { + "ok": failed == 0, + "pattern": request.pattern, + "file": None if request.file is None else str(request.file), + "functions_total": len(targets), + "functions_succeeded": succeeded, + "functions_failed": failed, + "functions": entries, + } if out_dir is None: artifact_path = request.out_file - if artifact_path is None: - raise CliUserError("decompilemany requires --out-file or --out-dir") + assert artifact_path is not None combined = "\n\n\n".join(combined_sections) output = write_output_result( combined, @@ -340,42 +287,15 @@ def run_decompilemany(args: argparse.Namespace) -> CommandResult: artifact = dict(output.artifact or {}) artifact["kind"] = "combined_text" artifacts.append(artifact) - summary = { - "ok": failed == 0, - "pattern": request.pattern, - "file": None if request.file is None else str(request.file), - "out_file": str(artifact_path), - "functions_total": len(targets), - "functions_succeeded": succeeded, - "functions_failed": failed, - "functions": entries, - } - stderr_lines = _decompilemany_failure_lines(summary) - return CommandResult( - render_op="decompile_bulk", - value=summary, - exit_code=0 if failed == 0 else 1, - stderr_lines=stderr_lines, - artifacts=artifacts, - ) - - manifest = { - "ok": failed == 0, - "pattern": request.pattern, - "file": None if request.file is None else str(request.file), - "out_dir": str(out_dir), - "functions_total": len(targets), - "functions_succeeded": succeeded, - "functions_failed": failed, - "functions": entries, - } - manifest_path = out_dir / "manifest.json" - output = write_output_result(manifest, fmt="json", out_path=manifest_path, stem="decompile_manifest") - artifact = dict(output.artifact or {}) - artifact["kind"] = "manifest" - artifacts.append(artifact) - summary = dict(manifest) - summary["manifest_path"] = str(manifest_path) + summary["out_file"] = str(artifact_path) + else: + summary["out_dir"] = str(out_dir) + manifest_path = out_dir / "manifest.json" + output = write_output_result(summary, fmt="json", out_path=manifest_path, stem="decompile_manifest") + artifact = dict(output.artifact or {}) + artifact["kind"] = "manifest" + artifacts.append(artifact) + summary["manifest_path"] = str(manifest_path) stderr_lines = _decompilemany_failure_lines(summary) return CommandResult( render_op="decompile_bulk", @@ -414,7 +334,15 @@ def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: parser = add_standard_command( - root_parser, subparsers, "decompile", help_text="Decompile one function", run=run_decompile + root_parser, + subparsers, + "decompile", + help_text="Decompile one function", + run=run_bound_operation, + ) + bind_operation( + parser, + OperationBinding("decompile", parameter_attrs=(("identifier", "function"), ("no_cache", "no_cache"))), ) parser.add_argument("function", help="Function name or address") parser.add_argument( @@ -432,17 +360,18 @@ def register( help_text="Decompile functions selected by name filter or target file", run=run_decompilemany, ) + parser.set_defaults(allow_preview=False, _request_validator=_decompilemany_request) parser.formatter_class = argparse.RawDescriptionHelpFormatter parser.epilog = """examples: # Decompile every function whose name contains Handler_ - idac decompilemany Handler_ --out-dir .idac/tmp/decomp -c db:sample.i64 + idac decompilemany Handler_ --out-dir .idac/tmp/decomp -c sample.i64 # Decompile an explicit set of functions printf '%s\\n' main sub_401000 0x401234 > funcs.txt - idac decompilemany --functions-file funcs.txt --out-dir .idac/tmp/decomp -c db:sample.i64 + idac decompilemany --functions-file funcs.txt --out-dir .idac/tmp/decomp -c sample.i64 # Write explicit functions into one combined output file - idac decompilemany --functions-file funcs.txt --out-file .idac/tmp/decompile.c -c db:sample.i64 + idac decompilemany --functions-file funcs.txt --out-file .idac/tmp/decompile.c -c sample.i64 """ selection = parser.add_argument_group("selection") selection.add_argument( @@ -454,6 +383,7 @@ def register( "This is not a list of function names; use --file for multiple exact functions." ), ) + parser.set_defaults(_input_path_attrs=("file",)) selection.add_argument( "--file", "--functions-file", @@ -499,15 +429,25 @@ def register( ) parser = add_standard_command( - root_parser, subparsers, "disasm", help_text="Disassemble a function or address range", run=run_disasm + root_parser, + subparsers, + "disasm", + help_text="Disassemble a function or address range", + run=run_bound_operation, ) + bind_operation(parser, OperationBinding(request_builder=disasm_request)) parser.add_argument("function", nargs="?", help="Function name or address") parser.add_argument("--start", help="Range start address or symbol") parser.add_argument("--end", help="Range end address or symbol") parser = add_standard_command( - root_parser, subparsers, "ctree", help_text="Inspect Hex-Rays ctree or microcode", run=run_ctree + root_parser, + subparsers, + "ctree", + help_text="Inspect Hex-Rays ctree or microcode", + run=run_bound_operation, ) + bind_operation(parser, OperationBinding("ctree", params_builder=_ctree_params)) parser.add_argument("function", help="Function name or address") parser.add_argument("--level", choices=("ctree", "micro"), default="ctree", help="Inspect ctree or microcode") parser.add_argument( @@ -516,7 +456,21 @@ def register( help="Requested microcode maturity when --level micro is used", ) - parser = add_standard_command(root_parser, subparsers, "xrefs", help_text="Show cross-references", run=run_xrefs) + parser = add_standard_command( + root_parser, + subparsers, + "xrefs", + help_text="Show cross-references", + run=run_bound_operation, + ) + bind_operation(parser, OperationBinding("xrefs", parameter_attrs=(("identifier", "identifier"),))) parser.add_argument("identifier", help="Function name, symbol, or address") - add_standard_command(root_parser, subparsers, "imports", help_text="List imports", run=run_imports) + parser = add_standard_command( + root_parser, + subparsers, + "imports", + help_text="List imports", + run=run_bound_operation, + ) + bind_operation(parser, OperationBinding("imports")) diff --git a/src/idac/cli2/commands/type_commands.py b/src/idac/cli/commands/type_commands.py similarity index 62% rename from src/idac/cli2/commands/type_commands.py rename to src/idac/cli/commands/type_commands.py index 0e53058..78c8a1c 100644 --- a/src/idac/cli2/commands/type_commands.py +++ b/src/idac/cli/commands/type_commands.py @@ -10,7 +10,7 @@ add_standard_command, read_decl_text, ) -from ..commands.common import parse_alias_list, send_op +from ..commands.common import OperationBinding, bind_operation, parse_alias_list, run_bound_operation from ..errors import CliUserError from ..result import CommandResult @@ -23,18 +23,17 @@ def _pattern_params(args: argparse.Namespace) -> dict[str, object]: } -def _name_params(args: argparse.Namespace) -> dict[str, object]: - return {"name": str(args.name)} - - def _type_declare_params(args: argparse.Namespace) -> dict[str, object]: - return { + params: dict[str, object] = { "decl": read_decl_text(args), - "replace": bool(args.replace), "aliases": parse_alias_list(args.alias), - "bisect": bool(args.bisect), "clang": bool(args.clang), } + if hasattr(args, "replace"): + params["replace"] = bool(args.replace) + if hasattr(args, "bisect"): + params["bisect"] = bool(args.bisect) + return params def _class_candidates_params(args: argparse.Namespace) -> dict[str, object]: @@ -44,14 +43,6 @@ def _class_candidates_params(args: argparse.Namespace) -> dict[str, object]: return params -def _class_fields_params(args: argparse.Namespace) -> dict[str, object]: - return {"name": str(args.name), "derived_only": bool(args.derived_only)} - - -def _class_vtable_params(args: argparse.Namespace) -> dict[str, object]: - return {"name": str(args.name), "runtime": bool(args.runtime)} - - def _struct_field_set_params(args: argparse.Namespace) -> dict[str, object]: return { "struct_name": str(args.struct_name), @@ -61,18 +52,6 @@ def _struct_field_set_params(args: argparse.Namespace) -> dict[str, object]: } -def _struct_field_rename_params(args: argparse.Namespace) -> dict[str, object]: - return { - "struct_name": str(args.struct_name), - "field_name": str(args.field_name), - "new_name": str(args.new_name), - } - - -def _struct_field_delete_params(args: argparse.Namespace) -> dict[str, object]: - return {"struct_name": str(args.struct_name), "field_name": str(args.field_name)} - - def _enum_member_set_params(args: argparse.Namespace) -> dict[str, object]: params: dict[str, object] = { "enum_name": str(args.enum_name), @@ -84,75 +63,33 @@ def _enum_member_set_params(args: argparse.Namespace) -> dict[str, object]: return params -def _enum_member_rename_params(args: argparse.Namespace) -> dict[str, object]: - return { - "enum_name": str(args.enum_name), - "member_name": str(args.member_name), - "new_name": str(args.new_name), - } - - -def _enum_member_delete_params(args: argparse.Namespace) -> dict[str, object]: - return {"enum_name": str(args.enum_name), "member_name": str(args.member_name)} - - -def _type_list_guard(args: argparse.Namespace) -> None: - if args.pattern in (None, "") and args.out is None: +def _large_list_params(args: argparse.Namespace) -> dict[str, object]: + if args.pattern in (None, "") and args.out is None and not getattr(args, "_wrapper_has_output", False): raise CliUserError("this list can be very large; rerun with a pattern or `--out `") - - -def run_type_list(args: argparse.Namespace) -> CommandResult: - _type_list_guard(args) - return send_op(args, op="type_list", params=_pattern_params(args)) - - -def run_type_show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="type_show", params=_name_params(args)) - - -def run_type_deps(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="type_deps", params=_name_params(args)) + return _pattern_params(args) def run_type_declare(args: argparse.Namespace) -> CommandResult: - result = send_op(args, op="type_declare", params=_type_declare_params(args)) - exit_code = 0 - stderr_lines: list[str] = [] + result = run_bound_operation(args) if isinstance(result.value, dict) and ( result.value.get("success") is False or int(result.value.get("errors") or 0) > 0 ): - exit_code = 1 - stderr_lines = _type_declare_failure_lines(result.value, action="type declare") - return CommandResult( - render_op=result.render_op, - value=result.value, - exit_code=exit_code, - warnings=list(result.warnings), - stderr_lines=stderr_lines, - artifacts=list(result.artifacts), - ) + result.exit_code = 1 + result.stderr_lines = _type_declare_failure_lines(result.value, action="type declare") + return result def run_type_check(args: argparse.Namespace) -> CommandResult: - result = send_op(args, op="type_declare_check", params=_type_declare_params(args)) - exit_code = 0 - stderr_lines: list[str] = [] + result = run_bound_operation(args) if isinstance(result.value, dict) and ( result.value.get("success") is False or int(result.value.get("errors") or 0) > 0 ): - exit_code = 1 - stderr_lines = _type_declare_failure_lines(result.value, action="type check") - return CommandResult( - render_op=result.render_op, - value=result.value, - exit_code=exit_code, - warnings=list(result.warnings), - stderr_lines=stderr_lines, - artifacts=list(result.artifacts), - ) + result.exit_code = 1 + result.stderr_lines = _type_declare_failure_lines(result.value, action="type check") + return result -def _type_declare_failure_lines(payload: dict[str, Any], *, action: str = "type declare") -> list[str]: +def _type_declare_failure_lines(payload: dict[str, Any], *, action: str) -> list[str]: errors = int(payload.get("errors") or 0) lines = [f"{action} failed: {errors} parser error(s)" if errors else f"{action} failed"] bisect = payload.get("bisect") @@ -180,10 +117,9 @@ def _type_declare_failure_lines(payload: dict[str, Any], *, action: str = "type ) diagnostics = payload.get("diagnostics") if isinstance(diagnostics, list): + diagnostics = [item for item in diagnostics if isinstance(item, dict)] displayed = 0 for item in diagnostics: - if not isinstance(item, dict): - continue message = str(item.get("message") or item.get("kind") or "diagnostic").strip() if not message: continue @@ -195,85 +131,26 @@ def _type_declare_failure_lines(payload: dict[str, Any], *, action: str = "type displayed += 1 if displayed >= 5: break - remaining = sum(1 for item in diagnostics if isinstance(item, dict)) - displayed + remaining = len(diagnostics) - displayed if remaining > 0: lines.append(f"... {remaining} more diagnostic(s)") return lines -def run_class_list(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="class_list", params=_pattern_params(args)) - - -def run_class_candidates(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="class_candidates", params=_class_candidates_params(args)) - - -def run_class_show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="class_show", params=_name_params(args)) - - -def run_class_hierarchy(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="class_hierarchy", params=_name_params(args)) - - -def run_class_fields(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="class_fields", params=_class_fields_params(args)) - - -def run_class_vtable(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="class_vtable", params=_class_vtable_params(args)) - - -def run_struct_list(args: argparse.Namespace) -> CommandResult: - _type_list_guard(args) - return send_op(args, op="struct_list", params=_pattern_params(args)) - - -def run_struct_show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="struct_show", params=_name_params(args)) - - -def run_struct_field_set(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="struct_field_set", params=_struct_field_set_params(args)) - - -def run_struct_field_rename(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="struct_field_rename", params=_struct_field_rename_params(args)) - - -def run_struct_field_delete(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="struct_field_delete", params=_struct_field_delete_params(args)) - - -def run_enum_list(args: argparse.Namespace) -> CommandResult: - _type_list_guard(args) - return send_op(args, op="enum_list", params=_pattern_params(args)) - - -def run_enum_show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="enum_show", params=_name_params(args)) - - -def run_enum_member_set(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="enum_member_set", params=_enum_member_set_params(args)) - - -def run_enum_member_rename(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="enum_member_rename", params=_enum_member_rename_params(args)) - - -def run_enum_member_delete(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="enum_member_delete", params=_enum_member_delete_params(args)) - - def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: parser = add_command(root_parser, subparsers, "type", help_text="Type and structure operations") type_subparsers = parser.add_subparsers(dest="type_command") - child = add_standard_command(parser, type_subparsers, "list", help_text="List types", run=run_type_list) + child = add_standard_command( + parser, + type_subparsers, + "list", + help_text="List types", + run=run_bound_operation, + ) + bind_operation(child, OperationBinding("type_list", params_builder=_large_list_params)) child.add_argument( "pattern", nargs="?", @@ -285,12 +162,24 @@ def register( ) add_pattern_options(child, label="TYPE_FILTER") - child = add_standard_command(parser, type_subparsers, "show", help_text="Show one type", run=run_type_show) + child = add_standard_command( + parser, + type_subparsers, + "show", + help_text="Show one type", + run=run_bound_operation, + ) + bind_operation(child, OperationBinding("type_show", parameter_attrs=(("name", "name"),))) child.add_argument("name", help="Type name") child = add_standard_command( - parser, type_subparsers, "deps", help_text="Show a type with IDA-printed dependencies", run=run_type_deps + parser, + type_subparsers, + "deps", + help_text="Show a type with IDA-printed dependencies", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("type_deps", parameter_attrs=(("name", "name"),))) child.add_argument("name", help="Type name") child = add_standard_command( @@ -301,22 +190,15 @@ def register( run=run_type_check, default_format="json", ) + bind_operation(child, OperationBinding("type_declare_check", params_builder=_type_declare_params)) add_decl_input( child, help_text="C/C++ declaration text to validate with IDA's parser", file_help="Read C/C++ declarations from this header/source file", ) - child.add_argument("--replace", action="store_true", help="Check using replace-mode parser behavior if needed") child.add_argument( "--alias", action="append", default=[], metavar="OLD=NEW", help="Rewrite identifiers before validation" ) - child.add_argument( - "--bisect", - "--diagnose", - dest="bisect", - action="store_true", - help=argparse.SUPPRESS, - ) child.add_argument( "--clang", action="store_true", @@ -329,19 +211,19 @@ def register( "declare", help_text="Import declarations into local types", run=run_type_declare, - mutating=True, default_format="json", ) + bind_operation(child, OperationBinding("type_declare", params_builder=_type_declare_params)) child.formatter_class = argparse.RawDescriptionHelpFormatter child.epilog = """examples: # Import a recovered C/C++ header into IDA local types - idac type declare --replace --decl-file recovered_types.h -c db:sample.i64 + idac type declare --replace --decl-file recovered_types.h -c sample.i64 # Diagnose the first declaration that IDA rejects - idac type declare --replace --bisect --decl-file recovered_types.h -c db:sample.i64 + idac type declare --replace --bisect --decl-file recovered_types.h -c sample.i64 # Use IDA's clang parser for template-heavy or modern C++ declarations - idac type declare --clang --decl-file recovered_templates.hpp -c db:sample.i64 + idac type declare --clang --decl-file recovered_templates.hpp -c sample.i64 """ add_decl_input( child, @@ -354,8 +236,6 @@ def register( ) child.add_argument( "--bisect", - "--diagnose", - dest="bisect", action="store_true", help="Diagnose the first failing declaration on import failure", ) @@ -369,8 +249,13 @@ def register( class_subparsers = class_parser.add_subparsers(dest="class_command") child = add_standard_command( - class_parser, class_subparsers, "list", help_text="List C++ class types", run=run_class_list + class_parser, + class_subparsers, + "list", + help_text="List C++ class types", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("class_list", params_builder=_pattern_params)) child.add_argument( "pattern", nargs="?", @@ -384,8 +269,9 @@ def register( class_subparsers, "candidates", help_text="Find likely class-related names and symbols", - run=run_class_candidates, + run=run_bound_operation, ) + bind_operation(child, OperationBinding("class_candidates", params_builder=_class_candidates_params)) child.add_argument( "pattern", nargs="?", @@ -404,17 +290,35 @@ def register( ) child = add_standard_command( - class_parser, class_subparsers, "show", help_text="Show one C++ class", run=run_class_show + class_parser, + class_subparsers, + "show", + help_text="Show one C++ class", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("class_show", parameter_attrs=(("name", "name"),))) child.add_argument("name", help="Class name") child = add_standard_command( - class_parser, class_subparsers, "hierarchy", help_text="Show base and derived classes", run=run_class_hierarchy + class_parser, + class_subparsers, + "hierarchy", + help_text="Show base and derived classes", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("class_hierarchy", parameter_attrs=(("name", "name"),))) child.add_argument("name", help="Class name") child = add_standard_command( - class_parser, class_subparsers, "fields", help_text="Show class fields", run=run_class_fields + class_parser, + class_subparsers, + "fields", + help_text="Show class fields", + run=run_bound_operation, + ) + bind_operation( + child, + OperationBinding("class_fields", parameter_attrs=(("name", "name"), ("derived_only", "derived_only"))), ) child.add_argument("name", help="Class name") child.add_argument( @@ -422,7 +326,15 @@ def register( ) child = add_standard_command( - class_parser, class_subparsers, "vtable", help_text="Show the vtable type for a class", run=run_class_vtable + class_parser, + class_subparsers, + "vtable", + help_text="Show the vtable type for a class", + run=run_bound_operation, + ) + bind_operation( + child, + OperationBinding("class_vtable", parameter_attrs=(("name", "name"), ("runtime", "runtime"))), ) child.add_argument("name", help="Class name") child.add_argument( @@ -433,8 +345,13 @@ def register( struct_subparsers = struct_parser.add_subparsers(dest="struct_command") child = add_standard_command( - struct_parser, struct_subparsers, "list", help_text="List structs and unions", run=run_struct_list + struct_parser, + struct_subparsers, + "list", + help_text="List structs and unions", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("struct_list", params_builder=_large_list_params)) child.add_argument( "pattern", nargs="?", @@ -447,8 +364,13 @@ def register( add_pattern_options(child, label="STRUCT_FILTER") child = add_standard_command( - struct_parser, struct_subparsers, "show", help_text="Show one struct", run=run_struct_show + struct_parser, + struct_subparsers, + "show", + help_text="Show one struct", + run=run_bound_operation, ) + bind_operation(child, OperationBinding("struct_show", parameter_attrs=(("name", "name"),))) child.add_argument("name", help="Struct or union name") field_parser = add_command(struct_parser, struct_subparsers, "field", help_text="Operate on struct fields") @@ -459,10 +381,10 @@ def register( field_subparsers, "set", help_text="Set or replace a struct field", - run=run_struct_field_set, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("struct_field_set", params_builder=_struct_field_set_params)) child.add_argument("struct_name", help="Struct or union name") child.add_argument("field_name", help="Struct field name") child.add_argument("--offset", required=True, help="Field offset within the struct or union") @@ -473,10 +395,20 @@ def register( field_subparsers, "rename", help_text="Rename a struct field", - run=run_struct_field_rename, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation( + child, + OperationBinding( + "struct_field_rename", + parameter_attrs=( + ("struct_name", "struct_name"), + ("field_name", "field_name"), + ("new_name", "new_name"), + ), + ), + ) child.add_argument("struct_name", help="Struct or union name") child.add_argument("field_name", help="Struct field name") child.add_argument("new_name", help="Replacement name") @@ -486,17 +418,30 @@ def register( field_subparsers, "delete", help_text="Delete a struct field", - run=run_struct_field_delete, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation( + child, + OperationBinding( + "struct_field_delete", + parameter_attrs=(("struct_name", "struct_name"), ("field_name", "field_name")), + ), + ) child.add_argument("struct_name", help="Struct or union name") child.add_argument("field_name", help="Struct field name") enum_parser = add_command(parser, type_subparsers, "enum", help_text="Enum operations") enum_subparsers = enum_parser.add_subparsers(dest="enum_command") - child = add_standard_command(enum_parser, enum_subparsers, "list", help_text="List enums", run=run_enum_list) + child = add_standard_command( + enum_parser, + enum_subparsers, + "list", + help_text="List enums", + run=run_bound_operation, + ) + bind_operation(child, OperationBinding("enum_list", params_builder=_large_list_params)) child.add_argument( "pattern", nargs="?", @@ -508,7 +453,14 @@ def register( ) add_pattern_options(child, label="ENUM_FILTER") - child = add_standard_command(enum_parser, enum_subparsers, "show", help_text="Show one enum", run=run_enum_show) + child = add_standard_command( + enum_parser, + enum_subparsers, + "show", + help_text="Show one enum", + run=run_bound_operation, + ) + bind_operation(child, OperationBinding("enum_show", parameter_attrs=(("name", "name"),))) child.add_argument("name", help="Enum name") member_parser = add_command(enum_parser, enum_subparsers, "member", help_text="Operate on enum members") @@ -519,10 +471,10 @@ def register( member_subparsers, "set", help_text="Set or add an enum member", - run=run_enum_member_set, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation(child, OperationBinding("enum_member_set", params_builder=_enum_member_set_params)) child.add_argument("enum_name", help="Enum name") child.add_argument("member_name", help="Enum member name") child.add_argument("--value", required=True, help="Enum member value") @@ -533,10 +485,20 @@ def register( member_subparsers, "rename", help_text="Rename an enum member", - run=run_enum_member_rename, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation( + child, + OperationBinding( + "enum_member_rename", + parameter_attrs=( + ("enum_name", "enum_name"), + ("member_name", "member_name"), + ("new_name", "new_name"), + ), + ), + ) child.add_argument("enum_name", help="Enum name") child.add_argument("member_name", help="Enum member name") child.add_argument("new_name", help="Replacement name") @@ -546,9 +508,15 @@ def register( member_subparsers, "delete", help_text="Delete an enum member", - run=run_enum_member_delete, - mutating=True, + run=run_bound_operation, default_format="json", ) + bind_operation( + child, + OperationBinding( + "enum_member_delete", + parameter_attrs=(("enum_name", "enum_name"), ("member_name", "member_name")), + ), + ) child.add_argument("enum_name", help="Enum name") child.add_argument("member_name", help="Enum member name") diff --git a/src/idac/cli2/commands/workspace.py b/src/idac/cli/commands/workspace.py similarity index 62% rename from src/idac/cli2/commands/workspace.py rename to src/idac/cli/commands/workspace.py index fae0161..1cbe0a3 100644 --- a/src/idac/cli2/commands/workspace.py +++ b/src/idac/cli/commands/workspace.py @@ -5,12 +5,7 @@ from ...workspace import initialize_workspace from ..argparse_utils import add_command, add_output_options -from ..commands.common import command_result -from ..result import CommandResult - - -def _init(args: argparse.Namespace) -> CommandResult: - return command_result("workspace_init", initialize_workspace(Path(args.dest), force=bool(args.force))) +from ..commands.common import LocalCommandBinding, run_bound_local_command def register( @@ -23,8 +18,18 @@ def register( child = add_command(parser, workspace_subparsers, "init", help_text="Initialize a reverse-engineering workspace") add_output_options(child, default_format="text") - child.add_argument("dest", nargs="?", default=".", help="Directory to initialize") + child.add_argument("dest", nargs="?", type=Path, default=Path("."), help="Directory to initialize") child.add_argument( "--force", action="store_true", help="Overwrite user-tunable config when the workspace already exists" ) - child.set_defaults(run=_init, context_policy="none", allow_batch=True, allow_preview=False, _mutating_command=False) + child.set_defaults( + run=run_bound_local_command, + _local_command_binding=LocalCommandBinding( + "workspace_init", + initialize_workspace, + positional_attrs=("dest",), + keyword_attrs=(("force", "force"),), + ), + allow_batch=True, + allow_preview=False, + ) diff --git a/src/idac/cli/context.py b/src/idac/cli/context.py new file mode 100644 index 0000000..ac7a4c9 --- /dev/null +++ b/src/idac/cli/context.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from .errors import CliUserError +from .path_resolution import reject_output_aliases + + +@dataclass(frozen=True) +class ResolvedContext: + locator: str | None + instance_id: str | None + + +def _context_path(value: object) -> str: + locator = str(value).strip() + if not locator: + raise CliUserError("-c/--context requires a database or binary path") + lowered = locator.lower() + if lowered.startswith(("db:", "pid:", "module:")): + raise CliUserError( + "legacy db:/pid:/module: context locators were removed; pass an .i64 or binary path, " + "or use --instance with an exact Nexus record ID" + ) + path = Path(locator).expanduser() + if path.suffix.lower() == ".idb": + raise CliUserError("32-bit .idb databases are not supported; convert the database to .i64") + if not path.is_file(): + raise CliUserError(f"context path is not a file: {path}") + return str(path) + + +def merge_parent_context(inner_args: argparse.Namespace, outer_args: argparse.Namespace) -> None: + if hasattr(inner_args, "context") or hasattr(inner_args, "instance"): + raise CliUserError( + "batch and preview child commands cannot switch Nexus targets; " + "put -c/--context or --instance on the wrapper command" + ) + if hasattr(inner_args, "timeout"): + raise CliUserError( + "batch and preview child commands cannot set --timeout; put --timeout on the wrapper command" + ) + if hasattr(outer_args, "context"): + inner_args.context = outer_args.context + if hasattr(outer_args, "instance"): + inner_args.instance = outer_args.instance + if hasattr(outer_args, "timeout"): + inner_args.timeout = outer_args.timeout + session = getattr(outer_args, "_nexus_session", None) + if session is not None: + inner_args._nexus_session = session + protected_paths = getattr(outer_args, "_protected_context_paths", None) + if protected_paths is not None: + inner_args._protected_context_paths = protected_paths + inner_args._wrapper_has_output = bool( + getattr(outer_args, "out", None) or getattr(outer_args, "_wrapper_has_output", False) + ) + + +def reject_output_context_aliases(args: argparse.Namespace, protected_paths: tuple[str, ...]) -> None: + """Keep command artifacts from replacing a selected input or IDA database.""" + + protected: list[tuple[str, Path | None]] = [ + ("selected input or database", Path(value)) for value in protected_paths + ] + protected.extend( + (f"--{key.replace('_', '-')} input", value) + for key, value in vars(args).items() + if isinstance(value, Path) and key not in {"out", "out_file", "out_dir"} + ) + for output_key in ("out", "out_file"): + output_value = getattr(args, output_key, None) + if output_value is None: + continue + reject_output_aliases( + Path(output_value), + protected, + option_label=f"--{output_key.replace('_', '-')}", + ) + + +def require_timeout_if_needed(args: argparse.Namespace) -> None: + if not getattr(args, "_require_timeout", False): + return + if getattr(args, "timeout", None) is not None: + return + label = str(getattr(args, "_timeout_requirement_label", None) or "this command") + raise CliUserError(f"{label} requires --timeout") + + +def resolve_context(args: argparse.Namespace) -> ResolvedContext: + if not args._uses_context: + return ResolvedContext(None, None) + + raw_locator = getattr(args, "context", None) + raw_instance_id = getattr(args, "instance", None) + locator = _context_path(raw_locator) if raw_locator is not None else None + instance_id = str(raw_instance_id).strip() if raw_instance_id is not None else None + if instance_id == "": + raise CliUserError("--instance requires a non-empty Nexus record ID") + if locator is not None and instance_id is not None: + raise CliUserError("-c/--context and --instance are mutually exclusive") + return ResolvedContext(locator, instance_id) + + +__all__ = [ + "ResolvedContext", + "merge_parent_context", + "reject_output_context_aliases", + "require_timeout_if_needed", + "resolve_context", +] diff --git a/src/idac/cli2/errors.py b/src/idac/cli/errors.py similarity index 100% rename from src/idac/cli2/errors.py rename to src/idac/cli/errors.py diff --git a/src/idac/cli/execute.py b/src/idac/cli/execute.py new file mode 100644 index 0000000..f35bf2c --- /dev/null +++ b/src/idac/cli/execute.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import argparse +from contextlib import ExitStack + +from ..nexus import NexusSession as _NexusSession +from .context import reject_output_context_aliases, require_timeout_if_needed, resolve_context +from .errors import CliUserError + + +def reject_unsupported_forwarded_context(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + if args._uses_context: + return + command = f"`{parser.prog}`" + if hasattr(args, "context"): + raise CliUserError(f"{command} does not accept -c/--context") + if hasattr(args, "timeout") and not getattr(args, "_accepts_timeout", False): + raise CliUserError(f"{command} does not accept --timeout") + + +def prepare_args(args: argparse.Namespace) -> None: + parser = args._selected_parser + reject_unsupported_forwarded_context(parser, args) + if args._uses_context: + require_timeout_if_needed(args) + resolved = resolve_context(args) + if resolved.locator is not None: + args.context = resolved.locator + protected_paths = [resolved.locator] + if not resolved.locator.lower().endswith(".i64"): + protected_paths.append(f"{resolved.locator}.i64") + args._protected_context_paths = tuple(protected_paths) + if resolved.instance_id is not None: + args.instance = resolved.instance_id + inherited_paths = getattr(args, "_protected_context_paths", ()) + if inherited_paths: + reject_output_context_aliases(args, inherited_paths) + + +def protect_output_context(args: argparse.Namespace, *, discover_only: bool = False) -> None: + """Validate artifacts against the resolved target before any file is written.""" + + session = args._nexus_session + assert session is not None + if discover_only: + # Lint and context-free batches must not open a database just to write + # their journal. Protect known live files without acquiring a lease. + targets = session.list_targets() + instance_id = vars(args).get("instance") + if instance_id is not None: + targets = [target for target in targets if target.get("record_id") == instance_id] + else: + instance = session.handle.instance + targets = [{"idb_path": instance.idb_path, "exe_path": instance.exe_path}] + + protected_paths: list[str] = list(getattr(args, "_protected_context_paths", ())) + for target in targets: + for key in ("idb_path", "exe_path"): + value = target.get(key) + if not isinstance(value, str) or not value: + continue + protected_paths.append(value) + if key == "exe_path" and not value.lower().endswith(".i64"): + protected_paths.append(f"{value}.i64") + args._protected_context_paths = tuple(dict.fromkeys(protected_paths)) + reject_output_context_aliases(args, args._protected_context_paths) + + +def execute_parsed(args: argparse.Namespace): + handler = args.run + if handler is None: + selected_parser = args._selected_parser + selected_parser.print_help() + raise SystemExit(2) + prepare_args(args) + session = getattr(args, "_nexus_session", None) + with ExitStack() as session_context: + if args._uses_context and session is None: + from .. import nexus + + try: + session = nexus.NexusSession( + locator=getattr(args, "context", None), + instance_id=getattr(args, "instance", None), + timeout=getattr(args, "timeout", None), + ) + except (TypeError, ValueError) as exc: + raise CliUserError(str(exc)) from exc + args._nexus_session = session + session_context.push(_NexusSession.__exit__.__get__(session)) + output_requested = getattr(args, "out", None) is not None or getattr(args, "out_file", None) is not None + if args._uses_context and output_requested and args.command != "batch": + protect_output_context(args) + return handler(args) diff --git a/src/idac/cli2/main.py b/src/idac/cli/main.py similarity index 57% rename from src/idac/cli2/main.py rename to src/idac/cli/main.py index b21c6cc..772642f 100644 --- a/src/idac/cli2/main.py +++ b/src/idac/cli/main.py @@ -3,10 +3,9 @@ import argparse import json import sys -from typing import Any +from ..nexus import NexusSessionError from ..output import OutputTooLargeError -from ..transport import BackendError from ..version import VERSION from . import batch, preview from .argparse_utils import add_root_context_options, create_parser, finalize_help_tree @@ -14,13 +13,13 @@ bookmark, comment, database, - docs, doctor, function, misc, python_exec, search, segment, + setup, targets, top_level, type_commands, @@ -28,11 +27,8 @@ ) from .errors import CliUserError from .execute import execute_parsed -from .result import CommandResult from .serialize import artifact_notice, emit_result, json_or_jsonl_from_path -DOCS_INLINE_CHAR_LIMIT = 50_000 - def build_parser(*, prog: str = "idac") -> argparse.ArgumentParser: parser = create_parser(prog=prog, description="Agent-friendly CLI for IDA") @@ -41,10 +37,10 @@ def build_parser(*, prog: str = "idac") -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command") doctor.register(parser, subparsers) - docs.register(parser, subparsers) database.register(parser, subparsers) function.register(parser, subparsers) segment.register(parser, subparsers) + setup.register(parser, subparsers) targets.register(parser, subparsers) top_level.register(parser, subparsers) search.register(parser, subparsers) @@ -78,61 +74,34 @@ def _print_error_payload(fmt: str, exc: OutputTooLargeError) -> None: print(str(exc), file=sys.stderr) -def _print_result_stderr(result: CommandResult) -> None: - for line in result.stderr_lines: - text = line.rstrip() - if text: - print(text, file=sys.stderr) - - -def _print_artifact_notices(result: CommandResult, artifacts: list[dict[str, object]]) -> None: - for artifact in artifacts: - notice = artifact_notice(result, artifact) - if notice: - print(notice, file=sys.stderr) - - def main(argv: list[str] | None = None, *, prog: str = "idac") -> int: parser = build_parser(prog=prog) args = parser.parse_args(argv) arg_map = vars(args) try: - result = execute_parsed(args, root_parser=parser) - for warning in result.warnings: - print(f"warning: {warning}", file=sys.stderr) - - if args.command == "preview" and not arg_map.get("_batch_mode", False): - if result.exit_code == 0: - _print_artifact_notices(result, result.artifacts) - else: - _print_result_stderr(result) - return result.exit_code - + result = execute_parsed(args) if args.command == "batch": if arg_map.get("out") is None: - fmt = json_or_jsonl_from_path(None, default="json") - emit_result(result, fmt=fmt, out_path=None) - elif result.exit_code == 0: - _print_artifact_notices(result, result.artifacts) + emit_result(result, fmt="json", out_path=None) + return result.exit_code + artifacts = result.artifacts + else: + if args.command == "preview": + fmt = json_or_jsonl_from_path(arg_map["out"]) + out_path = arg_map["out"] else: - _print_result_stderr(result) - return result.exit_code - - emit_kwargs: dict[str, Any] = {} - if args.command == "docs" and not arg_map.get("all", False): - emit_kwargs["inline_limit"] = DOCS_INLINE_CHAR_LIMIT - artifacts = emit_result( - result, - fmt=arg_map.get("format", "text"), - out_path=arg_map.get("out"), - **emit_kwargs, - ) - if artifacts: + fmt = arg_map.get("format", "text") + out_path = arg_map.get("out") + artifacts = emit_result(result, fmt=fmt, out_path=out_path) result.artifacts.extend(artifacts) - if result.exit_code == 0: - _print_artifact_notices(result, artifacts) - if result.exit_code != 0: - _print_result_stderr(result) + if result.exit_code == 0: + for artifact in artifacts: + if notice := artifact_notice(result, artifact): + print(notice, file=sys.stderr) + else: + for line in result.stderr_lines: + if text := line.rstrip(): + print(text, file=sys.stderr) return result.exit_code except SystemExit as exc: if isinstance(exc.code, int): @@ -141,6 +110,14 @@ def main(argv: list[str] | None = None, *, prog: str = "idac") -> int: except OutputTooLargeError as exc: _print_error_payload(arg_map.get("format", "text"), exc) return 1 - except (BackendError, CliUserError, OSError) as exc: - print(str(exc) or exc.__class__.__name__, file=sys.stderr) - return 1 + except (KeyboardInterrupt, NexusSessionError, CliUserError, OSError) as exc: + if isinstance(exc, KeyboardInterrupt): + message = "interrupted" + exit_code = 130 + else: + message = str(exc) or exc.__class__.__name__ + exit_code = 1 + print(message, file=sys.stderr) + for note in getattr(exc, "__notes__", ()): + print(f"note: {note}", file=sys.stderr) + return exit_code diff --git a/src/idac/cli/path_resolution.py b/src/idac/cli/path_resolution.py new file mode 100644 index 0000000..64251e7 --- /dev/null +++ b/src/idac/cli/path_resolution.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import argparse +import contextlib +from pathlib import Path + +from .errors import CliUserError + + +def resolve_relative_paths(args: argparse.Namespace, *, base_dir: Path) -> None: + for key, value in vars(args).items(): + if isinstance(value, Path) and not value.is_absolute(): + setattr(args, key, base_dir / value) + + +def reject_output_aliases( + output_path: Path | None, + protected_paths: list[tuple[str, Path | None]], + *, + option_label: str, +) -> None: + """Reject output paths that resolve to protected files, including links.""" + + if output_path is None: + return + resolved_output = output_path.expanduser().resolve(strict=False) + for protected_label, protected_path in protected_paths: + if protected_path is None: + continue + candidate = protected_path.expanduser() + aliases = resolved_output == candidate.resolve(strict=False) + if not aliases: + with contextlib.suppress(OSError): + aliases = output_path.samefile(candidate) + if aliases: + raise CliUserError(f"{option_label} must not overwrite the {protected_label}: {protected_path}") diff --git a/src/idac/cli2/preview.py b/src/idac/cli/preview.py similarity index 63% rename from src/idac/cli2/preview.py rename to src/idac/cli/preview.py index 12e7a7e..7d0d13a 100644 --- a/src/idac/cli2/preview.py +++ b/src/idac/cli/preview.py @@ -3,44 +3,43 @@ import argparse from pathlib import Path -from ..output import write_output_result from .argparse_utils import add_command, add_context_options, bind_root_handler from .context import merge_parent_context from .errors import CliUserError from .execute import execute_parsed -from .path_resolution import resolve_relative_paths +from .path_resolution import reject_output_aliases, resolve_relative_paths from .result import CommandResult +def normalize_wrapped_command_tokens(tokens: list[str] | None) -> list[str]: + normalized = list(tokens or []) + return normalized[1:] if normalized[:1] == ["--"] else normalized + + def _preview_payload(*, command: str, result: CommandResult, wrapped_args: argparse.Namespace) -> dict[str, object]: value = result.value - stderr = result.stderr_lines if wrapped_args._mutating_command: if not isinstance(value, dict): raise RuntimeError("preview expected an object result for a mutating command") - before = value.get("before") - after = value.get("after") - result_payload = value.get("result") - if result_payload is None: - result_payload = { - key: item - for key, item in value.items() - if key not in {"before", "after", "preview", "preview_mode", "persisted"} - } + missing = {"before", "after", "result", "preview_mode", "persisted"} - value.keys() + if missing: + raise RuntimeError(f"preview response is missing field(s): {', '.join(sorted(missing))}") + before = value["before"] + after = value["after"] return { "command": command, "status": "ok" if result.exit_code == 0 else "failed", "before": before, "after": after, - "result": result_payload, + "result": value["result"], "readback": after, "undo": { "status": "ok", - "mode": value.get("preview_mode", "undo"), - "persisted": value.get("persisted", False), + "mode": value["preview_mode"], + "persisted": value["persisted"], }, "artifacts": list(result.artifacts), - "stderr": stderr, + "stderr": result.stderr_lines, } return { "command": command, @@ -51,50 +50,50 @@ def _preview_payload(*, command: str, result: CommandResult, wrapped_args: argpa "readback": value, "undo": {"status": "noop", "mode": "read_only", "persisted": False}, "artifacts": list(result.artifacts), - "stderr": stderr, + "stderr": result.stderr_lines, } def run(args: argparse.Namespace, *, root_parser: argparse.ArgumentParser): - tokens = list(args.command_tokens or []) - if tokens and tokens[0] == "--": - tokens = tokens[1:] + tokens = normalize_wrapped_command_tokens(args.command_tokens) if not tokens: raise CliUserError("preview requires a command to wrap") - parsed = root_parser.parse_args(tokens) + parsed = getattr(args, "_wrapped_args", None) or root_parser.parse_args(tokens) parsed_map = vars(parsed) - if parsed_map.get("_hidden_command", False) or not parsed_map.get("allow_preview", True): + if not parsed_map.get("allow_preview", False): raise CliUserError("command is not available in preview mode") - if parsed.command == "preview": - raise CliUserError("nested preview is not supported") + child_outputs = [key for key in ("out", "out_file", "out_dir") if parsed_map.get(key) is not None] + if child_outputs: + rendered = ", ".join(f"--{key.replace('_', '-')}" for key in child_outputs) + raise CliUserError( + f"commands wrapped by preview cannot set their own output option(s): {rendered}; use preview --out" + ) if not args._batch_mode and args.out is None: raise CliUserError("preview requires `--out `") merge_parent_context(parsed, args) relative_path_base_dir = getattr(args, "_relative_path_base_dir", None) if args._batch_mode and relative_path_base_dir is not None: resolve_relative_paths(parsed, base_dir=Path(relative_path_base_dir)) + if args.out is not None: + reject_output_aliases( + Path(args.out), + [ + (f"wrapped --{key.replace('_', '-')} input", value) + for key, value in vars(parsed).items() + if isinstance(value, Path) and key not in {"out", "out_file", "out_dir"} + ], + option_label="preview --out", + ) parsed._preview_wrapper = True parsed._batch_mode = args._batch_mode - result = execute_parsed(parsed, root_parser=root_parser) + result = execute_parsed(parsed) payload = _preview_payload(command=" ".join(tokens), result=result, wrapped_args=parsed) - if args._batch_mode: - return CommandResult( - render_op="preview", - value=payload, - exit_code=result.exit_code, - stderr_lines=list(result.stderr_lines), - artifacts=list(result.artifacts), - ) - out_path = Path(args.out) - fmt = "jsonl" if out_path.suffix.lower() == ".jsonl" else "json" - output = write_output_result(payload, fmt=fmt, out_path=out_path, stem="preview") - artifacts = [output.artifact] if output.artifact is not None else [] return CommandResult( render_op="preview", - value={"ok": result.exit_code == 0, "out": str(out_path), "format": fmt}, + value=payload, exit_code=result.exit_code, stderr_lines=list(result.stderr_lines), - artifacts=artifacts, + artifacts=list(result.artifacts) if args._batch_mode else [], ) @@ -102,12 +101,12 @@ def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: parser = add_command( - root_parser, subparsers, "preview", help_text="Run a command under undo and emit structured preview data" + root_parser, subparsers, "preview", help_text="Run and roll back a command, emitting structured preview data" ) parser.formatter_class = argparse.RawDescriptionHelpFormatter parser.epilog = """examples: # Preview a prototype change; omit the leading `idac` from the wrapped command - idac preview -o .idac/tmp/proto_preview.json function prototype set sub_401000 --decl-file proto.h -c db:sample.i64 + idac preview -c sample.i64 -o .idac/tmp/proto_preview.json function prototype set sub_401000 --decl-file proto.h # Preview a local-variable update and write the full before/after JSON idac preview -o .idac/tmp/local_preview.json function locals update sub_401000 \\ @@ -129,8 +128,6 @@ def register( ) parser.set_defaults( run=bind_root_handler(root_parser, run), - context_policy="wrapper", allow_batch=True, allow_preview=False, - _mutating_command=False, ) diff --git a/src/idac/cli2/renderers/__init__.py b/src/idac/cli/renderers/__init__.py similarity index 83% rename from src/idac/cli2/renderers/__init__.py rename to src/idac/cli/renderers/__init__.py index 068368f..9ff02f9 100644 --- a/src/idac/cli2/renderers/__init__.py +++ b/src/idac/cli/renderers/__init__.py @@ -4,10 +4,6 @@ from collections.abc import Callable from typing import Any -from ...ops.families.type_declare import TypeDeclareDiagnostic, TypeDeclareResult -from ._registry import build_text_renderers -from ._registry import renderer_registry_drift as _renderer_registry_drift - def _fallback(value: Any) -> str: if isinstance(value, str): @@ -22,14 +18,6 @@ def _append_present_fields(lines: list[str], value: dict[str, Any], *keys: str) lines.append(f"{key}: {item}") -def _join_lines(lines: list[str], *, empty: str = "none") -> str: - return "\n".join(lines) if lines else empty - - -def _display(value: Any, *, empty: str = "none") -> str: - return empty if value in (None, "") else str(value) - - def _get_first_present(mapping: dict[str, Any], *keys: str, empty: str = "") -> str: for key in keys: value = mapping.get(key) @@ -38,54 +26,51 @@ def _get_first_present(mapping: dict[str, Any], *keys: str, empty: str = " str: - return sep.join(str(item) for item in items) if items else empty - - def _render_list_rows( value: Any, render_row: Callable[[Any], str], - *, - empty: str = "none", ) -> str: if not isinstance(value, list) or not value: - return empty - return _join_lines([render_row(item) for item in value], empty=empty) + return "none" + return "\n".join(render_row(item) for item in value) -def _append_section(lines: list[str], label: str, rows: list[str], *, empty: str | None = None) -> bool: +def _append_section(lines: list[str], label: str, rows: list[str]) -> None: if not rows: - if empty is not None: - lines.append(f"{label}: {empty}") - return False + return lines.append(f"{label}:") lines.extend(rows) - return True def _list_rows(items: list[Any], render_row: Callable[[Any], str]) -> list[str]: return [render_row(item) for item in items] -def _function_header(value: dict[str, Any], *, key: str = "function") -> str: - return f"{_get_first_present(value, key)} @ {_get_first_present(value, 'address')}" +def _function_header(value: dict[str, Any]) -> str: + return f"{_get_first_present(value, 'function')} @ {_get_first_present(value, 'address')}" def render_target_list(value: Any) -> str: def render_row(item: Any) -> str: - selector = _get_first_present(item, "selector", "target_id") - backend = item.get("backend") - module = item.get("module") - pid = item.get("instance_pid") - suffix = " [active]" if item.get("active") else "" + if not isinstance(item, dict): + return _fallback(item) + record_id = _get_first_present(item, "record_id") + state = _get_first_present(item, "state") details: list[str] = [] - if backend: + backend = item.get("backend") + if backend not in (None, ""): details.append(str(backend)) - if module: - details.append(str(module)) + path = item.get("idb_path") or item.get("exe_path") + if path not in (None, ""): + details.append(str(path)) + pid = item.get("pid") if pid not in (None, ""): details.append(f"pid={pid}") - return f"{selector}{suffix}" + (f" ({', '.join(details)})" if details else "") + detail = item.get("detail") + if detail not in (None, ""): + details.append(str(detail)) + suffix = f" [{state}]" + return f"{record_id}{suffix}" + (f" ({', '.join(details)})" if details else "") return _render_list_rows(value, render_row) @@ -151,23 +136,14 @@ def _status_text(status: Any, *, color: bool) -> str: return _ansi(text, _STATUS_COLORS.get(text, _ANSI_BOLD), color=color) -def _bool_text(value: Any, *, color: bool) -> str: - truthy = bool(value) - return _ansi(str(truthy), _BOOL_COLORS[truthy], color=color) - - def render_doctor(value: Any, *, color: bool = False) -> str: if not isinstance(value, dict): return _fallback(value) status = value.get("status", "unknown") - healthy = value.get("healthy", False) - backend = value.get("backend") - if not isinstance(backend, list): - backend = [] + healthy = bool(value.get("healthy", False)) lines: list[str] = [ f"status: {_status_text(status, color=color)}", - f"healthy: {_bool_text(healthy, color=color)}", - f"backend: {_join_inline(backend)}", + f"healthy: {_ansi(str(healthy), _BOOL_COLORS[healthy], color=color)}", ] checks = value.get("checks") if not isinstance(checks, list) or not checks: @@ -186,23 +162,6 @@ def render_doctor(value: Any, *, color: bool = False) -> str: return "\n".join(lines) -def render_targets_cleanup(value: Any) -> str: - if not isinstance(value, dict): - return _fallback(value) - lines = [ - f"runtime_dir: {value.get('runtime_dir', '')}", - f"removed: {value.get('removed_count', 0)}", - f"kept: {value.get('kept_count', 0)}", - ] - missing_count = value.get("missing_count", 0) - if missing_count: - lines.append(f"missing: {missing_count}") - log_path = value.get("log_path") - if log_path not in (None, ""): - lines.append(f"log: {log_path}") - return "\n".join(lines) - - def render_function_list(value: Any) -> str: return _render_list_rows( value, @@ -322,13 +281,13 @@ def render_decompile_bulk(value: Any) -> str: return "\n".join(lines) -def render_lines(value: Any, *, field: str = "text") -> str: +def render_lines(value: Any) -> str: if isinstance(value, str): return value - if isinstance(value, dict) and isinstance(value.get(field), str): - return value[field] + if isinstance(value, dict) and isinstance(value.get("text"), str): + return value["text"] if isinstance(value, list): - return "\n".join(str(item.get(field, "")) for item in value) + return "\n".join(str(item.get("text", "")) for item in value) return _fallback(value) @@ -392,7 +351,8 @@ def render_search_results(value: Any) -> str: def render_comment(value: Any) -> str: if not isinstance(value, dict): return _fallback(value) - return _display(value.get("comment")) + comment = value.get("comment") + return "none" if comment in (None, "") else str(comment) def render_bookmarks(value: Any) -> str: @@ -416,7 +376,7 @@ def render_row(item: Any) -> str: line += f" {comment}" return line - return _join_lines(_list_rows(rows, render_row)) + return "\n".join(_list_rows(rows, render_row)) def render_locals(value: Any) -> str: @@ -491,7 +451,8 @@ def render_class_hierarchy(value: Any) -> str: ("derived", derived), ("descendants", descendants), ): - lines.append(f"{label}: {_join_inline(items)}") + rendered = ", ".join(str(item) for item in items) if items else "none" + lines.append(f"{label}: {rendered}") return "\n".join(lines) @@ -577,7 +538,7 @@ def render_type_show(value: Any) -> str: name = value.get("name") kind = value.get("kind") if name or kind: - header = _display(name, empty="") + header = "" if name in (None, "") else str(name) if kind: header += f" {kind}" lines.append(header) @@ -606,7 +567,7 @@ def render_type_show(value: Any) -> str: def render_type_declare(value: Any) -> str: if not isinstance(value, dict): return _fallback(value) - result: TypeDeclareResult = value + result: dict[str, Any] = value imported = result.get("imported_types") replaced = result.get("replaced_types") errors = result.get("errors") @@ -679,7 +640,7 @@ def render_proto_check(value: Any) -> str: return "\n".join(lines) -def _render_type_diagnostic(item: TypeDeclareDiagnostic) -> str: +def _render_type_diagnostic(item: dict[str, Any]) -> str: line = item.get("line") message = item.get("message") or item.get("kind") or "diagnostic" if line not in (None, ""): @@ -687,55 +648,15 @@ def _render_type_diagnostic(item: TypeDeclareDiagnostic) -> str: return f"- {message}" -def render_vtable_dump(value: Any) -> str: - if not isinstance(value, dict): - return _fallback(value) - symbol = _get_first_present(value, "symbol", "identifier") - abi = value.get("abi", "unknown") - lines = [ - f"{symbol} abi={abi}", - f"table: {value.get('table_address', '')}", - f"slots: {value.get('slot_address', '')}", - ] - header = value.get("header") - if isinstance(header, list) and header: - _append_section( - lines, - "header", - _list_rows( - header, - lambda item: ( - f" {item.get('index', '?')}: {item.get('name', '')} = {item.get('value', '')}" - ).rstrip(), - ), - ) - members = value.get("members") - if isinstance(members, list) and members: - _append_section( - lines, - "members", - _list_rows( - members, - lambda member: ( - f"{member.get('slot', '?')!s:>8} {member.get('target', '')} {member.get('name', '')}" - ).rstrip(), - ), - ) - stop_reason = value.get("stop_reason") - if stop_reason not in (None, ""): - lines.append(f"stop_reason: {stop_reason}") - return "\n".join(lines) - - def render_python_exec(value: Any) -> str: if not isinstance(value, dict): return _fallback(value) - stdout = value.get("stdout") or "" + output = "".join(str(value.get(key) or "") for key in ("stdout", "stderr")) rendered_result = value.get("result_repr") - if stdout and rendered_result: - return f"{stdout.rstrip()}\n\nresult: {rendered_result}" - if stdout: - return stdout.rstrip() + if output and rendered_result: + return f"{output.rstrip()}\n\nresult: {rendered_result}" + if output: + return output.rstrip() if rendered_result: return f"result: {rendered_result}" return "ok" @@ -770,11 +691,69 @@ def render_workspace_init(value: Any) -> str: return "\n".join(lines) -TEXT_RENDERERS = build_text_renderers(globals()) - - -def renderer_registry_drift() -> tuple[list[str], list[str]]: - return _renderer_registry_drift(TEXT_RENDERERS) +TEXT_RENDERERS: dict[str, Callable[[Any], str]] = { + "doctor": render_doctor, + "list_targets": render_target_list, + "database_info": render_database_info, + "db_save": _fallback, + "segment_list": render_segment_list, + "function_list": render_function_list, + "function_show": render_function_show, + "function_frame": render_function_frame, + "function_stackvars": render_function_stackvars, + "function_callers": render_function_relations, + "function_callees": render_function_relations, + "disasm": render_lines, + "disasm_range": render_lines, + "decompile": render_lines, + "ctree": render_lines, + "search_bytes": render_search_results, + "xrefs": render_xrefs, + "strings": render_strings, + "imports": render_imports, + "bookmark_get": render_bookmarks, + "bookmark_add": _fallback, + "bookmark_set": _fallback, + "bookmark_delete": _fallback, + "comment_get": render_comment, + "comment_set": _fallback, + "comment_delete": _fallback, + "name_set": _fallback, + "local_list": render_locals, + "local_rename": render_locals, + "local_retype": render_locals, + "local_update": render_locals, + "local_apply_plan": render_locals, + "proto_get": render_type_show, + "proto_check": render_proto_check, + "proto_set": _fallback, + "type_list": render_types, + "type_show": render_type_show, + "type_deps": render_type_show, + "type_declare": render_type_declare, + "type_declare_check": render_type_declare, + "class_list": render_class_list, + "class_candidates": render_class_candidates, + "class_show": render_type_show, + "class_hierarchy": render_class_hierarchy, + "class_fields": render_class_fields, + "class_vtable": render_class_vtable, + "struct_list": render_types, + "struct_show": render_type_show, + "struct_field_set": render_type_show, + "struct_field_rename": render_type_show, + "struct_field_delete": render_type_show, + "enum_list": render_types, + "enum_show": render_type_show, + "enum_member_set": render_type_show, + "enum_member_rename": render_type_show, + "enum_member_delete": render_type_show, + "reanalyze": _fallback, + "python_exec": render_python_exec, + "decompile_bulk": render_decompile_bulk, + "workspace_init": render_workspace_init, + "setup_gui": _fallback, +} __all__ = [ @@ -803,12 +782,9 @@ def renderer_registry_drift() -> tuple[list[str], list[str]]: "render_segment_list", "render_strings", "render_target_list", - "render_targets_cleanup", "render_type_declare", "render_type_show", "render_types", - "render_vtable_dump", "render_workspace_init", "render_xrefs", - "renderer_registry_drift", ] diff --git a/src/idac/cli2/result.py b/src/idac/cli/result.py similarity index 85% rename from src/idac/cli2/result.py rename to src/idac/cli/result.py index d1a9ac5..d823373 100644 --- a/src/idac/cli2/result.py +++ b/src/idac/cli/result.py @@ -9,6 +9,5 @@ class CommandResult: render_op: str value: Any exit_code: int = 0 - warnings: list[str] = field(default_factory=list) stderr_lines: list[str] = field(default_factory=list) artifacts: list[dict[str, Any]] = field(default_factory=list) diff --git a/src/idac/cli2/serialize.py b/src/idac/cli/serialize.py similarity index 92% rename from src/idac/cli2/serialize.py rename to src/idac/cli/serialize.py index c95cac7..b7c37c9 100644 --- a/src/idac/cli2/serialize.py +++ b/src/idac/cli/serialize.py @@ -62,13 +62,6 @@ def _render_value(render_op: str, value: Any, fmt: str, *, color: bool = False) return renderer(value) -def _summary_prefix(text: str) -> str: - snippet = text[:SUMMARY_CHAR_LIMIT] - if snippet.endswith("\n"): - return snippet - return snippet + "\n" - - def _pluralize(noun: str, count: int) -> str: if count == 1: return noun @@ -146,8 +139,6 @@ def _inline_limit_hint(result: CommandResult, *, out_flag: str) -> str | None: return "rerun with `-o ` to write the full ctree output to a file" if result.render_op in {"local_list", "local_rename", "local_retype", "local_update", "local_apply_plan"}: return "rerun with `--json --out ` to inspect the full locals table" - if result.render_op == "docs": - return "rerun with `--out ` to write the full docs output to a file" count_summary = _result_count_summary(result) if count_summary is not None: @@ -180,7 +171,8 @@ def emit_result( if output.artifact is not None: return [output.artifact] if len(output.rendered) > inline_limit: - sys.stdout.write(_summary_prefix(output.rendered)) + snippet = output.rendered[:SUMMARY_CHAR_LIMIT] + sys.stdout.write(snippet if snippet.endswith("\n") else snippet + "\n") raise OutputTooLargeError( chars=len(output.rendered), limit=inline_limit, @@ -191,10 +183,7 @@ def emit_result( return [] -def json_or_jsonl_from_path(path: Path | None, *, default: str = "json") -> str: - if path is None: - return default - suffix = path.suffix.lower() - if suffix == ".jsonl": +def json_or_jsonl_from_path(path: Path | None) -> str: + if path is not None and path.suffix.lower() == ".jsonl": return "jsonl" return "json" diff --git a/src/idac/cli2/batch.py b/src/idac/cli2/batch.py deleted file mode 100644 index 3dac80d..0000000 --- a/src/idac/cli2/batch.py +++ /dev/null @@ -1,586 +0,0 @@ -from __future__ import annotations - -import argparse -import contextlib -import io -import json -import shlex -import time -from pathlib import Path -from typing import Any - -from ..output import write_output_result -from ..transport import BackendError -from .argparse_utils import add_command, add_context_options, bind_root_handler -from .context import merge_parent_context, require_timeout_if_needed -from .errors import CliUserError -from .execute import execute_parsed, reject_unsupported_forwarded_context -from .path_resolution import resolve_relative_paths -from .renderers import TEXT_RENDERERS -from .result import CommandResult -from .serialize import emit_result, json_or_jsonl_from_path - - -class BatchParseError(RuntimeError): - def __init__(self, message: str, *, exit_code: int) -> None: - super().__init__(message) - self.exit_code = exit_code - - -def _line_record( - *, - line: int, - command: str, - status: str, - exit_code: int, - stderr: str | None = None, - result: Any = None, - timing_ms: float, - artifacts: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: - payload: dict[str, Any] = { - "line": line, - "command": command, - "status": status, - "exit_code": exit_code, - "timing_ms": round(timing_ms, 3), - "artifacts": list(artifacts or []), - } - if stderr: - payload["stderr"] = stderr - if result is not None: - payload["result"] = result - return payload - - -def _serialize_child_if_needed(result, args) -> list[dict[str, Any]]: - artifacts = list(result.artifacts) - arg_map = vars(args) - out_path = arg_map.get("out") - if out_path is None: - return artifacts - artifacts.extend(emit_result(result, fmt=arg_map.get("format", "text"), out_path=out_path)) - return artifacts - - -def _fallback_child_failure(value: Any) -> str | None: - if isinstance(value, str): - return value - try: - return json.dumps(value, indent=2, sort_keys=True) - except TypeError: - return str(value) - - -def _render_child_failure(result: CommandResult) -> str | None: - if result.stderr_lines: - stderr_text = "\n".join(line for line in result.stderr_lines if line.strip()).strip() - if stderr_text: - return stderr_text - renderer = TEXT_RENDERERS.get(result.render_op) - if renderer is not None: - try: - rendered_text = renderer(result.value).strip() - if rendered_text: - return rendered_text - except Exception as exc: - fallback_text = (_fallback_child_failure(result.value) or "").strip() - failure_prefix = f"(renderer failure while formatting {result.render_op}: {exc.__class__.__name__}: {exc})" - return failure_prefix if not fallback_text else f"{failure_prefix}\n{fallback_text}" - fallback_text = (_fallback_child_failure(result.value) or "").strip() - return fallback_text or None - - -def _parse_batch_args(root_parser: argparse.ArgumentParser, argv: list[str]) -> argparse.Namespace: - stdout_buffer = io.StringIO() - stderr_buffer = io.StringIO() - try: - with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer): - return root_parser.parse_args(argv) - except SystemExit as exc: - message = _captured_cli_message(stdout_buffer.getvalue(), stderr_buffer.getvalue(), fallback="parse failed") - exit_code = int(exc.code) if isinstance(exc.code, int) else 1 - raise BatchParseError(message, exit_code=exit_code) from exc - - -def _reject_handlerless_command(parsed: argparse.Namespace) -> None: - if getattr(parsed, "run", None) is not None: - return - selected_parser = parsed._selected_parser - message = selected_parser.format_help().strip() or "missing subcommand" - raise BatchParseError(message, exit_code=2) - - -def _execute_batch_args(parsed: argparse.Namespace, *, root_parser: argparse.ArgumentParser) -> CommandResult: - stdout_buffer = io.StringIO() - stderr_buffer = io.StringIO() - try: - with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer): - return execute_parsed(parsed, root_parser=root_parser) - except SystemExit as exc: - message = _captured_cli_message(stdout_buffer.getvalue(), stderr_buffer.getvalue(), fallback="command exited") - exit_code = int(exc.code) if isinstance(exc.code, int) else 1 - raise BatchParseError(message, exit_code=exit_code) from exc - - -def _captured_cli_message(*parts: str, fallback: str) -> str: - message = "\n".join(part.strip() for part in parts if part.strip()) - return message or fallback - - -def _command_lines(batch_path: Path) -> list[tuple[int, str]]: - lines: list[tuple[int, str]] = [] - for line_number, raw_line in enumerate(batch_path.read_text(encoding="utf-8").splitlines(), start=1): - stripped = raw_line.strip() - if not stripped or stripped.startswith("#"): - continue - lines.append((line_number, stripped)) - return lines - - -def _argv_from_batch_line(stripped: str) -> list[str]: - argv = shlex.split(stripped, comments=True, posix=True) - if argv and argv[0] == "idac": - argv = argv[1:] - return argv - - -def _reject_mutating_batch_without_out( - *, - root_parser: argparse.ArgumentParser, - command_lines: list[tuple[int, str]], - out_path: Path | None, -) -> None: - if out_path is not None: - return - for line_number, stripped in command_lines: - try: - argv = _argv_from_batch_line(stripped) - if not argv: - continue - parsed = _parse_batch_args(root_parser, argv) - except (BatchParseError, ValueError): - continue - if bool(vars(parsed).get("_mutating_command", False)): - raise CliUserError( - "mutating batch commands require `--out ` so the ordered " - f"result log is preserved before changes run; first mutating line is {line_number}: {stripped}" - ) - - -def _lint_missing_input_paths(parsed: argparse.Namespace) -> list[str]: - missing: list[str] = [] - output_keys = {"out", "out_file", "out_dir"} - for key, value in vars(parsed).items(): - if key in output_keys or not isinstance(value, Path): - continue - if not value.exists(): - missing.append(f"{key} path does not exist: {value}") - return missing - - -def _lint_local_selector_warning(parsed: argparse.Namespace, *, after_type_or_reanalysis: bool) -> str | None: - parsed_map = vars(parsed) - if parsed_map.get("function_command") != "locals": - return None - if parsed_map.get("locals_command") not in {"rename", "retype", "update"}: - return None - if parsed_map.get("local_id") or parsed_map.get("index") is not None: - return None - selector = str(parsed_map.get("selector") or "").strip() - if not selector: - return None - if selector.isdigit() or "@" in selector: - return None - if after_type_or_reanalysis: - return "name-only local selector after type/prototype/reanalysis work; prefer --local-id or --index" - return "name-only local selector; prefer --local-id or --index for batch updates" - - -def _lint_changes_local_layout(parsed: argparse.Namespace) -> bool: - parsed_map = vars(parsed) - if parsed_map.get("misc_command") == "reanalyze": - return True - if parsed_map.get("type_command") == "declare": - return True - return parsed_map.get("function_command") == "prototype" and parsed_map.get("prototype_command") == "set" - - -def _lint_command_local_errors(parsed: argparse.Namespace) -> list[str]: - parsed_map = vars(parsed) - try: - if parsed_map.get("command") == "disasm": - from .commands import top_level - - top_level.disasm_request(parsed) - elif parsed_map.get("type_command") == "list": - from .commands import type_commands - - type_commands._type_list_guard(parsed) - elif parsed_map.get("function_command") == "locals": - from .commands import function as function_commands - from .commands.common import local_rename_params, local_retype_params, local_update_params - - locals_command = parsed_map.get("locals_command") - if locals_command == "rename": - local_rename_params(parsed) - elif locals_command == "retype": - local_retype_params(parsed) - elif locals_command == "update": - local_update_params(parsed) - elif locals_command == "apply": - function_commands._locals_apply_plan_params(parsed) - elif parsed_map.get("function_command") == "prototype": - from .commands import function as function_commands - - if parsed_map.get("prototype_command") == "set": - function_commands._prototype_set_params(parsed) - except CliUserError as exc: - return [str(exc) or exc.__class__.__name__] - return [] - - -def _lint_preview_wrapped_command( - *, - root_parser: argparse.ArgumentParser, - preview_args: argparse.Namespace, - batch_dir: Path, -) -> tuple[argparse.Namespace, list[str]]: - tokens = list(getattr(preview_args, "command_tokens", None) or []) - if tokens and tokens[0] == "--": - tokens = tokens[1:] - if not tokens: - raise CliUserError("preview requires a command to wrap") - - wrapped = _parse_batch_args(root_parser, tokens) - wrapped_map = vars(wrapped) - if wrapped_map.get("_hidden_command", False) or not wrapped_map.get("allow_preview", True): - raise CliUserError("command is not available in preview mode") - if wrapped_map.get("command") == "preview": - raise CliUserError("nested preview is not supported") - _reject_handlerless_command(wrapped) - - merge_parent_context(wrapped, preview_args) - reject_unsupported_forwarded_context(wrapped._selected_parser, wrapped) - require_timeout_if_needed(wrapped) - resolve_relative_paths(wrapped, base_dir=batch_dir) - line_errors = _lint_missing_input_paths(wrapped) - if not line_errors: - line_errors.extend(_lint_command_local_errors(wrapped)) - return wrapped, line_errors - - -def _lint_batch( - *, - root_parser: argparse.ArgumentParser, - batch_path: Path, - out_path: Path | None, - parent_args: argparse.Namespace, -) -> dict[str, Any]: - rows: list[dict[str, Any]] = [] - errors: list[dict[str, Any]] = [] - warnings: list[dict[str, Any]] = [] - batch_dir = batch_path.parent.resolve(strict=False) - after_type_or_reanalysis = False - for line_number, stripped in _command_lines(batch_path): - try: - try: - argv = _argv_from_batch_line(stripped) - except ValueError as exc: - raise BatchParseError(str(exc), exit_code=2) from exc - if not argv: - raise CliUserError("empty command") - parsed = _parse_batch_args(root_parser, argv) - parsed_map = vars(parsed) - if parsed_map.get("_hidden_command", False) or not parsed_map.get("allow_batch", True): - raise CliUserError("command is not available in batch mode") - _reject_handlerless_command(parsed) - merge_parent_context(parsed, parent_args) - reject_unsupported_forwarded_context(parsed._selected_parser, parsed) - if parsed_map.get("command") == "preview": - lint_target, line_errors = _lint_preview_wrapped_command( - root_parser=root_parser, - preview_args=parsed, - batch_dir=batch_dir, - ) - else: - lint_target = parsed - require_timeout_if_needed(parsed) - resolve_relative_paths(parsed, base_dir=batch_dir) - line_errors = _lint_missing_input_paths(parsed) - if bool(parsed_map.get("_mutating_command", False)) and out_path is None: - line_errors.append("mutating batch command requires wrapper --out") - if not line_errors: - line_errors.extend(_lint_command_local_errors(parsed)) - warning = _lint_local_selector_warning(lint_target, after_type_or_reanalysis=after_type_or_reanalysis) - if warning is not None: - warnings.append({"line": line_number, "command": stripped, "message": warning}) - if parsed_map.get("command") != "preview" and _lint_changes_local_layout(parsed): - after_type_or_reanalysis = True - if line_errors: - for message in line_errors: - errors.append({"line": line_number, "command": stripped, "message": message}) - rows.append( - _line_record( - line=line_number, - command=stripped, - status="failed", - exit_code=1, - stderr="; ".join(line_errors), - timing_ms=0.0, - ) - ) - continue - rows.append( - _line_record( - line=line_number, - command=stripped, - status="ok", - exit_code=0, - result={"lint": "ok"}, - timing_ms=0.0, - ) - ) - except BatchParseError as exc: - message = str(exc) - errors.append({"line": line_number, "command": stripped, "message": message}) - rows.append( - _line_record( - line=line_number, - command=stripped, - status="failed", - exit_code=exc.exit_code, - stderr=message, - timing_ms=0.0, - ) - ) - except CliUserError as exc: - message = str(exc) or exc.__class__.__name__ - errors.append({"line": line_number, "command": stripped, "message": message}) - rows.append( - _line_record( - line=line_number, - command=stripped, - status="failed", - exit_code=1, - stderr=message, - timing_ms=0.0, - ) - ) - return { - "ok": not errors, - "mode": "lint", - "batch_file": str(batch_path), - "commands_total": len(rows), - "commands_linted": sum(1 for row in rows if row["exit_code"] == 0), - "errors_total": len(errors), - "warnings_total": len(warnings), - "errors": errors, - "warnings": warnings, - "results": rows, - } - - -def failure_lines(payload: Any) -> list[str]: - if not isinstance(payload, dict): - return [] - lint_errors = payload.get("errors") - if isinstance(lint_errors, list) and lint_errors: - lines: list[str] = [] - for item in lint_errors: - if not isinstance(item, dict): - continue - lines.append(f"batch line {item.get('line', '?')}: {item.get('command', '')}") - lines.append(f" {item.get('message', 'lint failed')}") - return lines - rows = payload.get("results") - if not isinstance(rows, list): - return [] - lines: list[str] = [] - for row in rows: - if not isinstance(row, dict) or row.get("exit_code") == 0: - continue - lines.append(f"batch line {row.get('line', '?')}: {row.get('command', '')}") - message = str(row.get("stderr") or "").strip() - if not message: - message = "step failed" - for item in message.splitlines(): - lines.append(f" {item}") - return lines - - -def _batch_payload(rows: list[dict[str, Any]], *, batch_path: Path) -> dict[str, Any]: - return { - "ok": all(row["exit_code"] == 0 for row in rows), - "batch_file": str(batch_path), - "commands_total": len(rows), - "commands_succeeded": sum(1 for row in rows if row["exit_code"] == 0), - "commands_failed": sum(1 for row in rows if row["exit_code"] != 0), - "results": rows, - } - - -def _write_batch_rows( - rows: list[dict[str, Any]], - *, - batch_path: Path, - out_path: Path | None, -) -> list[dict[str, Any]]: - if out_path is None: - return [] - fmt = json_or_jsonl_from_path(out_path, default="json") - value = rows if fmt == "jsonl" else _batch_payload(rows, batch_path=batch_path) - output = write_output_result(value, fmt=fmt, out_path=Path(out_path), stem="batch") - return [] if output.artifact is None else [output.artifact] - - -def run(args: argparse.Namespace, *, root_parser: argparse.ArgumentParser) -> CommandResult: - rows: list[dict[str, Any]] = [] - batch_path = Path(args.batch_file) - batch_dir = batch_path.parent.resolve(strict=False) - command_lines = _command_lines(batch_path) - if args.lint: - payload = _lint_batch(root_parser=root_parser, batch_path=batch_path, out_path=args.out, parent_args=args) - artifacts: list[dict[str, Any]] = [] - if args.out is not None: - fmt = json_or_jsonl_from_path(args.out, default="json") - path = Path(args.out) - value = payload["results"] if fmt == "jsonl" else payload - output = write_output_result(value, fmt=fmt, out_path=path, stem="batch") - if output.artifact is not None: - artifacts.append(output.artifact) - return CommandResult( - render_op="batch", - value=payload, - exit_code=0 if payload["ok"] else 1, - stderr_lines=failure_lines(payload), - artifacts=artifacts, - ) - _reject_mutating_batch_without_out(root_parser=root_parser, command_lines=command_lines, out_path=args.out) - for line_number, stripped in command_lines: - started = time.perf_counter() - rows_before = len(rows) - try: - try: - argv = _argv_from_batch_line(stripped) - except ValueError as exc: - raise BatchParseError(str(exc), exit_code=2) from exc - if not argv: - raise CliUserError("empty command") - parsed = _parse_batch_args(root_parser, argv) - parsed_map = vars(parsed) - if parsed_map.get("_hidden_command", False) or not parsed_map.get("allow_batch", True): - raise CliUserError("command is not available in batch mode") - merge_parent_context(parsed, args) - resolve_relative_paths(parsed, base_dir=batch_dir) - parsed._relative_path_base_dir = batch_dir - parsed._batch_mode = True - result = _execute_batch_args(parsed, root_parser=root_parser) - record = _line_record( - line=line_number, - command=stripped, - status="ok" if result.exit_code == 0 else "failed", - exit_code=result.exit_code, - stderr=_render_child_failure(result) if result.exit_code != 0 else None, - result=result.value, - timing_ms=(time.perf_counter() - started) * 1000.0, - artifacts=list(result.artifacts), - ) - # Record the row before serializing the child's own --out file, so a - # write failure there cannot erase the fact that the command (which - # may have mutated the database) already ran. - rows.append(record) - try: - record["artifacts"] = _serialize_child_if_needed(result, parsed) - except OSError as exc: - record["status"] = "failed" - if record["exit_code"] == 0: - record["exit_code"] = 1 - record["stderr"] = f"command ran but writing its --out file failed: {exc}" - if record["exit_code"] != 0 and args.fail_fast: - break - except BatchParseError as exc: - rows.append( - _line_record( - line=line_number, - command=stripped, - status="failed", - exit_code=exc.exit_code, - stderr=str(exc), - timing_ms=(time.perf_counter() - started) * 1000.0, - ) - ) - if args.fail_fast: - break - except (BackendError, CliUserError) as exc: - rows.append( - _line_record( - line=line_number, - command=stripped, - status="failed", - exit_code=1, - stderr=str(exc) or exc.__class__.__name__, - timing_ms=(time.perf_counter() - started) * 1000.0, - ) - ) - if args.fail_fast: - break - finally: - # Checkpoint after every line so an interrupted mutating batch still - # leaves an ordered record of which commands ran. Best-effort: a - # write failure here must not mask an in-flight exception (e.g. - # KeyboardInterrupt) or abort the remaining batch. The authoritative - # write after the loop still surfaces a persistent out-file error. - if len(rows) != rows_before: - with contextlib.suppress(OSError): - _write_batch_rows(rows, batch_path=batch_path, out_path=args.out) - - payload = _batch_payload(rows, batch_path=batch_path) - exit_code = 0 if payload["ok"] else 1 - artifacts = _write_batch_rows(rows, batch_path=batch_path, out_path=args.out) - return CommandResult( - render_op="batch", - value=payload, - exit_code=exit_code, - stderr_lines=failure_lines(payload), - artifacts=artifacts, - ) - - -def register( - root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] -) -> None: - parser = add_command( - root_parser, subparsers, "batch", help_text="Apply one idac subcommand per line from a batch file" - ) - parser.formatter_class = argparse.RawDescriptionHelpFormatter - parser.epilog = """batch file format: - - one shell-like idac subcommand per line - - omit the leading `idac`; a leading `idac` is also accepted - - blank lines and lines starting with # are ignored - - relative child paths such as --decl-file, --functions-file, and --out resolve from the batch file directory - - preview lines are allowed, for example `preview function prototype set ...` - -example recovery.idac: - type declare --replace --decl-file recovered_types.h - preview function prototype set sub_401000 --decl-file sub_401000.h - function prototype set sub_401000 --decl-file sub_401000.h - function locals update sub_401000 --local-id 'stack(16)@0x1000' --rename count -""" - add_context_options(parser) - parser.add_argument( - "batch_file", - type=Path, - metavar="BATCH_FILE", - help="Read one shell-like idac subcommand per line from this file", - ) - parser.add_argument("-o", "--out", type=Path, help="Write ordered batch results to a JSON or JSONL file") - parser.add_argument("--fail-fast", action="store_true", help="Stop after the first failing command") - parser.add_argument("--lint", action="store_true", help="Parse and validate batch commands without executing them") - parser.set_defaults( - run=bind_root_handler(root_parser, run), - context_policy="wrapper", - allow_batch=False, - allow_preview=False, - _mutating_command=False, - ) diff --git a/src/idac/cli2/commands/common.py b/src/idac/cli2/commands/common.py deleted file mode 100644 index 5519cb7..0000000 --- a/src/idac/cli2/commands/common.py +++ /dev/null @@ -1,222 +0,0 @@ -from __future__ import annotations - -import argparse -import re -from dataclasses import dataclass -from typing import Any, Literal - -from ...ops.helpers.params import parse_aliases, parse_int_text -from ...transport import send_request -from ...transport.schema import RequestEnvelope -from ..argparse_utils import read_decl_or_type_text, read_decl_text_if_present -from ..errors import CliUserError -from ..result import CommandResult - -_INFERRED_LOCAL_ID_RE = re.compile( - # Stable local ids from `function locals list --json` encode storage plus a versioned slot identifier. - r"^(?:stack\([^)]*\)|reg\([^)]*\)|regpair\([^)]*\)|unknown)@(?:0x[0-9a-fA-F]+|\d+)$", - re.IGNORECASE, -) - - -@dataclass(frozen=True) -class LocalSelector: - param_name: Literal["old_name", "local_name", "local_id", "index"] - value: str | int - - def apply(self, params: dict[str, Any]) -> None: - params[self.param_name] = self.value - - -@dataclass(frozen=True) -class LocalRenameRequest: - identifier: str - selector: LocalSelector - new_name: str - - def to_params(self) -> dict[str, Any]: - params: dict[str, Any] = {"identifier": self.identifier, "new_name": self.new_name} - self.selector.apply(params) - return params - - -@dataclass(frozen=True) -class LocalRetypeRequest: - identifier: str - selector: LocalSelector - decl: str - - def to_params(self) -> dict[str, Any]: - params: dict[str, Any] = {"identifier": self.identifier, "decl": self.decl} - self.selector.apply(params) - return params - - -@dataclass(frozen=True) -class LocalUpdateRequest: - identifier: str - selector: LocalSelector - new_name: str | None - decl: str | None - - def to_params(self) -> dict[str, Any]: - params: dict[str, Any] = {"identifier": self.identifier} - self.selector.apply(params) - if self.new_name is not None: - params["new_name"] = self.new_name - if self.decl is not None: - params["decl"] = self.decl - return params - - def has_changes(self) -> bool: - return self.new_name is not None or self.decl is not None - - -def command_result( - render_op: str, - value: Any, - *, - exit_code: int = 0, - warnings: list[str] | None = None, - stderr_lines: list[str] | None = None, -) -> CommandResult: - return CommandResult( - render_op=render_op, - value=value, - exit_code=exit_code, - warnings=list(warnings or []), - stderr_lines=list(stderr_lines or []), - ) - - -def _response_error_message(response: dict[str, Any]) -> str: - message = str(response.get("error") or "request failed") - if response.get("error_kind") == "busy": - message += ( - "; idac requests for one target are serialized, so wait for the current command to finish " - "or use batch/decompilemany instead of running parallel idac commands" - ) - return message - - -def send_op( - args: argparse.Namespace, - *, - op: str, - params: dict[str, Any], - render_op: str | None = None, - preview: bool | None = None, -) -> CommandResult: - payload = dict(params) - preview_requested = bool(args._preview_wrapper and args._mutating_command) if preview is None else preview - if preview_requested: - payload["preview"] = True - response = send_request( - RequestEnvelope( - op=op, - params=payload, - backend=args.backend, - target=args.target, - database=args.database, - timeout=getattr(args, "timeout", None), - ) - ) - if not response.get("ok"): - raise CliUserError(_response_error_message(response)) - warnings = [str(item) for item in (response.get("warnings") or []) if str(item)] - return command_result(render_op or op, response.get("result"), warnings=warnings) - - -def parse_alias_list(values: list[str] | None) -> list[dict[str, str]]: - try: - return parse_aliases(values or []) - except ValueError as exc: - raise CliUserError(str(exc)) from exc - - -def _parse_cli_int_text(value: Any, *, label: str, minimum: int | None = None) -> int: - try: - return parse_int_text(value, label=label, minimum=minimum) - except ValueError as exc: - raise CliUserError(str(exc)) from exc - - -def parse_bookmark_slot(value: Any) -> int: - return _parse_cli_int_text(value, label="bookmark slot", minimum=0) - - -def _looks_like_local_id_selector(token: str) -> bool: - return bool(_INFERRED_LOCAL_ID_RE.match(token.strip())) - - -def _infer_local_selector(token: str) -> tuple[str, Any]: - text = str(token).strip() - if re.fullmatch(r"-?\d+", text): - return "index", _parse_cli_int_text(text, label="local index", minimum=0) - if _looks_like_local_id_selector(text): - return "local_id", text - return "old_name", text - - -def _local_selector_from_args( - args: argparse.Namespace, - *, - name_param: Literal["old_name", "local_name"], -) -> LocalSelector: - if args.local_id and args.index is not None: - raise CliUserError("--local-id and --index are mutually exclusive") - selector_text = str(args.selector or "").strip() - if (args.local_id or args.index is not None) and selector_text: - raise CliUserError("do not combine a positional selector with --local-id or --index") - if args.local_id: - return LocalSelector("local_id", str(args.local_id)) - if args.index is not None: - return LocalSelector("index", _parse_cli_int_text(args.index, label="local index", minimum=0)) - if not selector_text: - raise CliUserError("local selector is required via selector, --local-id, or --index") - selector_kind, selector_value = _infer_local_selector(selector_text) - if selector_kind == "old_name": - return LocalSelector(name_param, str(selector_value)) - if selector_kind == "local_id": - return LocalSelector("local_id", str(selector_value)) - return LocalSelector("index", int(selector_value)) - - -def _local_rename_request(args: argparse.Namespace) -> LocalRenameRequest: - return LocalRenameRequest( - identifier=str(args.function), - selector=_local_selector_from_args(args, name_param="old_name"), - new_name=str(args.new_name), - ) - - -def _local_retype_request(args: argparse.Namespace) -> LocalRetypeRequest: - return LocalRetypeRequest( - identifier=str(args.function), - selector=_local_selector_from_args(args, name_param="local_name"), - decl=read_decl_or_type_text(args), - ) - - -def _local_update_request(args: argparse.Namespace) -> LocalUpdateRequest: - request = LocalUpdateRequest( - identifier=str(args.function), - selector=_local_selector_from_args(args, name_param="local_name"), - new_name=str(args.rename or "").strip() or None, - decl=read_decl_text_if_present(args), - ) - if not request.has_changes(): - raise CliUserError("at least one of --rename or declaration input is required") - return request - - -def local_rename_params(args: argparse.Namespace) -> dict[str, Any]: - return _local_rename_request(args).to_params() - - -def local_retype_params(args: argparse.Namespace) -> dict[str, Any]: - return _local_retype_request(args).to_params() - - -def local_update_params(args: argparse.Namespace) -> dict[str, Any]: - return _local_update_request(args).to_params() diff --git a/src/idac/cli2/commands/database.py b/src/idac/cli2/commands/database.py deleted file mode 100644 index 5be4cc9..0000000 --- a/src/idac/cli2/commands/database.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import argparse - -from ..argparse_utils import add_command, add_context_options, add_output_options, set_context_defaults -from ..commands.common import send_op -from ..result import CommandResult - - -def _database_open_params(args: argparse.Namespace) -> dict[str, object]: - return {"path": str(args.path), "run_auto_analysis": bool(args.run_auto_analysis)} - - -def _database_save_params(args: argparse.Namespace) -> dict[str, object]: - return {} if args.path is None else {"path": str(args.path)} - - -def _database_close_params(args: argparse.Namespace) -> dict[str, object]: - return {"discard": bool(args.discard)} - - -def _show(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="database_info", params={}) - - -def _open(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="db_open", params=_database_open_params(args)) - - -def _save(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="db_save", params=_database_save_params(args)) - - -def _close(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="db_close", params=_database_close_params(args)) - - -def register( - root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] -) -> None: - parser = add_command(root_parser, subparsers, "database", help_text="Inspect or manage database state") - db_subparsers = parser.add_subparsers(dest="database_command") - - child = add_command(parser, db_subparsers, "show", help_text="Show database info") - add_context_options(child) - add_output_options(child, default_format="text") - child.set_defaults( - run=_show, context_policy="standard", allow_batch=True, allow_preview=False, _mutating_command=False - ) - - child = add_command(parser, db_subparsers, "open", help_text="Open a database in the idalib daemon") - add_output_options(child, default_format="json") - child.add_argument("path", help="Path to a database or binary to open") - child.add_argument( - "--no-auto-analysis", - dest="run_auto_analysis", - action="store_false", - help="Open without waiting for auto-analysis", - ) - set_context_defaults(child) - child.set_defaults( - run=_open, - context_policy="database_open", - allow_batch=True, - allow_preview=False, - _mutating_command=False, - run_auto_analysis=True, - ) - - child = add_command(parser, db_subparsers, "save", help_text="Save the current database") - add_context_options(child) - add_output_options(child, default_format="json") - child.add_argument("path", nargs="?", help="Optional destination database path") - child.set_defaults( - run=_save, context_policy="standard", allow_batch=True, allow_preview=False, _mutating_command=True - ) - - child = add_command(parser, db_subparsers, "close", help_text="Close the current idalib database") - add_context_options(child) - add_output_options(child, default_format="json") - child.add_argument("--discard", action="store_true", help="Close without saving pending changes") - child.set_defaults( - run=_close, context_policy="database_close", allow_batch=True, allow_preview=False, _mutating_command=True - ) diff --git a/src/idac/cli2/commands/docs.py b/src/idac/cli2/commands/docs.py deleted file mode 100644 index a083989..0000000 --- a/src/idac/cli2/commands/docs.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import argparse - -from ...docs import docs_payload -from ..argparse_utils import add_command, add_output_options -from ..commands.common import command_result -from ..errors import CliUserError -from ..result import CommandResult - - -def _show(args: argparse.Namespace) -> CommandResult: - try: - payload = docs_payload(args.topic, list_only=bool(args.list), all_topics=bool(args.all)) - except ValueError as exc: - raise CliUserError(str(exc)) from exc - return command_result("docs", payload) - - -def register( - root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] -) -> None: - parser = add_command(root_parser, subparsers, "docs", help_text="Print bundled idac help and IDA guidance") - add_output_options(parser, default_format="text") - parser.add_argument( - "topic", - nargs="?", - help="Topic to print, such as cli, workflows, class-recovery, ida-cpp-type-details, or workspace", - ) - mode = parser.add_mutually_exclusive_group() - mode.add_argument("--list", action="store_true", help="List available docs topics") - mode.add_argument("--all", action="store_true", help="Print all bundled docs; use --out for large output") - parser.set_defaults( - run=_show, - context_policy="none", - allow_batch=True, - allow_preview=False, - _mutating_command=False, - ) diff --git a/src/idac/cli2/commands/misc.py b/src/idac/cli2/commands/misc.py deleted file mode 100644 index e2aff25..0000000 --- a/src/idac/cli2/commands/misc.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import shutil -from pathlib import Path - -from ...paths import ( - plugin_bootstrap_install_path, - plugin_bootstrap_source_path, - plugin_install_dir, - plugin_runtime_package_install_dir, - plugin_runtime_package_source_dir, - plugin_source_dir, - skill_install_dirs, - skill_source_dir, -) -from ..argparse_utils import ( - add_command, - add_context_options, - add_install_options, - add_output_options, -) -from ..commands.common import command_result, send_op -from ..result import CommandResult - - -def _install_path(source: Path, dest: Path, *, mode: str, force: bool, is_dir: bool) -> None: - if not source.exists(): - raise OSError(f"source path is missing: {source}") - dest.parent.mkdir(parents=True, exist_ok=True) - if dest.exists() or dest.is_symlink(): - if not force: - raise OSError(f"destination already exists: {dest}") - if dest.is_symlink() or dest.is_file(): - dest.unlink() - else: - shutil.rmtree(dest) - if mode == "copy": - if is_dir: - shutil.copytree(source, dest) - else: - shutil.copy2(source, dest) - return - os.symlink(source, dest, target_is_directory=is_dir) - - -def _rename(args: argparse.Namespace) -> CommandResult: - params = {"identifier": args.identifier, "new_name": args.new_name} - return send_op(args, op="name_set", params=params) - - -def _reanalyze(args: argparse.Namespace) -> CommandResult: - params: dict[str, object] = {"identifier": args.identifier} - if args.end: - params["end"] = args.end - return send_op(args, op="reanalyze", params=params) - - -def _plugin_install(args: argparse.Namespace) -> CommandResult: - package_source = plugin_source_dir() - bootstrap_source = plugin_bootstrap_source_path() - runtime_source = plugin_runtime_package_source_dir() - custom_dest = args.dest - package_dest = custom_dest or plugin_install_dir() - bootstrap_dest = ( - package_dest.parent / plugin_bootstrap_install_path().name if custom_dest else plugin_bootstrap_install_path() - ) - runtime_dest = package_dest.parent / runtime_source.name if custom_dest else plugin_runtime_package_install_dir() - _install_path(package_source, package_dest, mode=args.mode, force=args.force, is_dir=True) - _install_path(bootstrap_source, bootstrap_dest, mode=args.mode, force=args.force, is_dir=False) - _install_path(runtime_source, runtime_dest, mode=args.mode, force=args.force, is_dir=True) - return command_result( - "plugin_install", - { - "installed": True, - "mode": args.mode, - "package_destination": str(package_dest), - "bootstrap_destination": str(bootstrap_dest), - "runtime_package_destination": str(runtime_dest), - }, - ) - - -def _skill_install(args: argparse.Namespace) -> CommandResult: - source = skill_source_dir() - custom_dest = args.dest - destinations = [custom_dest] if custom_dest else skill_install_dirs(host=args.host) - for dest in destinations: - _install_path(source, dest, mode=args.mode, force=args.force, is_dir=True) - return command_result( - "skill_install", - { - "installed": True, - "mode": args.mode, - "source": str(source), - "destinations": [str(dest) for dest in destinations], - }, - ) - - -def register( - root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] -) -> None: - parser = add_command(root_parser, subparsers, "misc", help_text="Maintenance, setup, and utility commands") - misc_subparsers = parser.add_subparsers(dest="misc_command") - - child = add_command(parser, misc_subparsers, "rename", help_text="Rename an item") - add_context_options(child) - add_output_options(child, default_format="json") - child.add_argument("identifier", help="Function name, symbol, or address") - child.add_argument("new_name", help="Replacement name") - child.set_defaults( - run=_rename, context_policy="standard", allow_batch=False, allow_preview=False, _mutating_command=True - ) - - child = add_command(parser, misc_subparsers, "reanalyze", help_text="Re-run IDA analysis on a function or range") - add_context_options(child) - add_output_options(child, default_format="json") - child.add_argument("identifier", help="Function name, symbol, or address") - child.add_argument("--end", help="Optional end address for range reanalysis") - child.set_defaults( - run=_reanalyze, context_policy="standard", allow_batch=True, allow_preview=False, _mutating_command=True - ) - - plugin_parser = add_command(parser, misc_subparsers, "plugin", help_text="Plugin operations") - plugin_subparsers = plugin_parser.add_subparsers(dest="misc_plugin_command") - child = add_command(plugin_parser, plugin_subparsers, "install", help_text="Install the GUI bridge plugin") - add_output_options(child, default_format="json") - add_install_options(child) - child.add_argument("--dest", type=Path, help="Custom installation destination") - child.set_defaults( - run=_plugin_install, context_policy="none", allow_batch=False, allow_preview=False, _mutating_command=False - ) - - skill_parser = add_command(parser, misc_subparsers, "skill", help_text="Skill operations") - skill_subparsers = skill_parser.add_subparsers(dest="misc_skill_command") - child = add_command(skill_parser, skill_subparsers, "install", help_text="Install the bundled idac skill") - add_output_options(child, default_format="json") - add_install_options(child) - child.add_argument("--dest", type=Path, help="Custom installation destination") - child.add_argument("--host", choices=("claude", "codex", "both"), default="both", help="Install target host") - child.set_defaults( - run=_skill_install, context_policy="none", allow_batch=False, allow_preview=False, _mutating_command=False - ) diff --git a/src/idac/cli2/commands/python_exec.py b/src/idac/cli2/commands/python_exec.py deleted file mode 100644 index 4f6828f..0000000 --- a/src/idac/cli2/commands/python_exec.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -from ..argparse_utils import add_command, add_context_options, add_output_options -from ..commands.common import send_op -from ..errors import CliUserError -from ..result import CommandResult - - -def _python_exec_params(args: argparse.Namespace) -> dict[str, object]: - script: str | None = None - script_path: str | None = None - if args.code: - script = str(args.code) - elif args.stdin: - script = sys.stdin.read() - elif args.script: - path = Path(args.script) - if not path.is_file(): - raise CliUserError(f"script file not found: {path}") - script_path = str(path.resolve()) - else: - raise CliUserError("missing Python input") - params: dict[str, object] = {} - if script is not None: - params["script"] = script - if script_path is not None: - params["script_path"] = script_path - if args.persist: - params["persist"] = True - return params - - -def _exec(args: argparse.Namespace) -> CommandResult: - return send_op(args, op="python_exec", params=_python_exec_params(args)) - - -def register( - root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] -) -> None: - parser = add_command(root_parser, subparsers, "py", help_text="Execute Python in the backend runtime") - py_subparsers = parser.add_subparsers(dest="py_command") - - child = add_command(parser, py_subparsers, "exec", help_text="Execute Python code") - add_context_options(child) - add_output_options(child, default_format="text") - mode = child.add_mutually_exclusive_group(required=True) - mode.add_argument("--code", help="Execute inline Python code") - mode.add_argument("--stdin", action="store_true", help="Read Python code from stdin") - mode.add_argument("--script", type=Path, help="Read Python code from this file") - child.add_argument( - "--persist", - action="store_true", - help="Reuse the same Python globals across later py exec commands in the current session", - ) - child.set_defaults( - run=_exec, context_policy="standard", allow_batch=True, allow_preview=False, _mutating_command=True - ) diff --git a/src/idac/cli2/commands/targets.py b/src/idac/cli2/commands/targets.py deleted file mode 100644 index 7c50a17..0000000 --- a/src/idac/cli2/commands/targets.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -import argparse -from pathlib import Path -from typing import Any - -from ...doctor import run_doctor_cleanup -from ...transport import BackendError -from ...transport.idalib_common import normalize_database_path -from ...transport.schema import RequestEnvelope -from ..argparse_utils import add_command, add_context_options, add_output_options -from ..commands.common import command_result, send_request -from ..errors import CliUserError -from ..result import CommandResult - -AGGREGATE_GUI_DISCOVERY_TIMEOUT = 2.0 - - -def _target_sort_key(item: dict[str, Any]) -> tuple[int, str, int, str]: - backend_order = {"gui": 0, "idalib": 1} - backend = str(item.get("backend") or "") - try: - pid = int(item.get("instance_pid") or 0) - except (TypeError, ValueError): - pid = 0 - return ( - backend_order.get(backend, 99), - str(item.get("module") or ""), - pid, - str(item.get("filename") or ""), - ) - - -def _row_matches_database(item: dict[str, Any], database: str) -> bool: - try: - requested = normalize_database_path(database) - except (OSError, RuntimeError, ValueError): - requested = str(Path(database).expanduser()) - values = { - str(item.get("filename") or ""), - str(item.get("database_path") or ""), - } - for value in values: - if not value: - continue - try: - if normalize_database_path(value) == requested: - return True - except (OSError, RuntimeError, ValueError): - if value == database: - return True - return False - - -def _row_matches_target(item: dict[str, Any], target: str) -> bool: - target_text = str(target).strip() - if not target_text: - return True - values = { - str(item.get("target_id") or ""), - str(item.get("selector") or ""), - str(item.get("local_selector") or ""), - str(item.get("local_target_id") or ""), - str(item.get("instance_selector") or ""), - str(item.get("module") or ""), - str(item.get("filename") or ""), - } - return target_text in values - - -def _list_backend(args: argparse.Namespace, backend: str) -> tuple[list[dict[str, Any]], list[str]]: - timeout = getattr(args, "timeout", None) - if backend == "gui" and args.backend is None and timeout is None: - timeout = AGGREGATE_GUI_DISCOVERY_TIMEOUT - response = send_request( - RequestEnvelope( - op="list_targets", - backend=backend, - target=args.target, - database=args.database, - timeout=timeout, - ) - ) - if not response.get("ok"): - raise CliUserError(str(response.get("error") or f"{backend} target listing failed")) - rows = [dict(item) for item in (response.get("result") or []) if isinstance(item, dict)] - for row in rows: - row.setdefault("backend", backend) - if backend == "gui" and args.target: - rows = [row for row in rows if _row_matches_target(row, str(args.target))] - if backend == "idalib" and args.database: - rows = [row for row in rows if _row_matches_database(row, str(args.database))] - warnings = [str(item) for item in (response.get("warnings") or []) if str(item)] - return rows, warnings - - -def _list(args: argparse.Namespace) -> CommandResult: - backends = [args.backend] if args.backend else ["gui", "idalib"] - rows: list[dict[str, Any]] = [] - warnings: list[str] = [] - errors: list[str] = [] - for backend in backends: - try: - backend_rows, backend_warnings = _list_backend(args, str(backend)) - except BackendError as exc: - errors.append(f"{backend}: {exc}") - continue - rows.extend(backend_rows) - warnings.extend(backend_warnings) - - if errors and not rows: - raise CliUserError("; ".join(errors)) - warnings.extend(f"failed to list {error}" for error in errors) - rows.sort(key=_target_sort_key) - return command_result("list_targets", rows, warnings=warnings) - - -def _cleanup(_args: argparse.Namespace) -> CommandResult: - return command_result("targets_cleanup", run_doctor_cleanup()) - - -def register( - root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] -) -> None: - parser = add_command(root_parser, subparsers, "targets", help_text="List and clean up IDA targets") - targets_subparsers = parser.add_subparsers(dest="targets_command") - - child = add_command(parser, targets_subparsers, "list", help_text="List open targets") - add_context_options(child) - add_output_options(child, default_format="text") - child.set_defaults( - run=_list, context_policy="targets_list", allow_batch=True, allow_preview=False, _mutating_command=False - ) - - child = add_command( - parser, targets_subparsers, "cleanup", help_text="Remove stale GUI bridge and idalib runtime files" - ) - add_output_options(child, default_format="text") - child.set_defaults( - run=_cleanup, context_policy="none", allow_batch=True, allow_preview=False, _mutating_command=False - ) diff --git a/src/idac/cli2/context.py b/src/idac/cli2/context.py deleted file mode 100644 index 3270702..0000000 --- a/src/idac/cli2/context.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -import argparse -from dataclasses import dataclass - -from ..transport.gui import list_discovered_instances as list_gui_discovered_instances -from ..transport.gui import list_instances as list_gui_instances -from ..transport.idalib import list_instances as list_idalib_instances -from .errors import CliUserError - -DATABASE_CONTEXT_PREFIX = "db:" - - -@dataclass(frozen=True) -class ResolvedContext: - backend: str | None - target: str | None - database: str | None - timeout: float | None - - -def database_path_from_context(locator: str) -> str | None: - text = str(locator).strip() - if not text: - return None - lowered = text.lower() - if not lowered.startswith(DATABASE_CONTEXT_PREFIX): - return None - database = text[len(DATABASE_CONTEXT_PREFIX) :].strip() - return database or None - - -def merge_parent_context(inner_args: argparse.Namespace, outer_args: argparse.Namespace) -> None: - if hasattr(outer_args, "context") and not hasattr(inner_args, "context"): - inner_args.context = outer_args.context - if hasattr(outer_args, "timeout") and not hasattr(inner_args, "timeout"): - inner_args.timeout = outer_args.timeout - - -def require_timeout_if_needed(args: argparse.Namespace) -> None: - if not getattr(args, "_require_timeout", False): - return - if getattr(args, "timeout", None) is not None: - return - label = str(getattr(args, "_timeout_requirement_label", None) or "this command") - raise CliUserError(f"{label} requires --timeout") - - -def resolve_context(parser: argparse.ArgumentParser, args: argparse.Namespace) -> ResolvedContext: - if not args._uses_context: - return ResolvedContext(None, None, None, None) - - policy = args.context_policy - timeout = getattr(args, "timeout", None) - context = getattr(args, "context", None) - - if policy in {"none", "wrapper"}: - return ResolvedContext(None, None, None, timeout) - - if policy == "database_open": - return ResolvedContext("idalib", None, None, timeout) - - if policy == "targets_list": - if context: - locator = str(context).strip() - database = database_path_from_context(locator) - if database is not None: - return ResolvedContext("idalib", None, database, timeout) - return ResolvedContext("gui", locator, None, timeout) - return ResolvedContext(None, None, None, timeout) - - if context: - locator = str(context).strip() - database = database_path_from_context(locator) - if database is not None: - return ResolvedContext("idalib", None, database, timeout) - return ResolvedContext("gui", locator, None, timeout) - - discovered_instances = list_gui_discovered_instances(warnings=[]) - warnings: list[str] = [] - gui_instances = list_gui_instances(timeout=timeout, warnings=warnings) - idalib_instances = list_idalib_instances() - timeout_warnings = [warning for warning in warnings if "timed out" in warning.lower()] - candidate_count = len(gui_instances) + len(idalib_instances) - if candidate_count == 1 and timeout_warnings and len(discovered_instances) > len(gui_instances): - raise CliUserError( - "IDA target autodiscovery is ambiguous because at least one GUI session timed out during " - "discovery; increase --timeout or pass an explicit context with " - "`-c pid:`, `-c `, or `-c db:`. " - f"Details: {timeout_warnings[0]}" - ) - if candidate_count == 1 and gui_instances: - return ResolvedContext("gui", None, None, timeout) - if candidate_count == 1: - return ResolvedContext("idalib", None, idalib_instances[0].database_path, timeout) - if candidate_count == 0: - if timeout_warnings: - raise CliUserError( - "IDA GUI autodiscovery timed out; increase --timeout or pass an explicit context with " - "`-c pid:`, `-c `, or `-c db:`. " - f"Details: {timeout_warnings[0]}" - ) - parser.error("no live IDA context found; start IDA, open an idalib database, or pass -c db:") - parser.error("multiple live IDA contexts found; pass -c pid:, -c , or -c db:") - - -def apply_context(args: argparse.Namespace, resolved: ResolvedContext) -> None: - args.backend = resolved.backend - args.target = resolved.target - args.database = resolved.database - if resolved.timeout is not None: - args.timeout = resolved.timeout - - -def validate_context(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: - backend = args.backend - if backend is None: - return - - policy = args.context_policy - database = args.database - - if policy == "database_open": - return - if policy == "database_close": - if backend != "idalib": - parser.error("`idac database close` is only supported for idalib contexts") - if not database: - parser.error("`idac database close` requires -c db:") - return - if backend == "idalib" and not database: - parser.error("-c db: is required for idalib commands") diff --git a/src/idac/cli2/execute.py b/src/idac/cli2/execute.py deleted file mode 100644 index f924d6e..0000000 --- a/src/idac/cli2/execute.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import argparse - -from .context import apply_context, require_timeout_if_needed, resolve_context, validate_context -from .errors import CliUserError - - -def reject_unsupported_forwarded_context(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: - if args._uses_context: - return - command = f"`{parser.prog}`" - if hasattr(args, "context"): - raise CliUserError(f"{command} does not accept -c/--context") - if hasattr(args, "timeout") and not getattr(args, "_accepts_timeout", False): - raise CliUserError(f"{command} does not accept --timeout") - - -def prepare_args(args: argparse.Namespace) -> argparse.Namespace: - parser = args._selected_parser - reject_unsupported_forwarded_context(parser, args) - if args._uses_context: - require_timeout_if_needed(args) - resolved = resolve_context(parser, args) - apply_context(args, resolved) - validate_context(parser, args) - return args - - -def execute_parsed(args: argparse.Namespace, *, root_parser: argparse.ArgumentParser): - handler = args.run - if handler is None: - selected_parser = args._selected_parser - selected_parser.print_help() - raise SystemExit(2) - prepare_args(args) - return handler(args) diff --git a/src/idac/cli2/path_resolution.py b/src/idac/cli2/path_resolution.py deleted file mode 100644 index 37d4ff5..0000000 --- a/src/idac/cli2/path_resolution.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -import argparse -from pathlib import Path - - -def resolve_relative_paths(args: argparse.Namespace, *, base_dir: Path) -> None: - for key, value in vars(args).items(): - if isinstance(value, Path) and not value.is_absolute(): - setattr(args, key, base_dir / value) diff --git a/src/idac/cli2/renderers/_registry.py b/src/idac/cli2/renderers/_registry.py deleted file mode 100644 index 64458e0..0000000 --- a/src/idac/cli2/renderers/_registry.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping -from typing import Any, cast - -from ...ops.manifest import SUPPORTED_OPERATIONS - -Renderer = Callable[[Any], str] - -_RENDERER_NAMES = { - "doctor": "render_doctor", - "docs": "render_lines", - "targets_cleanup": "render_targets_cleanup", - "list_targets": "render_target_list", - "database_info": "render_database_info", - "db_save": "_fallback", - "segment_list": "render_segment_list", - "function_list": "render_function_list", - "function_show": "render_function_show", - "function_frame": "render_function_frame", - "function_stackvars": "render_function_stackvars", - "function_callers": "render_function_relations", - "function_callees": "render_function_relations", - "disasm": "render_lines", - "disasm_range": "render_lines", - "decompile": "render_lines", - "ctree": "render_lines", - "search_bytes": "render_search_results", - "xrefs": "render_xrefs", - "strings": "render_strings", - "imports": "render_imports", - "bookmark_get": "render_bookmarks", - "bookmark_add": "_fallback", - "bookmark_set": "_fallback", - "bookmark_delete": "_fallback", - "comment_get": "render_comment", - "comment_set": "_fallback", - "comment_delete": "_fallback", - "name_set": "_fallback", - "local_list": "render_locals", - "local_rename": "render_locals", - "local_retype": "render_locals", - "local_update": "render_locals", - "local_apply_plan": "render_locals", - "proto_get": "render_type_show", - "proto_check": "render_proto_check", - "proto_set": "_fallback", - "type_list": "render_types", - "type_show": "render_type_show", - "type_deps": "render_type_show", - "type_declare": "render_type_declare", - "type_declare_check": "render_type_declare", - "class_list": "render_class_list", - "class_candidates": "render_class_candidates", - "class_show": "render_type_show", - "class_hierarchy": "render_class_hierarchy", - "class_fields": "render_class_fields", - "class_vtable": "render_class_vtable", - "vtable_dump": "render_vtable_dump", - "struct_list": "render_types", - "struct_show": "render_type_show", - "struct_field_set": "render_type_show", - "struct_field_rename": "render_type_show", - "struct_field_delete": "render_type_show", - "enum_list": "render_types", - "enum_show": "render_type_show", - "enum_member_set": "render_type_show", - "enum_member_rename": "render_type_show", - "enum_member_delete": "render_type_show", - "reanalyze": "_fallback", - "python_exec": "render_python_exec", - "decompile_bulk": "render_decompile_bulk", - "workspace_init": "render_workspace_init", -} - -_CLI_ONLY_RENDERERS = {"doctor", "docs", "targets_cleanup", "decompile_bulk", "workspace_init"} - - -def build_text_renderers(namespace: Mapping[str, object]) -> dict[str, Renderer]: - renderers: dict[str, Renderer] = {} - for operation, function_name in _RENDERER_NAMES.items(): - renderer = namespace[function_name] - if not callable(renderer): - raise TypeError(f"renderer is not callable: {function_name}") - renderers[operation] = cast("Renderer", renderer) - return renderers - - -def renderer_registry_drift(text_renderers: Mapping[str, Renderer]) -> tuple[list[str], list[str]]: - missing = sorted(str(name) for name in set(SUPPORTED_OPERATIONS) - set(text_renderers)) - extra = sorted(set(text_renderers) - set(SUPPORTED_OPERATIONS) - _CLI_ONLY_RENDERERS) - return missing, extra - - -__all__ = ["build_text_renderers", "renderer_registry_drift"] diff --git a/src/idac/compatibility.py b/src/idac/compatibility.py new file mode 100644 index 0000000..096e698 --- /dev/null +++ b/src/idac/compatibility.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import re +from importlib import metadata +from typing import Any + +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import InvalidVersion + +MINIMUM_PYTHON_VERSION = (3, 11) +MINIMUM_IDA_VERSION = (9, 4) + +REMOTE_ENVIRONMENT_CODE = """ +import importlib.metadata +import platform +import idaapi + +result = { + "ida_nexus": importlib.metadata.version("ida-nexus"), + "ida_domain": importlib.metadata.version("ida-domain"), + "ida": idaapi.get_kernel_version(), + "python": platform.python_version(), +} +""".strip() + + +def runtime_requirements() -> dict[str, SpecifierSet]: + """Read supported runtime versions from idac's installed package metadata.""" + return { + requirement.name: requirement.specifier + for dependency in metadata.requires("idac") or () + if (requirement := Requirement(dependency)).name in {"ida-nexus", "ida-domain"} + } + + +def compatibility_mismatches(environment: dict[str, Any]) -> list[str]: + """Describe every way a remote IDA runtime violates idac's supported stack.""" + + mismatches: list[str] = [] + requirements = runtime_requirements() + for distribution in ("ida-nexus", "ida-domain"): + expected = requirements[distribution] + try: + supported = str(environment.get(distribution.replace("-", "_")) or "") in expected + except InvalidVersion: + supported = False + if not supported: + mismatches.append(f"{distribution} must satisfy {expected}") + + for label, value, minimum in ( + ("IDA", environment.get("ida"), MINIMUM_IDA_VERSION), + ("IDA Python", environment.get("python"), MINIMUM_PYTHON_VERSION), + ): + match = re.fullmatch(r"\s*(\d+)(?:\.(\d+))?(?:\.\d+)*\s*", str(value or "")) + parsed = tuple(int(part) for part in match.groups(default="0")) if match is not None else None + if parsed is None or parsed < minimum: + required = ".".join(str(part) for part in minimum) + mismatches.append(f"{label} must be {required} or newer") + return mismatches + + +__all__ = [ + "MINIMUM_IDA_VERSION", + "MINIMUM_PYTHON_VERSION", + "REMOTE_ENVIRONMENT_CODE", + "compatibility_mismatches", + "runtime_requirements", +] diff --git a/src/idac/docs.py b/src/idac/docs.py deleted file mode 100644 index 33d58ea..0000000 --- a/src/idac/docs.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from .paths import skill_reference_source_dir, skill_source_dir, workspace_template_source_dir - -_DOCS_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ( - "Start here", - ("guide",), - ), - ( - "CLI and operation help", - ( - "cli", - "troubleshooting", - "targets", - ), - ), - ( - "IDA reference", - ( - "ida-cpp-type-details", - "ida-set-types", - "ida-advanced-type-annotations", - ), - ), - ( - "Workflows", - ( - "workflows", - "class-recovery", - ), - ), - ( - "Workspace resources", - ( - "workspace", - "templates", - ), - ), -) - - -@dataclass(frozen=True) -class DocsTopic: - name: str - title: str - path: Path | None - description: str - aliases: tuple[str, ...] = () - extra_paths: tuple[Path, ...] = () - - -def _topic_map() -> dict[str, DocsTopic]: - references = skill_reference_source_dir() - skill = skill_source_dir() - workspace = workspace_template_source_dir() - topics = [ - DocsTopic( - "guide", - "Agent Guide", - skill / "SKILL.md", - "Start here: critical defaults, task routing, and reference index.", - aliases=("start", "agents-guide", "skill"), - ), - DocsTopic( - "cli", - "CLI Quick Reference", - references / "cli.md", - "Public command grammar, common reads, preview, batch, and output notes.", - aliases=("commands", "quick-reference"), - ), - DocsTopic( - "workflows", - "Workflows", - references / "workflows.md", - "Safe mutation loop, batch usage, selector calibration, and readback.", - aliases=("workflow", "mutation"), - ), - DocsTopic( - "targets", - "Targets And Backends", - references / "targets-and-backends.md", - "Choosing GUI vs idalib targets and resolving backend state.", - aliases=("backends", "targets-and-backends"), - ), - DocsTopic( - "troubleshooting", - "Troubleshooting", - references / "troubleshooting.md", - "Bridge, backend, mutation, and stale-result troubleshooting.", - aliases=("debug", "problems"), - ), - DocsTopic( - "class-recovery", - "Class Recovery", - references / "class-recovery.md", - "C++ class recovery workflow, naming rules, vtables, and verification.", - aliases=("classes", "vtables"), - ), - DocsTopic( - "ida-cpp-type-details", - "IDA C++ Type Details", - references / "ida-cpp-type-details.md", - "IDA parser and decompiler expectations for C++ classes and vtables.", - aliases=("cpp-types", "c++", "ida-cpp"), - ), - DocsTopic( - "ida-set-types", - "IDA Type Declaration Syntax", - references / "ida-set-types.md", - "IDA C declaration syntax: calling conventions, usercall locations, and attribute/type keywords.", - aliases=("set-types",), - ), - DocsTopic( - "ida-advanced-type-annotations", - "IDA Advanced Type Annotations", - references / "ida-advanced-type-annotations.md", - "IDA-specific type annotation syntax for recovered declarations.", - aliases=("advanced-types", "annotations"), - ), - DocsTopic( - "templates", - "Reusable Templates", - references / "templates" / "README.md", - "Reusable batch, audit, and jq template files, printed in full.", - aliases=("template",), - extra_paths=( - references / "templates" / "checkpoint-note.md", - references / "templates" / "prototype-pass.idac", - references / "templates" / "rename-pass.idac", - references / "templates" / "locals-jq-snippets.sh", - ), - ), - DocsTopic( - "workspace", - "Workspace Instructions", - workspace / "AGENTS.md", - "Default workspace structure and agent conventions.", - aliases=("agents", "agents-md"), - ), - ] - by_name: dict[str, DocsTopic] = {} - for topic in topics: - by_name[topic.name] = topic - for alias in topic.aliases: - by_name[alias] = topic - return by_name - - -def docs_topics() -> list[DocsTopic]: - seen: set[str] = set() - unique: list[DocsTopic] = [] - topics = _topic_map() - for _, names in _DOCS_GROUPS: - for name in names: - topic = topics[name] - if topic.name in seen: - continue - seen.add(topic.name) - unique.append(topic) - for topic in topics.values(): - if topic.name in seen: - continue - seen.add(topic.name) - unique.append(topic) - return unique - - -def _grouped_topic_rows() -> list[str]: - topics = _topic_map() - lines: list[str] = [] - for group_name, names in _DOCS_GROUPS: - lines.append(f"{group_name}:") - for name in names: - topic = topics[name] - lines.append(f" {topic.name:<30} {topic.description}") - lines.append("") - if lines and lines[-1] == "": - lines.pop() - return lines - - -def _index_text() -> str: - lines = [ - "# idac docs", - "", - "Use `idac docs TOPIC` to print bundled idac and IDA guidance without needing a live IDA target.", - "", - "Start here:", - " idac docs guide", - " idac docs cli", - " idac docs troubleshooting", - " idac docs ida-cpp-type-details", - " idac docs ida-set-types", - " idac docs ida-advanced-type-annotations", - " idac docs workflows", - " idac docs targets", - "", - "Common recovery topics:", - " idac docs class-recovery", - "", - "Reusable workspace material:", - " idac docs templates", - " idac docs workspace", - "", - "Available topics:", - *_grouped_topic_rows(), - ] - return "\n".join(lines) - - -def _strip_frontmatter(text: str) -> str: - if not text.startswith("---\n"): - return text - _, sep, rest = text.partition("\n---\n") - if not sep: - return text - return rest.lstrip() - - -def _topic_payload(topic: DocsTopic) -> dict[str, Any]: - if topic.path is None: - text = _index_text() - path = None - else: - text = topic.path.read_text(encoding="utf-8") - text = _strip_frontmatter(text) - path = str(topic.path) - for extra in topic.extra_paths: - body = extra.read_text(encoding="utf-8").rstrip() - if extra.suffix != ".md": - body = f"```\n{body}\n```" - text = f"{text.rstrip()}\n\n---\n\n`{extra.name}`:\n\n{body}\n" - return { - "topic": topic.name, - "title": topic.title, - "description": topic.description, - "path": path, - "text": text, - } - - -def docs_payload(topic_name: str | None = None, *, list_only: bool = False, all_topics: bool = False) -> dict[str, Any]: - if list_only: - rows = [ - { - "name": topic.name, - "title": topic.title, - "description": topic.description, - "aliases": list(topic.aliases), - "path": None if topic.path is None else str(topic.path), - } - for topic in docs_topics() - ] - return { - "topic": "list", - "topics": rows, - "text": "\n".join(_grouped_topic_rows()), - } - - if all_topics: - parts = [_index_text()] - for topic in docs_topics(): - if topic.path is None: - continue - parts.extend(["", "", f"# {topic.title}", "", _topic_payload(topic)["text"]]) - return { - "topic": "all", - "topics": [topic.name for topic in docs_topics()], - "text": "\n".join(parts), - } - - if topic_name in (None, ""): - return { - "topic": "index", - "text": _index_text(), - } - - topic = _topic_map().get(str(topic_name).strip()) - if topic is None: - available = ", ".join(topic.name for topic in docs_topics()) - raise ValueError(f"unknown docs topic: {topic_name}. Available topics: {available}") - return _topic_payload(topic) diff --git a/src/idac/doctor.py b/src/idac/doctor.py index 0528c69..b810144 100644 --- a/src/idac/doctor.py +++ b/src/idac/doctor.py @@ -1,34 +1,38 @@ from __future__ import annotations -import filecmp +import importlib.metadata import json +import shutil import subprocess import sys -import tempfile -from pathlib import Path +from collections.abc import Callable, Sequence from typing import Any -from .metadata import BRIDGE_SOCKET_PREFIX, IDALIB_SOCKET_PREFIX -from .paths import ( - bridge_registry_paths, - idalib_registry_paths, - plugin_bootstrap_install_path, - plugin_bootstrap_source_path, - plugin_install_dir, - plugin_runtime_package_install_dir, - plugin_runtime_package_source_dir, - plugin_source_dir, - user_runtime_dir, +from packaging.version import InvalidVersion, Version + +from .compatibility import ( + MINIMUM_IDA_VERSION, + REMOTE_ENVIRONMENT_CODE, + compatibility_mismatches, + runtime_requirements, +) +from .nexus import KEEPALIVE_SECONDS, _target_row + +HCLI_STATUS_COMMAND = ( + sys.executable, + "-m", + "hcli", + "plugin", + "status", + "ida-nexus", + "--skip-upgrade-check", + "--json", ) -from .transport import gui, send_request -from .transport.common import pid_is_live -from .transport.idalib_common import bootstrap_idapro as _bootstrap_idapro -from .transport.idalib_common import candidate_ida_dirs as _candidate_ida_dirs -from .transport.schema import RequestEnvelope -from .version import VERSION -IDA_LICENSE_PROBE_TIMEOUT_SECONDS = 20.0 -IDA_EULA_REGISTRY_KEYS = ("EULA 90", "EULA 91", "EULA 92", "EULA 93") +VersionGetter = Callable[[str], str] +CommandRunner = Callable[..., subprocess.CompletedProcess[str]] +DiscoverDatabases = Callable[[float], Sequence[Any]] +RemoteProbe = Callable[[Any, float | None], dict[str, Any]] def _check(status: str, component: str, name: str, summary: str, **details: Any) -> dict[str, Any]: @@ -41,763 +45,390 @@ def _check(status: str, component: str, name: str, summary: str, **details: Any) } -def _symlink_target(path: Path) -> Path | None: - try: - return path.resolve(strict=True) - except OSError: - return None - - -def _relative_file_set(root: Path) -> set[Path]: - return {path.relative_to(root) for path in root.rglob("*") if path.is_file()} +def _first_line(text: str | None) -> str: + for line in (text or "").splitlines(): + stripped = line.strip() + if stripped: + return stripped[:200] + return "" -def _install_matches_source(install_path: Path, source_path: Path) -> tuple[bool, str, dict[str, Any]]: - source_resolved = source_path.resolve() - install_resolved = _symlink_target(install_path) - details: dict[str, Any] = { - "install_path": str(install_path), - "source_path": str(source_path), - "resolved_path": None if install_resolved is None else str(install_resolved), - } - if install_resolved == source_resolved: - return True, "installed path points at the repo source", details - if source_path.is_dir() != install_path.is_dir(): - return False, "installed path kind does not match the repo source", details - if source_path.is_file(): - matches = filecmp.cmp(source_path, install_path, shallow=False) - return ( - matches, - "installed file matches the repo source" if matches else "installed file differs from the repo source", - details, - ) +def _local_ida_check() -> dict[str, Any]: + """Check HCLI's configured installation without loading IDA or opening a database.""" + from hcli.lib.ida import find_current_ida_version - source_files = _relative_file_set(source_path) - install_files = _relative_file_set(install_path) - if source_files != install_files: - details["missing_files"] = sorted(str(path) for path in source_files - install_files)[:10] - details["extra_files"] = sorted(str(path) for path in install_files - source_files)[:10] - return False, "installed directory contents differ from the repo source", details - for relative_path in sorted(source_files): - if not filecmp.cmp(source_path / relative_path, install_path / relative_path, shallow=False): - details["mismatched_file"] = str(relative_path) - return False, "installed directory contents differ from the repo source", details - return True, "installed directory contents match the repo source", details - - -def _plugin_check(component: str, name: str, install_path: Path, source_path: Path) -> dict[str, Any]: - if not source_path.exists(): - return _check( - "error", - component, - name, - f"missing source path: {source_path}", - install_path=str(install_path), - source_path=str(source_path), - ) - if not install_path.exists(): + required = ".".join(str(part) for part in MINIMUM_IDA_VERSION) + try: + installed = find_current_ida_version() + supported = Version(installed) >= Version(required) + except Exception as exc: return _check( "error", - component, - name, - f"missing install path: {install_path}", - install_path=str(install_path), - source_path=str(source_path), + "runtime", + "ida", + f"could not determine the configured IDA version: {exc}", + expected=f">={required}", ) - matches, summary, details = _install_matches_source(install_path, source_path) - if not matches: - return _check("error", component, name, summary, **details) return _check( - "ok", - component, - name, - summary, - **details, + "ok" if supported else "error", + "runtime", + "ida", + f"Configured IDA {installed}" if supported else f"Configured IDA {installed}; requires IDA {required} or newer", + installed=installed, + expected=f">={required}", ) -def _downgrade_gui_optional_errors(checks: list[dict[str, Any]]) -> None: - for item in checks: - if item.get("component") != "gui" or item.get("status") != "error": - continue - item["status"] = "warn" - item["summary"] = f"{item.get('summary', '')}; GUI bridge support is optional for headless idalib use".strip() - - -def _doctor_gui(*, timeout: float | None, require_gui: bool = True) -> list[dict[str, Any]]: - checks: list[dict[str, Any]] = [] - checks.append( - _plugin_check( - "gui", - "plugin_package", - plugin_install_dir(), - plugin_source_dir(), - ) - ) - checks.append( - _plugin_check( - "gui", - "plugin_bootstrap", - plugin_bootstrap_install_path(), - plugin_bootstrap_source_path(), - ) - ) - checks.append( - _plugin_check( - "gui", - "plugin_runtime_package", - plugin_runtime_package_install_dir(), - plugin_runtime_package_source_dir(), - ) - ) - - runtime_dir = user_runtime_dir() - registry_paths = bridge_registry_paths() - checks.append( - _check( - "ok", - "gui", - "runtime_dir", - "runtime directory is available", - runtime_dir=str(runtime_dir), - registry_count=len(registry_paths), - ) - ) - - discovery_warnings: list[str] = [] +def _run_hcli_status( + *, + timeout: float | None, + runner: CommandRunner, +) -> dict[str, Any]: + expected = runtime_requirements()["ida-nexus"] try: - targets = gui.list_targets(timeout=timeout, warnings=discovery_warnings) - instances = gui.list_instances() - except (OSError, RuntimeError, ValueError) as exc: - status = "error" if require_gui else "warn" - summary = f"failed to enumerate running GUI bridge targets: {exc}" - if not require_gui: - summary = f"{summary}; GUI bridge support is optional for headless idalib use" - checks.append( - _check( - status, - "gui", - "bridge_targets", - summary, - timeout=timeout, - ) - ) - if not require_gui: - _downgrade_gui_optional_errors(checks) - return checks - - if discovery_warnings: - checks.append( - _check( - "warn", - "gui", - "bridge_discovery", - f"GUI bridge discovery reported {len(discovery_warnings)} issue(s)", - warnings=discovery_warnings, - ) - ) - - if not instances: - if not require_gui: - _downgrade_gui_optional_errors(checks) - checks.append( - _check( - "warn", - "gui", - "bridge_targets", - "no running GUI bridge instances found", - timeout=timeout, - ) - ) - return checks - - for item in checks: - if ( - item.get("component") == "gui" - and item.get("name") in {"plugin_package", "plugin_bootstrap", "plugin_runtime_package"} - and item.get("status") == "error" - ): - item["status"] = "warn" - item["summary"] = ( - f"{item.get('summary', '')}; this install mismatch does not block the current running GUI bridge" - ).strip() - - mismatched = [ - { - "pid": instance.pid, - "plugin_version": instance.plugin_version, - } - for instance in instances - if instance.plugin_version != VERSION - ] - if mismatched: - checks.append( - _check( - "error", - "gui", - "bridge_version", - "running GUI bridge version does not match the CLI", - cli_version=VERSION, - mismatched=mismatched, - ) - ) - else: - checks.append( - _check( - "ok", - "gui", - "bridge_version", - "running GUI bridge versions match the CLI", - cli_version=VERSION, - instance_count=len(instances), - ) - ) - - checks.append( - _check( - "ok", - "gui", - "bridge_targets", - f"found {len(targets)} GUI target(s)", - targets=targets, - ) - ) - return checks - - -def _idalib_candidate_rows() -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for candidate in _candidate_ida_dirs(): - python_dir = candidate / "idalib" / "python" - rows.append( - { - "path": str(candidate), - "python_dir": str(python_dir), - "exists": python_dir.exists(), - } - ) - return rows - - -def _idalib_install_dirs_check() -> dict[str, Any]: - candidate_rows = _idalib_candidate_rows() - status = "ok" if any(item["exists"] for item in candidate_rows) else "error" - summary = ( - "found at least one usable IDA install directory" - if status == "ok" - else "no usable IDA install directories were found" - ) - return _check( - status, - "idalib", - "install_dirs", - summary, - candidates=candidate_rows, - ) - - -def _ida_text_executable_candidates() -> list[Path]: - names = ["idat.exe"] if sys.platform == "win32" else ["idat"] - return [candidate / name for candidate in _candidate_ida_dirs() for name in names] - - -def _ida_license_probe(executable: Path) -> subprocess.CompletedProcess[str]: - with tempfile.TemporaryDirectory(prefix="idac-ida-license-") as tmp: - tmpdir = Path(tmp) - script = tmpdir / "exit.idc" - log = tmpdir / "ida.log" - script.write_text("static main() { qexit(0); }\n", encoding="utf-8") - probe = subprocess.run( - [str(executable), "-A", f"-L{log}", f"-S{script}", "-t"], - cwd=tmpdir, + process = runner( + list(HCLI_STATUS_COMMAND), check=False, capture_output=True, text=True, - timeout=IDA_LICENSE_PROBE_TIMEOUT_SECONDS, + timeout=timeout, ) - log_text = log.read_text(encoding="utf-8", errors="replace") if log.exists() else "" - stderr = "\n".join(part for part in (probe.stderr, log_text) if part) - return subprocess.CompletedProcess( - args=probe.args, - returncode=probe.returncode, - stdout=probe.stdout, - stderr=stderr, - ) - - -def _ida_license_check() -> dict[str, Any]: - candidates = _ida_text_executable_candidates() - executable = next((path for path in candidates if path.exists()), None) - if executable is None: + except subprocess.TimeoutExpired: return _check( "error", - "idalib", - "license", - "unable to check IDA license because the headless IDA executable was not found", - candidates=[str(path) for path in candidates], + "gui", + "plugin", + "timed out while checking the ida-nexus plugin with ida-hcli", + expected=str(expected), + command=list(HCLI_STATUS_COMMAND), + timeout=timeout, ) - - try: - probe = _ida_license_probe(executable) - except subprocess.TimeoutExpired as exc: + except OSError as exc: return _check( "error", - "idalib", - "license", - "timed out while checking IDA license", - executable=str(executable), - timeout=exc.timeout, - ) - - output = "\n".join(part.strip() for part in (probe.stdout, probe.stderr) if part and part.strip()) - if probe.returncode == 0: - return _check( - "ok", - "idalib", - "license", - "IDA license check passed", - executable=str(executable), - ) - - if "Cannot continue without a valid license" in output: - summary = "IDA could not find a valid license" - else: - summary = "IDA license check failed" - return _check( - "error", - "idalib", - "license", - summary, - executable=str(executable), - returncode=probe.returncode, - output=output[:4000], - ) - - -def _idalib_import_checks(database: str | None) -> list[dict[str, Any]]: - checks: list[dict[str, Any]] = [] - candidates = _candidate_ida_dirs() - try: - idapro = _bootstrap_idapro() - except RuntimeError as exc: - checks.append( - _check( - "error", - "idalib", - "idapro_import", - f"failed to import idapro: {exc}", - ) - ) - return checks - - checks.append( - _check( - "ok", - "idalib", - "idapro_import", - "idapro imported successfully", - module_path=str(getattr(idapro, "__file__", "")), - candidates=[str(path) for path in candidates], + "gui", + "plugin", + f"failed to run the ida-hcli status command: {exc}", + expected=str(expected), + command=list(HCLI_STATUS_COMMAND), ) - ) - checks.append(_ida_eula_check()) - checks.append(_idalib_hexrays_check(database)) - return checks - -def _ida_eula_check() -> dict[str, Any]: try: - import ida_registry # type: ignore - except Exception as exc: + payload = json.loads(process.stdout) + except (TypeError, json.JSONDecodeError): + reason = _first_line(process.stderr) or _first_line(process.stdout) + summary = "ida-hcli could not report plugin status" + if reason: + summary = f"{summary}: {reason}" return _check( "error", - "idalib", - "eula", - f"failed to import ida_registry for EULA check: {exc}", + "gui", + "plugin", + summary, + expected=str(expected), + returncode=process.returncode, + stdout=(process.stdout or "")[:4000], + stderr=(process.stderr or "")[:4000], ) - values: dict[str, int | None] = {} - errors: dict[str, str] = {} - for key in IDA_EULA_REGISTRY_KEYS: - try: - values[key] = int(ida_registry.reg_read_int(key, 0)) - except Exception as exc: - values[key] = None - errors[key] = str(exc) - - accepted = [key for key, value in values.items() if value == 1] - status = "ok" if accepted else "error" - remediation = None - if status == "ok": - summary = "IDA EULA acceptance was found in the registry" - else: - remediation = "Run `hcli ida accept-eula`." - summary = f"IDA EULA acceptance was not found in the registry. {remediation}" - return _check( - status, - "idalib", - "eula", - summary, - accepted_keys=accepted, - values=values, - errors=errors, - remediation=remediation, + plugins = payload.get("plugins") if isinstance(payload, dict) else None + entry = next( + (item for item in plugins or () if isinstance(item, dict) and item.get("name") == "ida-nexus"), + None, ) - - -def _idalib_hexrays_probe(database: str) -> bool: - probe = send_request( - RequestEnvelope( - op="python_exec", - backend="idalib", - database=database, - params={"script": ("import ida_hexrays\nresult = {'available': bool(ida_hexrays.init_hexrays_plugin())}")}, - ) - ) - probe_result = probe.get("result") if isinstance(probe, dict) else None - result = probe_result.get("result") if isinstance(probe_result, dict) else None - return bool(isinstance(result, dict) and result.get("available")) - - -def _idalib_hexrays_check(database: str | None) -> dict[str, Any]: - if not database: + installed = entry.get("version") if isinstance(entry, dict) and entry.get("installed") is True else None + if process.returncode != 0 or installed is None: return _check( - "warn", - "idalib", - "hexrays", - "Hex-Rays availability was not checked because no database was provided", + "error", + "gui", + "plugin", + "ida-nexus is not installed through ida-hcli", + expected=str(expected), + installed=installed, + returncode=process.returncode, + stderr=(process.stderr or "")[:4000], ) - try: - available = _idalib_hexrays_probe(database) - except (OSError, RuntimeError, ValueError) as exc: + supported = str(installed) in expected + except InvalidVersion: + supported = False + if not supported: return _check( "error", - "idalib", - "hexrays", - f"failed to probe Hex-Rays availability through idalib: {exc}", - database=database, - python=sys.executable, + "gui", + "plugin", + "installed ida-nexus plugin version does not match the supported stack", + expected=str(expected), + installed=installed, ) - - status = "ok" if available else "error" - summary = ( - "Hex-Rays decompiler is available" if available else "Hex-Rays decompiler could not be initialized in idalib" - ) return _check( - status, - "idalib", - "hexrays", - summary, - database=database, - python=sys.executable, + "ok", + "gui", + "plugin", + "installed ida-nexus plugin version matches the supported stack", + expected=str(expected), + installed=installed, ) -def _idalib_database_path_check(database: str) -> dict[str, Any]: - database_path = Path(database).expanduser() - exists = database_path.exists() - suffix = database_path.suffix.lower() - recognized_suffix = suffix in {".i64", ".idb"} - if not exists: - status = "warn" - summary = "database path is missing" - elif recognized_suffix: - status = "ok" - summary = "database path looks usable" - else: - status = "warn" - summary = "database path exists but does not use a standard IDA DB suffix" - return _check( - status, - "idalib", - "database_path", - summary, - database=str(database_path), - exists=exists, - suffix=suffix, - recognized_suffix=recognized_suffix, - ) +def _agent_skill_checks(*, cli_version: str, timeout: float | None, runner: CommandRunner) -> list[dict[str, Any]]: + """Compare installed agent guidance with the CLI using each client's inventory.""" + checks: list[dict[str, Any]] = [] + for client in ("codex", "claude"): + if shutil.which(client) is None: + continue + command = [client, "plugin", "list", "--json"] + try: + process = runner( + command, check=False, capture_output=True, text=True, timeout=2.0 if timeout is None else timeout + ) + if process.returncode != 0: + raise ValueError(_first_line(process.stderr) or f"plugin inventory exited with {process.returncode}") + payload = json.loads(process.stdout) + entries = payload.get("installed") if client == "codex" and isinstance(payload, dict) else payload + if not isinstance(entries, list) or any(not isinstance(entry, dict) for entry in entries): + raise ValueError("plugin inventory returned an invalid result") + except (OSError, subprocess.TimeoutExpired, ValueError) as exc: + checks.append( + _check( + "warn", + "agent", + client, + f"could not check the installed idac skill in {client}: {exc}", + expected=cli_version, + ) + ) + continue -def _doctor_idalib(*, database: str | None) -> list[dict[str, Any]]: - checks = [_idalib_install_dirs_check()] - checks.append(_ida_license_check()) - checks.extend(_idalib_import_checks(database)) - if database: - checks.append(_idalib_database_path_check(database)) + for entry in entries: + plugin_id = entry.get("pluginId") if client == "codex" else entry.get("id") + if not isinstance(plugin_id, str) or plugin_id.split("@", 1)[0] != "idac": + continue + installed = entry.get("version") + try: + matches = isinstance(installed, str) and Version(installed) == Version(cli_version) + except InvalidVersion: + matches = False + summary = f"{client} idac skill version {installed or 'unknown'} " + if matches: + summary += f"matches CLI version {cli_version}" + else: + summary += f"does not match CLI version {cli_version}; update the CLI or Agent Plugin" + checks.append( + _check( + "ok" if matches else "warn", + "agent", + client, + summary, + plugin_id=plugin_id, + installed=installed, + expected=cli_version, + enabled=entry.get("enabled"), + scope=entry.get("scope"), + ) + ) return checks -def _check_statuses(checks: list[dict[str, Any]], *, component: str) -> dict[str, str]: - return { - str(item.get("name")): str(item.get("status")) - for item in checks - if item.get("component") == component and item.get("name") not in (None, "") - } - - -def _available_backends(checks: list[dict[str, Any]]) -> list[str]: - available: list[str] = [] - - gui_statuses = _check_statuses(checks, component="gui") - if gui_statuses.get("bridge_targets") == "ok" and gui_statuses.get("bridge_version") == "ok": - available.append("gui") - - idalib_statuses = _check_statuses(checks, component="idalib") - if ( - idalib_statuses.get("install_dirs") == "ok" - and idalib_statuses.get("license") == "ok" - and idalib_statuses.get("idapro_import") == "ok" - and idalib_statuses.get("eula") == "ok" - ): - available.append("idalib") - - return available - - -def run_doctor( +def _discover_check( *, - scope: str = "all", - timeout: float | None = None, - database: str | None = None, -) -> dict[str, Any]: - selected = scope.strip().lower() if scope else "all" - if selected not in {"all", "gui", "idalib"}: - raise ValueError(f"unsupported doctor scope: {scope}") - - checks: list[dict[str, Any]] = [] - if selected in {"all", "gui"}: - checks.extend(_doctor_gui(timeout=timeout, require_gui=selected == "gui")) - if selected in {"all", "idalib"}: - checks.extend(_doctor_idalib(database=database)) - - status_order = {"ok": 0, "warn": 1, "error": 2} - overall_status = "ok" - if checks: - overall_status = max(checks, key=lambda item: status_order.get(str(item.get("status")), 99)).get("status", "ok") - return { - "healthy": overall_status != "error", - "backend": _available_backends(checks), - "status": overall_status, - "check_count": len(checks), - "checks": checks, - } - - -def _safe_unlink(path: Path) -> bool: + timeout: float, + discover_databases_fn: DiscoverDatabases, +) -> tuple[dict[str, Any], list[Any]]: try: - path.unlink() - return True - except FileNotFoundError: - return False + discovered = list(discover_databases_fn(timeout)) + except Exception as exc: + return ( + _check( + "error", + "nexus", + "discovery", + f"Nexus discovery failed: {exc}", + timeout=timeout, + ), + [], + ) + rows = [_target_row(item) for item in discovered] + ready = [item for item in discovered if item.state.value == "ready"] + unavailable_count = len(discovered) - len(ready) + if not discovered: + return ( + _check( + "warn", + "nexus", + "discovery", + "no running Nexus database instances found", + timeout=timeout, + ready_count=0, + unavailable_count=0, + instances=[], + ), + [], + ) + if not ready: + return ( + _check( + "error", + "nexus", + "discovery", + "Nexus instances were found, but none are ready", + timeout=timeout, + ready_count=0, + unavailable_count=unavailable_count, + instances=rows, + ), + [], + ) + status = "warn" if unavailable_count else "ok" + summary = f"found {len(ready)} ready Nexus database instance(s)" + if unavailable_count: + summary += f" and {unavailable_count} unavailable instance(s)" + return ( + _check( + status, + "nexus", + "discovery", + summary, + timeout=timeout, + ready_count=len(ready), + unavailable_count=unavailable_count, + instances=rows, + ), + ready, + ) -def _cleanup_entry( - kind: str, - path: Path, - status: str, - reason: str, - *, - backend: str | None = None, -) -> dict[str, Any]: - entry = { - "kind": kind, - "path": str(path), - "status": status, - "reason": reason, - } - if backend not in (None, ""): - entry["backend"] = backend - return entry +def _default_remote_probe(instance: Any, timeout: float | None) -> dict[str, Any]: + from ida_nexus import DatabaseHandle -def _non_gui_pid_reason(*, backend: str, pid: int) -> str | None: - if backend != "gui": - return None - return gui._pid_non_gui_bridge_reason(pid) + with DatabaseHandle.attach(instance, keepalive=KEEPALIVE_SECONDS) as handle: + execution = handle.execute_python( + REMOTE_ENVIRONMENT_CODE, + timeout=timeout, + operation_label="idac: doctor", + persist_globals=False, + filename="", + ) + result = execution.get("result") if isinstance(execution, dict) else None + if not isinstance(result, dict): + raise ValueError("remote environment probe returned an invalid result") + return result -def _registry_cleanup_entries( +def _remote_checks( + ready: Sequence[Any], *, - backend: str, - registry_paths: list[Path], -) -> tuple[list[dict[str, Any]], set[Path]]: - entries: list[dict[str, Any]] = [] - live_socket_paths: set[Path] = set() - for registry in registry_paths: + timeout: float | None, + remote_probe_fn: RemoteProbe, +) -> list[dict[str, Any]]: + checks: list[dict[str, Any]] = [] + for discovered in ready: + instance = discovered.instance + identity = { + "record_id": instance.record_id, + "backend": instance.backend, + "pid": instance.pid, + "idb_path": instance.idb_path, + "exe_path": instance.exe_path, + } try: - payload = json.loads(registry.read_text(encoding="utf-8")) - pid = int(payload["pid"]) - socket_path = Path(payload["socket_path"]) - except (OSError, ValueError, KeyError, json.JSONDecodeError): - removed = _safe_unlink(registry) - entries.append( - _cleanup_entry( - "registry", - registry, - "removed" if removed else "missing", - "malformed registry payload", - backend=backend, - ) - ) - continue - - if not socket_path.exists(): - removed = _safe_unlink(registry) - entries.append( - _cleanup_entry( - "registry", - registry, - "removed" if removed else "missing", - f"missing socket {socket_path}", - backend=backend, + environment = remote_probe_fn(instance, timeout) + except Exception as exc: + checks.append( + _check( + "error", + "nexus", + "remote_environment", + f"failed to probe Nexus instance {instance.record_id}: {exc}", + **identity, ) ) continue - if not pid_is_live(pid): - removed = _safe_unlink(registry) - entries.append( - _cleanup_entry( - "registry", - registry, - "removed" if removed else "missing", - f"dead pid {pid}", - backend=backend, - ) - ) - socket_removed = _safe_unlink(socket_path) - entries.append( - _cleanup_entry( - "socket", - socket_path, - "removed" if socket_removed else "missing", - f"dead pid {pid}", - backend=backend, + mismatches = compatibility_mismatches(environment) + if mismatches: + checks.append( + _check( + "error", + "nexus", + "remote_environment", + f"Nexus instance {instance.record_id} does not match the supported stack", + **identity, + environment=environment, + mismatches=mismatches, ) ) continue - - non_gui_reason = _non_gui_pid_reason(backend=backend, pid=pid) - if non_gui_reason is not None: - removed = _safe_unlink(registry) - entries.append( - _cleanup_entry( - "registry", - registry, - "removed" if removed else "missing", - non_gui_reason, - backend=backend, - ) - ) - socket_removed = _safe_unlink(socket_path) - entries.append( - _cleanup_entry( - "socket", - socket_path, - "removed" if socket_removed else "missing", - non_gui_reason, - backend=backend, - ) + checks.append( + _check( + "ok", + "nexus", + "remote_environment", + f"Nexus instance {instance.record_id} matches the supported stack", + **identity, + environment=environment, ) - continue - - live_socket_paths.add(socket_path) - entries.append(_cleanup_entry("registry", registry, "kept", f"live pid {pid}", backend=backend)) - return entries, live_socket_paths + ) + return checks -def _runtime_socket_cleanup_entries( +def run_doctor( *, - backend: str, - socket_prefix: str, - live_socket_paths: set[Path], -) -> list[dict[str, Any]]: - entries: list[dict[str, Any]] = [] - runtime_dir = user_runtime_dir() - if not runtime_dir.exists(): - return entries - - for socket_path in sorted(runtime_dir.glob(f"{socket_prefix}-*.sock")): - if socket_path in live_socket_paths: - continue - pid_text = socket_path.stem.rsplit("-", 1)[-1] - try: - pid = int(pid_text) - except ValueError: - pid = None - - if pid is not None and pid_is_live(pid): - non_gui_reason = None if pid is None else _non_gui_pid_reason(backend=backend, pid=pid) - if non_gui_reason is not None: - removed = _safe_unlink(socket_path) - entries.append( - _cleanup_entry( - "socket", - socket_path, - "removed" if removed else "missing", - non_gui_reason, - backend=backend, - ) - ) - continue - entries.append( - _cleanup_entry( - "socket", - socket_path, - "kept", - f"live pid {pid} without registry", - backend=backend, - ) - ) - continue + timeout: float | None = None, + version_getter: VersionGetter = importlib.metadata.version, + runner: CommandRunner = subprocess.run, + discover_databases_fn: DiscoverDatabases | None = None, + remote_probe_fn: RemoteProbe | None = None, +) -> dict[str, Any]: + """Inspect the local and remote Nexus stack without changing any installation.""" - removed = _safe_unlink(socket_path) - reason = "orphaned socket" - if pid is not None: - reason = f"dead pid {pid}" - entries.append( - _cleanup_entry( - "socket", - socket_path, - "removed" if removed else "missing", - reason, - backend=backend, + discovery_timeout = 1.0 if timeout is None else timeout + checks = [ + _check( + "ok", "runtime", "python", "Local Python version", version=sys.version.split()[0], executable=sys.executable + ), + *( + _check( + "ok", + "runtime", + distribution.replace("-", "_"), + f"Installed {distribution} version", + installed=version_getter(distribution), ) - ) - return entries - + for distribution in ("idac", "ida-nexus", "ida-domain", "ida-hcli") + ), + _local_ida_check(), + _run_hcli_status(timeout=timeout, runner=runner), + *_agent_skill_checks(cli_version=version_getter("idac"), timeout=timeout, runner=runner), + ] + if discover_databases_fn is None: + from ida_nexus import discover_databases -def run_doctor_cleanup() -> dict[str, Any]: - runtime_dir = user_runtime_dir() - gui_registry_entries, gui_live_socket_paths = _registry_cleanup_entries( - backend="gui", - registry_paths=bridge_registry_paths(), + discover_databases_fn = discover_databases + discovery, ready = _discover_check( + timeout=discovery_timeout, + discover_databases_fn=discover_databases_fn, ) - idalib_registry_entries, idalib_live_socket_paths = _registry_cleanup_entries( - backend="idalib", - registry_paths=idalib_registry_paths(), - ) - gui_socket_entries = _runtime_socket_cleanup_entries( - backend="gui", - socket_prefix=BRIDGE_SOCKET_PREFIX, - live_socket_paths=gui_live_socket_paths, + checks.append(discovery) + checks.extend( + _remote_checks( + ready, + timeout=timeout, + remote_probe_fn=remote_probe_fn if remote_probe_fn is not None else _default_remote_probe, + ) ) - idalib_socket_entries = _runtime_socket_cleanup_entries( - backend="idalib", - socket_prefix=IDALIB_SOCKET_PREFIX, - live_socket_paths=idalib_live_socket_paths, + + status_order = {"ok": 0, "warn": 1, "error": 2} + overall_status = max( + (str(item.get("status", "error")) for item in checks), + key=lambda status: status_order.get(status, 99), + default="ok", ) - entries = gui_registry_entries + idalib_registry_entries + gui_socket_entries + idalib_socket_entries - removed = [entry for entry in entries if entry["status"] == "removed"] - kept = [entry for entry in entries if entry["status"] == "kept"] - missing = [entry for entry in entries if entry["status"] == "missing"] return { - "ok": True, - "runtime_dir": str(runtime_dir), - "removed_count": len(removed), - "kept_count": len(kept), - "missing_count": len(missing), - "entries": entries, + "healthy": overall_status in {"ok", "warn"}, + "status": overall_status, + "check_count": len(checks), + "checks": checks, } diff --git a/src/idac/ida_plugin/__init__.py b/src/idac/ida_plugin/__init__.py deleted file mode 100644 index d6b2abe..0000000 --- a/src/idac/ida_plugin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""IDA GUI plugin assets for idac.""" diff --git a/src/idac/ida_plugin/idac_bridge/__init__.py b/src/idac/ida_plugin/idac_bridge/__init__.py deleted file mode 100644 index a27f56e..0000000 --- a/src/idac/ida_plugin/idac_bridge/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -"""IDA GUI bridge scaffold package for idac.""" - -from .protocol import ( - PLUGIN_NAME, - SUPPORTED_OPERATIONS, - registry_path, - socket_path, -) - -__all__ = [ - "PLUGIN_NAME", - "SUPPORTED_OPERATIONS", - "BridgeService", - "IdacBridge", - "build_default_registry", - "current_target_info", - "registry_path", - "socket_path", -] - - -def __getattr__(name: str): - if name in {"BridgeService", "IdacBridge"}: - from .bridge import BridgeService, IdacBridge - - return { - "BridgeService": BridgeService, - "IdacBridge": IdacBridge, - }[name] - if name in {"build_default_registry", "current_target_info"}: - from .handlers import build_default_registry, current_target_info - - return { - "build_default_registry": build_default_registry, - "current_target_info": current_target_info, - }[name] - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/idac/ida_plugin/idac_bridge/bridge.py b/src/idac/ida_plugin/idac_bridge/bridge.py deleted file mode 100644 index 76ba741..0000000 --- a/src/idac/ida_plugin/idac_bridge/bridge.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Bridge core for parsing envelopes and dispatching operations.""" - -from __future__ import annotations - -import contextlib -import json -import os -import socketserver -import sys -import threading -import time -import traceback -import uuid -from collections.abc import Callable -from pathlib import Path -from typing import Any, cast - -import ida_kernwin # type: ignore - -from idac.metadata import BRIDGE_PLUGIN_NAME, bridge_registry_payload -from idac.ops.runtime import IdaOperationError -from idac.transport.common import atomic_write_json, read_request_bytes -from idac.transport.dispatch import DispatcherBusyError, DispatcherStoppedError, DispatchMetrics, SerializedDispatcher -from idac.transport.schema import response_error, response_ok -from idac.version import VERSION - -from .handlers import HandlerFn, TargetValidator, build_default_registry -from .protocol import ( - BridgeError, - EnvelopeParseError, - UnsupportedOperationError, - parse_request_envelope, - registry_path, - runtime_dir, - socket_path, -) - -BRIDGE_REQUEST_READ_TIMEOUT = 300.0 -BRIDGE_REQUEST_MAX_BYTES = 1_048_576 -BRIDGE_MAX_PENDING_CALLS = 16 -BRIDGE_STOP_DRAIN_TIMEOUT = 5.0 - - -def _bridge_log(message: str, *, exc: BaseException | None = None) -> None: - lines = [f"[idac] {message}\n"] - if exc is not None: - lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) - text = "".join(lines) - writer = getattr(ida_kernwin, "msg", None) - if callable(writer): - writer(text) - return - sys.stderr.write(text) - - -def _format_log_payload(payload: Any) -> str: - try: - if isinstance(payload, bytes): - text = payload.decode("utf-8", errors="replace").strip() - if not text: - return "" - payload = json.loads(text) - if isinstance(payload, (dict, list)): - return json.dumps(payload, ensure_ascii=False, sort_keys=True) - return json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) - except Exception: - return str(payload) - - -class IdacBridge: - """Minimal backend-contract oriented bridge facade.""" - - def __init__( - self, - handlers: dict[str, HandlerFn] | None = None, - *, - validate_target: TargetValidator | None = None, - status_provider: Callable[[], dict[str, Any]] | None = None, - ) -> None: - if handlers is None: - handlers, default_validate = build_default_registry() - if validate_target is None: - validate_target = default_validate - self.handlers = handlers - self.validate_target = validate_target - self.status_provider = status_provider - - def _dispatch(self, op_name: str, params: dict[str, Any], *, target: str | None) -> Any: - if op_name == "bridge_status": - if self.status_provider is None: - raise UnsupportedOperationError("bridge status is unavailable") - return self.status_provider() - if op_name != "list_targets" and self.validate_target is not None: - self.validate_target(target) - try: - handler = self.handlers[op_name] - except KeyError as exc: - raise UnsupportedOperationError(f"unknown operation '{op_name}'") from exc - return handler(params) - - def handle_request(self, payload: Any) -> dict[str, Any]: - request_id: str | None = None - op_name = "" - try: - envelope = parse_request_envelope(payload) - request_id = envelope.request_id - op_name = envelope.operation - result = self._dispatch( - envelope.operation, - envelope.params, - target=envelope.target, - ) - return response_ok(result, backend="gui", request_id=request_id) - except EnvelopeParseError as exc: - return response_error(str(exc), backend="gui", request_id=request_id) - except UnsupportedOperationError as exc: - return response_error(str(exc), backend="gui", request_id=request_id) - except IdaOperationError as exc: - return response_error(str(exc), backend="gui", request_id=request_id) - except BridgeError as exc: - _bridge_log(f"GUI bridge request failed for `{op_name}`", exc=exc) - return response_error(str(exc), backend="gui", request_id=request_id) - except Exception as exc: - _bridge_log(f"GUI bridge request raised an internal error for `{op_name}`", exc=exc) - return response_error(str(exc) or exc.__class__.__name__, backend="gui", request_id=request_id) - - -class _BridgeRequestHandler(socketserver.StreamRequestHandler): - def _write_response(self, response: dict[str, Any]) -> None: - with contextlib.suppress(BrokenPipeError, OSError): - self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) - - def _read_request(self) -> bytes: - return read_request_bytes( - self.connection, - timeout=BRIDGE_REQUEST_READ_TIMEOUT, - max_bytes=BRIDGE_REQUEST_MAX_BYTES, - ) - - def handle(self) -> None: - service = cast("_ThreadedUnixBridgeServer", self.server).service - try: - raw = self._read_request() - except TimeoutError: - self._write_response( - response_error( - f"request body read timed out after {int(BRIDGE_REQUEST_READ_TIMEOUT)} seconds", - backend="gui", - ) - ) - return - except ValueError as exc: - self._write_response(response_error(str(exc), backend="gui")) - return - if not raw.strip(): - return - - self._write_response(service.dispatch_request(raw)) - - -class _ThreadedUnixBridgeServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): - allow_reuse_address = True - daemon_threads = True - request_queue_size = 64 - service: BridgeService - - -def _run_on_main_thread(fn: Any) -> Any: - holder: dict[str, Any] = {} - - def _dispatch() -> int: - holder["result"] = fn() - return 1 - - # Possible future improvement: dispatch obviously non-mutating ops with - # MFF_READ. In practice this is probably not worth doing. Read-only IDA - # work such as decompilation can still trigger database writes, and users - # have reported hangs with MFF_READ in similar bridges. - ida_kernwin.execute_sync(_dispatch, ida_kernwin.MFF_WRITE) - return holder.get("result") - - -class BridgeService: - """Minimal Unix-socket service wrapper for the GUI bridge.""" - - def __init__(self, bridge: IdacBridge | None = None) -> None: - self._instance_id = str(uuid.uuid4()) - self._started_at: str | None = None - self._state = "stopped" - self.bridge = bridge or IdacBridge() - self.bridge.status_provider = self.status_snapshot - self._dispatcher = SerializedDispatcher( - "idac-gui", - runner=_run_on_main_thread, - max_pending=BRIDGE_MAX_PENDING_CALLS, - ) - self._server: _ThreadedUnixBridgeServer | None = None - self._thread: threading.Thread | None = None - self.request_logging_enabled = False - self.response_logging_enabled = False - - def start(self) -> None: - runtime_dir().mkdir(parents=True, exist_ok=True) - pid = os.getpid() - path = socket_path(pid) - with contextlib.suppress(FileNotFoundError): - path.unlink() - self._started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - self._state = "starting" - self._dispatcher.start() - server = _ThreadedUnixBridgeServer(str(path), _BridgeRequestHandler) - server.service = self - self._server = server - self._write_registry(path) - thread = threading.Thread(target=server.serve_forever, name="idac-bridge", daemon=True) - thread.start() - self._thread = thread - self._state = "ready" - self._write_registry(path) - - def stop(self) -> None: - server = self._server - self._state = "draining" - if server is not None: - self._write_registry(socket_path(os.getpid())) - server.shutdown() - server.server_close() - self._server = None - if not self._dispatcher.wait_for_idle(timeout=BRIDGE_STOP_DRAIN_TIMEOUT): - _bridge_log("GUI bridge drain timed out; forcing dispatcher shutdown") - self._dispatcher.stop() - with contextlib.suppress(FileNotFoundError): - socket_path(os.getpid()).unlink() - with contextlib.suppress(FileNotFoundError): - registry_path(os.getpid()).unlink() - self._thread = None - self._state = "stopped" - - def dispatch_request(self, raw: bytes) -> dict[str, Any]: - request_id, op_name = _best_effort_request_info(raw) - if self.request_logging_enabled: - _bridge_log(f"GUI bridge request: {_format_log_payload(raw)}") - metrics: DispatchMetrics | None = None - if self._state == "starting": - response = response_error( - "IDA GUI bridge is still starting", - backend="gui", - request_id=request_id, - error_kind="startup_incomplete", - ) - elif self._state != "ready": - response = response_error( - "IDA GUI bridge is draining", - backend="gui", - request_id=request_id, - error_kind="draining", - ) - else: - try: - response, metrics = self._dispatcher.call_with_metrics( - "bridge-request", - lambda: self.bridge.handle_request(raw), - ) - except DispatcherBusyError as exc: - response = response_error( - str(exc), - backend="gui", - request_id=request_id, - error_kind="busy", - ) - except DispatcherStoppedError as exc: - response = response_error( - str(exc), - backend="gui", - request_id=request_id, - error_kind="draining", - ) - if self.response_logging_enabled: - _bridge_log(f"GUI bridge response: {_format_log_payload(response)}") - if (self.request_logging_enabled or self.response_logging_enabled) and metrics is not None: - _bridge_log( - "GUI bridge timings: " - f"id={request_id or ''}, op={op_name}, " - f"queue_depth={metrics.queue_depth_at_enqueue}, " - f"queue_wait={metrics.queue_wait_seconds:.3f}s, " - f"run={metrics.run_seconds:.3f}s" - ) - return response - - def status_snapshot(self) -> dict[str, Any]: - pid = os.getpid() - return { - "pid": pid, - "socket_path": str(socket_path(pid)), - "instance_id": self._instance_id, - "state": self._state, - "plugin_name": BRIDGE_PLUGIN_NAME, - "plugin_version": VERSION, - "started_at": self._started_at, - "queue_depth": self._dispatcher.pending_count(), - "queue_capacity": self._dispatcher.max_pending(), - } - - def set_request_logging_enabled(self, enabled: bool) -> bool: - self.request_logging_enabled = bool(enabled) - return self.request_logging_enabled - - def set_response_logging_enabled(self, enabled: bool) -> bool: - self.response_logging_enabled = bool(enabled) - return self.response_logging_enabled - - def _write_registry(self, sock_path: Path) -> None: - payload = bridge_registry_payload( - pid=os.getpid(), - socket_path=str(sock_path), - started_at=self._started_at, - ) - payload["instance_id"] = self._instance_id - payload["state"] = self._state - destination = registry_path(os.getpid()) - atomic_write_json(destination, payload) - - -def _best_effort_request_info(raw: bytes) -> tuple[str | None, str]: - try: - payload = json.loads(raw.decode("utf-8")) - except Exception: - return None, "" - if not isinstance(payload, dict): - return None, "" - request_id = payload.get("id") - op_name = str(payload.get("op") or "") - return (None if request_id in (None, "") else str(request_id), op_name) diff --git a/src/idac/ida_plugin/idac_bridge/handlers.py b/src/idac/ida_plugin/idac_bridge/handlers.py deleted file mode 100644 index 2616b0a..0000000 --- a/src/idac/ida_plugin/idac_bridge/handlers.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Operation handlers and dispatch registry for the IDA GUI bridge.""" - -from __future__ import annotations - -import contextlib -import os -from collections.abc import Callable -from pathlib import Path -from typing import Any - -from idac.ops.dispatch import build_operation_registry -from idac.ops.manifest import SUPPORTED_OPERATIONS -from idac.ops.runtime import IdaRuntime - -HandlerFn = Callable[[dict[str, Any]], Any] -TargetValidator = Callable[[str | None], None] - - -class DefaultHandlers: - """Thin adapter from the GUI bridge to the shared IDA operation registry.""" - - def __init__(self) -> None: - self.runtime = IdaRuntime() - - def _current_target(self) -> dict[str, Any]: - path = "" - module = "" - with contextlib.suppress(ImportError, AttributeError, RuntimeError, OSError, ValueError): - import idaapi # type: ignore - - path = idaapi.get_input_file_path() or "" - module = idaapi.get_root_filename() or "" - selector = module or Path(path).name or "active" - return { - "target_id": "active", - "selector": selector, - "filename": path, - "module": module, - "active": True, - } - - def current_target_info(self) -> dict[str, Any]: - info = dict(self._current_target()) - pid = os.getpid() - info["instance_pid"] = pid - info["instance_selector"] = f"pid:{pid}" - info["global_target_id"] = f"{pid}:{info['target_id']}" - return info - - def list_targets(self, _params: dict[str, Any]) -> list[dict[str, Any]]: - return [self._current_target()] - - def validate_target(self, target: str | None) -> None: - if target in (None, "", "active"): - return - current = self._current_target() - aliases = { - "active", - str(current.get("selector") or ""), - str(current.get("filename") or ""), - str(current.get("module") or ""), - } - if target not in aliases: - from .protocol import UnsupportedOperationError - - raise UnsupportedOperationError(f"target '{target}' does not match the active IDA database") - - -def build_default_registry() -> tuple[dict[str, HandlerFn], TargetValidator]: - """Create the default GUI bridge handler map plus target validator.""" - - handlers = DefaultHandlers() - registry = build_operation_registry(handlers.runtime, list_targets=handlers.list_targets) - - missing = set(SUPPORTED_OPERATIONS) - set(registry.keys()) - if missing: - raise RuntimeError(f"default registry missing operations: {sorted(missing)!r}") - - return registry, handlers.validate_target - - -def current_target_info() -> dict[str, Any]: - return DefaultHandlers().current_target_info() diff --git a/src/idac/ida_plugin/idac_bridge/protocol.py b/src/idac/ida_plugin/idac_bridge/protocol.py deleted file mode 100644 index 2491103..0000000 --- a/src/idac/ida_plugin/idac_bridge/protocol.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Protocol and contract definitions for the IDA GUI bridge scaffold.""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from typing import Any - -from idac.metadata import BRIDGE_PLUGIN_NAME, WIRE_PROTOCOL_VERSION -from idac.ops.manifest import SUPPORTED_OPERATIONS -from idac.paths import ( - bridge_registry_filename as registry_filename, -) -from idac.paths import ( - bridge_registry_path as registry_path, -) -from idac.paths import ( - bridge_socket_filename as socket_filename, -) -from idac.paths import ( - bridge_socket_path as socket_path, -) -from idac.paths import ( - ida_user_dir, - runtime_dir, -) - -PLUGIN_NAME = BRIDGE_PLUGIN_NAME - - -class BridgeError(Exception): - """Base error type for bridge request handling.""" - - -class EnvelopeParseError(BridgeError): - """Raised when a request envelope cannot be parsed or validated.""" - - -class UnsupportedOperationError(BridgeError): - """Raised when an operation exists in contract but has no implementation.""" - - -@dataclass(frozen=True) -class BridgeRequest: - """Normalized request envelope shape used internally by the bridge.""" - - request_id: str - operation: str - params: dict[str, Any] - target: str | None = None - - -def parse_request_envelope(payload: Any) -> BridgeRequest: - """Parse a raw payload into a normalized request envelope.""" - if isinstance(payload, bytes): - payload = payload.decode("utf-8") - - if isinstance(payload, str): - try: - data = json.loads(payload) - except json.JSONDecodeError as exc: - raise EnvelopeParseError(f"invalid JSON payload: {exc.msg}") from exc - elif isinstance(payload, dict): - data = payload - else: - raise EnvelopeParseError(f"unsupported payload type: {type(payload).__name__}") - - version = data.get("version") - request_id = data.get("id") - operation = data.get("op") - params = data.get("params", {}) - target = data.get("target") - - if version != WIRE_PROTOCOL_VERSION: - raise EnvelopeParseError(f"unsupported protocol version: expected {WIRE_PROTOCOL_VERSION}, got {version!r}") - if not request_id: - raise EnvelopeParseError("missing request id ('id')") - if not operation: - raise EnvelopeParseError("missing operation ('op')") - if not isinstance(params, dict): - raise EnvelopeParseError("params must be a JSON object") - - return BridgeRequest( - request_id=str(request_id), - operation=str(operation), - params=params, - target=None if target in (None, "") else str(target), - ) - - -__all__ = [ - "PLUGIN_NAME", - "SUPPORTED_OPERATIONS", - "BridgeError", - "BridgeRequest", - "EnvelopeParseError", - "UnsupportedOperationError", - "ida_user_dir", - "parse_request_envelope", - "registry_filename", - "registry_path", - "runtime_dir", - "socket_filename", - "socket_path", -] diff --git a/src/idac/ida_plugin/idac_bridge_plugin.py b/src/idac/ida_plugin/idac_bridge_plugin.py deleted file mode 100644 index 8ce23d1..0000000 --- a/src/idac/ida_plugin/idac_bridge_plugin.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import annotations - -import os -import signal -import sys -import traceback -from pathlib import Path - -# pro tip from ida-pro-mcp -if hasattr(signal, "SIGPIPE"): - signal.signal(signal.SIGPIPE, signal.SIG_IGN) - - -def _bootstrap_paths() -> None: - here = Path(__file__).resolve() - plugin_dir = here.parent - if str(plugin_dir) not in sys.path: - sys.path.insert(0, str(plugin_dir)) - - candidates: list[Path] = [] - raw_repo = os.environ.get("IDAC_REPO") - if raw_repo: - candidates.append(Path(raw_repo).expanduser().resolve()) - candidates.append(plugin_dir.parent) - - for repo_root in candidates: - src_dir = repo_root / "src" - if (src_dir / "idac").exists(): - if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - break - - -_bootstrap_paths() - - -import ida_kernwin # type: ignore -import idaapi # type: ignore -from idac_bridge.bridge import BridgeService # ty: ignore[unresolved-import] -from idac_bridge.protocol import registry_path, socket_path # ty: ignore[unresolved-import] - -from idac.version import VERSION - -REQUEST_LOG_FLAG = 0x1 -RESPONSE_LOG_FLAG = 0x2 - - -class _BridgeLoggingForm(getattr(ida_kernwin, "Form", object)): - def __init__(self) -> None: - super().__init__( - f"""BUTTON YES* OK -BUTTON CANCEL Cancel -idac bridge - -Version: {VERSION} - - -{{loggingFlags}}> -""", - { - "loggingFlags": ida_kernwin.Form.ChkGroupControl(("requestLog", "responseLog")), - }, - ) - - -class IdacBridgePlugin(idaapi.plugin_t): - flags = idaapi.PLUGIN_FIX - comment = "Expose the active IDA session to the idac CLI" - help = "Starts a local Unix-socket bridge for idac" - wanted_name = "idac bridge" - wanted_hotkey = "" - - def __init__(self) -> None: - super().__init__() - self._service: BridgeService | None = None - - def _log(self, message: str, *, exc: BaseException | None = None) -> None: - lines = [f"[idac] {message}\n"] - if exc is not None: - lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) - text = "".join(lines) - idaapi.msg(text) - - def init(self): - try: - self._service = BridgeService() - self._service.start() - self._emit_status() - return idaapi.PLUGIN_KEEP - except Exception as exc: - self._service = None - self._log("GUI bridge failed to start", exc=exc) - return idaapi.PLUGIN_SKIP - - def _emit_status(self) -> None: - pid = os.getpid() - state = "running" if self._service is not None else "not running" - message = ( - f"[idac] idac bridge v{VERSION} loaded\n" - f"[idac] GUI bridge {state} (v{VERSION}): " - f"pid={pid}, socket={socket_path(pid)}, registry={registry_path(pid)}\n" - ) - idaapi.msg(message) - - def run(self, _arg: int) -> None: - self._show_logging_dialog() - return None - - def _show_logging_dialog(self) -> None: - service = self._service - if service is None: - self._emit_status() - return - - form = _BridgeLoggingForm() - form, _ = form.Compile() - form.loggingFlags.value = self._current_logging_flags() - try: - form.Execute() - self._apply_logging_flags(form.loggingFlags.value) - finally: - form.Free() - self._emit_status() - - def _current_logging_flags(self) -> int: - service = self._service - if service is None: - return 0 - flags = 0 - if service.request_logging_enabled: - flags |= REQUEST_LOG_FLAG - if service.response_logging_enabled: - flags |= RESPONSE_LOG_FLAG - return flags - - def _apply_logging_flags(self, flags: int) -> None: - service = self._service - if service is None: - return - service.set_request_logging_enabled(bool(flags & REQUEST_LOG_FLAG)) - service.set_response_logging_enabled(bool(flags & RESPONSE_LOG_FLAG)) - - def term(self) -> None: - if self._service is not None: - try: - self._service.stop() - except Exception as exc: - self._log("GUI bridge failed during shutdown", exc=exc) - finally: - self._service = None - - -def PLUGIN_ENTRY(): - return IdacBridgePlugin() diff --git a/src/idac/metadata.py b/src/idac/metadata.py deleted file mode 100644 index 5b6533b..0000000 --- a/src/idac/metadata.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from .version import VERSION - -BRIDGE_PLUGIN_NAME = "idac_bridge" -BRIDGE_REGISTRY_PREFIX = "idac-bridge" -BRIDGE_SOCKET_PREFIX = "idac-bridge" -IDALIB_DAEMON_NAME = "idac-idalib" -IDALIB_REGISTRY_PREFIX = "idac-idalib" -IDALIB_SOCKET_PREFIX = "idac-idalib" -BRIDGE_RUNTIME_DIRNAME = "idac" -SKILL_NAME = "idac" -WIRE_PROTOCOL_VERSION = 1 -GUI_BACKEND_NAME = "gui" -IDALIB_BACKEND_NAME = "idalib" - - -def bridge_registry_payload( - *, - pid: int, - socket_path: str, - started_at: str | None, -) -> dict[str, Any]: - return { - "pid": pid, - "socket_path": socket_path, - "plugin_name": BRIDGE_PLUGIN_NAME, - "plugin_version": VERSION, - "started_at": started_at, - "backend": GUI_BACKEND_NAME, - } - - -def idalib_registry_payload( - *, - pid: int, - socket_path: str, - started_at: str | None, - database_path: str | None, -) -> dict[str, Any]: - return { - "pid": pid, - "socket_path": socket_path, - "server_name": IDALIB_DAEMON_NAME, - "started_at": started_at, - "database_path": database_path, - "backend": IDALIB_BACKEND_NAME, - } diff --git a/src/idac/nexus.py b/src/idac/nexus.py new file mode 100644 index 0000000..63cafb8 --- /dev/null +++ b/src/idac/nexus.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +import math +import re +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .compatibility import ( + REMOTE_ENVIRONMENT_CODE, + compatibility_mismatches, +) +from .operations import MUTATING_OPERATIONS, REMOTE_OPERATIONS + +KEEPALIVE_SECONDS = 300.0 +DEFAULT_ANALYSIS_TIMEOUT_SECONDS = 120.0 +DISCARD_TIMEOUT_SECONDS = 30.0 +REMOTE_OPS_PATH = Path(__file__).with_name("remote_ops.py") + + +class NexusSessionError(RuntimeError): + """An ida-nexus failure translated for idac's application boundary.""" + + def __init__( + self, + message: str, + *, + kind: str = "nexus_error", + status: int | None = None, + details: dict[str, object] | None = None, + ) -> None: + super().__init__(message) + self.kind = kind + self.status = status + self.details = dict(details or {}) + + +class NexusSelectionError(NexusSessionError): + """A context could not be resolved to exactly one READY Nexus instance.""" + + +@dataclass(frozen=True) +class NexusApi: + """The public ida-nexus objects used by :class:`NexusSession`.""" + + DatabaseHandle: Any + DatabaseOpenOptions: Any + RemoteModule: Any + InstanceState: Any + NexusError: type[BaseException] + discover_databases: Callable[..., list[Any]] + wait_database_released: Callable[..., bool] + + +def load_nexus_api() -> NexusApi: + """Load only ida-nexus's supported public Python exports.""" + + from ida_nexus import ( + DatabaseHandle, + DatabaseOpenOptions, + InstanceState, + NexusError, + RemoteModule, + discover_databases, + wait_database_released, + ) + + return NexusApi( + DatabaseHandle=DatabaseHandle, + DatabaseOpenOptions=DatabaseOpenOptions, + RemoteModule=RemoteModule, + InstanceState=InstanceState, + NexusError=NexusError, + discover_databases=discover_databases, + wait_database_released=wait_database_released, + ) + + +def translate_nexus_error(error: BaseException) -> NexusSessionError: + """Translate a public Nexus exception without depending on CLI types.""" + + code = getattr(error, "code", None) + kind = str(code) if code else _error_kind(type(error).__name__) + raw_status = getattr(error, "status", None) + status = raw_status if isinstance(raw_status, int) and not isinstance(raw_status, bool) else None + raw_details = getattr(error, "details", None) + details = {str(key): value for key, value in raw_details.items()} if isinstance(raw_details, dict) else {} + message = str(error).strip() or type(error).__name__ + version_failure = re.search(r"^\[ida-nexus\] (.*requires IDA \d+\.\d+ or newer)\s*$", message, re.MULTILINE) + if version_failure is not None: + details["worker_output"] = message + message = version_failure.group(1) + return NexusSessionError(message, kind=kind, status=status, details=details) + + +def _note_secondary_error(primary: BaseException, secondary: BaseException | None, context: str) -> None: + """Preserve the primary failure while recording a distinct cleanup failure.""" + + if secondary is not None and secondary is not primary: + primary.add_note(f"{context}: {str(secondary) or secondary.__class__.__name__}") + + +def list_targets(timeout: float | None = None) -> list[dict[str, object]]: + """Discover Nexus databases without opening or leasing one.""" + + session = NexusSession(timeout=timeout) + try: + return session.list_targets() + finally: + session.close() + + +def _error_kind(class_name: str) -> str: + pieces: list[str] = [] + current = "" + for character in class_name: + if character.isupper() and current: + pieces.append(current) + current = character.lower() + else: + current += character.lower() + if current: + pieces.append(current) + return "_".join(pieces) + + +def _target_row(discovered: Any) -> dict[str, object]: + instance = discovered.instance + return { + "record_id": instance.record_id, + "state": discovered.state.value, + "detail": discovered.detail, + "backend": instance.backend, + "pid": instance.pid, + "idb_path": instance.idb_path, + "exe_path": instance.exe_path, + "managed": instance.managed, + "started_at": instance.started_at, + } + + +def dispatch(db: Any, op: str, params: dict[str, object], preview: bool) -> object: + """Import-safe declaration matched to ``remote_ops.dispatch`` by Nexus.""" + + ... + + +class NexusSession: + """One lazily acquired Nexus database lease for a top-level idac command.""" + + def __init__( + self, + locator: str | Path | None = None, + instance_id: str | None = None, + timeout: float | None = None, + *, + api: NexusApi | None = None, + remote_module_path: str | Path = REMOTE_OPS_PATH, + ) -> None: + if locator is not None and instance_id is not None: + raise ValueError("locator and instance_id are mutually exclusive") + if timeout is not None and ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or not math.isfinite(timeout) + or timeout <= 0 + ): + raise ValueError("timeout must be a positive finite number or None") + + locator_text = str(locator).strip() if locator is not None else None + if locator_text == "": + raise ValueError("locator must not be empty") + if locator_text is not None: + lowered = locator_text.lower() + if lowered.endswith(".idb"): + raise ValueError("legacy .idb databases are not supported; use .i64") + if lowered.startswith(("db:", "pid:", "module:")): + raise ValueError("legacy db:/pid:/module: context selectors are not supported") + + instance_text = instance_id.strip() if instance_id is not None else None + if instance_text == "": + raise ValueError("instance_id must not be empty") + + self.locator = locator_text + self.instance_id = instance_text + self.timeout = float(timeout) if timeout is not None else None + self._api = api + self._remote_module_path = Path(remote_module_path) + self._handle: Any | None = None + self._dispatch: Callable[..., object] | None = None + self._active_operation_label: str | None = None + self._dirty = False + self._discard_required = False + self._closed = False + self._terminal_error: BaseException | None = None + self._save_error: BaseException | None = None + + @property + def handle(self) -> Any: + """Return the session's single handle, acquiring it on first use.""" + + return self._get_handle() + + def execute_operation( + self, + op: str, + params: dict[str, object], + *, + preview: bool = False, + operation_label: str | None = None, + ) -> object: + """Dispatch one JSON-native idac operation through ``RemoteModule``.""" + + self._ensure_open() + if not isinstance(op, str) or not op.strip(): + raise ValueError("op must be a non-empty string") + if not isinstance(params, dict): + raise TypeError("params must be a dictionary") + if not isinstance(preview, bool): + raise TypeError("preview must be a boolean") + if op not in REMOTE_OPERATIONS: + raise ValueError(f"unsupported operation: {op}") + + if self._dirty: + handle = self._get_handle() + if handle.instance.backend == "idalib": + # Checkpoint each completed batch step before starting another + # remote request. If the next request is interrupted and its RPC + # connection becomes unusable, discarding that worker will not + # lose already reported successes. + self.save_database() + + remote_dispatch = self._get_dispatch() + self._active_operation_label = operation_label or f"idac: {op}" + try: + result = self._call_nexus(lambda: remote_dispatch(self._get_handle(), op, dict(params), preview)) + except KeyboardInterrupt as exc: + self._poison_uncertain_session( + exc, + gui_note=( + "the interrupted GUI request may have left unsaved in-memory changes; idac did not save them" + ), + ) + raise + except BaseException as exc: + # A timeout, disconnect, or remote exception can arrive after + # IDA changed. A failed preview has an especially uncertain + # rollback outcome: poison the session and discard a headless + # worker instead of checkpointing possibly preview-only state. + if op in MUTATING_OPERATIONS: + if preview: + self._poison_uncertain_session( + exc, + gui_note=( + "the failed GUI preview may have left unsaved in-memory changes; " + "inspect or undo them in IDA before saving" + ), + ) + else: + self._dirty = True + raise + finally: + self._active_operation_label = None + failed_result = isinstance(result, dict) and result.get("success") is False + if op in MUTATING_OPERATIONS and not preview and not failed_result: + self._dirty = True + return result + + def execute_python( + self, + code: str, + *, + filename: str | None = None, + operation_label: str | None = None, + ) -> dict[str, object]: + """Execute Python in a fresh Nexus namespace and return its result envelope.""" + + self._ensure_open() + if not isinstance(code, str) or not code.strip(): + raise ValueError("code must be a non-empty string") + if self._dirty: + handle = self._get_handle() + if handle.instance.backend == "idalib": + self.save_database() + # Arbitrary Python can mutate the IDB before it raises or times out, so + # a headless session must checkpoint the resulting state even when the + # execution outcome is an error. + self._dirty = True + try: + result = self._call_nexus( + lambda: self._get_handle().execute_python( + code, + timeout=self.timeout, + operation_label=operation_label or "idac: py exec", + persist_globals=False, + filename=filename, + ) + ) + except KeyboardInterrupt as exc: + self._poison_uncertain_session( + exc, + gui_note=( + "the interrupted GUI Python request may have left unsaved in-memory changes; idac did not save them" + ), + ) + raise + return result + + def save_database(self) -> dict[str, object]: + """Explicitly checkpoint the selected database, including GUI databases.""" + + self._ensure_open() + handle = self._get_handle() + try: + return self._save_handle(handle) + except BaseException as exc: + # A failed save has an uncertain outcome. Do not issue another save + # from close() or allow later operations to continue on this session. + self._save_error = exc + self._terminal_error = exc + if handle.instance.backend == "idalib": + self._discard_required = True + raise + + def _save_handle(self, handle: Any) -> dict[str, object]: + result = self._call_nexus(handle.save_database) + if not isinstance(result, dict) or result.get("saved") is not True: + raise NexusSessionError("Nexus did not confirm that the database was saved", kind="save_failed") + idb_path = result.get("idb_path") + if not isinstance(idb_path, str) or not idb_path: + raise NexusSessionError( + "Nexus save response did not include an IDB path", + kind="save_failed", + ) + self._dirty = False + return {"saved": True, "path": idb_path} + + def list_targets(self) -> list[dict[str, object]]: + """Return sanitized public discovery records without acquiring a lease.""" + + self._ensure_open() + discovered = self._call_nexus(self._discover_databases) + rows = [_target_row(item) for item in discovered] + return sorted(rows, key=lambda row: str(row["record_id"])) + + def close(self) -> None: + """Deterministically finalize and release this session once.""" + + if self._closed: + return + self._closed = True + handle = self._handle + if handle is None: + return + self._handle = None + self._dispatch = None + + if self._discard_required and handle.instance.backend == "idalib": + discard_error: BaseException | None = None + try: + self._discard_headless_handle(handle) + except BaseException as exc: + discard_error = exc + if self._save_error is not None: + _note_secondary_error( + self._save_error, + discard_error, + "discarding the headless worker after save failure also failed", + ) + raise self._save_error + if discard_error is not None: + raise discard_error + return + + finalization_error = self._save_error + release_error: BaseException | None = None + released_after_save_failure = False + try: + if finalization_error is None and self._dirty and handle.instance.backend == "idalib": + try: + self._save_handle(handle) + except BaseException as exc: + finalization_error = exc + self._save_error = exc + self._terminal_error = exc + self._discard_required = True + released_after_save_failure = True + try: + self._discard_headless_handle(handle) + except BaseException as discard_error: + _note_secondary_error( + exc, + discard_error, + "discarding the headless worker after save failure also failed", + ) + raise + finally: + if not released_after_save_failure: + try: + self._call_nexus(handle.close) + except BaseException as exc: + release_error = exc + + if finalization_error is not None: + _note_secondary_error(finalization_error, release_error, "releasing the Nexus lease also failed") + raise finalization_error + if release_error is not None: + raise release_error + + def _discard_headless_handle(self, handle: Any) -> None: + """Release one poisoned lease and retire that exact worker without saving.""" + + instance = handle.instance + release_error: BaseException | None = None + try: + self._call_nexus(handle.close) + except BaseException as exc: + release_error = exc + + discard_error: BaseException | None = None + discard_handle: Any | None = None + shutdown_requested = False + try: + discard_handle = self._call_nexus(lambda: self._nexus.DatabaseHandle.attach(instance, keepalive=0.0)) + deadline = time.monotonic() + DISCARD_TIMEOUT_SECONDS + while True: + try: + self._call_nexus(lambda: discard_handle.shutdown_database(save=False)) + shutdown_requested = True + break + except NexusSessionError as exc: + remaining = deadline - time.monotonic() + if exc.kind not in {"instance_busy", "instance_shared"} or remaining <= 0: + raise + time.sleep(min(0.05, remaining)) + except BaseException as exc: + discard_error = exc + finally: + if discard_handle is not None: + try: + self._call_nexus(discard_handle.close) + except BaseException as exc: + if discard_error is None: + discard_error = exc + else: + _note_secondary_error(discard_error, exc, "releasing the discard lease also failed") + if shutdown_requested: + try: + released = self._call_nexus( + lambda: self._nexus.wait_database_released( + instance, + timeout=DISCARD_TIMEOUT_SECONDS, + ) + ) + if released is not True: + raise NexusSessionError( + "Nexus headless worker did not terminate after its uncertain state was discarded", + kind="discard_timeout", + details={"record_id": instance.record_id}, + ) + except BaseException as exc: + if discard_error is None: + discard_error = exc + else: + _note_secondary_error(discard_error, exc, "waiting for the discarded Nexus worker also failed") + if discard_error is not None: + _note_secondary_error(discard_error, release_error, "releasing the Nexus lease also failed") + raise discard_error + if release_error is not None: + raise release_error + + def _poison_uncertain_session(self, error: BaseException, *, gui_note: str) -> None: + """Prevent further use and select discard semantics for uncertain state.""" + + self._dirty = False + self._discard_required = True + self._terminal_error = error + if self._handle is not None and self._handle.instance.backend == "gui": + error.add_note(gui_note) + + def __enter__(self) -> NexusSession: + self._ensure_open() + return self + + def __exit__( + self, + _exc_type: type[BaseException] | None, + exc: BaseException | None, + _traceback: object | None, + ) -> None: + if exc is None: + self.close() + return + try: + self.close() + except BaseException as close_error: + _note_secondary_error(exc, close_error, "Nexus session finalization also failed") + + @property + def _nexus(self) -> NexusApi: + if self._api is None: + self._api = load_nexus_api() + return self._api + + def _get_handle(self) -> Any: + self._ensure_open() + if self._handle is not None: + return self._handle + + try: + handle = self._call_nexus(self._open_handle) + except BaseException as exc: + self._terminal_error = exc + raise + self._handle = handle + try: + if handle.instance.backend == "idalib": + analysis_timeout = self.timeout if self.timeout is not None else DEFAULT_ANALYSIS_TIMEOUT_SECONDS + analysis = self._call_nexus(lambda: handle.wait_autoanalysis(timeout=analysis_timeout)) + if not isinstance(analysis, dict) or analysis.get("complete") is not True: + raise NexusSessionError( + "Nexus did not confirm that headless autoanalysis completed", + kind="analysis_incomplete", + ) + self._validate_remote_environment(handle) + except BaseException as exc: + try: + if handle.instance.backend == "idalib": + self._discard_headless_handle(handle) + else: + self._call_nexus(handle.close) + except BaseException as close_error: + _note_secondary_error( + exc, + close_error, + "releasing the Nexus lease after initialization failure also failed", + ) + finally: + self._handle = None + self._dispatch = None + self._terminal_error = exc + raise + return handle + + def _validate_remote_environment(self, handle: Any) -> None: + execution = self._call_nexus( + lambda: handle.execute_python( + REMOTE_ENVIRONMENT_CODE, + timeout=self.timeout, + operation_label="idac: compatibility check", + persist_globals=False, + filename="", + ) + ) + environment = execution.get("result") if isinstance(execution, dict) else None + if not isinstance(environment, dict): + raise NexusSessionError( + "Nexus returned an invalid remote environment response", + kind="unsupported_remote_environment", + ) + mismatches = compatibility_mismatches(environment) + if mismatches: + raise NexusSessionError( + "unsupported remote IDA environment: " + "; ".join(mismatches), + kind="unsupported_remote_environment", + details={"environment": environment, "mismatches": mismatches}, + ) + + def _open_handle(self) -> Any: + if self.locator is not None: + option_values: dict[str, object] = { + "keepalive": KEEPALIVE_SECONDS, + "auto_analysis": True, + } + if self.timeout is not None: + option_values["startup_timeout"] = self.timeout + options = self._nexus.DatabaseOpenOptions(**option_values) + return self._nexus.DatabaseHandle.open(self.locator, options=options) + + instance = self._select_instance() + return self._nexus.DatabaseHandle.attach(instance, keepalive=KEEPALIVE_SECONDS) + + def _select_instance(self) -> Any: + discovered = self._discover_databases() + ready_state = self._nexus.InstanceState.READY + if self.instance_id is not None: + matches = [item for item in discovered if item.instance.record_id == self.instance_id] + if not matches: + available = ", ".join(sorted(item.instance.record_id for item in discovered)) or "none" + raise NexusSelectionError( + f"Nexus instance {self.instance_id!r} was not found; available record IDs: {available}", + kind="instance_not_found", + ) + selected = matches[0] + if selected.state != ready_state: + detail = f": {selected.detail}" if selected.detail else "" + state = selected.state.value + raise NexusSelectionError( + f"Nexus instance {self.instance_id!r} is {state}{detail}", + kind="instance_not_ready", + ) + return selected.instance + + ready = [item for item in discovered if item.state == ready_state] + if len(ready) == 1: + return ready[0].instance + if not ready: + unavailable = ", ".join(f"{item.instance.record_id} ({item.state.value})" for item in discovered) + suffix = f"; discovered: {unavailable}" if unavailable else "" + raise NexusSelectionError( + f"no READY Nexus database instance found{suffix}", + kind="no_ready_instance", + ) + records = ", ".join(sorted(item.instance.record_id for item in ready)) + raise NexusSelectionError( + f"multiple READY Nexus database instances found: {records}; pass --instance or --context", + kind="ambiguous_instance", + ) + + def _discover_databases(self) -> list[Any]: + if self.timeout is None: + return self._nexus.discover_databases() + return self._nexus.discover_databases(timeout=self.timeout) + + def _get_dispatch(self) -> Callable[..., object]: + if self._dispatch is not None: + return self._dispatch + if not self._remote_module_path.is_file(): + raise NexusSessionError( + f"idac remote operation bundle is missing: {self._remote_module_path}", + kind="remote_bundle_missing", + ) + module = self._nexus.RemoteModule( + self._remote_module_path, + codec="json", + operation_label=lambda: self._active_operation_label, + ) + self._dispatch = module.function(database=True, timeout=self.timeout)(dispatch) + return self._dispatch + + def _call_nexus(self, action: Callable[[], Any]) -> Any: + api = self._nexus + try: + return action() + except api.NexusError as exc: + raise translate_nexus_error(exc) from exc + + def _ensure_open(self) -> None: + if self._closed: + raise NexusSessionError("Nexus session is closed", kind="session_closed") + if self._terminal_error is not None: + raise self._terminal_error + + +__all__ = [ + "DEFAULT_ANALYSIS_TIMEOUT_SECONDS", + "DISCARD_TIMEOUT_SECONDS", + "KEEPALIVE_SECONDS", + "NexusApi", + "NexusSelectionError", + "NexusSession", + "NexusSessionError", + "list_targets", + "load_nexus_api", + "translate_nexus_error", +] diff --git a/src/idac/operations.py b/src/idac/operations.py new file mode 100644 index 0000000..2a80fa4 --- /dev/null +++ b/src/idac/operations.py @@ -0,0 +1,92 @@ +# Public client inventory. REMOTE_OPERATIONS mirrors remote_ops.dispatch; +# list_targets, db_save, and python_exec use public Nexus APIs directly. +REMOTE_OPERATIONS = ( + "database_info", + "segment_list", + "function_list", + "function_show", + "function_frame", + "function_stackvars", + "function_callers", + "function_callees", + "disasm", + "disasm_range", + "decompile", + "ctree", + "search_bytes", + "xrefs", + "strings", + "imports", + "bookmark_get", + "bookmark_add", + "bookmark_set", + "bookmark_delete", + "comment_get", + "comment_set", + "comment_delete", + "name_set", + "local_list", + "local_rename", + "local_retype", + "local_update", + "local_apply_plan", + "proto_get", + "proto_check", + "proto_set", + "type_list", + "type_show", + "type_deps", + "type_declare", + "type_declare_check", + "class_list", + "class_candidates", + "class_show", + "class_hierarchy", + "class_fields", + "class_vtable", + "struct_list", + "struct_show", + "struct_field_set", + "struct_field_rename", + "struct_field_delete", + "enum_list", + "enum_show", + "enum_member_set", + "enum_member_rename", + "enum_member_delete", + "reanalyze", +) + +MUTATING_OPERATIONS = frozenset( + { + "bookmark_add", + "bookmark_set", + "bookmark_delete", + "comment_set", + "comment_delete", + "name_set", + "local_rename", + "local_retype", + "local_update", + "local_apply_plan", + "proto_set", + "type_declare", + "struct_field_set", + "struct_field_rename", + "struct_field_delete", + "enum_member_set", + "enum_member_rename", + "enum_member_delete", + "reanalyze", + } +) + +SUPPORTED_OPERATIONS = ( + "list_targets", + *REMOTE_OPERATIONS, + "db_save", + "python_exec", +) + + +__all__ = ["MUTATING_OPERATIONS", "REMOTE_OPERATIONS", "SUPPORTED_OPERATIONS"] diff --git a/src/idac/ops/__init__.py b/src/idac/ops/__init__.py deleted file mode 100644 index 1c2a367..0000000 --- a/src/idac/ops/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from .base import OperationContext, OperationSpec -from .dispatch import build_operation_registry -from .manifest import ( - MUTATING_OPERATIONS, - OPERATION_SPEC_MAP, - PREVIEW_UNSUPPORTED_OPERATIONS, - SUPPORTED_OPERATIONS, - OperationName, - operation_specs, -) -from .models import payload_from_model -from .preview import PreviewOutcome, PreviewSpec, PreviewUnsupportedError -from .registry import OperationLookupError, OperationRegistry - -__all__ = [ - "MUTATING_OPERATIONS", - "OPERATION_SPEC_MAP", - "PREVIEW_UNSUPPORTED_OPERATIONS", - "SUPPORTED_OPERATIONS", - "OperationContext", - "OperationLookupError", - "OperationName", - "OperationRegistry", - "OperationSpec", - "PreviewOutcome", - "PreviewSpec", - "PreviewUnsupportedError", - "build_operation_registry", - "operation_specs", - "payload_from_model", -] diff --git a/src/idac/ops/base.py b/src/idac/ops/base.py deleted file mode 100644 index 0485182..0000000 --- a/src/idac/ops/base.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, TypeVar - -if TYPE_CHECKING: - from .preview import PreviewSpec - from .runtime import IdaRuntime - -RequestT = TypeVar("RequestT") -ResultT = TypeVar("ResultT") -Params = Mapping[str, Any] - -ParseParams = Callable[[Params], RequestT] -RunOperation = Callable[["OperationContext", RequestT], ResultT] - - -@dataclass(frozen=True) -class OperationContext: - runtime: IdaRuntime - preview: bool = False - - -@dataclass(frozen=True) -class OperationSpec(Generic[RequestT, ResultT]): - name: str - parse: ParseParams[RequestT] - run: RunOperation[RequestT, ResultT] - mutating: bool = False - preview: PreviewSpec[RequestT, ResultT] | None = None - - def parse_params(self, params: Params) -> RequestT: - return self.parse(params) - - -__all__ = [ - "OperationContext", - "OperationSpec", - "Params", - "ParseParams", - "RequestT", - "ResultT", - "RunOperation", -] diff --git a/src/idac/ops/dispatch.py b/src/idac/ops/dispatch.py deleted file mode 100644 index 08314fb..0000000 --- a/src/idac/ops/dispatch.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping -from typing import Any - -from .base import OperationContext -from .manifest import operation_specs -from .models import payload_from_model -from .preview import PreviewUnsupportedError -from .registry import OperationRegistry -from .runtime import IdaOperationError, IdaRuntime - - -def build_operation_registry( - runtime: IdaRuntime, - *, - list_targets: Callable[[dict[str, Any]], Any] | None = None, -) -> dict[str, Callable[[dict[str, Any]], Any]]: - registry = OperationRegistry(operation_specs()) - - def make_handler(name: str) -> Callable[[dict[str, Any]], Any]: - def handler(params: Mapping[str, Any]) -> Any: - payload = dict(params) - preview_requested = bool(payload.pop("preview", False)) - context = OperationContext(runtime=runtime, preview=preview_requested) - try: - result = registry.execute(name, params=payload, context=context) - except PreviewUnsupportedError as exc: - raise IdaOperationError(str(exc)) from exc - return payload_from_model(result) - - return handler - - handlers = {name: make_handler(name) for name in registry.names()} - if list_targets is not None: - handlers["list_targets"] = list_targets - return handlers - - -__all__ = ["build_operation_registry"] diff --git a/src/idac/ops/families/__init__.py b/src/idac/ops/families/__init__.py deleted file mode 100644 index 6dbfac8..0000000 --- a/src/idac/ops/families/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from .bookmarks import bookmark_operations -from .classes import class_operations -from .comments import comment_operations -from .database import database_operations -from .functions import function_operations -from .locals import local_operations -from .misc import misc_operations -from .named_types import named_type_operations -from .names import name_operations -from .prototypes import prototype_operations -from .search import search_operations -from .segments import segment_operations -from .type_declare import type_declare_operations - -__all__ = [ - "bookmark_operations", - "class_operations", - "comment_operations", - "database_operations", - "function_operations", - "local_operations", - "misc_operations", - "name_operations", - "named_type_operations", - "prototype_operations", - "search_operations", - "segment_operations", - "type_declare_operations", -] diff --git a/src/idac/ops/families/bookmarks.py b/src/idac/ops/families/bookmarks.py deleted file mode 100644 index 533f4e2..0000000 --- a/src/idac/ops/families/bookmarks.py +++ /dev/null @@ -1,319 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.params import require_str -from ..preview import PreviewSpec -from ..runtime import IdaOperationError, IdaRuntime - - -@dataclass(frozen=True) -class BookmarkGetRequest: - slot: int | None = None - - -@dataclass(frozen=True) -class BookmarkSetRequest: - slot: int - identifier: str - comment: str = "" - - -@dataclass(frozen=True) -class BookmarkAddRequest: - identifier: str - comment: str = "" - - -@dataclass(frozen=True) -class BookmarkDeleteRequest: - slot: int - - -@dataclass(frozen=True) -class BookmarkState: - slot: int - present: bool - address: str | None - comment: str | None - - -@dataclass(frozen=True) -class BookmarkList: - bookmarks: list[BookmarkState] - count: int - - -@dataclass(frozen=True) -class BookmarkMutationResult: - slot: int - present: bool - address: str | None - comment: str | None - changed: bool - - -def _require_identifier(value: object, *, label: str) -> str: - return require_str(value, field=label) - - -def _parse_slot(value: object) -> int: - text = str("" if value is None else value).strip() - if not text: - raise IdaOperationError("bookmark slot is required") - try: - slot = int(text, 0) - except ValueError as exc: - raise IdaOperationError("bookmark slot must be an integer") from exc - if slot < 0: - raise IdaOperationError("bookmark slot must be greater than or equal to 0") - return slot - - -def _validate_slot(runtime: IdaRuntime, slot: int) -> int: - ida_moves = runtime.mod("ida_moves") - if slot > ida_moves.MAX_MARK_SLOT: - raise IdaOperationError(f"bookmark slot must be less than or equal to {ida_moves.MAX_MARK_SLOT}") - return slot - - -def _bookmark_template(runtime: IdaRuntime): - ida_kernwin = runtime.mod("ida_kernwin") - ida_moves = runtime.mod("ida_moves") - place_id = ida_kernwin.get_place_class_id("idaplace_t") - if place_id < 0: - raise IdaOperationError("failed to resolve IDA bookmark place class") - place = ida_kernwin.get_place_class_template(place_id) - if place is None: - raise IdaOperationError("failed to build an idaplace_t template for bookmarks") - loc = ida_moves.lochist_entry_t() - loc.set_place(place) - return loc - - -def _bookmark_state(runtime: IdaRuntime, slot: int) -> BookmarkState: - ida_kernwin = runtime.mod("ida_kernwin") - ida_moves = runtime.mod("ida_moves") - loc = _bookmark_template(runtime) - desc, found_slot = ida_moves.bookmarks_t.get(loc, slot, None) - if desc is None or found_slot is None: - return BookmarkState(slot=slot, present=False, address=None, comment=None) - place = loc.place() - idaplace = ida_kernwin.place_t.as_idaplace_t(place) - if idaplace is None: - raise IdaOperationError(f"failed to decode bookmark slot {slot}") - address = hex(idaplace.ea) - return BookmarkState( - slot=slot, - present=True, - address=address, - comment=desc, - ) - - -def _bookmark_slots(runtime: IdaRuntime) -> list[int]: - ida_moves = runtime.mod("ida_moves") - return [slot for slot in range(ida_moves.MAX_MARK_SLOT + 1) if _bookmark_state(runtime, slot).present] - - -def _bookmark_list(runtime: IdaRuntime) -> BookmarkList: - bookmarks = [_bookmark_state(runtime, slot) for slot in _bookmark_slots(runtime)] - return BookmarkList(bookmarks=bookmarks, count=len(bookmarks)) - - -def _first_free_slot(runtime: IdaRuntime) -> int: - ida_moves = runtime.mod("ida_moves") - for slot in range(ida_moves.MAX_MARK_SLOT + 1): - if not _bookmark_state(runtime, slot).present: - return slot - raise IdaOperationError(f"no free bookmark slots remain (0..{ida_moves.MAX_MARK_SLOT})") - - -def _erase_bookmark(runtime: IdaRuntime, slot: int) -> None: - ida_moves = runtime.mod("ida_moves") - loc = _bookmark_template(runtime) - if not ida_moves.bookmarks_t.erase(loc, slot, None): - raise IdaOperationError(f"failed to delete bookmark slot {slot}") - - -def _write_bookmark(runtime: IdaRuntime, *, slot: int, identifier: str, comment: str) -> BookmarkMutationResult: - ida_idc = runtime.mod("ida_idc") - ea = runtime.resolve_address(identifier) - ida_idc.mark_position(ea, 0, 0, 0, slot, comment) - state = _bookmark_state(runtime, slot) - if not state.present: - raise IdaOperationError(f"failed to set bookmark slot {slot}") - return BookmarkMutationResult( - slot=state.slot, - present=state.present, - address=state.address, - comment=state.comment, - changed=True, - ) - - -def _parse_get(params: Mapping[str, Any]) -> BookmarkGetRequest: - slot_value = params.get("slot") - if slot_value in (None, ""): - return BookmarkGetRequest() - return BookmarkGetRequest(slot=_parse_slot(slot_value)) - - -def _parse_set(params: Mapping[str, Any]) -> BookmarkSetRequest: - return BookmarkSetRequest( - slot=_parse_slot(params.get("slot")), - identifier=_require_identifier(params.get("address"), label="address"), - comment=str(params.get("comment") or ""), - ) - - -def _parse_add(params: Mapping[str, Any]) -> BookmarkAddRequest: - return BookmarkAddRequest( - identifier=_require_identifier(params.get("address"), label="address"), - comment=str(params.get("comment") or ""), - ) - - -def _parse_delete(params: Mapping[str, Any]) -> BookmarkDeleteRequest: - return BookmarkDeleteRequest(slot=_parse_slot(params.get("slot"))) - - -def _get_bookmark(context: OperationContext, request: BookmarkGetRequest) -> BookmarkState | BookmarkList: - runtime = context.runtime - if request.slot is None: - return _bookmark_list(runtime) - return _bookmark_state(runtime, _validate_slot(runtime, request.slot)) - - -def _set_bookmark(context: OperationContext, request: BookmarkSetRequest) -> BookmarkMutationResult: - runtime = context.runtime - slot = _validate_slot(runtime, request.slot) - return _write_bookmark(runtime, slot=slot, identifier=request.identifier, comment=request.comment) - - -def _add_bookmark(context: OperationContext, request: BookmarkAddRequest) -> BookmarkMutationResult: - runtime = context.runtime - slot = _first_free_slot(runtime) - return _write_bookmark(runtime, slot=slot, identifier=request.identifier, comment=request.comment) - - -def _delete_bookmark(context: OperationContext, request: BookmarkDeleteRequest) -> BookmarkMutationResult: - runtime = context.runtime - slot = _validate_slot(runtime, request.slot) - before = _bookmark_state(runtime, slot) - if not before.present: - return BookmarkMutationResult( - slot=before.slot, - present=before.present, - address=before.address, - comment=before.comment, - changed=False, - ) - _erase_bookmark(runtime, slot) - after = _bookmark_state(runtime, slot) - return BookmarkMutationResult( - slot=after.slot, - present=after.present, - address=after.address, - comment=after.comment, - changed=True, - ) - - -def _preview_single_slot( - context: OperationContext, - request: BookmarkSetRequest | BookmarkDeleteRequest, -) -> BookmarkState: - runtime = context.runtime - return _bookmark_state(runtime, _validate_slot(runtime, request.slot)) - - -def _preview_bookmark_list(context: OperationContext, request: BookmarkAddRequest) -> BookmarkList: - del request - return _bookmark_list(context.runtime) - - -def _restore_bookmark_state( - context: OperationContext, - request: BookmarkSetRequest | BookmarkDeleteRequest, - before: BookmarkState, - result: BookmarkMutationResult, -) -> None: - runtime = context.runtime - slot = _validate_slot(runtime, request.slot) - if before.present: - if before.address is None: - raise IdaOperationError(f"bookmark slot {slot} is present but has no saved address") - _write_bookmark(runtime, slot=slot, identifier=before.address, comment=before.comment or "") - return - if result.present: - _erase_bookmark(runtime, slot) - - -def _restore_added_bookmark( - context: OperationContext, - request: BookmarkAddRequest, - before: BookmarkList, - result: BookmarkMutationResult, -) -> None: - del request, before - if result.present: - _erase_bookmark(context.runtime, result.slot) - - -def bookmark_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="bookmark_get", - parse=_parse_get, - run=_get_bookmark, - ), - OperationSpec( - name="bookmark_set", - parse=_parse_set, - run=_set_bookmark, - mutating=True, - preview=PreviewSpec( - capture_before=_preview_single_slot, - capture_after=_preview_single_slot, - rollback=_restore_bookmark_state, - ), - ), - OperationSpec( - name="bookmark_add", - parse=_parse_add, - run=_add_bookmark, - mutating=True, - preview=PreviewSpec( - capture_before=_preview_bookmark_list, - capture_after=_preview_bookmark_list, - rollback=_restore_added_bookmark, - ), - ), - OperationSpec( - name="bookmark_delete", - parse=_parse_delete, - run=_delete_bookmark, - mutating=True, - preview=PreviewSpec( - capture_before=_preview_single_slot, - capture_after=_preview_single_slot, - rollback=_restore_bookmark_state, - ), - ), - ) - - -__all__ = [ - "BookmarkAddRequest", - "BookmarkDeleteRequest", - "BookmarkGetRequest", - "BookmarkList", - "BookmarkMutationResult", - "BookmarkSetRequest", - "BookmarkState", - "bookmark_operations", -] diff --git a/src/idac/ops/families/classes.py b/src/idac/ops/families/classes.py deleted file mode 100644 index 59454db..0000000 --- a/src/idac/ops/families/classes.py +++ /dev/null @@ -1,546 +0,0 @@ -from __future__ import annotations - -from collections import deque -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.params import optional_param_int, optional_str, require_str -from ..runtime import ( - IdaOperationError, - IdaRuntime, - is_recoverable_ida_error, -) -from ..symbols import classify_symbol_kind, is_rtti_symbol_name, is_vtable_symbol_name - - -@dataclass(frozen=True) -class ClassListRequest: - query: str | None - pattern: str | None - glob: bool - regex: bool - ignore_case: bool - - -@dataclass(frozen=True) -class ClassCandidatesRequest: - query: str - kinds: tuple[str, ...] - glob: bool - regex: bool - ignore_case: bool - - -@dataclass(frozen=True) -class ClassNameRequest: - name: str - - -@dataclass(frozen=True) -class ClassFieldsRequest: - name: str - derived_only: bool - - -@dataclass(frozen=True) -class ClassVtableRequest: - name: str - runtime: bool - - -@dataclass(frozen=True) -class VtableDumpRequest: - identifier: str - slot_limit: int - - -def _parse_list(params: Mapping[str, Any]) -> ClassListRequest: - query = optional_str(params.get("query")) - pattern = optional_str(params.get("pattern")) - return ClassListRequest( - query=query, - pattern=pattern, - glob=bool(params.get("glob")), - regex=bool(params.get("regex")), - ignore_case=bool(params.get("ignore_case")), - ) - - -def _parse_candidates(params: Mapping[str, Any]) -> ClassCandidatesRequest: - query = str(params.get("pattern") or params.get("query") or "") - kinds = tuple(str(item) for item in (params.get("kinds") or []) if str(item)) - return ClassCandidatesRequest( - query=query, - kinds=kinds, - glob=bool(params.get("glob")), - regex=bool(params.get("regex")), - ignore_case=bool(params.get("ignore_case")) or params.get("query") not in (None, ""), - ) - - -def _parse_name(params: Mapping[str, Any]) -> ClassNameRequest: - return ClassNameRequest(name=require_str(params.get("name"), field="class name")) - - -def _parse_fields(params: Mapping[str, Any]) -> ClassFieldsRequest: - return ClassFieldsRequest( - name=require_str(params.get("name"), field="class name"), - derived_only=bool(params.get("derived_only")), - ) - - -def _parse_class_vtable(params: Mapping[str, Any]) -> ClassVtableRequest: - return ClassVtableRequest( - name=require_str(params.get("name"), field="class name"), - runtime=bool(params.get("runtime")), - ) - - -def _parse_vtable_dump(params: Mapping[str, Any]) -> VtableDumpRequest: - identifier = require_str(params.get("identifier"), field="vtable identifier") - slot_limit = optional_param_int(params, "slot_limit", label="vtable slot limit", minimum=1) or 64 - return VtableDumpRequest(identifier=identifier, slot_limit=slot_limit) - - -def _class_graph(runtime: IdaRuntime) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]: - rows = runtime.list_named_classes() - classes = {row["name"]: row for row in rows} - children: dict[str, list[str]] = {name: [] for name in classes} - for name, row in classes.items(): - for base_name in row.get("bases") or []: - if base_name in children: - children[base_name].append(name) - for names in children.values(): - names.sort(key=str.lower) - return classes, children - - -def _member_size_bytes(member) -> int | None: - size_bits = member.size - return size_bits // 8 if size_bits else None - - -def _base_tif(runtime: IdaRuntime, member): - base_name = member.type.get_type_name() or member.type.dstr() - return runtime.find_named_type(base_name or "") - - -def _iter_udt_members(runtime: IdaRuntime, tif, *, expand_bases: bool, base_offset_bits: int = 0): - for member in runtime.udt_members(tif): - offset_bits = base_offset_bits + int(member.offset) - if runtime.member_has(member, "is_baseclass"): - if expand_bases: - base_tif = _base_tif(runtime, member) - if base_tif is not None: - yield from _iter_udt_members( - runtime, - base_tif, - expand_bases=True, - base_offset_bits=offset_bits, - ) - continue - yield offset_bits, member - - -def _walk_graph(start: list[str], edges: dict[str, list[str]], *, known: set[str]) -> list[str]: - queue = deque(start) - seen: set[str] = set() - rows: list[str] = [] - while queue: - current = queue.popleft() - if current in seen or current not in known: - continue - seen.add(current) - rows.append(current) - queue.extend(edges.get(current, [])) - return rows - - -def _vtable_header(runtime: IdaRuntime, table_ea: int, symbol_name: str, *, ptr_size: int): - ida_name = runtime.mod("ida_name") - if _looks_like_itanium_vtable(runtime, table_ea, symbol_name): - typeinfo_ea = runtime.read_pointer(table_ea + ptr_size) - return ( - "itanium", - table_ea + (ptr_size * 2), - [ - { - "index": 0, - "address": hex(table_ea), - "value": hex(runtime.read_pointer(table_ea)), - "name": "offset_to_top", - }, - { - "index": 1, - "address": hex(table_ea + ptr_size), - "value": hex(typeinfo_ea), - "name": "typeinfo", - "symbol": ida_name.get_name(typeinfo_ea) or "", - "demangled": runtime.demangle_name(ida_name.get_name(typeinfo_ea) or ""), - }, - ], - ) - if symbol_name.startswith("??_7"): - return "msvc", table_ea, [] - return "unknown", table_ea, [] - - -def _flatten_class_fields(runtime: IdaRuntime, tif, *, derived_only: bool) -> list[dict[str, Any]]: - fields: list[dict[str, Any]] = [] - for offset_bits, member in _iter_udt_members(runtime, tif, expand_bases=not derived_only): - if runtime.member_has(member, "is_method"): - continue - fields.append( - { - "name": member.name or "", - "offset_bits": offset_bits, - "offset": offset_bits // 8, - "size_bits": member.size, - "size": _member_size_bytes(member), - "type": member.type.dstr() or runtime.tinfo_decl(member.type, multi=False), - "is_vftable": runtime.member_has(member, "is_vftable"), - } - ) - fields.sort(key=lambda item: (item["offset"], item["name"])) - return fields - - -def _vtable_members(runtime: IdaRuntime, vtable_tif) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for index, (offset_bits, member) in enumerate(_iter_udt_members(runtime, vtable_tif, expand_bases=True)): - rows.append( - { - "index": index, - "offset_bits": offset_bits, - "offset": offset_bits // 8, - "slot": runtime.vtable_slot(offset_bits), - "name": member.name or "", - "type": member.type.dstr() or runtime.tinfo_decl(member.type, multi=False), - "comment": member.cmt or "", - } - ) - rows.sort(key=lambda item: (item["slot"], item["name"])) - return rows - - -def _looks_like_itanium_vtable(runtime: IdaRuntime, ea: int, symbol_name: str) -> bool: - if is_vtable_symbol_name(symbol_name) and not symbol_name.startswith("??_7"): - return True - ida_name = runtime.mod("ida_name") - ptr_size = runtime.pointer_size() - first = runtime.read_pointer(ea) - second = runtime.read_pointer(ea + ptr_size) - second_name = ida_name.get_name(second) or "" - return first == 0 and str(second_name).startswith(("__ZTI", "_ZTI")) - - -def _resolve_vtable(runtime: IdaRuntime, identifier: str) -> tuple[int, str, str | None]: - ida_name = runtime.mod("ida_name") - table_ea = runtime.resolve_address(identifier) - symbol_name = ida_name.get_name(table_ea) or str(identifier) - return table_ea, symbol_name, runtime.demangle_name(symbol_name) - - -def _runtime_vtable_member(runtime: IdaRuntime, entry_ea: int, slot: int) -> tuple[dict[str, Any] | None, str | None]: - ida_name = runtime.mod("ida_name") - ida_bytes = runtime.mod("ida_bytes") - ida_funcs = runtime.mod("ida_funcs") - target = runtime.read_pointer(entry_ea) - if target == 0: - return None, "null_target" - name = ida_name.get_name(target) or "" - if is_rtti_symbol_name(name): - return None, "rtti_boundary" - flags = ida_bytes.get_flags(target) - is_code = bool(ida_bytes.is_code(flags)) or ida_funcs.get_func(target) is not None - if not is_code: - return None, "non_function_target" - return ( - { - "slot": slot, - "entry_address": hex(entry_ea), - "target": hex(target), - "name": name, - "demangled": runtime.demangle_name(name), - "is_code": is_code, - }, - None, - ) - - -def _runtime_vtable_members(runtime: IdaRuntime, slot_ea: int, *, slot_limit: int, ptr_size: int): - rows: list[dict[str, Any]] = [] - for slot in range(max(1, slot_limit)): - entry_ea = slot_ea + slot * ptr_size - member, stop_reason = _runtime_vtable_member(runtime, entry_ea, slot) - if member is None: - return rows, stop_reason - rows.append(member) - return rows, "slot_limit" - - -def _raw_vtable_dump(runtime: IdaRuntime, identifier: str, *, slot_limit: int = 64) -> dict[str, Any]: - table_ea, symbol_name, demangled = _resolve_vtable(runtime, identifier) - ptr_size = runtime.pointer_size() - abi, slot_ea, header = _vtable_header(runtime, table_ea, symbol_name, ptr_size=ptr_size) - members, stop_reason = _runtime_vtable_members(runtime, slot_ea, slot_limit=slot_limit, ptr_size=ptr_size) - return { - "identifier": identifier, - "kind": "raw_vtable", - "abi": abi, - "table_address": hex(table_ea), - "slot_address": hex(slot_ea), - "symbol": symbol_name, - "demangled_symbol": demangled, - "header": header, - "slot_count": len(members), - "members": members, - "stop_reason": stop_reason, - } - - -def _raise_non_materialized_class_error(runtime: IdaRuntime, name: str, tif) -> None: - kind = runtime.classify_tinfo(tif) - if kind in {"struct", "union"}: - raise IdaOperationError( - f"type `{name}` exists as a {kind}, but is not class-materialized in local types; " - + _class_materialization_hint(runtime, name) - ) - raise IdaOperationError(f"type `{name}` exists as `{kind}`, but is not class-materialized in local types") - - -def _class_materialization_hint(runtime: IdaRuntime, name: str) -> str: - hints = [ - f"try `type show {name}`", - f"`type class candidates --query {name}`", - "then import a concrete class layout with `type declare --replace`", - ] - evidence = _symbol_evidence(runtime, name) - if evidence: - hints.append("symbol evidence: " + ", ".join(evidence)) - return "; ".join(hints) - - -def _symbol_evidence(runtime: IdaRuntime, name: str) -> list[str]: - try: - symbols = runtime.find_symbols(query=name) - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - return [] - if not isinstance(symbols, list): - return [] - - vtable_count = 0 - typeinfo_count = 0 - function_count = 0 - for item in symbols: - if not isinstance(item, dict): - continue - symbol_name = str(item.get("name") or "") - kind = classify_symbol_kind(symbol_name, is_function=bool(item.get("is_function"))) - if kind == "vtable_symbol": - vtable_count += 1 - elif kind in {"typeinfo_symbol", "typeinfo_name_symbol"}: - typeinfo_count += 1 - elif kind == "function_symbol": - function_count += 1 - - evidence: list[str] = [] - if vtable_count: - evidence.append(f"{vtable_count} vtable symbol(s)") - if typeinfo_count: - evidence.append(f"{typeinfo_count} RTTI symbol(s)") - if function_count: - evidence.append(f"{function_count} function symbol(s)") - return evidence - - -def _local_type_candidate_rows( - runtime: IdaRuntime, - query: str, - kind_filter: set[str], - seen: set[tuple[str, str, str]], - *, - glob: bool, - regex: bool, - ignore_case: bool, -) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for item in runtime.list_named_types(pattern=query or None, glob=glob, regex=regex, ignore_case=ignore_case): - key = ("local_type", str(item.get("name") or ""), "") - if key in seen or (kind_filter and "local_type" not in kind_filter): - continue - seen.add(key) - rows.append( - { - "kind": "local_type", - "name": item.get("name"), - "decl": item.get("decl"), - "type_kind": item.get("kind"), - } - ) - return rows - - -def _symbol_candidate_rows( - runtime: IdaRuntime, - query: str, - kind_filter: set[str], - seen: set[tuple[str, str, str]], - *, - glob: bool, - regex: bool, - ignore_case: bool, -) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for item in runtime.find_symbols(pattern=query or None, glob=glob, regex=regex, ignore_case=ignore_case): - name = str(item.get("name") or "") - kind = classify_symbol_kind(name, is_function=bool(item.get("is_function"))) - if kind_filter and kind not in kind_filter: - continue - key = (kind, name, str(item.get("address") or "")) - if key in seen: - continue - seen.add(key) - rows.append({"kind": kind, "name": name, "address": item.get("address"), "demangled": item.get("demangled")}) - return rows - - -def _runtime_class_vtable(runtime: IdaRuntime, name: str, tif) -> dict[str, Any] | None: - identifier = runtime.class_runtime_vtable_identifier(tif, name=name) - if identifier is None: - return None - return _raw_vtable_dump(runtime, identifier) - - -def _require_class_tinfo(runtime: IdaRuntime, name: str): - tif = runtime.find_named_type(name) - if tif is None: - raise IdaOperationError(f"class not found: {name}") - if not runtime.is_class_tinfo(tif): - _raise_non_materialized_class_error(runtime, name, tif) - return tif - - -def _class_list(context: OperationContext, request: ClassListRequest) -> list[dict[str, Any]]: - return context.runtime.list_named_classes( - query=request.query, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ) - - -def _class_candidates(context: OperationContext, request: ClassCandidatesRequest) -> list[dict[str, Any]]: - runtime = context.runtime - kind_filter = set(request.kinds) - seen: set[tuple[str, str, str]] = set() - rows = _local_type_candidate_rows( - runtime, - request.query, - kind_filter, - seen, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ) - rows.extend( - _symbol_candidate_rows( - runtime, - request.query, - kind_filter, - seen, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ) - ) - rows.sort(key=lambda item: (str(item.get("kind") or ""), str(item.get("name") or "").lower())) - return rows - - -def _class_show(context: OperationContext, request: ClassNameRequest) -> dict[str, Any]: - runtime = context.runtime - tif = _require_class_tinfo(runtime, request.name) - payload = dict(runtime.class_summary(tif, name=request.name, decl_multi=True)) - payload["members"] = _flatten_class_fields(runtime, tif, derived_only=False) - return payload - - -def _class_hierarchy(context: OperationContext, request: ClassNameRequest) -> dict[str, Any]: - runtime = context.runtime - name = request.name - classes, children = _class_graph(runtime) - if name not in classes: - tif = runtime.find_named_type(name) - if tif is not None and not runtime.is_class_tinfo(tif): - _raise_non_materialized_class_error(runtime, name, tif) - raise IdaOperationError(f"class not found: {name}") - base_edges = {class_name: row.get("bases") or [] for class_name, row in classes.items()} - known = set(classes) - ancestors = _walk_graph(list(classes[name].get("bases") or []), base_edges, known=known) - descendants = _walk_graph(list(children.get(name, [])), children, known=known) - return { - "name": name, - "bases": classes[name].get("bases") or [], - "derived": children.get(name, []), - "ancestors": ancestors, - "descendants": descendants, - } - - -def _class_fields(context: OperationContext, request: ClassFieldsRequest) -> dict[str, Any]: - runtime = context.runtime - tif = _require_class_tinfo(runtime, request.name) - return { - "name": request.name, - "kind": "class_fields", - "derived_only": request.derived_only, - "fields": _flatten_class_fields(runtime, tif, derived_only=request.derived_only), - } - - -def _class_vtable(context: OperationContext, request: ClassVtableRequest) -> dict[str, Any]: - runtime = context.runtime - tif = _require_class_tinfo(runtime, request.name) - vtable_name = runtime.class_vtable_type_name(tif) - if not vtable_name: - raise IdaOperationError(f"class has no vtable type: {request.name}") - vtable_tif = runtime.get_named_type(vtable_name) - payload = { - "name": request.name, - "kind": "class_vtable", - "vtable_type": vtable_name, - "decl": runtime.tinfo_decl(vtable_tif, name=vtable_name, multi=True), - "members": _vtable_members(runtime, vtable_tif), - } - if request.runtime: - runtime_vtable = _runtime_class_vtable(runtime, request.name, tif) - if runtime_vtable is not None: - payload["runtime_vtable"] = runtime_vtable - return payload - - -def _vtable_dump(context: OperationContext, request: VtableDumpRequest) -> dict[str, Any]: - return _raw_vtable_dump(context.runtime, request.identifier, slot_limit=request.slot_limit) - - -def class_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec(name="class_list", parse=_parse_list, run=_class_list), - OperationSpec(name="class_candidates", parse=_parse_candidates, run=_class_candidates), - OperationSpec(name="class_show", parse=_parse_name, run=_class_show), - OperationSpec(name="class_hierarchy", parse=_parse_name, run=_class_hierarchy), - OperationSpec(name="class_fields", parse=_parse_fields, run=_class_fields), - OperationSpec(name="class_vtable", parse=_parse_class_vtable, run=_class_vtable), - OperationSpec(name="vtable_dump", parse=_parse_vtable_dump, run=_vtable_dump), - ) - - -__all__ = [ - "class_operations", -] diff --git a/src/idac/ops/families/comments.py b/src/idac/ops/families/comments.py deleted file mode 100644 index c7759a1..0000000 --- a/src/idac/ops/families/comments.py +++ /dev/null @@ -1,275 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, Literal - -from ..base import OperationContext, OperationSpec -from ..helpers.params import require_str -from ..preview import PreviewSpec -from ..runtime import IdaOperationError, IdaRuntime - -CommentScope = Literal["line", "function", "anterior", "posterior"] - - -@dataclass(frozen=True) -class CommentLookup: - identifier: str - scope: CommentScope - repeatable: bool - - -@dataclass(frozen=True) -class CommentChange: - identifier: str - text: str - scope: CommentScope - repeatable: bool - - -@dataclass(frozen=True) -class CommentView: - address: str - scope: CommentScope - repeatable: bool - comment: str | None - - -@dataclass(frozen=True) -class CommentMutationResult: - address: str - scope: CommentScope - repeatable: bool - comment: str | None - changed: bool - - -def _normalize_comment_text(text: str | None) -> str | None: - return None if text in (None, "") else str(text) - - -def _parse_scope(params: Mapping[str, Any]) -> CommentScope: - scope = str(params.get("scope") or "line").strip().lower() - if scope not in {"line", "function", "anterior", "posterior"}: - raise IdaOperationError(f"unsupported comment scope: {scope}") - return scope - - -def _parse_repeatable(params: Mapping[str, Any], *, scope: CommentScope) -> bool: - repeatable = bool(params.get("repeatable")) - if repeatable and scope in {"anterior", "posterior"}: - raise IdaOperationError("repeatable comments are only supported for line or function scope") - return repeatable - - -def _function_for_comment(runtime: IdaRuntime, ea: int): - func = runtime.ida_funcs.get_func(ea) - if func is None: - raise IdaOperationError(f"no function contains address {hex(ea)}") - return func - - -def _extra_anchor(runtime: IdaRuntime, scope: CommentScope) -> int: - ida_lines = runtime.mod("ida_lines") - if scope == "anterior": - return ida_lines.E_PREV - if scope == "posterior": - return ida_lines.E_NEXT - raise IdaOperationError(f"extra comments are unsupported for scope: {scope}") - - -def _read_extra_comment(runtime: IdaRuntime, ea: int, *, scope: CommentScope) -> str | None: - ida_lines = runtime.mod("ida_lines") - index = _extra_anchor(runtime, scope) - lines: list[str] = [] - while True: - line = ida_lines.get_extra_cmt(ea, index) - if line is None: - break - lines.append(str(line)) - index += 1 - return _normalize_comment_text(None if not lines else "\n".join(lines)) - - -def _extra_comment_lines(text: str | None) -> list[str]: - rendered = "" if text is None else str(text) - return [] if rendered == "" else rendered.splitlines() - - -def _set_extra_comment_lines(runtime: IdaRuntime, ea: int, *, scope: CommentScope, lines: list[str]) -> None: - ida_lines = runtime.mod("ida_lines") - anchor = _extra_anchor(runtime, scope) - ida_lines.delete_extra_cmts(ea, anchor) - for index, line in enumerate(lines): - if not ida_lines.update_extra_cmt(ea, anchor + index, line): - raise IdaOperationError(f"failed to set {scope} comment at {hex(ea)}") - - -def _write_extra_comment(runtime: IdaRuntime, ea: int, *, scope: CommentScope, text: str | None) -> None: - before = _read_extra_comment(runtime, ea, scope=scope) - before_lines = _extra_comment_lines(before) - new_lines = _extra_comment_lines(text) - try: - _set_extra_comment_lines(runtime, ea, scope=scope, lines=new_lines) - except Exception as exc: - try: - _set_extra_comment_lines(runtime, ea, scope=scope, lines=before_lines) - except Exception as restore_exc: - raise IdaOperationError( - f"failed to restore {scope} comment at {hex(ea)} after update failure" - ) from restore_exc - raise IdaOperationError(f"failed to set {scope} comment at {hex(ea)}") from exc - - -def _read_comment(runtime: IdaRuntime, request: CommentLookup | CommentChange) -> CommentView: - ida_bytes = runtime.mod("ida_bytes") - ea = runtime.resolve_address(request.identifier) - if request.scope == "line": - comment = _normalize_comment_text(ida_bytes.get_cmt(ea, request.repeatable)) - return CommentView(address=hex(ea), scope=request.scope, repeatable=request.repeatable, comment=comment) - if request.scope == "function": - func = _function_for_comment(runtime, ea) - comment = _normalize_comment_text(runtime.ida_funcs.get_func_cmt(func, request.repeatable)) - return CommentView( - address=hex(func.start_ea), - scope=request.scope, - repeatable=request.repeatable, - comment=comment, - ) - return CommentView( - address=hex(ea), - scope=request.scope, - repeatable=False, - comment=_read_extra_comment(runtime, ea, scope=request.scope), - ) - - -def _write_comment(runtime: IdaRuntime, request: CommentChange, *, text: str) -> CommentMutationResult: - ida_bytes = runtime.mod("ida_bytes") - ea = runtime.resolve_address(request.identifier) - if request.scope == "line": - if not ida_bytes.set_cmt(ea, text, request.repeatable): - raise IdaOperationError(f"failed to set comment at {hex(ea)}") - return CommentMutationResult( - address=hex(ea), - scope=request.scope, - repeatable=request.repeatable, - comment=_normalize_comment_text(ida_bytes.get_cmt(ea, request.repeatable)), - changed=True, - ) - if request.scope == "function": - func = _function_for_comment(runtime, ea) - if not runtime.ida_funcs.set_func_cmt(func, text, request.repeatable): - raise IdaOperationError(f"failed to set function comment at {hex(func.start_ea)}") - return CommentMutationResult( - address=hex(func.start_ea), - scope=request.scope, - repeatable=request.repeatable, - comment=_normalize_comment_text(runtime.ida_funcs.get_func_cmt(func, request.repeatable)), - changed=True, - ) - _write_extra_comment(runtime, ea, scope=request.scope, text=text) - return CommentMutationResult( - address=hex(ea), - scope=request.scope, - repeatable=False, - comment=_read_extra_comment(runtime, ea, scope=request.scope), - changed=True, - ) - - -def _comment_view(context: OperationContext, request: CommentLookup | CommentChange) -> CommentView: - return _read_comment(context.runtime, request) - - -def _parse_lookup(params: Mapping[str, Any]) -> CommentLookup: - identifier = require_str(params.get("address"), field="address") - scope = _parse_scope(params) - return CommentLookup(identifier=identifier, scope=scope, repeatable=_parse_repeatable(params, scope=scope)) - - -def _parse_change(params: Mapping[str, Any]) -> CommentChange: - request = _parse_lookup(params) - return CommentChange( - identifier=request.identifier, - text=str(params.get("text") or ""), - scope=request.scope, - repeatable=request.repeatable, - ) - - -def _set_comment(context: OperationContext, request: CommentChange) -> CommentMutationResult: - return _write_comment(context.runtime, request, text=request.text) - - -def _delete_comment(context: OperationContext, request: CommentLookup) -> CommentMutationResult: - return _write_comment( - context.runtime, - CommentChange( - identifier=request.identifier, - text="", - scope=request.scope, - repeatable=request.repeatable, - ), - text="", - ) - - -def _restore_comment( - context: OperationContext, - request: CommentLookup | CommentChange, - before: CommentView, - result: CommentMutationResult, -) -> None: - del result - _write_comment( - context.runtime, - CommentChange( - identifier=request.identifier, - text="" if before.comment is None else before.comment, - scope=before.scope, - repeatable=before.repeatable, - ), - text="" if before.comment is None else before.comment, - ) - - -def comment_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="comment_get", - parse=_parse_lookup, - run=_comment_view, - ), - OperationSpec( - name="comment_set", - parse=_parse_change, - run=_set_comment, - mutating=True, - preview=PreviewSpec( - capture_before=_comment_view, - capture_after=_comment_view, - rollback=_restore_comment, - ), - ), - OperationSpec( - name="comment_delete", - parse=_parse_lookup, - run=_delete_comment, - mutating=True, - preview=PreviewSpec( - capture_before=_comment_view, - capture_after=_comment_view, - rollback=_restore_comment, - ), - ), - ) - - -__all__ = [ - "CommentChange", - "CommentLookup", - "CommentMutationResult", - "CommentView", - "comment_operations", -] diff --git a/src/idac/ops/families/database.py b/src/idac/ops/families/database.py deleted file mode 100644 index 47ccf62..0000000 --- a/src/idac/ops/families/database.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..runtime import IdaOperationError - - -@dataclass(frozen=True) -class DatabaseInfoRequest: - pass - - -@dataclass(frozen=True) -class DatabaseInfoResult: - path: str - database_path: str - module: str - processor: str - bits: int - base: str - min_ea: str - max_ea: str - main_ea: str | None - start_ea: str | None - entry_ea: str | None - - -@dataclass(frozen=True) -class DatabaseSaveRequest: - path: str | None = None - - -@dataclass(frozen=True) -class DatabaseSaveResult: - saved: bool - path: str - - -def _parse_info(_params: Mapping[str, Any]) -> DatabaseInfoRequest: - return DatabaseInfoRequest() - - -def _parse_save(params: Mapping[str, Any]) -> DatabaseSaveRequest: - path = str(params.get("path") or "").strip() - return DatabaseSaveRequest(path=path or None) - - -def _database_info(context: OperationContext, request: DatabaseInfoRequest) -> DatabaseInfoResult: - del request - runtime = context.runtime - ida_entry = runtime.mod("ida_entry") - ida_ida = runtime.mod("ida_ida") - ida_loader = runtime.mod("ida_loader") - idaapi = runtime.mod("idaapi") - entry_ord = ida_entry.get_entry_ordinal(0) - entry_ea = ida_entry.get_entry(entry_ord) if entry_ord != idaapi.BADADDR else idaapi.BADADDR - main_ea = ida_ida.inf_get_main() - start_ea = ida_ida.inf_get_start_ea() - return DatabaseInfoResult( - path=idaapi.get_input_file_path() or "", - database_path=ida_loader.get_path(ida_loader.PATH_TYPE_IDB) or "", - module=idaapi.get_root_filename() or "", - processor=ida_ida.inf_get_procname(), - bits=runtime.database_bits(), - base=hex(idaapi.get_imagebase()), - min_ea=hex(ida_ida.inf_get_min_ea()), - max_ea=hex(ida_ida.inf_get_max_ea()), - main_ea=None if main_ea == idaapi.BADADDR else hex(main_ea), - start_ea=None if start_ea == idaapi.BADADDR else hex(start_ea), - entry_ea=None if entry_ea == idaapi.BADADDR else hex(entry_ea), - ) - - -def _database_save(context: OperationContext, request: DatabaseSaveRequest) -> DatabaseSaveResult: - runtime = context.runtime - ida_loader = runtime.mod("ida_loader") - save_path = request.path or ida_loader.get_path(ida_loader.PATH_TYPE_IDB) or "" - if not save_path: - raise IdaOperationError("could not resolve database path") - if not bool(ida_loader.save_database(save_path, 0)): - raise IdaOperationError(f"failed to save database: {save_path}") - return DatabaseSaveResult(saved=True, path=save_path) - - -def database_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="database_info", - parse=_parse_info, - run=_database_info, - ), - OperationSpec( - name="db_save", - parse=_parse_save, - run=_database_save, - mutating=True, - ), - ) - - -__all__ = [ - "DatabaseInfoRequest", - "DatabaseInfoResult", - "DatabaseSaveRequest", - "DatabaseSaveResult", - "database_operations", -] diff --git a/src/idac/ops/families/functions.py b/src/idac/ops/families/functions.py deleted file mode 100644 index a44b175..0000000 --- a/src/idac/ops/families/functions.py +++ /dev/null @@ -1,690 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.matching import pattern_from_params, text_matches -from ..helpers.params import optional_param_int, optional_str, require_str -from ..runtime import IdaOperationError, IdaRuntime, _ea_text, _strip_tags - - -@dataclass(frozen=True) -class FunctionListRequest: - pattern: str - glob: bool - regex: bool - ignore_case: bool - segment: str | None - limit: int | None - demangle: bool - - -@dataclass(frozen=True) -class FunctionIdentifierRequest: - identifier: str - - -@dataclass(frozen=True) -class DisasmRangeRequest: - start: str - end: str - - -@dataclass(frozen=True) -class DecompileRequest: - identifier: str - no_cache: bool - - -@dataclass(frozen=True) -class CtreeRequest: - identifier: str - level: str - maturity: str - - -@dataclass(frozen=True) -class FunctionListEntry: - name: str - display_name: str - render_name: str - address: str - section: str - size: int - - -@dataclass(frozen=True) -class FunctionShowResult: - name: str - display_name: str - address: str - size: int - prototype: str - flags: str - - -@dataclass(frozen=True) -class FrameXref: - address: str - operand: int - type: int - access: str - - -@dataclass(frozen=True) -class FrameMember: - index: int - name: str - offset: int - end_offset: int - size: int - type: str - kind: str - is_special: bool - is_arg: bool - fp_offset: int | None = None - xrefs: tuple[FrameXref, ...] = () - xref_count: int | None = None - - -@dataclass(frozen=True) -class FunctionFrameResult: - function: str - address: str - frame_size: int - local_size: int - saved_registers_size: int - argument_size: int - members: tuple[FrameMember, ...] - - -@dataclass(frozen=True) -class FunctionStackvarsResult: - function: str - address: str - stackvars: tuple[FrameMember, ...] - - -@dataclass(frozen=True) -class CallerEdge: - call_site: str - caller: str - caller_address: str - - -@dataclass(frozen=True) -class CalleeEdge: - call_site: str - callee: str - callee_address: str - - -@dataclass(frozen=True) -class IncomingEdgesResult: - function: str - address: str - edges: tuple[CallerEdge, ...] - - -@dataclass(frozen=True) -class OutgoingEdgesResult: - function: str - address: str - edges: tuple[CalleeEdge, ...] - - -@dataclass(frozen=True) -class TextResult: - text: str - - -@dataclass(frozen=True) -class CtreeNode: - kind: str - depth: int - op: str - ea: str | None - text: str - - -@dataclass(frozen=True) -class CtreeResult: - function: str - address: str - level: str - nodes: tuple[CtreeNode, ...] - text: str - - -@dataclass(frozen=True) -class MicrocodeResult: - function: str - address: str - level: str - maturity: str - lines: tuple[str, ...] - text: str - - -def _function_header(runtime: IdaRuntime, func) -> tuple[str, str]: - return runtime.function_identity(func) - - -def _require_identifier(params: Mapping[str, Any], *, key: str = "identifier") -> str: - return require_str(params.get(key), field="address or identifier") - - -def _parse_function_list(params: Mapping[str, Any]) -> FunctionListRequest: - pattern, glob, regex, ignore_case = pattern_from_params(params) - return FunctionListRequest( - pattern=pattern, - glob=glob, - regex=regex, - ignore_case=ignore_case, - segment=optional_str(params.get("segment")), - limit=optional_param_int(params, "limit", label="function list limit", minimum=1), - demangle=bool(params.get("demangle")), - ) - - -def _function_list(context: OperationContext, request: FunctionListRequest) -> tuple[FunctionListEntry, ...]: - runtime = context.runtime - ranges = () if request.segment is None else runtime.resolve_segment_ranges(request.segment) - rows: list[FunctionListEntry] = [] - for ea in runtime.idautils.Functions(): - if ranges and not runtime.ea_in_ranges(ea, ranges): - continue - name = runtime.function_name(ea) - display_name = runtime.display_function_name(ea, demangle=True) - match_name = display_name if request.demangle else name - if request.pattern and not text_matches( - match_name, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ): - continue - func = runtime.ida_funcs.get_func(ea) - segment = runtime.ida_segment.getseg(ea) - rows.append( - FunctionListEntry( - name=name, - display_name=display_name, - render_name=display_name if request.demangle else name, - address=hex(ea), - section="" if segment is None else runtime._segment_name(segment), - size=0 if func is None else func.end_ea - func.start_ea, - ) - ) - if request.limit is not None and len(rows) >= request.limit: - break - return tuple(rows) - - -def _parse_identifier(params: Mapping[str, Any]) -> FunctionIdentifierRequest: - return FunctionIdentifierRequest(identifier=_require_identifier(params)) - - -def _parse_disasm_range(params: Mapping[str, Any]) -> DisasmRangeRequest: - return DisasmRangeRequest( - start=require_str(params.get("start"), field="range start"), - end=require_str(params.get("end"), field="range end"), - ) - - -def _function_show(context: OperationContext, request: FunctionIdentifierRequest) -> FunctionShowResult: - runtime = context.runtime - func = runtime.resolve_function(request.identifier) - ida_typeinf = runtime.ida_typeinf - name, address = _function_header(runtime, func) - ea = func.start_ea - return FunctionShowResult( - name=name, - display_name=runtime.display_function_name(ea, demangle=True), - address=address, - size=func.end_ea - func.start_ea, - prototype=ida_typeinf.print_type(ea, ida_typeinf.PRTYPE_1LINE) or "", - flags=hex(func.flags), - ) - - -def _frame_members( - runtime: IdaRuntime, - func, - *, - include_special: bool, - include_xrefs: bool, - query: str | None = None, -) -> tuple[FrameMember, ...]: - frame_tif = runtime.ida_typeinf.tinfo_t() - if not frame_tif.get_func_frame(func): - raise IdaOperationError(f"function has no frame: {hex(func.start_ea)}") - - xref_names = { - runtime.ida_xref.dr_R: "read", - runtime.ida_xref.dr_W: "write", - } - members: list[FrameMember] = [] - for index, frame_udm in enumerate(frame_tif.iter_struct()): - offset = frame_udm.begin() // 8 - end_offset = frame_udm.end() // 8 - tid = frame_tif.get_udm_tid(index) - local_size = func.frsize - saved_regs_size = func.frregs - special_from_layout = local_size <= offset < (local_size + saved_regs_size) - is_special = bool(runtime.ida_frame.is_special_frame_member(tid)) or special_from_layout - if is_special and not include_special: - continue - name = str(frame_udm.name or f"") - if query and not text_matches(name, pattern=query, ignore_case=True): - continue - is_arg = False if is_special else bool(runtime.ida_frame.is_funcarg_off(func, offset)) - xrefs: tuple[FrameXref, ...] = () - xref_count: int | None = None - if include_xrefs: - xreflist = runtime.ida_frame.xreflist_t() - runtime.ida_frame.build_stkvar_xrefs(xreflist, func, offset, end_offset) - xref_rows: list[FrameXref] = [] - for item_index in range(xreflist.size()): - item = xreflist[item_index] - xref_rows.append( - FrameXref( - address=hex(item.ea), - operand=item.opnum, - type=item.type, - access=xref_names.get(item.type, "unknown"), - ) - ) - xrefs = tuple(xref_rows) - xref_count = len(xref_rows) - members.append( - FrameMember( - index=index, - name=name, - offset=offset, - end_offset=end_offset, - size=max(0, end_offset - offset), - type=runtime.tinfo_decl(frame_udm.type, multi=False), - kind="special" if is_special else "arg" if is_arg else "local", - is_special=is_special, - is_arg=is_arg, - fp_offset=None if is_special else runtime.ida_frame.soff_to_fpoff(func, offset), - xrefs=xrefs, - xref_count=xref_count, - ) - ) - members.sort(key=lambda item: (item.offset, item.name.lower())) - return tuple(members) - - -def _function_frame(context: OperationContext, request: FunctionIdentifierRequest) -> FunctionFrameResult: - runtime = context.runtime - func = runtime.resolve_function(request.identifier) - name, address = _function_header(runtime, func) - frame_tif = runtime.ida_typeinf.tinfo_t() - if not frame_tif.get_func_frame(func): - raise IdaOperationError(f"function has no frame: {address}") - return FunctionFrameResult( - function=name, - address=address, - frame_size=frame_tif.get_size(), - local_size=func.frsize, - saved_registers_size=func.frregs, - argument_size=func.argsize, - members=_frame_members(runtime, func, include_special=True, include_xrefs=False), - ) - - -def _function_stackvars(context: OperationContext, request: FunctionIdentifierRequest) -> FunctionStackvarsResult: - runtime = context.runtime - func = runtime.resolve_function(request.identifier) - name, address = _function_header(runtime, func) - return FunctionStackvarsResult( - function=name, - address=address, - stackvars=_frame_members( - runtime, - func, - include_special=False, - include_xrefs=True, - ), - ) - - -def _incoming_edges(runtime: IdaRuntime, func) -> tuple[CallerEdge, ...]: - rows: list[CallerEdge] = [] - seen: set[tuple[int, int]] = set() - target_ea = func.start_ea - flags = runtime.ida_xref.XREF_CODE | runtime.ida_xref.XREF_NOFLOW - for ref in runtime.xrefs_to(target_ea, flags=flags): - if ref.kind != "call": - continue - caller = runtime.ida_funcs.get_func(ref.from_ea) - if caller is None: - continue - caller_ea = caller.start_ea - key = (caller_ea, ref.from_ea) - if key in seen: - continue - seen.add(key) - rows.append( - CallerEdge( - call_site=hex(ref.from_ea), - caller=runtime.function_name(caller_ea), - caller_address=hex(caller_ea), - ) - ) - rows.sort(key=lambda item: (item.caller.lower(), item.call_site)) - return tuple(rows) - - -def _outgoing_edges(runtime: IdaRuntime, func) -> tuple[CalleeEdge, ...]: - rows: list[CalleeEdge] = [] - seen: set[tuple[int, int]] = set() - flags = runtime.ida_xref.XREF_CODE | runtime.ida_xref.XREF_NOFLOW - for item in runtime.ida_funcs.func_item_iterator_t(func).code_items(): - for ref in runtime.xrefs_from(item, flags=flags): - if ref.kind != "call": - continue - callee = runtime.ida_funcs.get_func(ref.to_ea) - if callee is None: - continue - callee_ea = callee.start_ea - key = (item, callee_ea) - if key in seen: - continue - seen.add(key) - rows.append( - CalleeEdge( - call_site=hex(item), - callee=runtime.function_name(callee_ea), - callee_address=hex(callee_ea), - ) - ) - rows.sort(key=lambda item: (item.callee.lower(), item.call_site)) - return tuple(rows) - - -def _function_callers(context: OperationContext, request: FunctionIdentifierRequest) -> IncomingEdgesResult: - runtime = context.runtime - func = runtime.resolve_function(request.identifier) - name, address = _function_header(runtime, func) - return IncomingEdgesResult(function=name, address=address, edges=_incoming_edges(runtime, func)) - - -def _function_callees(context: OperationContext, request: FunctionIdentifierRequest) -> OutgoingEdgesResult: - runtime = context.runtime - func = runtime.resolve_function(request.identifier) - name, address = _function_header(runtime, func) - return OutgoingEdgesResult(function=name, address=address, edges=_outgoing_edges(runtime, func)) - - -def _disasm(context: OperationContext, request: FunctionIdentifierRequest) -> TextResult: - runtime = context.runtime - func_ea = runtime.function_ea(request.identifier) - ida_lines = runtime.mod("ida_lines") - lines = [ - f"{hex(ea)}: {_strip_tags(runtime, ida_lines.generate_disasm_line(ea, 0) or '')}" - for ea in runtime.idautils.FuncItems(func_ea) - ] - return TextResult(text="\n".join(lines)) - - -def _disasm_range(context: OperationContext, request: DisasmRangeRequest) -> TextResult: - runtime = context.runtime - start_ea, end_ea = runtime.resolve_range(start=request.start, end=request.end, require_bounds=True) - ida_bytes = runtime.mod("ida_bytes") - ida_lines = runtime.mod("ida_lines") - ida_idaapi = runtime.mod("ida_idaapi") - flags = getattr(ida_lines, "GENDSM_FORCE_CODE", 0) | getattr(ida_lines, "GENDSM_REMOVE_TAGS", 0) - - heads = getattr(runtime.idautils, "Heads", None) - if callable(heads): - eas = [int(ea) for ea in heads(start_ea, end_ea)] - else: - eas = [] - ea = start_ea - while ea < end_ea: - try: - if ida_bytes.is_head(ida_bytes.get_flags(ea)): - eas.append(ea) - except Exception: - pass - next_ea = ida_bytes.next_head(ea, end_ea) - if next_ea in (None, getattr(ida_idaapi, "BADADDR", -1)) or int(next_ea) <= ea: - break - ea = int(next_ea) - - lines: list[str] = [] - for ea in eas: - text = _strip_tags(runtime, ida_lines.generate_disasm_line(ea, flags) or "") - if text: - lines.append(f"{hex(ea)}: {text}") - return TextResult(text="\n".join(lines)) - - -def _parse_decompile(params: Mapping[str, Any]) -> DecompileRequest: - return DecompileRequest( - identifier=_require_identifier(params), - no_cache=bool(params.get("no_cache")), - ) - - -def _decompile(context: OperationContext, request: DecompileRequest) -> TextResult: - runtime = context.runtime - ida_hexrays = runtime.require_hexrays() - ea = runtime.function_ea(request.identifier) - flags = ida_hexrays.DECOMP_NO_CACHE if request.no_cache else 0 - cfunc = ida_hexrays.decompile(ea, None, flags) if flags else ida_hexrays.decompile(ea) - if cfunc is None: - raise IdaOperationError(f"failed to decompile function at {hex(ea)}") - return TextResult(text=runtime.pseudocode_text(cfunc)) - - -def _parse_ctree(params: Mapping[str, Any]) -> CtreeRequest: - return CtreeRequest( - identifier=_require_identifier(params), - level=str(params.get("level") or "ctree").lower(), - maturity=str(params.get("maturity") or "generated").lower(), - ) - - -def _ctree_rows(runtime: IdaRuntime, cfunc) -> tuple[CtreeNode, ...]: - ida_hexrays = runtime.require_hexrays() - rows: list[CtreeNode] = [] - - class Visitor(ida_hexrays.ctree_visitor_t): - def __init__(self) -> None: - super().__init__(ida_hexrays.CV_FAST | ida_hexrays.CV_PARENTS) - - def _append(self, kind: str, node) -> int: - rows.append( - CtreeNode( - kind=kind, - depth=max(0, len(self.parents) - 1), - op=node.opname, - ea=_ea_text(runtime, node.ea), - text=_strip_tags(runtime, node.print1(cfunc)), - ) - ) - return 0 - - def visit_insn(self, insn): - return self._append("insn", insn) - - def visit_expr(self, expr): - return self._append("expr", expr) - - visitor = Visitor() - visitor.apply_to(cfunc.body, None) - return tuple(rows) - - -def _render_ctree_text(nodes: tuple[CtreeNode, ...]) -> str: - return "\n".join( - f"{' ' * int(node.depth)}{node.kind}:{node.op}" - + (f" @{node.ea}" if node.ea else "") - + (f" {node.text}" if node.text else "") - for node in nodes - ) - - -def _maturity_value(runtime: IdaRuntime, name: str) -> int: - ida_hexrays = runtime.require_hexrays() - attr_names = { - "generated": "MMAT_GENERATED", - "preoptimized": "MMAT_PREOPTIMIZED", - "locopt": "MMAT_LOCOPT", - "calls": "MMAT_CALLS", - "glbopt1": "MMAT_GLBOPT1", - "glbopt2": "MMAT_GLBOPT2", - "glbopt3": "MMAT_GLBOPT3", - "lvars": "MMAT_LVARS", - } - try: - return getattr(ida_hexrays, attr_names[name]) - except KeyError as exc: - raise IdaOperationError(f"unsupported microcode maturity: {name}") from exc - - -def _microcode_lines(runtime: IdaRuntime, func, maturity: str) -> tuple[str, ...]: - ida_hexrays = runtime.require_hexrays() - maturity_value = _maturity_value(runtime, maturity) - - class Printer(ida_hexrays.vd_printer_t): - def __init__(self) -> None: - super().__init__() - self.lines: list[str] = [] - - def _print(self, indent, line): - prefix = " " * int(indent or 0) - rendered = _strip_tags(runtime, line).rstrip() - self.lines.append(f"{prefix}{rendered}".rstrip()) - return 0 - - mbr = ida_hexrays.mba_ranges_t() - mbr.ranges.push_back(runtime.ida_range.range_t(func.start_ea, func.end_ea)) - hf = ida_hexrays.hexrays_failure_t() - mba = ida_hexrays.gen_microcode( - mbr, - hf, - None, - ida_hexrays.DECOMP_WARNINGS | ida_hexrays.DECOMP_NO_CACHE, - maturity_value, - ) - if mba is None: - raise IdaOperationError(f"failed to generate microcode: {hf.desc()}") - printer = Printer() - mba._print(printer) - return tuple(line for line in printer.lines if line) - - -def _ctree(context: OperationContext, request: CtreeRequest) -> CtreeResult | MicrocodeResult: - runtime = context.runtime - func = runtime.resolve_function(request.identifier) - name, address = _function_header(runtime, func) - if request.level == "ctree": - cfunc = runtime.require_hexrays().decompile(func.start_ea) - if cfunc is None: - raise IdaOperationError(f"failed to decompile function at {address}") - nodes = _ctree_rows(runtime, cfunc) - return CtreeResult( - function=name, - address=address, - level=request.level, - nodes=nodes, - text=_render_ctree_text(nodes), - ) - if request.level == "micro": - lines = _microcode_lines(runtime, func, request.maturity) - return MicrocodeResult( - function=name, - address=address, - level=request.level, - maturity=request.maturity, - lines=lines, - text="\n".join(lines), - ) - raise IdaOperationError(f"unsupported ctree level: {request.level}") - - -def function_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="function_list", - parse=_parse_function_list, - run=_function_list, - ), - OperationSpec( - name="function_show", - parse=_parse_identifier, - run=_function_show, - ), - OperationSpec( - name="function_frame", - parse=_parse_identifier, - run=_function_frame, - ), - OperationSpec( - name="function_stackvars", - parse=_parse_identifier, - run=_function_stackvars, - ), - OperationSpec( - name="function_callers", - parse=_parse_identifier, - run=_function_callers, - ), - OperationSpec( - name="function_callees", - parse=_parse_identifier, - run=_function_callees, - ), - OperationSpec( - name="disasm", - parse=_parse_identifier, - run=_disasm, - ), - OperationSpec( - name="disasm_range", - parse=_parse_disasm_range, - run=_disasm_range, - ), - OperationSpec( - name="decompile", - parse=_parse_decompile, - run=_decompile, - ), - OperationSpec( - name="ctree", - parse=_parse_ctree, - run=_ctree, - ), - ) - - -__all__ = [ - "CalleeEdge", - "CallerEdge", - "CtreeNode", - "CtreeRequest", - "CtreeResult", - "DecompileRequest", - "DisasmRangeRequest", - "FrameMember", - "FrameXref", - "FunctionFrameResult", - "FunctionIdentifierRequest", - "FunctionListEntry", - "FunctionListRequest", - "FunctionShowResult", - "FunctionStackvarsResult", - "IncomingEdgesResult", - "MicrocodeResult", - "OutgoingEdgesResult", - "TextResult", - "function_operations", -] diff --git a/src/idac/ops/families/locals.py b/src/idac/ops/families/locals.py deleted file mode 100644 index 3386522..0000000 --- a/src/idac/ops/families/locals.py +++ /dev/null @@ -1,823 +0,0 @@ -from __future__ import annotations - -import contextlib -import re -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.params import optional_param_int, parse_int_text, require_str -from ..preview import PreviewSpec -from ..runtime import ( - IdaOperationError, - IdaRuntime, - is_recoverable_ida_error, - suppress_recoverable_ida_errors, -) - - -@dataclass(frozen=True) -class LocalSelector: - name: str | None = None - local_id: str | None = None - index: int | None = None - - def stable_selector(self) -> tuple[str, Any] | None: - if self.local_id is not None: - return "local_id", self.local_id - if self.index is not None: - return "index", self.index - return None - - -@dataclass(frozen=True) -class LocalListRequest: - identifier: str - - -@dataclass(frozen=True) -class LocalRenameRequest: - identifier: str - selector: LocalSelector - new_name: str - - -@dataclass(frozen=True) -class LocalRetypeRequest: - identifier: str - selector: LocalSelector - decl: str - - -@dataclass(frozen=True) -class LocalUpdateRequest: - identifier: str - selector: LocalSelector - new_name: str | None = None - decl: str | None = None - - -@dataclass(frozen=True) -class LocalPlanItem: - selector: LocalSelector - new_name: str | None = None - decl: str | None = None - type_text: str | None = None - - -@dataclass(frozen=True) -class LocalApplyPlanRequest: - identifier: str - items: tuple[LocalPlanItem, ...] - - -@dataclass(frozen=True) -class LocalRow: - index: int - local_id: str - definition_address: str - location: str - name: str - display_name: str - type: str - is_arg: bool - is_stack: bool - stack_offset: int | None - size: int - - -@dataclass(frozen=True) -class LocalListResult: - function: str - address: str - locals: tuple[LocalRow, ...] - - -@dataclass(frozen=True) -class LocalMutationResult: - function: str - address: str - locals: tuple[LocalRow, ...] - changed: bool - - -@dataclass(frozen=True) -class AppliedLocalPlanItem: - index: int - local_id: str - old_name: str - new_name: str | None - decl: str | None - - -@dataclass(frozen=True) -class LocalApplyPlanResult: - function: str - address: str - locals: tuple[LocalRow, ...] - changed: bool - applied: tuple[AppliedLocalPlanItem, ...] - - -@dataclass(frozen=True) -class SelectedLocal: - name: str - locator: Any - display_name: str | None = None - index: int | None = None - local_id: str | None = None - - def label(self) -> str: - return self.display_name or self.name or "" - - -_LOCAL_ID_NEW_RE = re.compile( - r"^(?Pstack|reg|regpair)\((?P[^)]*)\)@(?P0x[0-9a-fA-F]+|\d+)$", - re.IGNORECASE, -) -_LOCAL_SELECTOR_GUIDANCE = ( - "list locals again to confirm current names and prefer a stable selector such as local_id or index" -) - - -def _require_identifier(params: Mapping[str, Any]) -> str: - return require_str(params.get("identifier"), field="address or identifier") - - -def _parse_local_selector(params: Mapping[str, Any], *, name_key: str) -> LocalSelector: - name = str(params.get(name_key) or "").strip() or None - local_id = str(params.get("local_id") or "").strip() or None - index = optional_param_int(params, "index", label="local index", minimum=0) - stable_count = sum(value is not None for value in (local_id, index)) - if name is None and stable_count == 0: - raise IdaOperationError(f"local selector is required via {name_key}, local_id, or index") - if stable_count > 1: - raise IdaOperationError("--local-id and --index are mutually exclusive; got: local_id, index") - return LocalSelector(name=name, local_id=local_id, index=index) - - -def _parse_local_list(params: Mapping[str, Any]) -> LocalListRequest: - return LocalListRequest(identifier=_require_identifier(params)) - - -def _parse_local_rename(params: Mapping[str, Any]) -> LocalRenameRequest: - new_name = str(params.get("new_name") or "") - if not new_name: - raise IdaOperationError("new local variable name is required") - return LocalRenameRequest( - identifier=_require_identifier(params), - selector=_parse_local_selector(params, name_key="old_name"), - new_name=new_name, - ) - - -def _parse_local_retype(params: Mapping[str, Any]) -> LocalRetypeRequest: - decl = str(params.get("decl") or "") - if not decl: - raise IdaOperationError("local variable declaration is required") - return LocalRetypeRequest( - identifier=_require_identifier(params), - selector=_parse_local_selector(params, name_key="local_name"), - decl=decl, - ) - - -def _parse_local_update(params: Mapping[str, Any]) -> LocalUpdateRequest: - new_name = str(params.get("new_name") or "").strip() or None - decl = str(params.get("decl") or "").strip() or None - if new_name is None and decl is None: - raise IdaOperationError("at least one of new_name or decl is required") - return LocalUpdateRequest( - identifier=_require_identifier(params), - selector=_parse_local_selector(params, name_key="local_name"), - new_name=new_name, - decl=decl, - ) - - -def _local_plan_selector(raw: Mapping[Any, Any], *, index: int) -> LocalSelector: - selector_value = raw.get("selector") - selector_map: dict[str, Any] = selector_value if isinstance(selector_value, dict) else {} - name = ( - str( - selector_map.get("name") - or selector_map.get("local_name") - or raw.get("name") - or raw.get("local_name") - or raw.get("old_name") - or (selector_value if isinstance(selector_value, str) else "") - or "" - ).strip() - or None - ) - local_id = str(selector_map.get("local_id") or raw.get("local_id") or "").strip() or None - raw_index = selector_map.get("index", raw.get("index")) - try: - local_index = None if raw_index in (None, "") else parse_int_text(raw_index, label="local index", minimum=0) - except ValueError as exc: - raise IdaOperationError(f"local apply item {index}: {exc}") from exc - stable_count = sum(value is not None for value in (local_id, local_index)) - if name is None and stable_count == 0: - raise IdaOperationError(f"local apply item {index}: selector is required via local_id, index, or name") - if stable_count > 1: - raise IdaOperationError(f"local apply item {index}: local_id and index are mutually exclusive") - if name is not None and stable_count > 0: - raise IdaOperationError(f"local apply item {index}: do not combine name with local_id or index") - return LocalSelector(name=name, local_id=local_id, index=local_index) - - -def _parse_local_apply_plan(params: Mapping[str, Any]) -> LocalApplyPlanRequest: - raw_items = params.get("items") - if not isinstance(raw_items, list): - raise IdaOperationError("local apply requires a JSON list of item objects") - if not raw_items: - raise IdaOperationError("local apply requires at least one item") - items: list[LocalPlanItem] = [] - for item_index, raw in enumerate(raw_items, start=1): - if not isinstance(raw, dict): - raise IdaOperationError(f"local apply item {item_index}: expected object") - new_name = str(raw.get("new_name") or raw.get("rename") or "").strip() or None - decl = str(raw.get("decl") or "").strip() or None - type_text = str(raw.get("type") or raw.get("type_text") or "").strip() or None - if decl is not None and type_text is not None: - raise IdaOperationError(f"local apply item {item_index}: use either decl or type, not both") - if new_name is None and decl is None and type_text is None: - raise IdaOperationError(f"local apply item {item_index}: at least one of rename, decl, or type is required") - items.append( - LocalPlanItem( - selector=_local_plan_selector(raw, index=item_index), - new_name=new_name, - decl=decl, - type_text=type_text, - ) - ) - return LocalApplyPlanRequest(identifier=_require_identifier(params), items=tuple(items)) - - -def _vdloc_text(location) -> str: - if location.is_stkoff(): - return f"stack({location.stkoff()})" - if location.is_reg1(): - return f"reg({location.reg1()})" - if location.is_reg2(): - return f"regpair({location.reg1()},{location.reg2()})" - return "unknown" - - -def _local_identity(lvar) -> tuple[str, str, str]: - definition_address = hex(lvar.defea) - location = _vdloc_text(lvar.location) - return definition_address, location, f"{location}@{definition_address}" - - -def _safe_local_id(lvar) -> str: - try: - return _local_identity(lvar)[2] - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - return "" - - -def _normalize_local_location_text(text: str) -> str: - value = str(text).strip() - match = re.fullmatch(r"(?Pstack|reg|regpair)\((?P[^)]*)\)", value, re.IGNORECASE) - if match: - kind = match.group("kind").lower() - body = match.group("body").strip() - if kind in {"stack", "reg"} and body: - with contextlib.suppress(ValueError): - body = str(int(body, 0)) - elif kind == "regpair" and body: - left, sep, right = body.partition(",") - if sep: - with contextlib.suppress(ValueError): - left = str(int(left.strip(), 0)) - with contextlib.suppress(ValueError): - right = str(int(right.strip(), 0)) - body = f"{left},{right}" - return f"{kind}({body})" - return value - - -def _normalize_local_id_text(local_id: str) -> str: - text = str(local_id).strip() - match = _LOCAL_ID_NEW_RE.match(text) - if match: - defea_text = match.group("defea") - with contextlib.suppress(ValueError): - defea_text = hex(int(defea_text, 0)) - location_text = _normalize_local_location_text(f"{match.group('kind')}({match.group('body')})") - return f"{location_text}@{defea_text}" - return text - - -def _parse_var_decl(runtime: IdaRuntime, decl: str, *, error_message: str): - tif = runtime.ida_typeinf.tinfo_t() - parse_text = decl.strip() - if not parse_text.endswith(";"): - parse_text += ";" - parse_flags = runtime.ida_typeinf.PT_VAR | runtime.ida_typeinf.PT_SIL | runtime.ida_typeinf.PT_SEMICOLON - if not runtime.ida_typeinf.parse_decl(tif, None, parse_text, parse_flags): - raise IdaOperationError(error_message) - return tif - - -def _decompile_locals(runtime: IdaRuntime, func_ea: int, *, action: str): - cfunc = runtime.require_hexrays().decompile(func_ea) - if cfunc is None: - raise IdaOperationError(f"failed to {action} for {hex(func_ea)}") - return cfunc - - -def _lvar_locator(runtime: IdaRuntime, lvar): - locator = runtime.require_hexrays().lvar_locator_t() - locator.defea = lvar.defea - locator.location = lvar.location - return locator - - -def _local_row(runtime: IdaRuntime, index: int, lvar, saved) -> LocalRow: - stack_offset = None - if lvar.is_stk_var(): - with suppress_recoverable_ida_errors(): - stack_offset = lvar.get_stkoff() - definition_address, location, local_id = _local_identity(lvar) - name = lvar.name or "" - if saved is not None and saved.name: - name = str(saved.name) - type_text = runtime.tinfo_decl(lvar.tif, multi=False) - if saved is not None: - try: - saved_type = saved.type._print() if saved.type else "" - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - saved_type = "" - if saved_type: - type_text = saved_type - return LocalRow( - index=index, - local_id=local_id, - definition_address=definition_address, - location=location, - name=name, - display_name=name or f"", - type=type_text, - is_arg=lvar.is_arg_var, - is_stack=lvar.is_stk_var(), - stack_offset=stack_offset, - size=lvar.width, - ) - - -def _local_rows(runtime: IdaRuntime, func_ea: int) -> tuple[LocalRow, ...]: - ida_hexrays = runtime.require_hexrays() - cfunc = _decompile_locals(runtime, func_ea, action="inspect locals") - user_rows: dict[tuple[int, str], Any] = {} - user_info = ida_hexrays.lvar_uservec_t() - if ida_hexrays.restore_user_lvar_settings(user_info, func_ea): - for saved in user_info.lvvec: - user_rows[(saved.ll.defea, _vdloc_text(saved.ll.location))] = saved - - rows: list[LocalRow] = [] - for index, lvar in enumerate(cfunc.get_lvars()): - saved = user_rows.get((lvar.defea, _vdloc_text(lvar.location))) - rows.append(_local_row(runtime, index, lvar, saved)) - return tuple(rows) - - -def _local_list_result(runtime: IdaRuntime, func_ea: int) -> LocalListResult: - return LocalListResult( - function=runtime.function_name(func_ea), - address=hex(func_ea), - locals=_local_rows(runtime, func_ea), - ) - - -def _stable_local_matches( - lvars: list[Any], *, selector_name: str, selector_value: Any -) -> tuple[list[tuple[int, Any]], str]: - if selector_name == "local_id": - normalized_local_id = _normalize_local_id_text(str(selector_value)) - matches = [ - (index, lvar) - for index, lvar in enumerate(lvars) - if _normalize_local_id_text(_local_identity(lvar)[2]) == normalized_local_id - ] - return matches, f"local id `{selector_value}`" - matches = [(index, lvar) for index, lvar in enumerate(lvars) if index == selector_value] - return matches, f"local index {selector_value}" - - -def _resolve_lvar_by_name(runtime: IdaRuntime, func_ea: int, name: str): - local_name = name.strip() - if not local_name: - raise IdaOperationError("local variable name is required") - locator = runtime.require_hexrays().lvar_locator_t() - if not runtime.require_hexrays().locate_lvar(locator, func_ea, local_name): - raise IdaOperationError( - f"local variable not found: {local_name}; {_LOCAL_SELECTOR_GUIDANCE}" - f"{_available_locals_suffix(runtime, func_ea)}" - ) - return locator - - -def _select_local(runtime: IdaRuntime, func_ea: int, selector: LocalSelector) -> SelectedLocal: - stable = selector.stable_selector() - if stable is None: - if selector.name is None: - raise IdaOperationError("local selector name is required") - resolved_name = selector.name.strip() - return SelectedLocal( - name=resolved_name, - locator=_resolve_lvar_by_name(runtime, func_ea, resolved_name), - display_name=resolved_name, - ) - - selector_name, selector_value = stable - cfunc = _decompile_locals(runtime, func_ea, action="inspect locals") - matches, label = _stable_local_matches( - list(cfunc.get_lvars()), - selector_name=selector_name, - selector_value=selector_value, - ) - if not matches: - raise IdaOperationError( - f"local variable not found for {label}; {_LOCAL_SELECTOR_GUIDANCE}" - f"{_available_locals_suffix(runtime, func_ea)}" - ) - if len(matches) > 1: - raise IdaOperationError(f"multiple locals matched {label}; use local_id or index instead") - index, lvar = matches[0] - resolved_name = lvar.name or "" - return SelectedLocal( - name=resolved_name, - locator=_lvar_locator(runtime, lvar), - display_name=resolved_name or f"", - index=index, - local_id=_safe_local_id(lvar), - ) - - -def _select_local_from_lvars( - runtime: IdaRuntime, - func_ea: int, - lvars: list[Any], - selector: LocalSelector, -) -> SelectedLocal: - stable = selector.stable_selector() - if stable is None: - if selector.name is None: - raise IdaOperationError("local selector name is required") - name = selector.name.strip() - matches = [(index, lvar) for index, lvar in enumerate(lvars) if str(lvar.name or "") == name] - label = f"local name `{name}`" - else: - selector_name, selector_value = stable - matches, label = _stable_local_matches(lvars, selector_name=selector_name, selector_value=selector_value) - if not matches: - raise IdaOperationError( - f"local variable not found for {label}; {_LOCAL_SELECTOR_GUIDANCE}" - f"{_available_locals_suffix(runtime, func_ea)}" - ) - if len(matches) > 1: - raise IdaOperationError(f"multiple locals matched {label}; use local_id or index instead") - index, lvar = matches[0] - name = str(lvar.name or "") - return SelectedLocal( - name=name, - locator=_lvar_locator(runtime, lvar), - display_name=name or f"", - index=index, - local_id=_safe_local_id(lvar), - ) - - -def _available_locals_suffix(runtime: IdaRuntime, func_ea: int) -> str: - try: - rows = _local_rows(runtime, func_ea) - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - return "" - if not rows: - return "" - rendered: list[str] = [] - for row in rows[:12]: - name = row.display_name or row.name or f"" - rendered.append(f"#{row.index} {name} ({row.local_id})") - suffix = "; available locals: " + ", ".join(rendered) - if len(rows) > 12: - suffix += f", ... {len(rows) - 12} more" - return suffix - - -def _local_saved_info(runtime: IdaRuntime, locator): - info = runtime.require_hexrays().lvar_saved_info_t() - info.ll = locator - return info - - -def _readback_local_change(runtime: IdaRuntime, func_ea: int, *, success_message: str) -> LocalMutationResult: - try: - refreshed = _local_list_result(runtime, func_ea) - except Exception as exc: - detail = str(exc) or exc.__class__.__name__ - raise IdaOperationError(f"{success_message} but failed to read back locals: {detail}") from exc - return LocalMutationResult( - function=refreshed.function, - address=refreshed.address, - locals=refreshed.locals, - changed=True, - ) - - -def _dirty_local_cfunc(runtime: IdaRuntime, func_ea: int) -> None: - ida_hexrays = runtime.require_hexrays() - with suppress_recoverable_ida_errors(): - ida_hexrays.mark_cfunc_dirty(func_ea, False) - ida_hexrays.clear_cached_cfuncs() - - -def _rename_local_by_name( - runtime: IdaRuntime, - func_ea: int, - current_name: str, - new_name: str, - *, - failure_message: str, - success_message: str, -) -> LocalMutationResult | None: - rename_lvar = getattr(runtime.require_hexrays(), "rename_lvar", None) - if not callable(rename_lvar): - return None - if not rename_lvar(func_ea, current_name, new_name): - raise IdaOperationError(failure_message) - _dirty_local_cfunc(runtime, func_ea) - return _readback_local_change(runtime, func_ea, success_message=success_message) - - -def _apply_local_change( - runtime: IdaRuntime, - func_ea: int, - info, - *, - modify_flag: int, - failure_message: str, - success_message: str, -) -> LocalMutationResult: - if not runtime.require_hexrays().modify_user_lvar_info(func_ea, modify_flag, info): - raise IdaOperationError(failure_message) - _dirty_local_cfunc(runtime, func_ea) - return _readback_local_change(runtime, func_ea, success_message=success_message) - - -def _cleanup_local_preview( - context: OperationContext, - request: LocalRenameRequest | LocalRetypeRequest | LocalUpdateRequest | LocalApplyPlanRequest, -) -> None: - runtime = context.runtime - try: - func_ea = runtime.function_ea(request.identifier) - _dirty_local_cfunc(runtime, func_ea) - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - - -def _local_list(context: OperationContext, request: LocalListRequest) -> LocalListResult: - runtime = context.runtime - func_ea = runtime.function_ea(request.identifier) - return _local_list_result(runtime, func_ea) - - -def _local_list_for_change( - context: OperationContext, - request: LocalRenameRequest | LocalRetypeRequest | LocalUpdateRequest | LocalApplyPlanRequest, -) -> LocalListResult: - runtime = context.runtime - func_ea = runtime.function_ea(request.identifier) - return _local_list_result(runtime, func_ea) - - -def _local_rename(context: OperationContext, request: LocalRenameRequest) -> LocalMutationResult: - runtime = context.runtime - func_ea = runtime.function_ea(request.identifier) - selected = _select_local(runtime, func_ea, request.selector) - failure_message = f"failed to rename local variable: {selected.label()}" - success_message = f"renamed local variable `{selected.label()}` to `{request.new_name}`" - if request.selector.stable_selector() is None and selected.name: - renamed = _rename_local_by_name( - runtime, - func_ea, - selected.name, - request.new_name, - failure_message=failure_message, - success_message=success_message, - ) - if renamed is not None: - return renamed - info = _local_saved_info(runtime, selected.locator) - info.name = request.new_name - return _apply_local_change( - runtime, - func_ea, - info, - modify_flag=runtime.require_hexrays().MLI_NAME, - failure_message=failure_message, - success_message=success_message, - ) - - -def _local_retype(context: OperationContext, request: LocalRetypeRequest) -> LocalMutationResult: - runtime = context.runtime - func_ea = runtime.function_ea(request.identifier) - selected = _select_local(runtime, func_ea, request.selector) - info = _local_saved_info(runtime, selected.locator) - info.name = selected.name - info.type = _parse_var_decl( - runtime, - request.decl, - error_message=f"failed to parse local variable declaration: {request.decl}", - ) - return _apply_local_change( - runtime, - func_ea, - info, - modify_flag=runtime.require_hexrays().MLI_TYPE, - failure_message=f"failed to update local variable type: {selected.label()}", - success_message=f"updated local variable type for `{selected.label()}`", - ) - - -def _local_update(context: OperationContext, request: LocalUpdateRequest) -> LocalMutationResult: - runtime = context.runtime - func_ea = runtime.function_ea(request.identifier) - selected = _select_local(runtime, func_ea, request.selector) - info = _local_saved_info(runtime, selected.locator) - modify_flag = 0 - if request.new_name is not None: - info.name = request.new_name - modify_flag |= runtime.require_hexrays().MLI_NAME - if request.decl is not None: - info.name = request.new_name or selected.name - info.type = _parse_var_decl( - runtime, - request.decl, - error_message=f"failed to parse local variable declaration: {request.decl}", - ) - modify_flag |= runtime.require_hexrays().MLI_TYPE - if modify_flag == 0: - raise IdaOperationError("at least one of new_name or decl is required") - success_message_parts: list[str] = [] - if request.new_name is not None: - success_message_parts.append(f"renamed local variable `{selected.label()}` to `{request.new_name}`") - if request.decl is not None: - success_message_parts.append(f"updated local variable type for `{request.new_name or selected.label()}`") - return _apply_local_change( - runtime, - func_ea, - info, - modify_flag=modify_flag, - failure_message=f"failed to update local variable: {selected.label()}", - success_message=" and ".join(success_message_parts), - ) - - -def _decl_for_plan_item(item: LocalPlanItem, selected: SelectedLocal) -> str | None: - if item.decl is not None: - return item.decl - if item.type_text is None: - return None - name = item.new_name or selected.name - if not name: - raise IdaOperationError("local apply type entries for unnamed locals require a rename or full decl") - return f"{item.type_text.rstrip(';')} {name};" - - -def _local_apply_plan(context: OperationContext, request: LocalApplyPlanRequest) -> LocalApplyPlanResult: - runtime = context.runtime - ida_hexrays = runtime.require_hexrays() - func_ea = runtime.function_ea(request.identifier) - cfunc = _decompile_locals(runtime, func_ea, action="inspect locals") - lvars = list(cfunc.get_lvars()) - prepared: list[tuple[SelectedLocal, Any, int, LocalPlanItem, str | None]] = [] - for item in request.items: - selected = _select_local_from_lvars(runtime, func_ea, lvars, item.selector) - info = _local_saved_info(runtime, selected.locator) - modify_flag = 0 - if item.new_name is not None: - info.name = item.new_name - modify_flag |= ida_hexrays.MLI_NAME - decl = _decl_for_plan_item(item, selected) - if decl is not None: - info.name = item.new_name or selected.name - info.type = _parse_var_decl( - runtime, - decl, - error_message=f"failed to parse local variable declaration: {decl}", - ) - modify_flag |= ida_hexrays.MLI_TYPE - prepared.append((selected, info, modify_flag, item, decl)) - - applied: list[AppliedLocalPlanItem] = [] - for selected, info, modify_flag, item, decl in prepared: - if not ida_hexrays.modify_user_lvar_info(func_ea, modify_flag, info): - raise IdaOperationError(f"failed to apply local plan item for `{selected.label()}`") - applied.append( - AppliedLocalPlanItem( - index=-1 if selected.index is None else selected.index, - local_id=selected.local_id or "", - old_name=selected.name, - new_name=item.new_name, - decl=decl, - ) - ) - _dirty_local_cfunc(runtime, func_ea) - try: - refreshed = _local_list_result(runtime, func_ea) - except Exception as exc: - detail = str(exc) or exc.__class__.__name__ - raise IdaOperationError(f"applied local plan but failed to read back locals: {detail}") from exc - return LocalApplyPlanResult( - function=refreshed.function, - address=refreshed.address, - locals=refreshed.locals, - changed=True, - applied=tuple(applied), - ) - - -def local_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="local_list", - parse=_parse_local_list, - run=_local_list, - ), - OperationSpec( - name="local_rename", - parse=_parse_local_rename, - run=_local_rename, - mutating=True, - preview=PreviewSpec( - capture_before=_local_list_for_change, - capture_after=_local_list_for_change, - cleanup=_cleanup_local_preview, - use_undo=True, - ), - ), - OperationSpec( - name="local_retype", - parse=_parse_local_retype, - run=_local_retype, - mutating=True, - preview=PreviewSpec( - capture_before=_local_list_for_change, - capture_after=_local_list_for_change, - cleanup=_cleanup_local_preview, - use_undo=True, - ), - ), - OperationSpec( - name="local_update", - parse=_parse_local_update, - run=_local_update, - mutating=True, - preview=PreviewSpec( - capture_before=_local_list_for_change, - capture_after=_local_list_for_change, - cleanup=_cleanup_local_preview, - use_undo=True, - ), - ), - OperationSpec( - name="local_apply_plan", - parse=_parse_local_apply_plan, - run=_local_apply_plan, - mutating=True, - preview=PreviewSpec( - capture_before=_local_list_for_change, - capture_after=_local_list_for_change, - cleanup=_cleanup_local_preview, - use_undo=True, - ), - ), - ) - - -__all__ = [ - "LocalApplyPlanRequest", - "LocalApplyPlanResult", - "LocalListRequest", - "LocalListResult", - "LocalMutationResult", - "LocalRenameRequest", - "LocalRetypeRequest", - "LocalRow", - "LocalSelector", - "LocalUpdateRequest", - "local_operations", -] diff --git a/src/idac/ops/families/misc.py b/src/idac/ops/families/misc.py deleted file mode 100644 index af4ec2d..0000000 --- a/src/idac/ops/families/misc.py +++ /dev/null @@ -1,176 +0,0 @@ -from __future__ import annotations - -import contextlib -import io -import json -from collections.abc import Mapping -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.params import optional_str, require_str -from ..runtime import IdaOperationError - - -@dataclass(frozen=True) -class ReanalyzeRequest: - identifier: str - end: str | None = None - - -@dataclass(frozen=True) -class ReanalyzeRangeResult: - mode: str - start: str - end: str - waited: bool - - -@dataclass(frozen=True) -class ReanalyzeFunctionResult: - mode: str - function: str - start: str - end: str - waited: bool - - -@dataclass(frozen=True) -class PythonExecRequest: - script: str | None = None - script_path: str | None = None - persist: bool = False - - -@dataclass(frozen=True) -class PythonExecResult: - stdout: str - result: object - result_repr: str - - -def _parse_reanalyze(params: Mapping[str, Any]) -> ReanalyzeRequest: - return ReanalyzeRequest( - identifier=require_str(params.get("identifier"), field="identifier"), - end=optional_str(params.get("end")), - ) - - -def _json_payload_result(value: object) -> tuple[object, str]: - try: - json.dumps(value) - except (TypeError, ValueError): - return None, repr(value) - return value, repr(value) - - -def _parse_python_exec(params: Mapping[str, Any]) -> PythonExecRequest: - raw_script = str(params.get("script") or "") - script = raw_script if raw_script.strip() else None - script_path = str(params.get("script_path") or "").strip() or None - if script is None and script_path is None: - raise IdaOperationError("python_exec requires non-empty script or script_path") - if script is not None and script_path is not None: - raise IdaOperationError("python_exec accepts either script or script_path, not both") - return PythonExecRequest(script=script, script_path=script_path, persist=bool(params.get("persist"))) - - -def _reanalyze( - context: OperationContext, - request: ReanalyzeRequest, -) -> ReanalyzeRangeResult | ReanalyzeFunctionResult: - runtime = context.runtime - ida_auto = runtime.mod("ida_auto") - ida_funcs = runtime.mod("ida_funcs") - if request.end is not None: - start_ea = runtime.resolve_address(request.identifier) - end_ea = runtime.resolve_address(request.end) - if end_ea <= start_ea: - raise IdaOperationError("reanalyze range end must be greater than the start") - ida_auto.plan_and_wait(start_ea, end_ea, True) - return ReanalyzeRangeResult(mode="range", start=hex(start_ea), end=hex(end_ea), waited=True) - try: - func = runtime.resolve_function(request.identifier) - except IdaOperationError: - ea = runtime.resolve_address(request.identifier) - ida_auto.plan_and_wait(ea, ea + 1, True) - return ReanalyzeRangeResult(mode="address", start=hex(ea), end=hex(ea + 1), waited=True) - ea = func.start_ea - ida_funcs.reanalyze_function(func) - ida_auto.auto_wait() - return ReanalyzeFunctionResult( - mode="function", - function=ida_funcs.get_func_name(ea), - start=hex(ea), - end=hex(func.end_ea), - waited=True, - ) - - -def _python_exec(context: OperationContext, request: PythonExecRequest) -> PythonExecResult: - runtime = context.runtime - stdout = io.StringIO() - scope = runtime.python_exec_scope(persist=request.persist) - try: - with contextlib.redirect_stdout(stdout): - if request.script_path is not None: - _exec_script_file(runtime, request.script_path, scope) - else: - exec(str(request.script), scope, scope) - except Exception as exc: - raise IdaOperationError(f"python_exec failed: {exc.__class__.__name__}: {exc}") from exc - payload_result, result_repr = _json_payload_result(scope.get("result")) - return PythonExecResult(stdout=stdout.getvalue(), result=payload_result, result_repr=result_repr) - - -def _exec_script_file(runtime, script_path: str, scope: dict[str, object]) -> None: - path = Path(script_path).expanduser() - if not path.exists(): - raise IdaOperationError(f"python_exec script not found: {script_path}") - ida_idaapi = runtime.mod("ida_idaapi") - exec_script = getattr(ida_idaapi, "IDAPython_ExecScript", None) - if callable(exec_script): - error = exec_script(str(path), scope, False) - if error: - raise IdaOperationError(str(error).strip() or f"script failed: {path}") - return - - code = path.read_text(encoding="utf-8") - previous_file_marker = object() - previous_file = scope.get("__file__", previous_file_marker) - scope["__file__"] = str(path) - try: - exec(compile(code, str(path), "exec"), scope, scope) - finally: - if previous_file is previous_file_marker: - scope.pop("__file__", None) - else: - scope["__file__"] = previous_file - - -def misc_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="reanalyze", - parse=_parse_reanalyze, - run=_reanalyze, - mutating=True, - ), - OperationSpec( - name="python_exec", - parse=_parse_python_exec, - run=_python_exec, - mutating=True, - ), - ) - - -__all__ = [ - "PythonExecRequest", - "PythonExecResult", - "ReanalyzeFunctionResult", - "ReanalyzeRangeResult", - "ReanalyzeRequest", - "misc_operations", -] diff --git a/src/idac/ops/families/named_types.py b/src/idac/ops/families/named_types.py deleted file mode 100644 index 6e2e2e8..0000000 --- a/src/idac/ops/families/named_types.py +++ /dev/null @@ -1,720 +0,0 @@ -from __future__ import annotations - -import contextlib -import re -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.params import optional_param_int, param_int -from ..preview import PreviewSpec -from ..runtime import IdaOperationError, IdaRuntime - - -@dataclass(frozen=True) -class NamedTypeListRequest: - query: str | None - pattern: str | None - glob: bool - regex: bool - ignore_case: bool - - -@dataclass(frozen=True) -class NamedTypeShowRequest: - name: str - - -@dataclass(frozen=True) -class NamedTypeDepsResult: - name: str - kind: str - decl: str - dependencies_included: bool - - -@dataclass(frozen=True) -class StructFieldSetRequest: - struct_name: str - field_name: str - decl: str - offset: int - - -@dataclass(frozen=True) -class StructFieldRenameRequest: - struct_name: str - field_name: str - new_name: str - - -@dataclass(frozen=True) -class StructFieldDeleteRequest: - struct_name: str - field_name: str - - -@dataclass(frozen=True) -class EnumMemberSetRequest: - enum_name: str - member_name: str - value: int - mask: int | None - - -@dataclass(frozen=True) -class EnumMemberRenameRequest: - enum_name: str - member_name: str - new_name: str - - -@dataclass(frozen=True) -class EnumMemberDeleteRequest: - enum_name: str - member_name: str - - -@dataclass(frozen=True) -class NamedTypeEntry: - name: str - kind: str - decl: str - - -@dataclass(frozen=True) -class StructMember: - index: int - name: str | None - offset_bits: int - offset: int - size_bits: int - size: int | None - type: str - comment: str - - -@dataclass(frozen=True) -class EnumMember: - index: int - name: str | None - value: int - value_hex: str - comment: str - - -@dataclass(frozen=True) -class NamedTypeView: - name: str - kind: str - size: int | None - size_known: bool - decl: str - - -@dataclass(frozen=True) -class StructuredTypeView: - name: str - kind: str - size: int | None - size_known: bool - decl: str - layout: str - members: tuple[StructMember, ...] - - -@dataclass(frozen=True) -class EnumTypeView: - name: str - kind: str - size: int | None - size_known: bool - decl: str - members: tuple[EnumMember, ...] - - -@dataclass(frozen=True) -class StructView: - name: str - kind: str - layout: str - members: tuple[StructMember, ...] - - -@dataclass(frozen=True) -class StructMutationResult: - name: str - kind: str - layout: str - members: tuple[StructMember, ...] - changed: bool - - -@dataclass(frozen=True) -class EnumView: - name: str - kind: str - decl: str - members: tuple[EnumMember, ...] - - -@dataclass(frozen=True) -class EnumMutationResult: - name: str - kind: str - decl: str - members: tuple[EnumMember, ...] - changed: bool - - -_UNKNOWN_TINFO_SIZE_THRESHOLD = 1 << 63 - - -def _require_name(params: Mapping[str, Any], *, key: str = "name", message: str = "type name is required") -> str: - value = str(params.get(key) or "").strip() - if not value: - raise IdaOperationError(message) - return value - - -def _parse_list(params: Mapping[str, Any]) -> NamedTypeListRequest: - query = str(params.get("query") or "").strip() or None - pattern = str(params.get("pattern") or "").strip() or None - return NamedTypeListRequest( - query=query, - pattern=pattern, - glob=bool(params.get("glob")), - regex=bool(params.get("regex")), - ignore_case=bool(params.get("ignore_case")), - ) - - -def _parse_show(params: Mapping[str, Any]) -> NamedTypeShowRequest: - return NamedTypeShowRequest(name=_require_name(params)) - - -def _parse_struct_show(params: Mapping[str, Any]) -> NamedTypeShowRequest: - return NamedTypeShowRequest(name=_require_name(params, message="struct name is required")) - - -def _parse_enum_show(params: Mapping[str, Any]) -> NamedTypeShowRequest: - return NamedTypeShowRequest(name=_require_name(params, message="enum name is required")) - - -def _parse_struct_field_set(params: Mapping[str, Any]) -> StructFieldSetRequest: - struct_name = _require_name(params, key="struct_name", message="struct name is required") - field_name = _require_name(params, key="field_name", message="field name is required") - decl = str(params.get("decl") or "") - if not decl: - raise IdaOperationError("struct field declaration is required") - offset = param_int(params, "offset", label="struct field offset", minimum=0) - return StructFieldSetRequest(struct_name=struct_name, field_name=field_name, decl=decl, offset=offset) - - -def _parse_struct_field_rename(params: Mapping[str, Any]) -> StructFieldRenameRequest: - return StructFieldRenameRequest( - struct_name=_require_name(params, key="struct_name", message="struct name is required"), - field_name=_require_name(params, key="field_name", message="field name is required"), - new_name=_require_name(params, key="new_name", message="new field name is required"), - ) - - -def _parse_struct_field_delete(params: Mapping[str, Any]) -> StructFieldDeleteRequest: - return StructFieldDeleteRequest( - struct_name=_require_name(params, key="struct_name", message="struct name is required"), - field_name=_require_name(params, key="field_name", message="field name is required"), - ) - - -def _parse_enum_member_set(params: Mapping[str, Any]) -> EnumMemberSetRequest: - return EnumMemberSetRequest( - enum_name=_require_name(params, key="enum_name", message="enum name is required"), - member_name=_require_name(params, key="member_name", message="enum member name is required"), - value=param_int(params, "value", label="enum member value"), - mask=None if params.get("mask") in (None, "") else optional_param_int(params, "mask", label="enum member mask"), - ) - - -def _parse_enum_member_rename(params: Mapping[str, Any]) -> EnumMemberRenameRequest: - return EnumMemberRenameRequest( - enum_name=_require_name(params, key="enum_name", message="enum name is required"), - member_name=_require_name(params, key="member_name", message="enum member name is required"), - new_name=_require_name(params, key="new_name", message="new enum member name is required"), - ) - - -def _parse_enum_member_delete(params: Mapping[str, Any]) -> EnumMemberDeleteRequest: - return EnumMemberDeleteRequest( - enum_name=_require_name(params, key="enum_name", message="enum name is required"), - member_name=_require_name(params, key="member_name", message="enum member name is required"), - ) - - -def _normalize_tinfo_size(value: Any) -> int | None: - try: - size = int(value) - except (TypeError, ValueError): - return None - if size < 0 or size >= _UNKNOWN_TINFO_SIZE_THRESHOLD: - return None - return size - - -def _coerce_named_type_entries(rows: list[dict[str, Any]]) -> tuple[NamedTypeEntry, ...]: - return tuple( - NamedTypeEntry( - name=str(item.get("name") or ""), - kind=str(item.get("kind") or ""), - decl=str(item.get("decl") or ""), - ) - for item in rows - ) - - -def _coerce_struct_members(rows: list[dict[str, Any]]) -> tuple[StructMember, ...]: - return tuple( - StructMember( - index=int(item.get("index") or 0), - name=None if item.get("name") is None else str(item.get("name")), - offset_bits=int(item.get("offset_bits") or 0), - offset=int(item.get("offset") or 0), - size_bits=int(item.get("size_bits") or 0), - size=None if (raw_size := item.get("size")) is None else int(raw_size), - type=str(item.get("type") or ""), - comment=str(item.get("comment") or ""), - ) - for item in rows - ) - - -def _coerce_enum_members(rows: list[dict[str, Any]]) -> tuple[EnumMember, ...]: - return tuple( - EnumMember( - index=int(item.get("index") or 0), - name=None if item.get("name") is None else str(item.get("name")), - value=int(item.get("value") or 0), - value_hex=str(item.get("value_hex") or hex(int(item.get("value") or 0))), - comment=str(item.get("comment") or ""), - ) - for item in rows - ) - - -def _ensure_terr_ok(runtime: IdaRuntime, code: int, action: str) -> None: - ida_typeinf = runtime.mod("ida_typeinf") - if code != ida_typeinf.TERR_OK: - raise IdaOperationError(f"{action}: {ida_typeinf.tinfo_errstr(code)}") - - -def _persist_named_type(runtime: IdaRuntime, tif, name: str) -> None: - ida_typeinf = runtime.mod("ida_typeinf") - code = tif.set_named_type(None, name, ida_typeinf.NTF_REPLACE) - if code != ida_typeinf.TERR_OK: - raise IdaOperationError(f"failed to persist type `{name}`: {ida_typeinf.tinfo_errstr(code)}") - - -def _struct_member_index(tif, struct_name: str, field_name: str) -> int: - idx, _udm = tif.get_udm(field_name) - if idx < 0: - raise IdaOperationError(f"struct field not found: {struct_name}.{field_name}") - return idx - - -def _enum_type(runtime: IdaRuntime, name: str): - return runtime.get_named_type(name, kind="enum") - - -def _enum_member_index(tif, enum_name: str, member_name: str) -> int: - idx, _edm = tif.get_edm(member_name) - if idx < 0: - raise IdaOperationError(f"enum member not found: {enum_name}.{member_name}") - return idx - - -def _parse_member_type(runtime: IdaRuntime, decl: str, field_name: str): - ida_typeinf = runtime.mod("ida_typeinf") - tif = ida_typeinf.tinfo_t() - parse_flags = ida_typeinf.PT_VAR | ida_typeinf.PT_SIL | ida_typeinf.PT_SEMICOLON - raw_decl = decl.strip() - normalized_decl = f"{raw_decl.rstrip(';')};" - candidates: list[str] = [] - if re.search(rf"(? tuple[NamedTypeEntry, ...]: - runtime = context.runtime - return _coerce_named_type_entries( - runtime.list_named_types( - query=request.query, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ) - ) - - -def _type_show( - context: OperationContext, - request: NamedTypeShowRequest, -) -> NamedTypeView | StructuredTypeView | EnumTypeView: - runtime = context.runtime - tif = runtime.get_named_type(request.name) - kind = runtime.classify_tinfo(tif) - size = _normalize_tinfo_size(tif.get_size()) - decl = runtime.tinfo_decl(tif, name=request.name, multi=True) - if kind in {"struct", "union"}: - members = _coerce_struct_members(runtime.tinfo_members(tif)) - return StructuredTypeView( - name=request.name, - kind=kind, - size=size, - size_known=size is not None, - decl=decl, - layout=decl, - members=members, - ) - if kind == "enum": - return EnumTypeView( - name=request.name, - kind=kind, - size=size, - size_known=size is not None, - decl=decl, - members=_coerce_enum_members(runtime.enum_members(tif)), - ) - return NamedTypeView( - name=request.name, - kind=kind, - size=size, - size_known=size is not None, - decl=decl, - ) - - -def _type_deps(context: OperationContext, request: NamedTypeShowRequest) -> NamedTypeDepsResult: - runtime = context.runtime - tif = runtime.get_named_type(request.name) - kind = runtime.classify_tinfo(tif) - decl = _print_type_deps(runtime, tif, request.name) - return NamedTypeDepsResult( - name=request.name, - kind=kind, - decl=decl or runtime.tinfo_decl(tif, name=request.name, multi=True), - dependencies_included=bool(decl), - ) - - -def _type_ordinal(runtime: IdaRuntime, tif, name: str) -> int | None: - with contextlib.suppress(Exception): - ordinal = int(tif.get_ordinal()) - if ordinal > 0: - return ordinal - ida_typeinf = runtime.mod("ida_typeinf") - getter = getattr(ida_typeinf, "get_type_ordinal", None) - if callable(getter): - for args in ((None, name), (runtime.ida_typeinf.get_idati(), name), (name,)): - with contextlib.suppress(Exception): - ordinal = int(getter(*args)) - if ordinal > 0: - return ordinal - return None - - -def _print_type_deps(runtime: IdaRuntime, tif, name: str) -> str: - ida_typeinf = runtime.mod("ida_typeinf") - print_decls = getattr(ida_typeinf, "print_decls", None) - sink_type = getattr(ida_typeinf, "text_sink_t", None) - ordinal = _type_ordinal(runtime, tif, name) - if not callable(print_decls) or sink_type is None or ordinal is None: - return "" - - class Sink(sink_type): - def __init__(self) -> None: - super().__init__() - self.text = "" - - def _print(self, text): - self.text += str(text) - return 0 - - sink = Sink() - flags = getattr(ida_typeinf, "PDF_INCL_DEPS", 0) | getattr(ida_typeinf, "PDF_DEF_FWD", 0) - with contextlib.suppress(Exception): - print_decls(sink, None, [ordinal], flags) - return sink.text.strip() - return "" - - -def _struct_list(context: OperationContext, request: NamedTypeListRequest) -> tuple[NamedTypeEntry, ...]: - runtime = context.runtime - return _coerce_named_type_entries( - runtime.list_named_types( - query=request.query, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - kinds={"struct", "union"}, - ) - ) - - -def _struct_view(context: OperationContext, request: NamedTypeShowRequest) -> StructView: - runtime = context.runtime - tif = runtime.get_struct_or_union(request.name) - return StructView( - name=request.name, - kind=runtime.classify_tinfo(tif), - layout=runtime.tinfo_decl(tif, name=request.name, multi=True), - members=_coerce_struct_members(runtime.tinfo_members(tif)), - ) - - -def _persist_and_show_struct(runtime: IdaRuntime, tif, *, name: str) -> StructMutationResult: - _persist_named_type(runtime, tif, name) - try: - shown = _struct_view(OperationContext(runtime=runtime), NamedTypeShowRequest(name=name)) - except Exception as exc: - detail = str(exc) or exc.__class__.__name__ - raise IdaOperationError(f"persisted named type `{name}` but failed to read it back: {detail}") from exc - return StructMutationResult( - name=shown.name, - kind=shown.kind, - layout=shown.layout, - members=shown.members, - changed=True, - ) - - -def _struct_field_set(context: OperationContext, request: StructFieldSetRequest) -> StructMutationResult: - runtime = context.runtime - tif = runtime.get_struct_or_union(request.struct_name) - offset_bits = request.offset * 8 - member_tif = _parse_member_type(runtime, request.decl, request.field_name) - idx, udm = tif.get_udm_by_offset(offset_bits) - if idx >= 0 and udm is not None and udm.offset == offset_bits: - _ensure_terr_ok(runtime, tif.set_udm_type(idx, member_tif), "failed to set field type") - if udm.name != request.field_name: - _ensure_terr_ok(runtime, tif.rename_udm(idx, request.field_name), "failed to rename field") - else: - _ensure_terr_ok(runtime, tif.add_udm(request.field_name, member_tif, offset_bits), "failed to add field") - return _persist_and_show_struct(runtime, tif, name=request.struct_name) - - -def _struct_field_rename(context: OperationContext, request: StructFieldRenameRequest) -> StructMutationResult: - runtime = context.runtime - tif = runtime.get_struct_or_union(request.struct_name) - idx = _struct_member_index(tif, request.struct_name, request.field_name) - _ensure_terr_ok(runtime, tif.rename_udm(idx, request.new_name), "failed to rename field") - return _persist_and_show_struct(runtime, tif, name=request.struct_name) - - -def _struct_field_delete(context: OperationContext, request: StructFieldDeleteRequest) -> StructMutationResult: - runtime = context.runtime - tif = runtime.get_struct_or_union(request.struct_name) - idx = _struct_member_index(tif, request.struct_name, request.field_name) - _ensure_terr_ok(runtime, tif.del_udm(idx), "failed to delete field") - return _persist_and_show_struct(runtime, tif, name=request.struct_name) - - -def _struct_view_for_set(context: OperationContext, request: StructFieldSetRequest) -> StructView: - return _struct_view(context, NamedTypeShowRequest(name=request.struct_name)) - - -def _struct_view_for_rename(context: OperationContext, request: StructFieldRenameRequest) -> StructView: - return _struct_view(context, NamedTypeShowRequest(name=request.struct_name)) - - -def _struct_view_for_delete(context: OperationContext, request: StructFieldDeleteRequest) -> StructView: - return _struct_view(context, NamedTypeShowRequest(name=request.struct_name)) - - -def _enum_list(context: OperationContext, request: NamedTypeListRequest) -> tuple[NamedTypeEntry, ...]: - runtime = context.runtime - return _coerce_named_type_entries( - runtime.list_named_types( - query=request.query, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - kinds={"enum"}, - ) - ) - - -def _enum_view(context: OperationContext, request: NamedTypeShowRequest) -> EnumView: - runtime = context.runtime - tif = _enum_type(runtime, request.name) - return EnumView( - name=request.name, - kind="enum", - decl=runtime.tinfo_decl(tif, name=request.name, multi=True), - members=_coerce_enum_members(runtime.enum_members(tif)), - ) - - -def _persist_and_show_enum(runtime: IdaRuntime, tif, *, name: str) -> EnumMutationResult: - _persist_named_type(runtime, tif, name) - try: - shown = _enum_view(OperationContext(runtime=runtime), NamedTypeShowRequest(name=name)) - except Exception as exc: - detail = str(exc) or exc.__class__.__name__ - raise IdaOperationError(f"persisted named type `{name}` but failed to read it back: {detail}") from exc - return EnumMutationResult( - name=shown.name, - kind=shown.kind, - decl=shown.decl, - members=shown.members, - changed=True, - ) - - -def _enum_member_set(context: OperationContext, request: EnumMemberSetRequest) -> EnumMutationResult: - runtime = context.runtime - tif = _enum_type(runtime, request.enum_name) - ida_typeinf = runtime.mod("ida_typeinf") - mask = ida_typeinf.DEFMASK64 if request.mask is None else request.mask - idx, _edm = tif.get_edm(request.member_name) - if idx >= 0: - _ensure_terr_ok(runtime, tif.edit_edm(idx, request.value, mask), "failed to edit enum member") - else: - _ensure_terr_ok(runtime, tif.add_edm(request.member_name, request.value, mask), "failed to add enum member") - return _persist_and_show_enum(runtime, tif, name=request.enum_name) - - -def _enum_member_rename(context: OperationContext, request: EnumMemberRenameRequest) -> EnumMutationResult: - runtime = context.runtime - tif = _enum_type(runtime, request.enum_name) - idx = _enum_member_index(tif, request.enum_name, request.member_name) - _ensure_terr_ok(runtime, tif.rename_edm(idx, request.new_name), "failed to rename enum member") - return _persist_and_show_enum(runtime, tif, name=request.enum_name) - - -def _enum_member_delete(context: OperationContext, request: EnumMemberDeleteRequest) -> EnumMutationResult: - runtime = context.runtime - tif = _enum_type(runtime, request.enum_name) - _ensure_terr_ok(runtime, tif.del_edm(request.member_name), "failed to delete enum member") - return _persist_and_show_enum(runtime, tif, name=request.enum_name) - - -def _enum_view_for_set(context: OperationContext, request: EnumMemberSetRequest) -> EnumView: - return _enum_view(context, NamedTypeShowRequest(name=request.enum_name)) - - -def _enum_view_for_rename(context: OperationContext, request: EnumMemberRenameRequest) -> EnumView: - return _enum_view(context, NamedTypeShowRequest(name=request.enum_name)) - - -def _enum_view_for_delete(context: OperationContext, request: EnumMemberDeleteRequest) -> EnumView: - return _enum_view(context, NamedTypeShowRequest(name=request.enum_name)) - - -def named_type_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec(name="type_list", parse=_parse_list, run=_type_list), - OperationSpec(name="type_show", parse=_parse_show, run=_type_show), - OperationSpec(name="type_deps", parse=_parse_show, run=_type_deps), - OperationSpec(name="struct_list", parse=_parse_list, run=_struct_list), - OperationSpec(name="struct_show", parse=_parse_struct_show, run=_struct_view), - OperationSpec( - name="struct_field_set", - parse=_parse_struct_field_set, - run=_struct_field_set, - mutating=True, - preview=PreviewSpec( - capture_before=_struct_view_for_set, - capture_after=_struct_view_for_set, - use_undo=True, - ), - ), - OperationSpec( - name="struct_field_rename", - parse=_parse_struct_field_rename, - run=_struct_field_rename, - mutating=True, - preview=PreviewSpec( - capture_before=_struct_view_for_rename, - capture_after=_struct_view_for_rename, - use_undo=True, - ), - ), - OperationSpec( - name="struct_field_delete", - parse=_parse_struct_field_delete, - run=_struct_field_delete, - mutating=True, - preview=PreviewSpec( - capture_before=_struct_view_for_delete, - capture_after=_struct_view_for_delete, - use_undo=True, - ), - ), - OperationSpec(name="enum_list", parse=_parse_list, run=_enum_list), - OperationSpec(name="enum_show", parse=_parse_enum_show, run=_enum_view), - OperationSpec( - name="enum_member_set", - parse=_parse_enum_member_set, - run=_enum_member_set, - mutating=True, - preview=PreviewSpec( - capture_before=_enum_view_for_set, - capture_after=_enum_view_for_set, - use_undo=True, - ), - ), - OperationSpec( - name="enum_member_rename", - parse=_parse_enum_member_rename, - run=_enum_member_rename, - mutating=True, - preview=PreviewSpec( - capture_before=_enum_view_for_rename, - capture_after=_enum_view_for_rename, - use_undo=True, - ), - ), - OperationSpec( - name="enum_member_delete", - parse=_parse_enum_member_delete, - run=_enum_member_delete, - mutating=True, - preview=PreviewSpec( - capture_before=_enum_view_for_delete, - capture_after=_enum_view_for_delete, - use_undo=True, - ), - ), - ) - - -__all__ = [ - "EnumMember", - "EnumMemberDeleteRequest", - "EnumMemberRenameRequest", - "EnumMemberSetRequest", - "EnumMutationResult", - "EnumTypeView", - "EnumView", - "NamedTypeDepsResult", - "NamedTypeEntry", - "NamedTypeListRequest", - "NamedTypeShowRequest", - "NamedTypeView", - "StructFieldDeleteRequest", - "StructFieldRenameRequest", - "StructFieldSetRequest", - "StructMember", - "StructMutationResult", - "StructView", - "StructuredTypeView", - "named_type_operations", -] diff --git a/src/idac/ops/families/names.py b/src/idac/ops/families/names.py deleted file mode 100644 index fa42a8b..0000000 --- a/src/idac/ops/families/names.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.params import require_str -from ..preview import PreviewSpec -from ..runtime import IdaOperationError - - -@dataclass(frozen=True) -class NameSetRequest: - identifier: str - new_name: str - - -@dataclass(frozen=True) -class NameState: - address: str - name: str - - -@dataclass(frozen=True) -class NameMutationResult: - address: str - name: str - changed: bool - - -def _parse_name_set(params: Mapping[str, Any]) -> NameSetRequest: - identifier = require_str(params.get("identifier"), field="address or identifier") - new_name = require_str(params.get("new_name"), field="new name") - return NameSetRequest(identifier=identifier, new_name=new_name) - - -def _prepare_name_set(context: OperationContext, request: NameSetRequest) -> NameSetRequest: - runtime = context.runtime - return NameSetRequest(identifier=hex(runtime.resolve_address(request.identifier)), new_name=request.new_name) - - -def _name_state(context: OperationContext, request: NameSetRequest) -> NameState: - runtime = context.runtime - ida_name = runtime.mod("ida_name") - ea = runtime.resolve_address(request.identifier) - return NameState(address=hex(ea), name=ida_name.get_name(ea) or "") - - -def _set_name(context: OperationContext, request: NameSetRequest) -> NameMutationResult: - runtime = context.runtime - ida_name = runtime.mod("ida_name") - ea = runtime.resolve_address(request.identifier) - if not ida_name.set_name(ea, request.new_name, ida_name.SN_CHECK): - raise IdaOperationError(f"failed to set name at {hex(ea)}") - return NameMutationResult(address=hex(ea), name=request.new_name, changed=True) - - -def _restore_name( - context: OperationContext, - request: NameSetRequest, - before: NameState, - result: NameMutationResult, -) -> None: - del result - runtime = context.runtime - ida_name = runtime.mod("ida_name") - ea = runtime.resolve_address(request.identifier) - if not ida_name.set_name(ea, before.name, ida_name.SN_CHECK): - raise IdaOperationError(f"failed to restore name at {hex(ea)}") - - -def name_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="name_set", - parse=_parse_name_set, - run=_set_name, - mutating=True, - preview=PreviewSpec( - capture_before=_name_state, - capture_after=_name_state, - rollback=_restore_name, - prepare=_prepare_name_set, - ), - ), - ) - - -__all__ = [ - "NameMutationResult", - "NameSetRequest", - "NameState", - "name_operations", -] diff --git a/src/idac/ops/families/prototypes.py b/src/idac/ops/families/prototypes.py deleted file mode 100644 index 4fcb90c..0000000 --- a/src/idac/ops/families/prototypes.py +++ /dev/null @@ -1,415 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.params import require_str -from ..preview import PreviewSpec -from ..runtime import ( - IdaOperationError, - IdaRuntime, - suppress_recoverable_ida_errors, -) - -_PROTO_BUILTIN_TOKENS = { - "__cdecl", - "__fastcall", - "__hidden", - "__int128", - "__int16", - "__int32", - "__int64", - "__int8", - "__noreturn", - "__pascal", - "__ptr32", - "__ptr64", - "__stdcall", - "__thiscall", - "__usercall", - "__userpurge", - "__return_ptr", - "bool", - "char", - "class", - "const", - "double", - "enum", - "float", - "int", - "long", - "short", - "signed", - "size_t", - "struct", - "u16", - "u32", - "u64", - "u8", - "uint16_t", - "uint32_t", - "uint64_t", - "uint8_t", - "uintptr_t", - "union", - "unsigned", - "void", - "volatile", - "wchar_t", - "s8", - "s16", - "s32", - "s64", - "int8_t", - "int16_t", - "int32_t", - "int64_t", -} - - -@dataclass(frozen=True) -class PrototypeGetRequest: - identifier: str - - -@dataclass(frozen=True) -class PrototypeSetRequest: - identifier: str - decl: str - preview_decompile: bool = False - propagate_callers: bool = False - - -@dataclass(frozen=True) -class PrototypeCheckRequest: - identifier: str - decl: str - - -@dataclass(frozen=True) -class PrototypeView: - address: str - prototype: str - - -@dataclass(frozen=True) -class PrototypePreviewView: - address: str - prototype: str - decompile: str - - -@dataclass(frozen=True) -class PrototypePreviewErrorView: - address: str - prototype: str - decompile: None - decompile_error: str - - -@dataclass(frozen=True) -class PrototypeMutationResult: - address: str - prototype: str - changed: bool - callers_considered: int = 0 - callers_updated: int = 0 - callers_failed: int = 0 - - -@dataclass(frozen=True) -class PrototypeCheckResult: - address: str - success: bool - parsed: bool - is_function: bool - arglocs_calculated: bool | None - unknown_types: tuple[str, ...] - diagnostics: tuple[str, ...] - - -def _require_identifier(params: Mapping[str, Any]) -> str: - return require_str(params.get("identifier"), field="address or identifier") - - -def _parse_proto_get(params: Mapping[str, Any]) -> PrototypeGetRequest: - return PrototypeGetRequest(identifier=_require_identifier(params)) - - -def _parse_proto_set(params: Mapping[str, Any]) -> PrototypeSetRequest: - decl = str(params.get("decl") or "") - if not decl: - raise IdaOperationError("prototype declaration is required") - return PrototypeSetRequest( - identifier=_require_identifier(params), - decl=decl, - preview_decompile=bool(params.get("preview_decompile")), - propagate_callers=bool(params.get("propagate_callers")), - ) - - -def _parse_proto_check(params: Mapping[str, Any]) -> PrototypeCheckRequest: - decl = str(params.get("decl") or "") - if not decl: - raise IdaOperationError("prototype declaration is required") - return PrototypeCheckRequest(identifier=_require_identifier(params), decl=decl) - - -def _prototype_view( - context: OperationContext, - request: PrototypeGetRequest | PrototypeSetRequest, -) -> PrototypeView | PrototypePreviewView | PrototypePreviewErrorView: - runtime = context.runtime - ea = runtime.function_ea(request.identifier) - prototype = runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "" - if not isinstance(request, PrototypeSetRequest) or not request.preview_decompile: - return PrototypeView(address=hex(ea), prototype=prototype) - try: - cfunc = runtime.require_hexrays().decompile(ea) - if cfunc is None: - raise IdaOperationError(f"failed to decompile function at {hex(ea)}") - except Exception as exc: - detail = str(exc) or exc.__class__.__name__ - return PrototypePreviewErrorView( - address=hex(ea), - prototype=prototype, - decompile=None, - decompile_error=detail, - ) - return PrototypePreviewView(address=hex(ea), prototype=prototype, decompile=runtime.pseudocode_text(cfunc)) - - -def _propagate_callee_tinfo(runtime: IdaRuntime, callee_ea: int, tif) -> tuple[int, int, int]: - callers_considered = 0 - callers_updated = 0 - callers_failed = 0 - seen: set[int] = set() - for ref in runtime.idautils.CodeRefsTo(callee_ea, 0): - if ref in seen: - continue - seen.add(ref) - insn = runtime.ida_ua.insn_t() - if not runtime.ida_ua.decode_insn(insn, ref): - continue - if not runtime.ida_idp.is_call_insn(insn): - continue - callers_considered += 1 - if runtime.ida_typeinf.apply_callee_tinfo(ref, tif): - callers_updated += 1 - else: - callers_failed += 1 - return callers_considered, callers_updated, callers_failed - - -def _unknown_proto_types(runtime: IdaRuntime, decl: str) -> list[str]: - working = decl.strip().rstrip(";") - working = re.sub(r"@<[^>]+>", "", working) - header, _, params_text = working.partition("(") - prefix = header - if prefix: - parts = prefix.split() - if parts: - prefix = " ".join(parts[:-1]) - param_chunks = [prefix] - if params_text: - params_body = params_text.rsplit(")", 1)[0] - for raw_param in params_body.split(","): - segment = raw_param.strip() - if not segment or segment == "void": - continue - param_name = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\s*$", segment) - if param_name: - segment = segment[: param_name.start()].strip() - param_chunks.append(segment) - - unknown: list[str] = [] - seen: set[str] = set() - for chunk in param_chunks: - for token in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", chunk): - if token in _PROTO_BUILTIN_TOKENS or token in seen: - continue - if runtime.find_named_type(token) is not None: - continue - seen.add(token) - unknown.append(token) - return unknown - - -def _decl_looks_like_destructor(decl: str) -> bool: - return bool(re.search(r"~[A-Za-z_][A-Za-z0-9_]*\s*\(", decl)) - - -def _parse_prototype_decl(runtime: IdaRuntime, decl: str): - ida_typeinf = runtime.ida_typeinf - parse_text = decl.strip() - if not parse_text.endswith(";"): - parse_text += ";" - base_flags = ida_typeinf.PT_VAR | ida_typeinf.PT_SIL | ida_typeinf.PT_SEMICOLON - flag_candidates = [base_flags] - relaxed_flags = base_flags | getattr(ida_typeinf, "PT_RELAXED", 0) - if "::" in parse_text and relaxed_flags != base_flags: - flag_candidates.append(relaxed_flags) - for parse_flags in flag_candidates: - tif = ida_typeinf.tinfo_t() - if ida_typeinf.parse_decl(tif, None, parse_text, parse_flags): - return tif - return None - - -def _prototype_arglocs_ok(runtime: IdaRuntime, tif) -> bool | None: - if not getattr(tif, "is_func", lambda: False)(): - return False - ida_typeinf = runtime.ida_typeinf - ftd_factory = getattr(ida_typeinf, "func_type_data_t", None) - if not callable(ftd_factory): - return None - details = ftd_factory() - get_details = getattr(tif, "get_func_details", None) - if not callable(get_details): - return None - flags = getattr(ida_typeinf, "GTD_CALC_ARGLOCS", 0) - try: - return bool(get_details(details, flags)) - except TypeError: - return bool(get_details(details)) - - -def _mark_prototype_dirty(runtime: IdaRuntime, ea: int, *, include_callers: bool) -> None: - try: - ida_hexrays = runtime.require_hexrays() - except Exception: - return - with suppress_recoverable_ida_errors(): - ida_hexrays.mark_cfunc_dirty(ea, False) - if include_callers: - for ref in runtime.idautils.CodeRefsTo(ea, 0): - caller = runtime.ida_funcs.get_func(ref) - if caller is not None: - ida_hexrays.mark_cfunc_dirty(caller.start_ea, False) - ida_hexrays.clear_cached_cfuncs() - - -def _proto_get(context: OperationContext, request: PrototypeGetRequest) -> PrototypeView: - viewed = _prototype_view(context, request) - if isinstance(viewed, PrototypeView): - return viewed - raise IdaOperationError("internal error: expected a prototype view for proto_get") - - -def _proto_check(context: OperationContext, request: PrototypeCheckRequest) -> PrototypeCheckResult: - runtime = context.runtime - ea = runtime.function_ea(request.identifier) - unknown_types = tuple(_unknown_proto_types(runtime, request.decl)) - diagnostics: list[str] = [] - tif = _parse_prototype_decl(runtime, request.decl) - parsed = tif is not None - is_function = False - arglocs_calculated: bool | None = None - if tif is None: - if unknown_types: - diagnostics.append("unknown type(s): " + ", ".join(unknown_types)) - else: - diagnostics.append("IDA failed to parse the prototype declaration") - else: - is_function = bool(getattr(tif, "is_func", lambda: False)()) - if not is_function: - diagnostics.append("declaration parsed but did not produce a function type") - arglocs_calculated = _prototype_arglocs_ok(runtime, tif) - if arglocs_calculated is False: - diagnostics.append("IDA could not calculate function argument locations") - return PrototypeCheckResult( - address=hex(ea), - success=parsed and is_function and arglocs_calculated is not False, - parsed=parsed, - is_function=is_function, - arglocs_calculated=arglocs_calculated, - unknown_types=unknown_types, - diagnostics=tuple(diagnostics), - ) - - -def _proto_set(context: OperationContext, request: PrototypeSetRequest) -> PrototypeMutationResult: - runtime = context.runtime - ea = runtime.function_ea(request.identifier) - decl = request.decl - original_name = runtime.ida_name.get_name(ea) or "" - unknown_types = _unknown_proto_types(runtime, decl) - tif = _parse_prototype_decl(runtime, decl) - if tif is None: - if unknown_types: - rendered = ", ".join(unknown_types) - raise IdaOperationError(f"failed to apply prototype at {hex(ea)}; unknown type(s): {rendered}") - current_prototype = runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "" - raise IdaOperationError( - f"failed to apply prototype at {hex(ea)}; current prototype: {current_prototype or ''}; " - "check declaration syntax, parser limitations, missing support types, " - "and retry after `function prototype show`" - ) - if not runtime.ida_typeinf.apply_tinfo(ea, tif, runtime.ida_typeinf.TINFO_DEFINITE): - current_prototype = runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "" - raise IdaOperationError( - f"failed to apply prototype at {hex(ea)}; current prototype: {current_prototype or ''}; " - "parsed declaration successfully but apply_tinfo failed" - ) - callers_considered = 0 - callers_updated = 0 - callers_failed = 0 - if request.propagate_callers: - callers_considered, callers_updated, callers_failed = _propagate_callee_tinfo(runtime, ea, tif) - _mark_prototype_dirty(runtime, ea, include_callers=request.propagate_callers) - normalized_decl = decl if decl.endswith(";") else f"{decl};" - if original_name and _decl_looks_like_destructor(normalized_decl): - current_name = runtime.ida_name.get_name(ea) or "" - if current_name and current_name != original_name: - with suppress_recoverable_ida_errors(): - runtime.ida_name.set_name(ea, original_name, runtime.ida_name.SN_CHECK) - return PrototypeMutationResult( - address=hex(ea), - prototype=runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "", - changed=True, - callers_considered=callers_considered, - callers_updated=callers_updated, - callers_failed=callers_failed, - ) - - -def prototype_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="proto_get", - parse=_parse_proto_get, - run=_proto_get, - ), - OperationSpec( - name="proto_set", - parse=_parse_proto_set, - run=_proto_set, - mutating=True, - preview=PreviewSpec( - capture_before=_prototype_view, - capture_after=_prototype_view, - use_undo=True, - ), - ), - OperationSpec( - name="proto_check", - parse=_parse_proto_check, - run=_proto_check, - ), - ) - - -__all__ = [ - "PrototypeCheckRequest", - "PrototypeCheckResult", - "PrototypeGetRequest", - "PrototypeMutationResult", - "PrototypePreviewErrorView", - "PrototypePreviewView", - "PrototypeSetRequest", - "PrototypeView", - "prototype_operations", -] diff --git a/src/idac/ops/families/search.py b/src/idac/ops/families/search.py deleted file mode 100644 index 39556fe..0000000 --- a/src/idac/ops/families/search.py +++ /dev/null @@ -1,504 +0,0 @@ -from __future__ import annotations - -import contextlib -import os -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.matching import pattern_from_params, text_matches -from ..helpers.params import optional_param_int, optional_str, require_str -from ..runtime import IdaOperationError, IdaRuntime, SegmentRange - -MEBIBYTE = 1024 * 1024 -MAX_DSC_STRING_SCAN_BYTES = 16 * MEBIBYTE - - -@dataclass(frozen=True) -class SearchBytesRequest: - pattern: str - segment: str - start: str | None - end: str | None - limit: int - - -@dataclass(frozen=True) -class SearchMatch: - address: str - - -@dataclass(frozen=True) -class SearchMatchInFunction: - address: str - function: str - - -@dataclass(frozen=True) -class SearchBytesResult: - pattern: str - segment: str - start: str - end: str - limit: int - truncated: bool - ranges: tuple[SearchScopeRange, ...] - results: tuple[SearchMatch | SearchMatchInFunction, ...] - - -@dataclass(frozen=True) -class XrefsRequest: - identifier: str - - -@dataclass(frozen=True) -class XrefRow: - from_: str - to: str - type: str - kind: str - user: bool - function: str | None = None - - -@dataclass(frozen=True) -class StringsRequest: - pattern: str - glob: bool - regex: bool - ignore_case: bool - scan: bool - segment: str - start: str | None - end: str | None - - -@dataclass(frozen=True) -class StringRow: - address: str - text: str - - -@dataclass(frozen=True) -class SearchScopeRange: - name: str - start: str - end: str - - -@dataclass(frozen=True) -class ImportEntry: - address: str - name: str - ordinal: int - - -@dataclass(frozen=True) -class ImportModule: - module: str - entries: tuple[ImportEntry, ...] - - -def _require_identifier(params: Mapping[str, Any], *, key: str = "identifier") -> str: - return require_str(params.get(key), field="address or identifier") - - -def _require_segment_selector(params: Mapping[str, Any]) -> str: - return require_str(params.get("segment"), field="segment selector") - - -def _parse_search_bytes(params: Mapping[str, Any]) -> SearchBytesRequest: - pattern = require_str(params.get("pattern"), field="byte pattern") - segment = _require_segment_selector(params) - start = optional_str(params.get("start")) - end = optional_str(params.get("end")) - limit = optional_param_int(params, "limit", label="search result limit", minimum=1) or 100 - return SearchBytesRequest(pattern=pattern, segment=segment, start=start, end=end, limit=limit) - - -def _match_row(address: int, function: str | None) -> SearchMatch | SearchMatchInFunction: - if function: - return SearchMatchInFunction(address=hex(address), function=function) - return SearchMatch(address=hex(address)) - - -def _bin_search_address(result: Any) -> int: - if isinstance(result, tuple): - return int(result[0]) - return int(result) - - -def _search_bytes_from_cursor(runtime: IdaRuntime, start: int, end: int, compiled_pattern, flags: int) -> int: - return _bin_search_address(runtime.mod("ida_bytes").bin_search(start, end, compiled_pattern, flags)) - - -def _has_more_search_bytes_matches( - runtime: IdaRuntime, - *, - ranges: tuple[SegmentRange, ...], - start_index: int, - cursor: int, - compiled_pattern, - flags: int, - badaddr: int, -) -> bool: - for index in range(start_index, len(ranges)): - scope = ranges[index] - scope_cursor = cursor if index == start_index else scope.start_ea - if scope_cursor >= scope.end_ea: - continue - match_ea = _search_bytes_from_cursor(runtime, scope_cursor, scope.end_ea, compiled_pattern, flags) - if match_ea != badaddr: - return True - return False - - -def _search_bytes(context: OperationContext, request: SearchBytesRequest) -> SearchBytesResult: - runtime = context.runtime - ida_bytes = runtime.mod("ida_bytes") - idaapi = runtime.mod("idaapi") - ranges = runtime.resolve_segment_ranges( - request.segment, - start=request.start, - end=request.end, - require_bounds=False, - ) - flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOBREAK | ida_bytes.BIN_SEARCH_NOSHOW - compiled_pattern = runtime.compile_binpat(request.pattern, ea=ranges[0].start_ea) - - rows: list[SearchMatch | SearchMatchInFunction] = [] - truncated = False - for index, scope in enumerate(ranges): - cursor = scope.start_ea - while cursor < scope.end_ea and len(rows) < request.limit: - match_ea = _search_bytes_from_cursor(runtime, cursor, scope.end_ea, compiled_pattern, flags) - if match_ea == idaapi.BADADDR: - break - func = runtime.ida_funcs.get_func(match_ea) - function_name = None if func is None else runtime.function_name(func.start_ea) - rows.append(_match_row(match_ea, function_name)) - cursor = match_ea + 1 - if len(rows) == request.limit: - truncated = _has_more_search_bytes_matches( - runtime, - ranges=ranges, - start_index=index, - cursor=cursor, - compiled_pattern=compiled_pattern, - flags=flags, - badaddr=idaapi.BADADDR, - ) - break - return SearchBytesResult( - pattern=request.pattern, - segment=request.segment, - start=hex(ranges[0].start_ea), - end=hex(ranges[-1].end_ea), - limit=request.limit, - truncated=truncated, - ranges=tuple( - SearchScopeRange(name=item.name, start=hex(item.start_ea), end=hex(item.end_ea)) for item in ranges - ), - results=tuple(rows), - ) - - -def _parse_xrefs(params: Mapping[str, Any]) -> XrefsRequest: - return XrefsRequest(identifier=_require_identifier(params)) - - -def _xrefs( - context: OperationContext, - request: XrefsRequest, -) -> tuple[XrefRow, ...]: - runtime = context.runtime - ea = runtime.resolve_address(request.identifier) - rows: list[XrefRow] = [] - seen: set[tuple[str, str, str, str, bool, str | None]] = set() - for flags in ( - runtime.ida_xref.XREF_FLOW, - runtime.ida_xref.XREF_CODE, - runtime.ida_xref.XREF_DATA, - ): - for ref in runtime.xrefs_to(ea, flags=flags): - func = runtime.ida_funcs.get_func(ref.from_ea) - row = XrefRow( - from_=hex(ref.from_ea), - to=hex(ref.to_ea), - type=ref.type, - kind=ref.kind, - user=ref.user, - function=None if func is None else runtime.function_name(func.start_ea), - ) - key = (row.from_, row.to, row.type, row.kind, row.user, row.function) - if key in seen: - continue - seen.add(key) - rows.append(row) - rows.sort(key=lambda item: (item.kind, item.from_)) - return tuple(rows) - - -def _parse_strings(params: Mapping[str, Any]) -> StringsRequest: - pattern, glob, regex, ignore_case = pattern_from_params(params) - segment = _require_segment_selector(params) - start = optional_str(params.get("start")) - end = optional_str(params.get("end")) - return StringsRequest( - pattern=pattern, - glob=glob, - regex=regex, - ignore_case=ignore_case, - scan=bool(params.get("scan")), - segment=segment, - start=start, - end=end, - ) - - -def _string_text(runtime: IdaRuntime, ea: int, length: int, strtype: int) -> str: - value = runtime.ida_bytes.get_strlit_contents(ea, length, strtype) - if value is None: - return "" - return value.decode("UTF-8", "replace") - - -def _defined_string_rows( - runtime: IdaRuntime, - *, - ranges: tuple[SegmentRange, ...], - pattern: str, - glob: bool, - regex: bool, - ignore_case: bool, -) -> tuple[StringRow, ...]: - ida_strlist = runtime.ida_strlist - known_types = ( - "STRTYPE_TERMCHR", - "STRTYPE_C", - "STRTYPE_C_16", - "STRTYPE_C_32", - "STRTYPE_PASCAL", - "STRTYPE_PASCAL_16", - "STRTYPE_PASCAL_32", - "STRTYPE_LEN2", - "STRTYPE_LEN2_16", - "STRTYPE_LEN2_32", - "STRTYPE_LEN4", - "STRTYPE_LEN4_16", - "STRTYPE_LEN4_32", - ) - string_types: list[int] = [] - seen_types: set[int] = set() - for name in known_types: - value = getattr(runtime.ida_nalt, name, None) - if not isinstance(value, int) or value in seen_types: - continue - seen_types.add(value) - string_types.append(value) - - options = ida_strlist.get_strlist_options() - saved_strtypes = list(options.strtypes) - saved_minlen = int(options.minlen) - saved_display_only_existing_strings = bool(options.display_only_existing_strings) - saved_only_7bit = bool(options.only_7bit) - saved_ignore_heads = bool(options.ignore_heads) - - rows: list[StringRow] = [] - try: - options.strtypes = string_types or [runtime.ida_nalt.STRTYPE_C] - options.minlen = 1 - options.display_only_existing_strings = True - options.only_7bit = False - options.ignore_heads = False - ida_strlist.build_strlist() - - item = ida_strlist.string_info_t() - for index in range(int(ida_strlist.get_strlist_qty())): - if not ida_strlist.get_strlist_item(item, index): - continue - ea = int(item.ea) - if not runtime.ea_in_ranges(ea, ranges): - continue - text = _string_text(runtime, ea, int(item.length), int(item.type)) - if text_matches(text, pattern=pattern, glob=glob, regex=regex, ignore_case=ignore_case): - rows.append(StringRow(address=hex(ea), text=text)) - finally: - options.strtypes = saved_strtypes - options.minlen = saved_minlen - options.display_only_existing_strings = saved_display_only_existing_strings - options.only_7bit = saved_only_7bit - options.ignore_heads = saved_ignore_heads - ida_strlist.build_strlist() - return tuple(rows) - - -def _scan_string_rows( - runtime: IdaRuntime, - *, - ranges: tuple[SegmentRange, ...], - pattern: str, - glob: bool, - regex: bool, - ignore_case: bool, -) -> tuple[StringRow, ...]: - rows: list[StringRow] = [] - for scope in ranges: - cursor = scope.start_ea - while cursor < scope.end_ea: - length = runtime.ida_bytes.get_max_strlit_length( - cursor, - runtime.ida_nalt.STRTYPE_C, - runtime.ida_bytes.ALOPT_IGNHEADS, - ) - if length < 5 or (cursor + length) > scope.end_ea: - cursor += 1 - continue - text = _string_text(runtime, cursor, length, runtime.ida_nalt.STRTYPE_C) - if not text: - cursor += 1 - continue - if not text_matches(text, pattern=pattern, glob=glob, regex=regex, ignore_case=ignore_case): - cursor += max(length, 1) - continue - rows.append(StringRow(address=hex(cursor), text=text)) - cursor += max(length, 1) - return tuple(rows) - - -def _input_file_basename(runtime: IdaRuntime) -> str: - for module_name in ("idaapi", "ida_nalt"): - with contextlib.suppress(Exception): - module = runtime.mod(module_name) - getter = getattr(module, "get_input_file_path", None) - if not callable(getter): - continue - path = str(getter() or "").strip() - if path: - return os.path.basename(path) - return "" - - -def _is_current_file_dsc(runtime: IdaRuntime) -> bool: - basename = _input_file_basename(runtime).lower() - return basename == "dyld_shared_cache" or basename.startswith("dyld_shared_cache_") - - -def _range_total_size(ranges: tuple[SegmentRange, ...]) -> int: - return sum(max(0, item.end_ea - item.start_ea) for item in ranges) - - -def _validate_dsc_string_scan_ranges(ranges: tuple[SegmentRange, ...]) -> None: - requested = _range_total_size(ranges) - if requested <= MAX_DSC_STRING_SCAN_BYTES: - return - raise IdaOperationError( - "dyld shared cache string scans are limited to " - f"{MAX_DSC_STRING_SCAN_BYTES // MEBIBYTE} MiB; requested {requested // MEBIBYTE} MiB" - ) - - -def _strings(context: OperationContext, request: StringsRequest) -> tuple[StringRow, ...]: - runtime = context.runtime - is_dsc = _is_current_file_dsc(runtime) - ranges = runtime.resolve_segment_ranges( - request.segment, - start=request.start, - end=request.end, - require_bounds=is_dsc and request.scan, - missing_message="dyld shared cache string scan requires both start and end addresses", - ) - if request.scan: - if is_dsc: - _validate_dsc_string_scan_ranges(ranges) - return _scan_string_rows( - runtime, - ranges=ranges, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ) - if is_dsc: - raise IdaOperationError( - "defined string listing is disabled for dyld shared caches; use " - "`search strings --scan --segment ... --start ... --end ...` with a range of at most " - f"{MAX_DSC_STRING_SCAN_BYTES // MEBIBYTE} MiB" - ) - return _defined_string_rows( - runtime, - ranges=ranges, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ) - - -def _parse_imports(_params: Mapping[str, Any]) -> None: - return None - - -def _imports(context: OperationContext, request: None) -> tuple[ImportModule, ...]: - del request - runtime = context.runtime - modules: list[ImportModule] = [] - for index in range(runtime.ida_nalt.get_import_module_qty()): - module_name = runtime.ida_nalt.get_import_module_name(index) or "" - entries: list[ImportEntry] = [] - - def imp_cb(ea: int, name: str | None, ordinal: int, entries: list[ImportEntry] = entries) -> bool: - entries.append( - ImportEntry( - address=hex(ea), - name=name or f"ordinal_{ordinal}", - ordinal=ordinal, - ) - ) - return True - - runtime.ida_nalt.enum_import_names(index, imp_cb) - modules.append(ImportModule(module=module_name, entries=tuple(entries))) - return tuple(modules) - - -def search_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="search_bytes", - parse=_parse_search_bytes, - run=_search_bytes, - ), - OperationSpec( - name="xrefs", - parse=_parse_xrefs, - run=_xrefs, - ), - OperationSpec( - name="strings", - parse=_parse_strings, - run=_strings, - ), - OperationSpec( - name="imports", - parse=_parse_imports, - run=_imports, - ), - ) - - -__all__ = [ - "ImportEntry", - "ImportModule", - "SearchBytesRequest", - "SearchBytesResult", - "SearchMatch", - "SearchMatchInFunction", - "SearchScopeRange", - "StringRow", - "StringsRequest", - "XrefRow", - "XrefsRequest", - "search_operations", -] diff --git a/src/idac/ops/families/segments.py b/src/idac/ops/families/segments.py deleted file mode 100644 index 4d5365d..0000000 --- a/src/idac/ops/families/segments.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from ..base import OperationContext, OperationSpec -from ..helpers.matching import pattern_from_params, text_matches -from ..runtime import IdaOperationError - - -@dataclass(frozen=True) -class SegmentListRequest: - pattern: str - glob: bool - regex: bool - ignore_case: bool - - -@dataclass(frozen=True) -class SegmentListEntry: - name: str - start: str - end: str - size: int - - -def _parse_segment_list(params: Mapping[str, Any]) -> SegmentListRequest: - pattern, glob, regex, ignore_case = pattern_from_params(params) - if regex and pattern: - try: - re.compile(pattern) - except re.error as exc: - raise IdaOperationError(f"invalid segment regex: {exc}") from exc - return SegmentListRequest(pattern=pattern, glob=glob, regex=regex, ignore_case=ignore_case) - - -def _segment_list( - context: OperationContext, - request: SegmentListRequest, -) -> tuple[SegmentListEntry, ...]: - rows: list[SegmentListEntry] = [] - for segment in context.runtime.iter_segments(): - if request.pattern and not text_matches( - segment.name, - pattern=request.pattern, - glob=request.glob, - regex=request.regex, - ignore_case=request.ignore_case, - ): - continue - rows.append( - SegmentListEntry( - name=segment.name, - start=hex(segment.start_ea), - end=hex(segment.end_ea), - size=segment.end_ea - segment.start_ea, - ) - ) - return tuple(rows) - - -def segment_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="segment_list", - parse=_parse_segment_list, - run=_segment_list, - ), - ) - - -__all__ = [ - "SegmentListEntry", - "SegmentListRequest", - "segment_operations", -] diff --git a/src/idac/ops/families/type_declare.py b/src/idac/ops/families/type_declare.py deleted file mode 100644 index a734574..0000000 --- a/src/idac/ops/families/type_declare.py +++ /dev/null @@ -1,1071 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, TypedDict, cast - -from ..base import OperationContext, OperationSpec -from ..helpers.params import parse_aliases -from ..preview import PreviewSpec -from ..runtime import IdaOperationError, IdaRuntime, ida_undo_restore_point - -_FORWARD_DECL_RE = re.compile( - r"^\s*(?:typedef\s+)?(?:struct|class|union)\s+" - r"(?P[A-Za-z_][A-Za-z0-9_:]*)" - r"(?:\s+(?P[A-Za-z_][A-Za-z0-9_:]*))?\s*;\s*$", - re.DOTALL, -) -_CONCRETE_TYPE_RE = re.compile( - r"\b(?:struct|class|union|enum)\s+(?P[A-Za-z_][A-Za-z0-9_:]*)\s*\{", - re.DOTALL, -) -_TYPEDEF_ALIAS_RE = re.compile( - r"^\s*typedef\s+(?:struct|class|union|enum)\s+" - r"(?:(?P[A-Za-z_][A-Za-z0-9_:]*)\s*)?" - r"\{.*\}\s*(?P[A-Za-z_][A-Za-z0-9_:]*)\s*;\s*$", - re.DOTALL, -) -_TYPEDEF_FUNC_ALIAS_RE = re.compile( - r"^\s*typedef\b.*\(\s*\*\s*(?P[A-Za-z_][A-Za-z0-9_:]*)\s*\)\s*\([^;]*\)\s*;\s*$", - re.DOTALL, -) -_TYPEDEF_SIMPLE_ALIAS_RE = re.compile( - r"^\s*typedef\b.*?\b(?P[A-Za-z_][A-Za-z0-9_:]*)\s*;\s*$", - re.DOTALL, -) -_BY_VALUE_MEMBER_RE = re.compile( - r"^(?:typedef\s+)?" - r"(?:(?:const|volatile|mutable|signed|unsigned|short|long)\s+)*" - r"(?:(?:struct|class|union)\s+)?" - r"(?P[A-Za-z_][A-Za-z0-9_:]*)" - r"(?:\s+(?:const|volatile))*\s+" - r"(?P[A-Za-z_][A-Za-z0-9_]*)" - r"\s*(?:\[[^\]]+\])?\s*$", - re.DOTALL, -) -_BUILTIN_MEMBER_TYPES = { - "bool", - "char", - "double", - "float", - "int", - "long", - "short", - "signed", - "size_t", - "ssize_t", - "unsigned", - "void", - "wchar_t", - "__int8", - "__int16", - "__int32", - "__int64", - "__int128", -} - -TypeAlias = TypedDict("TypeAlias", {"from": str, "to": str}) -AppliedAlias = TypedDict("AppliedAlias", {"from": str, "to": str, "count": int}) - - -class DeclarationChunkDict(TypedDict): - text: str - start_line: int - end_line: int - terminated: bool - - -class BlockingMember(TypedDict): - type_name: str - member_name: str - - -class BisectTrial(TypedDict): - prefix_count: int - errors: int - success: bool - - -class TypeDiagnosticDict(TypedDict, total=False): - kind: str - message: str - line: int - end_line: int - snippet: str - construct: str - balance: int - - -class FailingDeclaration(TypedDict, total=False): - index: int - line: int - end_line: int - snippet: str - standalone_errors: int - standalone_success: bool - - -class TypeDeclareBisectResult(TypedDict, total=False): - requested: bool - supported: bool - mode: str - declaration_count: int - message: str - trials: list[BisectTrial] - diagnostics: list[TypeDiagnosticDict] - failing_declaration: FailingDeclaration - blocking_members: list[BlockingMember] - - -class TypeDeclareResult(TypedDict, total=False): - errors: int - replace: bool - check: bool - aliases_applied: list[AppliedAlias] - diagnostics: list[TypeDiagnosticDict] - imported_types: list[str] - replaced_types: list[str] - declaration_count: int - success: bool - bisect: TypeDeclareBisectResult | None - - -TypeDeclareDiagnostic = TypeDiagnosticDict -NamedTypeSnapshot = dict[str, str | None] - - -@dataclass(frozen=True) -class DeclarationChunk: - text: str - start_line: int - end_line: int - terminated: bool - - def to_dict(self) -> DeclarationChunkDict: - return { - "text": self.text, - "start_line": self.start_line, - "end_line": self.end_line, - "terminated": self.terminated, - } - - -@dataclass(frozen=True) -class TypeDiagnostic: - kind: str - message: str - line: int | None = None - end_line: int | None = None - snippet: str | None = None - construct: str | None = None - balance: int | None = None - - def to_dict(self) -> TypeDiagnosticDict: - item: TypeDiagnosticDict = {"kind": self.kind, "message": self.message} - if self.line is not None: - item["line"] = self.line - if self.end_line is not None: - item["end_line"] = self.end_line - if self.snippet: - item["snippet"] = self.snippet[:240] - if self.construct: - item["construct"] = self.construct - if self.balance is not None: - item["balance"] = self.balance - return item - - -@dataclass(frozen=True) -class TypeDeclareRequest: - decl: str - aliases: tuple[TypeAlias, ...] - replace: bool - bisect: bool - clang: bool - - -@dataclass(frozen=True) -class TypeDeclarePreviewSnapshot: - type_count: int - class_count: int - type_names_sample: tuple[str, ...] - - -def _named_type_map(rows: list[dict[str, Any]]) -> NamedTypeSnapshot: - return {item["name"]: item.get("decl") for item in rows} - - -def _named_types_snapshot(runtime: IdaRuntime) -> NamedTypeSnapshot: - return _named_type_map(runtime.list_named_types()) - - -def _strip_comments_preserve_lines(text: str) -> str: - out: list[str] = [] - in_string: str | None = None - in_line_comment = False - in_block_comment = False - i = 0 - while i < len(text): - ch = text[i] - nxt = text[i + 1] if i + 1 < len(text) else "" - if in_line_comment: - if ch == "\n": - out.append(ch) - in_line_comment = False - i += 1 - continue - if in_block_comment: - if ch == "*" and nxt == "/": - in_block_comment = False - i += 2 - continue - if ch == "\n": - out.append(ch) - i += 1 - continue - if in_string: - out.append(ch) - if ch == "\\" and nxt: - out.append(nxt) - i += 2 - continue - if ch == in_string: - in_string = None - i += 1 - continue - if ch == "/" and nxt == "/": - in_line_comment = True - i += 2 - continue - if ch == "/" and nxt == "*": - in_block_comment = True - i += 2 - continue - out.append(ch) - if ch in {"'", '"'}: - in_string = ch - i += 1 - return "".join(out) - - -def _strip_preprocessor_lines(text: str) -> str: - lines = text.splitlines(keepends=True) - out: list[str] = [] - in_directive_continuation = False - for line in lines: - stripped = line.lstrip() - is_directive = stripped.startswith("#") or in_directive_continuation - if is_directive: - newline = "\n" if line.endswith("\n") else "" - out.append(newline) - in_directive_continuation = line.rstrip().endswith("\\") - continue - in_directive_continuation = False - out.append(line) - return "".join(out) - - -def _sanitize_declaration_text(text: str) -> str: - return _strip_preprocessor_lines(_strip_comments_preserve_lines(text)) - - -def _parse_request(params: Mapping[str, Any]) -> TypeDeclareRequest: - decl = _sanitize_declaration_text(str(params.get("decl") or "")) - if not decl: - raise IdaOperationError("type declarations are required via --decl or --file") - try: - aliases = cast("tuple[TypeAlias, ...]", tuple(parse_aliases(params.get("aliases") or []))) - except ValueError as exc: - raise IdaOperationError(str(exc)) from exc - return TypeDeclareRequest( - decl=decl, - aliases=aliases, - replace=bool(params.get("replace")), - bisect=bool(params.get("bisect")), - clang=bool(params.get("clang")), - ) - - -def _join_declaration_chunks(chunks: list[DeclarationChunk]) -> str: - return "\n".join(chunk.text for chunk in chunks) - - -def _apply_type_aliases(decl: str, aliases: list[TypeAlias]) -> tuple[str, list[AppliedAlias]]: - updated = decl - applied: list[AppliedAlias] = [] - for alias in aliases: - src = alias["from"] - dst = alias["to"] - pattern = re.compile( - rf"(?:(?P(?:^|(?<=[^A-Za-z0-9_:]))::)|(?{re.escape(src)})(?![A-Za-z0-9_:])" - ) - - def replace(match: re.Match[str], *, replacement: str = dst) -> str: - prefix = match.group("global_prefix") or "" - return f"{prefix}{replacement}" - - updated, count = pattern.subn(replace, updated) - if count: - applied.append({"from": src, "to": dst, "count": count}) - return updated, applied - - -def _split_declarations(text: str) -> list[DeclarationChunkDict]: - chunks, _brace_balance = _parse_declaration_chunks(text) - return [chunk.to_dict() for chunk in chunks] - - -def _parse_declaration_chunks(text: str) -> tuple[list[DeclarationChunk], int]: - chunks: list[DeclarationChunk] = [] - current: list[str] = [] - line = 1 - start_line = 1 - brace_depth = 0 - paren_depth = 0 - in_string: str | None = None - in_line_comment = False - in_block_comment = False - i = 0 - while i < len(text): - ch = text[i] - nxt = text[i + 1] if i + 1 < len(text) else "" - current.append(ch) - if ch == "\n": - line += 1 - if in_line_comment: - if ch == "\n": - in_line_comment = False - i += 1 - continue - if in_block_comment: - if ch == "*" and nxt == "/": - in_block_comment = False - current.append(nxt) - i += 2 - continue - i += 1 - continue - if in_string: - if ch == "\\": - if nxt: - current.append(nxt) - if nxt == "\n": - line += 1 - i += 2 - continue - elif ch == in_string: - in_string = None - i += 1 - continue - if ch == "/" and nxt == "/": - current.append(nxt) - in_line_comment = True - i += 2 - continue - if ch == "/" and nxt == "*": - current.append(nxt) - in_block_comment = True - i += 2 - continue - if ch in {"'", '"'}: - in_string = ch - i += 1 - continue - if ch == "{": - brace_depth += 1 - elif ch == "}": - brace_depth = max(0, brace_depth - 1) - elif ch == "(": - paren_depth += 1 - elif ch == ")": - paren_depth = max(0, paren_depth - 1) - elif ch == ";" and brace_depth == 0 and paren_depth == 0: - raw = "".join(current) - stripped = raw.strip() - if stripped: - chunks.append(DeclarationChunk(stripped, start_line, line, True)) - current = [] - start_line = _next_chunk_start_line(text, i + 1, line) - i += 1 - tail = "".join(current).strip() - if tail: - chunks.append(DeclarationChunk(tail, start_line, line, False)) - return chunks, brace_depth - - -def _next_chunk_start_line(text: str, index: int, line: int) -> int: - next_line = line - i = index - while i < len(text): - ch = text[i] - if ch == "\n": - next_line += 1 - i += 1 - continue - if ch in {" ", "\t", "\r"}: - i += 1 - continue - break - return next_line - - -def _append_type_diagnostic( - diagnostics: list[TypeDiagnostic], - *, - kind: str, - message: str, - line: int | None = None, - end_line: int | None = None, - snippet: str | None = None, - construct: str | None = None, -) -> None: - diagnostics.append( - TypeDiagnostic( - kind=kind, - message=message, - line=line, - end_line=end_line, - snippet=snippet, - construct=construct, - ) - ) - - -def _chunk_type_diagnostics( - chunk: DeclarationChunk, - *, - aliases_applied: list[AppliedAlias], -) -> list[TypeDiagnostic]: - text = chunk.text - snippet = text[:240] - line = chunk.start_line - end_line = chunk.end_line - diagnostics: list[TypeDiagnostic] = [] - - if "__cppobj" in text: - _append_type_diagnostic( - diagnostics, - kind="cppobj_hint", - message=( - "IDA may reject `__cppobj` in local type imports; retry with plain `struct` " - "declarations and concrete placeholder support types" - ), - line=line, - end_line=end_line, - snippet=snippet, - construct="__cppobj", - ) - if re.match(r"^\s*(class|struct|union)\s+[A-Za-z_][A-Za-z0-9_:]*\s*;\s*$", text): - _append_type_diagnostic( - diagnostics, - kind="forward_declaration_hint", - message=( - "forward declarations are often insufficient here; import a concrete placeholder " - "definition instead of only `type_name;`" - ), - line=line, - end_line=end_line, - snippet=snippet, - construct="forward_declaration", - ) - if "__cppobj" not in text and re.search(r"\bclass\s+[A-Za-z_][A-Za-z0-9_:]*\b", text): - _append_type_diagnostic( - diagnostics, - kind="class_keyword_hint", - message=( - "if this is a recovered object layout, retry with plain `struct` declarations instead of `class` syntax" - ), - line=line, - end_line=end_line, - snippet=snippet, - construct="class", - ) - if "::" in text and not aliases_applied: - _append_type_diagnostic( - diagnostics, - kind="namespace_hint", - message="namespace-qualified identifiers may require --alias old=new before import", - line=line, - end_line=end_line, - snippet=snippet, - construct="::", - ) - return diagnostics - - -def _type_declare_diagnostics( - decl: str, - *, - errors: int, - aliases_applied: list[AppliedAlias], - chunks: list[DeclarationChunk] | None = None, - brace_balance: int | None = None, -) -> list[TypeDiagnosticDict]: - diagnostics: list[TypeDiagnostic] = [] - seen: set[tuple[str, int | None, str | None]] = set() - - def add_unique(item: TypeDiagnostic) -> None: - key = (item.kind, item.line, item.construct) - if key in seen: - return - seen.add(key) - diagnostics.append(item) - - if chunks is None or brace_balance is None: - parsed_chunks, parsed_brace_balance = _parse_declaration_chunks(decl) - if chunks is None: - chunks = parsed_chunks - if brace_balance is None: - brace_balance = parsed_brace_balance - resolved_chunks = chunks if chunks is not None else [] - resolved_brace_balance = 0 if brace_balance is None else brace_balance - for chunk in resolved_chunks: - if not chunk.terminated: - add_unique( - TypeDiagnostic( - kind="unterminated_declaration", - message="declaration does not end with a top-level semicolon", - line=chunk.start_line, - end_line=chunk.end_line, - snippet=chunk.text[:240], - ) - ) - if resolved_brace_balance > 0: - add_unique( - TypeDiagnostic( - kind="unbalanced_braces", - message="more opening braces than closing braces were found", - balance=resolved_brace_balance, - ) - ) - if errors: - for chunk in resolved_chunks: - for item in _chunk_type_diagnostics(chunk, aliases_applied=aliases_applied): - add_unique(item) - if errors and not diagnostics: - first = resolved_chunks[0] if resolved_chunks else DeclarationChunk(decl.strip(), 1, 1, False) - add_unique( - TypeDiagnostic( - kind="parser_error", - message=f"IDA reported {errors} parser error(s); rerun with smaller declaration batches if needed", - line=first.start_line, - end_line=first.end_line, - snippet=first.text[:240], - ) - ) - return [item.to_dict() for item in diagnostics] - - -def _parse_type_declarations(runtime: IdaRuntime, decl: str, *, replace: bool, clang: bool) -> int: - ida_typeinf = runtime.mod("ida_typeinf") - if not clang: - flags = ida_typeinf.PT_REPLACE if replace else 0 - return ida_typeinf.idc_parse_types(decl, flags) - - if replace: - return _parse_type_declarations_with_clang_replace(runtime, decl) - - return _parse_type_declarations_with_clang(runtime, decl) - - -def _parse_type_declarations_with_clang(runtime: IdaRuntime, decl: str) -> int: - ida_typeinf = runtime.mod("ida_typeinf") - ida_srclang = runtime.mod("ida_srclang") - hti_flags = _clang_parse_flags(ida_typeinf, decl, test=False) - errors = ida_srclang.parse_decls_with_parser_ext("clang", None, decl, hti_flags) - if errors < 0: - raise IdaOperationError("clang parser is unavailable for type declare") - return errors - - -def _clang_parse_flags(ida_typeinf: Any, decl: str, *, test: bool) -> int: - hti_flags = ida_typeinf.HTI_DCL | ida_typeinf.HTI_SEMICOLON - if test: - hti_flags |= getattr(ida_typeinf, "HTI_TST", 0) - if "::" in decl: - hti_flags |= getattr(ida_typeinf, "HTI_RELAXED", 0) - return hti_flags - - -def _test_type_declarations(runtime: IdaRuntime, decl: str, *, replace: bool, clang: bool) -> int: - ida_typeinf = runtime.mod("ida_typeinf") - if clang: - ida_srclang = runtime.mod("ida_srclang") - hti_flags = _clang_parse_flags(ida_typeinf, decl, test=True) - with ida_undo_restore_point( - runtime, - action_name="idac_type_check_clang", - label="idac type check clang", - unavailable_message="type check --clang requires IDA undo support", - restore_error_message="type check --clang could not restore the trial import via undo", - ): - errors = ida_srclang.parse_decls_with_parser_ext("clang", None, decl, hti_flags) - if errors < 0: - raise IdaOperationError("clang parser is unavailable for type check") - return errors - - parse_decls = getattr(ida_typeinf, "parse_decls", None) - if callable(parse_decls): - hti_flags = ida_typeinf.HTI_DCL | ida_typeinf.HTI_SEMICOLON | getattr(ida_typeinf, "HTI_TST", 0) - if "::" in decl: - hti_flags |= getattr(ida_typeinf, "HTI_RELAXED", 0) - return int(parse_decls(None, decl, None, hti_flags)) - - return _trial_type_parse_errors(runtime, decl, replace=replace, clang=False, label="check") - - -def _typedef_alias_names(text: str) -> set[str]: - names: set[str] = set() - func_alias_match = _TYPEDEF_FUNC_ALIAS_RE.match(text) - if func_alias_match: - alias = func_alias_match.group("alias") - if alias: - names.add(alias) - return names - simple_alias_match = _TYPEDEF_SIMPLE_ALIAS_RE.match(text) - if simple_alias_match: - alias = simple_alias_match.group("alias") - if alias: - names.add(alias) - return names - - -def _declared_type_names(chunks: list[DeclarationChunk]) -> set[str]: - names: set[str] = set() - for chunk in chunks: - names.update(_concrete_type_names(chunk.text)) - names.update(_forward_declared_type_names(chunk.text)) - names.update(_typedef_alias_names(chunk.text)) - return {name for name in names if name} - - -def _delete_named_types(runtime: IdaRuntime, type_names: set[str]) -> None: - if not type_names: - return - ida_typeinf = runtime.mod("ida_typeinf") - for name in sorted(type_names): - if runtime.find_named_type(name) is None: - continue - if not ida_typeinf.del_named_type(None, name, ida_typeinf.NTF_TYPE): - raise IdaOperationError(f"failed to replace existing local type: {name}") - - -def _parse_type_declarations_with_clang_replace(runtime: IdaRuntime, decl: str) -> int: - type_names = _declared_type_names(_parse_declaration_chunks(decl)[0]) - if not type_names: - return _parse_type_declarations_with_clang(runtime, decl) - - ida_undo = runtime.mod("ida_undo") - if not ida_undo.create_undo_point( - action_name="idac_type_declare_clang_replace", - label="idac type declare clang replace", - ): - raise IdaOperationError("type declare --clang --replace requires IDA undo support") - - try: - _delete_named_types(runtime, type_names) - errors = _parse_type_declarations_with_clang(runtime, decl) - except Exception as exc: - if not ida_undo.perform_undo(): - raise IdaOperationError( - "type declare --clang --replace could not restore deleted local types via undo" - ) from exc - raise - if errors and not ida_undo.perform_undo(): - raise IdaOperationError("type declare --clang --replace could not restore deleted local types via undo") - return errors - - -def _apply_type_declarations( - runtime: IdaRuntime, - decl: str, - *, - replace: bool, - clang: bool, -) -> tuple[int, NamedTypeSnapshot, NamedTypeSnapshot]: - before = _named_types_snapshot(runtime) - errors = _parse_type_declarations(runtime, decl, replace=replace, clang=clang) - return errors, before, _named_types_snapshot(runtime) - - -def _forward_declared_type_names(text: str) -> set[str]: - match = _FORWARD_DECL_RE.match(text) - if not match: - return set() - names = {match.group("tag")} - alias = match.group("alias") - if alias: - names.add(alias) - return {name for name in names if name} - - -def _concrete_type_names(text: str) -> set[str]: - names = {match.group("name") for match in _CONCRETE_TYPE_RE.finditer(text)} - alias_match = _TYPEDEF_ALIAS_RE.match(text) - if alias_match: - alias = alias_match.group("alias") - if alias: - names.add(alias) - tag = alias_match.group("tag") - if tag: - names.add(tag) - return {name for name in names if name} - - -def _opaque_by_value_members( - failing_chunk: DeclarationChunk, - *, - earlier_chunks: list[DeclarationChunk], -) -> list[BlockingMember]: - concrete: set[str] = set() - forward: set[str] = set() - for chunk in [*earlier_chunks, failing_chunk]: - text = chunk.text - concrete.update(_concrete_type_names(text)) - forward.update(_forward_declared_type_names(text)) - forward -= concrete - if not forward: - return [] - - owning_types = _concrete_type_names(failing_chunk.text) - rows: list[BlockingMember] = [] - for raw_stmt in failing_chunk.text.replace("\n", " ").split(";"): - stmt = raw_stmt.strip() - if not stmt or any(token in stmt for token in ("*", "&", "(")): - continue - if "{" in stmt: - stmt = stmt.rsplit("{", 1)[-1].strip() - if "}" in stmt: - stmt = stmt.split("}", 1)[0].strip() - if not stmt: - continue - match = _BY_VALUE_MEMBER_RE.match(stmt) - if not match: - continue - type_name = match.group("type") or "" - member_name = match.group("name") or "" - if not type_name or not member_name: - continue - if type_name in owning_types or type_name.lower() in _BUILTIN_MEMBER_TYPES: - continue - if type_name not in forward: - continue - rows.append({"type_name": type_name, "member_name": member_name}) - return rows - - -def _trial_type_parse_errors( - runtime: IdaRuntime, - decl: str, - *, - replace: bool, - clang: bool, - label: str, -) -> int: - action = re.sub(r"[^A-Za-z0-9_]+", "_", label).strip("_") or "trial" - with ida_undo_restore_point( - runtime, - action_name=f"idac_type_declare_{action}", - label=f"idac type declare {label}", - unavailable_message="type declare bisect requires IDA undo support", - restore_error_message="type declare bisect could not restore the trial import via undo", - ): - return _parse_type_declarations(runtime, decl, replace=replace, clang=clang) - - -def _bisect_type_declarations( - runtime: IdaRuntime, - chunks: list[DeclarationChunk], - *, - replace: bool, - clang: bool, -) -> TypeDeclareBisectResult: - result: TypeDeclareBisectResult = { - "requested": True, - "supported": True, - "mode": "ordered_prefix", - "declaration_count": len(chunks), - "trials": [], - } - if not chunks: - result["supported"] = False - result["message"] = "no declarations were available for bisect" - return result - - try: - if len(chunks) == 1: - failing_index = 0 - standalone_errors = _trial_type_parse_errors( - runtime, - _join_declaration_chunks([chunks[0]]), - replace=replace, - clang=clang, - label="single_decl", - ) - else: - low = 1 - high = len(chunks) - while low < high: - mid = (low + high) // 2 - errors = _trial_type_parse_errors( - runtime, - _join_declaration_chunks(chunks[:mid]), - replace=replace, - clang=clang, - label=f"prefix_{mid}", - ) - result["trials"].append({"prefix_count": mid, "errors": errors, "success": errors == 0}) - if errors: - high = mid - else: - low = mid + 1 - failing_index = low - 1 - standalone_errors = _trial_type_parse_errors( - runtime, - _join_declaration_chunks([chunks[failing_index]]), - replace=replace, - clang=clang, - label=f"single_{failing_index + 1}", - ) - except IdaOperationError as exc: - result["supported"] = False - result["message"] = str(exc) or exc.__class__.__name__ - result["diagnostics"] = [{"kind": "bisect_unavailable", "message": result["message"]}] - return result - - failing_chunk = chunks[failing_index] - diagnostics: list[TypeDiagnosticDict] = [ - { - "kind": "bisect_culprit", - "message": "ordered bisect isolated the first failing declaration", - "line": failing_chunk.start_line, - "end_line": failing_chunk.end_line, - "snippet": failing_chunk.text[:240], - } - ] - if standalone_errors == 0 and len(chunks) > 1: - diagnostics.append( - { - "kind": "bisect_context_hint", - "message": ( - "the isolated declaration imports alone; the failure depends on earlier " - "declarations or ordered batch context" - ), - "line": failing_chunk.start_line, - "end_line": failing_chunk.end_line, - "snippet": failing_chunk.text[:240], - } - ) - blocking_members = _opaque_by_value_members(failing_chunk, earlier_chunks=chunks[:failing_index]) - for member in blocking_members: - diagnostics.append( - { - "kind": "opaque_by_value_member_hint", - "message": ( - "by-value member " - f"`{member['member_name']}` uses forward-declared or opaque type " - f"`{member['type_name']}`; import a concrete placeholder definition first" - ), - "line": failing_chunk.start_line, - "end_line": failing_chunk.end_line, - "snippet": failing_chunk.text[:240], - "construct": member["type_name"], - } - ) - result["message"] = diagnostics[0]["message"] - result["failing_declaration"] = { - "index": failing_index + 1, - "line": failing_chunk.start_line, - "end_line": failing_chunk.end_line, - "snippet": failing_chunk.text[:240], - "standalone_errors": standalone_errors, - "standalone_success": standalone_errors == 0, - } - if blocking_members: - result["blocking_members"] = blocking_members - result["diagnostics"] = diagnostics - return result - - -def _bisect_unavailable_result( - chunks: list[DeclarationChunk], - exc: IdaOperationError, -) -> TypeDeclareBisectResult: - message = str(exc) or exc.__class__.__name__ - return { - "requested": True, - "supported": False, - "mode": "ordered_prefix", - "declaration_count": len(chunks), - "message": message, - "diagnostics": [{"kind": "bisect_unavailable", "message": message}], - } - - -def _apply_type_declarations_with_optional_bisect( - runtime: IdaRuntime, - decl: str, - *, - replace: bool, - clang: bool, - chunks: list[DeclarationChunk], - bisect_requested: bool, -) -> tuple[int, NamedTypeSnapshot, NamedTypeSnapshot, TypeDeclareBisectResult | None]: - if not bisect_requested: - errors, before, after = _apply_type_declarations(runtime, decl, replace=replace, clang=clang) - return errors, before, after, None - - try: - trial_errors = _trial_type_parse_errors(runtime, decl, replace=replace, clang=clang, label="full") - except IdaOperationError as exc: - before = _named_types_snapshot(runtime) - return 1, before, dict(before), _bisect_unavailable_result(chunks, exc) - - if trial_errors == 0: - errors, before, after = _apply_type_declarations(runtime, decl, replace=replace, clang=clang) - return errors, before, after, None - - before = _named_types_snapshot(runtime) - return ( - trial_errors, - before, - dict(before), - _bisect_type_declarations(runtime, chunks, replace=replace, clang=clang), - ) - - -def _type_declare_result( - decl: str, - *, - replace: bool, - errors: int, - before: NamedTypeSnapshot, - after: NamedTypeSnapshot, - aliases_applied: list[AppliedAlias], - chunks: list[DeclarationChunk], - brace_balance: int, - bisect: TypeDeclareBisectResult | None = None, -) -> TypeDeclareResult: - diagnostics = _type_declare_diagnostics( - decl, - errors=errors, - aliases_applied=aliases_applied, - chunks=chunks, - brace_balance=brace_balance, - ) - if bisect is not None: - existing = {(item["kind"], item.get("line"), item["message"]) for item in diagnostics} - for item in bisect.get("diagnostics") or []: - key = (item["kind"], item.get("line"), item["message"]) - if key in existing: - continue - diagnostics.append(item) - existing.add(key) - return { - "errors": errors, - "replace": replace, - "aliases_applied": aliases_applied, - "diagnostics": diagnostics, - "imported_types": sorted(set(after) - set(before)), - "replaced_types": sorted(name for name in set(after) & set(before) if before.get(name) != after.get(name)), - "declaration_count": len(chunks), - "success": errors == 0, - "bisect": bisect, - } - - -def _preview_snapshot( - context: OperationContext, - request: TypeDeclareRequest, -) -> TypeDeclarePreviewSnapshot: - del request - runtime = context.runtime - names = runtime.list_named_types() - return TypeDeclarePreviewSnapshot( - type_count=len(names), - class_count=len(runtime.list_named_classes()), - type_names_sample=tuple(str(item.get("name") or "") for item in names[:10]), - ) - - -def _type_declare(context: OperationContext, request: TypeDeclareRequest) -> TypeDeclareResult: - runtime = context.runtime - decl, aliases_applied = _apply_type_aliases(request.decl, list(request.aliases)) - chunks, brace_balance = _parse_declaration_chunks(decl) - errors, before, after, bisect = _apply_type_declarations_with_optional_bisect( - runtime, - decl, - replace=request.replace, - clang=request.clang, - chunks=chunks, - bisect_requested=request.bisect, - ) - return _type_declare_result( - decl, - replace=request.replace, - errors=errors, - before=before, - after=after, - aliases_applied=aliases_applied, - chunks=chunks, - brace_balance=brace_balance, - bisect=bisect, - ) - - -def _type_declare_check(context: OperationContext, request: TypeDeclareRequest) -> TypeDeclareResult: - runtime = context.runtime - decl, aliases_applied = _apply_type_aliases(request.decl, list(request.aliases)) - chunks, brace_balance = _parse_declaration_chunks(decl) - errors = _test_type_declarations(runtime, decl, replace=request.replace, clang=request.clang) - result = _type_declare_result( - decl, - replace=request.replace, - errors=errors, - before={}, - after={}, - aliases_applied=aliases_applied, - chunks=chunks, - brace_balance=brace_balance, - bisect=None, - ) - result["check"] = True - return result - - -def type_declare_operations() -> tuple[OperationSpec[Any, Any], ...]: - return ( - OperationSpec( - name="type_declare", - parse=_parse_request, - run=_type_declare, - mutating=True, - preview=PreviewSpec( - capture_before=_preview_snapshot, - capture_after=_preview_snapshot, - use_undo=True, - ), - ), - OperationSpec( - name="type_declare_check", - parse=_parse_request, - run=_type_declare_check, - ), - ) - - -__all__ = [ - "AppliedAlias", - "BlockingMember", - "DeclarationChunk", - "DeclarationChunkDict", - "TypeAlias", - "TypeDeclareBisectResult", - "TypeDeclareDiagnostic", - "TypeDeclarePreviewSnapshot", - "TypeDeclareRequest", - "TypeDeclareResult", - "type_declare_operations", -] diff --git a/src/idac/ops/helpers/matching.py b/src/idac/ops/helpers/matching.py deleted file mode 100644 index 91ab506..0000000 --- a/src/idac/ops/helpers/matching.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Mapping -from fnmatch import fnmatchcase -from typing import Any - - -def pattern_from_params(params: Mapping[str, Any]) -> tuple[str, bool, bool, bool]: - pattern = params.get("pattern") - if pattern not in (None, ""): - return str(pattern), bool(params.get("glob")), bool(params.get("regex")), bool(params.get("ignore_case")) - query = params.get("query") - if query in (None, ""): - return "", False, False, False - return str(query), False, False, True - - -def text_matches( - text: str, - *, - pattern: str, - glob: bool = False, - regex: bool = False, - ignore_case: bool = False, -) -> bool: - if not pattern: - return True - if glob and regex: - raise ValueError("glob and regex are mutually exclusive") - haystack = text - needle = pattern - if regex: - flags = re.IGNORECASE if ignore_case else 0 - try: - return re.search(needle, haystack, flags=flags) is not None - except re.error as exc: - raise ValueError(f"invalid regex pattern: {pattern}") from exc - if ignore_case: - haystack = haystack.lower() - needle = needle.lower() - if glob: - return fnmatchcase(haystack, needle) - return needle in haystack diff --git a/src/idac/ops/helpers/params.py b/src/idac/ops/helpers/params.py deleted file mode 100644 index 745005e..0000000 --- a/src/idac/ops/helpers/params.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from ..runtime import IdaOperationError - - -def parse_int_text( - value: Any, - *, - label: str, - minimum: int | None = None, -) -> int: - text = str(value).strip() - if not text: - raise ValueError(f"{label} is required") - try: - parsed = int(text, 0) - except ValueError as exc: - raise ValueError(f"{label} must be an integer") from exc - if minimum is not None and parsed < minimum: - raise ValueError(f"{label} must be greater than or equal to {minimum}") - return parsed - - -def param_int( - params: Mapping[str, Any], - key: str, - *, - label: str | None = None, - minimum: int | None = None, -) -> int: - name = key if label is None else label - try: - return parse_int_text(params.get(key), label=name, minimum=minimum) - except ValueError as exc: - raise IdaOperationError(str(exc)) from exc - - -def optional_param_int( - params: Mapping[str, Any], - key: str, - *, - label: str | None = None, - minimum: int | None = None, -) -> int | None: - value = params.get(key) - if value in (None, ""): - return None - return param_int(params, key, label=label, minimum=minimum) - - -def require_str(value: Any, *, field: str) -> str: - text = str(value or "").strip() - if not text: - raise IdaOperationError(f"{field} is required") - return text - - -def optional_str(value: Any) -> str | None: - return str(value or "").strip() or None - - -def parse_aliases(raw_aliases: Any) -> list[dict[str, str]]: - rows: list[dict[str, str]] = [] - for item in raw_aliases or []: - if isinstance(item, dict): - src = str(item.get("from") or "").strip() - dst = str(item.get("to") or "").strip() - raw = f"{src}={dst}" if src or dst else "" - else: - raw = str(item).strip() - src = dst = "" - if "=" in raw: - src, dst = (part.strip() for part in raw.split("=", 1)) - if not src or not dst: - text = raw or str(item) - raise ValueError(f"invalid alias `{text}`; expected OLD=NEW") - rows.append({"from": src, "to": dst}) - return rows - - -__all__ = [ - "optional_param_int", - "optional_str", - "param_int", - "parse_aliases", - "parse_int_text", - "require_str", -] diff --git a/src/idac/ops/manifest.py b/src/idac/ops/manifest.py deleted file mode 100644 index 1d8c806..0000000 --- a/src/idac/ops/manifest.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal, cast - -from .base import OperationSpec -from .families import ( - bookmark_operations, - class_operations, - comment_operations, - database_operations, - function_operations, - local_operations, - misc_operations, - name_operations, - named_type_operations, - prototype_operations, - search_operations, - segment_operations, - type_declare_operations, -) - -OperationName = Literal[ - "list_targets", - "database_info", - "db_save", - "segment_list", - "function_list", - "function_show", - "function_frame", - "function_stackvars", - "function_callers", - "function_callees", - "disasm", - "disasm_range", - "decompile", - "ctree", - "search_bytes", - "xrefs", - "strings", - "imports", - "bookmark_get", - "bookmark_add", - "bookmark_set", - "bookmark_delete", - "comment_get", - "comment_set", - "comment_delete", - "name_set", - "local_list", - "local_rename", - "local_retype", - "local_update", - "local_apply_plan", - "proto_get", - "proto_check", - "proto_set", - "type_list", - "type_show", - "type_deps", - "type_declare", - "type_declare_check", - "class_list", - "class_candidates", - "class_show", - "class_hierarchy", - "class_fields", - "class_vtable", - "vtable_dump", - "struct_list", - "struct_show", - "struct_field_set", - "struct_field_rename", - "struct_field_delete", - "enum_list", - "enum_show", - "enum_member_set", - "enum_member_rename", - "enum_member_delete", - "reanalyze", - "python_exec", -] - -_OPERATION_SPECS: tuple[OperationSpec[Any, Any], ...] = ( - *database_operations(), - *segment_operations(), - *function_operations(), - *search_operations(), - *bookmark_operations(), - *comment_operations(), - *name_operations(), - *local_operations(), - *prototype_operations(), - *named_type_operations(), - *type_declare_operations(), - *class_operations(), - *misc_operations(), -) - -OPERATION_SPEC_MAP: dict[str, OperationSpec[Any, Any]] = {spec.name: spec for spec in _OPERATION_SPECS} - -# The Literal names are hand-listed above; sync with the spec map is enforced -# by tests, so these derived tuples need a cast from plain str. -SUPPORTED_OPERATIONS: tuple[OperationName, ...] = cast( - "tuple[OperationName, ...]", ("list_targets", *tuple(OPERATION_SPEC_MAP)) -) - -MUTATING_OPERATIONS: tuple[OperationName, ...] = cast( - "tuple[OperationName, ...]", tuple(name for name, spec in OPERATION_SPEC_MAP.items() if spec.mutating) -) - -PREVIEW_UNSUPPORTED_OPERATIONS: tuple[OperationName, ...] = cast( - "tuple[OperationName, ...]", - tuple(name for name, spec in OPERATION_SPEC_MAP.items() if spec.mutating and spec.preview is None), -) - - -def operation_specs() -> tuple[OperationSpec[Any, Any], ...]: - return _OPERATION_SPECS - - -__all__ = [ - "MUTATING_OPERATIONS", - "OPERATION_SPEC_MAP", - "PREVIEW_UNSUPPORTED_OPERATIONS", - "SUPPORTED_OPERATIONS", - "OperationName", - "operation_specs", -] diff --git a/src/idac/ops/models.py b/src/idac/ops/models.py deleted file mode 100644 index 1a64d61..0000000 --- a/src/idac/ops/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from dataclasses import fields, is_dataclass -from typing import Any - -JsonScalar = None | bool | int | float | str -JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] - - -def payload_from_model(value: Any) -> JsonValue: - if is_dataclass(value) and not isinstance(value, type): - return { - (field.name[:-1] if field.name.endswith("_") else field.name): payload_from_model( - getattr(value, field.name) - ) - for field in fields(value) - } - if isinstance(value, (list, tuple)): - return [payload_from_model(item) for item in value] - if isinstance(value, dict): - return {str(key): payload_from_model(item) for key, item in value.items()} - return value - - -__all__ = ["JsonScalar", "JsonValue", "payload_from_model"] diff --git a/src/idac/ops/preview.py b/src/idac/ops/preview.py deleted file mode 100644 index eb4b057..0000000 --- a/src/idac/ops/preview.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, Generic - -from .base import OperationContext, RequestT, ResultT, RunOperation -from .runtime import ida_undo_restore_point - -PreviewPrepare = Callable[[OperationContext, RequestT], RequestT] -PreviewCapture = Callable[[OperationContext, RequestT], Any] -PreviewRollback = Callable[[OperationContext, RequestT, Any, ResultT], None] -PreviewCleanup = Callable[[OperationContext, RequestT], None] - - -class PreviewUnsupportedError(RuntimeError): - """Raised when an operation is executed in preview mode without preview support.""" - - -@dataclass(frozen=True) -class PreviewOutcome(Generic[ResultT]): - result: ResultT - before: Any - after: Any - persisted: bool = False - preview: bool = True - preview_mode: str = "undo" - - -@dataclass(frozen=True) -class PreviewSpec(Generic[RequestT, ResultT]): - capture_before: PreviewCapture - capture_after: PreviewCapture - rollback: PreviewRollback[RequestT, ResultT] | None = None - prepare: PreviewPrepare[RequestT] | None = None - cleanup: PreviewCleanup[RequestT] | None = None - use_undo: bool = False - - def prepare_request(self, context: OperationContext, request: RequestT) -> RequestT: - if self.prepare is None: - return request - return self.prepare(context, request) - - -def run_preview( - context: OperationContext, - name: str, - request: RequestT, - runner: RunOperation[RequestT, ResultT], - spec: PreviewSpec[RequestT, ResultT] | None, -) -> PreviewOutcome[ResultT]: - if spec is None: - raise PreviewUnsupportedError("preview is not supported for this operation") - - prepared = spec.prepare_request(context, request) - if spec.use_undo: - cleanup_error: BaseException | None = None - try: - with ida_undo_restore_point( - context.runtime, - action_name=f"idac_preview_{name}", - label=f"idac preview {name}", - unavailable_message="preview is unavailable because IDA undo is disabled", - restore_error_message=f"preview failed to restore changes via undo for {name}", - restore_failure_message=f"preview failed and IDA could not restore changes via undo for {name}", - ): - before = spec.capture_before(context, prepared) - result = runner(context, prepared) - after = spec.capture_after(context, prepared) - finally: - if spec.cleanup is not None: - try: - spec.cleanup(context, prepared) - except BaseException as exc: # pragma: no cover - defensive cleanup branch. - cleanup_error = exc - if cleanup_error is not None: - raise cleanup_error - return PreviewOutcome(result=result, before=before, after=after, preview_mode="undo") - - if spec.rollback is None: - raise PreviewUnsupportedError(f"preview is not supported for this operation: {name}") - - # Typed Any rather than ResultT | None: result is only consumed on paths - # where the mutation succeeded and it holds a real ResultT. - result: Any = None - after: Any = None - mutation_succeeded = False - primary_error: BaseException | None = None - rollback_error: BaseException | None = None - cleanup_error: BaseException | None = None - try: - before = spec.capture_before(context, prepared) - try: - result = runner(context, prepared) - mutation_succeeded = True - after = spec.capture_after(context, prepared) - except BaseException as exc: - primary_error = exc - finally: - if mutation_succeeded: - try: - spec.rollback(context, prepared, before, result) - except BaseException as exc: - rollback_error = exc - finally: - if spec.cleanup is not None: - try: - spec.cleanup(context, prepared) - except BaseException as exc: # pragma: no cover - defensive cleanup branch. - cleanup_error = exc - if rollback_error is not None: - if primary_error is not None: - raise rollback_error from primary_error - raise rollback_error - if primary_error is not None: - raise primary_error - if cleanup_error is not None: - raise cleanup_error - return PreviewOutcome(result=result, before=before, after=after) - - -__all__ = [ - "PreviewOutcome", - "PreviewSpec", - "PreviewUnsupportedError", - "run_preview", -] diff --git a/src/idac/ops/registry.py b/src/idac/ops/registry.py deleted file mode 100644 index bc92a21..0000000 --- a/src/idac/ops/registry.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable, Mapping -from typing import Any - -from .base import OperationContext, OperationSpec -from .preview import run_preview - - -class OperationLookupError(KeyError): - """Raised when a named operation has not been registered.""" - - -class OperationRegistry: - def __init__(self, operations: Iterable[OperationSpec[Any, Any]] = ()) -> None: - self._operations: dict[str, OperationSpec[Any, Any]] = {} - for operation in operations: - self.register(operation) - - def register(self, operation: OperationSpec[Any, Any]) -> None: - if operation.name in self._operations: - raise ValueError(f"duplicate operation registered: {operation.name}") - self._operations[operation.name] = operation - - def get(self, name: str) -> OperationSpec[Any, Any]: - try: - return self._operations[name] - except KeyError as exc: - raise OperationLookupError(name) from exc - - def names(self) -> tuple[str, ...]: - return tuple(self._operations) - - def execute( - self, - name: str, - *, - params: Mapping[str, Any], - context: OperationContext, - ) -> Any: - operation = self.get(name) - request = operation.parse_params(params) - if context.preview: - return run_preview(context, name, request, operation.run, operation.preview) - return operation.run(context, request) - - -__all__ = ["OperationLookupError", "OperationRegistry"] diff --git a/src/idac/ops/runtime.py b/src/idac/ops/runtime.py deleted file mode 100644 index 474ab37..0000000 --- a/src/idac/ops/runtime.py +++ /dev/null @@ -1,901 +0,0 @@ -from __future__ import annotations - -import contextlib -import importlib -from dataclasses import dataclass -from typing import Any - -from . import runtime_classes -from .helpers.matching import text_matches - - -class IdaOperationError(RuntimeError): - """Raised for expected user-facing IDA lookup and operation failures.""" - - -@dataclass(frozen=True) -class XrefRecord: - from_ea: int - to_ea: int - type: str - kind: str - user: bool - - -@dataclass(frozen=True) -class SegmentRange: - name: str - start_ea: int - end_ea: int - - -RECOVERABLE_IDA_ERRORS = (AttributeError, RuntimeError, OSError) -_CACHED_IDA_MODULE_ATTRS = frozenset( - { - "idaapi", - "idautils", - "idc", - "ida_auto", - "ida_bytes", - "ida_entry", - "ida_frame", - "ida_funcs", - "ida_ida", - "ida_idc", - "ida_idp", - "ida_kernwin", - "ida_lines", - "ida_loader", - "ida_moves", - "ida_name", - "ida_nalt", - "ida_range", - "ida_segment", - "ida_strlist", - "ida_typeinf", - "ida_ua", - "ida_undo", - "ida_xref", - } -) -_PYTHON_SCOPE_MODULE_NAMES = ( - "idaapi", - "ida_auto", - "ida_bytes", - "ida_entry", - "ida_frame", - "ida_funcs", - "ida_ida", - "ida_idc", - "ida_idp", - "ida_kernwin", - "ida_lines", - "ida_loader", - "ida_moves", - "ida_name", - "ida_nalt", - "ida_range", - "ida_segment", - "ida_strlist", - "ida_srclang", - "ida_typeinf", - "ida_ua", - "ida_undo", - "ida_xref", -) - - -def is_recoverable_ida_error(exc: BaseException) -> bool: - """Return whether an IDA exception should degrade gracefully.""" - - return isinstance(exc, RECOVERABLE_IDA_ERRORS) and not isinstance(exc, IdaOperationError) - - -@contextlib.contextmanager -def suppress_recoverable_ida_errors(): - """Suppress IDA API errors that are safe to treat as missing metadata.""" - - try: - yield - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - - -@contextlib.contextmanager -def ida_undo_restore_point( - runtime: IdaRuntime, - *, - action_name: str, - label: str, - unavailable_message: str, - restore_error_message: str, - restore_failure_message: str | None = None, -): - """Create an IDA undo point and always restore it on exit.""" - - ida_undo = runtime.mod("ida_undo") - if not ida_undo.create_undo_point(action_name=action_name, label=label): - raise IdaOperationError(unavailable_message) - try: - yield - except Exception as exc: - if not ida_undo.perform_undo(): - raise IdaOperationError(restore_failure_message or restore_error_message) from exc - raise - else: - if not ida_undo.perform_undo(): - raise IdaOperationError(restore_error_message) - - -class IdaRuntime: - """Small facade over imported IDA modules plus convenience helpers.""" - - def __init__( - self, - *, - database_path: str | None = None, - python_scope: dict[str, Any] | None = None, - ) -> None: - self.database_path = database_path - self._module_cache: dict[str, Any] = {} - self._python_scope = python_scope - - @staticmethod - def member_name(member) -> str: - """Return the member name as a normalized string.""" - - return member.name or "" - - @staticmethod - def member_has(member, attr: str) -> bool: - """Call an IDA member predicate when present.""" - - return bool(getattr(member, attr, lambda: False)()) - - def __getattr__(self, name: str): - """Resolve selected IDA modules as cached runtime attributes.""" - - if name not in _CACHED_IDA_MODULE_ATTRS: - raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}") - module = self.mod(name) - self._module_cache.setdefault(name, module) - setattr(self, name, module) - return module - - def mod(self, name: str): - """Import an IDA module by name.""" - - cached = self._module_cache.get(name) - if cached is not None: - return cached - module = importlib.import_module(name) - self._module_cache[name] = module - return module - - @staticmethod - def is_recoverable_error(exc: BaseException) -> bool: - return is_recoverable_ida_error(exc) - - def python_exec_scope(self, *, persist: bool) -> dict[str, Any]: - """Build the globals dict used by ``py exec`` operations.""" - - scope: dict[str, Any] - if persist: - if self._python_scope is None: - self._python_scope = {} - scope = self._python_scope - else: - scope = {} - for name in _PYTHON_SCOPE_MODULE_NAMES: - try: - scope.setdefault(name, self.mod(name)) - except (ImportError, OSError): - continue - with contextlib.suppress(IdaOperationError): - scope.setdefault("ida_hexrays", self.require_hexrays()) - scope.setdefault("idc", self.idc) - scope.setdefault("idautils", self.idautils) - scope["result"] = None - return scope - - def udt_members(self, tif): - """Return UDT members for ``tif`` or an empty iterable when unavailable.""" - - udt = self.ida_typeinf.udt_type_data_t() - return udt if tif.get_udt_details(udt) else () - - def _member_pointed_name(self, member) -> str | None: - pointed = member.type.get_pointed_object() - if pointed is None: - return None - name = pointed.get_type_name() or self.tinfo_decl(pointed, multi=False) - if name == "": - return None - return name or None - - def resolve_address(self, identifier: str) -> int: - """Resolve a user-supplied address or symbol to an effective address.""" - - text = str(identifier).strip() - if not text: - raise IdaOperationError("address or identifier is required") - - ea = self.ida_kernwin.str2ea_ex(text, self.idaapi.BADADDR, self.ida_kernwin.S2EAOPT_NOCALC) - if ea not in (None, self.idaapi.BADADDR): - return ea - raise IdaOperationError(f"symbol not found: {identifier}") - - @staticmethod - def _looks_like_demangled_identifier(text: str) -> bool: - stripped = str(text).strip() - return any( - marker in stripped - for marker in ( - "::", - "(", - "~", - "operator", - ) - ) - - @staticmethod - def _lookup_text_variants(text: str) -> list[str]: - rendered = str(text or "").strip() - if not rendered: - return [] - variants = [rendered] - if "(" in rendered: - short_name = rendered.split("(", 1)[0].strip() - if short_name and short_name != rendered: - variants.append(short_name) - return variants - - def _render_demangled_lookup_texts(self, ea: int) -> list[str]: - texts: list[str] = [] - short_flags = self.ida_name.GN_VISIBLE | self.ida_name.GN_DEMANGLED | self.ida_name.GN_SHORT - long_flags = self.ida_name.GN_VISIBLE | self.ida_name.GN_DEMANGLED | self.ida_name.GN_LONG - with suppress_recoverable_ida_errors(): - texts.extend(self._lookup_text_variants(self.ida_name.get_ea_name(ea, short_flags) or "")) - with suppress_recoverable_ida_errors(): - texts.extend(self._lookup_text_variants(self.ida_name.get_ea_name(ea, long_flags) or "")) - return texts - - def _raw_name_for_lookup(self, ea: int, raw_name: str | None = None) -> str: - resolved = str(raw_name or "").strip() - if resolved: - return resolved - - with suppress_recoverable_ida_errors(): - return str(self.ida_name.get_name(ea) or "").strip() - return "" - - def _demangled_lookup_texts(self, ea: int, raw_name: str | None = None) -> list[str]: - texts = self._render_demangled_lookup_texts(ea) - demangled_name = self.demangle_name(self._raw_name_for_lookup(ea, raw_name)) - texts.extend(self._lookup_text_variants(demangled_name or "")) - return texts - - def _resolve_demangled_function_address(self, identifier: str) -> int | None: - if not self._looks_like_demangled_identifier(identifier): - return None - - query = str(identifier).strip() - match_ea: int | None = None - for ea in self.idautils.Functions(): - if query not in self._demangled_lookup_texts(ea): - continue - if match_ea is None: - match_ea = ea - continue - if ea != match_ea: - raise IdaOperationError( - f"multiple functions matched demangled name: {identifier}; " - "use a mangled name, full signature, or address" - ) - - return match_ea - - def resolve_function(self, identifier: str): - """Resolve a function identifier and require that it names a function.""" - - address_error: IdaOperationError | None = None - try: - ea = self.resolve_address(identifier) - except IdaOperationError as exc: - address_error = exc - ea = None - func = None if ea is None else self.ida_funcs.get_func(ea) - if func is not None: - return func - - demangled_ea = self._resolve_demangled_function_address(str(identifier)) - func = None if demangled_ea is None else self.ida_funcs.get_func(demangled_ea) - if func is not None: - return func - if address_error is not None: - raise address_error - raise IdaOperationError(f"function not found: {identifier}") - - def function_name(self, ea: int) -> str: - return self.ida_funcs.get_func_name(ea) or hex(ea) - - def display_function_name(self, ea: int, *, demangle: bool = False) -> str: - name = self.function_name(ea) - if not demangle: - return name - flags = self.ida_name.GN_VISIBLE | self.ida_name.GN_DEMANGLED | self.ida_name.GN_SHORT - return self.ida_name.get_short_name(ea, flags) or name - - def function_identity(self, func) -> tuple[str, str]: - ea = int(func.start_ea) - return self.function_name(ea), hex(ea) - - def function_ea(self, identifier: str) -> int: - return self.resolve_function(identifier).start_ea - - def database_bounds(self) -> tuple[int, int]: - return self.ida_ida.inf_get_min_ea(), self.ida_ida.inf_get_max_ea() - - def _segment_name(self, segment) -> str: - ida_segment = self.ida_segment - for attr in ("get_visible_segm_name", "get_segm_name"): - getter = getattr(ida_segment, attr, None) - if not callable(getter): - continue - try: - value = getter(segment) - except TypeError: - continue - except Exception as exc: - if not self.is_recoverable_error(exc): - raise - continue - text = str(value or "").strip() - if text: - return text - return hex(int(segment.start_ea)) - - def iter_segments(self) -> tuple[SegmentRange, ...]: - ida_segment = self.mod("ida_segment") - rows: list[SegmentRange] = [] - segment = ida_segment.get_first_seg() - while segment is not None: - start_ea = int(segment.start_ea) - end_ea = int(segment.end_ea) - rows.append( - SegmentRange( - name=self._segment_name(segment), - start_ea=start_ea, - end_ea=end_ea, - ) - ) - next_segment = ida_segment.get_next_seg(start_ea) - if next_segment is None: - break - if int(next_segment.start_ea) <= start_ea: - raise IdaOperationError("segment enumeration did not advance") - segment = next_segment - return tuple(rows) - - @staticmethod - def _segment_selector_matches(name: str, selector: str) -> bool: - normalized_name = str(name).strip() - normalized_selector = str(selector).strip() - if not normalized_name or not normalized_selector: - return False - if normalized_name == normalized_selector: - return True - if ":" not in normalized_selector and normalized_name.startswith(f"{normalized_selector}:"): - return True - if ":" in normalized_name: - _prefix, suffix = normalized_name.split(":", 1) - if normalized_selector == suffix: - return True - return False - - @staticmethod - def ea_in_ranges(ea: int, ranges: tuple[SegmentRange, ...]) -> bool: - return any(item.start_ea <= ea < item.end_ea for item in ranges) - - @staticmethod - def _validate_range_endpoint( - *, - label: str, - value: int | None, - bounds_start: int, - bounds_end: int, - ) -> None: - if value is None or bounds_start <= value <= bounds_end: - return - raise IdaOperationError( - f"range {label} {hex(value)} is outside database bounds {hex(bounds_start)}-{hex(bounds_end)}" - ) - - def resolve_segment_ranges( - self, - selector: str, - *, - start: str | None = None, - end: str | None = None, - require_bounds: bool = False, - missing_message: str = "range requires both start and end addresses", - ) -> tuple[SegmentRange, ...]: - selector_text = str(selector).strip() - if not selector_text: - raise IdaOperationError("segment selector is required") - - all_segments = self.iter_segments() - segments = tuple(item for item in all_segments if self._segment_selector_matches(item.name, selector_text)) - if not segments: - prefixes = sorted({item.name.split(":", 1)[0] for item in all_segments}) - detail = "" - if prefixes: - shown = ", ".join(prefixes[:10]) - if len(prefixes) > 10: - shown += ", ..." - detail = f"; available segments: {shown}" - raise IdaOperationError(f"segment not found: {selector_text}{detail}") - - bounds_start, bounds_end = self.database_bounds() - range_start, range_end = bounds_start, bounds_end - if require_bounds and (start is None or end is None): - raise IdaOperationError(missing_message) - if start is not None: - range_start = self.resolve_address(start) - if end is not None: - range_end = self.resolve_address(end) - self._validate_range_endpoint( - label="start", - value=range_start if start is not None else None, - bounds_start=bounds_start, - bounds_end=bounds_end, - ) - self._validate_range_endpoint( - label="end", - value=range_end if end is not None else None, - bounds_start=bounds_start, - bounds_end=bounds_end, - ) - if range_end <= range_start: - raise IdaOperationError("range end must be greater than the start") - - clipped = tuple( - SegmentRange( - name=item.name, - start_ea=max(item.start_ea, range_start), - end_ea=min(item.end_ea, range_end), - ) - for item in segments - if min(item.end_ea, range_end) > max(item.start_ea, range_start) - ) - if not clipped: - raise IdaOperationError(f"range does not overlap segment: {selector_text}") - return clipped - - def database_bits(self) -> int: - if self.ida_ida.inf_is_64bit(): - return 64 - if self.ida_ida.inf_is_32bit_exactly(): - return 32 - return 16 - - def xref_type_name(self, type_code: int) -> str: - names = { - self.ida_xref.fl_U: "Data_Unknown", - self.ida_xref.dr_O: "Data_Offset", - self.ida_xref.dr_W: "Data_Write", - self.ida_xref.dr_R: "Data_Read", - self.ida_xref.dr_T: "Data_Text", - self.ida_xref.dr_I: "Data_Informational", - self.ida_xref.fl_CF: "Code_Far_Call", - self.ida_xref.fl_CN: "Code_Near_Call", - self.ida_xref.fl_JF: "Code_Far_Jump", - self.ida_xref.fl_JN: "Code_Near_Jump", - self.ida_xref.fl_F: "Ordinary_Flow", - } - return names.get(type_code, f"xref_{type_code}") - - def xref_kind(self, *, iscode: bool, type_code: int) -> str: - if type_code in (self.ida_xref.fl_CF, self.ida_xref.fl_CN): - return "call" - if type_code in (self.ida_xref.fl_JF, self.ida_xref.fl_JN): - return "jump" - if type_code == self.ida_xref.fl_F: - return "flow" - if type_code == self.ida_xref.dr_O: - return "offset" - if type_code == self.ida_xref.dr_W: - return "write" - if type_code == self.ida_xref.dr_R: - return "read" - if type_code == self.ida_xref.dr_T: - return "text" - if type_code == self.ida_xref.dr_I: - return "informational" - if type_code == self.ida_xref.fl_U: - return "unknown" - return "code" if iscode else "data" - - def _normalize_xref(self, xref) -> XrefRecord: - type_code = int(xref.type) - return XrefRecord( - from_ea=int(xref.frm), - to_ea=int(xref.to), - type=self.xref_type_name(type_code), - kind=self.xref_kind(iscode=bool(xref.iscode), type_code=type_code), - user=bool(xref.user), - ) - - def xrefs_to(self, ea: int, *, flags: int | None = None) -> tuple[XrefRecord, ...]: - block = self.ida_xref.xrefblk_t() - resolved_flags = self.ida_xref.XREF_FLOW if flags is None else flags - return tuple(self._normalize_xref(xref) for xref in block.refs_to(ea, resolved_flags)) - - def xrefs_from(self, ea: int, *, flags: int | None = None) -> tuple[XrefRecord, ...]: - block = self.ida_xref.xrefblk_t() - resolved_flags = self.ida_xref.XREF_FLOW if flags is None else flags - return tuple(self._normalize_xref(xref) for xref in block.refs_from(ea, resolved_flags)) - - def resolve_range( - self, - *, - start: str | None = None, - end: str | None = None, - require_bounds: bool = False, - missing_message: str = "range requires both start and end addresses", - ) -> tuple[int, int]: - bounds_start, bounds_end = self.database_bounds() - range_start, range_end = bounds_start, bounds_end - if require_bounds and (start is None or end is None): - raise IdaOperationError(missing_message) - if start is not None: - range_start = self.resolve_address(start) - if end is not None: - range_end = self.resolve_address(end) - self._validate_range_endpoint( - label="start", - value=range_start if start is not None else None, - bounds_start=bounds_start, - bounds_end=bounds_end, - ) - self._validate_range_endpoint( - label="end", - value=range_end if end is not None else None, - bounds_start=bounds_start, - bounds_end=bounds_end, - ) - if range_end <= range_start: - raise IdaOperationError("range end must be greater than the start") - return range_start, range_end - - def compile_binpat( - self, - pattern: str, - *, - ea: int | None = None, - radix: int = 16, - strlit_encoding: int = -1, - ): - """Compile an IDA byte-pattern string without going through ``find_bytes``.""" - - text = str(pattern or "").strip() - if not text: - raise IdaOperationError("byte pattern is required") - compile_ea = self.database_bounds()[0] if ea is None else ea - try: - compiled = self.ida_bytes.compiled_binpat_vec_t.parse( - compile_ea, - text, - radix, - strlit_encoding, - ) - except Exception as exc: - detail = str(exc).strip() or "unknown error" - prefix = "Could not parse pattern: " - if detail.startswith(prefix): - detail = detail[len(prefix) :].strip() or "unknown error" - raise IdaOperationError(f"invalid byte pattern: {detail}") from exc - if len(compiled) == 0: - raise IdaOperationError("invalid byte pattern") - return compiled - - def require_hexrays(self): - """Require a working Hex-Rays decompiler session.""" - - ida_hexrays = self.mod("ida_hexrays") - if not ida_hexrays.init_hexrays_plugin(): - raise IdaOperationError("Hex-Rays decompiler is unavailable") - return ida_hexrays - - def get_named_type(self, name: str, *, kind: str | None = None): - """Resolve a named type, optionally constraining the expected kind.""" - - tif = self.ida_typeinf.tinfo_t() - kind_attr = { - "struct": "BTF_STRUCT", - "union": "BTF_UNION", - "enum": "BTF_ENUM", - }.get(kind or "") - if kind_attr is None: - ok = tif.get_named_type(None, name) - else: - ok = tif.get_named_type( - self.ida_typeinf.get_idati(), - name, - getattr(self.ida_typeinf, kind_attr), - True, - False, - ) - if not ok: - raise IdaOperationError(f"type not found: {name}") - return tif - - def find_named_type(self, name: str, *, kind: str | None = None): - """Best-effort named type lookup that returns ``None`` on failure.""" - - try: - return self.get_named_type(name, kind=kind) - except IdaOperationError: - return None - - def get_struct_or_union(self, name: str): - """Resolve a struct first, then fall back to a union of the same name.""" - - tif = self.find_named_type(name, kind="struct") - return tif if tif is not None else self.get_named_type(name, kind="union") - - def classify_tinfo(self, tif) -> str: - """Classify a ``tinfo_t`` into the coarse kinds used by the CLI.""" - - if tif.is_struct(): - return "struct" - if tif.is_union(): - return "union" - if tif.is_enum(): - return "enum" - if tif.is_func(): - return "function" - if tif.is_typedef(): - return "typedef" - if tif.is_ptr(): - return "pointer" - if tif.is_array(): - return "array" - return "type" - - def demangle_name(self, name: str) -> str | None: - """Best-effort demangling that hides expected IDA failures.""" - - text = (name or "").strip() - if not text: - return None - try: - if demangled := self.ida_name.demangle_name(text, 0): - return demangled - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - return None - - def tinfo_decl(self, tif, *, name: str | None = None, multi: bool = True) -> str: - """Render a stable declaration string for a ``tinfo_t``.""" - - type_name = name or tif.get_type_name() or "" - try: - flags = self.ida_typeinf.PRTYPE_TYPE | self.ida_typeinf.PRTYPE_DEF - flags |= self.ida_typeinf.PRTYPE_MULTI if multi else self.ida_typeinf.PRTYPE_1LINE - text = tif._print(type_name, flags) - if text: - return text - except Exception as exc: - if not is_recoverable_ida_error(exc): - raise - with suppress_recoverable_ida_errors(): - text = tif.dstr() - if text: - return text - return type_name or "" - - def tinfo_members(self, tif) -> list[dict[str, Any]]: - """Return a normalized list of UDT member metadata.""" - - members: list[dict[str, Any]] = [] - for index, udm in enumerate(self.udt_members(tif)): - size_bits = udm.size - members.append( - { - "index": index, - "name": udm.name, - "offset_bits": udm.offset, - "offset": udm.offset // 8, - "size_bits": size_bits, - "size": size_bits // 8 if size_bits else None, - "type": self.tinfo_decl(udm.type, multi=False), - "comment": udm.cmt or "", - } - ) - return members - - def enum_members(self, tif) -> list[dict[str, Any]]: - """Return a normalized list of enum member metadata.""" - - members: list[dict[str, Any]] = [] - for index, edm in enumerate(tif.iter_enum()): - members.append( - { - "index": index, - "name": edm.name, - "value": edm.value, - "value_hex": hex(edm.value), - "comment": edm.cmt or "", - } - ) - return members - - def list_named_types( - self, - *, - query: str | None = None, - pattern: str | None = None, - glob: bool = False, - regex: bool = False, - ignore_case: bool = False, - kinds: set[str] | None = None, - ) -> list[dict[str, Any]]: - """List named local types, optionally filtered by substring and kind.""" - - pattern_text = str(pattern if pattern is not None else query or "") - rows: list[dict[str, Any]] = [] - for tif in self.ida_typeinf.get_idati().named_types(): - name = tif.get_type_name() or "" - kind = self.classify_tinfo(tif) - if pattern_text and not text_matches( - name, - pattern=pattern_text, - glob=glob, - regex=regex, - ignore_case=ignore_case or query is not None, - ): - continue - if kinds is not None and kind not in kinds: - continue - rows.append( - { - "name": name, - "kind": kind, - "decl": self.tinfo_decl(tif, name=name, multi=False), - } - ) - rows.sort(key=lambda item: (item["kind"], item["name"].lower())) - return rows - - def iter_named_types(self): - yield from self.ida_typeinf.get_idati().named_types() - - def _looks_like_vtable_type(self, tif) -> bool: - return runtime_classes.looks_like_vtable_type(self, tif) - - def is_class_tinfo(self, tif) -> bool: - return runtime_classes.is_class_tinfo(self, tif) - - def class_base_names(self, tif) -> list[str]: - return runtime_classes.class_base_names(self, tif) - - def class_vtable_type_name(self, tif) -> str | None: - return runtime_classes.class_vtable_type_name(self, tif) - - def vtable_ea(self, tif) -> int | None: - return runtime_classes.vtable_ea(self, tif) - - def class_vtable_ea(self, tif) -> int | None: - return runtime_classes.class_vtable_ea(self, tif) - - def class_runtime_vtable_identifier(self, tif, *, name: str | None = None) -> str | None: - return runtime_classes.class_runtime_vtable_identifier(self, tif, name=name) - - def class_summary(self, tif, *, name: str | None = None, decl_multi: bool = False) -> dict[str, Any]: - return runtime_classes.class_summary(self, tif, name=name, decl_multi=decl_multi) - - def list_named_classes( - self, - *, - query: str | None = None, - pattern: str | None = None, - glob: bool = False, - regex: bool = False, - ignore_case: bool = False, - ) -> list[dict[str, Any]]: - return runtime_classes.list_named_classes( - self, - query=query, - pattern=pattern, - glob=glob, - regex=regex, - ignore_case=ignore_case, - ) - - def iter_names(self): - for ea, name in self.idautils.Names(): - yield ea, name, self.demangle_name(name) - - def find_symbols( - self, - *, - query: str | None = None, - pattern: str | None = None, - glob: bool = False, - regex: bool = False, - ignore_case: bool = False, - ) -> list[dict[str, Any]]: - pattern_text = str(pattern if pattern is not None else query or "") - rows: list[dict[str, Any]] = [] - for ea, name, demangled in self.iter_names(): - haystack = "\n".join(part for part in (name, demangled or "") if part) - if pattern_text and not text_matches( - haystack, - pattern=pattern_text, - glob=glob, - regex=regex, - ignore_case=ignore_case or query is not None, - ): - continue - rows.append( - { - "address": hex(ea), - "name": name, - "demangled": demangled, - "is_function": self.ida_funcs.get_func(ea) is not None, - } - ) - return rows - - def find_vtable_symbol(self, class_name: str) -> dict[str, Any] | None: - return runtime_classes.find_vtable_symbol(self, class_name) - - def pointer_size(self) -> int: - if self.ida_ida.inf_is_64bit(): - return 8 - if self.ida_ida.inf_is_32bit_exactly(): - return 4 - if self.ida_ida.inf_is_16bit(): - return 2 - return 2 - - def pointer_bits(self) -> int: - return self.pointer_size() * 8 - - def read_pointer(self, ea: int) -> int: - width = self.pointer_size() - if width == 8: - return self.ida_bytes.get_qword(ea) - if width == 4: - return self.ida_bytes.get_wide_dword(ea) - return self.ida_bytes.get_wide_word(ea) - - def vtable_slot(self, offset_bits: int) -> int: - return offset_bits // self.pointer_bits() - - def pseudocode_text(self, cfunc) -> str: - """Render Hex-Rays pseudocode as plain text.""" - - lines: list[str] = [] - for item in cfunc.get_pseudocode(): - lines.append(_strip_tags(self, item.line).rstrip()) - return "\n".join(lines) - - -def _strip_tags(runtime: IdaRuntime, text: Any) -> str: - ida_lines = runtime.mod("ida_lines") - return ida_lines.tag_remove(str(text or "")) - - -def _ea_text(runtime: IdaRuntime, ea: Any) -> str | None: - idaapi = runtime.mod("idaapi") - try: - value = int(ea) - except (TypeError, ValueError): - return None - if value == idaapi.BADADDR: - return None - return hex(value) diff --git a/src/idac/ops/runtime_classes.py b/src/idac/ops/runtime_classes.py deleted file mode 100644 index a943c03..0000000 --- a/src/idac/ops/runtime_classes.py +++ /dev/null @@ -1,227 +0,0 @@ -from __future__ import annotations - -import re -from typing import TYPE_CHECKING, Any - -from .helpers.matching import text_matches -from .symbols import is_vtable_symbol_name - -if TYPE_CHECKING: - from .runtime import IdaRuntime - - -def looks_like_vtable_type(runtime: IdaRuntime, tif) -> bool: - try: - if tif.is_vftable(): - return True - except Exception as exc: - if not runtime.is_recoverable_error(exc): - raise - name = tif.get_type_name() or "" - if name.endswith("_vtbl"): - return True - decl = runtime.tinfo_decl(tif, name=name or None, multi=False) - return decl.lstrip().startswith("struct /*VFT*/") - - -def is_class_tinfo(runtime: IdaRuntime, tif) -> bool: - if not tif.is_struct() or runtime._looks_like_vtable_type(tif): - return False - try: - if tif.has_vftable(): - return True - except Exception as exc: - if not runtime.is_recoverable_error(exc): - raise - try: - if tif.is_cpp_struct(): - return True - except Exception as exc: - if not runtime.is_recoverable_error(exc): - raise - decl = runtime.tinfo_decl(tif, name=tif.get_type_name() or None, multi=False) - if "__cppobj" in decl: - return True - for member in runtime.udt_members(tif): - member_name = runtime.member_name(member) - if runtime.member_has(member, "is_baseclass") or runtime.member_has(member, "is_vftable"): - return True - if member_name.startswith("_vptr$") or member_name == "__vftable": - return True - class_name = tif.get_type_name() or "" - return bool(class_name and runtime.find_named_type(f"{class_name}_vtbl")) - - -def class_base_names(runtime: IdaRuntime, tif) -> list[str]: - bases: list[str] = [] - for member in runtime.udt_members(tif): - if not runtime.member_has(member, "is_baseclass"): - continue - base_name = member.type.get_type_name() or member.type.dstr() or runtime.tinfo_decl(member.type, multi=False) - if base_name: - bases.append(base_name) - return bases - - -def _class_vtable_member_type_name(runtime: IdaRuntime, tif) -> str | None: - for member in runtime.udt_members(tif): - member_name = runtime.member_name(member) - if not ( - runtime.member_has(member, "is_vftable") or member_name.startswith("_vptr$") or member_name == "__vftable" - ): - continue - name = runtime._member_pointed_name(member) - if name: - return name - return None - - -def class_vtable_type_name(runtime: IdaRuntime, tif) -> str | None: - class_name = tif.get_type_name() or "" - if vtable_name := _class_vtable_member_type_name(runtime, tif): - return vtable_name - guessed = f"{class_name}_vtbl" if class_name else "" - if guessed and runtime.find_named_type(guessed): - return guessed - for base_name in runtime.class_base_names(tif): - base_tif = runtime.find_named_type(base_name) - if base_tif is None: - continue - if vtable_name := runtime.class_vtable_type_name(base_tif): - return vtable_name - return None - - -def vtable_ea(runtime: IdaRuntime, tif) -> int | None: - ida_typeinf = runtime.mod("ida_typeinf") - idaapi = runtime.mod("idaapi") - - seen: set[int] = set() - for ordinal in (tif.get_ordinal(), tif.get_final_ordinal()): - if ordinal <= 0 or ordinal in seen: - continue - seen.add(ordinal) - try: - ea = ida_typeinf.get_vftable_ea(ordinal) - except Exception as exc: - if not runtime.is_recoverable_error(exc): - raise - continue - if ea not in (0, idaapi.BADADDR): - return ea - return None - - -def class_vtable_ea(runtime: IdaRuntime, tif) -> int | None: - direct = runtime.vtable_ea(tif) - if direct is not None: - return direct - vtable_name = runtime.class_vtable_type_name(tif) - if not vtable_name: - return None - vtable_tif = runtime.find_named_type(vtable_name) - if vtable_tif is None: - return None - return runtime.vtable_ea(vtable_tif) - - -def class_runtime_vtable_identifier(runtime: IdaRuntime, tif, *, name: str | None = None) -> str | None: - if (table_ea := runtime.class_vtable_ea(tif)) is not None: - return hex(table_ea) - class_name = name or tif.get_type_name() or "" - if not class_name: - return None - symbol = runtime.find_vtable_symbol(class_name) - return None if symbol is None else str(symbol["address"]) - - -def class_summary( - runtime: IdaRuntime, - tif, - *, - name: str | None = None, - decl_multi: bool = False, -) -> dict[str, Any]: - display_name = name or tif.get_type_name() or "" - return { - "name": display_name, - "kind": "class", - "size": tif.get_size(), - "bases": runtime.class_base_names(tif), - "vtable_type": runtime.class_vtable_type_name(tif), - "decl": runtime.tinfo_decl(tif, name=display_name or None, multi=decl_multi), - } - - -def _class_matches_pattern(row: dict[str, Any], pattern: str, *, glob: bool, regex: bool, ignore_case: bool) -> bool: - haystack = "\n".join( - part - for part in ( - str(row["name"]), - str(row["decl"]), - " ".join(str(base) for base in row["bases"]), - str(row["vtable_type"] or ""), - ) - if part - ) - return text_matches(haystack, pattern=pattern, glob=glob, regex=regex, ignore_case=ignore_case) - - -def list_named_classes( - runtime: IdaRuntime, - *, - query: str | None = None, - pattern: str | None = None, - glob: bool = False, - regex: bool = False, - ignore_case: bool = False, -) -> list[dict[str, Any]]: - pattern_text = str(pattern if pattern is not None else query or "") - rows: list[dict[str, Any]] = [] - for tif in runtime.iter_named_types(): - if not runtime.is_class_tinfo(tif): - continue - row = runtime.class_summary(tif, decl_multi=False) - if pattern_text and not _class_matches_pattern( - row, - pattern_text, - glob=glob, - regex=regex, - ignore_case=ignore_case or query is not None, - ): - continue - rows.append(row) - rows.sort(key=lambda item: item["name"].lower()) - return rows - - -def _candidate_class_names(class_name: str) -> set[str]: - candidates = {class_name} - if "__" in class_name: - candidates.add(class_name.replace("__", "::")) - if "::" in class_name: - candidates.add(class_name.replace("::", "__")) - return {item for item in candidates if item} - - -def _demangled_text_contains_class_name(text: str, class_name: str) -> bool: - exact_pattern = rf"(? dict[str, Any] | None: - targets = _candidate_class_names(class_name) - for ea, name, demangled in runtime.iter_names(): - if not is_vtable_symbol_name(name): - continue - demangled_text = demangled or "" - if any(_demangled_text_contains_class_name(demangled_text, target) for target in targets): - return { - "address": hex(ea), - "name": name, - "demangled": demangled, - } - return None diff --git a/src/idac/ops/symbols.py b/src/idac/ops/symbols.py deleted file mode 100644 index 0854bb5..0000000 --- a/src/idac/ops/symbols.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -VTABLE_SYMBOL_PREFIXES = ("__ZTV", "_ZTV", "??_7") -RTTI_TYPEINFO_PREFIXES = ("__ZTI", "_ZTI") -RTTI_NAME_PREFIXES = ("__ZTS", "_ZTS") -RTTI_SYMBOL_PREFIXES = RTTI_TYPEINFO_PREFIXES + RTTI_NAME_PREFIXES - - -def is_vtable_symbol_name(name: str) -> bool: - return (name or "").startswith(VTABLE_SYMBOL_PREFIXES) - - -def is_rtti_symbol_name(name: str) -> bool: - return (name or "").startswith(RTTI_SYMBOL_PREFIXES) - - -def classify_symbol_kind(name: str, *, is_function: bool) -> str: - if is_vtable_symbol_name(name): - return "vtable_symbol" - if (name or "").startswith(RTTI_TYPEINFO_PREFIXES): - return "typeinfo_symbol" - if (name or "").startswith(RTTI_NAME_PREFIXES): - return "typeinfo_name_symbol" - if is_function: - return "function_symbol" - return "symbol" - - -__all__ = [ - "RTTI_NAME_PREFIXES", - "RTTI_SYMBOL_PREFIXES", - "RTTI_TYPEINFO_PREFIXES", - "VTABLE_SYMBOL_PREFIXES", - "classify_symbol_kind", - "is_rtti_symbol_name", - "is_vtable_symbol_name", -] diff --git a/src/idac/output.py b/src/idac/output.py index 06c88d8..d3ae7f1 100644 --- a/src/idac/output.py +++ b/src/idac/output.py @@ -31,7 +31,6 @@ def __init__( super().__init__(message) self.chars = chars self.limit = limit - self.out_flag = out_flag self.hint = hint @@ -46,31 +45,23 @@ class OutputResult: def resolve_output_format(fmt: str, out_path: Path | None, *, force_fmt: bool = False) -> str: """Infer structured formats from output suffixes unless the caller explicitly forces fmt.""" - if force_fmt: - return fmt - if out_path and out_path.suffix.lower() == ".json": - return "json" - if out_path and out_path.suffix.lower() == ".jsonl": - return "jsonl" + if not force_fmt and out_path is not None: + suffix_format = out_path.suffix.lower().removeprefix(".") + if suffix_format in {"json", "jsonl"}: + return suffix_format return fmt def _render_value(value: Any, fmt: str) -> str: - """Render a response value in the requested transport format.""" - - if fmt == "json": - return json.dumps(value, indent=2, sort_keys=True) + "\n" if fmt == "jsonl": rows = value if isinstance(value, list) else [value] return "".join(json.dumps(item, sort_keys=True) + "\n" for item in rows) - if isinstance(value, str): + if fmt != "json" and isinstance(value, str): return value if value.endswith("\n") else value + "\n" return json.dumps(value, indent=2, sort_keys=True) + "\n" def _artifact_summary(value: Any) -> dict[str, Any]: - """Summarize the result shape without repeating the full payload.""" - if isinstance(value, dict): return {"kind": "object", "count": len(value), "keys": sorted(value.keys())[:10]} if isinstance(value, list): @@ -80,14 +71,6 @@ def _artifact_summary(value: Any) -> dict[str, Any]: return {"kind": type(value).__name__} -def _artifact_write_target(path: Path) -> Path: - """Follow symlink destinations so `--out` keeps prior write semantics.""" - - if path.is_symlink(): - return Path(os.path.realpath(path)) - return path - - def _default_artifact_mode() -> int: """Approximate the mode a normal write would create under the current umask.""" @@ -97,9 +80,7 @@ def _default_artifact_mode() -> int: def _write_artifact(path: Path, rendered: str, fmt: str, value: Any) -> dict[str, Any]: - """Write the rendered payload and return stable metadata about the artifact.""" - - write_path = _artifact_write_target(path) + write_path = Path(os.path.realpath(path)) if path.is_symlink() else path write_path.parent.mkdir(parents=True, exist_ok=True) data = rendered.encode("utf-8") desired_mode = _default_artifact_mode() diff --git a/src/idac/paths.py b/src/idac/paths.py index 431f180..d0ad2f6 100644 --- a/src/idac/paths.py +++ b/src/idac/paths.py @@ -1,262 +1,19 @@ from __future__ import annotations -import hashlib -import json -import os -import sys from pathlib import Path -from .metadata import ( - BRIDGE_PLUGIN_NAME, - BRIDGE_REGISTRY_PREFIX, - BRIDGE_RUNTIME_DIRNAME, - BRIDGE_SOCKET_PREFIX, - IDALIB_REGISTRY_PREFIX, - IDALIB_SOCKET_PREFIX, - SKILL_NAME, -) - - -def _env_path(name: str, default: Path) -> Path: - raw = os.environ.get(name) - return Path(raw).expanduser() if raw else default - - -def _ensure_dir(path: Path) -> Path: - path.mkdir(parents=True, exist_ok=True) - return path - - -def _default_ida_user_dir() -> Path: - if os.name == "nt": - appdata = os.environ.get("APPDATA") - if appdata: - return Path(appdata) / "Hex-Rays" / "IDA Pro" - return Path.home() / ".idapro" - - -def repo_root() -> Path: - return Path(__file__).resolve().parents[2] - def package_source_dir() -> Path: - return Path(__file__).resolve().parent - - -def codex_home() -> Path: - return _env_path("CODEX_HOME", Path.home() / ".codex") - - -def claude_home() -> Path: - return _env_path("CLAUDE_HOME", Path.home() / ".claude") - - -def runtime_uid_token() -> str: - getuid = getattr(os, "getuid", None) - if getuid is None: - return "nouid" - return str(int(getuid())) - - -def ida_user_dir() -> Path: - return _env_path("IDAUSR", _default_ida_user_dir()) - - -def ida_config_path() -> Path: - return ida_user_dir() / "ida-config.json" - - -def ida_configured_install_dir() -> Path | None: - try: - payload = json.loads(ida_config_path().read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - if not isinstance(payload, dict): - return None - paths = payload.get("Paths") - if not isinstance(paths, dict): - return None - raw_install_dir = paths.get("ida-install-dir") - if not isinstance(raw_install_dir, str) or not raw_install_dir.strip(): - return None - return Path(raw_install_dir).expanduser() - - -def hcli_config_dir() -> Path: - if sys.platform == "darwin": - return Path.home() / "Library" / "Application Support" / "hcli" - if sys.platform == "win32": - base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") - if base: - return Path(base).expanduser() / "hex-rays" / "hcli" - return Path.home() / "AppData" / "Local" / "hex-rays" / "hcli" - base = os.environ.get("XDG_CONFIG_HOME") - if base: - return Path(base).expanduser() / "hcli" - return Path.home() / ".config" / "hcli" - - -def hcli_configured_install_dir() -> Path | None: - try: - config_path = hcli_config_dir() / "config.json" - payload = json.loads(config_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - if not isinstance(payload, dict): - return None - default_name = payload.get("ida.default") - if not isinstance(default_name, str) or not default_name.strip(): - return None - instances = payload.get("ida.instances") - if not isinstance(instances, dict): - return None - raw_install_dir = instances.get(default_name) - if not isinstance(raw_install_dir, str) or not raw_install_dir.strip(): - return None - return Path(raw_install_dir).expanduser() - - -def runtime_dir() -> Path: - return _env_path("IDAC_RUNTIME_DIR", Path("/tmp") / BRIDGE_RUNTIME_DIRNAME) - - -def user_runtime_dir() -> Path: - return runtime_dir() - - -def ensure_user_runtime_dir() -> Path: - return _ensure_dir(user_runtime_dir()) - - -def bridge_registry_filename(pid: int) -> str: - return f"{BRIDGE_REGISTRY_PREFIX}-{runtime_uid_token()}-{pid}.json" - - -def bridge_socket_filename(pid: int) -> str: - return f"{BRIDGE_SOCKET_PREFIX}-{runtime_uid_token()}-{pid}.sock" - - -def bridge_registry_path(pid: int | None = None) -> Path: - resolved_pid = os.getpid() if pid is None else pid - return user_runtime_dir() / bridge_registry_filename(resolved_pid) - - -def bridge_socket_path(pid: int | None = None) -> Path: - resolved_pid = os.getpid() if pid is None else pid - return user_runtime_dir() / bridge_socket_filename(resolved_pid) - - -def _runtime_path(filename: str) -> Path: - return user_runtime_dir() / filename + """Return the installed idac package directory containing bundled assets.""" - -def bridge_registry_paths() -> list[Path]: - runtime = user_runtime_dir() - if not runtime.exists(): - return [] - return sorted(runtime.glob(f"{BRIDGE_REGISTRY_PREFIX}-*.json")) - - -def idalib_registry_filename(pid: int) -> str: - return f"{IDALIB_REGISTRY_PREFIX}-{runtime_uid_token()}-{pid}.json" - - -def idalib_socket_filename(pid: int) -> str: - return f"{IDALIB_SOCKET_PREFIX}-{runtime_uid_token()}-{pid}.sock" - - -def idalib_registry_path(pid: int | None = None) -> Path: - resolved_pid = os.getpid() if pid is None else pid - return _runtime_path(idalib_registry_filename(resolved_pid)) - - -def idalib_socket_path(pid: int | None = None) -> Path: - resolved_pid = os.getpid() if pid is None else pid - return _runtime_path(idalib_socket_filename(resolved_pid)) - - -def idalib_registry_paths() -> list[Path]: - runtime = user_runtime_dir() - if not runtime.exists(): - return [] - return sorted(runtime.glob(f"{IDALIB_REGISTRY_PREFIX}-*.json")) - - -def idalib_open_lock_path(database_path: str) -> Path: - """Return the per-database lock file that serializes concurrent opens.""" - - digest = hashlib.sha256(database_path.encode("utf-8")).hexdigest()[:16] - return _runtime_path(f"{IDALIB_REGISTRY_PREFIX}-open-{runtime_uid_token()}-{digest}.lock") - - -def plugin_source_dir() -> Path: - return package_source_dir() / "ida_plugin" / BRIDGE_PLUGIN_NAME - - -def plugin_install_dir() -> Path: - return ida_user_dir() / "plugins" / BRIDGE_PLUGIN_NAME - - -def plugin_bootstrap_source_path() -> Path: - return package_source_dir() / "ida_plugin" / "idac_bridge_plugin.py" - - -def plugin_bootstrap_install_path() -> Path: - return ida_user_dir() / "plugins" / "idac_bridge_plugin.py" - - -def plugin_runtime_package_source_dir() -> Path: - return package_source_dir() - - -def plugin_runtime_package_install_dir() -> Path: - return ida_user_dir() / "plugins" / package_source_dir().name - - -def codex_skills_dir() -> Path: - return codex_home() / "skills" - - -def ensure_codex_skills_dir() -> Path: - return _ensure_dir(codex_skills_dir()) - - -def claude_skills_dir() -> Path: - return claude_home() / "skills" - - -def ensure_claude_skills_dir() -> Path: - return _ensure_dir(claude_skills_dir()) - - -def skill_source_dir() -> Path: - return package_source_dir() / "skills" / SKILL_NAME - - -def skill_reference_source_dir() -> Path: - return skill_source_dir() / "references" + return Path(__file__).resolve().parent def workspace_template_source_dir() -> Path: return package_source_dir() / "workspace_template" / "default" -def skill_install_dir(*, host: str = "codex") -> Path: - if host == "codex": - return codex_skills_dir() / SKILL_NAME - if host == "claude": - return claude_skills_dir() / SKILL_NAME - raise ValueError(f"unsupported skill host: {host}") - - -def skill_install_dirs(*, host: str = "both") -> list[Path]: - if host == "both": - deduped: list[Path] = [] - for candidate in ( - skill_install_dir(host="claude"), - skill_install_dir(host="codex"), - ): - if candidate not in deduped: - deduped.append(candidate) - return deduped - return [skill_install_dir(host=host)] +__all__ = [ + "package_source_dir", + "workspace_template_source_dir", +] diff --git a/src/idac/remote_ops.py b/src/idac/remote_ops.py new file mode 100644 index 0000000..a6ad29b --- /dev/null +++ b/src/idac/remote_ops.py @@ -0,0 +1,6094 @@ +"""Self-contained IDA-side operations executed through ida-nexus. + +This module is uploaded as one content-addressed RemoteModule source file. +It must not import the local idac package: an IDA process is not expected to +have idac installed. Request-specific state belongs to dispatch() and its +IdaRuntime instance; module globals contain immutable operation definitions. +""" + +from __future__ import annotations + +import contextlib +import importlib +import os +import re +from collections import deque +from collections.abc import Callable, Mapping +from dataclasses import dataclass, fields, is_dataclass +from types import MappingProxyType +from typing import Any, Generic, Literal, TypedDict, TypeVar, cast + +# ---- Matching ---- + + +def pattern_from_params(params: Mapping[str, Any]) -> tuple[str, bool, bool]: + pattern = str(params.get("pattern") or "") + return (pattern, bool(params.get("regex")), bool(params.get("ignore_case"))) + + +def text_matches(text: str, *, pattern: str, regex: bool = False, ignore_case: bool = False) -> bool: + if not pattern: + return True + haystack = text + needle = pattern + if regex: + flags = re.IGNORECASE if ignore_case else 0 + try: + return re.search(needle, haystack, flags=flags) is not None + except re.error as exc: + raise ValueError(f"invalid regex pattern: {pattern}") from exc + if ignore_case: + haystack = haystack.lower() + needle = needle.lower() + return needle in haystack + + +# ---- Symbol classification ---- + + +VTABLE_SYMBOL_PREFIXES = ("__ZTV", "_ZTV", "??_7") +RTTI_TYPEINFO_PREFIXES = ("__ZTI", "_ZTI") +RTTI_NAME_PREFIXES = ("__ZTS", "_ZTS") +RTTI_SYMBOL_PREFIXES = RTTI_TYPEINFO_PREFIXES + RTTI_NAME_PREFIXES + + +def is_vtable_symbol_name(name: str) -> bool: + return (name or "").startswith(VTABLE_SYMBOL_PREFIXES) + + +def classify_symbol_kind(name: str, *, is_function: bool) -> str: + if is_vtable_symbol_name(name): + return "vtable_symbol" + if (name or "").startswith(RTTI_TYPEINFO_PREFIXES): + return "typeinfo_symbol" + if (name or "").startswith(RTTI_NAME_PREFIXES): + return "typeinfo_name_symbol" + if is_function: + return "function_symbol" + return "symbol" + + +# ---- Class recognition ---- + + +def _class_vtable_member_type_name(runtime: IdaRuntime, tif) -> str | None: + for member in runtime.udt_members(tif): + member_name = member.name or "" + if not (member.is_vftable() or member_name.startswith("_vptr$") or member_name == "__vftable"): + continue + name = runtime._member_pointed_name(member) + if name: + return name + return None + + +def _class_matches_pattern(row: dict[str, Any], pattern: str, *, regex: bool, ignore_case: bool) -> bool: + haystack = "\n".join( + part + for part in ( + str(row["name"]), + str(row["decl"]), + " ".join(str(base) for base in row["bases"]), + str(row["vtable_type"] or ""), + ) + if part + ) + return text_matches(haystack, pattern=pattern, regex=regex, ignore_case=ignore_case) + + +def _candidate_class_names(class_name: str) -> set[str]: + candidates = {class_name} + if "__" in class_name: + candidates.add(class_name.replace("__", "::")) + if "::" in class_name: + candidates.add(class_name.replace("::", "__")) + return {item for item in candidates if item} + + +def _demangled_text_contains_class_name(text: str, class_name: str) -> bool: + exact_pattern = f"(? bool: + """Return whether an IDA exception should degrade gracefully.""" + return isinstance(exc, RECOVERABLE_IDA_ERRORS) and (not isinstance(exc, IdaOperationError)) + + +@contextlib.contextmanager +def suppress_recoverable_ida_errors(): + """Suppress IDA API errors that are safe to treat as missing metadata.""" + try: + yield + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + + +@contextlib.contextmanager +def ida_undo_restore_point( + runtime: IdaRuntime, + *, + action_name: str, + label: str, + unavailable_message: str, + restore_error_message: str, + restore_failure_message: str | None = None, +): + """Create an IDA undo point and always restore it on exit.""" + ida_undo = runtime.mod("ida_undo") + if not ida_undo.create_undo_point(action_name=action_name, label=label): + raise IdaOperationError(unavailable_message) + try: + yield + except BaseException as exc: + if not ida_undo.perform_undo(): + raise IdaOperationError(restore_failure_message or restore_error_message) from exc + raise + else: + if not ida_undo.perform_undo(): + raise IdaOperationError(restore_error_message) + + +class IdaRuntime: + """Small facade over imported IDA modules plus convenience helpers.""" + + def __init__(self) -> None: + self._module_cache: dict[str, Any] = {} + + def __getattr__(self, name: str): + """Resolve selected IDA modules as cached runtime attributes.""" + if name not in _CACHED_IDA_MODULE_ATTRS: + raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}") + module = self.mod(name) + self._module_cache.setdefault(name, module) + setattr(self, name, module) + return module + + def mod(self, name: str): + """Import an IDA module by name.""" + cached = self._module_cache.get(name) + if cached is not None: + return cached + module = importlib.import_module(name) + self._module_cache[name] = module + return module + + def udt_members(self, tif): + """Return UDT members for ``tif`` or an empty iterable when unavailable.""" + udt = self.ida_typeinf.udt_type_data_t() + return udt if tif.get_udt_details(udt) else () + + def _member_pointed_name(self, member) -> str | None: + pointed = member.type.get_pointed_object() + if pointed is None: + return None + name = pointed.get_type_name() or self.tinfo_decl(pointed, multi=False) + if name == "": + return None + return name or None + + def resolve_address(self, identifier: str) -> int: + """Resolve a user-supplied address or symbol to an effective address.""" + text = str(identifier).strip() + if not text: + raise IdaOperationError("address or identifier is required") + ea = self.ida_kernwin.str2ea_ex(text, self.idaapi.BADADDR, self.ida_kernwin.S2EAOPT_NOCALC) + if ea not in (None, self.idaapi.BADADDR): + return ea + raise IdaOperationError(f"symbol not found: {identifier}") + + @staticmethod + def _lookup_text_variants(text: str) -> list[str]: + rendered = str(text or "").strip() + if not rendered: + return [] + variants = [rendered] + if "(" in rendered: + short_name = rendered.split("(", 1)[0].strip() + if short_name and short_name != rendered: + variants.append(short_name) + return variants + + def _demangled_lookup_texts(self, ea: int) -> list[str]: + texts: list[str] = [] + short_flags = self.ida_name.GN_VISIBLE | self.ida_name.GN_DEMANGLED | self.ida_name.GN_SHORT + long_flags = self.ida_name.GN_VISIBLE | self.ida_name.GN_DEMANGLED | self.ida_name.GN_LONG + with suppress_recoverable_ida_errors(): + texts.extend(self._lookup_text_variants(self.ida_name.get_ea_name(ea, short_flags) or "")) + with suppress_recoverable_ida_errors(): + texts.extend(self._lookup_text_variants(self.ida_name.get_ea_name(ea, long_flags) or "")) + raw_name = "" + with suppress_recoverable_ida_errors(): + raw_name = str(self.ida_name.get_name(ea) or "").strip() + demangled_name = self.demangle_name(raw_name) + texts.extend(self._lookup_text_variants(demangled_name or "")) + return texts + + def _resolve_demangled_function_address(self, identifier: str) -> int | None: + if not any(marker in str(identifier).strip() for marker in ("::", "(", "~", "operator")): + return None + query = str(identifier).strip() + match_ea: int | None = None + for ea in self.idautils.Functions(): + if query not in self._demangled_lookup_texts(ea): + continue + if match_ea is None: + match_ea = ea + continue + if ea != match_ea: + raise IdaOperationError( + f"multiple functions matched demangled name: {identifier}; " + "use a mangled name, full signature, or address" + ) + return match_ea + + def resolve_function(self, identifier: str): + """Resolve a function identifier and require that it names a function.""" + address_error: IdaOperationError | None = None + try: + ea = self.resolve_address(identifier) + except IdaOperationError as exc: + address_error = exc + ea = None + func = None if ea is None else self.ida_funcs.get_func(ea) + if func is not None: + return func + demangled_ea = self._resolve_demangled_function_address(str(identifier)) + func = None if demangled_ea is None else self.ida_funcs.get_func(demangled_ea) + if func is not None: + return func + if address_error is not None: + raise address_error + raise IdaOperationError(f"function not found: {identifier}") + + def function_name(self, ea: int) -> str: + return self.ida_funcs.get_func_name(ea) or hex(ea) + + def display_function_name(self, ea: int, *, demangle: bool = False) -> str: + name = self.function_name(ea) + if not demangle: + return name + flags = self.ida_name.GN_VISIBLE | self.ida_name.GN_DEMANGLED | self.ida_name.GN_SHORT + return self.ida_name.get_short_name(ea, flags) or name + + def function_identity(self, func) -> tuple[str, str]: + ea = int(func.start_ea) + return (self.function_name(ea), hex(ea)) + + def function_ea(self, identifier: str) -> int: + return self.resolve_function(identifier).start_ea + + def database_bounds(self) -> tuple[int, int]: + return (self.ida_ida.inf_get_min_ea(), self.ida_ida.inf_get_max_ea()) + + def iter_segments(self) -> tuple[SegmentRange, ...]: + ida_segment = self.ida_segment + rows: list[SegmentRange] = [] + segment_ea = ida_segment.get_first_segment_ea() + while segment_ea != self.idaapi.BADADDR: + info = ida_segment.segment_info_t() + if not ida_segment.get_segment_info(info, segment_ea): + raise IdaOperationError(f"failed to read segment information at {hex(segment_ea)}") + start_ea = int(info.start_ea) + end_ea = int(info.end_ea) + name = ida_segment.get_segment_name(segment_ea, self.ida_name.GN_VISIBLE) + rows.append(SegmentRange(name=name, start_ea=start_ea, end_ea=end_ea)) + next_ea = ida_segment.get_next_segment_ea(segment_ea) + if next_ea == self.idaapi.BADADDR: + break + if next_ea <= segment_ea: + raise IdaOperationError("segment enumeration did not advance") + segment_ea = next_ea + return tuple(rows) + + @staticmethod + def _segment_selector_matches(name: str, selector: str) -> bool: + normalized_name = str(name).strip() + normalized_selector = str(selector).strip() + if not normalized_name or not normalized_selector: + return False + if normalized_name == normalized_selector: + return True + if ":" not in normalized_selector and normalized_name.startswith(f"{normalized_selector}:"): + return True + if ":" in normalized_name: + _prefix, suffix = normalized_name.split(":", 1) + if normalized_selector == suffix: + return True + return False + + @staticmethod + def ea_in_ranges(ea: int, ranges: tuple[SegmentRange, ...]) -> bool: + return any(item.start_ea <= ea < item.end_ea for item in ranges) + + @staticmethod + def _validate_range_endpoint(*, label: str, value: int | None, bounds_start: int, bounds_end: int) -> None: + if value is None or bounds_start <= value <= bounds_end: + return + raise IdaOperationError( + f"range {label} {hex(value)} is outside database bounds {hex(bounds_start)}-{hex(bounds_end)}" + ) + + def resolve_segment_ranges( + self, + selector: str, + *, + start: str | None = None, + end: str | None = None, + require_bounds: bool = False, + missing_message: str = "range requires both start and end addresses", + ) -> tuple[SegmentRange, ...]: + selector_text = str(selector).strip() + if not selector_text: + raise IdaOperationError("segment selector is required") + all_segments = self.iter_segments() + segments = tuple(item for item in all_segments if self._segment_selector_matches(item.name, selector_text)) + if not segments: + prefixes = sorted({item.name.split(":", 1)[0] for item in all_segments}) + detail = "" + if prefixes: + shown = ", ".join(prefixes[:10]) + if len(prefixes) > 10: + shown += ", ..." + detail = f"; available segments: {shown}" + raise IdaOperationError(f"segment not found: {selector_text}{detail}") + range_start, range_end = self.resolve_range( + start=start, + end=end, + require_bounds=require_bounds, + missing_message=missing_message, + ) + clipped = tuple( + SegmentRange(name=item.name, start_ea=max(item.start_ea, range_start), end_ea=min(item.end_ea, range_end)) + for item in segments + if min(item.end_ea, range_end) > max(item.start_ea, range_start) + ) + if not clipped: + raise IdaOperationError(f"range does not overlap segment: {selector_text}") + return clipped + + def _normalize_xref(self, xref) -> XrefRecord: + type_code = int(xref.type) + type_name, kind = { + self.ida_xref.fl_U: ("Data_Unknown", "unknown"), + self.ida_xref.dr_O: ("Data_Offset", "offset"), + self.ida_xref.dr_W: ("Data_Write", "write"), + self.ida_xref.dr_R: ("Data_Read", "read"), + self.ida_xref.dr_T: ("Data_Text", "text"), + self.ida_xref.dr_I: ("Data_Informational", "informational"), + self.ida_xref.fl_CF: ("Code_Far_Call", "call"), + self.ida_xref.fl_CN: ("Code_Near_Call", "call"), + self.ida_xref.fl_JF: ("Code_Far_Jump", "jump"), + self.ida_xref.fl_JN: ("Code_Near_Jump", "jump"), + self.ida_xref.fl_F: ("Ordinary_Flow", "flow"), + }.get(type_code, (f"xref_{type_code}", "code" if bool(xref.iscode) else "data")) + return XrefRecord( + from_ea=int(xref.frm), + to_ea=int(xref.to), + type=type_name, + kind=kind, + user=bool(xref.user), + ) + + def xrefs_to(self, ea: int, *, flags: int | None = None) -> tuple[XrefRecord, ...]: + block = self.ida_xref.xrefblk_t() + resolved_flags = self.ida_xref.XREF_FLOW if flags is None else flags + return tuple(self._normalize_xref(xref) for xref in block.refs_to(ea, resolved_flags)) + + def xrefs_from(self, ea: int, *, flags: int | None = None) -> tuple[XrefRecord, ...]: + block = self.ida_xref.xrefblk_t() + resolved_flags = self.ida_xref.XREF_FLOW if flags is None else flags + return tuple(self._normalize_xref(xref) for xref in block.refs_from(ea, resolved_flags)) + + def resolve_range( + self, + *, + start: str | None = None, + end: str | None = None, + require_bounds: bool = False, + missing_message: str = "range requires both start and end addresses", + ) -> tuple[int, int]: + bounds_start, bounds_end = self.database_bounds() + range_start, range_end = (bounds_start, bounds_end) + if require_bounds and (start is None or end is None): + raise IdaOperationError(missing_message) + if start is not None: + range_start = self.resolve_address(start) + if end is not None: + range_end = self.resolve_address(end) + self._validate_range_endpoint( + label="start", + value=range_start if start is not None else None, + bounds_start=bounds_start, + bounds_end=bounds_end, + ) + self._validate_range_endpoint( + label="end", value=range_end if end is not None else None, bounds_start=bounds_start, bounds_end=bounds_end + ) + if range_end <= range_start: + raise IdaOperationError("range end must be greater than the start") + return (range_start, range_end) + + def compile_binpat(self, pattern: str, *, ea: int | None = None, radix: int = 16, strlit_encoding: int = -1): + """Compile an IDA byte-pattern string without going through ``find_bytes``.""" + text = str(pattern or "").strip() + if not text: + raise IdaOperationError("byte pattern is required") + compile_ea = self.database_bounds()[0] if ea is None else ea + try: + compiled = self.ida_bytes.compiled_binpat_vec_t.parse(compile_ea, text, radix, strlit_encoding) + except Exception as exc: + detail = str(exc).strip() or "unknown error" + prefix = "Could not parse pattern: " + if detail.startswith(prefix): + detail = detail[len(prefix) :].strip() or "unknown error" + raise IdaOperationError(f"invalid byte pattern: {detail}") from exc + if len(compiled) == 0: + raise IdaOperationError("invalid byte pattern") + return compiled + + def require_hexrays(self): + """Require a working Hex-Rays decompiler session.""" + ida_hexrays = self.mod("ida_hexrays") + if not ida_hexrays.init_hexrays_plugin(): + raise IdaOperationError("Hex-Rays decompiler is unavailable") + return ida_hexrays + + def get_named_type(self, name: str, *, kind: str | None = None): + """Resolve a named type, optionally constraining the expected kind.""" + tif = self.ida_typeinf.tinfo_t() + kind_code = { + "struct": self.ida_typeinf.BTF_STRUCT, + "union": self.ida_typeinf.BTF_UNION, + "enum": self.ida_typeinf.BTF_ENUM, + }.get(kind or "") + if kind_code is None: + ok = tif.get_named_type(None, name) + else: + ok = tif.get_named_type(self.ida_typeinf.get_idati(), name, kind_code, True, False) + if not ok: + raise IdaOperationError(f"type not found: {name}") + return tif + + def find_named_type(self, name: str, *, kind: str | None = None): + """Best-effort named type lookup that returns ``None`` on failure.""" + try: + return self.get_named_type(name, kind=kind) + except IdaOperationError: + return None + + def get_struct_or_union(self, name: str): + """Resolve a struct first, then fall back to a union of the same name.""" + tif = self.find_named_type(name, kind="struct") + return tif if tif is not None else self.get_named_type(name, kind="union") + + def classify_tinfo(self, tif) -> str: + """Classify a ``tinfo_t`` into the coarse kinds used by the CLI.""" + if tif.is_struct(): + return "struct" + if tif.is_union(): + return "union" + if tif.is_enum(): + return "enum" + if tif.is_func(): + return "function" + if tif.is_typedef(): + return "typedef" + if tif.is_ptr(): + return "pointer" + if tif.is_array(): + return "array" + return "type" + + def demangle_name(self, name: str) -> str | None: + """Best-effort demangling that hides expected IDA failures.""" + text = (name or "").strip() + if not text: + return None + try: + if demangled := self.ida_name.demangle_name(text, 0): + return demangled + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + return None + + def tinfo_decl(self, tif, *, name: str | None = None, multi: bool = True) -> str: + """Render a stable declaration string for a ``tinfo_t``.""" + type_name = name or tif.get_type_name() or "" + try: + flags = self.ida_typeinf.PRTYPE_TYPE | self.ida_typeinf.PRTYPE_DEF + flags |= self.ida_typeinf.PRTYPE_MULTI if multi else self.ida_typeinf.PRTYPE_1LINE + text = tif._print(type_name, flags) + if text: + return text + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + with suppress_recoverable_ida_errors(): + text = tif.dstr() + if text: + return text + return type_name or "" + + def tinfo_members(self, tif) -> list[dict[str, Any]]: + """Return a normalized list of UDT member metadata.""" + members: list[dict[str, Any]] = [] + for index, udm in enumerate(self.udt_members(tif)): + size_bits = udm.size + members.append( + { + "index": index, + "name": udm.name, + "offset_bits": udm.offset, + "offset": udm.offset // 8, + "size_bits": size_bits, + "size": size_bits // 8 if size_bits else None, + "type": self.tinfo_decl(udm.type, multi=False), + "comment": udm.cmt or "", + } + ) + return members + + def enum_members(self, tif) -> list[dict[str, Any]]: + """Return a normalized list of enum member metadata.""" + members: list[dict[str, Any]] = [] + for index, edm in enumerate(tif.iter_enum()): + members.append( + { + "index": index, + "name": edm.name, + "value": edm.value, + "value_hex": hex(edm.value), + "comment": edm.cmt or "", + } + ) + return members + + def list_named_types( + self, + *, + pattern: str | None = None, + regex: bool = False, + ignore_case: bool = False, + kinds: set[str] | None = None, + ) -> list[dict[str, Any]]: + """List named local types, optionally filtered by substring and kind.""" + pattern_text = str(pattern or "") + rows: list[dict[str, Any]] = [] + for tif in self.ida_typeinf.get_idati().named_types(): + name = tif.get_type_name() or "" + kind = self.classify_tinfo(tif) + if pattern_text and not text_matches(name, pattern=pattern_text, regex=regex, ignore_case=ignore_case): + continue + if kinds is not None and kind not in kinds: + continue + rows.append({"name": name, "kind": kind, "decl": self.tinfo_decl(tif, name=name, multi=False)}) + rows.sort(key=lambda item: (item["kind"], item["name"].lower())) + return rows + + def _looks_like_vtable_type(self, tif) -> bool: + try: + if tif.is_vftable(): + return True + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + name = tif.get_type_name() or "" + if name.endswith("_vtbl"): + return True + decl = self.tinfo_decl(tif, name=name or None, multi=False) + return decl.lstrip().startswith("struct /*VFT*/") + + def is_class_tinfo(self, tif) -> bool: + if not tif.is_struct() or self._looks_like_vtable_type(tif): + return False + with suppress_recoverable_ida_errors(): + if tif.has_vftable(): + return True + with suppress_recoverable_ida_errors(): + if tif.is_cpp_struct(): + return True + decl = self.tinfo_decl(tif, name=tif.get_type_name() or None, multi=False) + if "__cppobj" in decl: + return True + for member in self.udt_members(tif): + member_name = member.name or "" + if member.is_baseclass() or member.is_vftable(): + return True + if member_name.startswith("_vptr$") or member_name == "__vftable": + return True + class_name = tif.get_type_name() or "" + return bool(class_name and self.find_named_type(f"{class_name}_vtbl")) + + def class_base_names(self, tif) -> list[str]: + bases: list[str] = [] + for member in self.udt_members(tif): + if not member.is_baseclass(): + continue + base_name = member.type.get_type_name() or member.type.dstr() or self.tinfo_decl(member.type, multi=False) + if base_name: + bases.append(base_name) + return bases + + def class_vtable_type_name(self, tif) -> str | None: + class_name = tif.get_type_name() or "" + if vtable_name := _class_vtable_member_type_name(self, tif): + return vtable_name + guessed = f"{class_name}_vtbl" if class_name else "" + if guessed and self.find_named_type(guessed): + return guessed + for base_name in self.class_base_names(tif): + base_tif = self.find_named_type(base_name) + if base_tif is None: + continue + if vtable_name := self.class_vtable_type_name(base_tif): + return vtable_name + return None + + def vtable_ea(self, tif) -> int | None: + ida_typeinf = self.mod("ida_typeinf") + idaapi = self.mod("idaapi") + seen: set[int] = set() + for ordinal in (tif.get_ordinal(), tif.get_final_ordinal()): + if ordinal <= 0 or ordinal in seen: + continue + seen.add(ordinal) + try: + ea = ida_typeinf.get_vftable_ea(ordinal) + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + continue + if ea not in (0, idaapi.BADADDR): + return ea + return None + + def class_vtable_ea(self, tif) -> int | None: + direct = self.vtable_ea(tif) + if direct is not None: + return direct + vtable_name = self.class_vtable_type_name(tif) + if not vtable_name: + return None + vtable_tif = self.find_named_type(vtable_name) + if vtable_tif is None: + return None + return self.vtable_ea(vtable_tif) + + def class_runtime_vtable_identifier(self, tif, *, name: str | None = None) -> str | None: + if (table_ea := self.class_vtable_ea(tif)) is not None: + return hex(table_ea) + class_name = name or tif.get_type_name() or "" + if not class_name: + return None + symbol = self.find_vtable_symbol(class_name) + return None if symbol is None else str(symbol["address"]) + + def class_summary(self, tif, *, name: str | None = None, decl_multi: bool = False) -> dict[str, Any]: + display_name = name or tif.get_type_name() or "" + return { + "name": display_name, + "kind": "class", + "size": tif.get_size(), + "bases": self.class_base_names(tif), + "vtable_type": self.class_vtable_type_name(tif), + "decl": self.tinfo_decl(tif, name=display_name or None, multi=decl_multi), + } + + def list_named_classes( + self, + *, + pattern: str | None = None, + regex: bool = False, + ignore_case: bool = False, + ) -> list[dict[str, Any]]: + pattern_text = str(pattern or "") + rows: list[dict[str, Any]] = [] + for tif in self.ida_typeinf.get_idati().named_types(): + if not self.is_class_tinfo(tif): + continue + row = self.class_summary(tif, decl_multi=False) + if pattern_text and ( + not _class_matches_pattern( + row, + pattern_text, + regex=regex, + ignore_case=ignore_case, + ) + ): + continue + rows.append(row) + rows.sort(key=lambda item: item["name"].lower()) + return rows + + def iter_names(self): + for ea, name in self.idautils.Names(): + yield (ea, name, self.demangle_name(name)) + + def find_symbols( + self, + *, + pattern: str | None = None, + regex: bool = False, + ignore_case: bool = False, + ) -> list[dict[str, Any]]: + pattern_text = str(pattern or "") + rows: list[dict[str, Any]] = [] + for ea, name, demangled in self.iter_names(): + haystack = "\n".join(part for part in (name, demangled or "") if part) + if pattern_text and not text_matches(haystack, pattern=pattern_text, regex=regex, ignore_case=ignore_case): + continue + rows.append( + { + "address": hex(ea), + "name": name, + "demangled": demangled, + "is_function": self.ida_funcs.get_func(ea) is not None, + } + ) + return rows + + def find_vtable_symbol(self, class_name: str) -> dict[str, Any] | None: + targets = _candidate_class_names(class_name) + for ea, name, demangled in self.iter_names(): + if not is_vtable_symbol_name(name): + continue + demangled_text = demangled or "" + if any(_demangled_text_contains_class_name(demangled_text, target) for target in targets): + return {"address": hex(ea), "name": name, "demangled": demangled} + return None + + def pointer_size(self) -> int: + if self.ida_ida.inf_is_64bit(): + return 8 + if self.ida_ida.inf_is_32bit_exactly(): + return 4 + return 2 + + def read_pointer(self, ea: int) -> int: + width = self.pointer_size() + if width == 8: + return self.ida_bytes.get_qword(ea) + if width == 4: + return self.ida_bytes.get_wide_dword(ea) + return self.ida_bytes.get_wide_word(ea) + + def vtable_slot(self, offset_bits: int) -> int: + return offset_bits // (self.pointer_size() * 8) + + def pseudocode_text(self, cfunc) -> str: + """Render Hex-Rays pseudocode as plain text.""" + lines: list[str] = [] + for item in cfunc.get_pseudocode(): + lines.append(_strip_tags(self, item.line).rstrip()) + return "\n".join(lines) + + +def _strip_tags(runtime: IdaRuntime, text: Any) -> str: + ida_lines = runtime.mod("ida_lines") + return ida_lines.tag_remove(str(text or "")) + + +def _ea_text(runtime: IdaRuntime, ea: Any) -> str | None: + idaapi = runtime.mod("idaapi") + try: + value = int(ea) + except (TypeError, ValueError): + return None + if value == idaapi.BADADDR: + return None + return hex(value) + + +# ---- Parameter parsing ---- + + +def parse_int_text(value: Any, *, label: str, minimum: int | None = None) -> int: + text = str(value).strip() + if not text: + raise ValueError(f"{label} is required") + try: + parsed = int(text, 0) + except ValueError as exc: + raise ValueError(f"{label} must be an integer") from exc + if minimum is not None and parsed < minimum: + raise ValueError(f"{label} must be greater than or equal to {minimum}") + return parsed + + +def param_int(params: Mapping[str, Any], key: str, *, label: str | None = None, minimum: int | None = None) -> int: + name = key if label is None else label + try: + return parse_int_text(params.get(key), label=name, minimum=minimum) + except ValueError as exc: + raise IdaOperationError(str(exc)) from exc + + +def optional_param_int( + params: Mapping[str, Any], key: str, *, label: str | None = None, minimum: int | None = None +) -> int | None: + value = params.get(key) + if value in (None, ""): + return None + return param_int(params, key, label=label, minimum=minimum) + + +def require_str(value: Any, *, field: str) -> str: + text = str(value or "").strip() + if not text: + raise IdaOperationError(f"{field} is required") + return text + + +def optional_str(value: Any) -> str | None: + return str(value or "").strip() or None + + +def parse_aliases(raw_aliases: Any) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for item in raw_aliases or []: + if isinstance(item, dict): + src = str(item.get("from") or "").strip() + dst = str(item.get("to") or "").strip() + raw = f"{src}={dst}" if src or dst else "" + else: + raw = str(item).strip() + src = dst = "" + if "=" in raw: + src, dst = (part.strip() for part in raw.split("=", 1)) + if not src or not dst: + text = raw or str(item) + raise ValueError(f"invalid alias `{text}`; expected OLD=NEW") + rows.append({"from": src, "to": dst}) + return rows + + +# ---- Operation definitions ---- + + +RequestT = TypeVar("RequestT") +ResultT = TypeVar("ResultT") +Params = Mapping[str, Any] +ParseParams = Callable[[Params], RequestT] +RunOperation = Callable[["OperationContext", RequestT], ResultT] + + +@dataclass(frozen=True) +class OperationContext: + runtime: IdaRuntime + preview: bool = False + + +@dataclass(frozen=True) +class OperationSpec(Generic[RequestT, ResultT]): + name: str + run: RunOperation[RequestT, ResultT] + parse: ParseParams[RequestT] | None = None + mutating: bool = False + preview: PreviewSpec[RequestT, ResultT] | None = None + + +# ---- Preview execution ---- + + +PreviewPrepare = Callable[[OperationContext, RequestT], RequestT] +PreviewCapture = Callable[[OperationContext, RequestT], Any] +PreviewRollback = Callable[[OperationContext, RequestT, Any], None] +PreviewCleanup = Callable[[OperationContext, RequestT], None] + + +@dataclass(frozen=True) +class PreviewOutcome(Generic[ResultT]): + result: ResultT + before: Any + after: Any + persisted: bool = False + preview: bool = True + preview_mode: str = "rollback" + + +@dataclass(frozen=True) +class PreviewSpec(Generic[RequestT, ResultT]): + capture_before: PreviewCapture + capture_after: PreviewCapture + rollback: PreviewRollback[RequestT] | None = None + prepare: PreviewPrepare[RequestT] | None = None + cleanup: PreviewCleanup[RequestT] | None = None + use_undo: bool = False + + +def run_preview( + context: OperationContext, + name: str, + request: RequestT, + runner: RunOperation[RequestT, ResultT], + spec: PreviewSpec[RequestT, ResultT] | None, +) -> PreviewOutcome[ResultT]: + if spec is None: + raise IdaOperationError("preview is not supported for this operation") + prepared = request if spec.prepare is None else spec.prepare(context, request) + if spec.use_undo: + cleanup_error: BaseException | None = None + try: + with ida_undo_restore_point( + context.runtime, + action_name=f"idac_preview_{name}", + label=f"idac preview {name}", + unavailable_message="preview is unavailable because IDA undo is disabled", + restore_error_message=f"preview failed to restore changes via undo for {name}", + restore_failure_message=f"preview failed and IDA could not restore changes via undo for {name}", + ): + before = spec.capture_before(context, prepared) + result = runner(context, prepared) + after = spec.capture_after(context, prepared) + finally: + if spec.cleanup is not None: + try: + spec.cleanup(context, prepared) + except BaseException as exc: + cleanup_error = exc + if cleanup_error is not None: + raise cleanup_error + return PreviewOutcome(result=result, before=before, after=after, preview_mode="undo") + if spec.rollback is None: + raise IdaOperationError(f"preview is not supported for this operation: {name}") + result: Any = None + after: Any = None + runner_started = False + primary_error: BaseException | None = None + rollback_error: BaseException | None = None + cleanup_error: BaseException | None = None + try: + before = spec.capture_before(context, prepared) + try: + runner_started = True + result = runner(context, prepared) + after = spec.capture_after(context, prepared) + except BaseException as exc: + primary_error = exc + finally: + if runner_started: + try: + spec.rollback(context, prepared, before) + except BaseException as exc: + rollback_error = exc + finally: + if spec.cleanup is not None: + try: + spec.cleanup(context, prepared) + except BaseException as exc: + cleanup_error = exc + if rollback_error is not None: + if primary_error is not None: + raise rollback_error from primary_error + raise rollback_error + if primary_error is not None: + raise primary_error + if cleanup_error is not None: + raise cleanup_error + return PreviewOutcome(result=result, before=before, after=after) + + +# ---- Shared wire models ---- + + +JsonScalar = None | bool | int | float | str +JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] + + +def payload_from_model(value: Any) -> JsonValue: + if is_dataclass(value) and (not isinstance(value, type)): + return { + field.name[:-1] if field.name.endswith("_") else field.name: payload_from_model(getattr(value, field.name)) + for field in fields(value) + } + if isinstance(value, (list, tuple)): + return [payload_from_model(item) for item in value] + if isinstance(value, dict): + return {str(key): payload_from_model(item) for key, item in value.items()} + return value + + +# ---- Database operations ---- + + +@dataclass(frozen=True) +class _database_DatabaseInfoResult: + path: str + database_path: str + module: str + processor: str + bits: int + base: str + min_ea: str + max_ea: str + main_ea: str | None + start_ea: str | None + entry_ea: str | None + + +def _database_database_info(context: OperationContext, request: None) -> _database_DatabaseInfoResult: + del request + runtime = context.runtime + ida_entry = runtime.mod("ida_entry") + ida_ida = runtime.mod("ida_ida") + ida_loader = runtime.mod("ida_loader") + ida_nalt = runtime.mod("ida_nalt") + idaapi = runtime.mod("idaapi") + entry_ord = ida_entry.get_entry_ordinal(0) + entry_ea = ida_entry.get_entry(entry_ord) if entry_ord != idaapi.BADADDR else idaapi.BADADDR + main_ea = ida_ida.inf_get_main() + start_ea = ida_ida.inf_get_start_ea() + return _database_DatabaseInfoResult( + path=ida_nalt.get_input_file_path() or "", + database_path=ida_loader.get_path(ida_loader.PATH_TYPE_IDB) or "", + module=ida_nalt.get_root_filename() or "", + processor=ida_ida.inf_get_procname(), + bits=runtime.pointer_size() * 8, + base=hex(ida_nalt.get_imagebase()), + min_ea=hex(ida_ida.inf_get_min_ea()), + max_ea=hex(ida_ida.inf_get_max_ea()), + main_ea=None if main_ea == idaapi.BADADDR else hex(main_ea), + start_ea=None if start_ea == idaapi.BADADDR else hex(start_ea), + entry_ea=None if entry_ea == idaapi.BADADDR else hex(entry_ea), + ) + + +# ---- Segment operations ---- + + +@dataclass(frozen=True) +class _segments_SegmentListRequest: + pattern: str + regex: bool + ignore_case: bool + + +@dataclass(frozen=True) +class _segments_SegmentListEntry: + name: str + start: str + end: str + size: int + + +def _segments_parse_segment_list(params: Mapping[str, Any]) -> _segments_SegmentListRequest: + pattern, regex, ignore_case = pattern_from_params(params) + if regex and pattern: + try: + re.compile(pattern) + except re.error as exc: + raise IdaOperationError(f"invalid segment regex: {exc}") from exc + return _segments_SegmentListRequest(pattern=pattern, regex=regex, ignore_case=ignore_case) + + +def _segments_segment_list( + context: OperationContext, request: _segments_SegmentListRequest +) -> tuple[_segments_SegmentListEntry, ...]: + rows: list[_segments_SegmentListEntry] = [] + for segment in context.runtime.iter_segments(): + if request.pattern and ( + not text_matches( + segment.name, + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + ) + ): + continue + rows.append( + _segments_SegmentListEntry( + name=segment.name, + start=hex(segment.start_ea), + end=hex(segment.end_ea), + size=segment.end_ea - segment.start_ea, + ) + ) + return tuple(rows) + + +# ---- Function operations ---- + + +@dataclass(frozen=True) +class _functions_FunctionListRequest: + pattern: str + regex: bool + ignore_case: bool + segment: str | None + limit: int | None + demangle: bool + + +@dataclass(frozen=True) +class _functions_FunctionIdentifierRequest: + identifier: str + + +@dataclass(frozen=True) +class _functions_DisasmRangeRequest: + start: str + end: str + + +@dataclass(frozen=True) +class _functions_DecompileRequest: + identifier: str + no_cache: bool + + +@dataclass(frozen=True) +class _functions_CtreeRequest: + identifier: str + level: str + maturity: str + + +@dataclass(frozen=True) +class _functions_FunctionListEntry: + name: str + display_name: str + render_name: str + address: str + section: str + size: int + + +@dataclass(frozen=True) +class _functions_FunctionShowResult: + name: str + display_name: str + address: str + size: int + prototype: str + flags: str + + +@dataclass(frozen=True) +class _functions_FrameXref: + address: str + operand: int + type: int + access: str + + +@dataclass(frozen=True) +class _functions_FrameMember: + index: int + name: str + offset: int + end_offset: int + size: int + type: str + kind: str + is_special: bool + is_arg: bool + fp_offset: int | None = None + xrefs: tuple[_functions_FrameXref, ...] = () + xref_count: int | None = None + + +@dataclass(frozen=True) +class _functions_FunctionFrameResult: + function: str + address: str + frame_size: int + local_size: int + saved_registers_size: int + argument_size: int + members: tuple[_functions_FrameMember, ...] + + +@dataclass(frozen=True) +class _functions_FunctionStackvarsResult: + function: str + address: str + stackvars: tuple[_functions_FrameMember, ...] + + +@dataclass(frozen=True) +class _functions_CallerEdge: + call_site: str + caller: str + caller_address: str + + +@dataclass(frozen=True) +class _functions_CalleeEdge: + call_site: str + callee: str + callee_address: str + + +@dataclass(frozen=True) +class _functions_IncomingEdgesResult: + function: str + address: str + edges: tuple[_functions_CallerEdge, ...] + + +@dataclass(frozen=True) +class _functions_OutgoingEdgesResult: + function: str + address: str + edges: tuple[_functions_CalleeEdge, ...] + + +@dataclass(frozen=True) +class _functions_TextResult: + text: str + + +@dataclass(frozen=True) +class _functions_CtreeNode: + kind: str + depth: int + op: str + ea: str | None + text: str + + +@dataclass(frozen=True) +class _functions_CtreeResult: + function: str + address: str + level: str + nodes: tuple[_functions_CtreeNode, ...] + text: str + + +@dataclass(frozen=True) +class _functions_MicrocodeResult: + function: str + address: str + level: str + maturity: str + lines: tuple[str, ...] + text: str + + +def _functions_parse_function_list(params: Mapping[str, Any]) -> _functions_FunctionListRequest: + pattern, regex, ignore_case = pattern_from_params(params) + return _functions_FunctionListRequest( + pattern=pattern, + regex=regex, + ignore_case=ignore_case, + segment=optional_str(params.get("segment")), + limit=optional_param_int(params, "limit", label="function list limit", minimum=1), + demangle=bool(params.get("demangle")), + ) + + +def _functions_function_list( + context: OperationContext, request: _functions_FunctionListRequest +) -> tuple[_functions_FunctionListEntry, ...]: + runtime = context.runtime + ranges = () if request.segment is None else runtime.resolve_segment_ranges(request.segment) + rows: list[_functions_FunctionListEntry] = [] + for ea in runtime.idautils.Functions(): + if ranges and (not runtime.ea_in_ranges(ea, ranges)): + continue + name = runtime.function_name(ea) + display_name = runtime.display_function_name(ea, demangle=True) + match_name = display_name if request.demangle else name + if request.pattern and ( + not text_matches( + match_name, + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + ) + ): + continue + func = runtime.ida_funcs.get_func(ea) + rows.append( + _functions_FunctionListEntry( + name=name, + display_name=display_name, + render_name=display_name if request.demangle else name, + address=hex(ea), + section=runtime.ida_segment.get_segment_name(ea, runtime.ida_name.GN_VISIBLE), + size=0 if func is None else func.end_ea - func.start_ea, + ) + ) + if request.limit is not None and len(rows) >= request.limit: + break + return tuple(rows) + + +def _functions_parse_identifier(params: Mapping[str, Any]) -> _functions_FunctionIdentifierRequest: + return _functions_FunctionIdentifierRequest( + identifier=require_str(params.get("identifier"), field="address or identifier") + ) + + +def _functions_parse_disasm_range(params: Mapping[str, Any]) -> _functions_DisasmRangeRequest: + return _functions_DisasmRangeRequest( + start=require_str(params.get("start"), field="range start"), + end=require_str(params.get("end"), field="range end"), + ) + + +def _functions_function_show( + context: OperationContext, request: _functions_FunctionIdentifierRequest +) -> _functions_FunctionShowResult: + runtime = context.runtime + func = runtime.resolve_function(request.identifier) + ida_typeinf = runtime.ida_typeinf + name, address = runtime.function_identity(func) + ea = func.start_ea + return _functions_FunctionShowResult( + name=name, + display_name=runtime.display_function_name(ea, demangle=True), + address=address, + size=func.end_ea - func.start_ea, + prototype=ida_typeinf.print_type(ea, ida_typeinf.PRTYPE_1LINE) or "", + flags=hex(func.flags), + ) + + +def _functions_frame_members( + runtime: IdaRuntime, func, *, include_special: bool, include_xrefs: bool, query: str | None = None +) -> tuple[_functions_FrameMember, ...]: + frame_tif = runtime.ida_typeinf.tinfo_t() + if not frame_tif.get_func_frame(func): + raise IdaOperationError(f"function has no frame: {hex(func.start_ea)}") + xref_names = {runtime.ida_xref.dr_R: "read", runtime.ida_xref.dr_W: "write"} + members: list[_functions_FrameMember] = [] + for index, frame_udm in enumerate(frame_tif.iter_struct()): + offset = frame_udm.begin() // 8 + end_offset = frame_udm.end() // 8 + tid = frame_tif.get_udm_tid(index) + local_size = func.frsize + saved_regs_size = func.frregs + special_from_layout = local_size <= offset < local_size + saved_regs_size + is_special = bool(runtime.ida_frame.is_special_frame_member(tid)) or special_from_layout + if is_special and (not include_special): + continue + name = str(frame_udm.name or f"") + if query and (not text_matches(name, pattern=query, ignore_case=True)): + continue + is_arg = False if is_special else bool(runtime.ida_frame.is_funcarg_off(func, offset)) + xrefs: tuple[_functions_FrameXref, ...] = () + xref_count: int | None = None + if include_xrefs: + xreflist = runtime.ida_frame.xreflist_t() + runtime.ida_frame.build_stkvar_xrefs(xreflist, func, offset, end_offset) + xref_rows: list[_functions_FrameXref] = [] + for item_index in range(xreflist.size()): + item = xreflist[item_index] + xref_rows.append( + _functions_FrameXref( + address=hex(item.ea), + operand=item.opnum, + type=item.type, + access=xref_names.get(item.type, "unknown"), + ) + ) + xrefs = tuple(xref_rows) + xref_count = len(xref_rows) + members.append( + _functions_FrameMember( + index=index, + name=name, + offset=offset, + end_offset=end_offset, + size=max(0, end_offset - offset), + type=runtime.tinfo_decl(frame_udm.type, multi=False), + kind="special" if is_special else "arg" if is_arg else "local", + is_special=is_special, + is_arg=is_arg, + fp_offset=None if is_special else runtime.ida_frame.soff_to_fpoff(func, offset), + xrefs=xrefs, + xref_count=xref_count, + ) + ) + members.sort(key=lambda item: (item.offset, item.name.lower())) + return tuple(members) + + +def _functions_function_frame( + context: OperationContext, request: _functions_FunctionIdentifierRequest +) -> _functions_FunctionFrameResult: + runtime = context.runtime + func = runtime.resolve_function(request.identifier) + name, address = runtime.function_identity(func) + frame_tif = runtime.ida_typeinf.tinfo_t() + if not frame_tif.get_func_frame(func): + raise IdaOperationError(f"function has no frame: {address}") + return _functions_FunctionFrameResult( + function=name, + address=address, + frame_size=frame_tif.get_size(), + local_size=func.frsize, + saved_registers_size=func.frregs, + argument_size=func.argsize, + members=_functions_frame_members(runtime, func, include_special=True, include_xrefs=False), + ) + + +def _functions_function_stackvars( + context: OperationContext, request: _functions_FunctionIdentifierRequest +) -> _functions_FunctionStackvarsResult: + runtime = context.runtime + func = runtime.resolve_function(request.identifier) + name, address = runtime.function_identity(func) + return _functions_FunctionStackvarsResult( + function=name, + address=address, + stackvars=_functions_frame_members(runtime, func, include_special=False, include_xrefs=True), + ) + + +def _functions_incoming_edges(runtime: IdaRuntime, func) -> tuple[_functions_CallerEdge, ...]: + rows: list[_functions_CallerEdge] = [] + seen: set[tuple[int, int]] = set() + target_ea = func.start_ea + flags = runtime.ida_xref.XREF_CODE | runtime.ida_xref.XREF_NOFLOW + for ref in runtime.xrefs_to(target_ea, flags=flags): + if ref.kind != "call": + continue + caller = runtime.ida_funcs.get_func(ref.from_ea) + if caller is None: + continue + caller_ea = caller.start_ea + key = (caller_ea, ref.from_ea) + if key in seen: + continue + seen.add(key) + rows.append( + _functions_CallerEdge( + call_site=hex(ref.from_ea), caller=runtime.function_name(caller_ea), caller_address=hex(caller_ea) + ) + ) + rows.sort(key=lambda item: (item.caller.lower(), item.call_site)) + return tuple(rows) + + +def _functions_outgoing_edges(runtime: IdaRuntime, func) -> tuple[_functions_CalleeEdge, ...]: + rows: list[_functions_CalleeEdge] = [] + seen: set[tuple[int, int]] = set() + flags = runtime.ida_xref.XREF_CODE | runtime.ida_xref.XREF_NOFLOW + for item in runtime.ida_funcs.func_item_iterator_t(func).code_items(): + for ref in runtime.xrefs_from(item, flags=flags): + if ref.kind != "call": + continue + callee = runtime.ida_funcs.get_func(ref.to_ea) + if callee is None: + continue + callee_ea = callee.start_ea + key = (item, callee_ea) + if key in seen: + continue + seen.add(key) + rows.append( + _functions_CalleeEdge( + call_site=hex(item), callee=runtime.function_name(callee_ea), callee_address=hex(callee_ea) + ) + ) + rows.sort(key=lambda item: (item.callee.lower(), item.call_site)) + return tuple(rows) + + +def _functions_function_callers( + context: OperationContext, request: _functions_FunctionIdentifierRequest +) -> _functions_IncomingEdgesResult: + runtime = context.runtime + func = runtime.resolve_function(request.identifier) + name, address = runtime.function_identity(func) + return _functions_IncomingEdgesResult( + function=name, address=address, edges=_functions_incoming_edges(runtime, func) + ) + + +def _functions_function_callees( + context: OperationContext, request: _functions_FunctionIdentifierRequest +) -> _functions_OutgoingEdgesResult: + runtime = context.runtime + func = runtime.resolve_function(request.identifier) + name, address = runtime.function_identity(func) + return _functions_OutgoingEdgesResult( + function=name, address=address, edges=_functions_outgoing_edges(runtime, func) + ) + + +def _functions_disasm( + context: OperationContext, request: _functions_FunctionIdentifierRequest +) -> _functions_TextResult: + runtime = context.runtime + func_ea = runtime.function_ea(request.identifier) + ida_lines = runtime.mod("ida_lines") + lines = [ + f"{hex(ea)}: {_strip_tags(runtime, ida_lines.generate_disasm_line(ea, 0) or '')}" + for ea in runtime.idautils.FuncItems(func_ea) + ] + return _functions_TextResult(text="\n".join(lines)) + + +def _functions_disasm_range(context: OperationContext, request: _functions_DisasmRangeRequest) -> _functions_TextResult: + runtime = context.runtime + start_ea, end_ea = runtime.resolve_range(start=request.start, end=request.end, require_bounds=True) + ida_lines = runtime.mod("ida_lines") + flags = ida_lines.GENDSM_FORCE_CODE | ida_lines.GENDSM_REMOVE_TAGS + lines: list[str] = [] + for ea in runtime.idautils.Heads(start_ea, end_ea): + text = _strip_tags(runtime, ida_lines.generate_disasm_line(ea, flags) or "") + if text: + lines.append(f"{hex(ea)}: {text}") + return _functions_TextResult(text="\n".join(lines)) + + +def _functions_parse_decompile(params: Mapping[str, Any]) -> _functions_DecompileRequest: + return _functions_DecompileRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + no_cache=bool(params.get("no_cache")), + ) + + +def _functions_decompile(context: OperationContext, request: _functions_DecompileRequest) -> _functions_TextResult: + runtime = context.runtime + ida_hexrays = runtime.require_hexrays() + ea = runtime.function_ea(request.identifier) + flags = ida_hexrays.DECOMP_NO_CACHE if request.no_cache else 0 + cfunc = ida_hexrays.decompile(ea, None, flags) if flags else ida_hexrays.decompile(ea) + if cfunc is None: + raise IdaOperationError(f"failed to decompile function at {hex(ea)}") + return _functions_TextResult(text=runtime.pseudocode_text(cfunc)) + + +def _functions_parse_ctree(params: Mapping[str, Any]) -> _functions_CtreeRequest: + return _functions_CtreeRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + level=str(params.get("level") or "ctree").lower(), + maturity=str(params.get("maturity") or "generated").lower(), + ) + + +def _functions_ctree_rows(runtime: IdaRuntime, cfunc) -> tuple[_functions_CtreeNode, ...]: + ida_hexrays = runtime.require_hexrays() + rows: list[_functions_CtreeNode] = [] + + class Visitor(ida_hexrays.ctree_visitor_t): + def __init__(self) -> None: + super().__init__(ida_hexrays.CV_FAST | ida_hexrays.CV_PARENTS) + + def _append(self, kind: str, node) -> int: + rows.append( + _functions_CtreeNode( + kind=kind, + depth=max(0, len(self.parents) - 1), + op=node.opname, + ea=_ea_text(runtime, node.ea), + text=_strip_tags(runtime, node.print1(cfunc)), + ) + ) + return 0 + + def visit_insn(self, insn): + return self._append("insn", insn) + + def visit_expr(self, expr): + return self._append("expr", expr) + + visitor = Visitor() + visitor.apply_to(cfunc.body, None) + return tuple(rows) + + +def _functions_render_ctree_text(nodes: tuple[_functions_CtreeNode, ...]) -> str: + return "\n".join( + f"{' ' * int(node.depth)}{node.kind}:{node.op}" + + (f" @{node.ea}" if node.ea else "") + + (f" {node.text}" if node.text else "") + for node in nodes + ) + + +def _functions_maturity_value(runtime: IdaRuntime, name: str) -> int: + ida_hexrays = runtime.require_hexrays() + maturities = { + "generated": ida_hexrays.MMAT_GENERATED, + "preoptimized": ida_hexrays.MMAT_PREOPTIMIZED, + "locopt": ida_hexrays.MMAT_LOCOPT, + "calls": ida_hexrays.MMAT_CALLS, + "glbopt1": ida_hexrays.MMAT_GLBOPT1, + "glbopt2": ida_hexrays.MMAT_GLBOPT2, + "glbopt3": ida_hexrays.MMAT_GLBOPT3, + "lvars": ida_hexrays.MMAT_LVARS, + } + try: + return maturities[name] + except KeyError as exc: + raise IdaOperationError(f"unsupported microcode maturity: {name}") from exc + + +def _functions_microcode_lines(runtime: IdaRuntime, func, maturity: str) -> tuple[str, ...]: + ida_hexrays = runtime.require_hexrays() + maturity_value = _functions_maturity_value(runtime, maturity) + + class Printer(ida_hexrays.vd_printer_t): + def __init__(self) -> None: + super().__init__() + self.lines: list[str] = [] + + def _print(self, indent, line): + prefix = " " * int(indent or 0) + rendered = _strip_tags(runtime, line).rstrip() + self.lines.append(f"{prefix}{rendered}".rstrip()) + return 0 + + mbr = ida_hexrays.mba_ranges_t() + mbr.ranges.push_back(runtime.ida_range.range_t(func.start_ea, func.end_ea)) + hf = ida_hexrays.hexrays_failure_t() + mba = ida_hexrays.gen_microcode( + mbr, hf, None, ida_hexrays.DECOMP_WARNINGS | ida_hexrays.DECOMP_NO_CACHE, maturity_value + ) + if mba is None: + raise IdaOperationError(f"failed to generate microcode: {hf.desc()}") + printer = Printer() + mba._print(printer) + return tuple(line for line in printer.lines if line) + + +def _functions_ctree( + context: OperationContext, request: _functions_CtreeRequest +) -> _functions_CtreeResult | _functions_MicrocodeResult: + runtime = context.runtime + func = runtime.resolve_function(request.identifier) + name, address = runtime.function_identity(func) + if request.level == "ctree": + cfunc = runtime.require_hexrays().decompile(func.start_ea) + if cfunc is None: + raise IdaOperationError(f"failed to decompile function at {address}") + nodes = _functions_ctree_rows(runtime, cfunc) + return _functions_CtreeResult( + function=name, address=address, level=request.level, nodes=nodes, text=_functions_render_ctree_text(nodes) + ) + if request.level == "micro": + lines = _functions_microcode_lines(runtime, func, request.maturity) + return _functions_MicrocodeResult( + function=name, + address=address, + level=request.level, + maturity=request.maturity, + lines=lines, + text="\n".join(lines), + ) + raise IdaOperationError(f"unsupported ctree level: {request.level}") + + +# ---- Search operations ---- + + +_search_MEBIBYTE = 1024 * 1024 +_search_MAX_DSC_STRING_SCAN_BYTES = 16 * _search_MEBIBYTE + + +@dataclass(frozen=True) +class _search_SearchBytesRequest: + pattern: str + segment: str + start: str | None + end: str | None + limit: int + + +@dataclass(frozen=True) +class _search_SearchMatch: + address: str + + +@dataclass(frozen=True) +class _search_SearchMatchInFunction: + address: str + function: str + + +@dataclass(frozen=True) +class _search_SearchBytesResult: + pattern: str + segment: str + start: str + end: str + limit: int + truncated: bool + ranges: tuple[_search_SearchScopeRange, ...] + results: tuple[_search_SearchMatch | _search_SearchMatchInFunction, ...] + + +@dataclass(frozen=True) +class _search_XrefsRequest: + identifier: str + + +@dataclass(frozen=True) +class _search_XrefRow: + from_: str + to: str + type: str + kind: str + user: bool + function: str | None = None + + +@dataclass(frozen=True) +class _search_StringsRequest: + pattern: str + regex: bool + ignore_case: bool + scan: bool + segment: str + start: str | None + end: str | None + + +@dataclass(frozen=True) +class _search_StringRow: + address: str + text: str + + +@dataclass(frozen=True) +class _search_SearchScopeRange: + name: str + start: str + end: str + + +@dataclass(frozen=True) +class _search_ImportEntry: + address: str + name: str + ordinal: int + + +@dataclass(frozen=True) +class _search_ImportModule: + module: str + entries: tuple[_search_ImportEntry, ...] + + +def _search_parse_search_bytes(params: Mapping[str, Any]) -> _search_SearchBytesRequest: + pattern = require_str(params.get("pattern"), field="byte pattern") + segment = require_str(params.get("segment"), field="segment selector") + start = optional_str(params.get("start")) + end = optional_str(params.get("end")) + limit = optional_param_int(params, "limit", label="search result limit", minimum=1) or 100 + return _search_SearchBytesRequest(pattern=pattern, segment=segment, start=start, end=end, limit=limit) + + +def _search_search_bytes_from_cursor(runtime: IdaRuntime, start: int, end: int, compiled_pattern, flags: int) -> int: + result = runtime.mod("ida_bytes").bin_search(start, end, compiled_pattern, flags) + return int(result[0]) if isinstance(result, tuple) else int(result) + + +def _search_has_more_search_bytes_matches( + runtime: IdaRuntime, + *, + ranges: tuple[SegmentRange, ...], + start_index: int, + cursor: int, + compiled_pattern, + flags: int, + badaddr: int, +) -> bool: + for index in range(start_index, len(ranges)): + scope = ranges[index] + scope_cursor = cursor if index == start_index else scope.start_ea + if scope_cursor >= scope.end_ea: + continue + match_ea = _search_search_bytes_from_cursor(runtime, scope_cursor, scope.end_ea, compiled_pattern, flags) + if match_ea != badaddr: + return True + return False + + +def _search_search_bytes(context: OperationContext, request: _search_SearchBytesRequest) -> _search_SearchBytesResult: + runtime = context.runtime + ida_bytes = runtime.mod("ida_bytes") + idaapi = runtime.mod("idaapi") + ranges = runtime.resolve_segment_ranges(request.segment, start=request.start, end=request.end, require_bounds=False) + flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOBREAK | ida_bytes.BIN_SEARCH_NOSHOW + compiled_pattern = runtime.compile_binpat(request.pattern, ea=ranges[0].start_ea) + rows: list[_search_SearchMatch | _search_SearchMatchInFunction] = [] + truncated = False + for index, scope in enumerate(ranges): + cursor = scope.start_ea + while cursor < scope.end_ea and len(rows) < request.limit: + match_ea = _search_search_bytes_from_cursor(runtime, cursor, scope.end_ea, compiled_pattern, flags) + if match_ea == idaapi.BADADDR: + break + func = runtime.ida_funcs.get_func(match_ea) + function_name = None if func is None else runtime.function_name(func.start_ea) + if function_name: + rows.append(_search_SearchMatchInFunction(address=hex(match_ea), function=function_name)) + else: + rows.append(_search_SearchMatch(address=hex(match_ea))) + cursor = match_ea + 1 + if len(rows) == request.limit: + truncated = _search_has_more_search_bytes_matches( + runtime, + ranges=ranges, + start_index=index, + cursor=cursor, + compiled_pattern=compiled_pattern, + flags=flags, + badaddr=idaapi.BADADDR, + ) + break + return _search_SearchBytesResult( + pattern=request.pattern, + segment=request.segment, + start=hex(ranges[0].start_ea), + end=hex(ranges[-1].end_ea), + limit=request.limit, + truncated=truncated, + ranges=tuple( + _search_SearchScopeRange(name=item.name, start=hex(item.start_ea), end=hex(item.end_ea)) for item in ranges + ), + results=tuple(rows), + ) + + +def _search_parse_xrefs(params: Mapping[str, Any]) -> _search_XrefsRequest: + return _search_XrefsRequest(identifier=require_str(params.get("identifier"), field="address or identifier")) + + +def _search_xrefs(context: OperationContext, request: _search_XrefsRequest) -> tuple[_search_XrefRow, ...]: + runtime = context.runtime + ea = runtime.resolve_address(request.identifier) + rows: list[_search_XrefRow] = [] + seen: set[tuple[str, str, str, str, bool, str | None]] = set() + for flags in (runtime.ida_xref.XREF_FLOW, runtime.ida_xref.XREF_CODE, runtime.ida_xref.XREF_DATA): + for ref in runtime.xrefs_to(ea, flags=flags): + func = runtime.ida_funcs.get_func(ref.from_ea) + row = _search_XrefRow( + from_=hex(ref.from_ea), + to=hex(ref.to_ea), + type=ref.type, + kind=ref.kind, + user=ref.user, + function=None if func is None else runtime.function_name(func.start_ea), + ) + key = (row.from_, row.to, row.type, row.kind, row.user, row.function) + if key in seen: + continue + seen.add(key) + rows.append(row) + rows.sort(key=lambda item: (item.kind, item.from_)) + return tuple(rows) + + +def _search_parse_strings(params: Mapping[str, Any]) -> _search_StringsRequest: + pattern, regex, ignore_case = pattern_from_params(params) + segment = require_str(params.get("segment"), field="segment selector") + start = optional_str(params.get("start")) + end = optional_str(params.get("end")) + return _search_StringsRequest( + pattern=pattern, + regex=regex, + ignore_case=ignore_case, + scan=bool(params.get("scan")), + segment=segment, + start=start, + end=end, + ) + + +def _search_string_text(runtime: IdaRuntime, ea: int, length: int, strtype: int) -> str: + value = runtime.ida_bytes.get_strlit_contents(ea, length, strtype) + if value is None: + return "" + return value.decode("UTF-8", "replace") + + +def _search_defined_string_rows( + runtime: IdaRuntime, *, ranges: tuple[SegmentRange, ...], pattern: str, regex: bool, ignore_case: bool +) -> tuple[_search_StringRow, ...]: + ida_strlist = runtime.ida_strlist + string_types = list( + dict.fromkeys( + ( + runtime.ida_nalt.STRTYPE_TERMCHR, + runtime.ida_nalt.STRTYPE_C, + runtime.ida_nalt.STRTYPE_C_16, + runtime.ida_nalt.STRTYPE_C_32, + runtime.ida_nalt.STRTYPE_PASCAL, + runtime.ida_nalt.STRTYPE_PASCAL_16, + runtime.ida_nalt.STRTYPE_PASCAL_32, + runtime.ida_nalt.STRTYPE_LEN2, + runtime.ida_nalt.STRTYPE_LEN2_16, + runtime.ida_nalt.STRTYPE_LEN2_32, + runtime.ida_nalt.STRTYPE_LEN4, + runtime.ida_nalt.STRTYPE_LEN4_16, + runtime.ida_nalt.STRTYPE_LEN4_32, + ) + ) + ) + options = ida_strlist.get_strlist_options() + saved_strtypes = list(options.strtypes) + saved_minlen = int(options.minlen) + saved_display_only_existing_strings = bool(options.display_only_existing_strings) + saved_only_7bit = bool(options.only_7bit) + saved_ignore_heads = bool(options.ignore_heads) + rows: list[_search_StringRow] = [] + try: + options.strtypes = string_types + options.minlen = 1 + options.display_only_existing_strings = True + options.only_7bit = False + options.ignore_heads = False + ida_strlist.build_strlist() + item = ida_strlist.string_info_t() + for index in range(int(ida_strlist.get_strlist_qty())): + if not ida_strlist.get_strlist_item(item, index): + continue + ea = int(item.ea) + if not runtime.ea_in_ranges(ea, ranges): + continue + text = _search_string_text(runtime, ea, int(item.length), int(item.type)) + if text_matches(text, pattern=pattern, regex=regex, ignore_case=ignore_case): + rows.append(_search_StringRow(address=hex(ea), text=text)) + finally: + options.strtypes = saved_strtypes + options.minlen = saved_minlen + options.display_only_existing_strings = saved_display_only_existing_strings + options.only_7bit = saved_only_7bit + options.ignore_heads = saved_ignore_heads + ida_strlist.build_strlist() + return tuple(rows) + + +def _search_scan_string_rows( + runtime: IdaRuntime, *, ranges: tuple[SegmentRange, ...], pattern: str, regex: bool, ignore_case: bool +) -> tuple[_search_StringRow, ...]: + rows: list[_search_StringRow] = [] + for scope in ranges: + cursor = scope.start_ea + while cursor < scope.end_ea: + length = runtime.ida_bytes.get_max_strlit_length( + cursor, runtime.ida_nalt.STRTYPE_C, runtime.ida_bytes.ALOPT_IGNHEADS + ) + if length < 5 or cursor + length > scope.end_ea: + cursor += 1 + continue + text = _search_string_text(runtime, cursor, length, runtime.ida_nalt.STRTYPE_C) + if not text: + cursor += 1 + continue + if not text_matches(text, pattern=pattern, regex=regex, ignore_case=ignore_case): + cursor += max(length, 1) + continue + rows.append(_search_StringRow(address=hex(cursor), text=text)) + cursor += max(length, 1) + return tuple(rows) + + +def _search_validate_dsc_string_scan_ranges(ranges: tuple[SegmentRange, ...]) -> None: + requested = sum(max(0, item.end_ea - item.start_ea) for item in ranges) + if requested <= _search_MAX_DSC_STRING_SCAN_BYTES: + return + raise IdaOperationError( + "dyld shared cache string scans are limited to " + f"{_search_MAX_DSC_STRING_SCAN_BYTES // _search_MEBIBYTE} MiB; " + f"requested {requested // _search_MEBIBYTE} MiB" + ) + + +def _search_strings(context: OperationContext, request: _search_StringsRequest) -> tuple[_search_StringRow, ...]: + runtime = context.runtime + input_basename = os.path.basename(str(runtime.ida_nalt.get_input_file_path() or "").strip()).lower() + is_dsc = input_basename == "dyld_shared_cache" or input_basename.startswith("dyld_shared_cache_") + ranges = runtime.resolve_segment_ranges( + request.segment, + start=request.start, + end=request.end, + require_bounds=is_dsc and request.scan, + missing_message="dyld shared cache string scan requires both start and end addresses", + ) + if request.scan: + if is_dsc: + _search_validate_dsc_string_scan_ranges(ranges) + return _search_scan_string_rows( + runtime, + ranges=ranges, + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + ) + if is_dsc: + raise IdaOperationError( + "defined string listing is disabled for dyld shared caches; use " + "`search strings --scan --segment ... --start ... --end ...` with a range of at most " + f"{_search_MAX_DSC_STRING_SCAN_BYTES // _search_MEBIBYTE} MiB" + ) + return _search_defined_string_rows( + runtime, + ranges=ranges, + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + ) + + +def _search_imports(context: OperationContext, request: None) -> tuple[_search_ImportModule, ...]: + del request + runtime = context.runtime + modules: list[_search_ImportModule] = [] + for index in range(runtime.ida_nalt.get_import_module_qty()): + module_name = runtime.ida_nalt.get_import_module_name(index) or "" + entries: list[_search_ImportEntry] = [] + + def imp_cb(ea: int, name: str | None, ordinal: int, entries: list[_search_ImportEntry] = entries) -> bool: + entries.append(_search_ImportEntry(address=hex(ea), name=name or f"ordinal_{ordinal}", ordinal=ordinal)) + return True + + runtime.ida_nalt.enum_import_names(index, imp_cb) + modules.append(_search_ImportModule(module=module_name, entries=tuple(entries))) + return tuple(modules) + + +# ---- Bookmark operations ---- + + +@dataclass(frozen=True) +class _bookmarks_BookmarkGetRequest: + slot: int | None = None + + +@dataclass(frozen=True) +class _bookmarks_BookmarkSetRequest: + slot: int + identifier: str + comment: str = "" + + +@dataclass(frozen=True) +class _bookmarks_BookmarkAddRequest: + identifier: str + comment: str = "" + slot: int | None = None + + +@dataclass(frozen=True) +class _bookmarks_BookmarkDeleteRequest: + slot: int + + +@dataclass(frozen=True) +class _bookmarks_BookmarkState: + slot: int + present: bool + address: str | None + comment: str | None + + +@dataclass(frozen=True) +class _bookmarks_BookmarkList: + bookmarks: list[_bookmarks_BookmarkState] + count: int + + +@dataclass(frozen=True) +class _bookmarks_BookmarkMutationResult: + slot: int + present: bool + address: str | None + comment: str | None + changed: bool + + +def _bookmarks_parse_slot(value: object) -> int: + text = str("" if value is None else value).strip() + if not text: + raise IdaOperationError("bookmark slot is required") + try: + slot = int(text, 0) + except ValueError as exc: + raise IdaOperationError("bookmark slot must be an integer") from exc + if slot < 0: + raise IdaOperationError("bookmark slot must be greater than or equal to 0") + return slot + + +def _bookmarks_validate_slot(runtime: IdaRuntime, slot: int) -> int: + ida_moves = runtime.mod("ida_moves") + if slot > ida_moves.MAX_MARK_SLOT: + raise IdaOperationError(f"bookmark slot must be less than or equal to {ida_moves.MAX_MARK_SLOT}") + return slot + + +def _bookmarks_bookmark_template(runtime: IdaRuntime): + ida_kernwin = runtime.mod("ida_kernwin") + ida_moves = runtime.mod("ida_moves") + place_id = ida_kernwin.get_place_class_id("idaplace_t") + if place_id < 0: + raise IdaOperationError("failed to resolve IDA bookmark place class") + place = ida_kernwin.get_place_class_template(place_id) + if place is None: + raise IdaOperationError("failed to build an idaplace_t template for bookmarks") + loc = ida_moves.lochist_entry_t() + loc.set_place(place) + return loc + + +def _bookmarks_bookmark_state(runtime: IdaRuntime, slot: int) -> _bookmarks_BookmarkState: + ida_kernwin = runtime.mod("ida_kernwin") + ida_moves = runtime.mod("ida_moves") + loc = _bookmarks_bookmark_template(runtime) + desc, found_slot = ida_moves.bookmarks_t.get(loc, slot, None) + if desc is None or found_slot is None: + return _bookmarks_BookmarkState(slot=slot, present=False, address=None, comment=None) + place = loc.place() + idaplace = ida_kernwin.place_t.as_idaplace_t(place) + if idaplace is None: + raise IdaOperationError(f"failed to decode bookmark slot {slot}") + address = hex(idaplace.ea) + return _bookmarks_BookmarkState(slot=slot, present=True, address=address, comment=desc) + + +def _bookmarks_bookmark_list(runtime: IdaRuntime) -> _bookmarks_BookmarkList: + ida_moves = runtime.mod("ida_moves") + bookmarks: list[_bookmarks_BookmarkState] = [] + for slot in range(ida_moves.MAX_MARK_SLOT + 1): + state = _bookmarks_bookmark_state(runtime, slot) + if state.present: + bookmarks.append(state) + return _bookmarks_BookmarkList(bookmarks=bookmarks, count=len(bookmarks)) + + +def _bookmarks_first_free_slot(runtime: IdaRuntime) -> int: + ida_moves = runtime.mod("ida_moves") + for slot in range(ida_moves.MAX_MARK_SLOT + 1): + if not _bookmarks_bookmark_state(runtime, slot).present: + return slot + raise IdaOperationError(f"no free bookmark slots remain (0..{ida_moves.MAX_MARK_SLOT})") + + +def _bookmarks_erase_bookmark_raw(runtime: IdaRuntime, slot: int) -> bool: + ida_moves = runtime.mod("ida_moves") + loc = _bookmarks_bookmark_template(runtime) + return bool(ida_moves.bookmarks_t.erase(loc, slot, None)) + + +def _bookmarks_write_bookmark_raw(runtime: IdaRuntime, *, slot: int, identifier: str, comment: str) -> int: + ea = runtime.resolve_address(identifier) + runtime.mod("ida_idc").mark_position(ea, 0, 0, 0, slot, comment) + return ea + + +def _bookmarks_write_bookmark( + runtime: IdaRuntime, *, slot: int, identifier: str, comment: str +) -> _bookmarks_BookmarkMutationResult: + ea = _bookmarks_write_bookmark_raw(runtime, slot=slot, identifier=identifier, comment=comment) + state = _bookmarks_bookmark_state(runtime, slot) + if not state.present or state.address != hex(ea) or state.comment != comment: + raise IdaOperationError(f"failed to set bookmark slot {slot}") + return _bookmarks_BookmarkMutationResult( + slot=state.slot, present=state.present, address=state.address, comment=state.comment, changed=True + ) + + +def _bookmarks_parse_get(params: Mapping[str, Any]) -> _bookmarks_BookmarkGetRequest: + slot_value = params.get("slot") + if slot_value in (None, ""): + return _bookmarks_BookmarkGetRequest() + return _bookmarks_BookmarkGetRequest(slot=_bookmarks_parse_slot(slot_value)) + + +def _bookmarks_parse_set(params: Mapping[str, Any]) -> _bookmarks_BookmarkSetRequest: + return _bookmarks_BookmarkSetRequest( + slot=_bookmarks_parse_slot(params.get("slot")), + identifier=require_str(params.get("address"), field="address"), + comment=str(params.get("comment") or ""), + ) + + +def _bookmarks_parse_add(params: Mapping[str, Any]) -> _bookmarks_BookmarkAddRequest: + return _bookmarks_BookmarkAddRequest( + identifier=require_str(params.get("address"), field="address"), + comment=str(params.get("comment") or ""), + ) + + +def _bookmarks_parse_delete(params: Mapping[str, Any]) -> _bookmarks_BookmarkDeleteRequest: + return _bookmarks_BookmarkDeleteRequest(slot=_bookmarks_parse_slot(params.get("slot"))) + + +def _bookmarks_get_bookmark( + context: OperationContext, request: _bookmarks_BookmarkGetRequest +) -> _bookmarks_BookmarkState | _bookmarks_BookmarkList: + runtime = context.runtime + if request.slot is None: + return _bookmarks_bookmark_list(runtime) + return _bookmarks_bookmark_state(runtime, _bookmarks_validate_slot(runtime, request.slot)) + + +def _bookmarks_set_bookmark( + context: OperationContext, request: _bookmarks_BookmarkSetRequest +) -> _bookmarks_BookmarkMutationResult: + runtime = context.runtime + slot = _bookmarks_validate_slot(runtime, request.slot) + before = _bookmarks_bookmark_state(runtime, slot) + target_address = hex(runtime.resolve_address(request.identifier)) + if before.present and before.address == target_address and before.comment == request.comment: + return _bookmarks_BookmarkMutationResult( + slot=before.slot, + present=before.present, + address=before.address, + comment=before.comment, + changed=False, + ) + try: + return _bookmarks_write_bookmark( + runtime, + slot=slot, + identifier=target_address, + comment=request.comment, + ) + except BaseException: + _bookmarks_restore_bookmark_state(context, request, before) + raise + + +def _bookmarks_add_bookmark( + context: OperationContext, request: _bookmarks_BookmarkAddRequest +) -> _bookmarks_BookmarkMutationResult: + runtime = context.runtime + slot = ( + _bookmarks_first_free_slot(runtime) if request.slot is None else _bookmarks_validate_slot(runtime, request.slot) + ) + resolved_request = _bookmarks_BookmarkAddRequest( + identifier=request.identifier, + comment=request.comment, + slot=slot, + ) + before = _bookmarks_BookmarkState(slot=slot, present=False, address=None, comment=None) + try: + return _bookmarks_write_bookmark( + runtime, + slot=slot, + identifier=request.identifier, + comment=request.comment, + ) + except BaseException: + _bookmarks_restore_bookmark_state(context, resolved_request, before) + raise + + +def _bookmarks_prepare_add_bookmark( + context: OperationContext, request: _bookmarks_BookmarkAddRequest +) -> _bookmarks_BookmarkAddRequest: + return _bookmarks_BookmarkAddRequest( + identifier=request.identifier, + comment=request.comment, + slot=_bookmarks_first_free_slot(context.runtime), + ) + + +def _bookmarks_delete_bookmark( + context: OperationContext, request: _bookmarks_BookmarkDeleteRequest +) -> _bookmarks_BookmarkMutationResult: + runtime = context.runtime + slot = _bookmarks_validate_slot(runtime, request.slot) + before = _bookmarks_bookmark_state(runtime, slot) + if not before.present: + return _bookmarks_BookmarkMutationResult( + slot=before.slot, present=before.present, address=before.address, comment=before.comment, changed=False + ) + try: + if not _bookmarks_erase_bookmark_raw(runtime, slot): + raise IdaOperationError(f"failed to delete bookmark slot {slot}") + after = _bookmarks_bookmark_state(runtime, slot) + if after.present: + raise IdaOperationError(f"failed to delete bookmark slot {slot}") + except BaseException: + _bookmarks_restore_bookmark_state(context, request, before) + raise + return _bookmarks_BookmarkMutationResult( + slot=after.slot, present=after.present, address=after.address, comment=after.comment, changed=True + ) + + +def _bookmarks_preview_single_slot( + context: OperationContext, + request: _bookmarks_BookmarkSetRequest | _bookmarks_BookmarkAddRequest | _bookmarks_BookmarkDeleteRequest, +) -> _bookmarks_BookmarkState: + runtime = context.runtime + if request.slot is None: + raise IdaOperationError("bookmark preview did not resolve a slot") + return _bookmarks_bookmark_state(runtime, _bookmarks_validate_slot(runtime, request.slot)) + + +def _bookmarks_restore_bookmark_state( + context: OperationContext, + request: _bookmarks_BookmarkSetRequest | _bookmarks_BookmarkAddRequest | _bookmarks_BookmarkDeleteRequest, + before: _bookmarks_BookmarkState, +) -> None: + runtime = context.runtime + if request.slot is None: + raise IdaOperationError("bookmark preview did not resolve a slot") + slot = _bookmarks_validate_slot(runtime, request.slot) + if before.present: + if before.address is None: + raise IdaOperationError(f"bookmark slot {slot} is present but has no saved address") + _bookmarks_write_bookmark_raw(runtime, slot=slot, identifier=before.address, comment=before.comment or "") + return + _bookmarks_erase_bookmark_raw(runtime, slot) + + +# ---- Comment operations ---- + + +_comments_CommentScope = Literal["line", "function", "anterior", "posterior"] + + +@dataclass(frozen=True) +class _comments_CommentLookup: + identifier: str + scope: _comments_CommentScope + repeatable: bool + + +@dataclass(frozen=True) +class _comments_CommentChange: + identifier: str + text: str + scope: _comments_CommentScope + repeatable: bool + + +@dataclass(frozen=True) +class _comments_CommentView: + address: str + scope: _comments_CommentScope + repeatable: bool + comment: str | None + + +@dataclass(frozen=True) +class _comments_CommentMutationResult: + address: str + scope: _comments_CommentScope + repeatable: bool + comment: str | None + changed: bool + + +def _comments_normalize_comment_text(text: str | None) -> str | None: + return None if text in (None, "") else str(text) + + +def _comments_parse_scope(params: Mapping[str, Any]) -> _comments_CommentScope: + scope = str(params.get("scope") or "line").strip().lower() + if scope not in {"line", "function", "anterior", "posterior"}: + raise IdaOperationError(f"unsupported comment scope: {scope}") + return scope + + +def _comments_parse_repeatable(params: Mapping[str, Any], *, scope: _comments_CommentScope) -> bool: + repeatable = bool(params.get("repeatable")) + if repeatable and scope in {"anterior", "posterior"}: + raise IdaOperationError("repeatable comments are only supported for line or function scope") + return repeatable + + +def _comments_function_for_comment(runtime: IdaRuntime, ea: int): + func = runtime.ida_funcs.get_func(ea) + if func is None: + raise IdaOperationError(f"no function contains address {hex(ea)}") + return func + + +def _comments_extra_anchor(runtime: IdaRuntime, scope: _comments_CommentScope) -> int: + ida_lines = runtime.mod("ida_lines") + if scope == "anterior": + return ida_lines.E_PREV + if scope == "posterior": + return ida_lines.E_NEXT + raise IdaOperationError(f"extra comments are unsupported for scope: {scope}") + + +def _comments_read_extra_comment(runtime: IdaRuntime, ea: int, *, scope: _comments_CommentScope) -> str | None: + ida_lines = runtime.mod("ida_lines") + index = _comments_extra_anchor(runtime, scope) + lines: list[str] = [] + while True: + line = ida_lines.get_extra_cmt(ea, index) + if line is None: + break + lines.append(str(line)) + index += 1 + return _comments_normalize_comment_text(None if not lines else "\n".join(lines)) + + +def _comments_extra_comment_lines(text: str | None) -> list[str]: + rendered = "" if text is None else str(text) + return [] if rendered == "" else rendered.split("\n") + + +def _comments_set_extra_comment_lines( + runtime: IdaRuntime, ea: int, *, scope: _comments_CommentScope, lines: list[str] +) -> None: + ida_lines = runtime.mod("ida_lines") + anchor = _comments_extra_anchor(runtime, scope) + ida_lines.delete_extra_cmts(ea, anchor) + for index, line in enumerate(lines): + if not ida_lines.update_extra_cmt(ea, anchor + index, line): + raise IdaOperationError(f"failed to set {scope} comment at {hex(ea)}") + + +def _comments_write_extra_comment( + runtime: IdaRuntime, ea: int, *, scope: _comments_CommentScope, text: str | None +) -> None: + before = _comments_read_extra_comment(runtime, ea, scope=scope) + before_lines = _comments_extra_comment_lines(before) + new_lines = _comments_extra_comment_lines(text) + try: + _comments_set_extra_comment_lines(runtime, ea, scope=scope, lines=new_lines) + except Exception as exc: + try: + _comments_set_extra_comment_lines(runtime, ea, scope=scope, lines=before_lines) + except Exception as restore_exc: + raise IdaOperationError( + f"failed to restore {scope} comment at {hex(ea)} after update failure" + ) from restore_exc + raise IdaOperationError(f"failed to set {scope} comment at {hex(ea)}") from exc + + +def _comments_read_comment( + runtime: IdaRuntime, request: _comments_CommentLookup | _comments_CommentChange +) -> _comments_CommentView: + ida_bytes = runtime.mod("ida_bytes") + ea = runtime.resolve_address(request.identifier) + if request.scope == "line": + comment = _comments_normalize_comment_text(ida_bytes.get_cmt(ea, request.repeatable)) + return _comments_CommentView( + address=hex(ea), scope=request.scope, repeatable=request.repeatable, comment=comment + ) + if request.scope == "function": + func = _comments_function_for_comment(runtime, ea) + comment = _comments_normalize_comment_text(runtime.ida_funcs.get_func_cmt(func, request.repeatable)) + return _comments_CommentView( + address=hex(func.start_ea), scope=request.scope, repeatable=request.repeatable, comment=comment + ) + return _comments_CommentView( + address=hex(ea), + scope=request.scope, + repeatable=False, + comment=_comments_read_extra_comment(runtime, ea, scope=request.scope), + ) + + +def _comments_write_comment( + runtime: IdaRuntime, request: _comments_CommentChange, *, text: str +) -> _comments_CommentMutationResult: + ida_bytes = runtime.mod("ida_bytes") + ea = runtime.resolve_address(request.identifier) + if request.scope == "line": + if not ida_bytes.set_cmt(ea, text, request.repeatable): + raise IdaOperationError(f"failed to set comment at {hex(ea)}") + return _comments_CommentMutationResult( + address=hex(ea), + scope=request.scope, + repeatable=request.repeatable, + comment=_comments_normalize_comment_text(ida_bytes.get_cmt(ea, request.repeatable)), + changed=True, + ) + if request.scope == "function": + func = _comments_function_for_comment(runtime, ea) + if not runtime.ida_funcs.set_func_cmt(func, text, request.repeatable): + raise IdaOperationError(f"failed to set function comment at {hex(func.start_ea)}") + return _comments_CommentMutationResult( + address=hex(func.start_ea), + scope=request.scope, + repeatable=request.repeatable, + comment=_comments_normalize_comment_text(runtime.ida_funcs.get_func_cmt(func, request.repeatable)), + changed=True, + ) + _comments_write_extra_comment(runtime, ea, scope=request.scope, text=text) + return _comments_CommentMutationResult( + address=hex(ea), + scope=request.scope, + repeatable=False, + comment=_comments_read_extra_comment(runtime, ea, scope=request.scope), + changed=True, + ) + + +def _comments_comment_view( + context: OperationContext, request: _comments_CommentLookup | _comments_CommentChange +) -> _comments_CommentView: + return _comments_read_comment(context.runtime, request) + + +def _comments_parse_lookup(params: Mapping[str, Any]) -> _comments_CommentLookup: + identifier = require_str(params.get("address"), field="address") + scope = _comments_parse_scope(params) + return _comments_CommentLookup( + identifier=identifier, scope=scope, repeatable=_comments_parse_repeatable(params, scope=scope) + ) + + +def _comments_parse_change(params: Mapping[str, Any]) -> _comments_CommentChange: + request = _comments_parse_lookup(params) + return _comments_CommentChange( + identifier=request.identifier, + text=str(params.get("text") or ""), + scope=request.scope, + repeatable=request.repeatable, + ) + + +def _comments_change_comment( + context: OperationContext, request: _comments_CommentLookup | _comments_CommentChange +) -> _comments_CommentMutationResult: + change = ( + request + if isinstance(request, _comments_CommentChange) + else _comments_CommentChange( + identifier=request.identifier, text="", scope=request.scope, repeatable=request.repeatable + ) + ) + before = _comments_read_comment(context.runtime, change) + desired = _comments_normalize_comment_text(change.text) + if before.comment == desired: + return _comments_CommentMutationResult( + address=before.address, + scope=before.scope, + repeatable=before.repeatable, + comment=before.comment, + changed=False, + ) + try: + return _comments_write_comment(context.runtime, change, text=change.text) + except BaseException: + try: + _comments_restore_comment(context, change, before) + except BaseException as restore_exc: + raise IdaOperationError(f"failed to restore {change.scope} comment after mutation failure") from restore_exc + raise + + +def _comments_restore_comment( + context: OperationContext, + request: _comments_CommentLookup | _comments_CommentChange, + before: _comments_CommentView, +) -> None: + del request + runtime = context.runtime + ea = runtime.resolve_address(before.address) + text = "" if before.comment is None else before.comment + if before.scope == "line": + if not runtime.mod("ida_bytes").set_cmt(ea, text, before.repeatable): + raise IdaOperationError(f"failed to restore comment at {hex(ea)}") + return + if before.scope == "function": + func = _comments_function_for_comment(runtime, ea) + if not runtime.ida_funcs.set_func_cmt(func, text, before.repeatable): + raise IdaOperationError(f"failed to restore function comment at {hex(func.start_ea)}") + return + _comments_set_extra_comment_lines( + runtime, + ea, + scope=before.scope, + lines=_comments_extra_comment_lines(before.comment), + ) + + +# ---- Name operations ---- + + +@dataclass(frozen=True) +class _names_NameSetRequest: + identifier: str + new_name: str + + +@dataclass(frozen=True) +class _names_NameState: + address: str + name: str + + +@dataclass(frozen=True) +class _names_NameMutationResult: + address: str + name: str + changed: bool + + +def _names_parse_name_set(params: Mapping[str, Any]) -> _names_NameSetRequest: + identifier = require_str(params.get("identifier"), field="address or identifier") + new_name = require_str(params.get("new_name"), field="new name") + return _names_NameSetRequest(identifier=identifier, new_name=new_name) + + +def _names_prepare_name_set(context: OperationContext, request: _names_NameSetRequest) -> _names_NameSetRequest: + runtime = context.runtime + return _names_NameSetRequest(identifier=hex(runtime.resolve_address(request.identifier)), new_name=request.new_name) + + +def _names_name_state(context: OperationContext, request: _names_NameSetRequest) -> _names_NameState: + runtime = context.runtime + ida_name = runtime.mod("ida_name") + ea = runtime.resolve_address(request.identifier) + return _names_NameState(address=hex(ea), name=ida_name.get_name(ea) or "") + + +def _names_set_name(context: OperationContext, request: _names_NameSetRequest) -> _names_NameMutationResult: + runtime = context.runtime + ida_name = runtime.mod("ida_name") + ea = runtime.resolve_address(request.identifier) + before = _names_NameState(address=hex(ea), name=ida_name.get_name(ea) or "") + if before.name == request.new_name: + return _names_NameMutationResult(address=before.address, name=before.name, changed=False) + try: + if not ida_name.set_name(ea, request.new_name, ida_name.SN_CHECK): + raise IdaOperationError(f"failed to set name at {hex(ea)}") + after = ida_name.get_name(ea) or "" + if after != request.new_name: + raise IdaOperationError(f"failed to read back name at {hex(ea)}") + except BaseException: + _names_restore_name(context, request, before) + raise + return _names_NameMutationResult(address=hex(ea), name=after, changed=True) + + +def _names_restore_name( + context: OperationContext, + request: _names_NameSetRequest, + before: _names_NameState, +) -> None: + runtime = context.runtime + ida_name = runtime.mod("ida_name") + ea = runtime.resolve_address(request.identifier) + if not ida_name.set_name(ea, before.name, ida_name.SN_CHECK): + raise IdaOperationError(f"failed to restore name at {hex(ea)}") + + +# ---- Local-variable operations ---- + + +@dataclass(frozen=True) +class _locals_LocalSelector: + name: str | None = None + local_id: str | None = None + index: int | None = None + + def stable_selector(self) -> tuple[str, Any] | None: + if self.local_id is not None: + return ("local_id", self.local_id) + if self.index is not None: + return ("index", self.index) + return None + + +@dataclass(frozen=True) +class _locals_LocalListRequest: + identifier: str + + +@dataclass(frozen=True) +class _locals_LocalRenameRequest: + identifier: str + selector: _locals_LocalSelector + new_name: str + + +@dataclass(frozen=True) +class _locals_LocalRetypeRequest: + identifier: str + selector: _locals_LocalSelector + decl: str + + +@dataclass(frozen=True) +class _locals_LocalUpdateRequest: + identifier: str + selector: _locals_LocalSelector + new_name: str | None = None + decl: str | None = None + + +@dataclass(frozen=True) +class _locals_LocalPlanItem: + selector: _locals_LocalSelector + new_name: str | None = None + decl: str | None = None + type_text: str | None = None + + +@dataclass(frozen=True) +class _locals_LocalApplyPlanRequest: + identifier: str + items: tuple[_locals_LocalPlanItem, ...] + + +@dataclass(frozen=True) +class _locals_LocalRow: + index: int + local_id: str + definition_address: str + location: str + name: str + display_name: str + type: str + is_arg: bool + is_stack: bool + stack_offset: int | None + size: int + + +@dataclass(frozen=True) +class _locals_LocalListResult: + function: str + address: str + locals: tuple[_locals_LocalRow, ...] + + +@dataclass(frozen=True) +class _locals_LocalMutationResult: + function: str + address: str + locals: tuple[_locals_LocalRow, ...] + changed: bool + + +@dataclass(frozen=True) +class _locals_AppliedLocalPlanItem: + index: int + local_id: str + old_name: str + new_name: str | None + decl: str | None + + +@dataclass(frozen=True) +class _locals_LocalApplyPlanResult: + function: str + address: str + locals: tuple[_locals_LocalRow, ...] + changed: bool + applied: tuple[_locals_AppliedLocalPlanItem, ...] + + +@dataclass(frozen=True) +class _locals_SelectedLocal: + name: str + locator: Any + display_name: str | None = None + index: int | None = None + local_id: str | None = None + + def label(self) -> str: + return self.display_name or self.name or "" + + +_locals_LOCAL_ID_NEW_RE = re.compile( + "^(?Pstack|reg|regpair)\\((?P[^)]*)\\)@(?P0x[0-9a-fA-F]+|\\d+)$", re.IGNORECASE +) +_locals_LOCAL_SELECTOR_GUIDANCE = ( + "list locals again to confirm current names and prefer a stable selector such as local_id or index" +) + + +def _locals_parse_local_selector(params: Mapping[str, Any], *, name_key: str) -> _locals_LocalSelector: + name = str(params.get(name_key) or "").strip() or None + local_id = str(params.get("local_id") or "").strip() or None + index = optional_param_int(params, "index", label="local index", minimum=0) + stable_count = sum(value is not None for value in (local_id, index)) + if name is None and stable_count == 0: + raise IdaOperationError(f"local selector is required via {name_key}, local_id, or index") + if stable_count > 1: + raise IdaOperationError("--local-id and --index are mutually exclusive; got: local_id, index") + return _locals_LocalSelector(name=name, local_id=local_id, index=index) + + +def _locals_parse_local_list(params: Mapping[str, Any]) -> _locals_LocalListRequest: + return _locals_LocalListRequest(identifier=require_str(params.get("identifier"), field="address or identifier")) + + +def _locals_parse_local_rename(params: Mapping[str, Any]) -> _locals_LocalRenameRequest: + new_name = str(params.get("new_name") or "") + if not new_name: + raise IdaOperationError("new local variable name is required") + return _locals_LocalRenameRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + selector=_locals_parse_local_selector(params, name_key="old_name"), + new_name=new_name, + ) + + +def _locals_parse_local_retype(params: Mapping[str, Any]) -> _locals_LocalRetypeRequest: + decl = str(params.get("decl") or "") + if not decl: + raise IdaOperationError("local variable declaration is required") + return _locals_LocalRetypeRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + selector=_locals_parse_local_selector(params, name_key="local_name"), + decl=decl, + ) + + +def _locals_parse_local_update(params: Mapping[str, Any]) -> _locals_LocalUpdateRequest: + new_name = str(params.get("new_name") or "").strip() or None + decl = str(params.get("decl") or "").strip() or None + if new_name is None and decl is None: + raise IdaOperationError("at least one of new_name or decl is required") + return _locals_LocalUpdateRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + selector=_locals_parse_local_selector(params, name_key="local_name"), + new_name=new_name, + decl=decl, + ) + + +def _locals_local_plan_selector(raw: Mapping[Any, Any], *, index: int) -> _locals_LocalSelector: + name = str(raw.get("name") or "").strip() or None + local_id = str(raw.get("local_id") or "").strip() or None + raw_index = raw.get("index") + try: + local_index = None if raw_index in (None, "") else parse_int_text(raw_index, label="local index", minimum=0) + except ValueError as exc: + raise IdaOperationError(f"local apply item {index}: {exc}") from exc + stable_count = sum(value is not None for value in (local_id, local_index)) + if name is None and stable_count == 0: + raise IdaOperationError(f"local apply item {index}: selector is required via local_id, index, or name") + if stable_count > 1: + raise IdaOperationError(f"local apply item {index}: local_id and index are mutually exclusive") + if name is not None and stable_count > 0: + raise IdaOperationError(f"local apply item {index}: do not combine name with local_id or index") + return _locals_LocalSelector(name=name, local_id=local_id, index=local_index) + + +def _locals_parse_local_apply_plan(params: Mapping[str, Any]) -> _locals_LocalApplyPlanRequest: + raw_items = params.get("items") + if not isinstance(raw_items, list): + raise IdaOperationError("local apply requires a JSON list of item objects") + if not raw_items: + raise IdaOperationError("local apply requires at least one item") + items: list[_locals_LocalPlanItem] = [] + for item_index, raw in enumerate(raw_items, start=1): + if not isinstance(raw, dict): + raise IdaOperationError(f"local apply item {item_index}: expected object") + unsupported_fields = set(raw) - {"name", "local_id", "index", "rename", "decl", "type"} + if unsupported_fields: + fields_text = ", ".join(sorted(str(field) for field in unsupported_fields)) + raise IdaOperationError(f"local apply item {item_index}: unsupported field(s): {fields_text}") + new_name = str(raw.get("rename") or "").strip() or None + decl = str(raw.get("decl") or "").strip() or None + type_text = str(raw.get("type") or "").strip() or None + if decl is not None and type_text is not None: + raise IdaOperationError(f"local apply item {item_index}: use either decl or type, not both") + if new_name is None and decl is None and (type_text is None): + raise IdaOperationError(f"local apply item {item_index}: at least one of rename, decl, or type is required") + items.append( + _locals_LocalPlanItem( + selector=_locals_local_plan_selector(raw, index=item_index), + new_name=new_name, + decl=decl, + type_text=type_text, + ) + ) + return _locals_LocalApplyPlanRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + items=tuple(items), + ) + + +def _locals_vdloc_text(location) -> str: + if location.is_stkoff(): + return f"stack({location.stkoff()})" + if location.is_reg1(): + return f"reg({location.reg1()})" + if location.is_reg2(): + return f"regpair({location.reg1()},{location.reg2()})" + return "unknown" + + +def _locals_local_identity(lvar) -> tuple[str, str, str]: + definition_address = hex(lvar.defea) + location = _locals_vdloc_text(lvar.location) + return (definition_address, location, f"{location}@{definition_address}") + + +def _locals_safe_local_id(lvar) -> str: + try: + return _locals_local_identity(lvar)[2] + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + return "" + + +def _locals_normalize_local_location_text(text: str) -> str: + value = str(text).strip() + match = re.fullmatch("(?Pstack|reg|regpair)\\((?P[^)]*)\\)", value, re.IGNORECASE) + if match: + kind = match.group("kind").lower() + body = match.group("body").strip() + if kind in {"stack", "reg"} and body: + with contextlib.suppress(ValueError): + body = str(int(body, 0)) + elif kind == "regpair" and body: + left, sep, right = body.partition(",") + if sep: + with contextlib.suppress(ValueError): + left = str(int(left.strip(), 0)) + with contextlib.suppress(ValueError): + right = str(int(right.strip(), 0)) + body = f"{left},{right}" + return f"{kind}({body})" + return value + + +def _locals_normalize_local_id_text(local_id: str) -> str: + text = str(local_id).strip() + match = _locals_LOCAL_ID_NEW_RE.match(text) + if match: + defea_text = match.group("defea") + with contextlib.suppress(ValueError): + defea_text = hex(int(defea_text, 0)) + location_text = _locals_normalize_local_location_text(f"{match.group('kind')}({match.group('body')})") + return f"{location_text}@{defea_text}" + return text + + +def _locals_parse_var_decl(runtime: IdaRuntime, decl: str, *, error_message: str): + tif = runtime.ida_typeinf.tinfo_t() + parse_text = decl.strip() + if not parse_text.endswith(";"): + parse_text += ";" + parse_flags = runtime.ida_typeinf.PT_VAR | runtime.ida_typeinf.PT_SIL | runtime.ida_typeinf.PT_SEMICOLON + if not runtime.ida_typeinf.parse_decl(tif, None, parse_text, parse_flags): + raise IdaOperationError(error_message) + return tif + + +def _locals_decompile_locals(runtime: IdaRuntime, func_ea: int, *, action: str): + cfunc = runtime.require_hexrays().decompile(func_ea) + if cfunc is None: + raise IdaOperationError(f"failed to {action} for {hex(func_ea)}") + return cfunc + + +def _locals_lvar_locator(runtime: IdaRuntime, lvar): + locator = runtime.require_hexrays().lvar_locator_t() + locator.defea = lvar.defea + locator.location = lvar.location + return locator + + +def _locals_local_row(runtime: IdaRuntime, index: int, lvar, saved) -> _locals_LocalRow: + stack_offset = None + if lvar.is_stk_var(): + with suppress_recoverable_ida_errors(): + stack_offset = lvar.get_stkoff() + definition_address, location, local_id = _locals_local_identity(lvar) + name = lvar.name or "" + if saved is not None and saved.name: + name = str(saved.name) + type_text = runtime.tinfo_decl(lvar.tif, multi=False) + if saved is not None: + try: + saved_type = saved.type._print() if saved.type else "" + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + saved_type = "" + if saved_type: + type_text = saved_type + return _locals_LocalRow( + index=index, + local_id=local_id, + definition_address=definition_address, + location=location, + name=name, + display_name=name or f"", + type=type_text, + is_arg=lvar.is_arg_var, + is_stack=lvar.is_stk_var(), + stack_offset=stack_offset, + size=lvar.width, + ) + + +def _locals_local_rows(runtime: IdaRuntime, func_ea: int) -> tuple[_locals_LocalRow, ...]: + ida_hexrays = runtime.require_hexrays() + cfunc = _locals_decompile_locals(runtime, func_ea, action="inspect locals") + user_rows: dict[tuple[int, str], Any] = {} + user_info = ida_hexrays.lvar_uservec_t() + if ida_hexrays.restore_user_lvar_settings(user_info, func_ea): + for saved in user_info.lvvec: + user_rows[saved.ll.defea, _locals_vdloc_text(saved.ll.location)] = saved + rows: list[_locals_LocalRow] = [] + for index, lvar in enumerate(cfunc.get_lvars()): + saved = user_rows.get((lvar.defea, _locals_vdloc_text(lvar.location))) + rows.append(_locals_local_row(runtime, index, lvar, saved)) + return tuple(rows) + + +def _locals_local_list_result(runtime: IdaRuntime, func_ea: int) -> _locals_LocalListResult: + return _locals_LocalListResult( + function=runtime.function_name(func_ea), address=hex(func_ea), locals=_locals_local_rows(runtime, func_ea) + ) + + +def _locals_stable_local_matches( + lvars: list[Any], *, selector_name: str, selector_value: Any +) -> tuple[list[tuple[int, Any]], str]: + if selector_name == "local_id": + normalized_local_id = _locals_normalize_local_id_text(str(selector_value)) + matches = [ + (index, lvar) + for index, lvar in enumerate(lvars) + if _locals_normalize_local_id_text(_locals_local_identity(lvar)[2]) == normalized_local_id + ] + return (matches, f"local id `{selector_value}`") + matches = [(index, lvar) for index, lvar in enumerate(lvars) if index == selector_value] + return (matches, f"local index {selector_value}") + + +def _locals_resolve_lvar_by_name(runtime: IdaRuntime, func_ea: int, name: str): + local_name = name.strip() + if not local_name: + raise IdaOperationError("local variable name is required") + locator = runtime.require_hexrays().lvar_locator_t() + if not runtime.require_hexrays().locate_lvar(locator, func_ea, local_name): + raise IdaOperationError( + f"local variable not found: {local_name}; {_locals_LOCAL_SELECTOR_GUIDANCE}" + f"{_locals_available_locals_suffix(runtime, func_ea)}" + ) + return locator + + +def _locals_select_local(runtime: IdaRuntime, func_ea: int, selector: _locals_LocalSelector) -> _locals_SelectedLocal: + stable = selector.stable_selector() + if stable is None: + if selector.name is None: + raise IdaOperationError("local selector name is required") + resolved_name = selector.name.strip() + return _locals_SelectedLocal( + name=resolved_name, + locator=_locals_resolve_lvar_by_name(runtime, func_ea, resolved_name), + display_name=resolved_name, + ) + selector_name, selector_value = stable + cfunc = _locals_decompile_locals(runtime, func_ea, action="inspect locals") + matches, label = _locals_stable_local_matches( + list(cfunc.get_lvars()), selector_name=selector_name, selector_value=selector_value + ) + if not matches: + raise IdaOperationError( + f"local variable not found for {label}; {_locals_LOCAL_SELECTOR_GUIDANCE}" + f"{_locals_available_locals_suffix(runtime, func_ea)}" + ) + if len(matches) > 1: + raise IdaOperationError(f"multiple locals matched {label}; use local_id or index instead") + index, lvar = matches[0] + resolved_name = lvar.name or "" + return _locals_SelectedLocal( + name=resolved_name, + locator=_locals_lvar_locator(runtime, lvar), + display_name=resolved_name or f"", + index=index, + local_id=_locals_safe_local_id(lvar), + ) + + +def _locals_select_local_from_lvars( + runtime: IdaRuntime, func_ea: int, lvars: list[Any], selector: _locals_LocalSelector +) -> _locals_SelectedLocal: + stable = selector.stable_selector() + if stable is None: + if selector.name is None: + raise IdaOperationError("local selector name is required") + name = selector.name.strip() + matches = [(index, lvar) for index, lvar in enumerate(lvars) if str(lvar.name or "") == name] + label = f"local name `{name}`" + else: + selector_name, selector_value = stable + matches, label = _locals_stable_local_matches(lvars, selector_name=selector_name, selector_value=selector_value) + if not matches: + raise IdaOperationError( + f"local variable not found for {label}; {_locals_LOCAL_SELECTOR_GUIDANCE}" + f"{_locals_available_locals_suffix(runtime, func_ea)}" + ) + if len(matches) > 1: + raise IdaOperationError(f"multiple locals matched {label}; use local_id or index instead") + index, lvar = matches[0] + name = str(lvar.name or "") + return _locals_SelectedLocal( + name=name, + locator=_locals_lvar_locator(runtime, lvar), + display_name=name or f"", + index=index, + local_id=_locals_safe_local_id(lvar), + ) + + +def _locals_available_locals_suffix(runtime: IdaRuntime, func_ea: int) -> str: + try: + rows = _locals_local_rows(runtime, func_ea) + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + return "" + if not rows: + return "" + rendered: list[str] = [] + for row in rows[:12]: + name = row.display_name or row.name or f"" + rendered.append(f"#{row.index} {name} ({row.local_id})") + suffix = "; available locals: " + ", ".join(rendered) + if len(rows) > 12: + suffix += f", ... {len(rows) - 12} more" + return suffix + + +def _locals_local_saved_info(runtime: IdaRuntime, locator): + info = runtime.require_hexrays().lvar_saved_info_t() + info.ll = locator + return info + + +def _locals_readback_local_change( + runtime: IdaRuntime, func_ea: int, *, success_message: str +) -> _locals_LocalMutationResult: + try: + refreshed = _locals_local_list_result(runtime, func_ea) + except Exception as exc: + detail = str(exc) or exc.__class__.__name__ + raise IdaOperationError(f"{success_message} but failed to read back locals: {detail}") from exc + return _locals_LocalMutationResult( + function=refreshed.function, address=refreshed.address, locals=refreshed.locals, changed=True + ) + + +def _locals_dirty_local_cfunc(runtime: IdaRuntime, func_ea: int) -> None: + ida_hexrays = runtime.require_hexrays() + with suppress_recoverable_ida_errors(): + ida_hexrays.mark_cfunc_dirty(func_ea, False) + ida_hexrays.clear_cached_cfuncs() + + +def _locals_apply_local_change( + runtime: IdaRuntime, func_ea: int, info, *, modify_flag: int, failure_message: str, success_message: str +) -> _locals_LocalMutationResult: + if not runtime.require_hexrays().modify_user_lvar_info(func_ea, modify_flag, info): + raise IdaOperationError(failure_message) + _locals_dirty_local_cfunc(runtime, func_ea) + return _locals_readback_local_change(runtime, func_ea, success_message=success_message) + + +def _locals_cleanup_local_preview( + context: OperationContext, + request: _locals_LocalRenameRequest + | _locals_LocalRetypeRequest + | _locals_LocalUpdateRequest + | _locals_LocalApplyPlanRequest, +) -> None: + runtime = context.runtime + try: + func_ea = runtime.function_ea(request.identifier) + _locals_dirty_local_cfunc(runtime, func_ea) + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + + +def _locals_local_list( + context: OperationContext, + request: _locals_LocalListRequest + | _locals_LocalRenameRequest + | _locals_LocalRetypeRequest + | _locals_LocalUpdateRequest + | _locals_LocalApplyPlanRequest, +) -> _locals_LocalListResult: + runtime = context.runtime + func_ea = runtime.function_ea(request.identifier) + return _locals_local_list_result(runtime, func_ea) + + +def _locals_local_rename(context: OperationContext, request: _locals_LocalRenameRequest) -> _locals_LocalMutationResult: + runtime = context.runtime + func_ea = runtime.function_ea(request.identifier) + selected = _locals_select_local(runtime, func_ea, request.selector) + failure_message = f"failed to rename local variable: {selected.label()}" + success_message = f"renamed local variable `{selected.label()}` to `{request.new_name}`" + info = _locals_local_saved_info(runtime, selected.locator) + info.name = request.new_name + return _locals_apply_local_change( + runtime, + func_ea, + info, + modify_flag=runtime.require_hexrays().MLI_NAME, + failure_message=failure_message, + success_message=success_message, + ) + + +def _locals_local_retype(context: OperationContext, request: _locals_LocalRetypeRequest) -> _locals_LocalMutationResult: + runtime = context.runtime + func_ea = runtime.function_ea(request.identifier) + selected = _locals_select_local(runtime, func_ea, request.selector) + info = _locals_local_saved_info(runtime, selected.locator) + info.name = selected.name + info.type = _locals_parse_var_decl( + runtime, request.decl, error_message=f"failed to parse local variable declaration: {request.decl}" + ) + return _locals_apply_local_change( + runtime, + func_ea, + info, + modify_flag=runtime.require_hexrays().MLI_TYPE, + failure_message=f"failed to update local variable type: {selected.label()}", + success_message=f"updated local variable type for `{selected.label()}`", + ) + + +def _locals_local_update(context: OperationContext, request: _locals_LocalUpdateRequest) -> _locals_LocalMutationResult: + runtime = context.runtime + func_ea = runtime.function_ea(request.identifier) + selected = _locals_select_local(runtime, func_ea, request.selector) + info = _locals_local_saved_info(runtime, selected.locator) + modify_flag = 0 + if request.new_name is not None: + info.name = request.new_name + modify_flag |= runtime.require_hexrays().MLI_NAME + if request.decl is not None: + info.name = request.new_name or selected.name + info.type = _locals_parse_var_decl( + runtime, request.decl, error_message=f"failed to parse local variable declaration: {request.decl}" + ) + modify_flag |= runtime.require_hexrays().MLI_TYPE + success_message_parts: list[str] = [] + if request.new_name is not None: + success_message_parts.append(f"renamed local variable `{selected.label()}` to `{request.new_name}`") + if request.decl is not None: + success_message_parts.append(f"updated local variable type for `{request.new_name or selected.label()}`") + return _locals_apply_local_change( + runtime, + func_ea, + info, + modify_flag=modify_flag, + failure_message=f"failed to update local variable: {selected.label()}", + success_message=" and ".join(success_message_parts), + ) + + +def _locals_decl_for_plan_item(item: _locals_LocalPlanItem, selected: _locals_SelectedLocal) -> str | None: + if item.decl is not None: + return item.decl + if item.type_text is None: + return None + name = item.new_name or selected.name + if not name: + raise IdaOperationError("local apply type entries for unnamed locals require a rename or full decl") + return f"{item.type_text.rstrip(';')} {name};" + + +def _locals_local_apply_plan( + context: OperationContext, request: _locals_LocalApplyPlanRequest +) -> _locals_LocalApplyPlanResult: + runtime = context.runtime + ida_hexrays = runtime.require_hexrays() + func_ea = runtime.function_ea(request.identifier) + cfunc = _locals_decompile_locals(runtime, func_ea, action="inspect locals") + lvars = list(cfunc.get_lvars()) + prepared: list[tuple[_locals_SelectedLocal, Any, int, _locals_LocalPlanItem, str | None]] = [] + for item in request.items: + selected = _locals_select_local_from_lvars(runtime, func_ea, lvars, item.selector) + info = _locals_local_saved_info(runtime, selected.locator) + modify_flag = 0 + if item.new_name is not None: + info.name = item.new_name + modify_flag |= ida_hexrays.MLI_NAME + decl = _locals_decl_for_plan_item(item, selected) + if decl is not None: + info.name = item.new_name or selected.name + info.type = _locals_parse_var_decl( + runtime, decl, error_message=f"failed to parse local variable declaration: {decl}" + ) + modify_flag |= ida_hexrays.MLI_TYPE + prepared.append((selected, info, modify_flag, item, decl)) + applied: list[_locals_AppliedLocalPlanItem] = [] + for selected, info, modify_flag, item, decl in prepared: + if not ida_hexrays.modify_user_lvar_info(func_ea, modify_flag, info): + raise IdaOperationError(f"failed to apply local plan item for `{selected.label()}`") + applied.append( + _locals_AppliedLocalPlanItem( + index=-1 if selected.index is None else selected.index, + local_id=selected.local_id or "", + old_name=selected.name, + new_name=item.new_name, + decl=decl, + ) + ) + _locals_dirty_local_cfunc(runtime, func_ea) + try: + refreshed = _locals_local_list_result(runtime, func_ea) + except Exception as exc: + detail = str(exc) or exc.__class__.__name__ + raise IdaOperationError(f"applied local plan but failed to read back locals: {detail}") from exc + return _locals_LocalApplyPlanResult( + function=refreshed.function, + address=refreshed.address, + locals=refreshed.locals, + changed=True, + applied=tuple(applied), + ) + + +# ---- Prototype operations ---- + + +_prototypes_PROTO_BUILTIN_TOKENS = frozenset( + { + "__cdecl", + "__fastcall", + "__hidden", + "__int128", + "__int16", + "__int32", + "__int64", + "__int8", + "__noreturn", + "__pascal", + "__ptr32", + "__ptr64", + "__stdcall", + "__thiscall", + "__usercall", + "__userpurge", + "__return_ptr", + "bool", + "char", + "class", + "const", + "double", + "enum", + "float", + "int", + "long", + "short", + "signed", + "size_t", + "struct", + "u16", + "u32", + "u64", + "u8", + "uint16_t", + "uint32_t", + "uint64_t", + "uint8_t", + "uintptr_t", + "union", + "unsigned", + "void", + "volatile", + "wchar_t", + "s8", + "s16", + "s32", + "s64", + "int8_t", + "int16_t", + "int32_t", + "int64_t", + } +) + + +@dataclass(frozen=True) +class _prototypes_PrototypeGetRequest: + identifier: str + + +@dataclass(frozen=True) +class _prototypes_PrototypeSetRequest: + identifier: str + decl: str + preview_decompile: bool = False + propagate_callers: bool = False + preserve_cc: bool = False + + +@dataclass(frozen=True) +class _prototypes_PrototypeCheckRequest: + identifier: str + decl: str + + +@dataclass(frozen=True) +class _prototypes_PrototypeView: + address: str + prototype: str + + +@dataclass(frozen=True) +class _prototypes_PrototypePreviewView: + address: str + prototype: str + decompile: str + + +@dataclass(frozen=True) +class _prototypes_PrototypePreviewErrorView: + address: str + prototype: str + decompile: None + decompile_error: str + + +@dataclass(frozen=True) +class _prototypes_PrototypeMutationResult: + address: str + prototype: str + changed: bool + callers_considered: int = 0 + callers_updated: int = 0 + callers_failed: int = 0 + + +@dataclass(frozen=True) +class _prototypes_PrototypeCheckResult: + address: str + success: bool + parsed: bool + is_function: bool + arglocs_calculated: bool | None + unknown_types: tuple[str, ...] + diagnostics: tuple[str, ...] + + +def _prototypes_parse_proto_get(params: Mapping[str, Any]) -> _prototypes_PrototypeGetRequest: + return _prototypes_PrototypeGetRequest( + identifier=require_str(params.get("identifier"), field="address or identifier") + ) + + +def _prototypes_parse_proto_set(params: Mapping[str, Any]) -> _prototypes_PrototypeSetRequest: + decl = str(params.get("decl") or "") + if not decl: + raise IdaOperationError("prototype declaration is required") + return _prototypes_PrototypeSetRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + decl=decl, + preview_decompile=bool(params.get("preview_decompile")), + propagate_callers=bool(params.get("propagate_callers")), + preserve_cc=bool(params.get("preserve_cc")), + ) + + +def _prototypes_parse_proto_check(params: Mapping[str, Any]) -> _prototypes_PrototypeCheckRequest: + decl = str(params.get("decl") or "") + if not decl: + raise IdaOperationError("prototype declaration is required") + return _prototypes_PrototypeCheckRequest( + identifier=require_str(params.get("identifier"), field="address or identifier"), + decl=decl, + ) + + +def _prototypes_prototype_view( + context: OperationContext, request: _prototypes_PrototypeGetRequest | _prototypes_PrototypeSetRequest +) -> _prototypes_PrototypeView | _prototypes_PrototypePreviewView | _prototypes_PrototypePreviewErrorView: + runtime = context.runtime + ea = runtime.function_ea(request.identifier) + prototype = runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "" + if not isinstance(request, _prototypes_PrototypeSetRequest) or not request.preview_decompile: + return _prototypes_PrototypeView(address=hex(ea), prototype=prototype) + try: + cfunc = runtime.require_hexrays().decompile(ea) + if cfunc is None: + raise IdaOperationError(f"failed to decompile function at {hex(ea)}") + except Exception as exc: + detail = str(exc) or exc.__class__.__name__ + return _prototypes_PrototypePreviewErrorView( + address=hex(ea), prototype=prototype, decompile=None, decompile_error=detail + ) + return _prototypes_PrototypePreviewView( + address=hex(ea), prototype=prototype, decompile=runtime.pseudocode_text(cfunc) + ) + + +def _prototypes_propagate_callee_tinfo(runtime: IdaRuntime, callee_ea: int, tif) -> tuple[int, int, int]: + callers_considered = 0 + callers_updated = 0 + callers_failed = 0 + seen: set[int] = set() + for ref in runtime.idautils.CodeRefsTo(callee_ea, 0): + if ref in seen: + continue + seen.add(ref) + insn = runtime.ida_ua.insn_t() + if not runtime.ida_ua.decode_insn(insn, ref): + continue + if not runtime.ida_idp.is_call_insn(insn): + continue + callers_considered += 1 + if runtime.ida_typeinf.apply_callee_tinfo(ref, tif): + callers_updated += 1 + else: + callers_failed += 1 + return (callers_considered, callers_updated, callers_failed) + + +def _prototypes_unknown_proto_types(runtime: IdaRuntime, decl: str) -> list[str]: + working = decl.strip().rstrip(";") + working = re.sub("@<[^>]+>", "", working) + header, _, params_text = working.partition("(") + param_chunks = [" ".join(header.split()[:-1])] + if params_text: + params_body = params_text.rsplit(")", 1)[0] + for raw_param in params_body.split(","): + segment = raw_param.strip() + if not segment or segment == "void": + continue + param_name = re.search("([A-Za-z_][A-Za-z0-9_]*)\\s*$", segment) + if param_name: + segment = segment[: param_name.start()].strip() + param_chunks.append(segment) + unknown: list[str] = [] + seen: set[str] = set() + for chunk in param_chunks: + for token in re.findall("[A-Za-z_][A-Za-z0-9_]*", chunk): + if token in _prototypes_PROTO_BUILTIN_TOKENS or token in seen or runtime.find_named_type(token) is not None: + continue + seen.add(token) + unknown.append(token) + return unknown + + +def _prototypes_parse_prototype_decl(runtime: IdaRuntime, decl: str): + ida_typeinf = runtime.ida_typeinf + parse_text = decl.strip() + if not parse_text.endswith(";"): + parse_text += ";" + parse_flags = ida_typeinf.PT_VAR | ida_typeinf.PT_SIL | ida_typeinf.PT_SEMICOLON + if "::" in parse_text: + parse_flags |= ida_typeinf.PT_RELAXED + tif = ida_typeinf.tinfo_t() + return tif if ida_typeinf.parse_decl(tif, None, parse_text, parse_flags) else None + + +def _prototypes_prototype_arglocs_ok(runtime: IdaRuntime, tif) -> bool: + if not tif.is_func(): + return False + ida_typeinf = runtime.ida_typeinf + details = ida_typeinf.func_type_data_t() + return bool(tif.get_func_details(details, ida_typeinf.GTD_CALC_ARGLOCS)) + + +def _prototypes_mark_prototype_dirty(runtime: IdaRuntime, ea: int, *, include_callers: bool) -> None: + try: + ida_hexrays = runtime.require_hexrays() + except Exception: + return + with suppress_recoverable_ida_errors(): + ida_hexrays.mark_cfunc_dirty(ea, False) + if include_callers: + for ref in runtime.idautils.CodeRefsTo(ea, 0): + caller = runtime.ida_funcs.get_func(ref) + if caller is not None: + ida_hexrays.mark_cfunc_dirty(caller.start_ea, False) + ida_hexrays.clear_cached_cfuncs() + + +def _prototypes_proto_get( + context: OperationContext, request: _prototypes_PrototypeGetRequest +) -> _prototypes_PrototypeView: + viewed = _prototypes_prototype_view(context, request) + if isinstance(viewed, _prototypes_PrototypeView): + return viewed + raise IdaOperationError("internal error: expected a prototype view for proto_get") + + +def _prototypes_proto_check( + context: OperationContext, request: _prototypes_PrototypeCheckRequest +) -> _prototypes_PrototypeCheckResult: + runtime = context.runtime + ea = runtime.function_ea(request.identifier) + unknown_types = tuple(_prototypes_unknown_proto_types(runtime, request.decl)) + diagnostics: list[str] = [] + tif = _prototypes_parse_prototype_decl(runtime, request.decl) + parsed = tif is not None + is_function = False + arglocs_calculated: bool | None = None + if tif is None: + if unknown_types: + diagnostics.append("unknown type(s): " + ", ".join(unknown_types)) + else: + diagnostics.append("IDA failed to parse the prototype declaration") + else: + is_function = bool(tif.is_func()) + if not is_function: + diagnostics.append("declaration parsed but did not produce a function type") + arglocs_calculated = _prototypes_prototype_arglocs_ok(runtime, tif) + if arglocs_calculated is False: + diagnostics.append("IDA could not calculate function argument locations") + return _prototypes_PrototypeCheckResult( + address=hex(ea), + success=parsed and is_function and (arglocs_calculated is not False), + parsed=parsed, + is_function=is_function, + arglocs_calculated=arglocs_calculated, + unknown_types=unknown_types, + diagnostics=tuple(diagnostics), + ) + + +def _prototypes_proto_set( + context: OperationContext, request: _prototypes_PrototypeSetRequest +) -> _prototypes_PrototypeMutationResult: + runtime = context.runtime + ea = runtime.function_ea(request.identifier) + decl = request.decl + original_name = runtime.ida_name.get_name(ea) or "" + unknown_types = _prototypes_unknown_proto_types(runtime, decl) + tif = _prototypes_parse_prototype_decl(runtime, decl) + if tif is None: + if unknown_types: + rendered = ", ".join(unknown_types) + raise IdaOperationError(f"failed to apply prototype at {hex(ea)}; unknown type(s): {rendered}") + current_prototype = runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "" + raise IdaOperationError( + f"failed to apply prototype at {hex(ea)}; current prototype: {current_prototype or ''}; " + "check declaration syntax, parser limitations, missing support types, and retry after " + "`function prototype show`" + ) + if request.preserve_cc: + original = runtime.ida_typeinf.tinfo_t() + original_details = runtime.ida_typeinf.func_type_data_t() + updated_details = runtime.ida_typeinf.func_type_data_t() + if not runtime.mod("ida_nalt").get_tinfo(original, ea) or not original.get_func_details(original_details): + raise IdaOperationError(f"cannot preserve calling convention at {hex(ea)}: no existing function type") + if not tif.get_func_details(updated_details): + raise IdaOperationError(f"cannot preserve calling convention at {hex(ea)}: declaration is not a function") + updated_details.set_cc(original_details.get_cc()) + if not tif.create_func(updated_details): + raise IdaOperationError(f"failed to preserve calling convention at {hex(ea)}") + if not runtime.ida_typeinf.apply_tinfo(ea, tif, runtime.ida_typeinf.TINFO_DEFINITE): + current_prototype = runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "" + raise IdaOperationError( + f"failed to apply prototype at {hex(ea)}; current prototype: {current_prototype or ''}; " + "parsed declaration successfully but apply_tinfo failed" + ) + callers_considered = 0 + callers_updated = 0 + callers_failed = 0 + if request.propagate_callers: + callers_considered, callers_updated, callers_failed = _prototypes_propagate_callee_tinfo(runtime, ea, tif) + _prototypes_mark_prototype_dirty(runtime, ea, include_callers=request.propagate_callers) + normalized_decl = decl if decl.endswith(";") else f"{decl};" + if original_name and re.search("~[A-Za-z_][A-Za-z0-9_]*\\s*\\(", normalized_decl): + current_name = runtime.ida_name.get_name(ea) or "" + if current_name and current_name != original_name: + with suppress_recoverable_ida_errors(): + runtime.ida_name.set_name(ea, original_name, runtime.ida_name.SN_CHECK) + return _prototypes_PrototypeMutationResult( + address=hex(ea), + prototype=runtime.ida_typeinf.print_type(ea, runtime.ida_typeinf.PRTYPE_1LINE) or "", + changed=True, + callers_considered=callers_considered, + callers_updated=callers_updated, + callers_failed=callers_failed, + ) + + +# ---- Named-type operations ---- + + +@dataclass(frozen=True) +class _named_types_NamedTypeListRequest: + pattern: str | None + regex: bool + ignore_case: bool + + +@dataclass(frozen=True) +class _named_types_NamedTypeShowRequest: + name: str + + +@dataclass(frozen=True) +class _named_types_NamedTypeDepsResult: + name: str + kind: str + decl: str + dependencies_included: bool + + +@dataclass(frozen=True) +class _named_types_StructFieldSetRequest: + struct_name: str + field_name: str + decl: str + offset: int + + +@dataclass(frozen=True) +class _named_types_StructFieldRenameRequest: + struct_name: str + field_name: str + new_name: str + + +@dataclass(frozen=True) +class _named_types_StructFieldDeleteRequest: + struct_name: str + field_name: str + + +@dataclass(frozen=True) +class _named_types_EnumMemberSetRequest: + enum_name: str + member_name: str + value: int + mask: int | None + + +@dataclass(frozen=True) +class _named_types_EnumMemberRenameRequest: + enum_name: str + member_name: str + new_name: str + + +@dataclass(frozen=True) +class _named_types_EnumMemberDeleteRequest: + enum_name: str + member_name: str + + +@dataclass(frozen=True) +class _named_types_NamedTypeEntry: + name: str + kind: str + decl: str + + +@dataclass(frozen=True) +class _named_types_StructMember: + index: int + name: str | None + offset_bits: int + offset: int + size_bits: int + size: int | None + type: str + comment: str + + +@dataclass(frozen=True) +class _named_types_EnumMember: + index: int + name: str | None + value: int + value_hex: str + comment: str + + +@dataclass(frozen=True) +class _named_types_NamedTypeView: + name: str + kind: str + size: int | None + size_known: bool + decl: str + + +@dataclass(frozen=True) +class _named_types_StructuredTypeView: + name: str + kind: str + size: int | None + size_known: bool + decl: str + layout: str + members: tuple[_named_types_StructMember, ...] + + +@dataclass(frozen=True) +class _named_types_EnumTypeView: + name: str + kind: str + size: int | None + size_known: bool + decl: str + members: tuple[_named_types_EnumMember, ...] + + +@dataclass(frozen=True) +class _named_types_StructView: + name: str + kind: str + layout: str + members: tuple[_named_types_StructMember, ...] + + +@dataclass(frozen=True) +class _named_types_StructMutationResult: + name: str + kind: str + layout: str + members: tuple[_named_types_StructMember, ...] + changed: bool + + +@dataclass(frozen=True) +class _named_types_EnumView: + name: str + kind: str + decl: str + members: tuple[_named_types_EnumMember, ...] + + +@dataclass(frozen=True) +class _named_types_EnumMutationResult: + name: str + kind: str + decl: str + members: tuple[_named_types_EnumMember, ...] + changed: bool + + +_named_types_UNKNOWN_TINFO_SIZE_THRESHOLD = 1 << 63 + + +def _named_types_require_name( + params: Mapping[str, Any], *, key: str = "name", message: str = "type name is required" +) -> str: + value = str(params.get(key) or "").strip() + if not value: + raise IdaOperationError(message) + return value + + +def _named_types_parse_list(params: Mapping[str, Any]) -> _named_types_NamedTypeListRequest: + pattern = str(params.get("pattern") or "").strip() or None + return _named_types_NamedTypeListRequest( + pattern=pattern, + regex=bool(params.get("regex")), + ignore_case=bool(params.get("ignore_case")), + ) + + +def _named_types_parse_show(params: Mapping[str, Any]) -> _named_types_NamedTypeShowRequest: + return _named_types_NamedTypeShowRequest(name=_named_types_require_name(params)) + + +def _named_types_parse_struct_show(params: Mapping[str, Any]) -> _named_types_NamedTypeShowRequest: + return _named_types_NamedTypeShowRequest(name=_named_types_require_name(params, message="struct name is required")) + + +def _named_types_parse_enum_show(params: Mapping[str, Any]) -> _named_types_NamedTypeShowRequest: + return _named_types_NamedTypeShowRequest(name=_named_types_require_name(params, message="enum name is required")) + + +def _named_types_parse_struct_field_set(params: Mapping[str, Any]) -> _named_types_StructFieldSetRequest: + struct_name = _named_types_require_name(params, key="struct_name", message="struct name is required") + field_name = _named_types_require_name(params, key="field_name", message="field name is required") + decl = str(params.get("decl") or "") + if not decl: + raise IdaOperationError("struct field declaration is required") + offset = param_int(params, "offset", label="struct field offset", minimum=0) + return _named_types_StructFieldSetRequest(struct_name=struct_name, field_name=field_name, decl=decl, offset=offset) + + +def _named_types_parse_struct_field_rename(params: Mapping[str, Any]) -> _named_types_StructFieldRenameRequest: + return _named_types_StructFieldRenameRequest( + struct_name=_named_types_require_name(params, key="struct_name", message="struct name is required"), + field_name=_named_types_require_name(params, key="field_name", message="field name is required"), + new_name=_named_types_require_name(params, key="new_name", message="new field name is required"), + ) + + +def _named_types_parse_struct_field_delete(params: Mapping[str, Any]) -> _named_types_StructFieldDeleteRequest: + return _named_types_StructFieldDeleteRequest( + struct_name=_named_types_require_name(params, key="struct_name", message="struct name is required"), + field_name=_named_types_require_name(params, key="field_name", message="field name is required"), + ) + + +def _named_types_parse_enum_member_set(params: Mapping[str, Any]) -> _named_types_EnumMemberSetRequest: + return _named_types_EnumMemberSetRequest( + enum_name=_named_types_require_name(params, key="enum_name", message="enum name is required"), + member_name=_named_types_require_name(params, key="member_name", message="enum member name is required"), + value=param_int(params, "value", label="enum member value"), + mask=None if params.get("mask") in (None, "") else optional_param_int(params, "mask", label="enum member mask"), + ) + + +def _named_types_parse_enum_member_rename(params: Mapping[str, Any]) -> _named_types_EnumMemberRenameRequest: + return _named_types_EnumMemberRenameRequest( + enum_name=_named_types_require_name(params, key="enum_name", message="enum name is required"), + member_name=_named_types_require_name(params, key="member_name", message="enum member name is required"), + new_name=_named_types_require_name(params, key="new_name", message="new enum member name is required"), + ) + + +def _named_types_parse_enum_member_delete(params: Mapping[str, Any]) -> _named_types_EnumMemberDeleteRequest: + return _named_types_EnumMemberDeleteRequest( + enum_name=_named_types_require_name(params, key="enum_name", message="enum name is required"), + member_name=_named_types_require_name(params, key="member_name", message="enum member name is required"), + ) + + +def _named_types_normalize_tinfo_size(value: Any) -> int | None: + try: + size = int(value) + except (TypeError, ValueError): + return None + if size < 0 or size >= _named_types_UNKNOWN_TINFO_SIZE_THRESHOLD: + return None + return size + + +def _named_types_coerce_named_type_entries(rows: list[dict[str, Any]]) -> tuple[_named_types_NamedTypeEntry, ...]: + return tuple( + _named_types_NamedTypeEntry( + name=str(item.get("name") or ""), kind=str(item.get("kind") or ""), decl=str(item.get("decl") or "") + ) + for item in rows + ) + + +def _named_types_coerce_struct_members(rows: list[dict[str, Any]]) -> tuple[_named_types_StructMember, ...]: + return tuple( + _named_types_StructMember( + index=int(item.get("index") or 0), + name=None if item.get("name") is None else str(item.get("name")), + offset_bits=int(item.get("offset_bits") or 0), + offset=int(item.get("offset") or 0), + size_bits=int(item.get("size_bits") or 0), + size=None if (raw_size := item.get("size")) is None else int(raw_size), + type=str(item.get("type") or ""), + comment=str(item.get("comment") or ""), + ) + for item in rows + ) + + +def _named_types_coerce_enum_members(rows: list[dict[str, Any]]) -> tuple[_named_types_EnumMember, ...]: + return tuple( + _named_types_EnumMember( + index=int(item.get("index") or 0), + name=None if item.get("name") is None else str(item.get("name")), + value=int(item.get("value") or 0), + value_hex=str(item.get("value_hex") or hex(int(item.get("value") or 0))), + comment=str(item.get("comment") or ""), + ) + for item in rows + ) + + +def _named_types_ensure_terr_ok(runtime: IdaRuntime, code: int, action: str) -> None: + ida_typeinf = runtime.mod("ida_typeinf") + if code != ida_typeinf.TERR_OK: + raise IdaOperationError(f"{action}: {ida_typeinf.tinfo_errstr(code)}") + + +def _named_types_persist_named_type(runtime: IdaRuntime, tif, name: str) -> None: + ida_typeinf = runtime.mod("ida_typeinf") + code = tif.set_named_type(None, name, ida_typeinf.NTF_REPLACE) + if code != ida_typeinf.TERR_OK: + raise IdaOperationError(f"failed to persist type `{name}`: {ida_typeinf.tinfo_errstr(code)}") + + +def _named_types_struct_member_index(tif, struct_name: str, field_name: str) -> int: + idx, _udm = tif.get_udm(field_name) + if idx < 0: + raise IdaOperationError(f"struct field not found: {struct_name}.{field_name}") + return idx + + +def _named_types_parse_member_type(runtime: IdaRuntime, decl: str, field_name: str): + ida_typeinf = runtime.mod("ida_typeinf") + tif = ida_typeinf.tinfo_t() + parse_flags = ida_typeinf.PT_VAR | ida_typeinf.PT_SIL | ida_typeinf.PT_SEMICOLON + raw_decl = decl.strip() + normalized_decl = f"{raw_decl.rstrip(';')};" + candidates: list[str] = [] + if re.search(f"(? tuple[_named_types_NamedTypeEntry, ...]: + runtime = context.runtime + return _named_types_coerce_named_type_entries( + runtime.list_named_types( + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + ) + ) + + +def _named_types_type_show( + context: OperationContext, request: _named_types_NamedTypeShowRequest +) -> _named_types_NamedTypeView | _named_types_StructuredTypeView | _named_types_EnumTypeView: + runtime = context.runtime + tif = runtime.get_named_type(request.name) + kind = runtime.classify_tinfo(tif) + size = _named_types_normalize_tinfo_size(tif.get_size()) + decl = runtime.tinfo_decl(tif, name=request.name, multi=True) + if kind in {"struct", "union"}: + members = _named_types_coerce_struct_members(runtime.tinfo_members(tif)) + return _named_types_StructuredTypeView( + name=request.name, + kind=kind, + size=size, + size_known=size is not None, + decl=decl, + layout=decl, + members=members, + ) + if kind == "enum": + return _named_types_EnumTypeView( + name=request.name, + kind=kind, + size=size, + size_known=size is not None, + decl=decl, + members=_named_types_coerce_enum_members(runtime.enum_members(tif)), + ) + return _named_types_NamedTypeView(name=request.name, kind=kind, size=size, size_known=size is not None, decl=decl) + + +def _named_types_type_deps( + context: OperationContext, request: _named_types_NamedTypeShowRequest +) -> _named_types_NamedTypeDepsResult: + runtime = context.runtime + tif = runtime.get_named_type(request.name) + kind = runtime.classify_tinfo(tif) + decl = _named_types_print_type_deps(runtime, tif, request.name) + return _named_types_NamedTypeDepsResult( + name=request.name, + kind=kind, + decl=decl, + dependencies_included=True, + ) + + +def _named_types_print_type_deps(runtime: IdaRuntime, tif, name: str) -> str: + ida_typeinf = runtime.mod("ida_typeinf") + ordinal = int(tif.get_ordinal()) + if ordinal <= 0: + raise IdaOperationError(f"named type has no local ordinal: {name}") + + class Sink(ida_typeinf.text_sink_t): + def __init__(self) -> None: + super().__init__() + self.text = "" + + def _print(self, text): + self.text += str(text) + return 0 + + sink = Sink() + flags = ida_typeinf.PDF_INCL_DEPS | ida_typeinf.PDF_DEF_FWD + exported = int(ida_typeinf.print_decls(sink, tif.get_til(), [ordinal], flags)) + decl = sink.text.strip() + if exported == 0 or not decl: + raise IdaOperationError(f"failed to export dependencies for named type: {name}") + return decl + + +def _named_types_struct_list( + context: OperationContext, request: _named_types_NamedTypeListRequest +) -> tuple[_named_types_NamedTypeEntry, ...]: + runtime = context.runtime + return _named_types_coerce_named_type_entries( + runtime.list_named_types( + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + kinds={"struct", "union"}, + ) + ) + + +def _named_types_struct_view( + context: OperationContext, + request: _named_types_NamedTypeShowRequest + | _named_types_StructFieldSetRequest + | _named_types_StructFieldRenameRequest + | _named_types_StructFieldDeleteRequest, +) -> _named_types_StructView: + runtime = context.runtime + name = request.name if isinstance(request, _named_types_NamedTypeShowRequest) else request.struct_name + tif = runtime.get_struct_or_union(name) + return _named_types_StructView( + name=name, + kind=runtime.classify_tinfo(tif), + layout=runtime.tinfo_decl(tif, name=name, multi=True), + members=_named_types_coerce_struct_members(runtime.tinfo_members(tif)), + ) + + +def _named_types_persist_and_show_struct(runtime: IdaRuntime, tif, *, name: str) -> _named_types_StructMutationResult: + _named_types_persist_named_type(runtime, tif, name) + try: + shown = _named_types_struct_view( + OperationContext(runtime=runtime), _named_types_NamedTypeShowRequest(name=name) + ) + except Exception as exc: + detail = str(exc) or exc.__class__.__name__ + raise IdaOperationError(f"persisted named type `{name}` but failed to read it back: {detail}") from exc + return _named_types_StructMutationResult( + name=shown.name, kind=shown.kind, layout=shown.layout, members=shown.members, changed=True + ) + + +def _named_types_struct_field_set( + context: OperationContext, request: _named_types_StructFieldSetRequest +) -> _named_types_StructMutationResult: + runtime = context.runtime + tif = runtime.get_struct_or_union(request.struct_name) + offset_bits = request.offset * 8 + member_tif = _named_types_parse_member_type(runtime, request.decl, request.field_name) + idx, udm = tif.get_udm_by_offset(offset_bits) + if idx >= 0 and udm is not None and (udm.offset == offset_bits): + _named_types_ensure_terr_ok(runtime, tif.set_udm_type(idx, member_tif), "failed to set field type") + if udm.name != request.field_name: + _named_types_ensure_terr_ok(runtime, tif.rename_udm(idx, request.field_name), "failed to rename field") + else: + _named_types_ensure_terr_ok( + runtime, tif.add_udm(request.field_name, member_tif, offset_bits), "failed to add field" + ) + return _named_types_persist_and_show_struct(runtime, tif, name=request.struct_name) + + +def _named_types_struct_field_rename( + context: OperationContext, request: _named_types_StructFieldRenameRequest +) -> _named_types_StructMutationResult: + runtime = context.runtime + tif = runtime.get_struct_or_union(request.struct_name) + idx = _named_types_struct_member_index(tif, request.struct_name, request.field_name) + _named_types_ensure_terr_ok(runtime, tif.rename_udm(idx, request.new_name), "failed to rename field") + return _named_types_persist_and_show_struct(runtime, tif, name=request.struct_name) + + +def _named_types_struct_field_delete( + context: OperationContext, request: _named_types_StructFieldDeleteRequest +) -> _named_types_StructMutationResult: + runtime = context.runtime + tif = runtime.get_struct_or_union(request.struct_name) + idx = _named_types_struct_member_index(tif, request.struct_name, request.field_name) + _named_types_ensure_terr_ok(runtime, tif.del_udm(idx), "failed to delete field") + return _named_types_persist_and_show_struct(runtime, tif, name=request.struct_name) + + +def _named_types_enum_list( + context: OperationContext, request: _named_types_NamedTypeListRequest +) -> tuple[_named_types_NamedTypeEntry, ...]: + runtime = context.runtime + return _named_types_coerce_named_type_entries( + runtime.list_named_types( + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + kinds={"enum"}, + ) + ) + + +def _named_types_enum_view( + context: OperationContext, + request: _named_types_NamedTypeShowRequest + | _named_types_EnumMemberSetRequest + | _named_types_EnumMemberRenameRequest + | _named_types_EnumMemberDeleteRequest, +) -> _named_types_EnumView: + runtime = context.runtime + name = request.name if isinstance(request, _named_types_NamedTypeShowRequest) else request.enum_name + tif = runtime.get_named_type(name, kind="enum") + return _named_types_EnumView( + name=name, + kind="enum", + decl=runtime.tinfo_decl(tif, name=name, multi=True), + members=_named_types_coerce_enum_members(runtime.enum_members(tif)), + ) + + +def _named_types_persist_and_show_enum(runtime: IdaRuntime, tif, *, name: str) -> _named_types_EnumMutationResult: + _named_types_persist_named_type(runtime, tif, name) + try: + shown = _named_types_enum_view(OperationContext(runtime=runtime), _named_types_NamedTypeShowRequest(name=name)) + except Exception as exc: + detail = str(exc) or exc.__class__.__name__ + raise IdaOperationError(f"persisted named type `{name}` but failed to read it back: {detail}") from exc + return _named_types_EnumMutationResult( + name=shown.name, kind=shown.kind, decl=shown.decl, members=shown.members, changed=True + ) + + +def _named_types_enum_member_set( + context: OperationContext, request: _named_types_EnumMemberSetRequest +) -> _named_types_EnumMutationResult: + runtime = context.runtime + tif = runtime.get_named_type(request.enum_name, kind="enum") + ida_typeinf = runtime.mod("ida_typeinf") + mask = ida_typeinf.DEFMASK64 if request.mask is None else request.mask + idx, _edm = tif.get_edm(request.member_name) + if idx >= 0: + _named_types_ensure_terr_ok(runtime, tif.edit_edm(idx, request.value, mask), "failed to edit enum member") + else: + _named_types_ensure_terr_ok( + runtime, tif.add_edm(request.member_name, request.value, mask), "failed to add enum member" + ) + return _named_types_persist_and_show_enum(runtime, tif, name=request.enum_name) + + +def _named_types_enum_member_rename( + context: OperationContext, request: _named_types_EnumMemberRenameRequest +) -> _named_types_EnumMutationResult: + runtime = context.runtime + tif = runtime.get_named_type(request.enum_name, kind="enum") + idx, _edm = tif.get_edm(request.member_name) + if idx < 0: + raise IdaOperationError(f"enum member not found: {request.enum_name}.{request.member_name}") + _named_types_ensure_terr_ok(runtime, tif.rename_edm(idx, request.new_name), "failed to rename enum member") + return _named_types_persist_and_show_enum(runtime, tif, name=request.enum_name) + + +def _named_types_enum_member_delete( + context: OperationContext, request: _named_types_EnumMemberDeleteRequest +) -> _named_types_EnumMutationResult: + runtime = context.runtime + tif = runtime.get_named_type(request.enum_name, kind="enum") + _named_types_ensure_terr_ok(runtime, tif.del_edm(request.member_name), "failed to delete enum member") + return _named_types_persist_and_show_enum(runtime, tif, name=request.enum_name) + + +# ---- Type declaration ---- + + +_type_declare_FORWARD_DECL_RE = re.compile( + "^\\s*(?:typedef\\s+)?(?:struct|class|union)\\s+(?P[A-Za-z_][A-Za-z0-9_:]*)(?:\\s+(?P[A-Za-z_][A-Za-z0-9_:]*))?\\s*;\\s*$", + re.DOTALL, +) +_type_declare_CONCRETE_TYPE_RE = re.compile( + "\\b(?:struct|class|union|enum)\\s+(?P[A-Za-z_][A-Za-z0-9_:]*)\\s*\\{", re.DOTALL +) +_type_declare_TYPEDEF_ALIAS_RE = re.compile( + "^\\s*typedef\\s+(?:struct|class|union|enum)\\s+(?:(?P[A-Za-z_][A-Za-z0-9_:]*)\\s*)?\\{.*\\}\\s*(?P[A-Za-z_][A-Za-z0-9_:]*)\\s*;\\s*$", + re.DOTALL, +) +_type_declare_TYPEDEF_FUNC_ALIAS_RE = re.compile( + "^\\s*typedef\\b.*\\(\\s*\\*\\s*(?P[A-Za-z_][A-Za-z0-9_:]*)\\s*\\)\\s*\\([^;]*\\)\\s*;\\s*$", re.DOTALL +) +_type_declare_TYPEDEF_SIMPLE_ALIAS_RE = re.compile( + "^\\s*typedef\\b.*?\\b(?P[A-Za-z_][A-Za-z0-9_:]*)\\s*;\\s*$", re.DOTALL +) +_type_declare_BY_VALUE_MEMBER_RE = re.compile( + "^(?:typedef\\s+)?(?:(?:const|volatile|mutable|signed|unsigned|short|long)\\s+)*(?:(?:struct|class|union)\\s+)?(?P[A-Za-z_][A-Za-z0-9_:]*)(?:\\s+(?:const|volatile))*\\s+(?P[A-Za-z_][A-Za-z0-9_]*)\\s*(?:\\[[^\\]]+\\])?\\s*$", + re.DOTALL, +) +_type_declare_BUILTIN_MEMBER_TYPES = frozenset( + { + "bool", + "char", + "double", + "float", + "int", + "long", + "short", + "signed", + "size_t", + "ssize_t", + "unsigned", + "void", + "wchar_t", + "__int8", + "__int16", + "__int32", + "__int64", + "__int128", + } +) +_type_declare_TypeAlias = TypedDict("_type_declare_TypeAlias", {"from": str, "to": str}) +_type_declare_AppliedAlias = TypedDict("_type_declare_AppliedAlias", {"from": str, "to": str, "count": int}) + + +class _type_declare_DeclarationChunkDict(TypedDict): + text: str + start_line: int + end_line: int + terminated: bool + + +class _type_declare_BlockingMember(TypedDict): + type_name: str + member_name: str + + +class _type_declare_BisectTrial(TypedDict): + prefix_count: int + errors: int + success: bool + + +class _type_declare_TypeDiagnosticDict(TypedDict, total=False): + kind: str + message: str + line: int + end_line: int + snippet: str + construct: str + balance: int + + +class _type_declare_FailingDeclaration(TypedDict, total=False): + index: int + line: int + end_line: int + snippet: str + standalone_errors: int + standalone_success: bool + + +class _type_declare_TypeDeclareBisectResult(TypedDict, total=False): + requested: bool + supported: bool + mode: str + declaration_count: int + message: str + trials: list[_type_declare_BisectTrial] + diagnostics: list[_type_declare_TypeDiagnosticDict] + failing_declaration: _type_declare_FailingDeclaration + blocking_members: list[_type_declare_BlockingMember] + + +class _type_declare_TypeDeclareResult(TypedDict, total=False): + errors: int + replace: bool + check: bool + aliases_applied: list[_type_declare_AppliedAlias] + diagnostics: list[_type_declare_TypeDiagnosticDict] + imported_types: list[str] + replaced_types: list[str] + declaration_count: int + success: bool + bisect: _type_declare_TypeDeclareBisectResult | None + + +_type_declare_NamedTypeSnapshot = dict[str, str | None] + + +@dataclass(frozen=True) +class _type_declare_DeclarationChunk: + text: str + start_line: int + end_line: int + terminated: bool + + def to_dict(self) -> _type_declare_DeclarationChunkDict: + return { + "text": self.text, + "start_line": self.start_line, + "end_line": self.end_line, + "terminated": self.terminated, + } + + +@dataclass(frozen=True) +class _type_declare_TypeDiagnostic: + kind: str + message: str + line: int | None = None + end_line: int | None = None + snippet: str | None = None + construct: str | None = None + balance: int | None = None + + def to_dict(self) -> _type_declare_TypeDiagnosticDict: + item: _type_declare_TypeDiagnosticDict = {"kind": self.kind, "message": self.message} + if self.line is not None: + item["line"] = self.line + if self.end_line is not None: + item["end_line"] = self.end_line + if self.snippet: + item["snippet"] = self.snippet[:240] + if self.construct: + item["construct"] = self.construct + if self.balance is not None: + item["balance"] = self.balance + return item + + +@dataclass(frozen=True) +class _type_declare_TypeDeclareRequest: + decl: str + aliases: tuple[_type_declare_TypeAlias, ...] + replace: bool + bisect: bool + clang: bool + + +@dataclass(frozen=True) +class _type_declare_TypeDeclarePreviewSnapshot: + type_count: int + class_count: int + declarations: dict[str, str | None] + + +def _type_declare_named_type_map(rows: list[dict[str, Any]]) -> _type_declare_NamedTypeSnapshot: + return {item["name"]: item.get("decl") for item in rows} + + +def _type_declare_named_types_snapshot(runtime: IdaRuntime) -> _type_declare_NamedTypeSnapshot: + return _type_declare_named_type_map(runtime.list_named_types()) + + +def _type_declare_strip_comments_preserve_lines(text: str) -> str: + out: list[str] = [] + in_string: str | None = None + in_line_comment = False + in_block_comment = False + i = 0 + while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else "" + if in_line_comment: + if ch == "\n": + out.append(ch) + in_line_comment = False + i += 1 + continue + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + i += 2 + continue + if ch == "\n": + out.append(ch) + i += 1 + continue + if in_string: + out.append(ch) + if ch == "\\" and nxt: + out.append(nxt) + i += 2 + continue + if ch == in_string: + in_string = None + i += 1 + continue + if ch == "/" and nxt == "/": + in_line_comment = True + i += 2 + continue + if ch == "/" and nxt == "*": + in_block_comment = True + i += 2 + continue + out.append(ch) + if ch in {"'", '"'}: + in_string = ch + i += 1 + return "".join(out) + + +def _type_declare_strip_preprocessor_lines(text: str) -> str: + lines = text.splitlines(keepends=True) + out: list[str] = [] + in_directive_continuation = False + for line in lines: + stripped = line.lstrip() + is_directive = stripped.startswith("#") or in_directive_continuation + if is_directive: + newline = "\n" if line.endswith("\n") else "" + out.append(newline) + in_directive_continuation = line.rstrip().endswith("\\") + continue + in_directive_continuation = False + out.append(line) + return "".join(out) + + +def _type_declare_parse_request(params: Mapping[str, Any]) -> _type_declare_TypeDeclareRequest: + decl = _type_declare_strip_comments_preserve_lines(str(params.get("decl") or "")) + decl = _type_declare_strip_preprocessor_lines(decl) + if not decl.strip(): + raise IdaOperationError("type declarations are required via --decl or --file") + try: + aliases = cast("tuple[_type_declare_TypeAlias, ...]", tuple(parse_aliases(params.get("aliases") or []))) + except ValueError as exc: + raise IdaOperationError(str(exc)) from exc + return _type_declare_TypeDeclareRequest( + decl=decl, + aliases=aliases, + replace=bool(params.get("replace")), + bisect=bool(params.get("bisect")), + clang=bool(params.get("clang")), + ) + + +def _type_declare_parse_check_request(params: Mapping[str, Any]) -> _type_declare_TypeDeclareRequest: + if {"replace", "bisect"} & params.keys(): + raise IdaOperationError("type check does not accept replace or bisect parameters") + return _type_declare_parse_request(params) + + +def _type_declare_join_declaration_chunks(chunks: list[_type_declare_DeclarationChunk]) -> str: + return "\n".join(chunk.text for chunk in chunks) + + +def _type_declare_apply_type_aliases( + decl: str, aliases: list[_type_declare_TypeAlias] +) -> tuple[str, list[_type_declare_AppliedAlias]]: + updated = decl + applied: list[_type_declare_AppliedAlias] = [] + for alias in aliases: + src = alias["from"] + dst = alias["to"] + pattern = re.compile( + f"(?:(?P(?:^|(?<=[^A-Za-z0-9_:]))::)|(?{re.escape(src)})(?![A-Za-z0-9_:])" + ) + + def replace(match: re.Match[str], *, replacement: str = dst) -> str: + prefix = match.group("global_prefix") or "" + return f"{prefix}{replacement}" + + updated, count = pattern.subn(replace, updated) + if count: + applied.append({"from": src, "to": dst, "count": count}) + return (updated, applied) + + +def _type_declare_parse_declaration_chunks(text: str) -> tuple[list[_type_declare_DeclarationChunk], int]: + chunks: list[_type_declare_DeclarationChunk] = [] + current: list[str] = [] + line = 1 + start_line = 1 + brace_depth = 0 + paren_depth = 0 + in_string: str | None = None + in_line_comment = False + in_block_comment = False + i = 0 + while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else "" + current.append(ch) + if ch == "\n": + line += 1 + if in_line_comment: + if ch == "\n": + in_line_comment = False + i += 1 + continue + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + current.append(nxt) + i += 2 + continue + i += 1 + continue + if in_string: + if ch == "\\": + if nxt: + current.append(nxt) + if nxt == "\n": + line += 1 + i += 2 + continue + elif ch == in_string: + in_string = None + i += 1 + continue + if ch == "/" and nxt == "/": + current.append(nxt) + in_line_comment = True + i += 2 + continue + if ch == "/" and nxt == "*": + current.append(nxt) + in_block_comment = True + i += 2 + continue + if ch in {"'", '"'}: + in_string = ch + i += 1 + continue + if ch == "{": + brace_depth += 1 + elif ch == "}": + brace_depth = max(0, brace_depth - 1) + elif ch == "(": + paren_depth += 1 + elif ch == ")": + paren_depth = max(0, paren_depth - 1) + elif ch == ";" and brace_depth == 0 and (paren_depth == 0): + raw = "".join(current) + stripped = raw.strip() + if stripped: + chunks.append(_type_declare_DeclarationChunk(stripped, start_line, line, True)) + current = [] + start_line = _type_declare_next_chunk_start_line(text, i + 1, line) + i += 1 + tail = "".join(current).strip() + if tail: + chunks.append(_type_declare_DeclarationChunk(tail, start_line, line, False)) + return (chunks, brace_depth) + + +def _type_declare_next_chunk_start_line(text: str, index: int, line: int) -> int: + next_line = line + i = index + while i < len(text): + ch = text[i] + if ch == "\n": + next_line += 1 + i += 1 + continue + if ch in {" ", "\t", "\r"}: + i += 1 + continue + break + return next_line + + +def _type_declare_append_type_diagnostic( + diagnostics: list[_type_declare_TypeDiagnostic], + *, + kind: str, + message: str, + line: int | None = None, + end_line: int | None = None, + snippet: str | None = None, + construct: str | None = None, +) -> None: + diagnostics.append( + _type_declare_TypeDiagnostic( + kind=kind, message=message, line=line, end_line=end_line, snippet=snippet, construct=construct + ) + ) + + +def _type_declare_chunk_type_diagnostics( + chunk: _type_declare_DeclarationChunk, *, aliases_applied: list[_type_declare_AppliedAlias] +) -> list[_type_declare_TypeDiagnostic]: + text = chunk.text + snippet = text[:240] + line = chunk.start_line + end_line = chunk.end_line + diagnostics: list[_type_declare_TypeDiagnostic] = [] + if "__cppobj" in text: + _type_declare_append_type_diagnostic( + diagnostics, + kind="cppobj_hint", + message=( + "IDA may reject `__cppobj` in local type imports; retry with plain `struct` declarations " + "and concrete placeholder support types" + ), + line=line, + end_line=end_line, + snippet=snippet, + construct="__cppobj", + ) + if re.match("^\\s*(class|struct|union)\\s+[A-Za-z_][A-Za-z0-9_:]*\\s*;\\s*$", text): + _type_declare_append_type_diagnostic( + diagnostics, + kind="forward_declaration_hint", + message=( + "forward declarations are often insufficient here; import a concrete placeholder definition " + "instead of only `type_name;`" + ), + line=line, + end_line=end_line, + snippet=snippet, + construct="forward_declaration", + ) + if "__cppobj" not in text and re.search("\\bclass\\s+[A-Za-z_][A-Za-z0-9_:]*\\b", text): + _type_declare_append_type_diagnostic( + diagnostics, + kind="class_keyword_hint", + message=( + "if this is a recovered object layout, retry with plain `struct` declarations instead of `class` syntax" + ), + line=line, + end_line=end_line, + snippet=snippet, + construct="class", + ) + if "::" in text and (not aliases_applied): + _type_declare_append_type_diagnostic( + diagnostics, + kind="namespace_hint", + message="namespace-qualified identifiers may require --alias old=new before import", + line=line, + end_line=end_line, + snippet=snippet, + construct="::", + ) + return diagnostics + + +def _type_declare_type_declare_diagnostics( + decl: str, + *, + errors: int, + aliases_applied: list[_type_declare_AppliedAlias], + chunks: list[_type_declare_DeclarationChunk] | None = None, + brace_balance: int | None = None, +) -> list[_type_declare_TypeDiagnosticDict]: + diagnostics: dict[tuple[str, int | None, str | None], _type_declare_TypeDiagnostic] = {} + + def add_unique(item: _type_declare_TypeDiagnostic) -> None: + key = (item.kind, item.line, item.construct) + diagnostics.setdefault(key, item) + + if chunks is None: + chunks, brace_balance = _type_declare_parse_declaration_chunks(decl) + resolved_chunks = chunks + resolved_brace_balance = cast("int", brace_balance) + for chunk in resolved_chunks: + if not chunk.terminated: + add_unique( + _type_declare_TypeDiagnostic( + kind="unterminated_declaration", + message="declaration does not end with a top-level semicolon", + line=chunk.start_line, + end_line=chunk.end_line, + snippet=chunk.text[:240], + ) + ) + if resolved_brace_balance > 0: + add_unique( + _type_declare_TypeDiagnostic( + kind="unbalanced_braces", + message="more opening braces than closing braces were found", + balance=resolved_brace_balance, + ) + ) + if errors: + for chunk in resolved_chunks: + for item in _type_declare_chunk_type_diagnostics(chunk, aliases_applied=aliases_applied): + add_unique(item) + if errors and not diagnostics: + first = resolved_chunks[0] if resolved_chunks else _type_declare_DeclarationChunk(decl.strip(), 1, 1, False) + add_unique( + _type_declare_TypeDiagnostic( + kind="parser_error", + message=f"IDA reported {errors} parser error(s); rerun with smaller declaration batches if needed", + line=first.start_line, + end_line=first.end_line, + snippet=first.text[:240], + ) + ) + return [item.to_dict() for item in diagnostics.values()] + + +def _type_declare_parse_type_declarations(runtime: IdaRuntime, decl: str, *, replace: bool, clang: bool) -> int: + ida_typeinf = runtime.mod("ida_typeinf") + if not clang: + flags = ida_typeinf.PT_REPLACE if replace else 0 + return ida_typeinf.idc_parse_types(decl, flags) + if replace: + type_names = _type_declare_declared_type_names(_type_declare_parse_declaration_chunks(decl)[0]) + _type_declare_delete_named_types(runtime, type_names) + return _type_declare_parse_type_declarations_with_clang(runtime, decl) + + +def _type_declare_parse_type_declarations_with_clang(runtime: IdaRuntime, decl: str) -> int: + ida_typeinf = runtime.mod("ida_typeinf") + ida_srclang = runtime.mod("ida_srclang") + hti_flags = _type_declare_parse_flags(ida_typeinf, decl) + errors = ida_srclang.parse_decls_with_parser_ext("clang", None, decl, hti_flags) + if errors < 0: + raise IdaOperationError("clang parser is unavailable for type declare") + return errors + + +def _type_declare_parse_flags(ida_typeinf: Any, decl: str) -> int: + hti_flags = ida_typeinf.HTI_DCL | ida_typeinf.HTI_SEMICOLON + if "::" in decl: + hti_flags |= ida_typeinf.HTI_RELAXED + return hti_flags + + +def _type_declare_test_type_declarations(runtime: IdaRuntime, decl: str, *, clang: bool) -> int: + ida_typeinf = runtime.mod("ida_typeinf") + current = ida_typeinf.get_idati() + temporary = ida_typeinf.new_til("idac-type-check", "Temporary declaration validation") + if temporary is None: + raise IdaOperationError("failed to allocate a temporary type library for validation") + try: + temporary.cc = current.cc + # HTI_TST discards declarations before subsequent declarations can use + # them. Parse into a disposable TIL, seeding dependencies from IDA's + # declaration exporter so typedef names survive the transfer. + chunks = _type_declare_parse_declaration_chunks(decl)[0] + declared = _type_declare_declared_type_names( + [chunk for chunk in chunks if not _type_declare_FORWARD_DECL_RE.match(chunk.text)] + ) + referenced = set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*", decl)) + dependencies = [] + for name in sorted(referenced - declared): + tif = ida_typeinf.tinfo_t() + if tif.get_named_type(current, name): + dependencies.append(_named_types_print_type_deps(runtime, tif, name)) + if dependencies: + support_decl = "\n".join(dependencies) + errors = ida_typeinf.parse_decls( + temporary, support_decl, None, _type_declare_parse_flags(ida_typeinf, support_decl) + ) + if errors: + raise IdaOperationError(f"failed to copy type dependencies for validation: {errors} parser error(s)") + hti_flags = _type_declare_parse_flags(ida_typeinf, decl) + if clang: + errors = runtime.mod("ida_srclang").parse_decls_with_parser_ext("clang", temporary, decl, hti_flags) + if errors < 0: + raise IdaOperationError("clang parser is unavailable for type check") + return int(errors) + return int(ida_typeinf.parse_decls(temporary, decl, None, hti_flags)) + finally: + ida_typeinf.free_til(temporary) + + +def _type_declare_typedef_alias_names(text: str) -> set[str]: + names: set[str] = set() + func_alias_match = _type_declare_TYPEDEF_FUNC_ALIAS_RE.match(text) + if func_alias_match: + alias = func_alias_match.group("alias") + if alias: + names.add(alias) + return names + simple_alias_match = _type_declare_TYPEDEF_SIMPLE_ALIAS_RE.match(text) + if simple_alias_match: + alias = simple_alias_match.group("alias") + if alias: + names.add(alias) + return names + + +def _type_declare_declared_type_names(chunks: list[_type_declare_DeclarationChunk]) -> set[str]: + names: set[str] = set() + for chunk in chunks: + names.update(_type_declare_concrete_type_names(chunk.text)) + names.update(_type_declare_forward_declared_type_names(chunk.text)) + names.update(_type_declare_typedef_alias_names(chunk.text)) + return {name for name in names if name} + + +def _type_declare_delete_named_types(runtime: IdaRuntime, type_names: set[str]) -> None: + if not type_names: + return + ida_typeinf = runtime.mod("ida_typeinf") + for name in sorted(type_names): + if runtime.find_named_type(name) is None: + continue + if not ida_typeinf.del_named_type(None, name, ida_typeinf.NTF_TYPE): + raise IdaOperationError(f"failed to replace existing local type: {name}") + + +def _type_declare_apply_type_declarations( + runtime: IdaRuntime, decl: str, *, replace: bool, clang: bool +) -> tuple[int, _type_declare_NamedTypeSnapshot, _type_declare_NamedTypeSnapshot]: + before = _type_declare_named_types_snapshot(runtime) + errors = _type_declare_parse_type_declarations(runtime, decl, replace=replace, clang=clang) + return (errors, before, _type_declare_named_types_snapshot(runtime)) + + +def _type_declare_forward_declared_type_names(text: str) -> set[str]: + match = _type_declare_FORWARD_DECL_RE.match(text) + if not match: + return set() + names = {match.group("tag")} + alias = match.group("alias") + if alias: + names.add(alias) + return {name for name in names if name} + + +def _type_declare_concrete_type_names(text: str) -> set[str]: + names = {match.group("name") for match in _type_declare_CONCRETE_TYPE_RE.finditer(text)} + alias_match = _type_declare_TYPEDEF_ALIAS_RE.match(text) + if alias_match: + alias = alias_match.group("alias") + if alias: + names.add(alias) + tag = alias_match.group("tag") + if tag: + names.add(tag) + return {name for name in names if name} + + +def _type_declare_opaque_by_value_members( + failing_chunk: _type_declare_DeclarationChunk, *, earlier_chunks: list[_type_declare_DeclarationChunk] +) -> list[_type_declare_BlockingMember]: + concrete: set[str] = set() + forward: set[str] = set() + for chunk in [*earlier_chunks, failing_chunk]: + text = chunk.text + concrete.update(_type_declare_concrete_type_names(text)) + forward.update(_type_declare_forward_declared_type_names(text)) + forward -= concrete + if not forward: + return [] + rows: list[_type_declare_BlockingMember] = [] + for raw_stmt in failing_chunk.text.replace("\n", " ").split(";"): + stmt = raw_stmt.strip() + if not stmt or any(token in stmt for token in ("*", "&", "(")): + continue + if "{" in stmt: + stmt = stmt.rsplit("{", 1)[-1].strip() + if "}" in stmt: + stmt = stmt.split("}", 1)[0].strip() + if not stmt: + continue + match = _type_declare_BY_VALUE_MEMBER_RE.match(stmt) + if not match: + continue + type_name = match.group("type") + member_name = match.group("name") + if type_name.lower() in _type_declare_BUILTIN_MEMBER_TYPES or type_name not in forward: + continue + rows.append({"type_name": type_name, "member_name": member_name}) + return rows + + +def _type_declare_trial_type_parse_errors( + runtime: IdaRuntime, decl: str, *, replace: bool, clang: bool, label: str +) -> int: + action = re.sub("[^A-Za-z0-9_]+", "_", label).strip("_") or "trial" + with ida_undo_restore_point( + runtime, + action_name=f"idac_type_declare_{action}", + label=f"idac type declare {label}", + unavailable_message="type declare bisect requires IDA undo support", + restore_error_message="type declare bisect could not restore the trial import via undo", + ): + return _type_declare_parse_type_declarations(runtime, decl, replace=replace, clang=clang) + + +def _type_declare_bisect_type_declarations( + runtime: IdaRuntime, chunks: list[_type_declare_DeclarationChunk], *, replace: bool, clang: bool +) -> _type_declare_TypeDeclareBisectResult: + result: _type_declare_TypeDeclareBisectResult = { + "requested": True, + "supported": True, + "mode": "ordered_prefix", + "declaration_count": len(chunks), + "trials": [], + } + if not chunks: + result["supported"] = False + result["message"] = "no declarations were available for bisect" + return result + try: + if len(chunks) == 1: + failing_index = 0 + standalone_errors = _type_declare_trial_type_parse_errors( + runtime, + _type_declare_join_declaration_chunks([chunks[0]]), + replace=replace, + clang=clang, + label="single_decl", + ) + else: + low = 1 + high = len(chunks) + while low < high: + mid = (low + high) // 2 + errors = _type_declare_trial_type_parse_errors( + runtime, + _type_declare_join_declaration_chunks(chunks[:mid]), + replace=replace, + clang=clang, + label=f"prefix_{mid}", + ) + result["trials"].append({"prefix_count": mid, "errors": errors, "success": errors == 0}) + if errors: + high = mid + else: + low = mid + 1 + failing_index = low - 1 + standalone_errors = _type_declare_trial_type_parse_errors( + runtime, + _type_declare_join_declaration_chunks([chunks[failing_index]]), + replace=replace, + clang=clang, + label=f"single_{failing_index + 1}", + ) + except IdaOperationError as exc: + result["supported"] = False + result["message"] = str(exc) or exc.__class__.__name__ + result["diagnostics"] = [{"kind": "bisect_unavailable", "message": result["message"]}] + return result + failing_chunk = chunks[failing_index] + diagnostics: list[_type_declare_TypeDiagnosticDict] = [ + { + "kind": "bisect_culprit", + "message": "ordered bisect isolated the first failing declaration", + "line": failing_chunk.start_line, + "end_line": failing_chunk.end_line, + "snippet": failing_chunk.text[:240], + } + ] + if standalone_errors == 0 and len(chunks) > 1: + diagnostics.append( + { + "kind": "bisect_context_hint", + "message": ( + "the isolated declaration imports alone; the failure depends on earlier declarations " + "or ordered batch context" + ), + "line": failing_chunk.start_line, + "end_line": failing_chunk.end_line, + "snippet": failing_chunk.text[:240], + } + ) + blocking_members = _type_declare_opaque_by_value_members(failing_chunk, earlier_chunks=chunks[:failing_index]) + for member in blocking_members: + diagnostics.append( + { + "kind": "opaque_by_value_member_hint", + "message": ( + f"by-value member `{member['member_name']}` uses forward-declared or opaque type " + f"`{member['type_name']}`; import a concrete placeholder definition first" + ), + "line": failing_chunk.start_line, + "end_line": failing_chunk.end_line, + "snippet": failing_chunk.text[:240], + "construct": member["type_name"], + } + ) + result["message"] = diagnostics[0]["message"] + result["failing_declaration"] = { + "index": failing_index + 1, + "line": failing_chunk.start_line, + "end_line": failing_chunk.end_line, + "snippet": failing_chunk.text[:240], + "standalone_errors": standalone_errors, + "standalone_success": standalone_errors == 0, + } + if blocking_members: + result["blocking_members"] = blocking_members + result["diagnostics"] = diagnostics + return result + + +def _type_declare_bisect_unavailable_result( + chunks: list[_type_declare_DeclarationChunk], exc: IdaOperationError +) -> _type_declare_TypeDeclareBisectResult: + message = str(exc) or exc.__class__.__name__ + return { + "requested": True, + "supported": False, + "mode": "ordered_prefix", + "declaration_count": len(chunks), + "message": message, + "diagnostics": [{"kind": "bisect_unavailable", "message": message}], + } + + +def _type_declare_apply_type_declarations_with_optional_bisect( + runtime: IdaRuntime, + decl: str, + *, + replace: bool, + clang: bool, + chunks: list[_type_declare_DeclarationChunk], + bisect_requested: bool, +) -> tuple[ + int, _type_declare_NamedTypeSnapshot, _type_declare_NamedTypeSnapshot, _type_declare_TypeDeclareBisectResult | None +]: + if not bisect_requested: + errors, before, after = _type_declare_apply_type_declarations(runtime, decl, replace=replace, clang=clang) + return (errors, before, after, None) + try: + trial_errors = _type_declare_trial_type_parse_errors(runtime, decl, replace=replace, clang=clang, label="full") + except IdaOperationError as exc: + before = _type_declare_named_types_snapshot(runtime) + return (1, before, dict(before), _type_declare_bisect_unavailable_result(chunks, exc)) + if trial_errors == 0: + errors, before, after = _type_declare_apply_type_declarations(runtime, decl, replace=replace, clang=clang) + return (errors, before, after, None) + before = _type_declare_named_types_snapshot(runtime) + return ( + trial_errors, + before, + dict(before), + _type_declare_bisect_type_declarations(runtime, chunks, replace=replace, clang=clang), + ) + + +def _type_declare_type_declare_result( + decl: str, + *, + replace: bool, + errors: int, + before: _type_declare_NamedTypeSnapshot, + after: _type_declare_NamedTypeSnapshot, + aliases_applied: list[_type_declare_AppliedAlias], + chunks: list[_type_declare_DeclarationChunk], + brace_balance: int, + bisect: _type_declare_TypeDeclareBisectResult | None = None, +) -> _type_declare_TypeDeclareResult: + diagnostics = _type_declare_type_declare_diagnostics( + decl, errors=errors, aliases_applied=aliases_applied, chunks=chunks, brace_balance=brace_balance + ) + if bisect is not None: + existing = {(item["kind"], item.get("line"), item["message"]) for item in diagnostics} + for item in bisect.get("diagnostics") or []: + key = (item["kind"], item.get("line"), item["message"]) + if key in existing: + continue + diagnostics.append(item) + existing.add(key) + return { + "errors": errors, + "replace": replace, + "aliases_applied": aliases_applied, + "diagnostics": diagnostics, + "imported_types": sorted(set(after) - set(before)), + "replaced_types": sorted(name for name in set(after) & set(before) if before.get(name) != after.get(name)), + "declaration_count": len(chunks), + "success": errors == 0, + "bisect": bisect, + } + + +def _type_declare_preview_snapshot( + context: OperationContext, request: _type_declare_TypeDeclareRequest +) -> _type_declare_TypeDeclarePreviewSnapshot: + runtime = context.runtime + names = runtime.list_named_types() + current = _type_declare_named_type_map(names) + decl, _aliases_applied = _type_declare_apply_type_aliases(request.decl, list(request.aliases)) + declared_names = _type_declare_declared_type_names(_type_declare_parse_declaration_chunks(decl)[0]) + return _type_declare_TypeDeclarePreviewSnapshot( + type_count=len(names), + class_count=len(runtime.list_named_classes()), + declarations={name: current.get(name) for name in sorted(declared_names)}, + ) + + +def _type_declare_type_declare( + context: OperationContext, request: _type_declare_TypeDeclareRequest +) -> _type_declare_TypeDeclareResult: + runtime = context.runtime + decl, aliases_applied = _type_declare_apply_type_aliases(request.decl, list(request.aliases)) + chunks, brace_balance = _type_declare_parse_declaration_chunks(decl) + errors, before, after, bisect = _type_declare_apply_type_declarations_with_optional_bisect( + runtime, decl, replace=request.replace, clang=request.clang, chunks=chunks, bisect_requested=request.bisect + ) + return _type_declare_type_declare_result( + decl, + replace=request.replace, + errors=errors, + before=before, + after=after, + aliases_applied=aliases_applied, + chunks=chunks, + brace_balance=brace_balance, + bisect=bisect, + ) + + +def _type_declare_type_declare_check( + context: OperationContext, request: _type_declare_TypeDeclareRequest +) -> _type_declare_TypeDeclareResult: + runtime = context.runtime + decl, aliases_applied = _type_declare_apply_type_aliases(request.decl, list(request.aliases)) + chunks, brace_balance = _type_declare_parse_declaration_chunks(decl) + errors = _type_declare_test_type_declarations(runtime, decl, clang=request.clang) + result = _type_declare_type_declare_result( + decl, + replace=request.replace, + errors=errors, + before={}, + after={}, + aliases_applied=aliases_applied, + chunks=chunks, + brace_balance=brace_balance, + bisect=None, + ) + result["check"] = True + return result + + +# ---- Class operations ---- + + +@dataclass(frozen=True) +class _classes_ClassListRequest: + pattern: str | None + regex: bool + ignore_case: bool + + +@dataclass(frozen=True) +class _classes_ClassCandidatesRequest: + pattern: str + kinds: tuple[str, ...] + regex: bool + ignore_case: bool + + +@dataclass(frozen=True) +class _classes_ClassNameRequest: + name: str + + +@dataclass(frozen=True) +class _classes_ClassFieldsRequest: + name: str + derived_only: bool + + +@dataclass(frozen=True) +class _classes_ClassVtableRequest: + name: str + runtime: bool + + +def _classes_parse_list(params: Mapping[str, Any]) -> _classes_ClassListRequest: + pattern = optional_str(params.get("pattern")) + return _classes_ClassListRequest( + pattern=pattern, + regex=bool(params.get("regex")), + ignore_case=bool(params.get("ignore_case")), + ) + + +def _classes_parse_candidates(params: Mapping[str, Any]) -> _classes_ClassCandidatesRequest: + pattern = str(params.get("pattern") or "") + kinds = tuple(str(item) for item in params.get("kinds") or [] if str(item)) + return _classes_ClassCandidatesRequest( + pattern=pattern, + kinds=kinds, + regex=bool(params.get("regex")), + ignore_case=bool(params.get("ignore_case")), + ) + + +def _classes_parse_name(params: Mapping[str, Any]) -> _classes_ClassNameRequest: + return _classes_ClassNameRequest(name=require_str(params.get("name"), field="class name")) + + +def _classes_parse_fields(params: Mapping[str, Any]) -> _classes_ClassFieldsRequest: + return _classes_ClassFieldsRequest( + name=require_str(params.get("name"), field="class name"), derived_only=bool(params.get("derived_only")) + ) + + +def _classes_parse_class_vtable(params: Mapping[str, Any]) -> _classes_ClassVtableRequest: + return _classes_ClassVtableRequest( + name=require_str(params.get("name"), field="class name"), runtime=bool(params.get("runtime")) + ) + + +def _classes_class_graph(runtime: IdaRuntime) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]: + rows = runtime.list_named_classes() + classes = {row["name"]: row for row in rows} + children: dict[str, list[str]] = {name: [] for name in classes} + for name, row in classes.items(): + for base_name in row.get("bases") or []: + if base_name in children: + children[base_name].append(name) + for names in children.values(): + names.sort(key=str.lower) + return (classes, children) + + +def _classes_iter_udt_members(runtime: IdaRuntime, tif, *, expand_bases: bool, base_offset_bits: int = 0): + for member in runtime.udt_members(tif): + offset_bits = base_offset_bits + int(member.offset) + if member.is_baseclass(): + if expand_bases: + base_name = member.type.get_type_name() or member.type.dstr() + base_tif = runtime.find_named_type(base_name or "") + if base_tif is not None: + yield from _classes_iter_udt_members( + runtime, base_tif, expand_bases=True, base_offset_bits=offset_bits + ) + continue + yield (offset_bits, member) + + +def _classes_walk_graph(start: list[str], edges: dict[str, list[str]], *, known: set[str]) -> list[str]: + queue = deque(start) + seen: set[str] = set() + rows: list[str] = [] + while queue: + current = queue.popleft() + if current in seen or current not in known: + continue + seen.add(current) + rows.append(current) + queue.extend(edges.get(current, [])) + return rows + + +def _classes_vtable_header(runtime: IdaRuntime, table_ea: int, symbol_name: str, *, ptr_size: int): + ida_name = runtime.mod("ida_name") + if _classes_looks_like_itanium_vtable(runtime, table_ea, symbol_name): + typeinfo_ea = runtime.read_pointer(table_ea + ptr_size) + return ( + "itanium", + table_ea + ptr_size * 2, + [ + { + "index": 0, + "address": hex(table_ea), + "value": hex(runtime.read_pointer(table_ea)), + "name": "offset_to_top", + }, + { + "index": 1, + "address": hex(table_ea + ptr_size), + "value": hex(typeinfo_ea), + "name": "typeinfo", + "symbol": ida_name.get_name(typeinfo_ea) or "", + "demangled": runtime.demangle_name(ida_name.get_name(typeinfo_ea) or ""), + }, + ], + ) + if symbol_name.startswith("??_7"): + return ("msvc", table_ea, []) + return ("unknown", table_ea, []) + + +def _classes_flatten_class_fields(runtime: IdaRuntime, tif, *, derived_only: bool) -> list[dict[str, Any]]: + fields: list[dict[str, Any]] = [] + for offset_bits, member in _classes_iter_udt_members(runtime, tif, expand_bases=not derived_only): + if member.is_method(): + continue + fields.append( + { + "name": member.name or "", + "offset_bits": offset_bits, + "offset": offset_bits // 8, + "size_bits": member.size, + "size": member.size // 8 if member.size else None, + "type": member.type.dstr() or runtime.tinfo_decl(member.type, multi=False), + "is_vftable": member.is_vftable(), + } + ) + fields.sort(key=lambda item: (item["offset"], item["name"])) + return fields + + +def _classes_vtable_members(runtime: IdaRuntime, vtable_tif) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for index, (offset_bits, member) in enumerate(_classes_iter_udt_members(runtime, vtable_tif, expand_bases=True)): + rows.append( + { + "index": index, + "offset_bits": offset_bits, + "offset": offset_bits // 8, + "slot": runtime.vtable_slot(offset_bits), + "name": member.name or "", + "type": member.type.dstr() or runtime.tinfo_decl(member.type, multi=False), + "comment": member.cmt or "", + } + ) + rows.sort(key=lambda item: (item["slot"], item["name"])) + return rows + + +def _classes_looks_like_itanium_vtable(runtime: IdaRuntime, ea: int, symbol_name: str) -> bool: + if is_vtable_symbol_name(symbol_name) and (not symbol_name.startswith("??_7")): + return True + ida_name = runtime.mod("ida_name") + ptr_size = runtime.pointer_size() + first = runtime.read_pointer(ea) + second = runtime.read_pointer(ea + ptr_size) + second_name = ida_name.get_name(second) or "" + return first == 0 and str(second_name).startswith(("__ZTI", "_ZTI")) + + +def _classes_runtime_vtable_member( + runtime: IdaRuntime, entry_ea: int, slot: int +) -> tuple[dict[str, Any] | None, str | None]: + ida_name = runtime.mod("ida_name") + ida_bytes = runtime.mod("ida_bytes") + ida_funcs = runtime.mod("ida_funcs") + target = runtime.read_pointer(entry_ea) + if target == 0: + return (None, "null_target") + name = ida_name.get_name(target) or "" + if (name or "").startswith(RTTI_SYMBOL_PREFIXES): + return (None, "rtti_boundary") + flags = ida_bytes.get_flags(target) + is_code = bool(ida_bytes.is_code(flags)) or ida_funcs.get_func(target) is not None + if not is_code: + return (None, "non_function_target") + return ( + { + "slot": slot, + "entry_address": hex(entry_ea), + "target": hex(target), + "name": name, + "demangled": runtime.demangle_name(name), + "is_code": is_code, + }, + None, + ) + + +def _classes_runtime_vtable_members(runtime: IdaRuntime, slot_ea: int, *, slot_limit: int, ptr_size: int): + rows: list[dict[str, Any]] = [] + for slot in range(max(1, slot_limit)): + entry_ea = slot_ea + slot * ptr_size + member, stop_reason = _classes_runtime_vtable_member(runtime, entry_ea, slot) + if member is None: + return (rows, stop_reason) + rows.append(member) + return (rows, "slot_limit") + + +def _classes_raw_vtable_dump(runtime: IdaRuntime, identifier: str, *, slot_limit: int = 64) -> dict[str, Any]: + table_ea = runtime.resolve_address(identifier) + symbol_name = runtime.mod("ida_name").get_name(table_ea) or str(identifier) + demangled = runtime.demangle_name(symbol_name) + ptr_size = runtime.pointer_size() + abi, slot_ea, header = _classes_vtable_header(runtime, table_ea, symbol_name, ptr_size=ptr_size) + members, stop_reason = _classes_runtime_vtable_members(runtime, slot_ea, slot_limit=slot_limit, ptr_size=ptr_size) + return { + "identifier": identifier, + "kind": "raw_vtable", + "abi": abi, + "table_address": hex(table_ea), + "slot_address": hex(slot_ea), + "symbol": symbol_name, + "demangled_symbol": demangled, + "header": header, + "slot_count": len(members), + "members": members, + "stop_reason": stop_reason, + } + + +def _classes_raise_non_materialized_class_error(runtime: IdaRuntime, name: str, tif) -> None: + kind = runtime.classify_tinfo(tif) + if kind in {"struct", "union"}: + raise IdaOperationError( + f"type `{name}` exists as a {kind}, but is not class-materialized in local types; " + + _classes_class_materialization_hint(runtime, name) + ) + raise IdaOperationError(f"type `{name}` exists as `{kind}`, but is not class-materialized in local types") + + +def _classes_class_materialization_hint(runtime: IdaRuntime, name: str) -> str: + hints = [ + f"try `type show {name}`", + f"`type class candidates {name}`", + "then import a concrete class layout with `type declare --replace`", + ] + evidence = _classes_symbol_evidence(runtime, name) + if evidence: + hints.append("symbol evidence: " + ", ".join(evidence)) + return "; ".join(hints) + + +def _classes_symbol_evidence(runtime: IdaRuntime, name: str) -> list[str]: + try: + symbols = runtime.find_symbols(pattern=name, ignore_case=True) + except Exception as exc: + if not is_recoverable_ida_error(exc): + raise + return [] + vtable_count = 0 + typeinfo_count = 0 + function_count = 0 + for item in symbols: + symbol_name = str(item.get("name") or "") + kind = classify_symbol_kind(symbol_name, is_function=bool(item.get("is_function"))) + if kind == "vtable_symbol": + vtable_count += 1 + elif kind in {"typeinfo_symbol", "typeinfo_name_symbol"}: + typeinfo_count += 1 + elif kind == "function_symbol": + function_count += 1 + evidence: list[str] = [] + if vtable_count: + evidence.append(f"{vtable_count} vtable symbol(s)") + if typeinfo_count: + evidence.append(f"{typeinfo_count} RTTI symbol(s)") + if function_count: + evidence.append(f"{function_count} function symbol(s)") + return evidence + + +def _classes_local_type_candidate_rows( + runtime: IdaRuntime, + pattern: str, + kind_filter: set[str], + seen: set[tuple[str, str, str]], + *, + regex: bool, + ignore_case: bool, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in runtime.list_named_types(pattern=pattern or None, regex=regex, ignore_case=ignore_case): + key = ("local_type", str(item.get("name") or ""), "") + if key in seen or (kind_filter and "local_type" not in kind_filter): + continue + seen.add(key) + rows.append( + {"kind": "local_type", "name": item.get("name"), "decl": item.get("decl"), "type_kind": item.get("kind")} + ) + return rows + + +def _classes_symbol_candidate_rows( + runtime: IdaRuntime, + pattern: str, + kind_filter: set[str], + seen: set[tuple[str, str, str]], + *, + regex: bool, + ignore_case: bool, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in runtime.find_symbols(pattern=pattern or None, regex=regex, ignore_case=ignore_case): + name = str(item.get("name") or "") + kind = classify_symbol_kind(name, is_function=bool(item.get("is_function"))) + if kind_filter and kind not in kind_filter: + continue + key = (kind, name, str(item.get("address") or "")) + if key in seen: + continue + seen.add(key) + rows.append({"kind": kind, "name": name, "address": item.get("address"), "demangled": item.get("demangled")}) + return rows + + +def _classes_require_class_tinfo(runtime: IdaRuntime, name: str): + tif = runtime.find_named_type(name) + if tif is None: + raise IdaOperationError(f"class not found: {name}") + if not runtime.is_class_tinfo(tif): + _classes_raise_non_materialized_class_error(runtime, name, tif) + return tif + + +def _classes_class_list(context: OperationContext, request: _classes_ClassListRequest) -> list[dict[str, Any]]: + return context.runtime.list_named_classes( + pattern=request.pattern, + regex=request.regex, + ignore_case=request.ignore_case, + ) + + +def _classes_class_candidates( + context: OperationContext, request: _classes_ClassCandidatesRequest +) -> list[dict[str, Any]]: + runtime = context.runtime + kind_filter = set(request.kinds) + seen: set[tuple[str, str, str]] = set() + rows = _classes_local_type_candidate_rows( + runtime, + request.pattern, + kind_filter, + seen, + regex=request.regex, + ignore_case=request.ignore_case, + ) + rows.extend( + _classes_symbol_candidate_rows( + runtime, + request.pattern, + kind_filter, + seen, + regex=request.regex, + ignore_case=request.ignore_case, + ) + ) + rows.sort(key=lambda item: (str(item.get("kind") or ""), str(item.get("name") or "").lower())) + return rows + + +def _classes_class_show(context: OperationContext, request: _classes_ClassNameRequest) -> dict[str, Any]: + runtime = context.runtime + tif = _classes_require_class_tinfo(runtime, request.name) + payload = dict(runtime.class_summary(tif, name=request.name, decl_multi=True)) + payload["members"] = _classes_flatten_class_fields(runtime, tif, derived_only=False) + return payload + + +def _classes_class_hierarchy(context: OperationContext, request: _classes_ClassNameRequest) -> dict[str, Any]: + runtime = context.runtime + name = request.name + classes, children = _classes_class_graph(runtime) + if name not in classes: + tif = runtime.find_named_type(name) + if tif is not None and (not runtime.is_class_tinfo(tif)): + _classes_raise_non_materialized_class_error(runtime, name, tif) + raise IdaOperationError(f"class not found: {name}") + base_edges = {class_name: row.get("bases") or [] for class_name, row in classes.items()} + known = set(classes) + ancestors = _classes_walk_graph(list(classes[name].get("bases") or []), base_edges, known=known) + descendants = _classes_walk_graph(list(children.get(name, [])), children, known=known) + return { + "name": name, + "bases": classes[name].get("bases") or [], + "derived": children.get(name, []), + "ancestors": ancestors, + "descendants": descendants, + } + + +def _classes_class_fields(context: OperationContext, request: _classes_ClassFieldsRequest) -> dict[str, Any]: + runtime = context.runtime + tif = _classes_require_class_tinfo(runtime, request.name) + return { + "name": request.name, + "kind": "class_fields", + "derived_only": request.derived_only, + "fields": _classes_flatten_class_fields(runtime, tif, derived_only=request.derived_only), + } + + +def _classes_class_vtable(context: OperationContext, request: _classes_ClassVtableRequest) -> dict[str, Any]: + runtime = context.runtime + tif = _classes_require_class_tinfo(runtime, request.name) + vtable_name = runtime.class_vtable_type_name(tif) + if not vtable_name: + raise IdaOperationError(f"class has no vtable type: {request.name}") + vtable_tif = runtime.get_named_type(vtable_name) + payload = { + "name": request.name, + "kind": "class_vtable", + "vtable_type": vtable_name, + "decl": runtime.tinfo_decl(vtable_tif, name=vtable_name, multi=True), + "members": _classes_vtable_members(runtime, vtable_tif), + } + if request.runtime: + identifier = runtime.class_runtime_vtable_identifier(tif, name=request.name) + if identifier is not None: + payload["runtime_vtable"] = _classes_raw_vtable_dump(runtime, identifier) + return payload + + +# ---- Miscellaneous operations ---- + + +@dataclass(frozen=True) +class _misc_ReanalyzeRequest: + identifier: str + end: str | None = None + + +@dataclass(frozen=True) +class _misc_ReanalyzeRangeResult: + mode: str + start: str + end: str + waited: bool + + +@dataclass(frozen=True) +class _misc_ReanalyzeFunctionResult: + mode: str + function: str + start: str + end: str + waited: bool + + +def _misc_parse_reanalyze(params: Mapping[str, Any]) -> _misc_ReanalyzeRequest: + return _misc_ReanalyzeRequest( + identifier=require_str(params.get("identifier"), field="identifier"), end=optional_str(params.get("end")) + ) + + +def _misc_reanalyze( + context: OperationContext, request: _misc_ReanalyzeRequest +) -> _misc_ReanalyzeRangeResult | _misc_ReanalyzeFunctionResult: + runtime = context.runtime + ida_auto = runtime.mod("ida_auto") + ida_funcs = runtime.mod("ida_funcs") + if request.end is not None: + start_ea = runtime.resolve_address(request.identifier) + end_ea = runtime.resolve_address(request.end) + if end_ea <= start_ea: + raise IdaOperationError("reanalyze range end must be greater than the start") + ida_auto.plan_and_wait(start_ea, end_ea, True) + return _misc_ReanalyzeRangeResult(mode="range", start=hex(start_ea), end=hex(end_ea), waited=True) + try: + func = runtime.resolve_function(request.identifier) + except IdaOperationError: + ea = runtime.resolve_address(request.identifier) + ida_auto.plan_and_wait(ea, ea + 1, True) + return _misc_ReanalyzeRangeResult(mode="address", start=hex(ea), end=hex(ea + 1), waited=True) + ea = func.start_ea + ida_funcs.reanalyze_function(func) + ida_auto.auto_wait() + return _misc_ReanalyzeFunctionResult( + mode="function", function=ida_funcs.get_func_name(ea), start=hex(ea), end=hex(func.end_ea), waited=True + ) + + +# ---- Operation registry and dispatch ---- + +_OPERATION_SPECS: tuple[OperationSpec[Any, Any], ...] = ( + OperationSpec(name="database_info", run=_database_database_info), + OperationSpec(name="segment_list", parse=_segments_parse_segment_list, run=_segments_segment_list), + OperationSpec(name="function_list", parse=_functions_parse_function_list, run=_functions_function_list), + OperationSpec(name="function_show", parse=_functions_parse_identifier, run=_functions_function_show), + OperationSpec(name="function_frame", parse=_functions_parse_identifier, run=_functions_function_frame), + OperationSpec(name="function_stackvars", parse=_functions_parse_identifier, run=_functions_function_stackvars), + OperationSpec(name="function_callers", parse=_functions_parse_identifier, run=_functions_function_callers), + OperationSpec(name="function_callees", parse=_functions_parse_identifier, run=_functions_function_callees), + OperationSpec(name="disasm", parse=_functions_parse_identifier, run=_functions_disasm), + OperationSpec(name="disasm_range", parse=_functions_parse_disasm_range, run=_functions_disasm_range), + OperationSpec(name="decompile", parse=_functions_parse_decompile, run=_functions_decompile), + OperationSpec(name="ctree", parse=_functions_parse_ctree, run=_functions_ctree), + OperationSpec(name="search_bytes", parse=_search_parse_search_bytes, run=_search_search_bytes), + OperationSpec(name="xrefs", parse=_search_parse_xrefs, run=_search_xrefs), + OperationSpec(name="strings", parse=_search_parse_strings, run=_search_strings), + OperationSpec(name="imports", run=_search_imports), + OperationSpec(name="bookmark_get", parse=_bookmarks_parse_get, run=_bookmarks_get_bookmark), + OperationSpec( + name="bookmark_add", + parse=_bookmarks_parse_add, + run=_bookmarks_add_bookmark, + mutating=True, + preview=PreviewSpec( + capture_before=_bookmarks_preview_single_slot, + capture_after=_bookmarks_preview_single_slot, + rollback=_bookmarks_restore_bookmark_state, + prepare=_bookmarks_prepare_add_bookmark, + ), + ), + OperationSpec( + name="bookmark_set", + parse=_bookmarks_parse_set, + run=_bookmarks_set_bookmark, + mutating=True, + preview=PreviewSpec( + capture_before=_bookmarks_preview_single_slot, + capture_after=_bookmarks_preview_single_slot, + rollback=_bookmarks_restore_bookmark_state, + ), + ), + OperationSpec( + name="bookmark_delete", + parse=_bookmarks_parse_delete, + run=_bookmarks_delete_bookmark, + mutating=True, + preview=PreviewSpec( + capture_before=_bookmarks_preview_single_slot, + capture_after=_bookmarks_preview_single_slot, + rollback=_bookmarks_restore_bookmark_state, + ), + ), + OperationSpec(name="comment_get", parse=_comments_parse_lookup, run=_comments_comment_view), + OperationSpec( + name="comment_set", + parse=_comments_parse_change, + run=_comments_change_comment, + mutating=True, + preview=PreviewSpec( + capture_before=_comments_comment_view, + capture_after=_comments_comment_view, + rollback=_comments_restore_comment, + ), + ), + OperationSpec( + name="comment_delete", + parse=_comments_parse_lookup, + run=_comments_change_comment, + mutating=True, + preview=PreviewSpec( + capture_before=_comments_comment_view, + capture_after=_comments_comment_view, + rollback=_comments_restore_comment, + ), + ), + OperationSpec( + name="name_set", + parse=_names_parse_name_set, + run=_names_set_name, + mutating=True, + preview=PreviewSpec( + capture_before=_names_name_state, + capture_after=_names_name_state, + rollback=_names_restore_name, + prepare=_names_prepare_name_set, + ), + ), + OperationSpec(name="local_list", parse=_locals_parse_local_list, run=_locals_local_list), + OperationSpec( + name="local_rename", + parse=_locals_parse_local_rename, + run=_locals_local_rename, + mutating=True, + preview=PreviewSpec( + capture_before=_locals_local_list, + capture_after=_locals_local_list, + cleanup=_locals_cleanup_local_preview, + use_undo=True, + ), + ), + OperationSpec( + name="local_retype", + parse=_locals_parse_local_retype, + run=_locals_local_retype, + mutating=True, + preview=PreviewSpec( + capture_before=_locals_local_list, + capture_after=_locals_local_list, + cleanup=_locals_cleanup_local_preview, + use_undo=True, + ), + ), + OperationSpec( + name="local_update", + parse=_locals_parse_local_update, + run=_locals_local_update, + mutating=True, + preview=PreviewSpec( + capture_before=_locals_local_list, + capture_after=_locals_local_list, + cleanup=_locals_cleanup_local_preview, + use_undo=True, + ), + ), + OperationSpec( + name="local_apply_plan", + parse=_locals_parse_local_apply_plan, + run=_locals_local_apply_plan, + mutating=True, + preview=PreviewSpec( + capture_before=_locals_local_list, + capture_after=_locals_local_list, + cleanup=_locals_cleanup_local_preview, + use_undo=True, + ), + ), + OperationSpec(name="proto_get", parse=_prototypes_parse_proto_get, run=_prototypes_proto_get), + OperationSpec(name="proto_check", parse=_prototypes_parse_proto_check, run=_prototypes_proto_check), + OperationSpec( + name="proto_set", + parse=_prototypes_parse_proto_set, + run=_prototypes_proto_set, + mutating=True, + preview=PreviewSpec( + capture_before=_prototypes_prototype_view, + capture_after=_prototypes_prototype_view, + use_undo=True, + ), + ), + OperationSpec(name="type_list", parse=_named_types_parse_list, run=_named_types_type_list), + OperationSpec(name="type_show", parse=_named_types_parse_show, run=_named_types_type_show), + OperationSpec(name="type_deps", parse=_named_types_parse_show, run=_named_types_type_deps), + OperationSpec( + name="type_declare", + parse=_type_declare_parse_request, + run=_type_declare_type_declare, + mutating=True, + preview=PreviewSpec( + capture_before=_type_declare_preview_snapshot, + capture_after=_type_declare_preview_snapshot, + use_undo=True, + ), + ), + OperationSpec( + name="type_declare_check", + parse=_type_declare_parse_check_request, + run=_type_declare_type_declare_check, + ), + OperationSpec(name="class_list", parse=_classes_parse_list, run=_classes_class_list), + OperationSpec(name="class_candidates", parse=_classes_parse_candidates, run=_classes_class_candidates), + OperationSpec(name="class_show", parse=_classes_parse_name, run=_classes_class_show), + OperationSpec(name="class_hierarchy", parse=_classes_parse_name, run=_classes_class_hierarchy), + OperationSpec(name="class_fields", parse=_classes_parse_fields, run=_classes_class_fields), + OperationSpec(name="class_vtable", parse=_classes_parse_class_vtable, run=_classes_class_vtable), + OperationSpec(name="struct_list", parse=_named_types_parse_list, run=_named_types_struct_list), + OperationSpec(name="struct_show", parse=_named_types_parse_struct_show, run=_named_types_struct_view), + OperationSpec( + name="struct_field_set", + parse=_named_types_parse_struct_field_set, + run=_named_types_struct_field_set, + mutating=True, + preview=PreviewSpec( + capture_before=_named_types_struct_view, + capture_after=_named_types_struct_view, + use_undo=True, + ), + ), + OperationSpec( + name="struct_field_rename", + parse=_named_types_parse_struct_field_rename, + run=_named_types_struct_field_rename, + mutating=True, + preview=PreviewSpec( + capture_before=_named_types_struct_view, + capture_after=_named_types_struct_view, + use_undo=True, + ), + ), + OperationSpec( + name="struct_field_delete", + parse=_named_types_parse_struct_field_delete, + run=_named_types_struct_field_delete, + mutating=True, + preview=PreviewSpec( + capture_before=_named_types_struct_view, + capture_after=_named_types_struct_view, + use_undo=True, + ), + ), + OperationSpec(name="enum_list", parse=_named_types_parse_list, run=_named_types_enum_list), + OperationSpec(name="enum_show", parse=_named_types_parse_enum_show, run=_named_types_enum_view), + OperationSpec( + name="enum_member_set", + parse=_named_types_parse_enum_member_set, + run=_named_types_enum_member_set, + mutating=True, + preview=PreviewSpec( + capture_before=_named_types_enum_view, + capture_after=_named_types_enum_view, + use_undo=True, + ), + ), + OperationSpec( + name="enum_member_rename", + parse=_named_types_parse_enum_member_rename, + run=_named_types_enum_member_rename, + mutating=True, + preview=PreviewSpec( + capture_before=_named_types_enum_view, + capture_after=_named_types_enum_view, + use_undo=True, + ), + ), + OperationSpec( + name="enum_member_delete", + parse=_named_types_parse_enum_member_delete, + run=_named_types_enum_member_delete, + mutating=True, + preview=PreviewSpec( + capture_before=_named_types_enum_view, + capture_after=_named_types_enum_view, + use_undo=True, + ), + ), + OperationSpec(name="reanalyze", parse=_misc_parse_reanalyze, run=_misc_reanalyze, mutating=True), +) +_OPERATIONS = MappingProxyType({spec.name: spec for spec in _OPERATION_SPECS}) +if len(_OPERATIONS) != len(_OPERATION_SPECS): + raise RuntimeError("duplicate remote operation name") +SUPPORTED_OPERATIONS = tuple(_OPERATIONS) +MUTATING_OPERATIONS = tuple(name for name, spec in _OPERATIONS.items() if spec.mutating) + + +def dispatch(db, op: str, params: dict[str, Any], preview: bool): + """Execute one idac operation in the active Nexus database interpreter.""" + + del db # Nexus binds the active ida-domain Database; IDAPython owns the APIs below. + if not isinstance(op, str) or not op: + raise IdaOperationError("operation name is required") + if not isinstance(params, Mapping): + raise IdaOperationError("operation parameters must be an object") + if not isinstance(preview, bool): + raise IdaOperationError("preview must be a boolean") + if "preview" in params: + raise IdaOperationError("preview must be passed as the dispatch argument") + operation = _OPERATIONS.get(op) + if operation is None: + raise IdaOperationError(f"unsupported operation: {op}") + + runtime = IdaRuntime() + context = OperationContext(runtime=runtime, preview=preview) + request = operation.parse(params) if operation.parse is not None else None + if preview: + result = run_preview( + context, + operation.name, + request, + operation.run, + operation.preview, + ) + elif operation.mutating and operation.preview is not None and operation.preview.use_undo: + ida_undo = runtime.mod("ida_undo") + if not ida_undo.create_undo_point( + action_name=f"idac_{operation.name}", + label=f"idac {operation.name}", + ): + raise IdaOperationError(f"{operation.name} requires IDA undo support for atomic mutation") + try: + result = operation.run(context, request) + except BaseException as exc: + if not ida_undo.perform_undo(): + raise IdaOperationError( + f"{operation.name} failed and IDA could not restore the mutation via undo" + ) from exc + raise + if isinstance(result, Mapping) and result.get("success") is False and not ida_undo.perform_undo(): + raise IdaOperationError( + f"{operation.name} reported failure and IDA could not restore the mutation via undo" + ) + else: + result = operation.run(context, request) + return payload_from_model(result) diff --git a/src/idac/setup.py b/src/idac/setup.py new file mode 100644 index 0000000..15ed381 --- /dev/null +++ b/src/idac/setup.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import importlib.metadata +import os +import subprocess +import sys +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .compatibility import runtime_requirements + + +def setup_gui( + *, + timeout: float | None = None, + runner: Any = subprocess.run, + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Install the GUI plugin matching the installed Nexus client.""" + + nexus_version = importlib.metadata.version("ida-nexus") + source = f"https://github.com/HexRaysSA/ida-nexus@v{nexus_version}" + domain_requirement = str(runtime_requirements()["ida-domain"]) + + with tempfile.TemporaryDirectory(prefix="idac-nexus-constraint-") as temporary_dir: + constraint = Path(temporary_dir) / "constraints.txt" + constraint.write_text(f"ida-domain{domain_requirement}\n", encoding="utf-8") + process_environment = dict(os.environ if environ is None else environ) + process_environment["PIP_CONSTRAINT"] = str(constraint) + process_environment["UV_CONSTRAINT"] = str(constraint) + try: + process = runner( + [sys.executable, "-m", "hcli", "plugin", "install", source], + check=False, + capture_output=True, + text=True, + timeout=timeout, + env=process_environment, + ) + except subprocess.TimeoutExpired as exc: + raise OSError("timed out while installing the ida-nexus GUI plugin") from exc + except OSError as exc: + raise OSError(f"failed to run the ida-hcli installer: {exc}") from exc + + if process.returncode != 0: + output = "\n".join(part.strip() for part in (process.stdout, process.stderr) if part and part.strip()) + suffix = f": {output}" if output else "" + raise OSError(f"ida-hcli failed to install ida-nexus (exit {process.returncode}){suffix}") + return { + "installed": True, + "plugin": "ida-nexus", + "version": nexus_version, + "ida_domain_requirement": domain_requirement, + "installer": f"ida-hcli=={importlib.metadata.version('ida-hcli')}", + "source": source, + } diff --git a/src/idac/skills/idac/SKILL.md b/src/idac/skills/idac/SKILL.md deleted file mode 100644 index 8563905..0000000 --- a/src/idac/skills/idac/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: idac -description: Use for reverse-engineering work through the local `idac` CLI against a live IDA GUI session, an existing `.i64` / `.idb` database, or a binary that IDA can open. Trigger this skill when the task involves decompilation, disassembly, ctree or microcode inspection, functions, locals, types, xrefs, strings, imports, C++ class or vtable recovery, target or backend selection, prototype or local/type mutations, reanalysis, or IDAPython execution through IDA. ---- - -# idac - -Use `idac` for IDA-backed reverse engineering through a live IDA GUI, an existing `.i64` / `.idb`, or a binary that IDA can open. -Prefer first-class `idac` commands, then `idac py exec`, then external or ad hoc tooling only when `idac` cannot cover the task. - -## Critical defaults - -- Work from the binary first. Do not search the web or external source trees unless the user explicitly asks for that or the task is specifically about external correlation. -- Run one `idac` command at a time for each target. Use `batch`, `decompilemany`, and `--out` artifacts for broad work instead of background parallel commands. -- Use `decompile --f5` or `decompilemany --f5` during type or prototype recovery. `--f5` is an alias for `--no-cache`. -- Preview supported persistent mutations first, then commit only after the preview confirms the intended change. Outside batch mode, `preview` requires `-o/--out`. -- Before `function prototype set`, run `function prototype show`. Run `function prototype check` first when the declaration uses a custom calling convention (`__usercall`, `__userpurge`, `__spoils`) or references newly imported types; declare missing support types before dependent prototypes. -- Before importing large headers, validate with `type check --decl-file ...`. -- After type or prototype mutations, run `misc reanalyze`, then reread pseudocode or locals before rename-heavy cleanup. Calibrate local renames from fresh `function locals list --json` output using `--local-id` or `--index`; see `idac docs workflows` for selector calibration. -- Before executing a mutation batch, run `batch --lint --out ` and fix reported issues. -- Context selection: omit `-c` for one live GUI session, use `-c pid:` for multiple GUI sessions, and use `-c "db:/path"` for headless work. -- When working in an idac workspace, keep audit notes append-only and factual. Distinguish proven facts from inferred names, types, and semantics. - -When this guide is installed as a skill, the reference files sit alongside it; otherwise use `idac docs TOPIC` for the same material. For CLI syntax, prefer targeted help such as `idac type class --help`; use `idac --full-help` only when the command surface itself is unclear. - -## When not to use - -- The task is not IDA-backed and the user wants standalone RE tooling. -- The task is source-level debugging or runtime inspection; use a debugger directly. -- A first-class `idac` command already covers the task; do not start with raw IDAPython. -- The task is static analysis, linting, or vulnerability scanning outside IDA-driven reverse-engineering work. - -## Choose the path - -``` -What is the task? -│ -├─ Read-only inspection (decompile, list, xrefs, strings, ctree, microcode) -│ └─ Run `idac docs cli` -│ -├─ Mutation (rename, retype, prototype, type declare) -│ └─ Run `idac docs workflows` -│ -├─ C++ class or vtable recovery -│ └─ Run `idac docs class-recovery` and `idac docs ida-cpp-type-details` -│ -├─ Context/target selection, backend state, or bridge trouble -│ └─ Run `idac docs targets` or `idac docs troubleshooting` -│ -└─ No first-class command covers the task - └─ Use `idac py exec` with a small explicit script -``` - -## First commands - -Use only the commands that match the current target state: - -```bash -# always: discover live GUI and headless targets -idac targets list --json -# headless binary import -idac database open "/path/to/binary" --json -# headless database or imported binary context -idac database show -c "db:/path/to/binary" --json -# single live GUI session: omit -c -idac function list "init|open|close" --demangle --regex -i --json --out /tmp/functions.json -# one of several live GUI sessions: select the pid from targets list -idac decompile "sub_08041337" -c "pid:1234" -o /tmp/sub_08041337.c -# single live GUI session: omit -c -idac decompile "sub_08041337" -o /tmp/sub_08041337.c -# current context -idac xrefs "sub_08041337" --json -idac disasm --start "0x100000460" --end "0x1000004a0" -``` - -## Mutation outline - -Use `idac docs workflows` (`workflows.md`) for exact syntax. - -1. Discovery and read-only audit. -2. Preview each persistent mutation. -3. Lint mutation batches before running them. -4. Commit the mutation. -5. Run `misc reanalyze` after type or prototype changes. -6. Reread pseudocode or locals; calibrate local selectors from fresh JSON. -7. Verify final readback and, when working in a workspace, record the pass in the workspace audit log (`audit/-recovery.md`) if one exists. - -For headless `db:` work, checkpoint with `database save`; `database close` saves by default, and `database close --discard` abandons pending changes. Live GUI edits remain in the IDA session. - -## Class recovery outline - -Use `idac docs class-recovery` (`class-recovery.md`) for the full workflow and `idac docs ida-cpp-type-details` (`ida-cpp-type-details.md`) before importing C++ class or vtable declarations. - -## Python escape hatch - -Use `py exec` only when no first-class command covers the task cleanly: - -```bash -idac py exec --code "print(hex(idaapi.get_imagebase())); result = {'entry': hex(idc.get_inf_attr(idc.INF_START_EA))}" -``` - -Supported modes: `--code`, `--stdin`, `--script`. `--script` preserves IDAPython script semantics such as `__file__`, script-directory imports, and `sys.argv` when IDA exposes that helper. Add `--persist` only when later `py exec` calls in the same session must reuse Python globals. -The execution scope includes the core `ida*` modules that `idac` imports itself, plus `idautils`, `idc`, and `result`. - -## Reference index - -| File | `idac docs` topic | When to read | -|------|-------------------|--------------| -| `references/cli.md` | `cli` | Command grammar, common reads, preview, batch, output notes | -| `references/targets-and-backends.md` | `targets` | Context selection, GUI vs `idalib`, opening binaries, target discovery | -| `references/workflows.md` | `workflows` | Safe mutation loop, batch, selector calibration, post-mutation readback | -| `references/class-recovery.md` | `class-recovery` | C++ class recovery workflow, naming rules, vtable guidance, verification | -| `references/ida-cpp-type-details.md` | `ida-cpp-type-details` | IDA C++ parser expectations, `__vftable`, `*_vtbl`, multiple inheritance | -| `references/ida-set-types.md` | `ida-set-types` | IDA C declaration syntax: calling conventions, usercall locations, attribute and type keywords | -| `references/ida-advanced-type-annotations.md` | `ida-advanced-type-annotations` | Scattered argument locations and other advanced IDA declaration annotations | -| `references/troubleshooting.md` | `troubleshooting` | Bridge, backend, mutation, stale-result, or sandbox problems | -| `references/templates/README.md` | `templates` | Reusable prototype-pass, rename-pass, checkpoint-note, and locals-jq templates (printed in full) | diff --git a/src/idac/skills/idac/agents/openai.yaml b/src/idac/skills/idac/agents/openai.yaml deleted file mode 100644 index 37c1f0f..0000000 --- a/src/idac/skills/idac/agents/openai.yaml +++ /dev/null @@ -1,7 +0,0 @@ -interface: - display_name: "idac" - short_description: "Use idac for IDA CLI reversing, mutation, and class recovery" - default_prompt: "Use $idac for reverse-engineering work through the local idac CLI against a live IDA GUI target, an existing .i64/.idb database, or a binary that IDA can open, including decompilation, xrefs, type and class recovery, safe mutations, backend selection, troubleshooting, and IDAPython when needed." - -policy: - allow_implicit_invocation: true diff --git a/src/idac/skills/idac/references/class-recovery.md b/src/idac/skills/idac/references/class-recovery.md deleted file mode 100644 index 57242bf..0000000 --- a/src/idac/skills/idac/references/class-recovery.md +++ /dev/null @@ -1,197 +0,0 @@ -# Class Recovery - -Read this for C++ class recovery, vtable evidence, class-layout imports, prototype propagation, and verification. - -Use `type class` as the primary entry point for C++ recovery work. -If the local type system is still opaque, start with `type list`, then use `type class candidates` before importing recovered types. After a local class/vtable type exists, use `type class vtable --runtime` for the combined local/runtime view. - -## Contents - -- [Recommended command order](#recommended-command-order) -- [What each command is for](#what-each-command-is-for) -- [Adjacent-class workflow](#adjacent-class-workflow) -- [Vtable guidance](#vtable-guidance) -- [C++ declaration guidance](#c-declaration-guidance) -- [Stop conditions](#stop-conditions) -- [Verification checklist](#verification-checklist) -- [Practical caveat](#practical-caveat) - -## Recommended command order - -```bash -idac type list "Example" -idac function list "Example_" --json --out "/tmp/class_family_functions.json" -idac decompilemany "Example_" --out-dir "/tmp/class_family_decompile_discovery" -idac type class candidates "Example" --json --out "/tmp/class_candidates.json" -idac type check --decl-file "support_types.h" -idac type declare --replace --decl-file "support_types.h" -idac type check --decl-file "recovered_classes.h" -idac type declare --replace --decl-file "recovered_classes.h" -idac type deps "ExampleDerived" -idac type class list "Example" -idac type class show "ExampleDerived" -idac type class fields "ExampleDerived" --derived-only -idac type class hierarchy "ExampleBase" -idac type class vtable "ExampleDerived" --runtime -idac function prototype show "0x100012340" -idac function prototype check "0x100012340" --decl "bool __fastcall ExampleDerived__method_1(ExampleDerived *__hidden this, const unsigned char *buf, u64 len, const char *arg3, u32 flags)" -idac preview -o "/tmp/proto_preview.json" function prototype set "0x100012340" --decl "bool __fastcall ExampleDerived__method_1(ExampleDerived *__hidden this, const unsigned char *buf, u64 len, const char *arg3, u32 flags)" -idac misc reanalyze "ExampleDerived__method_1" -idac decompilemany "Example_" --f5 --out-dir "/tmp/class_family_decompile_verify" -idac decompile "CreateExampleDerived" --f5 -idac decompile "ExampleDerived__method_1" --f5 -``` - -Use the safe mutation loop from [workflows.md](workflows.md#safe-mutation-loop). For class-recovery work on one target, keep the phases distinct and leave local renames or retypes until after prototype/type changes and reanalysis. - -Start narrow from confirmed family members, then widen to callers or adjacent helpers only when readback or propagation requires it. - -Raw runtime slot inspection is not exposed as a first-class CLI command. Use `type class vtable --runtime` once the class is materialized, or fall back to `py exec` only when no first-class command covers the task. - -## What each command is for - -- `type list`: find named local types even when they are still opaque structs. - Use `type list [TYPE_FILTER]`, and remember that an unfiltered run requires `--out`. -- `type class candidates`: find likely class names, vtables, RTTI, and helpers before local classes exist. -- `type class candidates` mixes `local_type`, `symbol`, `vtable_symbol`, `typeinfo_symbol`, `typeinfo_name_symbol`, and `function_symbol` rows in one flat result set. - Use `--kind` when you want only one subset, such as `--kind function_symbol`. -- Skip `type class candidates` when clean demangled symbols and RTTI already identify the family. It is most useful for opaque targets. -- `type declare --replace`: re-import a recovered class header without leaving stale local types behind. -- `type check`: validate recovered declaration text without importing it. Run it before large class headers or parser-risky C++ syntax. -- `type deps`: print an existing local type with IDA dependency expansion when available; use it for audit artifacts after import. -- `type class list`: find the classes materialized in local types. - Use the positional class-filter form `type class list [CLASS_FILTER]`. -- `type class show`: read the flattened object layout, including inherited fields. - If it says a type exists but is not class-materialized yet, use `type show` or re-import a concrete class layout first. -- `type class hierarchy`: confirm direct and transitive relationships after the recovered class layout has been imported. -- `type class fields --derived-only`: isolate the fields owned by one derived class. -- `type class vtable --runtime`: inspect both the local-type vtable layout and the raw runtime targets when symbols are available. -- `decompilemany "" --out-dir ...`: capture pseudocode artifacts for every function matching a class-family name filter before narrowing to representative callers and overrides. - For capture strategy, `--functions-file`, `manifest.json` handling, and the `--f5` policy, read [workflows.md](workflows.md#safe-mutation-loop). - During discovery, stop decompiling once constructor, destructor, one accessor, and one serializer or parser have already proven the layout. -- `function prototype set`: apply corrected function types to the runtime virtual targets after the class layout is in place. - Use `function prototype show` first so the current signature is recorded before the update. - Use `function prototype check` before applying signatures with custom calling conventions or newly imported types. - Use `preview -o /tmp/proto_preview.json function prototype set ...`; the command-specific preview readback carries richer before/after data when supported. -- `decompile`: spot-check representative constructors, factories, and overrides after type changes. - -Confidence and naming conventions: - -- Mark plausible-but-unconfirmed variable or parameter names with `_maybe` -- Keep placeholder field names neutral and offset-based, such as `field_8` or `field_20` -- Prefer one byte array or blob field for a long contiguous unknown region, such as `unsigned char _unk_14[136]`, instead of inventing many guessed scalar fields. -- Do not present inferred vtable slots, members, or helper names as if they were confirmed -- When semantics are inferred rather than proven, note that explicitly in the issue log -- Apply the same rule to field and member names. If a recovered member name is only probable, keep it explicitly inferred until stronger proof appears. - -## Adjacent-class workflow - -- recover support structs first -- recover directly referenced neighbor classes next -- fix function prototypes -- inspect the current signature with `function prototype show` before applying `function prototype set` -- if a missing enum or support type such as `ExampleEnum` blocks a prototype, create the placeholder type before retrying -- reanalyze touched callers, not just the callee whose prototype changed -- rename locals last -- recover support types before cosmetic renames; early support-type recovery usually improves output more than local-name cleanup - -For selector calibration and the local-rename rules, read [workflows.md](workflows.md#selector-calibration). - -## Vtable guidance - -- Prefer `type class vtable` once the local vtable type exists -- If you need raw slot evidence before a local type exists, use `type class candidates --kind vtable_symbol` to find the symbol first, then use `py exec` as the escape hatch for one-off raw slot inspection -- Treat raw-vtable lookup failures as evidence about symbol availability, not as proof that the class family is unrecoverable -- Before writing or importing C++ declarations, review [ida-cpp-type-details.md](ida-cpp-type-details.md) so the local type text matches IDA's naming and layout rules -- When you want the class helpers to recognize the layout reliably, prefer IDA-friendly naming and formatting: - - name the vtable type `ClassName_vtbl` - - declare it as `struct /*VFT*/ ClassName_vtbl { ... };` - - attach it to the object as `ClassName_vtbl *__vftable;` -- For Itanium-style ABIs, address-point offsets, or `ClassName_vtbl_layout` scratch wrappers, read [ida-cpp-type-details.md](ida-cpp-type-details.md). -- For multiple inheritance or secondary-base overrides, use the IDA-specific `ClassName_XXXX_vtbl` pattern described in [ida-cpp-type-details.md](ida-cpp-type-details.md) instead of inventing an ad hoc secondary vtable name -- Avoid alternate suffixes or ad hoc member spellings when helper compatibility matters - -Helpful scratch artifacts for long passes: - -- a scratch recovery header such as `recovered_classes.hpp` -- an issue log such as `recovery_issues.md` -- a machine-readable mutation artifact such as `recovery.idac` plus its `batch --out` log - -For batch syntax and `.idac` file format, read [workflows.md](workflows.md#batch). - -For large recovered families, use `idac py exec` with an explicit local script when first-class commands are not enough, and keep the script with the rest of your recovery artifacts so the pass stays reviewable and reproducible. - -Inspect JSON artifacts with portable shell tools such as `jq` and `sed` instead of assuming bare `python` exists. `type list`, `type class candidates`, and `function list` artifacts are top-level JSON arrays, so start filters with `.[]`; `function locals list` wraps rows under `.locals[]`. - -```bash -jq '.functions_succeeded' /tmp/class_family_decompile_discovery/manifest.json -jq -r '.functions[] | select(.ok) | [.name, .artifact_path] | @tsv' /tmp/class_family_decompile_discovery/manifest.json -jq -r '.results[] | select(.exit_code != 0) | [.line, .command] | @tsv' /tmp/recovery_batch.json -jq -r '.[].name' /tmp/class_types.json -jq -r '.[] | select(.kind == "function_symbol") | .name' /tmp/class_candidates.json -jq -r '.[] | [.kind, .name] | @tsv' /tmp/class_candidates.json -jq -r '.[].name' /tmp/class_family_functions.json -sed -n '1,80p' /tmp/recovery_batch.json -``` - -## C++ declaration guidance - -Prefer plain `struct` declarations first when the target database is still rough or diagnostics flag parser trouble. -Before finalizing class or vtable declaration text, read [ida-cpp-type-details.md](ida-cpp-type-details.md) for IDA parser syntax, naming patterns, and multiple-inheritance edge cases. - -Practical rules: - -- keep the first import minimal: plain `struct`, direct field declarations, and no preprocessor wrappers or comment noise -- only add vtable-specific forms when there is direct evidence of virtual dispatch on that class, such as a constructor storing a vtable pointer, a recovered runtime vtable symbol, or an already-confirmed `__vftable` member -- keep helper-compatible names such as `ClassName_vtbl` and `__vftable` -- use `__attribute__((packed))` when constructor, accessor, serializer, or field-offset evidence proves the compiler packed the layout -- do not use `packed` to hide uncertainty; keep explicit blob padding for true gaps and reread `type struct show` or `type class show` after import -- treat `__cppobj` as optional refinement, not a requirement for the first successful import -- for secondary-base virtual tables, use the IDA-specific `ClassName_XXXX_vtbl` pattern -- use `--alias old=new` during import when namespace-qualified names need flattening for local-type parsing -- if import errors suggest parser trouble, simplify the declaration and retry with preview first -- run `type check --decl-file ...` before importing the simplified declaration - -## Stop conditions - -Stop a recovery pass when: - -- the object layout is structurally readable and the important offsets are justified by constructor, accessor, serializer, or caller evidence -- the key helper and virtual-target prototypes are corrected enough that callers read coherently after reanalysis -- the remaining confusion is mostly Hex-Rays presentation noise rather than uncertainty about the actual data flow or type relationships - -Do not keep pushing for cosmetic pseudocode perfection when: - -- large-stack AArch64 prologues still show odd top-of-function value flow after prototype cleanup and reanalysis -- destructor or cleanup paths still lose precise derived-type propagation even though the recovered class layout is already consistent -- the remaining unnamed locals are mostly spill state, scratch temporaries, or compiler artifacts that do not change the safety argument - -## Verification checklist - -- class size matches the recovered declaration -- base list matches the intended hierarchy -- `type class fields --derived-only` contains the expected subclass fields -- `type class vtable` shows the expected slot names -- runtime virtual targets have function prototypes applied, not just local vtable slot types -- at least one representative base implementation and one representative override decompile with the expected `this` type -- at least one caller of a newly fixed prototype reflects the intended type cleanup -- stale caller casts or bad `this` propagation triggered targeted `misc reanalyze` on those callers before final readback -- each committed local rename was confirmed against fresh `function locals list --json` output, per the rules in [workflows.md](workflows.md#selector-calibration) - -Representative readback set: - -- one constructor or destructor -- one accessor -- one parser/helper -- one caller of a newly fixed prototype -- one function whose locals were renamed - -## Practical caveat - -Do not assume every derived field appears strictly after the base size. Real targets can reuse tail padding, so constructor evidence and `this+offset` access patterns still matter. -Treat empty derived classes as the default unless constructor evidence or `this+offset` access proves extra state. -For embedded opaque members, estimate size from neighboring field offsets first, then use constructor evidence as confirmation. -Inherited vtable slot types often keep the base-class `this` type even when the runtime target is a derived override. When improving decompiler quality, prefer the owning implementation class from the runtime target symbol when setting the function prototype. -Prefer preserving existing opaque local types unless replacing them is intentionally required for layout recovery. -If you replace an opaque type with a size-only blob or placeholder, record that tradeoff explicitly in the issue log. -Destructor bodies often start by restoring a vtable pointer and then lose precise derived-type propagation in Hex-Rays. When that happens, use function-local retypes on the derived locals after the prototype and reanalysis pass instead of forcing the whole type system harder. diff --git a/src/idac/skills/idac/references/cli.md b/src/idac/skills/idac/references/cli.md deleted file mode 100644 index 0e34609..0000000 --- a/src/idac/skills/idac/references/cli.md +++ /dev/null @@ -1,127 +0,0 @@ -# idac Quick Reference - -The command grammar for the `idac` CLI. - -## Conventions - -- list commands use one optional positional filter such as `NAME_FILTER` or `TYPE_FILTER` -- `--regex` enables regular-expression matching -- matching is case-sensitive by default; `-i` makes it case-insensitive -- `function list` text output includes the containing section, such as `.plt` or `.text` -- `function list --demangle` matches and renders demangled display names -- `function metadata` and JSON `function list` rows include `display_name` when available; JSON `function list` rows also include `section` -- function-taking commands can resolve a unique demangled C++ name such as `ExampleClass::method_1`; if multiple functions match, use a mangled name, full signature, or address -- `segment list` lists database segments -- setup, maintenance, and utility commands live under `misc` - -## Common reads - -```bash -idac docs -idac docs guide -idac docs workflows -idac function list -idac function list --demangle -idac function list "init|open|close" --demangle --regex -i -idac function list "sub_08041337" -idac function list "sub_.*" --regex -idac function list "sub_08041337" -i -idac segment list -idac segment list "__TEXT|__cstring" --regex -idac function metadata "sub_08041337" -idac function frame "sub_08041337" -idac function stackvars "sub_08041337" -idac function callees "sub_08041337" -idac function callers "sub_08041337" -idac function prototype show "sub_08042010" -idac function locals list "sub_08041337" -idac decompile "sub_08041337" -idac decompile "ExampleClass::method_1" -idac decompile "sub_08041337" -o "/tmp/sub_08041337.txt" -idac decompilemany "sub_08041337" --out-file "/tmp/sub_08041337.c" -idac decompilemany --functions-file "funcs.txt" --out-dir "/tmp/decomp" -idac decompilemany --functions-file "funcs.txt" --out-dir "/tmp/decomp" --disasm --ctree -idac disasm "sub_08041337" -idac disasm --start "0x100000460" --end "0x1000004a0" -idac ctree "sub_08041337" -idac xrefs "sub_08041337" -idac imports -idac search bytes "74 69 6e 79" --segment "__cstring" --timeout 30 -idac search strings "tiny" --segment "__cstring" --timeout 30 -idac type deps "ExampleStruct" -idac type check --decl-file "recovered_types.h" --json -idac function prototype check "sub_08041337" --decl-file "sub_08041337_proto.h" --json -``` - -`xrefs` is a top-level command; there is no `function xrefs` command. -For broad function discovery, prefer `function list "name1|name2" --regex -i` so IDA filters before rendering. Add `--demangle` when the filter should match demangled display names. Avoid producing a full function list just to pipe it into `rg`; add `--out ` if the filtered result is still too large for inline output. -`search strings` and `search bytes` require both `--timeout` and `--segment`. On dyld shared caches, `search strings` only allows `--scan` with explicit `--start` / `--end` bounds up to 16 MiB. - -## Preview - -`idac` preview is always a wrapper and always writes JSON or JSONL. -The payload includes `command`, `status`, `before`, `after`, `result`, `readback`, `undo`, `artifacts`, and `stderr`. - -```bash -idac preview -o "/tmp/preview.json" \ - function prototype set "sub_08042010" --decl "long long __cdecl sub_08042010(long long lhs, long long rhs)" -``` - -Read-only commands under `preview` are treated as no-op previews with identical `before` and `after` payloads. - -## Batch - -Batch accepts one command per line and writes structured JSON or JSONL. -It allows `preview ...` lines, but commands that are not batch-safe are rejected. -If a batch contains persistent mutating commands, `batch --out` is required so the ordered result log is preserved before any changes run. - -```bash -idac batch "recovery.idac" --out "/tmp/recovery.json" -idac batch "recovery.idac" --lint --out "/tmp/recovery.lint.json" -``` - -Batch files may include blank lines and `#` comments. Example: - -```text -# recovery.idac -type check --decl-file "recovered_types.h" -type declare --replace --decl-file "recovered_types.h" -function prototype set "sub_08041337" --decl-file "sub_08041337_proto.h" -misc reanalyze "sub_08041337" -function locals rename "sub_08041337" --index 6 --new-name "entry_count" -``` - -For a full recovery-pass example and the batch authoring rules, read [workflows.md](workflows.md#batch). - -## Misc commands - -These setup, maintenance, and utility commands live under `misc`: - -- `misc rename` — rename a function or global symbol. Not available in `batch` or `preview`; commit symbol renames one-off. -- `misc reanalyze` — re-run IDA analysis on a function or range. Batch-safe; place it between type/prototype mutations and local cleanup. -- `misc plugin install` — install the GUI bridge plugin; `--force` replaces an existing install. Setup-only; rejected from `batch`. -- `misc skill install` — install the bundled skill. Setup-only; rejected from `batch`. - -## Bundled docs - -`idac docs` prints an agent-oriented index of bundled reference material without needing a live IDA target. -Use `idac docs TOPIC` for focused guidance, such as `guide`, `cli`, `workflows`, `targets`, `troubleshooting`, `class-recovery`, `ida-cpp-type-details`, `ida-set-types`, `ida-advanced-type-annotations`, `templates`, or `workspace`. -Use `idac docs --list` to list every topic and `idac docs --all --out docs.md` to write all bundled docs to a file. - -## Output notes - -- terminal output still enforces the inline size limit -- large inline results print a short summary first, then error -- `type declare --clang` uses IDA's clang parser for more complex C/C++ declarations -- `type check` validates declarations without importing them; use it before large or parser-risky `type declare` runs -- `function prototype check` validates a function declaration without applying it -- `type deps NAME` prints an existing type with IDA dependency expansion when available -- `type list`, `type struct list`, and `type enum list` require `--out` when no pattern is given -- for `function locals retype`, `--type` is shorthand for simple type text; use `--decl` or `--decl-file` for a full declaration, such as arrays or function pointers -- `decompile` uses `-o/--out` for a single rendered result; `decompilemany` uses `--out-file` or `--out-dir` for bulk artifacts -- `decompilemany FUNCTION_FILTER` selects functions by name substring; it is not a list of exact functions -- for multiple explicit functions, write one function name or address per line and pass `decompilemany --functions-file ` -- `decompilemany --out-file` writes combined text -- `decompilemany --out-dir` writes one file per function plus `manifest.json` -- `decompilemany --disasm` and `--ctree` require `--out-dir` and add per-function `.asm` and `.ctree` artifacts to the manifest -- long `decompilemany --out-dir` artifact names are shortened with a stable digest; use `manifest.json` `.functions[].address` as the stable exact lookup key, and `.functions[].artifact_path` / `.functions[].artifacts` for file paths diff --git a/src/idac/skills/idac/references/targets-and-backends.md b/src/idac/skills/idac/references/targets-and-backends.md deleted file mode 100644 index c545b3f..0000000 --- a/src/idac/skills/idac/references/targets-and-backends.md +++ /dev/null @@ -1,75 +0,0 @@ -# Targets and Backends - -Read this when choosing a GUI or `idalib` context, opening a binary/database, or resolving target discovery state. - -`idac` has two execution contexts: - -- `gui`: use a live IDA desktop session through the `idac_bridge` plugin -- `idalib`: use a headless per-database idalib process against an existing `.i64` / `.idb`, or a binary that IDA can open - -Do not start every task with diagnostics or cleanup. Use the diagnostic commands in this file when context selection is unclear, target discovery fails, or bridge/runtime trouble is likely. - -## Live GUI context - -Use the default context or `-c ` when: - -- the database is already open in IDA -- you want to review changes in the UI immediately -- you want live mutation or preview behavior against the desktop session - -`-c/--context` and `--timeout` can appear either before the subcommand or on the command itself. If you pass both a root-level value and a command-local value, the command-local value wins. Omit `--timeout` to wait indefinitely. - -Discover targets first: - -```bash -idac doctor -idac targets list -idac decompile "sub_08041337" -idac decompile "sub_08041337" -c "pid:1234" -idac targets cleanup -``` - -If multiple GUI instances are open, pass `-c pid:`. -`targets list` reports both live GUI bridge targets and already-open headless `idalib` targets. In JSON output, check `backend`: GUI rows use `backend: "gui"` and headless rows use `backend: "idalib"`. If a binary was opened with `database open`, keep using `-c "db:/path/to/binary"` even when no GUI target is present. -If the runtime dir contains stale GUI bridge or `idalib` daemon files, run `idac targets cleanup` and then rerun `targets list`. Use `--out ` when you want to keep the full cleanup result. -If the bridge plugin is missing from the current IDA session, run `idac misc plugin install` (`--force` replaces an existing install), then ask the user to reload the plugin in IDA — idac cannot reload it — before retrying discovery. - -## Database context - -Use `idac database open` when you have a binary or database file and want headless automation without depending on the UI. - -For a binary: - -```bash -idac database open "/path/to/binary" --json -idac targets list --json -idac database show -c "db:/path/to/binary" --json -idac decompile "main" -c "db:/path/to/binary" --f5 -``` - -For massive binaries or first-time autoanalysis, prefer the indefinite default unless the user asked for a deadline. Do not wrap this import in a shell-level timeout or a tool-call timeout; let the command keep running and poll the session if your tool supports incremental output. -For source-level entry behavior, prefer `main_ea` from `database show --json`. If IDA cannot determine `main`, use `start_ea` / `entry_ea` or an address from `function list --json`. -`database open` has no architecture-slice or loader-prompt options today. If a raw binary requires a slice choice, open the intended slice with IDA first, save an `.i64` / `.idb`, then use that database path with `idac database open "sample.i64"` or `-c "db:sample.i64"`. - -For an existing `.i64` / `.idb`: - -```bash -idac database open "sample.i64" -idac doctor -idac database show -c "db:sample.i64" -idac decompile "sub_08041337" -c "db:sample.i64" -idac database save -c "db:sample.i64" -idac database close -c "db:sample.i64" --discard -``` - -`database save` writes an explicit checkpoint. `database close -c "db:sample.i64"` saves before closing by default; use `database close -c "db:sample.i64" --discard` to abandon pending changes. - - -## Selection rules - -- If exactly one GUI instance is open, most commands can omit `-c`. -- Explicit `db:` locators passed via `-c` resolve to `idalib`; the path can be a binary or database file that IDA can open. -- `targets list -c "db:/path"` filters to matching headless targets without starting a new daemon. -- GUI selectors passed via `-c` resolve to the live bridge. -- If multiple GUI instances are open, `-c` is required. -- `doctor` is the first command to run when backend state is unclear. diff --git a/src/idac/skills/idac/references/templates/README.md b/src/idac/skills/idac/references/templates/README.md deleted file mode 100644 index 2e870e7..0000000 --- a/src/idac/skills/idac/references/templates/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Template Files - -These templates are generic starting points for common `idac` mutation passes. - -- `prototype-pass.idac` - - Use for a small cluster of related `function prototype check` / `function prototype set` edits after support types already exist. - - Run `idac batch prototype-pass.idac --lint --out /tmp/prototype-pass.lint.json` before the real batch. -- `rename-pass.idac` - - Use for selective local renames after reanalysis and a fresh locals dump. - - For many edits in one function, prefer a JSON plan consumed by `function locals apply`. -- `checkpoint-note.md` - - Use for per-target `audit/-recovery.md` entries that separate confirmed changes, failures, open questions, and next steps. -- `locals-jq-snippets.sh` - - Use for inspecting the wrapped `function locals list --json --out ...` artifact shape without rediscovering the `jq` filters. - -These are examples, not canonical declarations. In the `.idac` files, replace the placeholder types, addresses, and names with evidence from the current target. `checkpoint-note.md` is a fill-in skeleton: replace every `{{...}}` slot and delete sections with nothing to report. diff --git a/src/idac/skills/idac/references/templates/prototype-pass.idac b/src/idac/skills/idac/references/templates/prototype-pass.idac deleted file mode 100644 index aa502ce..0000000 --- a/src/idac/skills/idac/references/templates/prototype-pass.idac +++ /dev/null @@ -1,21 +0,0 @@ -# Generic prototype cleanup pass -# -# Use this after support types already exist locally. -# Prefer addresses or mangled names for overloaded functions. -# Keep declarations parser-friendly and verify each one with: -# idac function prototype show -# idac function prototype check --decl "..." -# Then run: -# idac batch prototype-pass.idac --lint --out /tmp/prototype-pass.lint.json -# idac batch prototype-pass.idac --out /tmp/prototype-pass.json -# idac decompile --f5 - -function prototype check 0x100000000 --decl "int __fastcall ExampleClass__parseHeader(ExampleClass *__hidden this, const unsigned __int8 *buf, unsigned int len)" -function prototype set 0x100000000 --decl "int __fastcall ExampleClass__parseHeader(ExampleClass *__hidden this, const unsigned __int8 *buf, unsigned int len)" -misc reanalyze 0x100000000 -function prototype check 0x100000100 --decl "void *__fastcall ExampleClass__buildResult(ExampleClass *__hidden this, InputContext *ctx, const ExampleOptions *options)" -function prototype set 0x100000100 --decl "void *__fastcall ExampleClass__buildResult(ExampleClass *__hidden this, InputContext *ctx, const ExampleOptions *options)" -misc reanalyze 0x100000100 -function prototype check 0x100000200 --decl "unsigned int __fastcall ExampleClass__getCount(const ExampleClass *__hidden this)" -function prototype set 0x100000200 --decl "unsigned int __fastcall ExampleClass__getCount(const ExampleClass *__hidden this)" -misc reanalyze 0x100000200 diff --git a/src/idac/skills/idac/references/templates/rename-pass.idac b/src/idac/skills/idac/references/templates/rename-pass.idac deleted file mode 100644 index c641ff4..0000000 --- a/src/idac/skills/idac/references/templates/rename-pass.idac +++ /dev/null @@ -1,17 +0,0 @@ -# Generic local rename pass -# -# Run only after prototype fixes and reanalysis. -# Refresh locals first: -# idac function locals list --json --out /tmp/locals.json -# Prefer --index or --local-id after any prototype or reanalysis change. -# Stop on the first miss, refresh locals, and recalibrate selectors. -# For many edits in one function, create a JSON plan from the fresh locals dump and use: -# function locals apply --json-file locals-plan.json - -function locals rename 0x100000000 --index 5 --new-name header_size -function locals rename 0x100000000 --index 6 --new-name record_type -function locals rename 0x100000000 --index 7 --new-name entry_count -function locals rename 0x100000000 --index 12 --new-name parse_status -function locals rename 0x100000100 --index 4 --new-name result_ptr -function locals rename 0x100000100 --index 9 --new-name output_size -function locals rename 0x100000100 --index 11 --new-name error_code diff --git a/src/idac/skills/idac/references/troubleshooting.md b/src/idac/skills/idac/references/troubleshooting.md deleted file mode 100644 index 5046ab0..0000000 --- a/src/idac/skills/idac/references/troubleshooting.md +++ /dev/null @@ -1,118 +0,0 @@ -# Troubleshooting - -Read this when bridge/backend state, mutation failures, stale decompiler output, or sandbox socket access is unclear. - -## No GUI targets found - -Diagnose before installing anything: - -```bash -idac doctor -idac targets list -idac targets cleanup -idac targets list -``` - -If no targets appear, either the `idac_bridge` plugin is not loaded in the current GUI session or stale bridge runtime files were masking it (`targets cleanup` removes those; add `--out ` to keep the full result). If `doctor` reports the plugin missing, run `idac misc plugin install` (add `--force` to replace an existing install). idac cannot reload the plugin itself — ask the user to reload the `idac_bridge` plugin in the IDA GUI, or restart IDA, then rerun `targets list`. - -This section is only about GUI rows. Headless targets opened through `database open` appear in `targets list --json` with `backend: "idalib"` and should be used with `-c "db:/path"`. - -## Agent sandbox cannot reach the bridge socket - -Both backends use Unix sockets (default runtime dir: `/tmp/idac`, controlled by `IDAC_RUNTIME_DIR`). If your environment blocks Unix socket connections, *every* live GUI command will fail with errors like "Failed to contact IDA GUI bridge". - -The bridge socket does not distinguish read-only vs mutating operations. If read-only commands succeed but a mutation fails, focus on the reported IDA/database failure mode (for example: IDA undo disabled for `preview`, a read-only database, missing types, or a rejected declaration) rather than sandbox socket permissions. - -## Multiple GUI targets are open - -Use an explicit selector from `targets list`: - -```bash -idac targets list -idac decompile "sub_08041337" -c "pid:" -``` - -## Open a binary with `idalib` - -The `idalib` backend can open a binary that IDA recognizes. For the canonical command sequence, timeout guidance, and `main` fallback, read [targets-and-backends.md](targets-and-backends.md#database-context). - -## `idalib` changes did not reach disk - -That is expected until you save the open database. `database close -c "db:sample.i64"` saves before closing by default; use `database close -c "db:sample.i64" --discard` to abandon pending changes. - -```bash -idac database save -c "db:sample.i64" -idac database close -c "db:sample.i64" -idac database close -c "db:sample.i64" --discard -``` - -## `function prototype set` reports unknown type(s) - -Declare the missing support or placeholder types first, then retry the prototype. See `idac docs workflows` for the safe mutation loop and `idac docs class-recovery` for support-type ordering. - -Before retrying, use: - -```bash -idac type check --decl-file "support_types.h" -idac function prototype check "sub_08041337" --decl-file "sub_08041337_proto.h" -``` - -If a local type exists but its dependencies are unclear, use `type deps ` to ask IDA to print the type with dependencies when possible. - -## Preview did not persist - -That is expected. `preview` applies the mutation, captures the result, and undoes it before returning. - -```bash -idac preview -o "/tmp/preview.json" comment set "sub_08041337" "entry point" -``` - -Preview performs the real mutation before undoing it, so the readback reflects the temporary changed state. - -For `function locals update`, `function locals rename`, and `function locals retype`, preview always returns the full before/after local list. -For `function locals apply`, preview also returns before/after local lists, so use it when a single function has many coordinated local changes. - -Preview payloads are structured JSON or JSONL objects; see [cli.md](cli.md#preview) for the top-level key list. For mutating commands, `before` and `after` capture the temporary state around the undo cycle, and `result` contains the command-specific return payload. - -For `type declare` previews, `replaced_types` is the list of local types whose declarations changed in the preview. It is informational, not a failure signal. If a familiar framework typedef such as `CFDateRef` appears there, verify the local type directly with `type show` before treating it as a regression. - -## `type class show` says the type is not class-materialized - -That means the local type exists, but not yet as a C++ class that `idac` can flatten or inspect as a vtable-backed object. - -Run: - -```bash -idac type show "ExampleClass" -idac type class candidates "ExampleClass" --json --out "/tmp/class_candidates.json" -idac preview -o "/tmp/type_preview.json" type declare --replace --decl-file "recovered_classes.h" -``` - -If the candidates show vtable, RTTI, or family function symbols, switch to recovered-header import instead of probing more `type class show` or `type class vtable` commands on the opaque type. - -## Decompiler, local, or type results look stale - -Run: - -```bash -idac misc reanalyze "sub_08041337" -idac decompile "sub_08041337" -idac decompile "sub_08041337" --f5 -idac function locals list "sub_08041337" --json --out "/tmp/sub_08041337.locals.json" -``` - -`--f5` forces a fresh Hex-Rays pass instead of reusing cached pseudocode. -If the issue is backend-related, rerun `doctor` first. - -## Large readback is hard to inspect inline - -If a function, local-variable list, or decompile result is too large for the terminal, write it to a file instead of relying on inline output: - -```bash -idac decompile "sub_08041337" --f5 --out "/tmp/sub_08041337.json" -idac function locals list "sub_08041337" --json --out "/tmp/sub_08041337.locals.json" -idac decompilemany "Example_" --out-dir "/tmp/example_family" -idac disasm --start "0x100000460" --end "0x1000004a0" --out "/tmp/range.asm" -``` - -When the output is mostly for later inspection, prefer a file artifact from the start. That keeps the readback stable across reanalysis and avoids truncation. diff --git a/src/idac/skills/idac/references/workflows.md b/src/idac/skills/idac/references/workflows.md deleted file mode 100644 index a9cb03c..0000000 --- a/src/idac/skills/idac/references/workflows.md +++ /dev/null @@ -1,242 +0,0 @@ -# Common Workflows - -Read this for safe mutation, batch, selector calibration, broad discovery, and post-mutation readback. - -## Contents - -- [Inspect an open GUI target](#inspect-an-open-gui-target) -- [Open a binary](#open-a-binary) -- [Work from an existing database file](#work-from-an-existing-database-file) -- [Recover type information around a function](#recover-type-information-around-a-function) -- [Recover C++ class information](#recover-c-class-information) -- [Safe mutation loop](#safe-mutation-loop) -- [Selector calibration](#selector-calibration) -- [Batch](#batch) -- [Broad discovery defaults](#broad-discovery-defaults) -- [Structural inspection and reanalysis](#structural-inspection-and-reanalysis) - -## Inspect an open GUI target - -```bash -idac doctor -idac targets list --json -idac function metadata "sub_08041337" -c "pid:" -idac decompile "sub_08041337" -c "pid:" -idac decompile "sub_08041337" -o "/tmp/sub_08041337.txt" -c "pid:" -idac xrefs "sub_08041337" -c "pid:" -``` - -If only one IDA GUI window is open, you can often omit `-c`. -If target discovery is failing rather than merely unknown, read [targets-and-backends.md](targets-and-backends.md) or [troubleshooting.md](troubleshooting.md) before treating diagnostics as part of the normal read workflow. - -Live GUI notes: - -- if a command will be parsed, use `--json` -- for parsed-read and `--out` defaults, read [cli.md](cli.md) -- for a single large decompile, use `-o/--out` on `decompile`; reserve `--out-file` and `--out-dir` for `decompilemany` -- run one `idac` command at a time per GUI target; the bridge serializes requests internally, and background parallel commands can fill the queue or make mutation ordering unclear - -## Open a binary - -For detailed context selection and first-time import guidance, read [targets-and-backends.md](targets-and-backends.md). - -```bash -idac database open "/path/to/binary" --json -idac targets list --json -idac database show -c "db:/path/to/binary" --json -idac decompile "main" -c "db:/path/to/binary" --f5 -``` - -## Work from an existing database file - -```bash -idac doctor -idac database show -c "db:sample.i64" -idac decompile "sub_08041337" -c "db:sample.i64" -idac decompile "sub_08041337" --f5 -c "db:sample.i64" -idac decompilemany "sub_0804" --out-dir "/tmp/function_decompile" -c "db:sample.i64" -idac ctree "sub_08041337" -c "db:sample.i64" -``` - -## Recover type information around a function - -```bash -idac function prototype show "sub_08041337" -idac function locals list "sub_08041337" -idac function locals list "sub_08041337" --json --out "/tmp/locals.json" -idac preview -o "/tmp/local_rename_preview.json" function locals rename "sub_08041337" "v12" --new-name "value_maybe" -idac type list "example" -idac type deps "ExampleStruct" -idac type struct show "ExampleStruct" -idac type enum show "ExampleEnum" -``` - -If any demangled or printed signature references a useful type that does not exist locally yet, create a placeholder support struct before continuing with broader class or prototype cleanup. -If you need to check many support-type names, prefer one broad `type list --json --out ` artifact and inspect it locally rather than issuing many scattered single-name queries. `type list` requires either a pattern or `--out `. - -## Recover C++ class information - -For the full class recovery workflow, phased ordering, naming rules, vtable guidance, and verification checklist, read [class-recovery.md](class-recovery.md). - -## Safe mutation loop - -```bash -idac function prototype show "sub_08041337" -idac preview -o "/tmp/proto_preview.json" function prototype set "sub_08041337" --decl "int __fastcall sub_08041337(void *ctx, const unsigned char *buf, unsigned int len)" -idac preview -o "/tmp/proto_file_preview.json" function prototype set "sub_08041337" --decl-file "sub_08041337_proto.h" -idac preview -o "/tmp/local_update_preview.json" function locals update "sub_08041337" "v12" --rename "value_maybe" --decl "unsigned int value_maybe;" -idac preview -o "/tmp/local_rename_preview.json" function locals rename "sub_08041337" "v13" --new-name "entry_count" -idac preview -o "/tmp/local_retype_preview.json" function locals retype "sub_08041337" "v4" --type "unsigned int" -idac preview -o "/tmp/local_retype_decl_preview.json" function locals retype "sub_08041337" "v4" --decl "unsigned int v4;" -idac preview -o "/tmp/local_retype_file_preview.json" function locals retype "sub_08041337" "v4" --decl-file "local_v4.h" -idac type check --decl "typedef struct ExampleStruct { int field_0; } ExampleStruct;" -idac type check --decl-file "recovered_classes.h" -idac preview -o "/tmp/type_preview.json" type declare --decl "typedef struct ExampleStruct { int field_0; } ExampleStruct;" -idac preview -o "/tmp/type_replace_preview.json" type declare --replace --decl-file "recovered_classes.h" -idac preview -o "/tmp/type_clang_preview.json" type declare --clang --decl-file "recovered_templates.hpp" -idac function prototype check "sub_08041337" --decl "int __fastcall sub_08041337(void *ctx, const unsigned char *buf, unsigned int len)" -``` - -The positional local selectors in these previews are for pre-reanalysis one-off checks. After `misc reanalyze`, committed rename or retype batches should switch to `--index` or `--local-id`; see [Selector calibration](#selector-calibration). - -Then commit the real change and read it back: - -```bash -idac function prototype set "sub_08041337" --decl "int __fastcall sub_08041337(void *ctx, const unsigned char *buf, unsigned int len)" -idac function prototype show "sub_08041337" -idac decompile "sub_08041337" -``` - -Symbol renames go through `misc rename `, which is rejected from both `batch` and `preview`: check the current name first (`function metadata`), commit the rename one-off, and confirm with readback. -Add `--propagate-callers` when you want `function prototype set` to also apply the new callee type at matching caller call sites. -Use `function prototype check` first when the declaration uses custom calling conventions, usercall annotations, or newly imported support types. -Use `type check` before large imports; use `type deps ` after import when the dependency-expanded declaration is the clearest audit artifact. - -Post-mutation readback commands: - -```bash -idac function prototype show "sub_08041337" -idac function locals list "sub_08041337" -idac type struct show "ExampleStruct" -idac type enum show "ExampleEnum" -idac type class show "ExampleDerived" -idac type class vtable "ExampleDerived" --runtime -idac decompile "ExampleDerived__method_1" -``` - -When you need pseudocode for a whole family, prefer bulk decompile over many one-off `decompile` calls. -- Use `decompilemany "" --out-dir ...` for name-filtered discovery. -- For multiple explicit functions, write one function name or address per line and use `decompilemany --functions-file ... --out-dir ...`. -The command writes one `.c` artifact per function plus `manifest.json`. Add `--disasm` or `--ctree` when the same selected functions also need disassembly or Hex-Rays tree artifacts. Treat `manifest.json` as the source of truth for full function names, exact addresses, and artifact paths when long names need shortened filenames. Use `.functions[].address` as the stable exact lookup key. -For symbol-rich families, run one `decompilemany "" --out-dir ...` capture before the first mutation so you can grep every constructor, destructor, parser, and helper locally. After import and reanalysis, redecompile only the functions you must verify (with `--f5`); do not redo the family-wide dump. -During type or prototype recovery, prefer `decompile --f5` and `decompilemany --f5` so discovery and verification artifacts reflect the latest imported types and prototype changes. -For ordinary exploration and routine readback, rerun a one-off decompile with `--f5` or `--no-cache` only when the output looks stale after reanalysis. -If legacy `type declare` import rejects template-heavy or newer C++ syntax, retry the same import with `--clang`. -Before rename-heavy cleanup, fix the shared helper prototypes that dominate the caller bodies, then reanalyze those callers and reread the locals. Prototype cleanup usually improves trustworthiness more than cosmetic renames do. - -## Selector calibration - -```bash -idac function locals list "sub_08041337" --json -idac function locals list "sub_08041337" --json --out "/tmp/locals.json" -idac preview -o "/tmp/local_rename_index_preview.json" function locals rename "sub_08041337" "3" --new-name "value_maybe" -idac preview -o "/tmp/local_rename_id_preview.json" function locals rename "sub_08041337" --local-id "stack(16)@0x100000460" --new-name "value_maybe" -idac preview -o "/tmp/local_plan_preview.json" function locals apply "sub_08041337" --json-file "locals-plan.json" -``` - -Keep whichever stable selector readbacks cleanly, then use that selector style for the rest of the pass. -If one committed rename misses, stop immediately, reread locals, and recalibrate before continuing. -When using `--local-id` or `--index`, do not combine them with a positional selector. -`function locals list --json` emits the canonical `local_id` string in `@` form. Copy that exact text for stable-selector mode. -If the locals list is too large to inspect inline, add `--out` and read the JSON artifact locally instead of forcing the terminal buffer. - -Example `locals-plan.json` for `function locals apply`: - -```json -[ - {"local_id": "stack(16)@0x100000460", "rename": "value_count", "decl": "unsigned int value_count;"}, - {"index": 7, "type": "ExampleStruct *"} -] -``` - -Local rename caution: - -- The current local name is an acceptable selector only for a one-off rename before anything has shifted. For rename batches, and for any rename after a prototype change or reanalysis, capture fresh `function locals list --json` output and select by the exact `local_id` or `index` values it reports — never queue name-only rename batches across mutation phases. -- After each committed rename, reread `function locals list --json` and confirm the intended `index` or `local_id` now shows the new name. Work one function at a time, with a fresh reread between functions. -- Prefer `function locals update` when one local needs both a recovered name and a recovered type in the same pass -- Prefer `function locals apply --json-file` when several locals in one function need coordinated renames or retypes from one fresh locals snapshot -- For `function locals retype`, use `--type` for simple spellings such as `unsigned int` or `ExampleStruct *`. Use `--decl` or `--decl-file` when the retype needs a full declaration, such as arrays or function pointers. -- Use `--decl` for small one-off edits; prefer `--decl-file` in batch files and other long mutation passes -- Prefer `jq` or `sed` for shell inspection of JSON artifacts instead of assuming bare `python` exists - Example: `idac function locals list "sub_08041337" --json | jq -r '.locals[] | [.index, .local_id, .display_name, .type] | @tsv'` - -## Batch - -```bash -idac batch "recovery.idac" --out "/tmp/recovery_batch.json" -idac batch "recovery.idac" --lint --out "/tmp/recovery_batch_lint.json" -``` - -Batch files should: - -- use one `idac` subcommand per line -- omit the leading `idac` -- omit `-c`, `--timeout`, and `--format` -- omit per-command `--out` for mutation logging; use child `--out` only when that specific read command must write its own artifact -- prefer `--decl-file` for long type or prototype text -- always pass `--out` to `batch` so the full step log is captured in a stable artifact -- keep related `--decl-file`, `--functions-file`, and explicit child artifact paths next to the batch file; relative child paths are resolved from the batch file directory -- prefer one ordered `batch` file over multiple background `idac` processes for mutation passes -- mutating batches without wrapper `batch --out` are rejected before execution -- run `batch --lint --out ` before executing mutation batches; lint resolves relative paths, catches parse errors, rejects unsupported batch commands, and warns on risky name-only local selectors after type/prototype/reanalysis phases - -```text -# recovery.idac -type check --decl-file "recovered_classes.h" -type declare --replace --decl-file "recovered_classes.h" -function prototype check "ExampleDerived__method_1" --decl-file "example_method_1.h" -function prototype set "ExampleDerived__method_1" --decl-file "example_method_1.h" -misc reanalyze "ExampleDerived__method_1" -function locals update "ExampleDerived__method_1" --local-id "stack(16)@0x100000460" --rename "value_maybe" --decl-file "example_local.h" -function locals apply "ExampleDerived__method_1" --json-file "example_method_1_locals.json" -function locals rename "ExampleDerived__method_1" --index 6 --new-name "entry_count" -function locals retype "ExampleDerived__method_1" --index 7 --decl-file "example_local_7.h" -preview function prototype set "ExampleDerived__method_1" --decl-file "example_method_1.h" -``` - -For `idalib`, `batch` keeps ordered logging while reusing the same open database state for the shared `-c db:` locator. Each step is still a separate request. -For larger prototype and local-rename passes, prefer `batch` so the mutation order is explicit and the run leaves behind a stable ordered log. -Setup-only `misc` commands such as `misc plugin install` and `misc skill install` are intentionally rejected from `batch`, and so is `misc rename` — commit symbol renames one-off. `misc reanalyze` is batch-safe and belongs between type/prototype mutations and local cleanup in full recovery batches. - -## Broad discovery defaults - -- Use `--json --out ` by default for `type class candidates` -- If you only want functions, vtables, or RTTI from `type class candidates`, add `--kind` instead of post-filtering a broad mixed list -- Prefer IDA-side filters for broad function discovery: `function list "name1|name2" --regex -i --json --out `, adding `--demangle` when matching display names -- Avoid piping a full unfiltered `function list --demangle` through shell tools unless you genuinely need the whole list locally -- For large `function locals list` runs, prefer `--json --out ` so the canonical `local_id` data stays readable after reanalysis drift -- For rename previews on large functions, write the preview to disk with `preview -o ...`, then inspect the JSON with `jq` instead of trusting the inline summary alone -- The equivalent family reads are `function list [NAME_FILTER]`, `type list [TYPE_FILTER]`, and `type class candidates [CANDIDATE_FILTER]` with optional `--regex` and `-i` -- For strings, scan the relevant segment first with `search strings --scan --segment ...`, then read defined strings back with `search strings [TEXT_FILTER] --segment ... --timeout 30`; see [cli.md](cli.md) for the required flags and dyld-shared-cache limits -- Use `--out ` by default for wide string scans on real binaries -- For scoping class-family discovery, read [class-recovery.md](class-recovery.md) - -Binary-only analysis mode: bias toward strings, RTTI, vtables, demangled symbols, local types, and call behavior. Do not assume external source, headers, or online lookup. - -## Structural inspection and reanalysis - -```bash -idac ctree "sub_08041337" -idac ctree "sub_08041337" --level micro --maturity generated -idac misc reanalyze "sub_08041337" -idac decompile "sub_08041337" -``` - -Mandatory checkpoint: - -- after major type or prototype mutations, run `misc reanalyze` -- do that before local renames -- then reread pseudocode or locals instead of assuming propagation -- if callers still show stale casts or bad `this` propagation, reanalyze those callers too and reread them before declaring the pass done -- treat return-type changes as higher risk than parameter-name or local-name changes; if the body does not clearly prove the return value, leave it generic -- stop when the structure, call behavior, and safety-relevant data flow are readable; do not chase perfect pseudocode once the remaining problems are clearly presentation-only diff --git a/src/idac/transport/__init__.py b/src/idac/transport/__init__.py deleted file mode 100644 index f3f2ff3..0000000 --- a/src/idac/transport/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from .gui import GuiBackend -from .idalib import IdaLibBackend -from .schema import RequestEnvelope - - -class BackendError(RuntimeError): - """User-facing transport failure raised after backend normalization.""" - - pass - - -_BACKENDS = { - "gui": GuiBackend, - "idalib": IdaLibBackend, -} - - -def get_backend(name: str) -> GuiBackend | IdaLibBackend: - """Instantiate the backend selected on the CLI request envelope.""" - - backend_cls = _BACKENDS.get(name) - if backend_cls is None: - raise BackendError(f"Unsupported backend: {name}") - return backend_cls() - - -def send_request(request: RequestEnvelope) -> dict[str, Any]: - """Dispatch a request and normalize backend runtime errors.""" - - backend = get_backend(request.backend) - try: - return backend.send(request) - except RuntimeError as exc: - raise BackendError(str(exc)) from exc diff --git a/src/idac/transport/common.py b/src/idac/transport/common.py deleted file mode 100644 index 9a5a550..0000000 --- a/src/idac/transport/common.py +++ /dev/null @@ -1,99 +0,0 @@ -from __future__ import annotations - -import contextlib -import json -import os -import subprocess -import tempfile -from pathlib import Path -from typing import Any - -_TIMEOUT_REQUIRED_OPS = frozenset({"search_bytes", "strings"}) - - -def pid_is_live(pid: int) -> bool: - try: - os.kill(pid, 0) - return True - except PermissionError: - return True - except OSError: - return False - - -def pid_command_line(pid: int) -> str | None: - """Return the process command line for ``pid`` when it can be queried safely.""" - - try: - proc = subprocess.run( - ["ps", "-o", "args=", "-p", str(pid)], - capture_output=True, - text=True, - check=False, - ) - except OSError: - return None - if proc.returncode != 0: - return None - lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()] - if not lines: - return None - return lines[0] - - -def normalize_timeout(raw_timeout: float | None) -> float | None: - if raw_timeout is None: - return None - timeout = float(raw_timeout) - if timeout <= 0: - raise ValueError("backend timeout must be greater than 0") - return timeout - - -def require_timeout_for_operation(op: str, timeout: float | None) -> None: - if op in _TIMEOUT_REQUIRED_OPS and timeout is None: - raise ValueError(f"operation `{op}` requires a request timeout (`--timeout` on the CLI)") - - -def recv_all(sock) -> list[bytes]: - chunks: list[bytes] = [] - while True: - chunk = sock.recv(65536) - if not chunk: - return chunks - chunks.append(chunk) - - -def read_request_bytes(connection, *, timeout: float, max_bytes: int) -> bytes: - connection.settimeout(timeout) - chunks: list[bytes] = [] - total = 0 - while True: - chunk = connection.recv(65536) - if not chunk: - return b"".join(chunks) - total += len(chunk) - if total > max_bytes: - raise ValueError(f"request body exceeds maximum size of {max_bytes} bytes") - chunks.append(chunk) - - -def atomic_write_json(path: Path, payload: Any) -> None: - temp_path: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - dir=path.parent, - prefix=f"{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - temp_path = Path(handle.name) - handle.write(json.dumps(payload, indent=2, sort_keys=True)) - assert temp_path is not None - temp_path.replace(path) - finally: - if temp_path is not None: - with contextlib.suppress(FileNotFoundError): - temp_path.unlink() diff --git a/src/idac/transport/dispatch.py b/src/idac/transport/dispatch.py deleted file mode 100644 index 0f9ff20..0000000 --- a/src/idac/transport/dispatch.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -import queue -import threading -import time -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any, TypeVar, cast - -T = TypeVar("T") - - -class DispatcherStoppedError(RuntimeError): - """Raised when a serialized dispatcher is unavailable or shutting down.""" - - pass - - -class DispatcherBusyError(RuntimeError): - """Raised when a serialized dispatcher refuses new work.""" - - pass - - -@dataclass(frozen=True) -class DispatchMetrics: - """Timing and queue metrics recorded for one dispatcher call.""" - - queue_depth_at_enqueue: int - queue_wait_seconds: float - run_seconds: float - - -@dataclass -class DispatchCall: - """One queued call executed by a serialized dispatcher.""" - - label: str - fn: Callable[[], Any] - queue_depth_at_enqueue: int = 0 - done: threading.Event = field(default_factory=threading.Event) - result: Any = None - error: BaseException | None = None - enqueued_at: float = field(default_factory=time.monotonic) - started_at: float | None = None - finished_at: float | None = None - - -class SerializedDispatcher: - """Run queued calls one-at-a-time in FIFO order.""" - - def __init__( - self, - name: str, - *, - runner: Callable[[Callable[[], Any]], Any] | None = None, - max_pending: int | None = None, - ) -> None: - self._name = name - self._runner = runner or self._run_inline - self._queue: queue.Queue[DispatchCall | None] = queue.Queue() - self._thread: threading.Thread | None = None - self._lock = threading.Lock() - self._idle = threading.Condition(self._lock) - self._stopping = False - self._max_pending = max_pending - self._pending = 0 - - @staticmethod - def _run_inline(fn: Callable[[], T]) -> T: - return fn() - - def start(self) -> None: - """Start the background worker if it is not already running.""" - - with self._lock: - if self._thread is not None and self._thread.is_alive(): - return - self._stopping = False - thread = threading.Thread( - target=self._worker, - name=f"{self._name}-dispatcher", - daemon=True, - ) - thread.start() - self._thread = thread - - def stop(self, *, join_timeout: float = 1.0) -> None: - """Stop the worker and fail any queued-but-not-started calls.""" - - with self._lock: - if self._thread is None: - return - self._stopping = True - thread = self._thread - self._thread = None - self._queue.put(None) - - self._fail_pending_calls() - thread.join(timeout=join_timeout) - - def call(self, label: str, fn: Callable[[], T]) -> T: - """Queue a call and wait until it has run.""" - - result, _ = self.call_with_metrics(label, fn) - return result - - def call_with_metrics(self, label: str, fn: Callable[[], T]) -> tuple[T, DispatchMetrics]: - """Queue a call, wait until it has run, and return timing metrics.""" - - with self._lock: - thread = self._thread - if self._stopping or thread is None or not thread.is_alive(): - raise DispatcherStoppedError(f"{self._name} dispatcher is not running") - if self._max_pending is not None and self._pending >= self._max_pending: - raise DispatcherBusyError( - f"{self._name} dispatcher queue is full ({self._pending}/{self._max_pending})" - ) - call = DispatchCall( - label=label, - fn=fn, - queue_depth_at_enqueue=self._pending, - ) - self._pending += 1 - # Enqueue while holding the lock: stop() also enqueues its shutdown - # sentinel under the lock, so an accepted call can never land behind - # the sentinel and leave the caller waiting forever. - self._queue.put(call) - call.done.wait() - if call.error is not None: - raise call.error - started_at = call.started_at or call.enqueued_at - finished_at = call.finished_at or started_at - metrics = DispatchMetrics( - queue_depth_at_enqueue=call.queue_depth_at_enqueue, - queue_wait_seconds=max(0.0, started_at - call.enqueued_at), - run_seconds=max(0.0, finished_at - started_at), - ) - return cast(T, call.result), metrics - - def pending_count(self) -> int: - """Return the number of accepted calls that are queued or running.""" - - with self._lock: - return self._pending - - def max_pending(self) -> int | None: - """Return the configured maximum accepted pending calls.""" - - return self._max_pending - - def wait_for_idle(self, timeout: float | None = None) -> bool: - """Wait until no accepted calls are queued or running.""" - - deadline = None if timeout is None else time.monotonic() + timeout - with self._idle: - while self._pending != 0: - if deadline is None: - self._idle.wait() - continue - remaining = deadline - time.monotonic() - if remaining <= 0: - return False - self._idle.wait(timeout=remaining) - return True - - def _fail_pending_calls(self) -> None: - while True: - try: - item = self._queue.get_nowait() - except queue.Empty: - return - if item is None: - self._queue.put(None) - return - item.error = DispatcherStoppedError(f"{self._name} dispatcher is shutting down") - item.done.set() - self._queue.task_done() - with self._idle: - self._pending = max(0, self._pending - 1) - if self._pending == 0: - self._idle.notify_all() - - def _worker(self) -> None: - while True: - item = self._queue.get() - if item is None: - self._queue.task_done() - return - try: - item.started_at = time.monotonic() - item.result = self._runner(item.fn) - except BaseException as exc: - item.error = exc - finally: - item.finished_at = time.monotonic() - item.done.set() - self._queue.task_done() - with self._idle: - self._pending = max(0, self._pending - 1) - if self._pending == 0: - self._idle.notify_all() diff --git a/src/idac/transport/gui.py b/src/idac/transport/gui.py deleted file mode 100644 index af2e891..0000000 --- a/src/idac/transport/gui.py +++ /dev/null @@ -1,659 +0,0 @@ -from __future__ import annotations - -import contextlib -import errno -import json -import socket -import time -import uuid -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from ..metadata import WIRE_PROTOCOL_VERSION -from ..paths import bridge_registry_paths, bridge_socket_filename, user_runtime_dir -from ..version import VERSION -from .common import normalize_timeout, pid_command_line, pid_is_live, recv_all, require_timeout_for_operation -from .schema import RequestEnvelope, response_ok - -TRANSIENT_SOCKET_ERRNOS = { - errno.ECONNREFUSED, - errno.ENOENT, -} - -_IGNORED_SHUTDOWN_ERRNOS = { - errno.ENOTCONN, -} - - -class StaleBridgeInstanceError(RuntimeError): - """Raised when a GUI bridge registry entry points at a dead instance.""" - - pass - - -def _format_discovery_warning(message: str, *, registry_path: Path | None = None) -> str: - """Attach registry context to a GUI discovery warning when available.""" - - if registry_path is None: - return message - return f"{message} [{registry_path}]" - - -def _append_discovery_warning( - warnings: list[str] | None, - message: str, - *, - registry_path: Path | None = None, -) -> None: - if warnings is not None: - warnings.append(_format_discovery_warning(message, registry_path=registry_path)) - - -@dataclass -class BridgeInstance: - """A single live GUI bridge instance discovered from the registry.""" - - pid: int - socket_path: Path - registry_path: Path - plugin_name: str - plugin_version: str - started_at: str | None = None - instance_id: str | None = None - state: str = "ready" - meta: dict[str, Any] = field(default_factory=dict) - - -class GuiBackend: - name = "gui" - - def send(self, request: RequestEnvelope) -> dict[str, Any]: - """Route a request to the correct GUI instance and merge warnings.""" - - timeout = normalize_timeout(request.timeout) - require_timeout_for_operation(request.op, timeout) - if request.op == "list_targets": - warnings: list[str] = [] - return response_ok( - list_targets( - timeout=timeout, - warnings=warnings, - require_matching_version=True, - ), - backend="gui", - warnings=warnings, - ) - warnings = [] - instance, normalized_target = choose_instance( - request.target, - timeout=timeout, - warnings=warnings, - ) - _ensure_instance_version_match(instance) - forwarded = RequestEnvelope( - op=request.op, - params=dict(request.params), - backend=request.backend, - target=normalized_target, - database=request.database, - timeout=timeout, - ) - response = _send_request_to_instance(instance, forwarded) - if warnings: - response = dict(response) - response["warnings"] = warnings + list(response.get("warnings") or []) - return response - - -def _list_targets_request(*, timeout: float | None = None) -> RequestEnvelope: - """Build the internal ``list_targets`` request used during discovery.""" - - return RequestEnvelope(op="list_targets", backend="gui", timeout=timeout) - - -def _bridge_status_request(*, timeout: float | None = None) -> RequestEnvelope: - """Build the internal ``bridge_status`` probe request used during discovery.""" - - return RequestEnvelope(op="bridge_status", backend="gui", timeout=timeout) - - -def _purge_stale_registry(registry_path: Path) -> None: - """Best-effort removal of a stale GUI bridge registry file.""" - - with contextlib.suppress(OSError): - registry_path.unlink() - - -def _is_expected_bridge_socket_path(*, pid: int, socket_path: Path) -> bool: - """Return whether ``socket_path`` is the expected runtime bridge socket for ``pid``.""" - - expected = (user_runtime_dir() / bridge_socket_filename(pid)).resolve(strict=False) - candidate = socket_path.expanduser().resolve(strict=False) - return candidate == expected - - -def _purge_stale_instance_files(*, registry_path: Path, socket_path: Path | None = None) -> None: - """Best-effort removal of stale GUI bridge registry/socket files.""" - - _purge_stale_registry(registry_path) - if socket_path is None: - return - pid_text = registry_path.stem.rsplit("-", 1)[-1] - try: - pid = int(pid_text) - except ValueError: - return - if not _is_expected_bridge_socket_path(pid=pid, socket_path=socket_path): - return - with contextlib.suppress(OSError): - socket_path.unlink() - - -def _pid_non_gui_bridge_reason(pid: int) -> str | None: - """Return a rejection reason when a live pid clearly is not an IDA GUI host.""" - - command_line = pid_command_line(pid) - if not command_line: - return None - if "idac.transport.idalib_server" in command_line: - return "process is running the idalib worker, not an IDA GUI session" - return None - - -def _load_instance( - path: Path, - *, - warnings: list[str] | None = None, -) -> BridgeInstance | None: - """Load one registry file, purging obviously stale entries on the way.""" - - try: - payload = json.loads(path.read_text(encoding="utf-8")) - socket_path = Path(payload["socket_path"]) - pid = int(payload["pid"]) - except (OSError, ValueError, KeyError, json.JSONDecodeError): - _append_discovery_warning( - warnings, - "ignored unreadable or malformed GUI bridge registry", - registry_path=path, - ) - return None - - if not socket_path.exists(): - _append_discovery_warning( - warnings, - f"purged stale GUI bridge registry for pid {pid}: missing socket {socket_path}", - registry_path=path, - ) - _purge_stale_registry(path) - return None - - if not pid_is_live(pid): - _append_discovery_warning( - warnings, - f"purged stale GUI bridge registry for pid {pid}: process is not running", - registry_path=path, - ) - _purge_stale_instance_files(registry_path=path, socket_path=socket_path) - return None - - non_gui_reason = _pid_non_gui_bridge_reason(pid) - if non_gui_reason is not None: - _append_discovery_warning( - warnings, - f"purged stale GUI bridge registry for pid {pid}: {non_gui_reason}", - registry_path=path, - ) - _purge_stale_instance_files(registry_path=path, socket_path=socket_path) - return None - - return BridgeInstance( - pid=pid, - socket_path=socket_path, - registry_path=path, - plugin_name=str(payload.get("plugin_name", "idac_bridge")), - plugin_version=str(payload.get("plugin_version", "0")), - started_at=payload.get("started_at"), - instance_id=None if payload.get("instance_id") in (None, "") else str(payload.get("instance_id")), - state=str(payload.get("state") or "ready"), - meta=payload, - ) - - -def _is_stale_instance(instance: BridgeInstance, *, error: OSError | None = None) -> bool: - """Classify a bridge instance as stale using both pid and socket state.""" - - socket_exists = instance.socket_path.exists() - pid_live = pid_is_live(instance.pid) - if error is not None and error.errno in TRANSIENT_SOCKET_ERRNOS and socket_exists and pid_live: - return False - return not socket_exists or not pid_live - - -def _ensure_instance_version_match(instance: BridgeInstance) -> None: - """Reject GUI bridge instances built from a different idac version.""" - - if instance.plugin_version == VERSION: - return - raise RuntimeError( - "IDA GUI bridge version mismatch for " - f"pid {instance.pid}: plugin={instance.plugin_version}, cli={VERSION}. " - "Reinstall the plugin so the versions match." - ) - - -def _validate_instance_status(instance: BridgeInstance, status: dict[str, Any]) -> BridgeInstance: - """Validate a live bridge status payload against the discovered registry.""" - - try: - status_pid = int(status["pid"]) - status_socket_path = Path(str(status["socket_path"])) - except (KeyError, TypeError, ValueError) as exc: - raise RuntimeError("bridge_status payload was missing required identity fields") from exc - - if status_pid != instance.pid: - raise StaleBridgeInstanceError( - f"GUI bridge pid changed during discovery: registry={instance.pid}, socket={status_pid}" - ) - if status_socket_path != instance.socket_path: - raise StaleBridgeInstanceError( - "GUI bridge socket path changed during discovery: " - f"registry={instance.socket_path}, socket={status_socket_path}" - ) - - status_instance_id_raw = status.get("instance_id") - status_instance_id = None if status_instance_id_raw in (None, "") else str(status_instance_id_raw) - if instance.instance_id is not None and status_instance_id != instance.instance_id: - raise StaleBridgeInstanceError( - "GUI bridge instance id changed during discovery: " - f"registry={instance.instance_id}, socket={status_instance_id}" - ) - - status_started_at_raw = status.get("started_at") - status_started_at = None if status_started_at_raw in (None, "") else str(status_started_at_raw) - if instance.started_at is not None and status_started_at is not None and status_started_at != instance.started_at: - raise StaleBridgeInstanceError( - "GUI bridge start time changed during discovery: " - f"registry={instance.started_at}, socket={status_started_at}" - ) - - return BridgeInstance( - pid=status_pid, - socket_path=status_socket_path, - registry_path=instance.registry_path, - plugin_name=str(status.get("plugin_name", instance.plugin_name)), - plugin_version=str(status.get("plugin_version", instance.plugin_version)), - started_at=status_started_at, - instance_id=status_instance_id, - state=str(status.get("state") or instance.state or "ready"), - meta={**instance.meta, **status}, - ) - - -def _probe_instance_status(instance: BridgeInstance, *, timeout: float | None) -> BridgeInstance: - """Probe a bridge instance over the socket and confirm its identity/state.""" - - response = _send_request_to_instance(instance, _bridge_status_request(timeout=timeout)) - if not response.get("ok"): - detail = str(response.get("error") or "bridge_status failed") - error_kind = str(response.get("error_kind") or "") - if error_kind == "startup_incomplete": - raise RuntimeError(f"IDA GUI bridge pid {instance.pid} is still starting") - if error_kind == "draining": - raise RuntimeError(f"IDA GUI bridge pid {instance.pid} is draining") - if "unknown operation" in detail and "bridge_status" in detail: - return instance - raise RuntimeError(f"IDA GUI bridge pid {instance.pid} failed bridge_status: {detail}") - result = response.get("result") - if not isinstance(result, dict): - raise RuntimeError(f"IDA GUI bridge pid {instance.pid} returned malformed bridge_status") - validated = _validate_instance_status(instance, result) - if validated.state != "ready": - raise RuntimeError(f"IDA GUI bridge pid {instance.pid} is not ready: state={validated.state}") - return validated - - -def _discovered_instances( - *, - warnings: list[str] | None = None, -) -> list[BridgeInstance]: - """Return GUI bridge instances discovered from registry files only.""" - - instances: list[BridgeInstance] = [] - for registry in bridge_registry_paths(): - instance = _load_instance(registry, warnings=warnings) - if instance is not None: - instances.append(instance) - return instances - - -def _probe_discovered_instance( - instance: BridgeInstance, - *, - timeout: float | None = None, - warnings: list[str] | None = None, -) -> BridgeInstance | None: - """Validate one registry-discovered bridge instance over the socket.""" - - try: - return _probe_instance_status(instance, timeout=timeout) - except StaleBridgeInstanceError: - _append_discovery_warning( - warnings, - f"purged stale GUI bridge registry for pid {instance.pid}: bridge identity changed", - registry_path=instance.registry_path, - ) - _purge_stale_registry(instance.registry_path) - return None - except RuntimeError as exc: - _append_discovery_warning( - warnings, - str(exc), - registry_path=instance.registry_path, - ) - return None - - -def _live_instances( - *, - timeout: float | None = None, - warnings: list[str] | None = None, -) -> list[BridgeInstance]: - """Return registry-discovered instances that also pass live bridge probing.""" - - instances: list[BridgeInstance] = [] - for instance in _discovered_instances(warnings=warnings): - validated = _probe_discovered_instance(instance, timeout=timeout, warnings=warnings) - if validated is not None: - instances.append(validated) - return instances - - -def list_instances( - *, - timeout: float | None = None, - warnings: list[str] | None = None, -) -> list[BridgeInstance]: - """Return all currently discoverable live GUI bridge instances.""" - - return _live_instances(timeout=timeout, warnings=warnings) - - -def list_discovered_instances( - *, - warnings: list[str] | None = None, -) -> list[BridgeInstance]: - """Return registry-discovered GUI bridge instances before socket probing.""" - - return _discovered_instances(warnings=warnings) - - -def _instance_selector(instance: BridgeInstance) -> str: - """Return the canonical selector string for a bridge instance.""" - - return f"pid:{instance.pid}" - - -def _normalize_target_row(instance: BridgeInstance, item: dict[str, Any]) -> dict[str, Any]: - """Expand a per-instance target row into the CLI's global target shape.""" - - local_target_id = str(item.get("target_id") or "active") - local_selector = str(item.get("selector") or local_target_id) - instance_selector = _instance_selector(instance) - return { - **item, - "backend": "gui", - "local_target_id": local_target_id, - "local_selector": local_selector, - "target_id": f"{instance.pid}:{local_target_id}", - "selector": instance_selector, - "instance_pid": instance.pid, - "instance_selector": instance_selector, - } - - -def _pid_selector_match(instance: BridgeInstance, selector_text: str) -> str | None: - """Match selectors that name an instance without naming a subtarget.""" - - if selector_text in {str(instance.pid), _instance_selector(instance)}: - return "active" - return None - - -def _target_aliases(item: dict[str, Any]) -> set[str]: - """Return all textual aliases that may identify one GUI target row.""" - - return { - str(item.get("target_id") or ""), - str(item.get("selector") or ""), - str(item.get("local_selector") or ""), - str(item.get("filename") or ""), - str(item.get("module") or ""), - str(item.get("instance_selector") or ""), - } - - -def _select_instance_target( - instance: BridgeInstance, - selector_text: str, - *, - timeout: float | None = None, - warnings: list[str] | None = None, -) -> str | None: - """Return the local target id selected within a single GUI instance.""" - - pid_match = _pid_selector_match(instance, selector_text) - if pid_match is not None: - return pid_match - for item in _instance_target_rows(instance, timeout=timeout, warnings=warnings): - if selector_text in _target_aliases(item): - return str(item.get("local_target_id") or "active") - return None - - -def _instance_target_rows( - instance: BridgeInstance, - *, - timeout: float | None = None, - warnings: list[str] | None = None, -) -> list[dict[str, Any]]: - """Fetch and normalize ``list_targets`` rows for one GUI instance.""" - - try: - response = _send_request_to_instance(instance, _list_targets_request(timeout=timeout)) - except StaleBridgeInstanceError: - _append_discovery_warning( - warnings, - f"purged stale GUI bridge registry for pid {instance.pid}: bridge socket stopped responding", - registry_path=instance.registry_path, - ) - _purge_stale_registry(instance.registry_path) - return [] - if not response.get("ok"): - detail = str(response.get("error") or "request failed") - raise RuntimeError(f"IDA GUI bridge pid {instance.pid} failed list_targets: {detail}") - result = response.get("result") - if not isinstance(result, list) or any(not isinstance(item, dict) for item in result): - raise RuntimeError(f"IDA GUI bridge pid {instance.pid} returned malformed list_targets") - return [_normalize_target_row(instance, item) for item in result] - - -def _selection_error(message: str, *, warnings: list[str] | None = None) -> RuntimeError: - detail_parts = [f"runtime_dir={user_runtime_dir()}"] - if warnings: - detail_parts.append("diagnostics=" + " | ".join(warnings[:3])) - return RuntimeError(f"{message}. " + "; ".join(detail_parts)) - - -def list_targets( - *, - timeout: float | None = None, - warnings: list[str] | None = None, - require_matching_version: bool = False, -) -> list[dict[str, Any]]: - """List all GUI targets currently exposed across running bridge instances.""" - - rows: list[dict[str, Any]] = [] - for instance in list_instances(timeout=timeout, warnings=warnings): - if require_matching_version: - _ensure_instance_version_match(instance) - rows.extend(_instance_target_rows(instance, timeout=timeout, warnings=warnings)) - rows.sort(key=lambda item: (str(item.get("module") or ""), int(item.get("instance_pid") or 0))) - return rows - - -def _explicit_instance_match(instance: BridgeInstance, selector_text: str) -> bool: - """Return whether ``selector_text`` explicitly identifies ``instance``.""" - - if _pid_selector_match(instance, selector_text) is not None: - return True - pid_text, sep, _rest = selector_text.partition(":") - return bool(sep and pid_text == str(instance.pid)) - - -def choose_instance( - selector: str | None, - *, - timeout: float | None = None, - warnings: list[str] | None = None, -) -> tuple[BridgeInstance, str | None]: - """Resolve a user selector to a specific GUI bridge instance and target.""" - - discovered = _discovered_instances(warnings=warnings) - if not discovered: - raise _selection_error("No running IDA GUI bridge instances found", warnings=warnings) - if selector in (None, ""): - instances = _live_instances(timeout=timeout, warnings=warnings) - if not instances: - raise _selection_error("No running IDA GUI bridge instances found", warnings=warnings) - if len(instances) == 1: - return instances[0], None - raise RuntimeError("Multiple running IDA GUI bridge instances found; pass -c pid:") - - selector_text = str(selector).strip() - explicit_matches = [instance for instance in discovered if _explicit_instance_match(instance, selector_text)] - if len(explicit_matches) == 1: - validated = _probe_discovered_instance(explicit_matches[0], timeout=timeout, warnings=warnings) - if validated is None: - raise _selection_error( - f"No running IDA GUI target matched {selector_text!r}", - warnings=warnings, - ) - local_target_id = _select_instance_target( - validated, - selector_text, - timeout=timeout, - warnings=warnings, - ) - if local_target_id is None: - raise _selection_error( - f"No running IDA GUI target matched {selector_text!r}", - warnings=warnings, - ) - return validated, local_target_id - - instances = _live_instances(timeout=timeout, warnings=warnings) - if not instances: - raise _selection_error("No running IDA GUI bridge instances found", warnings=warnings) - matches: list[tuple[BridgeInstance, str]] = [] - for instance in instances: - local_target_id = _select_instance_target( - instance, - selector_text, - timeout=timeout, - warnings=warnings, - ) - if local_target_id is not None: - matches.append((instance, local_target_id)) - - if not matches: - raise _selection_error( - f"No running IDA GUI target matched {selector_text!r}", - warnings=warnings, - ) - if len(matches) > 1: - raise RuntimeError( - f"Target selector {selector_text!r} is ambiguous; use a more specific selector such as pid:" - ) - return matches[0] - - -def _request_payload(request: RequestEnvelope) -> dict[str, Any]: - """Encode a request envelope into the wire payload sent to the plugin.""" - - payload = { - "version": WIRE_PROTOCOL_VERSION, - "id": str(uuid.uuid4()), - "op": request.op, - "params": request.params, - } - if request.target is not None: - payload["target"] = request.target - return payload - - -def _socket_request( - socket_path: Path, - encoded: bytes, - *, - timeout: float | None, -) -> list[bytes]: - """Perform one request/response round-trip over the GUI bridge socket.""" - - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: - if timeout is not None: - sock.settimeout(timeout) - sock.connect(str(socket_path)) - sock.sendall(encoded) - try: - sock.shutdown(socket.SHUT_WR) - except OSError as exc: - if exc.errno not in _IGNORED_SHUTDOWN_ERRNOS: - raise - return recv_all(sock) - - -def _decode_response(chunks: list[bytes]) -> dict[str, Any]: - """Decode a JSON response from raw socket chunks.""" - - if not chunks: - raise RuntimeError("IDA GUI bridge returned an empty response") - try: - response = json.loads(b"".join(chunks).decode("utf-8")) - except json.JSONDecodeError as exc: - raise RuntimeError("IDA GUI bridge returned malformed JSON") from exc - if not isinstance(response, dict): - raise RuntimeError("IDA GUI bridge returned a malformed response") - return response - - -def _send_request_to_instance( - instance: BridgeInstance, - request: RequestEnvelope, - *, - connect_retries: int = 4, -) -> dict[str, Any]: - """Send a request to one GUI instance with transient socket retries.""" - - encoded = (json.dumps(_request_payload(request)) + "\n").encode("utf-8") - chunks: list[bytes] = [] - last_error: OSError | None = None - timeout = normalize_timeout(request.timeout) - for attempt in range(connect_retries): - try: - chunks = _socket_request(instance.socket_path, encoded, timeout=timeout) - break - except OSError as exc: - last_error = exc - if exc.errno not in TRANSIENT_SOCKET_ERRNOS or attempt == connect_retries - 1: - break - time.sleep(0.05 * (attempt + 1)) - - if last_error is not None and not chunks: - message = f"Failed to contact IDA GUI bridge pid {instance.pid} at {instance.socket_path}: {last_error}" - if _is_stale_instance(instance, error=last_error): - raise StaleBridgeInstanceError(message) from last_error - raise RuntimeError(message) from last_error - - return _decode_response(chunks) diff --git a/src/idac/transport/idalib.py b/src/idac/transport/idalib.py deleted file mode 100644 index 806996b..0000000 --- a/src/idac/transport/idalib.py +++ /dev/null @@ -1,523 +0,0 @@ -from __future__ import annotations - -import contextlib -import json -import os -import selectors -import socket -import subprocess -import sys -import tempfile -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from ..metadata import WIRE_PROTOCOL_VERSION -from ..paths import ( - ensure_user_runtime_dir, - idalib_open_lock_path, - idalib_registry_path, - idalib_registry_paths, -) -from .common import normalize_timeout, pid_command_line, pid_is_live, recv_all, require_timeout_for_operation -from .idalib_common import build_target_row, load_registry, normalize_database_path -from .schema import RequestEnvelope, response_ok - -IDALIB_CONNECT_RETRIES = 3 -IDALIB_READY_MAX_BYTES = 65_536 -IDALIB_STARTUP_ATTEMPTS = 2 -# A liveness probe is a trivial status check that a healthy daemon answers in -# milliseconds. Cap how long it waits so a blocking-mode command (no --timeout) -# never hangs indefinitely on the probe: a daemon that accepts the connection -# but does not reply within this window is treated as busy/reachable, and the -# real request then honors the caller's own (possibly blocking) timeout. -IDALIB_PROBE_TIMEOUT_CAP = 5.0 -IDALIB_STARTUP_HINT = ( - "idalib exited before reporting readiness; this usually means IDA failed during startup, " - "license validation, or Python initialization. Run `idac doctor` to check the local IDA/idalib runtime." -) - - -@dataclass(frozen=True) -class IdaLibInstance: - pid: int - socket_path: Path - registry_path: Path - database_path: str - started_at: str | None - meta: dict[str, Any] - - -def _timeout_text(timeout: float | None) -> str: - return "blocking mode" if timeout is None else f"{timeout:g}s" - - -def _timeout_error(op: str, timeout: float | None) -> RuntimeError: - return RuntimeError(f"idalib request timed out after {_timeout_text(timeout)}: {op}") - - -def _purge_instance_files( - *, - registry_path: Path | None = None, - socket_path: Path | None = None, -) -> None: - if registry_path is not None: - with contextlib.suppress(FileNotFoundError): - registry_path.unlink() - if socket_path is not None: - with contextlib.suppress(FileNotFoundError): - socket_path.unlink() - - -def _instance_from_registry(path: Path) -> IdaLibInstance | None: - payload = load_registry(path) - if payload is None: - _purge_instance_files(registry_path=path) - return None - try: - pid = int(payload["pid"]) - socket_path = Path(str(payload["socket_path"])) - database_path = normalize_database_path(str(payload["database_path"])) - except (KeyError, TypeError, ValueError): - _purge_instance_files(registry_path=path) - return None - if not pid_is_live(pid) or not socket_path.exists(): - _purge_instance_files(registry_path=path, socket_path=socket_path) - return None - started_at = payload.get("started_at") - return IdaLibInstance( - pid=pid, - socket_path=socket_path, - registry_path=path, - database_path=database_path, - started_at=None if started_at in (None, "") else str(started_at), - meta=dict(payload), - ) - - -def list_instances() -> list[IdaLibInstance]: - """List live idalib daemon instances with stale registry files purged.""" - - ensure_user_runtime_dir() - rows: list[IdaLibInstance] = [] - for registry_path in idalib_registry_paths(): - instance = _instance_from_registry(registry_path) - if instance is not None: - rows.append(instance) - return rows - - -def _find_instance_for_database(database_path: str) -> IdaLibInstance | None: - requested = normalize_database_path(database_path) - for instance in list_instances(): - if instance.database_path == requested: - return instance - return None - - -def _has_registry_for_database(database_path: str) -> bool: - """Return whether any registry file references this database, without purging. - - A registry that survives while no live daemon answers indicates the daemon - died without a clean close (a clean close removes its registry), which is how - ``db_close`` detects that unsaved changes may have been lost. - """ - - requested = normalize_database_path(database_path) - for registry_path in idalib_registry_paths(): - payload = load_registry(registry_path) - if payload is None: - continue - try: - candidate = normalize_database_path(str(payload["database_path"])) - except (KeyError, TypeError, ValueError): - continue - if candidate == requested: - return True - return False - - -def _socket_request( - socket_path: Path, - payload: dict[str, Any], - *, - timeout: float | None, -) -> dict[str, Any]: - encoded = json.dumps(payload).encode("utf-8") - last_error: OSError | None = None - for _ in range(IDALIB_CONNECT_RETRIES): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - if timeout is not None: - sock.settimeout(timeout) - try: - sock.connect(str(socket_path)) - except TimeoutError: - raise - except OSError as exc: - # Only connect-phase failures are retried; nothing has been - # sent yet, so a retry cannot re-execute the operation. - last_error = exc - time.sleep(0.05) - continue - try: - sock.sendall(encoded) - sock.shutdown(socket.SHUT_WR) - chunks = recv_all(sock) - except TimeoutError: - raise - except OSError as exc: - # The request may already be executing in the daemon; - # re-sending it could run a mutation twice. - raise RuntimeError(str(exc) or exc.__class__.__name__) from exc - if not chunks: - raise RuntimeError("idalib daemon returned an empty response") - try: - response = json.loads(b"".join(chunks).decode("utf-8")) - except ValueError as exc: - # Partial/corrupt payload, e.g. the daemon died mid-write. - raise RuntimeError(f"idalib daemon returned a malformed response: {exc}") from exc - if not isinstance(response, dict): - raise RuntimeError("idalib daemon returned a non-object JSON payload") - return response - finally: - sock.close() - detail = "idalib daemon is not running" if last_error is None else str(last_error) - raise RuntimeError(detail) - - -def _live_pid_is_foreign(pid: int) -> bool: - """Return whether a live pid is provably not an idalib worker process. - - Used to distinguish a genuinely busy daemon (keep its files) from a stale - registry whose pid was recycled by an unrelated process (purge and restart). - A pid whose command line cannot be read is treated as possibly-ours, so its - files are never purged on that uncertainty. - """ - - command_line = pid_command_line(pid) - return command_line is not None and "idac.transport.idalib_server" not in command_line - - -def _probe_timeout(timeout: float | None) -> float: - if timeout is None: - return IDALIB_PROBE_TIMEOUT_CAP - return min(timeout, IDALIB_PROBE_TIMEOUT_CAP) - - -def _probe_instance( - instance: IdaLibInstance, - *, - timeout: float | None, -) -> bool: - try: - response = _socket_request( - instance.socket_path, - {"version": WIRE_PROTOCOL_VERSION, "op": "daemon_status", "params": {}}, - timeout=_probe_timeout(timeout), - ) - except TimeoutError: - # The daemon accepted the connection but did not reply within the probe - # cap: it is alive but busy (or wedged). Treat it as reachable and never - # purge it - the real request that follows honors the caller's own - # timeout, so a blocking-mode command still waits for a busy daemon. - return True - except RuntimeError as exc: - if pid_is_live(instance.pid) and not _live_pid_is_foreign(instance.pid): - # A genuine idalib worker is alive but its socket refused the - # connection: it is shutting down or wedged. Never purge it, so a - # second daemon cannot open the same database. - raise RuntimeError( - f"idalib daemon (pid {instance.pid}) for `{instance.database_path}` " - f"is not answering on its socket ({exc}); it may be shutting down - retry shortly" - ) from exc - # The process is gone, or the pid was recycled by an unrelated process: - # purge the stale files so a fresh daemon can start. - _purge_instance_files( - registry_path=instance.registry_path, - socket_path=instance.socket_path, - ) - return False - return bool(response.get("ok")) - - -def _build_target_row(instance: IdaLibInstance) -> dict[str, Any]: - return build_target_row( - pid=instance.pid, - database_path=instance.database_path, - socket_path=instance.socket_path, - ) - - -def _terminate_process(proc: subprocess.Popen[str]) -> None: - with contextlib.suppress(ProcessLookupError): - proc.terminate() - try: - proc.wait(timeout=5.0) - except subprocess.TimeoutExpired: - with contextlib.suppress(ProcessLookupError): - proc.kill() - with contextlib.suppress(subprocess.TimeoutExpired): - proc.wait(timeout=5.0) - - -def _format_startup_failure(database_path: str, detail: str | None = None) -> str: - prefix = f"idalib daemon failed to start for `{database_path}`" - clean_detail = "" if detail is None else detail.strip() - if clean_detail: - return f"{prefix}: {clean_detail}" - return f"{prefix}: {IDALIB_STARTUP_HINT}" - - -def _read_ready_payload(read_fd: int, *, timeout: float | None) -> dict[str, Any]: - try: - with selectors.DefaultSelector() as selector: - selector.register(read_fd, selectors.EVENT_READ) - events = selector.select(timeout) - if not events: - raise TimeoutError() - raw = os.read(read_fd, IDALIB_READY_MAX_BYTES + 1) - finally: - with contextlib.suppress(OSError): - os.close(read_fd) - - if not raw: - raise EOFError("idalib daemon exited before reporting readiness") - if len(raw) > IDALIB_READY_MAX_BYTES: - raise RuntimeError("idalib daemon readiness payload is too large") - raw = raw.split(b"\n", 1)[0].strip() - payload = json.loads(raw.decode("utf-8")) - if not isinstance(payload, dict): - raise RuntimeError("idalib daemon returned a non-object readiness payload") - return payload - - -def _start_daemon_for_database( - database_path: str, - *, - startup_timeout: float | None, - run_auto_analysis: bool, -) -> IdaLibInstance: - ensure_user_runtime_dir() - base_cmd = [ - sys.executable, - "-m", - "idac.transport.idalib_server", - "--database", - database_path, - ] - if not run_auto_analysis: - base_cmd.append("--no-auto-analysis") - - for attempt in range(IDALIB_STARTUP_ATTEMPTS): - read_fd, write_fd = os.pipe() - os.set_inheritable(write_fd, True) - cmd = [*base_cmd, "--ready-fd", str(write_fd)] - with tempfile.TemporaryFile("w+", encoding="utf-8") as stderr_log: - try: - proc = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=stderr_log, - text=True, - start_new_session=True, - pass_fds=(write_fd,), - ) - except Exception: - with contextlib.suppress(OSError): - os.close(read_fd) - raise - finally: - with contextlib.suppress(OSError): - os.close(write_fd) - - expected_registry = idalib_registry_path(proc.pid) - try: - payload = _read_ready_payload(read_fd, timeout=startup_timeout) - except TimeoutError as exc: - _terminate_process(proc) - raise RuntimeError( - f"timed out after {_timeout_text(startup_timeout)} waiting for idalib daemon " - f"to start for `{database_path}`" - ) from exc - except EOFError as exc: - try: - proc.wait(timeout=0.5) - except subprocess.TimeoutExpired: - _terminate_process(proc) - stderr_log.seek(0) - detail = stderr_log.read().strip() - if detail: - raise RuntimeError(_format_startup_failure(database_path, detail)) from exc - if attempt + 1 < IDALIB_STARTUP_ATTEMPTS: - continue - raise RuntimeError(_format_startup_failure(database_path)) from exc - except Exception as exc: - _terminate_process(proc) - raise RuntimeError(_format_startup_failure(database_path, str(exc))) from exc - except BaseException: - # KeyboardInterrupt during the (possibly minutes-long) readiness - # wait must still reap the daemon, or it lingers holding the - # database lock after the CLI exits. - _terminate_process(proc) - raise - try: - if not bool(payload.get("ok")): - raise RuntimeError(_format_startup_failure(database_path, str(payload.get("error") or ""))) - instance = _instance_from_registry(expected_registry) - if instance is None: - raise RuntimeError("idalib daemon reported readiness but registry was unavailable") - if instance.database_path != database_path: - raise RuntimeError( - f"idalib daemon opened `{instance.database_path}` while `{database_path}` was requested" - ) - except BaseException: - # The daemon is running but failed validation; without this it - # would survive as an orphan holding the database lock. - _terminate_process(proc) - raise - return instance - - raise RuntimeError(_format_startup_failure(database_path)) - - -@contextlib.contextmanager -def _database_start_lock(database_path: str): - """Serialize daemon starts for one database across concurrent idac processes. - - Without this, two processes that both find no instance would each spawn a - daemon on the same database; the loser fails with `rc=4 (database busy or - locked)`. The advisory `flock` is released when the fd closes, including on - process death, so it cannot go stale. - """ - - import fcntl # Unix-only, imported lazily so non-idalib platforms can import this module. - - ensure_user_runtime_dir() - fd = os.open(str(idalib_open_lock_path(database_path)), os.O_CREAT | os.O_RDWR, 0o600) - try: - fcntl.flock(fd, fcntl.LOCK_EX) - yield - finally: - # Do not unlink the lock file: another waiter may hold this inode, and a - # fresh file would defeat the lock. The tiny per-database file is reused. - os.close(fd) - - -def _ensure_instance_for_database( - database_path: str, - *, - timeout: float | None, - run_auto_analysis: bool, - start_if_missing: bool, -) -> tuple[IdaLibInstance, bool]: - normalized = normalize_database_path(database_path) - instance = _find_instance_for_database(normalized) - if instance is not None and _probe_instance(instance, timeout=timeout): - return instance, True - if not start_if_missing: - raise RuntimeError( - f"idalib database is not open: {normalized}; use `idac database open {shlex_quote(normalized)}`" - ) - with _database_start_lock(normalized): - # Re-check under the lock: another process may have started the daemon - # while we waited to acquire it. - instance = _find_instance_for_database(normalized) - if instance is not None and _probe_instance(instance, timeout=timeout): - return instance, True - return ( - _start_daemon_for_database( - normalized, - startup_timeout=timeout, - run_auto_analysis=run_auto_analysis, - ), - False, - ) - - -def _already_closed_result(database: str) -> dict[str, Any]: - return { - "closed": False, - "database": normalize_database_path(database), - "already_closed": True, - } - - -def shlex_quote(value: str) -> str: - if value and all(ch.isalnum() or ch in "._/-" for ch in value): - return value - return "'" + value.replace("'", "'\"'\"'") + "'" - - -class IdaLibBackend: - name = "idalib" - - def send(self, request: RequestEnvelope) -> dict[str, Any]: - timeout = normalize_timeout(request.timeout) - require_timeout_for_operation(request.op, timeout) - if request.op == "list_targets": - return response_ok( - [_build_target_row(instance) for instance in list_instances()], - backend="idalib", - ) - - if request.op == "db_open": - raw_path = str(request.params.get("path") or "").strip() - if not raw_path: - raise RuntimeError("database open requires a path") - instance, already_open = _ensure_instance_for_database( - raw_path, - timeout=timeout, - run_auto_analysis=bool(request.params.get("run_auto_analysis", True)), - start_if_missing=True, - ) - return response_ok( - { - "opened": True, - "database": instance.database_path, - "already_open": already_open, - "pid": instance.pid, - "socket_path": str(instance.socket_path), - }, - backend="idalib", - ) - - database = str(request.database or "").strip() - if not database: - raise RuntimeError("idalib commands require a database context") - - if request.op == "db_close": - had_registry = _has_registry_for_database(database) - instance = _find_instance_for_database(database) - if instance is None or not _probe_instance(instance, timeout=timeout): - result = _already_closed_result(database) - warnings: list[str] = [] - if had_registry: - # A registry existed but no live daemon answered: the daemon - # died without a clean close, so anything not persisted by an - # explicit save is gone. - result["unclean"] = True - warnings.append( - f"idalib daemon for `{result['database']}` exited without a clean close; " - "changes not persisted by an explicit `database save` may have been lost" - ) - return response_ok(result, backend="idalib", warnings=warnings) - else: - instance, _ = _ensure_instance_for_database( - database, - timeout=timeout, - run_auto_analysis=True, - start_if_missing=True, - ) - - payload = { - "version": WIRE_PROTOCOL_VERSION, - "op": request.op, - "params": request.params, - } - try: - return _socket_request(instance.socket_path, payload, timeout=timeout) - except TimeoutError as exc: - raise _timeout_error(request.op, timeout) from exc diff --git a/src/idac/transport/idalib_common.py b/src/idac/transport/idalib_common.py deleted file mode 100644 index f093ac4..0000000 --- a/src/idac/transport/idalib_common.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -import json -import os -import sys -from itertools import chain -from pathlib import Path -from typing import Any - -from idac.paths import hcli_configured_install_dir, ida_configured_install_dir - -IDAPRO_IMPORT_ERRORS = (ImportError, ModuleNotFoundError, OSError, RuntimeError, ValueError) - - -class WorkerError(RuntimeError): - """Raised when the headless idalib worker cannot start cleanly.""" - - pass - - -def _dedupe_paths(paths: list[Path]) -> list[Path]: - return list(dict.fromkeys(paths)) - - -def default_ida_install_dirs() -> list[Path]: - """Return platform-specific default directories that may contain IDA.""" - - candidates: list[Path] = [] - if sys.platform == "darwin": - candidates.extend(sorted(Path("/Applications").glob("IDA Professional*.app/Contents/MacOS"), reverse=True)) - elif sys.platform.startswith("linux"): - for pattern in ("ida*", "IDA*"): - candidates.extend(sorted(Path("/opt").glob(pattern), reverse=True)) - candidates.extend(sorted(Path.home().glob(pattern), reverse=True)) - elif os.name == "nt": - for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"): - root = os.environ.get(env_name) - if not root: - continue - candidates.extend(sorted(Path(root).glob("IDA*"), reverse=True)) - return candidates - - -def candidate_ida_dirs() -> list[Path]: - """Return deduplicated IDA install candidates, preferring explicit configuration.""" - - explicit = [ - Path(raw).expanduser() for raw in (os.environ.get("IDAC_IDA_INSTALL_DIR"), os.environ.get("IDADIR")) if raw - ] - configured_candidates = ( - candidate - for read_configured_dir in (hcli_configured_install_dir, ida_configured_install_dir) - for candidate in (read_configured_dir(),) - if candidate is not None - ) - candidates = [] - for candidate in chain(explicit, configured_candidates, default_ida_install_dirs()): - expanded = candidate.expanduser() - if sys.platform == "darwin" and expanded.suffix == ".app": - expanded = expanded / "Contents" / "MacOS" - candidates.append(expanded) - return _dedupe_paths(candidates) - - -def normalize_database_path(path: str) -> str: - return str(Path(path).expanduser().resolve(strict=False)) - - -def load_registry(path: Path) -> dict[str, Any] | None: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - return payload if isinstance(payload, dict) else None - - -def build_target_row(*, pid: int, database_path: str, socket_path: str | Path) -> dict[str, Any]: - db = Path(database_path) - return { - "backend": "idalib", - "target_id": str(pid), - "selector": db.name, - "filename": database_path, - "module": db.stem, - "active": True, - "instance_pid": pid, - "socket_path": str(socket_path), - } - - -def _import_idapro() -> Any: - import idapro # type: ignore - - return idapro - - -def bootstrap_idapro(): - """Import ``idapro``, searching common install locations when needed.""" - - try: - return _import_idapro() - except IDAPRO_IMPORT_ERRORS: - pass - - last_error: Exception | None = None - for ida_dir in candidate_ida_dirs(): - python_dir = ida_dir / "idalib" / "python" - if not python_dir.exists(): - continue - # IDA's Python modules are not importable until its bundled site-packages - # directory is injected into ``sys.path``. - os.environ.setdefault("IDADIR", str(ida_dir)) - python_dir_text = str(python_dir) - if python_dir_text not in sys.path: - sys.path.insert(0, python_dir_text) - try: - return _import_idapro() - except IDAPRO_IMPORT_ERRORS as exc: - last_error = exc - - if last_error is not None: - raise WorkerError(f"failed to import idapro: {last_error}") from last_error - raise WorkerError("failed to import idapro: no usable IDA installation found") diff --git a/src/idac/transport/idalib_server.py b/src/idac/transport/idalib_server.py deleted file mode 100644 index 0110886..0000000 --- a/src/idac/transport/idalib_server.py +++ /dev/null @@ -1,327 +0,0 @@ -from __future__ import annotations - -import argparse -import contextlib -import json -import os -import socketserver -import sys -import threading -import time -import traceback -from pathlib import Path -from typing import Any, cast - -from ..metadata import WIRE_PROTOCOL_VERSION, idalib_registry_payload -from ..ops.dispatch import build_operation_registry -from ..ops.runtime import IdaOperationError, IdaRuntime -from ..paths import ( - ensure_user_runtime_dir, - idalib_registry_path, - idalib_registry_paths, - idalib_socket_path, -) -from .common import atomic_write_json, pid_is_live, read_request_bytes -from .idalib_common import ( - WorkerError, - bootstrap_idapro, - build_target_row, - load_registry, - normalize_database_path, -) -from .schema import response_error, response_ok - -OPEN_DATABASE_AUTO_ANALYSIS = True -REQUEST_MAX_BYTES = 1_048_576 -REQUEST_READ_TIMEOUT = 300.0 - - -def _idalib_log(message: str, *, exc: BaseException | None = None) -> None: - lines = [f"[idac-idalib] {message}\n"] - if exc is not None: - lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) - sys.stderr.write("".join(lines)) - - -def _format_open_error(path: str, rc: int) -> str: - if rc == 4: - return f"failed to open database `{path}`: rc=4 (database busy or locked)" - return f"failed to open database `{path}`: rc={rc}" - - -def _write_ready(ready_fd: int | None, payload: dict[str, Any]) -> None: - if ready_fd is None: - return - try: - os.write(ready_fd, (json.dumps(payload) + "\n").encode("utf-8")) - except OSError: - pass - finally: - with contextlib.suppress(OSError): - os.close(ready_fd) - - -def _other_live_instance_for_database(database_path: str) -> dict[str, Any] | None: - normalized = normalize_database_path(database_path) - for registry_path in idalib_registry_paths(): - payload = load_registry(registry_path) - if payload is None: - continue - try: - pid = int(payload["pid"]) - candidate_path = normalize_database_path(str(payload["database_path"])) - except (KeyError, TypeError, ValueError): - continue - if pid == os.getpid(): - continue - if candidate_path != normalized: - continue - socket_path = Path(str(payload.get("socket_path") or "")) - if pid_is_live(pid) and socket_path.exists(): - return payload - return None - - -def _parse_request(payload: Any) -> tuple[str, dict[str, Any]]: - if isinstance(payload, bytes): - payload = payload.decode("utf-8") - if isinstance(payload, str): - payload = json.loads(payload) - if not isinstance(payload, dict): - raise WorkerError(f"unsupported payload type: {type(payload).__name__}") - version = payload.get("version") - if version != WIRE_PROTOCOL_VERSION: - raise WorkerError(f"unsupported protocol version: expected {WIRE_PROTOCOL_VERSION}, got {version!r}") - op = str(payload.get("op") or "").strip() - if not op: - raise WorkerError("idalib backend requires an operation name") - params = payload.get("params") or {} - if not isinstance(params, dict): - raise WorkerError("params must be a JSON object") - return op, dict(params) - - -class IdaLibService: - def __init__(self, *, database_path: str, run_auto_analysis: bool) -> None: - self.idapro = bootstrap_idapro() - self.database_path = normalize_database_path(database_path) - self.run_auto_analysis = run_auto_analysis - self.python_scope: dict[str, Any] = {} - self._open = False - self.exit_requested = False - self._started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - self._open_database() - - def _open_database(self) -> None: - open_rc = self.idapro.open_database(self.database_path, self.run_auto_analysis) - if open_rc != 0: - raise WorkerError(_format_open_error(self.database_path, open_rc)) - self._open = True - - def close_runtime(self, *, save: bool) -> None: - if not self._open: - return - self.idapro.close_database(save) - self._open = False - - def list_targets(self) -> list[dict[str, Any]]: - return [ - build_target_row( - pid=os.getpid(), - database_path=self.database_path, - socket_path=idalib_socket_path(os.getpid()), - ) - ] - - def _write_registry(self) -> None: - destination = idalib_registry_path(os.getpid()) - payload = idalib_registry_payload( - pid=os.getpid(), - socket_path=str(idalib_socket_path(os.getpid())), - started_at=self._started_at, - database_path=self.database_path, - ) - atomic_write_json(destination, payload) - - def _build_registry(self) -> dict[str, Any]: - runtime = IdaRuntime( - database_path=self.database_path, - python_scope=self.python_scope, - ) - return build_operation_registry(runtime) - - def _validate_db_save(self, params: dict[str, Any]) -> str | None: - raw_path = str(params.get("path") or "").strip() - if not raw_path: - return None - normalized = normalize_database_path(raw_path) - other = _other_live_instance_for_database(normalized) - if other is not None: - raise WorkerError(f"another idalib daemon already has `{normalized}` open") - return normalized - - def _handle_db_close(self, params: dict[str, Any]) -> dict[str, Any]: - save = not bool(params.get("discard")) - current_path = self.database_path - self.close_runtime(save=save) - self.exit_requested = True - return {"closed": True, "database": current_path, "saved": save} - - def _dispatch_builtin(self, op: str, params: dict[str, Any]) -> dict[str, Any] | None: - if op == "daemon_status": - return response_ok( - { - "running": True, - "database_path": self.database_path, - "pid": os.getpid(), - }, - backend="idalib", - ) - if self.exit_requested: - return response_error("idalib daemon is shutting down", backend="idalib") - if op == "list_targets": - return response_ok(self.list_targets(), backend="idalib") - if op == "db_close": - return response_ok(self._handle_db_close(params), backend="idalib") - return None - - def _dispatch(self, payload: Any) -> dict[str, Any]: - op = "" - try: - op, params = _parse_request(payload) - builtin_response = self._dispatch_builtin(op, params) - if builtin_response is not None: - return builtin_response - - registry = self._build_registry() - if op not in registry: - return response_error(f"unsupported idalib operation: {op}", backend="idalib") - - requested_save_path = None - if op == "db_save": - requested_save_path = self._validate_db_save(params) - - result = registry[op](params) - - if op == "db_save": - saved_path = requested_save_path or str(result.get("path") or "").strip() - if saved_path: - self.database_path = normalize_database_path(saved_path) - self._write_registry() - - return response_ok(result, backend="idalib") - except (WorkerError, IdaOperationError) as exc: - return response_error(str(exc), backend="idalib") - except Exception as exc: - _idalib_log(f"unexpected server failure while handling `{op}`", exc=exc) - return response_error(f"unexpected idalib server failure: {exc}", backend="idalib") - - -class _IdaLibRequestHandler(socketserver.StreamRequestHandler): - def _write_response(self, response: dict[str, Any]) -> None: - with contextlib.suppress(BrokenPipeError, OSError): - self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) - - def _read_request(self) -> bytes: - return read_request_bytes( - self.connection, - timeout=REQUEST_READ_TIMEOUT, - max_bytes=REQUEST_MAX_BYTES, - ) - - def handle(self) -> None: - service = cast("_UnixIdaLibServer", self.server).service - try: - raw = self._read_request() - except TimeoutError: - self._write_response( - response_error( - f"request body read timed out after {int(REQUEST_READ_TIMEOUT)} seconds", - backend="idalib", - ) - ) - return - except ValueError as exc: - self._write_response(response_error(str(exc), backend="idalib")) - return - if not raw.strip(): - return - self._write_response(service._dispatch(raw)) - if service.exit_requested: - threading.Thread(target=self.server.shutdown, daemon=True).start() - - -class _UnixIdaLibServer(socketserver.UnixStreamServer): - allow_reuse_address = True - request_queue_size = 64 - service: IdaLibService - - -def serve(*, database_path: str, run_auto_analysis: bool, ready_fd: int | None = None) -> int: - ensure_user_runtime_dir() - socket_path = idalib_socket_path(os.getpid()) - registry_path = idalib_registry_path(os.getpid()) - with contextlib.suppress(FileNotFoundError): - socket_path.unlink() - service: IdaLibService | None = None - server: _UnixIdaLibServer | None = None - try: - service = IdaLibService( - database_path=database_path, - run_auto_analysis=run_auto_analysis, - ) - server = _UnixIdaLibServer(str(socket_path), _IdaLibRequestHandler) - server.service = service - service._write_registry() - _write_ready(ready_fd, {"ok": True}) - ready_fd = None - server.serve_forever() - except Exception as exc: - _write_ready(ready_fd, {"ok": False, "error": str(exc)}) - raise - finally: - if server is not None: - server.server_close() - with contextlib.suppress(FileNotFoundError): - socket_path.unlink() - with contextlib.suppress(FileNotFoundError): - registry_path.unlink() - if service is not None: - with contextlib.suppress(Exception): - service.close_runtime(save=False) - with contextlib.suppress(OSError): - if ready_fd is not None: - os.close(ready_fd) - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="python -m idac.transport.idalib_server") - parser.add_argument("--database", required=True, help="Database or input path to open") - parser.add_argument("--ready-fd", type=int, default=None, help=argparse.SUPPRESS) - parser.add_argument( - "--no-auto-analysis", - dest="run_auto_analysis", - action="store_false", - help="Open without waiting for auto-analysis", - ) - parser.set_defaults(run_auto_analysis=OPEN_DATABASE_AUTO_ANALYSIS) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - try: - return serve( - database_path=args.database, - run_auto_analysis=bool(args.run_auto_analysis), - ready_fd=args.ready_fd, - ) - except WorkerError as exc: - print(str(exc), file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/idac/transport/schema.py b/src/idac/transport/schema.py deleted file mode 100644 index 9e9fb19..0000000 --- a/src/idac/transport/schema.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from ..metadata import WIRE_PROTOCOL_VERSION - - -@dataclass -class RequestEnvelope: - op: str - params: dict[str, Any] = field(default_factory=dict) - backend: str = "gui" - target: str | None = None - database: str | None = None - timeout: float | None = None - - -def response_ok( - result: Any, - *, - backend: str, - warnings: list[str] | None = None, - request_id: str | None = None, -) -> dict[str, Any]: - return { - "version": WIRE_PROTOCOL_VERSION, - "id": request_id, - "ok": True, - "result": result, - "error": None, - "error_kind": None, - "backend": backend, - "warnings": list(warnings or []), - } - - -def response_error( - message: str, - *, - backend: str | None = None, - request_id: str | None = None, - error_kind: str | None = None, -) -> dict[str, Any]: - return { - "version": WIRE_PROTOCOL_VERSION, - "id": request_id, - "ok": False, - "result": None, - "error": message, - "error_kind": error_kind, - "backend": backend, - "warnings": [], - } - - -__all__ = ["RequestEnvelope", "response_error", "response_ok"] diff --git a/src/idac/workspace.py b/src/idac/workspace.py index 8712afa..b37b5b2 100644 --- a/src/idac/workspace.py +++ b/src/idac/workspace.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any -from .paths import skill_reference_source_dir, workspace_template_source_dir +from .paths import workspace_template_source_dir _WORKSPACE_MARKER = ".idac" _WORKSPACE_TMP = ".idac/tmp" @@ -14,23 +14,13 @@ ".claude/settings.json", ".codex/config.toml", ".codex/rules/default.rules", - "CLAUDE.md", "AGENTS.md", ".gitignore", } ) -def _display_path(relative: Path, *, is_dir: bool) -> str: - """Format one created workspace path for user-facing summaries.""" - - rendered = relative.as_posix() - return f"{rendered}/" if is_dir else rendered - - def _ensure_directory(path: Path, *, root: Path) -> list[str]: - """Create missing directories below ``root`` and report each one.""" - created: list[str] = [] missing: list[Path] = [] current = path @@ -39,13 +29,11 @@ def _ensure_directory(path: Path, *, root: Path) -> list[str]: current = current.parent for directory in reversed(missing): directory.mkdir() - created.append(_display_path(directory.relative_to(root), is_dir=True)) + created.append(f"{directory.relative_to(root).as_posix()}/") return created def _git_repo_root(path: Path) -> Path | None: - """Return the enclosing git root for ``path`` when one exists.""" - try: proc = subprocess.run( ["git", "-C", str(path), "rev-parse", "--show-toplevel"], @@ -62,8 +50,6 @@ def _git_repo_root(path: Path) -> Path | None: def _git_init(path: Path) -> Path: - """Initialize a git repository and return its detected root.""" - try: proc = subprocess.run( ["git", "-C", str(path), "init"], @@ -137,7 +123,7 @@ def _copy_tree( overwrite=overwrite, skip_existing=skip_existing, ) - display = _display_path(dest.relative_to(tracking_root), is_dir=False) + display = dest.relative_to(tracking_root).as_posix() if file_created and display not in created: created.append(display) if file_overwritten and display not in overwritten: @@ -172,19 +158,8 @@ def initialize_workspace(dest: Path, *, force: bool = False) -> dict[str, Any]: created.extend(template_created) overwritten.extend(template_overwritten) - reference_created, reference_overwritten = _copy_tree( - skill_reference_source_dir(), - destination / "references", - tracking_root=destination, - force=force, - workspace_exists=workspace_exists, - ) - created.extend(reference_created) - overwritten.extend(reference_overwritten) - tmp_dir = destination / _WORKSPACE_TMP - if not tmp_dir.exists(): - created.extend(_ensure_directory(tmp_dir, root=destination)) + created.extend(_ensure_directory(tmp_dir, root=destination)) repo_root = _git_repo_root(destination) git_initialized = False @@ -195,7 +170,7 @@ def initialize_workspace(dest: Path, *, force: bool = False) -> dict[str, Any]: return { "initialized": True, "destination": str(destination.resolve()), - "display_destination": str(dest) if str(dest) else ".", + "display_destination": str(dest) or ".", "created": created, "overwritten": overwritten, "git": { @@ -203,7 +178,7 @@ def initialize_workspace(dest: Path, *, force: bool = False) -> dict[str, Any]: "repo_root": str(repo_root), }, "next_steps": [ - "Run `idac misc skill install` if you haven't already", + "Install the idac Agent Plugin in your agent client if you haven't already", "Edit AGENTS.md to set your default target", "Commit when the workspace looks right", ], diff --git a/src/idac/workspace_template/default/.claude/settings.json b/src/idac/workspace_template/default/.claude/settings.json index 45a6f85..bd41af2 100644 --- a/src/idac/workspace_template/default/.claude/settings.json +++ b/src/idac/workspace_template/default/.claude/settings.json @@ -6,10 +6,5 @@ "Bash(uv run idac)", "Bash(uv run idac:*)" ] - }, - "sandbox": { - "network": { - "allowAllUnixSockets": true - } } } diff --git a/src/idac/workspace_template/default/.codex/config.toml b/src/idac/workspace_template/default/.codex/config.toml index a1e705d..81475c0 100644 --- a/src/idac/workspace_template/default/.codex/config.toml +++ b/src/idac/workspace_template/default/.codex/config.toml @@ -9,5 +9,4 @@ default_permissions = "workspace" [permissions.workspace.network] enabled = true -dangerously_allow_all_unix_sockets = true allow_local_binding = true diff --git a/src/idac/workspace_template/default/AGENTS.md b/src/idac/workspace_template/default/AGENTS.md index 54e444e..b688f91 100644 --- a/src/idac/workspace_template/default/AGENTS.md +++ b/src/idac/workspace_template/default/AGENTS.md @@ -1,16 +1,14 @@ # Workspace This is an idac reverse-engineering workspace. -If the idac guide is not already in context, run `idac docs guide`. Detailed references -live under `references/` and via `idac docs TOPIC`; when command syntax is unclear or -errors, `references/cli.md` (`idac docs cli`) is canonical. +Install the `idac` Agent Plugin through a compatible client before starting a pass; its +skill carries the guide and the detailed command, workflow, and IDA type references. ## Structure - `audit/` — durable notes and findings, one `audit/-recovery.md` per target - `headers/recovered/` — headers reconstructed from reversing, one `.h` per target - `headers/vendor/` — reference headers from SDKs or public source -- `references/` — local copies of the bundled idac and IDA reference docs - `scripts/` — reusable idac/IDA Python scripts - `prompts/` — the fill-in task prompt (`prompts/recovery-pass.md`) - `.idac/tmp/` — scratch space for transient output (gitignored) @@ -19,18 +17,21 @@ errors, `references/cli.md` (`idac docs cli`) is canonical. Default target: (none set — ask before assuming one) -Replace the parenthetical above with a context selector such as `db:/path/to/firmware.i64` -or `pid:1234`. A filled-in **Context** value in a task prompt overrides this default; if -both are missing, ask instead of guessing. +Replace the parenthetical above with a context selector such as +`-c /path/to/firmware.i64` or `--instance `. A filled-in **Context** value in +a task prompt overrides this default; if both are missing, omit a selector only when +exactly one READY Nexus instance exists. Otherwise ask instead of guessing. ## Conventions - Start each pass from `prompts/recovery-pass.md`. Target, Scope, and Objective are required — ask when they are missing. -- Follow the mutation rules in `idac docs workflows` for every database change: preview - before commit, lint batches before running them, reanalyze and reread after type or - prototype changes, and calibrate local selectors from fresh locals JSON. -- Record every pass in `audit/-recovery.md` using - `references/templates/checkpoint-note.md`. Keep entries append-only and factual, and - distinguish proven facts from inferred names, types, and semantics. +- Follow the skill's mutation workflow for database edits. Preview parser-risky + type/prototype changes and uncertain selectors; confirmed renames, comments, and + parameter-name edits with `--preserve-cc` can be committed directly with readback. + Lint mutation batches, reanalyze after meaningful type or prototype changes, and + calibrate local selectors from fresh locals JSON. +- Record every pass in `audit/-recovery.md` using the skill's checkpoint-note + skeleton. Keep entries append-only and factual, and distinguish proven facts from + inferred names, types, and semantics. - Use `.idac/tmp/` for large transient `--out` artifacts. diff --git a/src/idac/workspace_template/default/CLAUDE.md b/src/idac/workspace_template/default/CLAUDE.md deleted file mode 100644 index e95b704..0000000 --- a/src/idac/workspace_template/default/CLAUDE.md +++ /dev/null @@ -1,7 +0,0 @@ -# Workspace - - - - - -@AGENTS.md diff --git a/src/idac/workspace_template/default/prompts/recovery-pass.md b/src/idac/workspace_template/default/prompts/recovery-pass.md index 8d4cc1d..763f715 100644 --- a/src/idac/workspace_template/default/prompts/recovery-pass.md +++ b/src/idac/workspace_template/default/prompts/recovery-pass.md @@ -5,25 +5,22 @@ single function to a multi-target type/prototype recovery effort. ## Setup -- **Context**: {{CONTEXT}} (e.g. `-c db:/path/to/file.i64` or `-c pid:1234`) +- **Context**: {{CONTEXT}} (e.g. `-c /path/to/file.i64` or `--instance `) - **Target**: {{TARGET}} (symbol, function, type, class, family, module, or behavior) - **Scope**: {{SCOPE}} (the exact boundary of this pass: what is in and what is out) - **Objective**: {{OBJECTIVE}} (what should become readable, typed, or evidenced) - **Prior work**: {{PRIOR_WORK}} (audit entry, recovered header, artifact path, or "none") Any value still containing `{{...}}` is unfilled. Target, Scope, and Objective are -required — ask for them instead of guessing. Context may stay unfilled only when -AGENTS.md sets a default target; Prior work defaults to "none". +required — ask for them instead of guessing. For an unfilled Context, use the +AGENTS.md default target; if none is set, omit a selector only when exactly one +READY Nexus instance exists. Otherwise ask. Prior work defaults to "none". ## References -If the idac guide is not already in context, run `idac docs guide` first. Load focused -topics on demand: - -- `idac docs cli` — command grammar and output behavior -- `idac docs workflows` — safe mutation loop, batch, selector calibration, readback -- `idac docs targets` — context/backend selection and opening binaries -- `idac docs troubleshooting` — bridge, backend, or stale-result issues +Install the `idac` Agent Plugin through a compatible client first; its skill loads +focused references for command grammar, the mutation workflow, Nexus context +selection, and runtime troubleshooting. ## Pass contract @@ -31,9 +28,11 @@ topics on demand: and `headers/recovered/`. Extend prior work instead of rediscovering it; if the latest audit entry no longer matches the database, note the mismatch and re-verify before building on it. -- Follow the mutation rules in `idac docs workflows` for every database or header - change: preview before commit, lint batches, reanalyze and reread after type or - prototype changes. +- Follow the skill's mutation workflow for database edits: preview parser-risky + type/prototype changes and uncertain selectors, and read back committed state. + Confirmed renames, comments, and parameter-name edits with `--preserve-cc` can + be committed directly. Lint mutation batches and reanalyze and reread after + meaningful type or prototype changes. - Work from the binary/database only. Do external correlation only if the user explicitly asks or the task is specifically about external correlation. - Keep recovered declarations in `headers/recovered/.h` and durable notes in @@ -55,16 +54,17 @@ topics on demand: ## If the target is a C++ class, vtable, or hierarchy -Run `idac docs class-recovery` and follow it; it owns the family-scoping, vtable-loop, -naming, and verification rules. Read `idac docs ida-cpp-type-details` before writing or -importing class or vtable declarations. +Read the skill's class-recovery reference and follow it; it owns the family-scoping, +vtable-loop, naming, and verification rules. Read its C++ type-details reference before +writing or importing class or vtable declarations. ## Done when -- The stated objective is met, verified by redecompiling (with `--f5`) every mutated - function and at least one caller of each changed prototype. -- Class work passes the verification checklist in `idac docs class-recovery`. -- An audit entry appended to `audit/-recovery.md` — using the skeleton in - `references/templates/checkpoint-note.md` (also printed by `idac docs templates`) — - records the changes, the evidence, failed commands, remaining uncertainty, and next - steps concrete enough for another agent to continue without repeating discovery. +- The stated objective is met and affected state is verified. Use fresh `--f5` + pseudocode for meaningful type/prototype changes and an affected caller when + propagation matters; annotation-only edits need annotation readback. +- Class work satisfies the relevant checks in the skill's class-recovery reference. +- An audit entry appended to `audit/-recovery.md` — using the skill's + checkpoint-note skeleton — records the changes, the evidence, failed commands, + remaining uncertainty, and next steps concrete enough for another agent to continue + without repeating discovery. diff --git a/tests/README.md b/tests/README.md index 40b5504..d316681 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,32 +1,49 @@ +# Tests + Test layout: -- `test_cli*.py`: CLI parser, wrapper, and artifact behavior -- `test_gui_transport*.py`, `test_bridge.py`, `test_dispatch.py`, `test_doctor.py`: GUI bridge and transport behavior -- `test_idalib_*.py`: fixture-backed `idalib` integration coverage, including a raw-binary open workflow -- `test_operation_registry.py`, `test_ops_helpers.py`, `test_string_search.py`, `test_search_bytes.py`, `test_vtable_helpers.py`: focused unit coverage for operation helpers +- `test_cli*.py`: parser, wrapper, context, and artifact behavior +- `test_nexus_session.py`: public ida-nexus selection, leases, execution, errors, + analysis waits, keepalive, and save policy +- `test_remote_ops.py` and focused helper tests: remote dispatch, preview, + result-shape, and operation semantics +- fixture-backed integration tests: real Nexus-managed IDA workers, including raw + binary import, mutation, type, local, class, and vtable behavior +- `test_doctor.py` and `test_setup.py`: stack diagnosis and GUI installation behavior Running tests: ```bash +uv run pytest -q -m "not requires_ida" +uv run pytest -q -m requires_ida uv run pytest -q -uv run pytest -q tests/test_idalib_types.py -uv run pytest -q tests/test_gui_transport.py ``` Fixture notes: -- `fixtures/idb/tiny.i64` is the lightweight database for backend and mutation tests. -- `fixtures/idb/handler_hierarchy.i64` and `handler_hierarchy_stripped.i64` cover class and vtable flows. -- Tests open copied databases or temporary fixture binaries under a temporary runtime directory so each case can mutate safely. +- `fixtures/idb/tiny.i64` is the lightweight database for execution and mutation tests. +- `fixtures/idb/handler_hierarchy.i64` and `handler_hierarchy_stripped.i64` cover class + and vtable flows. +- Tests open copied databases or temporary fixture binaries so each case can mutate + safely. +- Headless integration requires Python 3.11+, IDA 9.4+, `ida-nexus>=0.13.2`, + and `ida-domain>=0.5.1`. -Live GUI tests: +Optional live Nexus GUI tests are skipped by default. Start a matching IDA session and +select its exact record ID from `idac targets list --json`, and enable them with: -- `tests/test_gui_transport_live.py` is optional and skipped by default. -- Enable it with `IDAC_RUN_LIVE_GUI_TESTS=1`. +```bash +IDAC_RUN_NEXUS_GUI_TESTS=1 \ +IDAC_NEXUS_GUI_RECORD_ID='' \ +uv run pytest -q -m nexus_gui_live +``` -IDA isolation: +The selected GUI database must be disposable. The lifecycle test temporarily changes +a line comment, explicitly saves the database, verifies a copied on-disk snapshot in a +fresh headless worker, closes and reattaches, then restores and saves the original +comment. It never auto-selects a GUI target. -- Tests that spawn IDA receive a per-test isolated `IDAUSR` through the shared `idac_env` fixture. It copies only - license/config files and uses an empty `plugins/` directory, so tests do not depend on the live `~/.idapro` profile. -- When regenerating fixtures outside pytest, create an isolated `IDAUSR` manually as documented in the repo - `AGENTS.md`. +Tests that start IDA receive a per-test isolated `IDAUSR` through the shared `idac_env` +fixture. It copies only required license/configuration files and does not depend on the +user's live IDA profile. Fixture regeneration outside pytest must use equivalent +isolation as documented in the repository `AGENTS.md`. diff --git a/tests/conftest.py b/tests/conftest.py index 5ff2ca7..079b187 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,57 +1,84 @@ from __future__ import annotations -import contextlib import functools import json import os import shutil -import signal +import subprocess import sys import tempfile -import time from collections.abc import Callable from pathlib import Path import pytest -_LIVE_GUI_ENV = "IDAC_RUN_LIVE_GUI_TESTS" - -# Modules whose tests all talk to a real idalib daemon and therefore need a -# local IDA install; individual tests elsewhere use @pytest.mark.requires_ida. -_REQUIRES_IDA_MODULE_PREFIX = "test_idalib_" -_REQUIRES_IDA_MODULES = {"test_preview", "test_output_limits"} +_LIVE_GUI_ENV = "IDAC_RUN_NEXUS_GUI_TESTS" + +# Integration modules that open real databases through Nexus. Keep this list +# explicit so unit tests for the Nexus client itself remain runnable without IDA. +_REQUIRES_IDA_MODULES = { + "test_nexus_batch", + "test_nexus_binary_workflow", + "test_nexus_bookmarks", + "test_nexus_classes", + "test_nexus_ctree", + "test_nexus_function_inspection_search", + "test_nexus_headless_lifecycle", + "test_nexus_locals", + "test_nexus_name_locals_semantics", + "test_nexus_proto_comments", + "test_nexus_reads", + "test_nexus_reanalyze_python", + "test_nexus_struct_enum_semantics", + "test_nexus_types", + "test_output_limits", + "test_preview", +} @functools.lru_cache(maxsize=1) -def _idalib_available() -> bool: - # Mirror bootstrap_idapro's search order: idapro may already be importable - # from the venv/site-packages, otherwise it is discovered from an install - # dir. Checking only install dirs would skip integration tests that would - # actually run. - import importlib.util - - if importlib.util.find_spec("idapro") is not None: - return True - from idac.transport.idalib_common import candidate_ida_dirs +def _nexus_ida_available() -> bool: + """Report whether ida-nexus has a configured idalib installation.""" + configured = os.environ.get("IDADIR") + if not configured: + idausr = Path(os.environ.get("IDAUSR", Path.home() / ".idapro")).expanduser() + config_path = Path(str(idausr).split(os.pathsep)[0]) / "ida-config.json" + try: + payload = json.loads(config_path.read_text(encoding="utf-8")) + paths = payload.get("Paths", {}) + configured = paths.get("ida-install-dir") if isinstance(paths, dict) else None + except (OSError, TypeError, json.JSONDecodeError): + return False + if not isinstance(configured, str) or not configured.strip(): + return False + ida_dir = Path(configured).expanduser() try: - return any((ida_dir / "idalib" / "python").exists() for ida_dir in candidate_ida_dirs()) + return any( + path.is_file() + for path in ( + ida_dir / "libidalib.so", + ida_dir / "libidalib.dylib", + ida_dir / "idalib.dll", + ida_dir / "Contents" / "MacOS" / "libidalib.dylib", + ) + ) except OSError: return False def pytest_collection_modifyitems(config, items) -> None: requires_ida = pytest.mark.requires_ida - skip_no_ida = pytest.mark.skip(reason="no local IDA install with idalib found; integration tests skipped") - skip_live = pytest.mark.skip(reason=f"set {_LIVE_GUI_ENV}=1 to run gui_live integration tests") + skip_no_ida = pytest.mark.skip(reason="no local IDA installation configured for ida-nexus") + skip_live = pytest.mark.skip(reason=f"set {_LIVE_GUI_ENV}=1 to run nexus_gui_live integration tests") run_gui_live = os.environ.get(_LIVE_GUI_ENV) == "1" for item in items: module_name = item.module.__name__.rpartition(".")[2] - if module_name.startswith(_REQUIRES_IDA_MODULE_PREFIX) or module_name in _REQUIRES_IDA_MODULES: + if module_name in _REQUIRES_IDA_MODULES: item.add_marker(requires_ida) - if "requires_ida" in item.keywords and not _idalib_available(): + if "requires_ida" in item.keywords and not _nexus_ida_available(): item.add_marker(skip_no_ida) - if "gui_live" in item.keywords and not run_gui_live: + if "nexus_gui_live" in item.keywords and not run_gui_live: item.add_marker(skip_live) @@ -107,37 +134,32 @@ def idac_cmd() -> list[str]: return [sys.executable, "-m", "idac"] -def _cleanup_runtime_dir(runtime_dir: Path) -> None: - for registry_path in runtime_dir.glob("idac-idalib-*.json"): - try: - payload = json.loads(registry_path.read_text(encoding="utf-8")) - pid = int(payload.get("pid", 0)) - except (OSError, ValueError, TypeError, json.JSONDecodeError): - pid = 0 - if pid > 0: - with contextlib.suppress(ProcessLookupError): - os.kill(pid, signal.SIGTERM) - deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline: - live = False - for registry_path in runtime_dir.glob("idac-idalib-*.json"): - try: - payload = json.loads(registry_path.read_text(encoding="utf-8")) - pid = int(payload.get("pid", 0)) - except (OSError, ValueError, TypeError, json.JSONDecodeError): - pid = 0 - if pid <= 0: - continue - try: - os.kill(pid, 0) - except OSError: - continue - live = True - break - if not live: - break - time.sleep(0.05) - shutil.rmtree(runtime_dir, ignore_errors=True) +def _shutdown_nexus_workers(env: dict[str, str]) -> None: + """Stop fixture-owned headless instances through ida-nexus's public API.""" + + code = """ +from ida_nexus import DatabaseHandle, InstanceState, discover_databases, wait_database_released + +for discovered in discover_databases(timeout=1.0): + if discovered.state is not InstanceState.READY or discovered.instance.backend != "idalib": + continue + instance = discovered.instance + handle = DatabaseHandle.attach(instance, keepalive=0.0) + try: + handle.shutdown_database(save=True) + finally: + handle.close() + if not wait_database_released(instance, timeout=20.0): + raise TimeoutError(f"Nexus worker did not release {instance.record_id}") +""" + subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + text=True, + env=env, + timeout=30, + ) def _prepare_isolated_idausr(source: Path, target: Path) -> None: @@ -160,12 +182,13 @@ def idac_env() -> dict[str, str]: idausr_dir = Path(tempfile.mkdtemp(prefix="idac-test-idapro-")) source_idausr = Path(env.get("IDAUSR", Path.home() / ".idapro")).expanduser() _prepare_isolated_idausr(source_idausr, idausr_dir) - env["IDAC_RUNTIME_DIR"] = str(runtime_dir) + env["IDA_NEXUS_STATE_DIR"] = str(runtime_dir) env["IDAUSR"] = str(idausr_dir) try: yield env finally: - _cleanup_runtime_dir(runtime_dir) + _shutdown_nexus_workers(env) + shutil.rmtree(runtime_dir, ignore_errors=True) shutil.rmtree(idausr_dir, ignore_errors=True) diff --git a/tests/helpers.py b/tests/helpers.py index eb0a147..6ba6a4b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -67,7 +67,7 @@ def run_cli_json( return json.loads(proc.stdout) -def run_idalib( +def run_nexus( idac_cmd: list[str], idac_env: dict[str, str], database: Path, @@ -75,21 +75,13 @@ def run_idalib( input_text: str | None = None, use_json: bool = False, ) -> subprocess.CompletedProcess[str]: - open_proc = subprocess.run( - [*idac_cmd, "database", "open", str(database), "--format", "json"], - check=False, - capture_output=True, - text=True, - env=idac_env, - ) - assert open_proc.returncode == 0, open_proc.stderr or open_proc.stdout format_args = ["--format", "json"] if use_json else [] return subprocess.run( [ *idac_cmd, *_flatten_args(*args), "-c", - f"db:{database}", + str(database), *format_args, ], check=False, @@ -100,26 +92,26 @@ def run_idalib( ) -def run_idalib_json( +def run_nexus_json( idac_cmd: list[str], idac_env: dict[str, str], database: Path, *args: object, input_text: str | None = None, ) -> object: - proc = run_idalib(idac_cmd, idac_env, database, *args, input_text=input_text, use_json=True) + proc = run_nexus(idac_cmd, idac_env, database, *args, input_text=input_text, use_json=True) assert proc.returncode == 0, proc.stderr or proc.stdout return json.loads(proc.stdout) -def run_idalib_text( +def run_nexus_text( idac_cmd: list[str], idac_env: dict[str, str], database: Path, *args: object, input_text: str | None = None, ) -> str: - proc = run_idalib(idac_cmd, idac_env, database, *args, input_text=input_text, use_json=False) + proc = run_nexus(idac_cmd, idac_env, database, *args, input_text=input_text, use_json=False) assert proc.returncode == 0, proc.stderr or proc.stdout return proc.stdout @@ -131,12 +123,12 @@ def run_preview_json( out_path: Path, *args: object, ) -> tuple[subprocess.CompletedProcess[str], object]: - proc = run_cli(idac_cmd, idac_env, "preview", "-o", str(out_path), "-c", f"db:{database}", *args) + proc = run_cli(idac_cmd, idac_env, "preview", "-o", str(out_path), "-c", str(database), *args) assert out_path.exists(), proc.stderr or proc.stdout return proc, json.loads(out_path.read_text(encoding="utf-8")) -def preview_snapshot_cli2( +def preview_snapshot( idac_cmd: list[str], idac_env: dict[str, str], database: Path, @@ -146,10 +138,10 @@ def preview_snapshot_cli2( preview_args: list[object] | tuple[object, ...], filename: str = "preview.json", ) -> dict[str, object]: - before = run_idalib_json(idac_cmd, idac_env, database, *read_args) + before = run_nexus_json(idac_cmd, idac_env, database, *read_args) proc, preview = run_preview_json(idac_cmd, idac_env, database, tmp_path / filename, *preview_args) assert proc.returncode == 0, proc.stderr or proc.stdout - after_preview = run_idalib_json(idac_cmd, idac_env, database, *read_args) + after_preview = run_nexus_json(idac_cmd, idac_env, database, *read_args) return { "before": before, "preview": preview, @@ -157,7 +149,7 @@ def preview_snapshot_cli2( } -def preview_round_trip_cli2( +def preview_round_trip( idac_cmd: list[str], idac_env: dict[str, str], database: Path, @@ -169,7 +161,7 @@ def preview_round_trip_cli2( after_persist_args: list[object] | tuple[object, ...] | None = None, filename: str = "preview.json", ) -> dict[str, object]: - preview_result = preview_snapshot_cli2( + preview_result = preview_snapshot( idac_cmd, idac_env, database, @@ -178,8 +170,8 @@ def preview_round_trip_cli2( preview_args=persist_args if preview_args is None else preview_args, filename=filename, ) - persisted = run_idalib_json(idac_cmd, idac_env, database, *persist_args) - after_persist = run_idalib_json( + persisted = run_nexus_json(idac_cmd, idac_env, database, *persist_args) + after_persist = run_nexus_json( idac_cmd, idac_env, database, @@ -194,12 +186,12 @@ def preview_round_trip_cli2( __all__ = [ "normalize_pseudocode_call_arguments", - "preview_round_trip_cli2", - "preview_snapshot_cli2", + "preview_round_trip", + "preview_snapshot", "run_cli", "run_cli_json", - "run_idalib", - "run_idalib_json", - "run_idalib_text", + "run_nexus", + "run_nexus_json", + "run_nexus_text", "run_preview_json", ] diff --git a/tests/remote_ops_harness.py b/tests/remote_ops_harness.py new file mode 100644 index 0000000..cbe40a7 --- /dev/null +++ b/tests/remote_ops_harness.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from types import MappingProxyType, ModuleType +from typing import Any + +from idac import remote_ops as default_remote_ops + + +def dispatch_with_runtime( + runtime: object, + op: str, + params: dict[str, object], + *, + preview: bool = False, + module: ModuleType = default_remote_ops, + operation: object | None = None, +) -> Any: + """Invoke the real remote dispatch with a supplied runtime and optional synthetic operation.""" + original_runtime = module.IdaRuntime + original_operations = module._OPERATIONS + module.IdaRuntime = lambda: runtime + if operation is not None: + module._OPERATIONS = MappingProxyType({op: operation}) + try: + return module.dispatch(object(), op, params, preview) + finally: + module.IdaRuntime = original_runtime + module._OPERATIONS = original_operations diff --git a/tests/test_batch_finalization.py b/tests/test_batch_finalization.py new file mode 100644 index 0000000..2e4b426 --- /dev/null +++ b/tests/test_batch_finalization.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, ClassVar + +import pytest + +from idac.cli import main +from idac.nexus import NexusSessionError + + +class RecordingSession: + instances: ClassVar[list[RecordingSession]] = [] + + def __init__(self, locator=None, instance_id=None, timeout=None) -> None: + self.handle = SimpleNamespace(instance=SimpleNamespace(idb_path=locator, exe_path=None)) + self.operations: list[str] = [] + self.closed = False + self.__class__.instances.append(self) + + def execute_operation(self, op, params, *, preview, operation_label): + self.operations.append(op) + return {"name": "sample", "changed": False} + + def close(self) -> None: + self.closed = True + + def list_targets(self) -> list[dict[str, object]]: + return [] + + +@pytest.mark.parametrize("suffix", [".json", ".jsonl"]) +def test_batch_save_failure_replaces_pending_checkpoint_with_failed_finalization( + suffix: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "mutate.idac" + out_path = tmp_path / f"batch{suffix}" + database.touch() + batch_file.write_text("comment set 0x401000 entry --json\n", encoding="utf-8") + + class SaveFailingSession: + instances: ClassVar[list[SaveFailingSession]] = [] + + def __init__( + self, + locator: str | None = None, + instance_id: str | None = None, + timeout: float | None = None, + ) -> None: + self.locator = locator + self.instance_id = instance_id + self.timeout = timeout + self.handle = SimpleNamespace(instance=SimpleNamespace(idb_path=locator, exe_path=None)) + self.closed = False + self.checkpoint_at_close: Any = None + self.__class__.instances.append(self) + + def execute_operation( + self, + op: str, + params: dict[str, Any], + *, + preview: bool, + operation_label: str, + ) -> dict[str, Any]: + return {"changed": True} + + def close(self) -> None: + if self.closed: + return + self.closed = True + if suffix == ".json": + self.checkpoint_at_close = json.loads(out_path.read_text(encoding="utf-8")) + else: + self.checkpoint_at_close = [ + json.loads(line) for line in out_path.read_text(encoding="utf-8").splitlines() + ] + raise NexusSessionError("headless autosave failed", kind="save_failed") + + monkeypatch.setattr("idac.nexus.NexusSession", SaveFailingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + + session = SaveFailingSession.instances[0] + + if suffix == ".json": + checkpoint = session.checkpoint_at_close + payload = json.loads(out_path.read_text(encoding="utf-8")) + assert checkpoint["ok"] is False + assert checkpoint["finalization"] == {"status": "pending"} + finalization = payload["finalization"] + assert payload["ok"] is False + assert payload["commands_succeeded"] == 1 + assert payload["commands_failed"] == 0 + else: + checkpoint = session.checkpoint_at_close[-1] + payload = [json.loads(line) for line in out_path.read_text(encoding="utf-8").splitlines()] + assert checkpoint == {"record_type": "batch_finalization", "status": "pending", "ok": False} + finalization = payload[-1] + assert finalization["record_type"] == "batch_finalization" + assert finalization["ok"] is False + + assert finalization == { + **({"record_type": "batch_finalization"} if suffix == ".jsonl" else {}), + "status": "failed", + "stage": "database_save_or_session_close", + "persistence": "unconfirmed", + "error_kind": "save_failed", + "error": "headless autosave failed", + **({"ok": False} if suffix == ".jsonl" else {}), + } + assert "batch finalization failed: headless autosave failed" in capsys.readouterr().err + + +def test_batch_establishes_wrapper_output_before_dispatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "mutate.idac" + out_path = tmp_path / "batch.json" + database.touch() + out_path.mkdir() + batch_file.write_text("comment set 0x401000 entry\n", encoding="utf-8") + + class RecordingSession: + instances: ClassVar[list[RecordingSession]] = [] + + def __init__(self, locator=None, instance_id=None, timeout=None) -> None: + self.handle = SimpleNamespace(instance=SimpleNamespace(idb_path=locator, exe_path=None)) + self.operations: list[str] = [] + self.close_attempts = 0 + self.closed = False + self.__class__.instances.append(self) + + def execute_operation(self, op, params, *, preview, operation_label): + self.operations.append(op) + return {"changed": True} + + def close(self) -> None: + if self.closed: + return + self.closed = True + self.close_attempts += 1 + + monkeypatch.setattr("idac.nexus.NexusSession", RecordingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + session = RecordingSession.instances[0] + assert session.operations == [] + assert "Is a directory" in capsys.readouterr().err + + +def test_batch_wrapper_output_cannot_overwrite_child_input( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + declarations = tmp_path / "types.h" + batch_file = tmp_path / "read.idac" + database.touch() + declarations.write_text("typedef int preserved_type;\n", encoding="utf-8") + batch_file.write_text("type check --decl-file types.h\n", encoding="utf-8") + + class RecordingSession: + instances: ClassVar[list[RecordingSession]] = [] + + def __init__(self, **_kwargs: object) -> None: + self.calls: list[str] = [] + self.closed = False + self.__class__.instances.append(self) + + def execute_operation(self, op, params, *, preview, operation_label): + self.calls.append(op) + return {"success": True} + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr("idac.nexus.NexusSession", RecordingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(declarations)]) == 1 + + assert declarations.read_text(encoding="utf-8") == "typedef int preserved_type;\n" + assert "must not overwrite the line 1 --decl-file input" in capsys.readouterr().err + assert all(not session.calls for session in RecordingSession.instances) + + +@pytest.mark.parametrize( + ("command", "expected_error"), + [ + ( + "comment set 0x401000 entry --out step.json\n", + "mutating batch child commands cannot set --out", + ), + ( + "preview --out preview.json comment set 0x401000 entry --out step.json\n", + "mutating commands wrapped by batch preview cannot set --out", + ), + ], +) +def test_batch_rejects_mutating_child_output_before_dispatch( + command: str, + expected_error: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "mutate.idac" + out_path = tmp_path / "batch.json" + database.touch() + batch_file.write_text(command, encoding="utf-8") + + class RecordingSession: + instances: ClassVar[list[RecordingSession]] = [] + + def __init__(self, locator=None, instance_id=None, timeout=None) -> None: + self.handle = SimpleNamespace(instance=SimpleNamespace(idb_path=locator, exe_path=None)) + self.operations: list[str] = [] + self.closed = False + self.__class__.instances.append(self) + + def execute_operation(self, op, params, *, preview, operation_label): + self.operations.append(op) + return {"changed": True} + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr("idac.nexus.NexusSession", RecordingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + payload = json.loads(out_path.read_text(encoding="utf-8")) + assert all(not session.operations for session in RecordingSession.instances) + assert payload["results"][0]["status"] == "failed" + assert expected_error in payload["results"][0]["stderr"] + assert expected_error in capsys.readouterr().err + + +def test_batch_interrupt_closes_and_writes_terminal_record_without_traceback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "mutate.idac" + out_path = tmp_path / "batch.json" + database.touch() + batch_file.write_text("comment set 0x401000 entry\n", encoding="utf-8") + + class InterruptingSession: + instances: ClassVar[list[InterruptingSession]] = [] + + def __init__(self, locator=None, instance_id=None, timeout=None) -> None: + self.handle = SimpleNamespace(instance=SimpleNamespace(idb_path=locator, exe_path=None)) + self.close_attempts = 0 + self.closed = False + self.__class__.instances.append(self) + + def execute_operation(self, op, params, *, preview, operation_label): + raise KeyboardInterrupt + + def close(self) -> None: + if self.closed: + return + self.closed = True + self.close_attempts += 1 + + monkeypatch.setattr("idac.nexus.NexusSession", InterruptingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 130 + session = InterruptingSession.instances[0] + payload = json.loads(out_path.read_text(encoding="utf-8")) + assert session.close_attempts == 1 + assert payload["finalization"] == { + "status": "interrupted", + "stage": "batch_execution", + "error_kind": "keyboard_interrupt", + "error": "interrupted by user", + "session_finalization": {"status": "ok"}, + } + assert payload["results"][0]["status"] == "interrupted" + assert payload["results"][0]["exit_code"] == 130 + stderr = capsys.readouterr().err + assert "batch interrupted: interrupted by user" in stderr + assert "Traceback" not in stderr + + +def test_batch_preserves_unexpected_execution_and_close_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "mutate.idac" + out_path = tmp_path / "batch.json" + database.touch() + batch_file.write_text("comment set 0x401000 entry\n", encoding="utf-8") + + class FailingSession: + instances: ClassVar[list[FailingSession]] = [] + + def __init__(self, locator=None, instance_id=None, timeout=None) -> None: + self.handle = SimpleNamespace(instance=SimpleNamespace(idb_path=locator, exe_path=None)) + self.close_attempts = 0 + self.closed = False + self.__class__.instances.append(self) + + def execute_operation(self, op, params, *, preview, operation_label): + raise RuntimeError("dispatch exploded") + + def close(self) -> None: + if self.closed: + return + self.closed = True + self.close_attempts += 1 + raise NexusSessionError("autosave exploded", kind="save_failed") + + monkeypatch.setattr("idac.nexus.NexusSession", FailingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + session = FailingSession.instances[0] + payload = json.loads(out_path.read_text(encoding="utf-8")) + finalization = payload["finalization"] + assert session.close_attempts == 1 + assert finalization["stage"] == "batch_execution" + assert finalization["error_kind"] == "RuntimeError" + assert finalization["error"] == "dispatch exploded" + assert finalization["session_finalization"] == { + "status": "failed", + "stage": "database_save_or_session_close", + "persistence": "unconfirmed", + "error_kind": "save_failed", + "error": "autosave exploded", + } + stderr = capsys.readouterr().err + assert "dispatch exploded" in stderr + assert "batch session finalization also failed: autosave exploded" in stderr + assert "Traceback" not in stderr + + +@pytest.mark.parametrize("protected", ["context", "batch_file", "context_symlink"]) +def test_batch_wrapper_output_cannot_overwrite_inputs( + protected: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + RecordingSession.instances.clear() + database = tmp_path / "sample.i64" + batch_file = tmp_path / "read.idac" + database.write_bytes(b"database sentinel") + batch_file.write_text("database show\n", encoding="utf-8") + if protected == "context": + out_path = database + elif protected == "batch_file": + out_path = batch_file + else: + out_path = tmp_path / "database-output.json" + out_path.symlink_to(database) + database_before = database.read_bytes() + batch_before = batch_file.read_text(encoding="utf-8") + monkeypatch.setattr("idac.nexus.NexusSession", RecordingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + assert all(session.operations == [] for session in RecordingSession.instances) + assert database.read_bytes() == database_before + assert batch_file.read_text(encoding="utf-8") == batch_before + assert "must not overwrite" in capsys.readouterr().err + + +@pytest.mark.parametrize("child_output", ["batch.json", "sample.i64"]) +def test_read_only_child_output_cannot_overwrite_journal_or_context( + child_output: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + RecordingSession.instances.clear() + database = tmp_path / "sample.i64" + batch_file = tmp_path / "read.idac" + out_path = tmp_path / "batch.json" + database.write_bytes(b"database sentinel") + batch_file.write_text(f"database show --out {child_output}\n", encoding="utf-8") + monkeypatch.setattr("idac.nexus.NexusSession", RecordingSession) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + payload = json.loads(out_path.read_text(encoding="utf-8")) + assert all(not session.operations for session in RecordingSession.instances) + assert database.read_bytes() == b"database sentinel" + assert payload["commands_total"] == 1 + assert "must not overwrite" in payload["results"][0]["stderr"] + capsys.readouterr() + + +def test_batch_preserves_close_failure_notes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "read.idac" + out_path = tmp_path / "batch.json" + database.touch() + batch_file.write_text("database show\n", encoding="utf-8") + + class NotedCloseFailure(RecordingSession): + def close(self) -> None: + if self.closed: + return + self.closed = True + error = NexusSessionError("autosave exploded", kind="save_failed") + error.add_note("lease release also failed: connection refused") + raise error + + NotedCloseFailure.instances.clear() + monkeypatch.setattr("idac.nexus.NexusSession", NotedCloseFailure) + + assert main(["batch", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + finalization = json.loads(out_path.read_text(encoding="utf-8"))["finalization"] + assert finalization["notes"] == ["lease release also failed: connection refused"] + assert "batch finalization detail: lease release also failed" in capsys.readouterr().err + + +def test_batch_lint_rejects_mutating_child_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + RecordingSession.instances.clear() + database = tmp_path / "sample.i64" + batch_file = tmp_path / "mutate.idac" + out_path = tmp_path / "lint.json" + database.touch() + batch_file.write_text("comment set 0x401000 entry --out step.json\n", encoding="utf-8") + monkeypatch.setattr("idac.nexus.NexusSession", RecordingSession) + + assert main(["batch", "--lint", "-c", str(database), str(batch_file), "--out", str(out_path)]) == 1 + payload = json.loads(out_path.read_text(encoding="utf-8")) + assert payload["mode"] == "lint" + assert "mutating batch child commands cannot set --out" in payload["errors"][0]["message"] + assert all(not session.operations for session in RecordingSession.instances) + capsys.readouterr() diff --git a/tests/test_bridge.py b/tests/test_bridge.py deleted file mode 100644 index d5335b5..0000000 --- a/tests/test_bridge.py +++ /dev/null @@ -1,372 +0,0 @@ -from __future__ import annotations - -import importlib -import json -import os -import sys -from pathlib import Path -from types import SimpleNamespace - -from idac.metadata import WIRE_PROTOCOL_VERSION -from idac.ops.runtime import IdaOperationError -from idac.version import VERSION - - -def _import_bridge_module(monkeypatch): - repo_root = Path(__file__).resolve().parents[1] - sys.path.insert(0, str(repo_root / "src")) - monkeypatch.setitem( - sys.modules, - "ida_kernwin", - SimpleNamespace(MFF_WRITE=1, execute_sync=lambda fn, _flags: fn()), - ) - try: - sys.modules.pop("idac.ida_plugin.idac_bridge.bridge", None) - return importlib.import_module("idac.ida_plugin.idac_bridge.bridge") - finally: - sys.path.pop(0) - - -def _import_handlers_module(monkeypatch): - repo_root = Path(__file__).resolve().parents[1] - sys.path.insert(0, str(repo_root / "src")) - try: - sys.modules.pop("idac.ida_plugin.idac_bridge.handlers", None) - return importlib.import_module("idac.ida_plugin.idac_bridge.handlers") - finally: - sys.path.pop(0) - - -def _import_plugin_module(monkeypatch, *, bridge_service): - repo_root = Path(__file__).resolve().parents[1] - sys.path.insert(0, str(repo_root / "src")) - messages: list[str] = [] - monkeypatch.setitem( - sys.modules, - "idaapi", - SimpleNamespace( - plugin_t=type("plugin_t", (), {}), - PLUGIN_FIX=1, - PLUGIN_HIDE=16, - PLUGIN_KEEP=2, - PLUGIN_SKIP=3, - msg=lambda text: messages.append(text), - ), - ) - monkeypatch.setitem( - sys.modules, - "ida_kernwin", - SimpleNamespace(MFF_WRITE=1, execute_sync=lambda fn, _flags: fn()), - ) - monkeypatch.setitem( - sys.modules, - "idac_bridge.bridge", - SimpleNamespace(BridgeService=bridge_service), - ) - monkeypatch.setitem( - sys.modules, - "idac_bridge.protocol", - SimpleNamespace( - registry_path=lambda pid: Path(f"/tmp/{pid}.json"), - socket_path=lambda pid: Path(f"/tmp/{pid}.sock"), - ), - ) - sys.modules.pop("idac.ida_plugin.idac_bridge_plugin", None) - try: - module = importlib.import_module("idac.ida_plugin.idac_bridge_plugin") - return module, messages - finally: - sys.path.pop(0) - - -def test_bridge_translates_expected_operation_failures(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - bridge = bridge_module.IdacBridge( - handlers={"database_info": lambda _params: (_ for _ in ()).throw(IdaOperationError("bad op"))} - ) - - response = bridge.handle_request({"version": WIRE_PROTOCOL_VERSION, "id": "1", "op": "database_info", "params": {}}) - - assert response["ok"] is False - assert response["error"] == "bad op" - assert response["version"] == WIRE_PROTOCOL_VERSION - assert response["id"] == "1" - assert response["warnings"] == [] - assert response["result"] is None - - -def test_bridge_dispatches_custom_handlers(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - seen: list[tuple[str, str | None, dict[str, object]]] = [] - - bridge = bridge_module.IdacBridge( - handlers={"database_info": lambda params: seen.append(("database_info", "active", params)) or {"ok": True}} - ) - response = bridge.handle_request( - { - "version": WIRE_PROTOCOL_VERSION, - "id": "1", - "op": "database_info", - "params": {"ping": "pong"}, - "target": "active", - } - ) - - assert response["ok"] is True - assert response["result"] == {"ok": True} - assert seen == [("database_info", "active", {"ping": "pong"})] - - -def test_bridge_surfaces_unexpected_dispatch_exceptions(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - bridge = bridge_module.IdacBridge( - handlers={"database_info": lambda _params: (_ for _ in ()).throw(TypeError("boom"))} - ) - - response = bridge.handle_request({"version": WIRE_PROTOCOL_VERSION, "id": "1", "op": "database_info", "params": {}}) - - assert response["ok"] is False - assert response["error"] == "boom" - assert response["version"] == WIRE_PROTOCOL_VERSION - assert response["id"] == "1" - assert response["warnings"] == [] - assert response["result"] is None - - -def test_bridge_logs_unexpected_dispatch_exceptions(monkeypatch, capsys) -> None: - bridge_module = _import_bridge_module(monkeypatch) - bridge = bridge_module.IdacBridge( - handlers={"database_info": lambda _params: (_ for _ in ()).throw(TypeError("boom"))} - ) - - bridge.handle_request({"version": WIRE_PROTOCOL_VERSION, "id": "1", "op": "database_info", "params": {}}) - - assert "GUI bridge request raised an internal error" in capsys.readouterr().err - - -def test_bridge_respects_explicit_validate_target_override(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - seen: list[str | None] = [] - - monkeypatch.setattr( - bridge_module, - "build_default_registry", - lambda: ( - {"database_info": lambda _params: {"ok": True}}, - lambda target: (_ for _ in ()).throw(AssertionError(f"default validator used: {target}")), - ), - ) - - bridge = bridge_module.IdacBridge(validate_target=lambda target: seen.append(target)) - response = bridge.handle_request( - { - "version": WIRE_PROTOCOL_VERSION, - "id": "1", - "op": "database_info", - "params": {}, - "target": "active", - } - ) - - assert response["ok"] is True - assert response["result"] == {"ok": True} - assert seen == ["active"] - - -def test_build_default_registry_returns_handlers_and_validator(monkeypatch) -> None: - handlers_module = _import_handlers_module(monkeypatch) - - monkeypatch.setattr(handlers_module, "SUPPORTED_OPERATIONS", ("database_info", "list_targets")) - monkeypatch.setattr( - handlers_module, - "build_operation_registry", - lambda _runtime, *, list_targets=None: { - "database_info": lambda params: {"params": params}, - "list_targets": list_targets, - }, - ) - - handlers, validate_target = handlers_module.build_default_registry() - - assert tuple(sorted(handlers.keys())) == ("database_info", "list_targets") - assert handlers["database_info"]({"ping": "pong"}) == {"params": {"ping": "pong"}} - assert handlers["list_targets"]({}) == [ - { - "target_id": "active", - "selector": "active", - "filename": "", - "module": "", - "active": True, - } - ] - assert validate_target("active") is None - - -def test_bridge_service_writes_registry_atomically(monkeypatch, tmp_path: Path) -> None: - bridge_module = _import_bridge_module(monkeypatch) - pid = 1234 - registry = tmp_path / f"idac-bridge-{pid}.json" - sock_path = tmp_path / f"idac-bridge-{pid}.sock" - - monkeypatch.setattr(bridge_module.os, "getpid", lambda: pid) - monkeypatch.setattr(bridge_module, "registry_path", lambda _pid: registry) - - service = bridge_module.BridgeService() - service._write_registry(sock_path) - - payload = json.loads(registry.read_text(encoding="utf-8")) - assert payload["pid"] == pid - assert payload["socket_path"] == str(sock_path) - assert payload["instance_id"] - assert payload["state"] == "stopped" - assert list(tmp_path.glob(f"{registry.name}.*.tmp")) == [] - - -def test_bridge_service_logs_requests_and_responses_when_enabled(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - logs: list[str] = [] - - class FakeBridge: - def handle_request(self, raw: bytes) -> dict[str, object]: - assert raw == b'{"version": 1, "id": "1", "op": "database_info", "params": {}}\n' - return {"ok": True, "backend": "gui", "result": {"ping": "pong"}} - - service = bridge_module.BridgeService(bridge=FakeBridge()) - service._state = "ready" - service._dispatcher = SimpleNamespace( - call_with_metrics=lambda _name, fn: ( - fn(), - bridge_module.DispatchMetrics( - queue_depth_at_enqueue=0, - queue_wait_seconds=0.0, - run_seconds=0.0, - ), - ) - ) - monkeypatch.setattr(bridge_module, "_bridge_log", lambda message, *, exc=None: logs.append(message)) - - service.set_request_logging_enabled(True) - service.set_response_logging_enabled(True) - response = service.dispatch_request(b'{"version": 1, "id": "1", "op": "database_info", "params": {}}\n') - - assert response == {"ok": True, "backend": "gui", "result": {"ping": "pong"}} - assert logs == [ - 'GUI bridge request: {"id": "1", "op": "database_info", "params": {}, "version": 1}', - 'GUI bridge response: {"backend": "gui", "ok": true, "result": {"ping": "pong"}}', - "GUI bridge timings: id=1, op=database_info, queue_depth=0, queue_wait=0.000s, run=0.000s", - ] - - -def test_bridge_service_returns_busy_error_when_dispatcher_is_full(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - - service = bridge_module.BridgeService() - service._state = "ready" - service._dispatcher = SimpleNamespace( - call_with_metrics=lambda _name, _fn: (_ for _ in ()).throw( - bridge_module.DispatcherBusyError("idac-gui dispatcher queue is full (16/16)") - ) - ) - - response = service.dispatch_request(b'{"version": 1, "id": "req-1", "op": "database_info", "params": {}}\n') - - assert response["ok"] is False - assert response["id"] == "req-1" - assert response["error_kind"] == "busy" - assert "queue is full" in str(response["error"]) - - -def test_bridge_service_returns_draining_error_before_dispatch(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - - service = bridge_module.BridgeService() - service._state = "draining" - - response = service.dispatch_request(b'{"version": 1, "id": "req-2", "op": "database_info", "params": {}}\n') - - assert response["ok"] is False - assert response["id"] == "req-2" - assert response["error_kind"] == "draining" - assert response["error"] == "IDA GUI bridge is draining" - - -def test_bridge_service_returns_startup_error_before_dispatch(monkeypatch) -> None: - bridge_module = _import_bridge_module(monkeypatch) - - service = bridge_module.BridgeService() - service._state = "starting" - - response = service.dispatch_request(b'{"version": 1, "id": "req-3", "op": "database_info", "params": {}}\n') - - assert response["ok"] is False - assert response["id"] == "req-3" - assert response["error_kind"] == "startup_incomplete" - assert response["error"] == "IDA GUI bridge is still starting" - - -def test_bridge_service_lifecycle_writes_start_ready_and_draining_states(monkeypatch, tmp_path: Path) -> None: - bridge_module = _import_bridge_module(monkeypatch) - pid = 4242 - runtime = tmp_path / "runtime" - registry = runtime / "bridge.json" - sock = Path(f"/tmp/idac-bridge-lifecycle-{os.getpid()}.sock") - states: list[str] = [] - - monkeypatch.setattr(bridge_module.os, "getpid", lambda: pid) - monkeypatch.setattr(bridge_module, "runtime_dir", lambda: runtime) - monkeypatch.setattr(bridge_module, "registry_path", lambda _pid: registry) - monkeypatch.setattr(bridge_module, "socket_path", lambda _pid: sock) - real_write_registry = bridge_module.BridgeService._write_registry - - def recording_write_registry(self, sock_path): - states.append(self._state) - return real_write_registry(self, sock_path) - - monkeypatch.setattr(bridge_module.BridgeService, "_write_registry", recording_write_registry) - - service = bridge_module.BridgeService( - bridge=bridge_module.IdacBridge( - handlers={"list_targets": lambda _params: []}, - validate_target=lambda _target: None, - ) - ) - service.start() - try: - assert service.status_snapshot()["state"] == "ready" - finally: - service.stop() - sock.unlink(missing_ok=True) - - assert states == ["starting", "ready", "draining"] - - -def test_plugin_logs_startup_failures(monkeypatch) -> None: - class ExplodingService: - def start(self) -> None: - raise RuntimeError("boom") - - plugin_module, messages = _import_plugin_module(monkeypatch, bridge_service=ExplodingService) - plugin = plugin_module.IdacBridgePlugin() - - result = plugin.init() - - assert result == 3 - assert any("GUI bridge failed to start" in message for message in messages) - - -def test_plugin_logs_version_and_status_on_init(monkeypatch) -> None: - class WorkingService: - def start(self) -> None: - return None - - plugin_module, messages = _import_plugin_module(monkeypatch, bridge_service=WorkingService) - monkeypatch.setattr(plugin_module.os, "getpid", lambda: 4242) - plugin = plugin_module.IdacBridgePlugin() - - result = plugin.init() - - assert result == 2 - assert any(f"idac bridge v{VERSION} loaded" in message for message in messages) - assert any(f"GUI bridge running (v{VERSION}): pid=4242" in message for message in messages) - assert any("/tmp/4242.sock" in message for message in messages) - assert any("/tmp/4242.json" in message for message in messages) diff --git a/tests/test_bundled_assets.py b/tests/test_bundled_assets.py deleted file mode 100644 index 5123bab..0000000 --- a/tests/test_bundled_assets.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from idac.paths import skill_source_dir, workspace_template_source_dir - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[1] - - -def test_plugin_assets_exist_in_single_packaged_location() -> None: - plugin_root = _repo_root() / "src" / "idac" / "ida_plugin" - expected = { - "idac_bridge/__init__.py", - "idac_bridge/bridge.py", - "idac_bridge/handlers.py", - "idac_bridge/protocol.py", - "idac_bridge_plugin.py", - } - for relative in expected: - assert (plugin_root / relative).is_file() - - -def test_packaged_skill_assets_match_checkout_sources_when_duplicated() -> None: - repo_root = _repo_root() - expected = { - "SKILL.md", - "agents/openai.yaml", - "references/class-recovery.md", - "references/cli.md", - "references/targets-and-backends.md", - "references/troubleshooting.md", - "references/workflows.md", - } - packaged_root = skill_source_dir() - checkout_root = repo_root / "src" / "idac" / "skills" / "idac" - - for relative in expected: - packaged_path = packaged_root / relative - assert packaged_path.is_file() - repo_path = checkout_root / relative - assert repo_path.is_file() - assert packaged_path.read_text(encoding="utf-8") == repo_path.read_text(encoding="utf-8") - - -def test_workspace_template_assets_exist_in_single_packaged_location() -> None: - template_root = workspace_template_source_dir() - expected = { - ".claude/settings.json", - ".codex/config.toml", - ".codex/rules/default.rules", - ".gitignore", - "AGENTS.md", - "CLAUDE.md", - "audit/.gitkeep", - "headers/recovered/.gitkeep", - "headers/vendor/.gitkeep", - "prompts/recovery-pass.md", - "scripts/.gitkeep", - } - - for relative in expected: - assert (template_root / relative).is_file() diff --git a/tests/test_cli.py b/tests/test_cli.py index 95f9f07..960145b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,2100 +1,766 @@ from __future__ import annotations -import contextlib import json -import os -import shutil -import signal -import tempfile -import time +from importlib import metadata from pathlib import Path from types import SimpleNamespace +from typing import Any, ClassVar import pytest from idac.cli import build_parser, main -from idac.cli2 import batch as batch_module -from idac.cli2.errors import CliUserError -from idac.cli2.result import CommandResult +from idac.nexus import NexusSessionError -FIXTURE_DB = "db:fixtures/idb/tiny.i64" +@pytest.fixture +def fake_nexus(monkeypatch): + class FakeNexusSession: + instances: ClassVar[list[FakeNexusSession]] = [] + discovered: ClassVar[list[dict[str, object]]] = [] + + def __init__( + self, + locator: str | None = None, + instance_id: str | None = None, + timeout: float | None = None, + ) -> None: + self.locator = locator + self.instance_id = instance_id + self.timeout = timeout + target = self.discovered[0] if self.discovered else {"idb_path": locator, "exe_path": None} + self.handle = SimpleNamespace( + instance=SimpleNamespace(idb_path=target.get("idb_path"), exe_path=target.get("exe_path")) + ) + self.calls: list[dict[str, Any]] = [] + self.closed = False + self.__class__.instances.append(self) + + def execute_operation( + self, + op: str, + params: dict[str, Any], + *, + preview: bool, + operation_label: str, + ) -> Any: + self.calls.append( + { + "op": op, + "params": params, + "preview": preview, + } + ) + if preview: + return { + "before": {"text": None}, + "after": {"text": params.get("text")}, + "result": {"changed": True}, + "preview_mode": "rollback" + if op + in {"bookmark_add", "bookmark_set", "bookmark_delete", "comment_set", "comment_delete", "name_set"} + else "undo", + "persisted": False, + } + if op == "database_info": + return {"database": self.locator, "record_id": self.instance_id} + return {"op": op, "params": params} + + def execute_python( + self, + source: str, + *, + filename: str, + operation_label: str, + ) -> dict[str, Any]: + self.calls.append( + { + "source": source, + } + ) + return {"result": {"result": 7, "result_repr": "7"}, "stdout": "hello\n", "stderr": ""} -def _help_text(parser, *args: str, capsys) -> str: - with pytest.raises(SystemExit): - parser.parse_args([*list(args), "--help"]) - return capsys.readouterr().out - - -@pytest.fixture -def short_runtime_dir(monkeypatch): - runtime_dir = Path(tempfile.mkdtemp(prefix="idacrt-", dir="/tmp")) - monkeypatch.setenv("IDAC_RUNTIME_DIR", str(runtime_dir)) - try: - yield runtime_dir - finally: - for registry_path in runtime_dir.glob("idac-idalib-*.json"): - try: - pid = int(json.loads(registry_path.read_text(encoding="utf-8")).get("pid", 0)) - except (OSError, ValueError, TypeError, json.JSONDecodeError): - pid = 0 - if pid > 0: - with contextlib.suppress(ProcessLookupError): - os.kill(pid, signal.SIGTERM) - deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline: - live = False - for registry_path in runtime_dir.glob("idac-idalib-*.json"): - try: - pid = int(json.loads(registry_path.read_text(encoding="utf-8")).get("pid", 0)) - except (OSError, ValueError, TypeError, json.JSONDecodeError): - pid = 0 - if pid <= 0: - continue - try: - os.kill(pid, 0) - except OSError: - continue - live = True - break - if not live: - break - time.sleep(0.05) - shutil.rmtree(runtime_dir, ignore_errors=True) - - -def _copied_fixture_db(copy_database, tiny_database: Path) -> str: - return f"db:{copy_database(tiny_database)}" - - -def test_root_help_shows_misc_and_hides_old_names(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, capsys=capsys) - - assert "docs" in help_text - assert "misc" in help_text - assert "decompilemany" in help_text - assert "strings" not in help_text - - -def test_root_help_mentions_global_context_forwarding(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, capsys=capsys) - - assert "-c LOCATOR" in help_text - assert "--timeout TIMEOUT" in help_text - - -def test_root_full_help_shows_misc_and_hides_old_function_show(capsys) -> None: - parser = build_parser() - - with pytest.raises(SystemExit) as exc: - parser.parse_args(["--full-help"]) - - assert exc.value.code == 0 - help_text = capsys.readouterr().out - assert "# idac function metadata" in help_text - assert "# idac function show" not in help_text - assert "# idac docs" in help_text - assert "# idac misc" in help_text - assert "# idac misc reanalyze" in help_text - assert "# idac misc plugin install" in help_text - assert "# idac misc skill install" in help_text - assert "# idac segment list" in help_text - assert "# idac targets list" in help_text - assert "# idac targets cleanup" in help_text - assert "# idac doctor check" not in help_text - assert "# idac doctor targets" not in help_text - assert "# idac doctor cleanup" not in help_text - assert "# idac search strings" in help_text - - -def test_function_help_uses_metadata_and_prototype(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "function", capsys=capsys) - - assert "metadata" in help_text - assert "callers" in help_text - assert "callees" in help_text - assert "prototype" in help_text - - -def test_function_list_help_mentions_name_filter_regex_and_ignore_case(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "function", "list", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "NAME_FILTER" in help_text - assert "not a list of function names" in normalized_help - assert "--limit" in help_text - assert "--demangle" in help_text - assert "--regex" in help_text - assert "--ignore-case" in help_text - - -def test_database_help_omits_segments(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "database", capsys=capsys) - - assert "show" in help_text - assert "segments" not in help_text - - -def test_database_segments_command_is_not_registered(capsys) -> None: - parser = build_parser() - - with pytest.raises(SystemExit) as exc: - parser.parse_args(["database", "segments"]) - - assert exc.value.code == 2 - assert "invalid choice" in capsys.readouterr().err - - -def test_segment_list_help_mentions_filter_regex_and_ignore_case(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "segment", "list", capsys=capsys) - - assert "SEGMENT_FILTER" in help_text - assert "--regex" in help_text - assert "--ignore-case" in help_text - - -def test_search_help_lists_bytes_and_strings(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "search", capsys=capsys) - - assert "bytes" in help_text - assert "strings" in help_text - - -def test_search_strings_help_mentions_scan_flag(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "search", "strings", capsys=capsys) - - assert "TEXT_FILTER" in help_text - assert "--scan" in help_text - assert "defined strings" in help_text - assert "examples:" in help_text - - -def test_search_bytes_help_clarifies_ida_byte_pattern(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "search", "bytes", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "BYTE_PATTERN" in help_text - assert "IDA byte pattern" in normalized_help - assert "not a regex" in normalized_help - assert "examples:" in help_text - - -def test_preview_help_mentions_wrapped_command_and_out(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "preview", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "COMMAND..." in help_text - assert "without the leading `idac`" in normalized_help - assert "requires --out" in normalized_help - assert "examples:" in help_text - - -def test_batch_help_mentions_file_format_and_relative_paths(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "batch", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "BATCH_FILE" in help_text - assert "one shell-like idac subcommand per line" in normalized_help - assert "relative child paths" in normalized_help - assert "preview lines are allowed" in normalized_help - - -def test_locals_help_clarifies_selector_and_new_name(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "function", "locals", "rename", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "LOCAL_SELECTOR" in help_text - assert "--new-name" in help_text - assert "prefer --local-id or --index" in normalized_help - assert "examples:" in help_text - - -def test_doctor_help_is_direct_health_check(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "doctor", capsys=capsys) - - assert "--backend" not in help_text - assert "-c LOCATOR" not in help_text - assert "--timeout" in help_text - assert "--json" in help_text - assert "check" not in help_text - assert "targets" not in help_text - assert "cleanup" not in help_text - assert "plugin" not in help_text - assert "skill" not in help_text - - -def test_docs_default_prints_agent_oriented_index(capsys) -> None: - exit_code = main(["docs"]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert "Use `idac docs TOPIC`" in output - assert "Start here:" in output - assert "CLI and operation help:" in output - assert "IDA reference:" in output - assert "Workflows:" in output - assert "Workspace resources:" in output - assert "idac docs guide" in output - assert "idac docs cli" in output - assert "idac docs workflows" in output - assert "idac docs class-recovery" in output - assert output.index("idac docs guide") < output.index("idac docs troubleshooting") - assert output.index("idac docs cli") < output.index("idac docs troubleshooting") - assert output.index("idac docs troubleshooting") < output.index("idac docs ida-cpp-type-details") - assert output.index("idac docs ida-cpp-type-details") < output.index("idac docs workflows") - - -def test_docs_guide_prints_frontmatter_stripped_skill_body(capsys) -> None: - exit_code = main(["docs", "guide"]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert output.startswith("# idac") - assert "---" not in output.splitlines()[:3] - assert "## Critical defaults" in output - assert "## Reference index" in output - - -def test_docs_skill_alias_prints_guide(capsys) -> None: - exit_code = main(["docs", "skill"]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert output.startswith("# idac") - assert "## Critical defaults" in output - - -def test_docs_topic_prints_bundled_reference(capsys) -> None: - exit_code = main(["docs", "cli"]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert "# idac Quick Reference" in output - assert "The command grammar for the `idac` CLI." in output - - -def test_docs_large_topic_prints_inline(capsys) -> None: - exit_code = main(["docs", "class-recovery"]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert "# Class Recovery" in output - assert "## Practical caveat" in output - - -def test_docs_templates_prints_template_files(capsys) -> None: - exit_code = main(["docs", "templates"]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert "# Template Files" in output - assert "`checkpoint-note.md`:" in output - assert "### Open Questions" in output - assert "`prototype-pass.idac`:" in output - assert "function prototype check" in output - assert "`rename-pass.idac`:" in output - assert "`locals-jq-snippets.sh`:" in output - - -def test_docs_list_prints_available_topics(capsys) -> None: - exit_code = main(["docs", "--list"]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert "Start here:" in output - assert "guide" in output - assert "CLI and operation help:" in output - assert "IDA reference:" in output - assert "Workspace resources:" in output - assert "cli" in output - assert "workflows" in output - assert "class-recovery" in output - assert "ida-cpp-type-details" in output - assert "prototype-pass" not in output - assert "prompt-class-recovery-pass" not in output - assert "claude-workspace" not in output - assert "\n skill " not in output - assert output.index(" guide") < output.index(" cli") - assert output.index(" cli") < output.index(" troubleshooting") - assert output.index(" troubleshooting") < output.index(" targets") - assert output.index(" targets") < output.index(" ida-cpp-type-details") - assert output.index(" ida-advanced-type-annotations") < output.index(" workflows") - - -def test_docs_json_includes_topic_metadata(capsys) -> None: - exit_code = main(["docs", "workspace", "--json"]) - - assert exit_code == 0 - payload = json.loads(capsys.readouterr().out) - assert payload["topic"] == "workspace" - assert payload["title"] == "Workspace Instructions" - assert "AGENTS.md" in payload["path"] - assert "# Workspace" in payload["text"] - - -def test_docs_rejects_unknown_topic(capsys) -> None: - exit_code = main(["docs", "nope"]) - - assert exit_code == 1 - error = capsys.readouterr().err - assert "unknown docs topic: nope" in error - assert "prototype-pass" not in error - assert "prompt-class-recovery-pass" not in error - - -def test_docs_rejects_root_context(capsys) -> None: - exit_code = main(["-c", "db:/tmp/demo.i64", "docs"]) - - assert exit_code == 1 - assert "`idac docs` does not accept -c/--context" in capsys.readouterr().err - - -def test_targets_help_keeps_list_cleanup_only(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "targets", capsys=capsys) - - assert "list" in help_text - assert "cleanup" in help_text - assert "plugin" not in help_text - assert "skill" not in help_text - - -def test_old_doctor_subcommands_are_not_registered(capsys) -> None: - parser = build_parser() - - for subcommand in ("check", "targets", "cleanup"): - with pytest.raises(SystemExit) as exc: - parser.parse_args(["doctor", subcommand]) - - assert exc.value.code == 2 - assert f"unrecognized arguments: {subcommand}" in capsys.readouterr().err - - -def test_doctor_backend_option_is_not_registered(capsys) -> None: - parser = build_parser() - - with pytest.raises(SystemExit) as exc: - parser.parse_args(["doctor", "--backend", "gui"]) - - assert exc.value.code == 2 - assert "unrecognized arguments: --backend gui" in capsys.readouterr().err - - -def test_doctor_context_is_not_registered(capsys) -> None: - parser = build_parser() - - with pytest.raises(SystemExit) as exc: - parser.parse_args(["doctor", "-c", "db:/tmp/demo.i64"]) - - assert exc.value.code == 2 - assert "unrecognized arguments: -c db:/tmp/demo.i64" in capsys.readouterr().err - - -def test_doctor_rejects_root_context(capsys) -> None: - exit_code = main(["-c", "db:/tmp/demo.i64", "doctor"]) - - assert exit_code == 1 - assert "`idac doctor` does not accept -c/--context" in capsys.readouterr().err - - -def test_doctor_accepts_root_timeout(capsys, monkeypatch) -> None: - captured = {} - - def fake_run_doctor(**kwargs): - captured.update(kwargs) - return { - "backend": ["idalib"], - "healthy": True, - "status": "ok", - "checks": [], - } - - monkeypatch.setattr("idac.cli2.commands.doctor.run_doctor", fake_run_doctor) - - exit_code = main(["--timeout", "2.5", "doctor"]) - - assert exit_code == 0 - assert captured == {"scope": "all", "timeout": 2.5} - assert "status: ok" in capsys.readouterr().out - - -def test_targets_list_sends_contextual_gui_list_targets_request(capsys, monkeypatch) -> None: - captured = {} - - def fake_send_request(request): - captured["request"] = request - return { - "ok": True, - "result": [ - { - "selector": "pid:1234", - "status": "active", - "module": "tiny", - "pid": 1234, - } - ], - } - - monkeypatch.setattr("idac.cli2.commands.targets.send_request", fake_send_request) - - exit_code = main(["targets", "list", "-c", "pid:1234"]) - - assert exit_code == 0 - assert captured["request"].op == "list_targets" - assert captured["request"].backend == "gui" - assert captured["request"].target == "pid:1234" - assert "pid:1234 (gui, tiny)" in capsys.readouterr().out - - -def test_targets_list_aggregates_gui_and_idalib_targets(capsys, monkeypatch) -> None: - captured = [] - - def fake_send_request(request): - captured.append(request) - if request.backend == "gui": - return { - "ok": True, - "result": [ - { - "selector": "pid:1234", - "module": "tiny-gui", - "instance_pid": 1234, - "active": True, - } - ], - "warnings": [], - } - return { - "ok": True, - "result": [ - { - "selector": "tiny.i64", - "filename": "/tmp/tiny.i64", - "module": "tiny", - "instance_pid": 5678, - "active": True, - } - ], - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.targets.send_request", fake_send_request) - - exit_code = main(["targets", "list"]) - - assert exit_code == 0 - assert [request.backend for request in captured] == ["gui", "idalib"] - assert captured[0].timeout == 2.0 - assert captured[1].timeout is None - output = capsys.readouterr().out - assert "pid:1234 [active] (gui, tiny-gui, pid=1234)" in output - assert "tiny.i64 [active] (idalib, tiny, pid=5678)" in output - - -def test_targets_list_db_context_queries_and_filters_idalib(capsys, monkeypatch) -> None: - captured = {} - - def fake_send_request(request): - captured["request"] = request - return { - "ok": True, - "result": [ - { - "selector": "tiny.i64", - "filename": "/tmp/tiny.i64", - "module": "tiny", - "instance_pid": 5678, - "active": True, - }, - { - "selector": "other.i64", - "filename": "/tmp/other.i64", - "module": "other", - "instance_pid": 8765, - "active": True, - }, - ], - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.targets.send_request", fake_send_request) - - exit_code = main(["targets", "list", "-c", "db:/tmp/tiny.i64"]) - - assert exit_code == 0 - assert captured["request"].backend == "idalib" - assert captured["request"].database == "/tmp/tiny.i64" - output = capsys.readouterr().out - assert "tiny.i64 [active] (idalib, tiny, pid=5678)" in output - assert "other.i64" not in output - - -def test_targets_list_keeps_idalib_rows_when_gui_listing_fails(capsys, monkeypatch) -> None: - from idac.transport import BackendError - - def fake_send_request(request): - if request.backend == "gui": - raise BackendError("GUI bridge timed out") - return { - "ok": True, - "result": [ - { - "selector": "tiny.i64", - "filename": "/tmp/tiny.i64", - "module": "tiny", - "instance_pid": 5678, - "active": True, - } - ], - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.targets.send_request", fake_send_request) - - exit_code = main(["targets", "list"]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert "tiny.i64 [active] (idalib, tiny, pid=5678)" in captured.out - assert "warning: failed to list gui: GUI bridge timed out" in captured.err - - -def test_targets_cleanup_uses_cleanup_runner(capsys, monkeypatch) -> None: - monkeypatch.setattr( - "idac.cli2.commands.targets.run_doctor_cleanup", - lambda: { - "runtime_dir": "/tmp/idac", - "removed_count": 1, - "kept_count": 2, - "missing_count": 0, - }, - ) - - exit_code = main(["targets", "cleanup"]) - - assert exit_code == 0 - assert "removed: 1" in capsys.readouterr().out - - -def test_py_help_only_exposes_exec(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "py", capsys=capsys) - - assert "{exec}" in help_text - assert "eval" not in help_text - - -def test_decompilemany_help_mentions_file_and_output_modes(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "decompilemany", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "FUNCTION_FILTER" in help_text - assert "--file" in help_text - assert "--functions-file" in help_text - assert "--out-file" in help_text - assert "--out-dir" in help_text - assert "--regex" in help_text - assert "--disasm" in help_text - assert "--ctree" in help_text - assert "This is not a list of function names" in normalized_help - assert "one per line" in normalized_help - assert "examples:" in help_text - - -def test_decompilemany_accepts_functions_file_alias(tmp_path: Path) -> None: - parser = build_parser() - functions_file = tmp_path / "funcs.txt" - out_dir = tmp_path / "decomp" - - args = parser.parse_args(["decompilemany", "--functions-file", str(functions_file), "--out-dir", str(out_dir)]) - - assert args.file == functions_file - assert args.out_dir == out_dir - - -def test_decompilemany_rejects_multiple_positional_exact_functions(tmp_path: Path, capsys) -> None: - out_dir = tmp_path / "decomp" - - exit_code = main(["decompilemany", "main", "add", "--out-dir", str(out_dir), "-c", "db:/tmp/demo.i64"]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert "decompilemany accepts one FUNCTION_FILTER" in captured.err - assert "--functions-file/--file" in captured.err - - -def test_type_list_help_uses_type_filter(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "type", "list", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "TYPE_FILTER" in help_text - assert "requires --out" in normalized_help - assert "Interpret TYPE_FILTER" in normalized_help - - -def test_type_declare_help_clarifies_decl_file_and_examples(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "type", "declare", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "--decl-file" in help_text - assert "C/C++ declarations" in normalized_help - assert "--bisect" in help_text - assert "--clang" in help_text - assert "examples:" in help_text - - -def test_type_class_candidates_help_uses_candidate_filter(capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, "type", "class", "candidates", capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert "CANDIDATE_FILTER" in help_text - assert "Use --kind to narrow candidate categories" in normalized_help - assert "Interpret CANDIDATE_FILTER" in normalized_help - - -@pytest.mark.parametrize( - ("command_args", "filter_name"), - [ - (("type", "class", "list"), "CLASS_FILTER"), - (("type", "struct", "list"), "STRUCT_FILTER"), - (("type", "enum", "list"), "ENUM_FILTER"), - ], -) -def test_type_family_list_help_uses_specific_filters(command_args: tuple[str, ...], filter_name: str, capsys) -> None: - parser = build_parser() - help_text = _help_text(parser, *command_args, capsys=capsys) - normalized_help = " ".join(help_text.split()) - - assert filter_name in help_text - assert f"Interpret {filter_name}" in normalized_help - - -def test_workspace_init_runs_on_public_cli(tmp_path: Path, capsys) -> None: - dest = tmp_path / "workspace" - - exit_code = main(["workspace", "init", str(dest), "--format", "json"]) - - assert exit_code == 0 - payload = json.loads(capsys.readouterr().out) - assert payload["initialized"] is True - assert payload["display_destination"] == str(dest) - assert payload["created"] == [ - ".claude/", - ".claude/settings.json", - ".codex/", - ".codex/config.toml", - ".codex/rules/", - ".codex/rules/default.rules", - ".gitignore", - "AGENTS.md", - "CLAUDE.md", - "audit/", - "audit/.gitkeep", - "headers/", - "headers/recovered/", - "headers/recovered/.gitkeep", - "headers/vendor/", - "headers/vendor/.gitkeep", - "prompts/", - "prompts/recovery-pass.md", - "scripts/", - "scripts/.gitkeep", - "references/", - "references/class-recovery.md", - "references/cli.md", - "references/ida-advanced-type-annotations.md", - "references/ida-cpp-type-details.md", - "references/ida-set-types.md", - "references/targets-and-backends.md", - "references/templates/", - "references/templates/README.md", - "references/templates/checkpoint-note.md", - "references/templates/locals-jq-snippets.sh", - "references/templates/prototype-pass.idac", - "references/templates/rename-pass.idac", - "references/troubleshooting.md", - "references/workflows.md", - ".idac/", - ".idac/tmp/", - ] - assert (dest / "AGENTS.md").exists() - assert (dest / "prompts" / "recovery-pass.md").exists() - assert (dest / ".codex" / "config.toml").exists() - - -def test_main_incomplete_group_command_returns_group_help(capsys) -> None: - exit_code = main(["function"]) - - assert exit_code == 2 - captured = capsys.readouterr() - assert "usage: idac function " in captured.out - assert "metadata" in captured.out - - -def test_type_list_without_pattern_requires_out(capsys) -> None: - exit_code = main(["type", "list", "-c", FIXTURE_DB]) - - assert exit_code == 1 - assert "rerun with a pattern or `--out `" in capsys.readouterr().err - - -@pytest.mark.requires_ida -def test_function_metadata_smoke(capsys, copy_database, tiny_database: Path, short_runtime_dir) -> None: - fixture_db = _copied_fixture_db(copy_database, tiny_database) - exit_code = main(["function", "metadata", "main", "-c", fixture_db]) - - assert exit_code == 0 - output = capsys.readouterr().out - assert "main @ 0x100000460" in output - assert "prototype:" in output - - -def test_root_context_forwards_to_direct_command(monkeypatch, tmp_path: Path) -> None: - captured = {} - out_path = tmp_path / "main.c" - - def fake_send_request(request): - captured["request"] = request - return {"ok": True, "result": {"text": "int main(void)\n{\n return 0;\n}\n"}, "warnings": []} - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["-c", "db:/tmp/demo.i64", "--timeout", "7", "decompile", "main", "--out", str(out_path)]) - - assert exit_code == 0 - assert captured["request"].backend == "idalib" - assert captured["request"].database == "/tmp/demo.i64" - assert captured["request"].timeout == 7.0 - - -def test_busy_backend_error_explains_serialized_requests(monkeypatch, capsys) -> None: - def fake_send_request(request): - return { - "ok": False, - "error": "idac-gui dispatcher queue is full (16/16)", - "error_kind": "busy", - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["-c", "pid:84428", "function", "metadata", "main"]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert "queue is full" in captured.err - assert "requests for one target are serialized" in captured.err - assert "batch/decompilemany" in captured.err - - -def test_root_context_forwards_to_nested_command(monkeypatch, capsys) -> None: - captured = {} - - def fake_send_request(request): - captured["request"] = request - return { - "ok": True, - "result": {"address": "0x1000", "name": "main", "prototype": "int main(void)"}, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["-c", "pid:84428", "function", "metadata", "main", "--format", "json"]) - - assert exit_code == 0 - assert captured["request"].backend == "gui" - assert captured["request"].target == "pid:84428" - assert json.loads(capsys.readouterr().out)["name"] == "main" - - -def test_single_idalib_target_is_auto_selected(monkeypatch, capsys) -> None: - captured = {} - - def fake_send_request(request): - captured["request"] = request - return { - "ok": True, - "result": {"address": "0x1000", "name": "main", "prototype": "int main(void)"}, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.context.list_gui_discovered_instances", lambda warnings=None: []) - monkeypatch.setattr("idac.cli2.context.list_gui_instances", lambda *, timeout=None, warnings=None: []) - monkeypatch.setattr( - "idac.cli2.context.list_idalib_instances", - lambda: [SimpleNamespace(database_path="/tmp/only-open.i64")], - ) - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["function", "metadata", "main", "--format", "json"]) - - assert exit_code == 0 - assert captured["request"].backend == "idalib" - assert captured["request"].database == "/tmp/only-open.i64" - assert json.loads(capsys.readouterr().out)["name"] == "main" - - -def test_command_local_context_overrides_root_context(monkeypatch, tmp_path: Path) -> None: - captured = {} - out_path = tmp_path / "main.c" - - def fake_send_request(request): - captured["request"] = request - return {"ok": True, "result": {"text": "int main(void)\n{\n return 0;\n}\n"}, "warnings": []} - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main( - [ - "-c", - "db:/tmp/root.i64", - "decompile", - "main", - "-c", - "db:/tmp/child.i64", - "--out", - str(out_path), - ] - ) - - assert exit_code == 0 - assert captured["request"].database == "/tmp/child.i64" - - -def test_function_locals_rename_accepts_index_without_positional_selector(capsys, monkeypatch) -> None: - captured = {} - - def fake_send_request(request): - captured["request"] = request - return {"ok": True, "result": {"locals": []}, "warnings": []} - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main( - [ - "function", - "locals", - "rename", - "main", - "--index", - "4", - "--new-name", - "msgBufferPtr", - "-c", - "db:/tmp/demo.i64", - ] - ) - - assert exit_code == 0 - assert captured["request"].params["identifier"] == "main" - assert captured["request"].params["index"] == 4 - assert captured["request"].params["new_name"] == "msgBufferPtr" - assert capsys.readouterr().err == "" - - -def test_function_locals_rename_rejects_ambiguous_single_positional_with_stable_selector(capsys) -> None: - parser = build_parser() - - with pytest.raises(SystemExit) as exc: - parser.parse_args(["function", "locals", "rename", "main", "v4", "-c", "db:/tmp/demo.i64"]) - - assert exc.value.code == 2 - assert "--new-name" in capsys.readouterr().err - - -def test_function_locals_retype_accepts_local_id_without_positional_selector(capsys, monkeypatch) -> None: - captured = {} - - def fake_send_request(request): - captured["request"] = request - return {"ok": True, "result": {"locals": []}, "warnings": []} - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main( - [ - "function", - "locals", - "retype", - "main", - "--local-id", - "stack(16)@0x100000460", - "--decl", - "unsigned int msgBufferPtr;", - "-c", - "db:/tmp/demo.i64", - ] - ) - - assert exit_code == 0 - assert captured["request"].params["identifier"] == "main" - assert captured["request"].params["local_id"] == "stack(16)@0x100000460" - assert captured["request"].params["decl"] == "unsigned int msgBufferPtr;" - assert capsys.readouterr().err == "" - - -def test_function_locals_retype_accepts_type_shorthand(capsys, monkeypatch) -> None: - captured = {} - - def fake_send_request(request): - captured["request"] = request - return {"ok": True, "result": {"locals": []}, "warnings": []} - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main( - [ - "function", - "locals", - "retype", - "main", - "--index", - "4", - "--type", - "unsigned int", - "-c", - "db:/tmp/demo.i64", - ] - ) - - assert exit_code == 0 - assert captured["request"].params["identifier"] == "main" - assert captured["request"].params["index"] == 4 - assert captured["request"].params["decl"] == "unsigned int __idac_local;" - assert capsys.readouterr().err == "" - - -def test_function_locals_retype_rejects_positional_selector_with_stable_selector(capsys) -> None: - exit_code = main( - [ - "function", - "locals", - "retype", - "main", - "v4", - "--index", - "4", - "--decl", - "unsigned int msgBufferPtr;", - "-c", - "db:/tmp/demo.i64", - ] - ) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "do not combine a positional selector with --local-id or --index" in captured.err - - -def test_function_locals_update_rejects_multiple_stable_selectors(capsys) -> None: - exit_code = main( - [ - "function", - "locals", - "update", - "main", - "--local-id", - "stack(16)@0x100000460", - "--index", - "4", - "--rename", - "msgBufferPtr", - "-c", - "db:/tmp/demo.i64", - ] - ) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "--local-id and --index are mutually exclusive" in captured.err - - -def test_function_locals_update_rejects_positional_selector_with_stable_selector(capsys) -> None: - exit_code = main( - [ - "function", - "locals", - "update", - "main", - "v4", - "--index", - "4", - "--rename", - "msgBufferPtr", - "-c", - "db:/tmp/demo.i64", - ] - ) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "do not combine a positional selector with --local-id or --index" in captured.err - - -def test_preview_requires_out_outside_batch(capsys) -> None: - exit_code = main(["preview", "-c", FIXTURE_DB, "function", "metadata", "main"]) - - assert exit_code == 1 - assert "preview requires `--out `" in capsys.readouterr().err - - -def test_preview_rejects_non_previewable_misc_command(tmp_path: Path, capsys) -> None: - out_path = tmp_path / "preview.json" - - exit_code = main(["preview", "-o", str(out_path), "misc", "rename", "main", "renamed"]) - - assert exit_code == 1 - assert "command is not available in preview mode" in capsys.readouterr().err - - -def test_root_context_forwards_to_preview_wrapper(tmp_path: Path, monkeypatch) -> None: - captured = {} - out_path = tmp_path / "preview.json" - - def fake_send_request(request): - captured["request"] = request - return { - "ok": True, - "result": { - "before": {"comment": None}, - "after": {"comment": "entry point"}, - "result": {"comment": "entry point"}, - "preview_mode": "undo", - "persisted": False, - }, - "warnings": [], - } + def save_database(self) -> dict[str, Any]: + self.calls.append({"save": True}) + return {"saved": True, "database": self.locator} - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) + def list_targets(self) -> list[dict[str, object]]: + return list(self.__class__.discovered) - exit_code = main( - ["-c", "db:/tmp/demo.i64", "preview", "-o", str(out_path), "comment", "set", "main", "entry point"] - ) + def close(self) -> None: + self.closed = True - assert exit_code == 0 - assert captured["request"].backend == "idalib" - assert captured["request"].database == "/tmp/demo.i64" + monkeypatch.setattr("idac.nexus.NexusSession", FakeNexusSession) + return FakeNexusSession -@pytest.mark.requires_ida -def test_batch_allows_preview_and_writes_jsonl( - tmp_path: Path, copy_database, tiny_database: Path, short_runtime_dir +@pytest.mark.parametrize("value", ["0", "nan"]) +def test_timeout_rejects_nonpositive_or_nonfinite_values( + value: str, + capsys: pytest.CaptureFixture[str], ) -> None: - fixture_db = _copied_fixture_db(copy_database, tiny_database) - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.jsonl" - batch_path.write_text( - "\n".join( - [ - f"function metadata main -c {fixture_db}", - f'preview -c {fixture_db} comment set main "entry point"', - ] - ) - + "\n", - encoding="utf-8", - ) - - exit_code = main(["batch", str(batch_path), "-o", str(out_path)]) - - assert exit_code == 0 - rows = [json.loads(line) for line in out_path.read_text(encoding="utf-8").splitlines() if line.strip()] - assert len(rows) == 2 - assert rows[0]["command"].startswith("function metadata") - assert rows[1]["command"].startswith("preview ") - assert rows[1]["result"]["after"]["comment"] == "entry point" - - -def test_batch_resolves_preview_wrapped_relative_paths(tmp_path: Path, capsys, monkeypatch) -> None: - batch_dir = tmp_path / "batch" - cwd = tmp_path / "cwd" - batch_dir.mkdir() - cwd.mkdir() - decl_file = batch_dir / "sub_401000.h" - decl_file.write_text("int sub_401000(void);\n", encoding="utf-8") - batch_path = batch_dir / "commands.idac" - batch_path.write_text( - "preview -c db:/tmp/demo.i64 function prototype set sub_401000 --decl-file sub_401000.h\n", - encoding="utf-8", - ) - captured = {} - - def fake_preview_execute(parsed, *, root_parser): - captured["decl_file"] = parsed.decl_file - return CommandResult( - render_op="proto_set", - value={ - "before": {"prototype": "int sub_401000(void);"}, - "after": {"prototype": "int sub_401000(void);"}, - "result": {"prototype": "int sub_401000(void);"}, - "preview_mode": "undo", - "persisted": False, - }, - ) - - monkeypatch.setattr("idac.cli2.preview.execute_parsed", fake_preview_execute) - monkeypatch.chdir(cwd) - - exit_code = main(["batch", str(batch_path)]) - - assert exit_code == 0 - assert captured["decl_file"] == decl_file - assert json.loads(capsys.readouterr().out)["ok"] is True - - -def test_root_context_forwards_to_batch_children(tmp_path: Path, capsys, monkeypatch) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("function metadata main\n", encoding="utf-8") - captured = {} - - def fake_send_request(request): - captured["request"] = request - return {"ok": True, "result": {"address": "0x1000", "name": "main"}, "warnings": []} - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["-c", "db:/tmp/demo.i64", "batch", str(batch_path)]) - - assert exit_code == 0 - assert captured["request"].backend == "idalib" - assert captured["request"].database == "/tmp/demo.i64" - assert json.loads(capsys.readouterr().out)["ok"] is True - - -def test_batch_with_out_still_prints_failures_to_stderr(tmp_path: Path, capsys, monkeypatch) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.json" - batch_path.write_text("function metadata missing_symbol -c db:/tmp/demo.i64\n", encoding="utf-8") - - def fake_send_request(request): - return {"ok": False, "error": "symbol not found: missing_symbol", "warnings": []} - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["batch", str(batch_path), "-o", str(out_path)]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "batch line 1:" in captured.err - assert "symbol not found: missing_symbol" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["results"][0]["stderr"] == "symbol not found: missing_symbol" - - -def test_batch_reports_renderer_failures_with_structured_fallback(tmp_path: Path, capsys, monkeypatch) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.json" - batch_path.write_text("function metadata main -c db:/tmp/demo.i64\n", encoding="utf-8") - - monkeypatch.setattr( - "idac.cli2.batch.execute_parsed", - lambda parsed, *, root_parser: CommandResult( - render_op="function_show", - value={"address": "0x1000", "name": "main"}, - exit_code=1, - ), - ) - - def broken_renderer(value) -> str: - raise RuntimeError("boom") + parser = build_parser() - monkeypatch.setitem(batch_module.TEXT_RENDERERS, "function_show", broken_renderer) + with pytest.raises(SystemExit) as caught: + parser.parse_args(["targets", "list", f"--timeout={value}"]) + assert caught.value.code == 2 - exit_code = main(["batch", str(batch_path), "-o", str(out_path)]) + assert "--timeout must be a positive finite number" in capsys.readouterr().err - assert exit_code == 1 - captured = capsys.readouterr() - assert "renderer failure while formatting function_show: RuntimeError: boom" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - stderr_text = payload["results"][0]["stderr"] - assert "renderer failure while formatting function_show: RuntimeError: boom" in stderr_text - assert '"name": "main"' in stderr_text +@pytest.mark.parametrize( + ("locator", "message"), + [ + ("db:sample.i64", "legacy db:/pid:/module: context locators were removed"), + ("sample.idb", "32-bit .idb databases are not supported"), + ], +) +def test_removed_context_forms_fail_closed(locator: str, message: str, capsys) -> None: + assert main(["-c", locator, "database", "show", "--json"]) == 1 + assert message in capsys.readouterr().err -def test_batch_preserves_missing_timeout_error_in_structured_output(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.json" - batch_path.write_text('search bytes "74 69 6e 79" --segment __TEXT\n', encoding="utf-8") - exit_code = main(["batch", str(batch_path), "-o", str(out_path)]) +def test_missing_context_path_fails_before_creating_session(tmp_path: Path, fake_nexus, capsys) -> None: + missing = tmp_path / "missing.bin" + Path(f"{missing}.i64").touch() - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "`idac search bytes` requires --timeout" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["results"][0]["stderr"] == "`idac search bytes` requires --timeout" + assert main(["-c", str(missing), "database", "show", "--json"]) == 1 + assert f"context path is not a file: {missing}" in capsys.readouterr().err + assert fake_nexus.instances == [] -def test_batch_rejects_mutating_commands_without_out_before_execution(tmp_path: Path, capsys, monkeypatch) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("comment set main entry\n", encoding="utf-8") +@pytest.mark.parametrize("use_symlink", [False, True]) +def test_output_cannot_overwrite_selected_database( + use_symlink: bool, + tmp_path: Path, + fake_nexus, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + database.write_bytes(b"database sentinel") + out_path = database + if use_symlink: + out_path = tmp_path / "output.json" + out_path.symlink_to(database) - def fake_send_request(request): - raise AssertionError("mutating batch command should not execute without --out") + assert main(["-c", str(database), "database", "show", "--out", str(out_path)]) == 1 - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) + assert database.read_bytes() == b"database sentinel" + assert "must not overwrite the selected input or database" in capsys.readouterr().err + assert fake_nexus.instances == [] - exit_code = main(["batch", str(batch_path), "-c", "db:/tmp/demo.i64"]) - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "mutating batch commands require `--out `" in captured.err - assert "line is 1: comment set main entry" in captured.err +def test_output_cannot_overwrite_database_created_for_binary(tmp_path: Path, fake_nexus, capsys) -> None: + binary = tmp_path / "sample.bin" + database = Path(f"{binary}.i64") + binary.write_bytes(b"binary sentinel") + database.write_bytes(b"database sentinel") + assert main(["-c", str(binary), "database", "show", "--out", str(database)]) == 1 -def test_batch_lint_reports_mutating_without_out_before_execution(tmp_path: Path, capsys, monkeypatch) -> None: - batch_path = tmp_path / "commands.txt" - decl_file = tmp_path / "types.h" - decl_file.write_text("typedef int sample_type;\n", encoding="utf-8") - batch_path.write_text("type declare --decl-file types.h\n", encoding="utf-8") + assert binary.read_bytes() == b"binary sentinel" + assert database.read_bytes() == b"database sentinel" + assert "must not overwrite the selected input or database" in capsys.readouterr().err + assert fake_nexus.instances == [] - def fake_execute(parsed, *, root_parser): - raise AssertionError("batch lint should not execute child commands") - monkeypatch.setattr("idac.cli2.batch.execute_parsed", fake_execute) +def test_instance_output_cannot_overwrite_discovered_database(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "selected.i64" + database.write_bytes(b"database sentinel") + fake_nexus.discovered = [ + { + "record_id": "record-42", + "state": "ready", + "idb_path": str(database), + "exe_path": None, + } + ] - exit_code = main(["batch", str(batch_path), "--lint"]) + assert main(["--instance", "record-42", "database", "show", "--out", str(database)]) == 1 - assert exit_code == 1 - captured = capsys.readouterr() - assert "mutating batch command requires wrapper --out" in captured.out + assert database.read_bytes() == b"database sentinel" + assert "must not overwrite the selected input or database" in capsys.readouterr().err + assert all(not session.calls and session.closed for session in fake_nexus.instances) -def test_batch_lint_writes_report_and_warns_on_name_local_selector_after_type_phase( - tmp_path: Path, capsys, monkeypatch +@pytest.mark.parametrize("command", ["read", "preview", "batch"]) +@pytest.mark.parametrize("selector", ["binary", "database"]) +@pytest.mark.parametrize("alias", ["direct", "symlink", "hardlink"]) +def test_output_preserves_resolved_target_database_and_input( + command: str, selector: str, alias: str, tmp_path: Path, fake_nexus, capsys ) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "lint.json" - decl_file = tmp_path / "types.h" - decl_file.write_text("typedef int sample_type;\n", encoding="utf-8") - batch_path.write_text( - "\n".join( + binary = tmp_path / "sample.bin" + database = tmp_path / "relocated.i64" + binary.write_bytes(b"binary sentinel") + database.write_bytes(b"database sentinel") + fake_nexus.discovered = [{"record_id": "gui-42", "idb_path": str(database), "exe_path": str(binary)}] + context_path, protected_path = (binary, database) if selector == "binary" else (database, binary) + out_path = protected_path + if alias != "direct": + out_path = tmp_path / "output.json" + if alias == "symlink": + out_path.symlink_to(protected_path) + else: + out_path.hardlink_to(protected_path) + + if command == "read": + argv = ["database", "show"] + elif command == "preview": + argv = ["preview", "--out", str(out_path), "database", "show"] + else: + batch_path = tmp_path / "read.idac" + batch_path.write_text("database show\n", encoding="utf-8") + argv = ["batch", str(batch_path)] + if command != "preview": + argv.extend(["--out", str(out_path)]) + + assert main(["-c", str(context_path), *argv]) == 1 + + assert binary.read_bytes() == b"binary sentinel" + assert database.read_bytes() == b"database sentinel" + assert all(not session.calls and session.closed for session in fake_nexus.instances) + assert "must not overwrite the selected input or database" in capsys.readouterr().err + + +def test_output_cannot_overwrite_command_input_file(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + declarations = tmp_path / "types.h" + database.touch() + declarations.write_text("typedef int preserved_type;\n", encoding="utf-8") + + assert ( + main( [ - "type declare --decl-file types.h", - "function locals update main v1 --rename count", + "-c", + str(database), + "type", + "check", + "--decl-file", + str(declarations), + "--out", + str(declarations), ] ) - + "\n", - encoding="utf-8", + == 1 ) - def fake_execute(parsed, *, root_parser): - raise AssertionError("batch lint should not execute child commands") + assert declarations.read_text(encoding="utf-8") == "typedef int preserved_type;\n" + assert "must not overwrite the --decl-file input" in capsys.readouterr().err + assert fake_nexus.instances == [] - monkeypatch.setattr("idac.cli2.batch.execute_parsed", fake_execute) - exit_code = main(["batch", str(batch_path), "--lint", "-o", str(out_path)]) +def test_preview_output_cannot_overwrite_wrapped_input_file(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + declarations = tmp_path / "types.h" + database.touch() + declarations.write_text("typedef int preserved_type;\n", encoding="utf-8") - assert exit_code == 0 - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["mode"] == "lint" - assert payload["commands_total"] == 2 - assert payload["warnings_total"] == 1 - assert "name-only local selector after type/prototype/reanalysis work" in payload["warnings"][0]["message"] - capsys.readouterr() + assert ( + main( + [ + "preview", + "-c", + str(database), + "--out", + str(declarations), + "type", + "declare", + "--decl-file", + str(declarations), + ] + ) + == 1 + ) + assert declarations.read_text(encoding="utf-8") == "typedef int preserved_type;\n" + assert "must not overwrite the wrapped --decl-file input" in capsys.readouterr().err + assert all(not session.calls and session.closed for session in fake_nexus.instances) -def test_batch_lint_reports_command_local_validation_errors(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "lint.json" - batch_path.write_text("function locals update main v1\n", encoding="utf-8") - exit_code = main(["batch", str(batch_path), "--lint", "-o", str(out_path)]) +def test_context_path_dispatches_operation_and_closes_session(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + database.touch() - assert exit_code == 1 - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["ok"] is False - assert payload["errors_total"] == 1 - assert payload["results"][0]["status"] == "failed" - assert "at least one of --rename or declaration input is required" in payload["results"][0]["stderr"] - capsys.readouterr() + assert main(["-c", str(database), "--timeout", "12.5", "database", "show", "--json"]) == 0 + session = fake_nexus.instances[0] + assert (session.locator, session.instance_id, session.timeout, session.closed) == ( + str(database), + None, + 12.5, + True, + ) + assert len(session.calls) == 1 + assert session.calls[0]["op"] == "database_info" + assert session.calls[0]["params"] == {} + assert session.calls[0]["preview"] is False + assert json.loads(capsys.readouterr().out) == {"database": str(database), "record_id": None} -def test_batch_lint_rejects_incomplete_command_groups(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("function\ntype struct\n", encoding="utf-8") - exit_code = main(["batch", str(batch_path), "--lint"]) +def test_operation_error_remains_primary_when_cli_session_close_also_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + database.touch() - assert exit_code == 1 - payload = json.loads(capsys.readouterr().out) - assert payload["ok"] is False - assert payload["errors_total"] == 2 - assert payload["results"][0]["exit_code"] == 2 - assert "usage: idac function" in payload["results"][0]["stderr"] - assert payload["results"][1]["exit_code"] == 2 - assert "usage: idac type struct" in payload["results"][1]["stderr"] + class FailingSession: + def __init__(self, **kwargs: object) -> None: + self.options = kwargs + def execute_operation(self, *args: object, **kwargs: object) -> object: + raise NexusSessionError("operation failed", kind="operation_failed") -def test_batch_lint_reports_disasm_missing_selector(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("disasm\n", encoding="utf-8") + def close(self) -> None: + raise NexusSessionError("lease release failed", kind="release_failed") - exit_code = main(["batch", str(batch_path), "--lint"]) + monkeypatch.setattr("idac.nexus.NexusSession", FailingSession) - assert exit_code == 1 - captured = capsys.readouterr() - assert "disasm requires a function or --start/--end" in captured.out + assert main(["-c", str(database), "database", "show", "--json"]) == 1 + stderr = capsys.readouterr().err + assert "operation failed" in stderr + assert "lease release failed" in stderr + assert stderr.index("operation failed") < stderr.index("lease release failed") + assert "Traceback" not in stderr -def test_batch_lint_reports_type_list_missing_pattern_or_out(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("type list\n", encoding="utf-8") - exit_code = main(["batch", str(batch_path), "--lint"]) +def test_keyboard_interrupt_exits_130_without_traceback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + database.touch() - assert exit_code == 1 - captured = capsys.readouterr() - assert "this list can be very large" in captured.out + class InterruptedSession: + def __init__(self, **kwargs: object) -> None: + self.options = kwargs + def execute_operation(self, *args: object, **kwargs: object) -> object: + raise KeyboardInterrupt -def test_batch_lint_accepts_disasm_range_and_type_list_filter(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("disasm --start 0x1000 --end 0x1010\ntype list Foo\n", encoding="utf-8") + def close(self) -> None: + return None - exit_code = main(["batch", str(batch_path), "--lint"]) + monkeypatch.setattr("idac.nexus.NexusSession", InterruptedSession) - assert exit_code == 0 - payload = json.loads(capsys.readouterr().out) - assert payload["ok"] is True - assert payload["commands_linted"] == 2 + assert main(["-c", str(database), "database", "show", "--json"]) == 130 + stderr = capsys.readouterr().err + assert "interrupted" in stderr.lower() + assert "Traceback" not in stderr -def test_batch_lint_rejects_forwarded_context_for_no_context_commands(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("docs cli\n", encoding="utf-8") - exit_code = main(["batch", str(batch_path), "--lint", "-c", "db:/tmp/demo.i64"]) +def test_exact_instance_and_implicit_ready_selection_reach_public_session(fake_nexus, capsys) -> None: + assert main(["--instance", "record-42", "database", "show", "--json"]) == 0 + first_output = json.loads(capsys.readouterr().out) + assert main(["database", "show", "--json"]) == 0 + second_output = json.loads(capsys.readouterr().out) - assert exit_code == 1 - captured = capsys.readouterr() - assert "`idac docs` does not accept -c/--context" in captured.out + assert [(item.locator, item.instance_id, item.closed) for item in fake_nexus.instances] == [ + (None, "record-42", True), + (None, None, True), + ] + assert first_output == {"database": None, "record_id": "record-42"} + assert second_output == {"database": None, "record_id": None} -def test_batch_lint_rejects_forwarded_timeout_for_no_context_commands(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("docs cli\n", encoding="utf-8") +def test_mutation_command_and_database_save_use_session_api(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + database.touch() - exit_code = main(["batch", str(batch_path), "--lint", "--timeout", "1"]) + assert main(["-c", str(database), "comment", "set", "0x401000", "entry", "--json"]) == 0 + assert main(["-c", str(database), "database", "save", "--json"]) == 0 - assert exit_code == 1 - captured = capsys.readouterr() - assert "`idac docs` does not accept --timeout" in captured.out + operation_call = fake_nexus.instances[0].calls[0] + assert operation_call["op"] == "comment_set" + assert operation_call["params"] == {"address": "0x401000", "text": "entry", "scope": "line"} + assert operation_call["preview"] is False + assert fake_nexus.instances[1].calls == [{"save": True}] + assert all(item.closed for item in fake_nexus.instances) + assert '"saved": true' in capsys.readouterr().out -def test_batch_lint_reports_missing_relative_input_file(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("type check --decl-file missing.h\n", encoding="utf-8") +def test_preview_reuses_wrapper_session_and_never_commits_mutation(tmp_path: Path, fake_nexus) -> None: + database = tmp_path / "sample.i64" + artifact = tmp_path / "preview.json" + database.touch() - exit_code = main(["batch", str(batch_path), "--lint"]) + assert ( + main( + [ + "preview", + "-c", + str(database), + "-o", + str(artifact), + "comment", + "set", + "0x401000", + "entry", + ] + ) + == 0 + ) - assert exit_code == 1 - captured = capsys.readouterr() - assert "decl_file path does not exist" in captured.out + assert len(fake_nexus.instances) == 1 + session = fake_nexus.instances[0] + assert session.closed is True + assert session.calls[0]["preview"] is True + payload = json.loads(artifact.read_text(encoding="utf-8")) + assert payload["before"] == {"text": None} + assert payload["after"] == {"text": "entry"} + assert payload["undo"] == {"mode": "rollback", "persisted": False, "status": "ok"} -def test_batch_lint_reports_missing_required_timeout(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text('search bytes "74 69 6e 79" --segment __TEXT\n', encoding="utf-8") +@pytest.mark.parametrize("family", [[], ["struct"], ["enum"]]) +@pytest.mark.parametrize("wrapper", ["preview", "batch", "batch_preview", "lint"]) +def test_unfiltered_type_lists_use_wrapper_artifact( + family: list[str], wrapper: str, tmp_path: Path, fake_nexus, capsys +) -> None: + database = tmp_path / "sample.i64" + artifact = tmp_path / "wrapper.json" + database.touch() + list_command = ["type", *family, "list"] + if wrapper == "preview": + argv = ["preview", "--out", str(artifact), *list_command] + else: + batch_path = tmp_path / "read.idac" + command = " ".join(list_command) + if wrapper == "batch_preview": + command = f"preview {command}" + batch_path.write_text(f"{command}\n", encoding="utf-8") + argv = ["batch", str(batch_path), "--out", str(artifact)] + if wrapper == "lint": + argv.append("--lint") + + assert main(["-c", str(database), *argv]) == 0 + + payload = json.loads(artifact.read_text(encoding="utf-8")) + if wrapper == "preview": + assert payload["status"] == "ok" + assert payload["undo"] == {"status": "noop", "mode": "read_only", "persisted": False} + else: + assert payload["ok"] is True + assert payload["results"][0]["status"] == "ok" + if wrapper != "lint": + calls = fake_nexus.instances[0].calls + expected_op = f"{family[0]}_list" if family else "type_list" + assert calls[0]["op"] == expected_op + assert calls[0]["preview"] is False + else: + assert all(not session.calls for session in fake_nexus.instances) + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize("wrapper", ["preview", "batch"]) +def test_unfiltered_type_lists_without_artifact_are_rejected(wrapper: str, tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + database.touch() + if wrapper == "preview": + argv = ["preview", "type", "struct", "list"] + else: + batch_path = tmp_path / "read.idac" + batch_path.write_text("type struct list\n", encoding="utf-8") + argv = ["batch", str(batch_path)] + assert main(["-c", str(database), *argv]) == 1 + assert all(not session.calls for session in fake_nexus.instances) + captured = capsys.readouterr() + if wrapper == "preview": + assert "preview requires" in captured.err + else: + payload = json.loads(captured.out) + assert "this list can be very large" in payload["results"][0]["stderr"] + + +@pytest.mark.parametrize("lint", [False, True]) +def test_batch_journals_do_not_require_a_database_for_local_commands( + lint: bool, tmp_path: Path, monkeypatch, capsys +) -> None: + class UnavailableSession: + @property + def handle(self): + raise NexusSessionError("no READY Nexus database instance found", kind="no_ready_instance") - exit_code = main(["batch", str(batch_path), "--lint"]) + def __init__(self, **_kwargs: object) -> None: + pass - assert exit_code == 1 - captured = capsys.readouterr() - assert "`idac search bytes` requires --timeout" in captured.out + def list_targets(self) -> list[dict[str, object]]: + return [] + def close(self) -> None: + pass -def test_batch_lint_accepts_batch_safe_no_context_commands(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("docs cli\ndoctor\n", encoding="utf-8") + monkeypatch.setattr("idac.nexus.NexusSession", UnavailableSession) + batch_path = tmp_path / "local.idac" + workspace = tmp_path / "workspace" + artifact = tmp_path / "batch.json" + batch_path.write_text(f"workspace init {workspace}\n", encoding="utf-8") + argv = ["batch", str(batch_path), "--out", str(artifact)] + if lint: + argv.append("--lint") - exit_code = main(["batch", str(batch_path), "--lint"]) + assert main(argv) == 0 - assert exit_code == 0 - payload = json.loads(capsys.readouterr().out) + payload = json.loads(artifact.read_text(encoding="utf-8")) assert payload["ok"] is True - assert payload["commands_total"] == 2 - assert payload["commands_linted"] == 2 - assert payload["errors"] == [] - - -def test_batch_lint_reports_preview_wrapped_missing_input_file(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("preview function prototype set main --decl-file missing.h\n", encoding="utf-8") - - exit_code = main(["batch", str(batch_path), "--lint"]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert "decl_file path does not exist" in captured.out - - -def test_batch_lint_reports_preview_wrapped_missing_required_timeout(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text('preview search bytes "74 69 6e 79" --segment __TEXT\n', encoding="utf-8") - - exit_code = main(["batch", str(batch_path), "--lint"]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert "`idac search bytes` requires --timeout" in captured.out + assert payload["results"][0]["status"] == "ok" + assert (workspace / "AGENTS.md").exists() is not lint + assert capsys.readouterr().out == "" -def test_batch_lint_reports_preview_wrapped_unsupported_command(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text('preview py exec --code "result = 1"\n', encoding="utf-8") - - exit_code = main(["batch", str(batch_path), "--lint"]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert "command is not available in preview mode" in captured.out - +def test_preview_does_not_publish_success_artifact_before_session_closes( + tmp_path: Path, + fake_nexus, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + database = tmp_path / "sample.i64" + artifact = tmp_path / "preview.json" + database.touch() -def test_batch_lint_rejects_preview_wrapped_incomplete_command_group(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - batch_path.write_text("preview function\n", encoding="utf-8") + def fail_close(self) -> None: + self.closed = True + raise NexusSessionError("lease release failed", kind="release_failed") - exit_code = main(["batch", str(batch_path), "--lint"]) + monkeypatch.setattr(fake_nexus, "close", fail_close) - assert exit_code == 1 - payload = json.loads(capsys.readouterr().out) - assert payload["ok"] is False - assert payload["errors_total"] == 1 - assert payload["results"][0]["exit_code"] == 2 - assert "usage: idac function" in payload["results"][0]["stderr"] - - -def test_batch_records_missing_local_apply_file_in_ordered_output(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.json" - batch_path.write_text( - "function locals apply -c db:/tmp/demo.i64 main --json-file missing-locals.json\n", - encoding="utf-8", + assert ( + main( + [ + "preview", + "-c", + str(database), + "-o", + str(artifact), + "comment", + "set", + "0x401000", + "entry", + ] + ) + == 1 ) - exit_code = main(["batch", str(batch_path), "-o", str(out_path)]) - - assert exit_code == 1 - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["ok"] is False - assert payload["commands_failed"] == 1 - row = payload["results"][0] - assert row["status"] == "failed" - assert row["exit_code"] == 1 - assert "failed to read local apply JSON file" in row["stderr"] - capsys.readouterr() - - -def test_batch_preserves_argparse_error_in_structured_output(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.json" - batch_path.write_text("does-not-exist\n", encoding="utf-8") - - exit_code = main(["batch", str(batch_path), "-o", str(out_path)]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "invalid choice: 'does-not-exist'" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert "invalid choice: 'does-not-exist'" in payload["results"][0]["stderr"] - - -def test_batch_persists_completed_rows_when_interrupted_mid_batch(tmp_path: Path, capsys, monkeypatch) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.jsonl" - batch_path.write_text("docs cli\ndocs cli\n", encoding="utf-8") - - from idac.cli2 import batch as batch_module - from idac.cli2.result import CommandResult + assert not artifact.exists() + assert "lease release failed" in capsys.readouterr().err - calls = {"count": 0} - def fake_execute(parsed, *, root_parser): - calls["count"] += 1 - if calls["count"] == 2: - raise KeyboardInterrupt - return CommandResult(render_op="docs", value={"ok": True}) - - monkeypatch.setattr(batch_module, "_execute_batch_args", fake_execute) - - with pytest.raises(KeyboardInterrupt): - main(["batch", str(batch_path), "-o", str(out_path)]) - - rows = [json.loads(line) for line in out_path.read_text(encoding="utf-8").splitlines() if line.strip()] - assert len(rows) == 1 - assert rows[0]["line"] == 1 - assert rows[0]["status"] == "ok" - capsys.readouterr() - - -def test_batch_records_incomplete_command_groups_in_structured_output(tmp_path: Path, capsys) -> None: - batch_path = tmp_path / "commands.txt" - out_path = tmp_path / "batch.json" - batch_path.write_text("function\nmisc\n", encoding="utf-8") - - exit_code = main(["batch", str(batch_path), "-o", str(out_path)]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "usage: idac function" in captured.err - assert "usage: idac misc" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["commands_failed"] == 2 - assert payload["results"][0]["exit_code"] == 2 - assert payload["results"][1]["exit_code"] == 2 - assert "usage: idac function" in payload["results"][0]["stderr"] - assert "usage: idac misc" in payload["results"][1]["stderr"] - - -def test_type_declare_failure_returns_exit_1_and_stderr(capsys, monkeypatch) -> None: - def fake_send_request(request): - return { - "ok": True, - "result": { - "aliases_applied": [], - "bisect": None, - "declaration_count": 1, - "diagnostics": [ - { - "kind": "unterminated_declaration", - "line": 1, - "message": "declaration does not end with a top-level semicolon", - } - ], - "errors": 1, - "imported_types": [], - "replace": False, - "replaced_types": [], - "success": False, - }, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["type", "declare", "-c", "db:/tmp/demo.i64", "--decl", "struct broken { int x;"]) - - assert exit_code == 1 - captured = capsys.readouterr() - payload = json.loads(captured.out) - assert payload["success"] is False - assert "type declare failed:" in captured.err - assert "line 1:" in captured.err - - -def test_prototype_check_failure_returns_exit_1_and_stderr(capsys, monkeypatch) -> None: - def fake_send_request(request): - return { - "ok": True, - "result": { - "address": "0x401000", - "success": False, - "parsed": False, - "is_function": False, - "arglocs_calculated": None, - "unknown_types": [], - "diagnostics": ["IDA failed to parse the prototype declaration"], - }, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) +def test_preview_child_cannot_switch_nexus_target(tmp_path: Path, fake_nexus, capsys) -> None: + outer = tmp_path / "outer.i64" + inner = tmp_path / "inner.i64" + outer.touch() + inner.touch() exit_code = main( [ - "function", - "prototype", - "check", + "preview", + "-c", + str(outer), + "-o", + str(tmp_path / "preview.json"), + "comment", + "set", + "0x401000", + "entry", "-c", - "db:/tmp/demo.i64", - "target", - "--decl", - "int target(", + str(inner), ] ) assert exit_code == 1 - captured = capsys.readouterr() - payload = json.loads(captured.out) - assert payload["success"] is False - assert "function prototype check failed:" in captured.err - assert "IDA failed to parse the prototype declaration" in captured.err - - -def test_preview_failure_writes_artifact_and_stderr_summary(tmp_path: Path, capsys, monkeypatch) -> None: - out_path = tmp_path / "preview.json" - - def fake_send_request(request): - return { - "ok": True, - "result": { - "aliases_applied": [], - "bisect": None, - "before": {"type_count": 1}, - "after": {"type_count": 1}, - "diagnostics": [ - { - "kind": "unterminated_declaration", - "line": 1, - "message": "declaration does not end with a top-level semicolon", - } - ], - "errors": 1, - "imported_types": [], - "replace": False, - "replaced_types": [], - "result": {"success": False}, - "success": False, - }, - "warnings": [], - } + assert "child commands cannot switch Nexus targets" in capsys.readouterr().err + assert all(not session.calls for session in fake_nexus.instances) - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) + +def test_preview_child_cannot_override_wrapper_timeout(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + database.touch() exit_code = main( [ "preview", - "-o", - str(out_path), "-c", - "db:/tmp/demo.i64", - "type", - "declare", - "--decl", - "struct broken { int x;", + str(database), + "--timeout", + "12", + "-o", + str(tmp_path / "preview.json"), + "comment", + "set", + "0x401000", + "entry", + "--timeout", + "1", ] ) assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "type declare failed:" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["status"] == "failed" - assert any("type declare failed:" in line for line in payload["stderr"]) - - -def test_decompilemany_failure_prints_stderr_summary(tmp_path: Path, capsys, monkeypatch) -> None: - out_dir = tmp_path / "out" - - monkeypatch.setattr( - "idac.cli2.commands.top_level._decompilemany_targets", - lambda args: [ - {"identifier": "ok", "name": "ok", "address": "0x1"}, - {"identifier": "bad", "name": "bad", "address": "0x2"}, - ], - ) - - def fake_single(args, *, identifier: str) -> dict[str, object]: - if identifier == "bad": - raise CliUserError("symbol not found: bad") - return {"text": "int ok(void) { return 0; }\n"} - - monkeypatch.setattr("idac.cli2.commands.top_level._run_single_decompile", fake_single) - - exit_code = main(["decompilemany", "demo", "--out-dir", str(out_dir), "-c", "db:/tmp/demo.i64"]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert "out_dir:" in captured.out - assert "decompilemany failed for 1/2 function(s)" in captured.err - assert "bad: symbol not found: bad" in captured.err - manifest = json.loads((out_dir / "manifest.json").read_text(encoding="utf-8")) - assert manifest["functions_failed"] == 1 - - -def test_decompilemany_out_file_keeps_raw_text_with_json_suffix(tmp_path: Path, capsys, monkeypatch) -> None: - out_file = tmp_path / "combined.json" - - monkeypatch.setattr( - "idac.cli2.commands.top_level._decompilemany_targets", - lambda args: [ - {"identifier": "first", "name": "first", "address": "0x1"}, - {"identifier": "second", "name": "second", "address": "0x2"}, - ], - ) - - def fake_single(args, *, identifier: str) -> dict[str, object]: - return {"text": f"int {identifier}(void) {{ return 0; }}\n"} - - monkeypatch.setattr("idac.cli2.commands.top_level._run_single_decompile", fake_single) - - exit_code = main(["decompilemany", "demo", "--out-file", str(out_file), "-c", "db:/tmp/demo.i64"]) - - assert exit_code == 0 - capsys.readouterr() - content = out_file.read_text(encoding="utf-8") - assert content.startswith("int first(void)") - assert "\nint second(void)" in content - with pytest.raises(json.JSONDecodeError): - json.loads(content) - - -def test_decompilemany_writes_optional_disasm_and_ctree_artifacts(tmp_path: Path, capsys, monkeypatch) -> None: - out_dir = tmp_path / "out" - - monkeypatch.setattr( - "idac.cli2.commands.top_level._decompilemany_targets", - lambda args: [{"identifier": "main", "name": "main", "address": "0x1000"}], - ) - monkeypatch.setattr( - "idac.cli2.commands.top_level._run_single_decompile", - lambda args, *, identifier: {"text": "int main(void) { return 0; }\n"}, - ) + assert "child commands cannot set --timeout" in capsys.readouterr().err + assert all(not session.calls for session in fake_nexus.instances) - def fake_text_op(args, *, op: str, identifier: str) -> dict[str, object]: - return {"text": f"{op} for {identifier}\n"} - monkeypatch.setattr("idac.cli2.commands.top_level._run_single_text_op", fake_text_op) +def test_preview_child_inherits_wrapper_timeout_for_validation(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + artifact = tmp_path / "preview.json" + database.touch() - exit_code = main( - ["decompilemany", "main", "--out-dir", str(out_dir), "--disasm", "--ctree", "-c", "db:/tmp/demo.i64"] + assert ( + main( + [ + "preview", + "-c", + str(database), + "--timeout", + "12", + "-o", + str(artifact), + "search", + "bytes", + "90", + "--segment", + ".text", + ] + ) + == 0 ) - assert exit_code == 0 + assert len(fake_nexus.instances) == 1 + session = fake_nexus.instances[0] + assert session.timeout == 12.0 + assert [call["op"] for call in session.calls] == ["search_bytes"] + assert json.loads(artifact.read_text(encoding="utf-8"))["status"] == "ok" capsys.readouterr() - manifest = json.loads((out_dir / "manifest.json").read_text(encoding="utf-8")) - entry = manifest["functions"][0] - assert entry["address"] == "0x1000" - assert entry["artifacts"]["decompile"].endswith(".c") - assert entry["artifacts"]["disasm"].endswith(".asm") - assert entry["artifacts"]["ctree"].endswith(".ctree") - assert Path(entry["artifacts"]["decompile"]).stem.endswith("_0x1000") - assert Path(entry["artifacts"]["decompile"]).read_text(encoding="utf-8").startswith("int main") - assert Path(entry["artifacts"]["disasm"]).read_text(encoding="utf-8") == "disasm for main\n" - assert Path(entry["artifacts"]["ctree"]).read_text(encoding="utf-8") == "ctree for main\n" - - -def test_decompilemany_rejects_optional_artifacts_with_out_file(tmp_path: Path, capsys) -> None: - out_file = tmp_path / "combined.c" - - exit_code = main(["decompilemany", "main", "--out-file", str(out_file), "--disasm", "-c", "db:/tmp/demo.i64"]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert "decompilemany --disasm/--ctree require --out-dir" in captured.err - assert not out_file.exists() - - -def test_decompilemany_long_artifact_stems_keep_suffix_and_stable_digest(tmp_path: Path, capsys, monkeypatch) -> None: - name = "VeryLongTemplateName_" + ("MiddleComponent_" * 12) + "ImportantTail" - manifests = [] - - monkeypatch.setattr( - "idac.cli2.commands.top_level._decompilemany_targets", - lambda args: [{"identifier": "0x1000", "name": name, "address": "0x1000"}], - ) - - for index, body in enumerate(("return 0", "return 1")): - out_dir = tmp_path / f"out_{index}" - - def fake_single(args, *, identifier: str, body: str = body) -> dict[str, object]: - return {"text": f"int demo(void) {{ {body}; }}\n"} - monkeypatch.setattr("idac.cli2.commands.top_level._run_single_decompile", fake_single) - exit_code = main(["decompilemany", "demo", "--out-dir", str(out_dir), "-c", "db:/tmp/demo.i64"]) +def test_batch_reuses_one_wrapper_session_for_all_children(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "read.idac" + database.touch() + batch_file.write_text("database show --json\ndatabase show --json\n", encoding="utf-8") - assert exit_code == 0 - capsys.readouterr() - manifests.append(json.loads((out_dir / "manifest.json").read_text(encoding="utf-8"))) + assert main(["batch", "-c", str(database), str(batch_file)]) == 0 - first_entry = manifests[0]["functions"][0] - second_entry = manifests[1]["functions"][0] - stem = first_entry["artifact_stem"] - assert first_entry["address"] == "0x1000" - assert first_entry["filename_truncated"] is True - assert stem == second_entry["artifact_stem"] - assert stem.startswith("VeryLongTemplateName_") - assert "ImportantTail" in stem - assert stem.endswith("_0x1000") - assert len(Path(first_entry["artifact_path"]).stem) <= 180 - - -def test_doctor_with_out_prints_error_summary(tmp_path: Path, capsys, monkeypatch) -> None: - out_path = tmp_path / "doctor.json" - - captured = {} - - def fake_run_doctor(**kwargs): - captured.update(kwargs) - return { - "backend": [], - "healthy": False, - "status": "error", - "checks": [ - { - "component": "gui", - "name": "bridge_targets", - "status": "error", - "summary": "no running GUI bridge instances found", - } - ], - } - - monkeypatch.setattr( - "idac.cli2.commands.doctor.run_doctor", - fake_run_doctor, - ) - - exit_code = main(["doctor", "--out", str(out_path)]) - - assert exit_code == 1 - assert captured["scope"] == "all" - assert "database" not in captured - captured = capsys.readouterr() - assert captured.out == "" - assert "doctor failed: status=error" in captured.err - assert "gui.bridge_targets: no running GUI bridge instances found" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["healthy"] is False - - -def test_function_list_with_out_prints_count_summary(tmp_path: Path, capsys, monkeypatch) -> None: - out_path = tmp_path / "functions.json" - - def fake_send_request(request): - return { - "ok": True, - "result": [ - {"address": "0x1000", "name": "CMessag::init"}, - {"address": "0x1010", "name": "CMessag::run"}, - ], - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["function", "list", "CMessag", "-c", "db:/tmp/demo.i64", "--json", "--out", str(out_path)]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert f"wrote 2 functions to {out_path}" in captured.err - assert "inspect that file for the full result" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert len(payload) == 2 - - -def test_database_open_with_out_prints_generic_artifact_notice(tmp_path: Path, capsys, monkeypatch) -> None: - database_path = tmp_path / "tiny.i64" - out_path = tmp_path / "open.json" - - def fake_send_request(request): - return { - "ok": True, - "result": {"database": str(database_path), "opened": True}, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["database", "open", str(database_path), "--out", str(out_path)]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert f"wrote result to {out_path}" in captured.err - assert "inspect that file for the full result" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["opened"] is True - - -def test_decompile_with_out_prints_specific_artifact_notice(tmp_path: Path, capsys, monkeypatch) -> None: - out_path = tmp_path / "main.c" - - def fake_send_request(request): - return { - "ok": True, - "result": {"text": "int main(void)\n{\n return 0;\n}\n"}, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["decompile", "main", "-c", "db:/tmp/demo.i64", "--out", str(out_path)]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert f"wrote decompile text to {out_path}" in captured.err - assert "inspect that file for the full result" in captured.err - assert out_path.read_text(encoding="utf-8").startswith("int main") - - -def test_large_decompile_output_suggests_dash_o(capsys, monkeypatch) -> None: - def fake_send_request(request): - return { - "ok": True, - "result": {"text": "x" * 12050}, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) + assert len(fake_nexus.instances) == 1 + session = fake_nexus.instances[0] + assert [call["op"] for call in session.calls] == ["database_info", "database_info"] + assert session.closed is True + payload = json.loads(capsys.readouterr().out) + assert payload["commands_succeeded"] == 2 + assert payload["commands_failed"] == 0 - exit_code = main(["decompile", "main", "-c", "db:/tmp/demo.i64"]) - assert exit_code == 1 - captured = capsys.readouterr() - assert "rerun with `-o ` to write the full decompile to a file" in captured.err - assert captured.out.startswith("x") +def test_batch_children_inherit_wrapper_timeout_for_validation(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "search.idac" + database.touch() + batch_file.write_text("search bytes 90 --segment .text\n", encoding="utf-8") + assert main(["batch", "-c", str(database), "--timeout", "12", str(batch_file)]) == 0 -def test_large_locals_output_suggests_json_out(capsys, monkeypatch) -> None: - locals_rows = [ - { - "index": i, - "local_id": f"stack({i * 8})@0x100000460", - "definition_address": "0x100000460", - "location": f"stack({i * 8})", - "name": f"local_{i}_{'x' * 80}", - "display_name": f"local_{i}_{'x' * 80}", - "type": "unsigned int", - "is_arg": False, - "is_stack": True, - "stack_offset": i * 8, - "size": 4, - } - for i in range(80) - ] + assert len(fake_nexus.instances) == 1 + session = fake_nexus.instances[0] + assert session.timeout == 12.0 + assert [call["op"] for call in session.calls] == ["search_bytes"] + payload = json.loads(capsys.readouterr().out) + assert payload["commands_succeeded"] == 1 + assert payload["commands_failed"] == 0 - def fake_send_request(request): - return { - "ok": True, - "result": { - "function": "main", - "address": "0x100000460", - "locals": locals_rows, - }, - "warnings": [], - } - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) +def test_batch_child_cannot_override_wrapper_timeout(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + batch_file = tmp_path / "read.idac" + database.touch() + batch_file.write_text("database show --timeout 1 --json\n", encoding="utf-8") - exit_code = main(["function", "locals", "list", "main", "-c", "db:/tmp/demo.i64"]) + assert main(["batch", "-c", str(database), "--timeout", "12", str(batch_file)]) == 1 - assert exit_code == 1 + assert all(not session.calls for session in fake_nexus.instances) captured = capsys.readouterr() - assert "rerun with `--json --out ` to inspect the full locals table" in captured.err - assert "main @ 0x100000460" in captured.out - - -def test_preview_success_with_out_prints_artifact_notice(tmp_path: Path, capsys, monkeypatch) -> None: - out_path = tmp_path / "preview.json" - - def fake_send_request(request): - return { - "ok": True, - "result": { - "before": {"comment": None}, - "after": {"comment": "entry point"}, - "result": {"comment": "entry point"}, - "preview_mode": "undo", - "persisted": False, - }, - "warnings": [], - } - - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main( - ["preview", "-o", str(out_path), "-c", "db:/tmp/demo.i64", "comment", "set", "main", "entry point"] - ) + payload = json.loads(captured.out) + assert payload["commands_succeeded"] == 0 + assert payload["commands_failed"] == 1 + assert "child commands cannot set --timeout" in payload["results"][0]["stderr"] - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert f"wrote preview data to {out_path}" in captured.err - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["status"] == "ok" +def test_batch_without_selector_accepts_context_free_children(tmp_path: Path, fake_nexus, capsys) -> None: + batch_file = tmp_path / "workspace.idac" + batch_file.write_text(f"workspace init {tmp_path / 'scaffold'}\n", encoding="utf-8") -def test_database_open_uses_idalib_backend(monkeypatch, capsys, tmp_path: Path) -> None: - captured = {} + assert main(["batch", str(batch_file)]) == 0 - def fake_send_request(request): - captured["request"] = request - return { - "ok": True, - "result": {"database": str(tmp_path / "tiny.i64"), "opened": True}, - "warnings": [], - } + payload = json.loads(capsys.readouterr().out) + assert payload["commands_succeeded"] == 1 + assert payload["commands_failed"] == 0 - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - exit_code = main(["database", "open", str(tmp_path / "tiny.i64"), "--format", "json"]) +def test_python_exec_renders_json_and_rejects_removed_persist_option(tmp_path: Path, fake_nexus, capsys) -> None: + database = tmp_path / "sample.i64" + database.touch() - assert exit_code == 0 - assert captured["request"].backend == "idalib" - assert captured["request"].database is None - assert json.loads(capsys.readouterr().out)["opened"] is True + assert main(["py", "exec", "-c", str(database), "--code", "result = 7", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == { + "result": 7, + "result_repr": "7", + "stderr": "", + "stdout": "hello\n", + } + with pytest.raises(SystemExit) as exc: + build_parser().parse_args(["py", "exec", "--code", "pass", "--persist"]) + assert exc.value.code == 2 -def test_bookmark_show_invalid_slot_returns_user_error(capsys) -> None: - exit_code = main(["bookmark", "show", "-c", FIXTURE_DB, "abc"]) - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "bookmark slot" in captured.err - assert "Traceback" not in captured.err +def test_targets_list_uses_public_discovery_seam(monkeypatch, capsys) -> None: + calls: list[float | None] = [] + + def list_targets(*, timeout: float | None = None) -> list[dict[str, Any]]: + calls.append(timeout) + return [ + { + "record_id": "gui-1", + "state": "ready", + "detail": None, + "backend": "gui", + "pid": 123, + "idb_path": "/tmp/sample.i64", + "exe_path": None, + "managed": False, + "started_at": 1.0, + } + ] + monkeypatch.setattr("idac.nexus.list_targets", list_targets) -def test_type_declare_missing_decl_file_returns_user_error(tmp_path: Path, capsys) -> None: - missing = tmp_path / "missing.h" + assert main(["targets", "list", "--timeout", "3", "--json"]) == 0 + assert calls == [3.0] + assert json.loads(capsys.readouterr().out)[0]["record_id"] == "gui-1" - exit_code = main(["type", "declare", "-c", FIXTURE_DB, "--decl-file", str(missing)]) - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert str(missing) in captured.err - assert "Traceback" not in captured.err +def test_setup_gui_command_forwards_timeout_and_renders_result(monkeypatch, capsys) -> None: + timeouts: list[float | None] = [] + def setup_gui(*, timeout: float | None = None) -> dict[str, Any]: + timeouts.append(timeout) + return {"installed": True, "plugin": "ida-nexus", "version": metadata.version("ida-nexus")} -def test_root_context_is_rejected_for_contextless_command(tmp_path: Path, capsys) -> None: - dest = tmp_path / "workspace" + monkeypatch.setattr("idac.cli.commands.setup.setup_gui", setup_gui) - exit_code = main(["-c", "db:/tmp/demo.i64", "workspace", "init", str(dest)]) + assert main(["setup", "gui", "--timeout", "9", "--json"]) == 0 + assert timeouts == [9.0] + assert json.loads(capsys.readouterr().out)["plugin"] == "ida-nexus" - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "`idac workspace init` does not accept -c/--context" in captured.err +def test_doctor_command_forwards_timeout_and_renders_result(monkeypatch, capsys) -> None: + timeouts: list[float | None] = [] -def test_root_timeout_is_rejected_for_contextless_command(tmp_path: Path, capsys) -> None: - dest = tmp_path / "workspace" + def run_doctor(*, timeout: float | None = None) -> dict[str, Any]: + timeouts.append(timeout) + return {"healthy": True, "status": "ok", "checks": []} - exit_code = main(["--timeout", "5", "workspace", "init", str(dest)]) + monkeypatch.setattr("idac.cli.commands.doctor.run_doctor", run_doctor) - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "`idac workspace init` does not accept --timeout" in captured.err + assert main(["doctor", "--timeout", "4", "--json"]) == 0 + assert timeouts == [4.0] + assert json.loads(capsys.readouterr().out)["healthy"] is True diff --git a/tests/test_cli2_request_builders.py b/tests/test_cli2_request_builders.py deleted file mode 100644 index b14d7ed..0000000 --- a/tests/test_cli2_request_builders.py +++ /dev/null @@ -1,511 +0,0 @@ -from __future__ import annotations - -import argparse -import io -import json -from pathlib import Path - -import pytest - -from idac.cli2.commands import ( - bookmark, - comment, - common, - database, - function, - python_exec, - search, - segment, - top_level, - type_commands, -) - - -def test_local_update_params_prefers_stable_selector_without_positional_selector() -> None: - args = argparse.Namespace( - function="main", - selector=None, - local_id="stack(-16)@0x401000", - index=None, - rename="sum_value", - decl="unsigned int sum_value;", - decl_file=None, - ) - - assert common.local_update_params(args) == { - "identifier": "main", - "local_id": "stack(-16)@0x401000", - "new_name": "sum_value", - "decl": "unsigned int sum_value;", - } - - -def test_local_rename_params_accepts_index_without_positional_selector() -> None: - args = argparse.Namespace( - function="main", - selector=None, - local_id=None, - index="4", - new_name="msgBufferPtr", - ) - - assert common.local_rename_params(args) == { - "identifier": "main", - "index": 4, - "new_name": "msgBufferPtr", - } - - -def test_local_rename_params_accepts_positional_selector_and_name() -> None: - args = argparse.Namespace( - function="main", - selector="v4", - local_id=None, - index=None, - new_name="msgBufferPtr", - ) - - assert common.local_rename_params(args) == { - "identifier": "main", - "old_name": "v4", - "new_name": "msgBufferPtr", - } - - -def test_local_retype_params_accepts_local_id_without_positional_selector() -> None: - args = argparse.Namespace( - function="main", - selector=None, - local_id="stack(-16)@0x401000", - index=None, - decl="unsigned int msgBufferPtr;", - decl_file=None, - type_text=None, - ) - - assert common.local_retype_params(args) == { - "identifier": "main", - "local_id": "stack(-16)@0x401000", - "decl": "unsigned int msgBufferPtr;", - } - - -def test_local_retype_params_accepts_type_shorthand() -> None: - args = argparse.Namespace( - function="main", - selector="v4", - local_id=None, - index=None, - decl=None, - decl_file=None, - type_text="unsigned int", - ) - - assert common.local_retype_params(args) == { - "identifier": "main", - "local_name": "v4", - "decl": "unsigned int __idac_local;", - } - - -def test_local_rename_params_rejects_missing_selector() -> None: - args = argparse.Namespace( - function="main", - selector=None, - local_id=None, - index=None, - new_name="msgBufferPtr", - ) - - with pytest.raises(common.CliUserError, match="local selector is required via selector, --local-id, or --index"): - common.local_rename_params(args) - - -def test_local_rename_params_rejects_positional_selector_with_stable_selector() -> None: - args = argparse.Namespace( - function="main", - selector="v4", - local_id=None, - index="4", - new_name="msgBufferPtr", - ) - - with pytest.raises(common.CliUserError, match="do not combine a positional selector with --local-id or --index"): - common.local_rename_params(args) - - -def test_local_retype_params_rejects_multiple_stable_selectors() -> None: - args = argparse.Namespace( - function="main", - selector=None, - local_id="stack(-16)@0x401000", - index="4", - decl="unsigned int msgBufferPtr;", - decl_file=None, - type_text=None, - ) - - with pytest.raises(common.CliUserError, match="--local-id and --index are mutually exclusive"): - common.local_retype_params(args) - - -def test_local_retype_params_rejects_positional_selector_with_stable_selector() -> None: - args = argparse.Namespace( - function="main", - selector="v4", - local_id=None, - index="4", - decl="unsigned int msgBufferPtr;", - decl_file=None, - type_text=None, - ) - - with pytest.raises(common.CliUserError, match="do not combine a positional selector with --local-id or --index"): - common.local_retype_params(args) - - -def test_local_update_params_rejects_multiple_stable_selectors() -> None: - args = argparse.Namespace( - function="main", - selector=None, - local_id="stack(-16)@0x401000", - index="4", - rename="sum_value", - decl="unsigned int sum_value;", - decl_file=None, - ) - - with pytest.raises(common.CliUserError, match="--local-id and --index are mutually exclusive"): - common.local_update_params(args) - - -def test_local_update_params_rejects_positional_selector_with_stable_selector() -> None: - args = argparse.Namespace( - function="main", - selector="v4", - local_id=None, - index="4", - rename="sum_value", - decl="unsigned int sum_value;", - decl_file=None, - ) - - with pytest.raises(common.CliUserError, match="do not combine a positional selector with --local-id or --index"): - common.local_update_params(args) - - -def test_decompilemany_request_captures_modes(tmp_path: Path) -> None: - args = argparse.Namespace( - patterns=["demo"], - file=None, - out_file=None, - out_dir=tmp_path / "out", - regex=True, - ignore_case=False, - no_cache=True, - disasm=False, - ctree=False, - ) - - request = top_level._decompilemany_request(args) - - assert request.pattern == "demo" - assert request.out_dir == tmp_path / "out" - assert request.regex is True - assert request.no_cache is True - assert request.include_disasm is False - assert request.include_ctree is False - - -def test_decompilemany_request_captures_extra_positionals(tmp_path: Path) -> None: - args = argparse.Namespace( - patterns=["main", "add", "sub_1000"], - file=None, - out_file=None, - out_dir=tmp_path / "out", - regex=False, - ignore_case=False, - no_cache=False, - disasm=False, - ctree=False, - ) - - request = top_level._decompilemany_request(args) - - assert request.pattern == "main" - assert request.extra_patterns == ("add", "sub_1000") - assert request.include_disasm is False - assert request.include_ctree is False - - -def test_disasm_request_accepts_function_identifier() -> None: - args = argparse.Namespace(function="main", start=None, end=None) - - request = top_level.disasm_request(args) - - assert request.op == "disasm" - assert request.params == {"identifier": "main"} - - -def test_disasm_request_accepts_range() -> None: - args = argparse.Namespace(function=None, start="0x1000", end="0x1010") - - request = top_level.disasm_request(args) - - assert request.op == "disasm_range" - assert request.params == {"start": "0x1000", "end": "0x1010"} - - -def test_disasm_request_rejects_missing_selector() -> None: - args = argparse.Namespace(function=None, start=None, end=None) - - with pytest.raises(top_level.CliUserError, match="disasm requires a function or --start/--end"): - top_level.disasm_request(args) - - -def test_python_exec_params_read_stdin(monkeypatch: pytest.MonkeyPatch) -> None: - args = argparse.Namespace(code=None, stdin=True, script=None, persist=True) - monkeypatch.setattr("sys.stdin", io.StringIO("print('hi')\n")) - - assert python_exec._python_exec_params(args) == {"script": "print('hi')\n", "persist": True} - - -def test_python_exec_params_preserve_script_path(tmp_path: Path) -> None: - script = tmp_path / "script.py" - script.write_text("result = __file__\n", encoding="utf-8") - args = argparse.Namespace(code=None, stdin=False, script=script, persist=False) - - assert python_exec._python_exec_params(args) == {"script_path": str(script)} - - -def test_python_exec_params_resolve_relative_script_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - script = tmp_path / "script.py" - script.write_text("result = __file__\n", encoding="utf-8") - monkeypatch.chdir(tmp_path) - args = argparse.Namespace(code=None, stdin=False, script=Path("script.py"), persist=False) - - assert python_exec._python_exec_params(args) == {"script_path": str(script.resolve())} - - -def test_python_exec_params_reject_missing_script_path(tmp_path: Path) -> None: - args = argparse.Namespace(code=None, stdin=False, script=tmp_path / "missing.py", persist=False) - - with pytest.raises(python_exec.CliUserError, match="script file not found"): - python_exec._python_exec_params(args) - - -def test_locals_apply_plan_params_accepts_items_object(tmp_path: Path) -> None: - plan_path = tmp_path / "locals.json" - plan_path.write_text( - json.dumps({"items": [{"local_id": "stack(16)@0x401000", "rename": "count"}]}), - encoding="utf-8", - ) - args = argparse.Namespace(function="sub_401000", json_file=plan_path) - - assert function._locals_apply_plan_params(args) == { - "identifier": "sub_401000", - "items": [{"local_id": "stack(16)@0x401000", "rename": "count"}], - } - - -def test_strings_params_switch_between_scan_and_pattern_modes() -> None: - pattern_args = argparse.Namespace( - pattern="hello", - segment="__TEXT", - start=None, - end=None, - regex=False, - ignore_case=True, - scan=False, - ) - scan_args = argparse.Namespace( - pattern="needle", - segment="__TEXT", - start="0x1000", - end="0x2000", - regex=False, - ignore_case=False, - scan=True, - ) - - assert search._strings_params(pattern_args) == { - "pattern": "hello", - "regex": False, - "ignore_case": True, - "segment": "__TEXT", - } - assert search._strings_params(scan_args) == { - "pattern": "needle", - "regex": False, - "ignore_case": False, - "segment": "__TEXT", - "scan": True, - "start": "0x1000", - "end": "0x2000", - } - - -def test_strings_params_reject_scan_bounds_without_scan_flag() -> None: - args = argparse.Namespace( - pattern="needle", - segment="__TEXT", - start="0x1000", - end="0x2000", - regex=False, - ignore_case=False, - scan=False, - ) - - with pytest.raises(common.CliUserError, match="`--start` and `--end` are only valid with `search strings --scan`"): - search._strings_params(args) - - -def test_search_bytes_params_include_segment_scope() -> None: - args = argparse.Namespace( - pattern="74 69 6e 79", - segment="__TEXT", - limit=25, - start="0x1000", - end="0x2000", - ) - - assert search._bytes_params(args) == { - "pattern": "74 69 6e 79", - "segment": "__TEXT", - "limit": 25, - "start": "0x1000", - "end": "0x2000", - } - - -def test_function_list_params_include_regex_flag_and_optional_segment() -> None: - args = argparse.Namespace( - pattern="GlobalHEIFInfo|HEIFGroupItem", - query=None, - regex=True, - ignore_case=False, - segment="__TEXT", - limit=25, - demangle=True, - ) - - assert function._list_params(args) == { - "pattern": "GlobalHEIFInfo|HEIFGroupItem", - "regex": True, - "ignore_case": False, - "demangle": True, - "segment": "__TEXT", - "limit": 25, - } - - -def test_function_list_params_accept_query_alias() -> None: - args = argparse.Namespace( - pattern=None, - query="PNG", - regex=False, - ignore_case=True, - segment=None, - limit=None, - demangle=False, - ) - - assert function._list_params(args) == { - "pattern": "PNG", - "regex": False, - "ignore_case": True, - "demangle": False, - } - - -def test_segment_list_params_include_pattern_flags() -> None: - args = argparse.Namespace(pattern="__TEXT|__cstring", regex=True, ignore_case=True) - - assert segment._list_params(args) == { - "pattern": "__TEXT|__cstring", - "regex": True, - "ignore_case": True, - } - - -def test_type_declare_params_build_aliases_and_flags() -> None: - args = argparse.Namespace( - decl="typedef int OLD;", - decl_file=None, - replace=True, - alias=["OLD=NEW"], - bisect=True, - clang=True, - ) - - assert type_commands._type_declare_params(args) == { - "decl": "typedef int OLD;", - "replace": True, - "aliases": [{"from": "OLD", "to": "NEW"}], - "bisect": True, - "clang": True, - } - - -def test_prototype_set_params_omit_default_caller_propagation() -> None: - args = argparse.Namespace( - function="add", - decl="void __fastcall add(int value);", - decl_file=None, - propagate_callers=False, - _preview_wrapper=False, - ) - - assert function._prototype_set_params(args) == {"identifier": "add", "decl": "void __fastcall add(int value);"} - - -def test_prototype_set_params_include_opt_in_for_caller_propagation() -> None: - args = argparse.Namespace( - function="add", - decl="void __fastcall add(int value);", - decl_file=None, - propagate_callers=True, - _preview_wrapper=False, - ) - - assert function._prototype_set_params(args) == { - "identifier": "add", - "decl": "void __fastcall add(int value);", - "propagate_callers": True, - } - - -def test_bookmark_and_database_params_builders_omit_optional_fields() -> None: - bookmark_args = argparse.Namespace(slot="3", identifier="main", comment=None) - save_args = argparse.Namespace(path=None) - - assert bookmark._bookmark_set_params(bookmark_args) == {"slot": 3, "address": "main"} - assert database._database_save_params(save_args) == {} - - -def test_comment_params_builders_capture_scope_and_repeatable() -> None: - lookup_args = argparse.Namespace(identifier="main", repeatable=True, scope="function") - anterior_args = argparse.Namespace(identifier="main", repeatable=False, scope="anterior") - change_args = argparse.Namespace(identifier="main", text="entry", repeatable=False, scope="posterior") - - assert comment._comment_lookup_params(lookup_args) == { - "address": "main", - "scope": "function", - "repeatable": True, - } - assert comment._comment_lookup_params(anterior_args) == { - "address": "main", - "scope": "anterior", - } - assert comment._comment_change_params(change_args) == { - "address": "main", - "text": "entry", - "scope": "posterior", - } - - -def test_comment_params_builders_reject_repeatable_extra_comments() -> None: - args = argparse.Namespace(identifier="main", repeatable=True, scope="anterior") - - with pytest.raises(common.CliUserError, match="--repeatable is only valid for line or function comments"): - comment._comment_lookup_params(args) diff --git a/tests/test_cli_request_builders.py b/tests/test_cli_request_builders.py new file mode 100644 index 0000000..fe586b2 --- /dev/null +++ b/tests/test_cli_request_builders.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, ClassVar + +import pytest + +from idac.cli import main + + +@pytest.fixture +def database(tmp_path: Path) -> Path: + path = tmp_path / "sample.i64" + path.touch() + return path + + +@pytest.fixture +def capture_nexus(monkeypatch: pytest.MonkeyPatch): + class CaptureSession: + instances: ClassVar[list[CaptureSession]] = [] + + def __init__(self, **_kwargs: object) -> None: + self.handle = SimpleNamespace(instance=SimpleNamespace(idb_path=_kwargs.get("locator"), exe_path=None)) + self.calls: list[dict[str, Any]] = [] + self.__class__.instances.append(self) + + def execute_operation( + self, + op: str, + params: dict[str, Any], + *, + preview: bool, + operation_label: str, + ) -> Any: + self.calls.append({"op": op, "params": params}) + if op == "function_list": + return [{"name": "demo", "address": "0x1000"}] + if op in {"decompile", "disasm", "disasm_range", "ctree"}: + return {"text": f"{op} output\n"} + if op in {"type_declare", "type_declare_check"}: + return {"success": True, "errors": 0} + return {} + + def close(self) -> None: + return None + + monkeypatch.setattr("idac.nexus.NexusSession", CaptureSession) + return CaptureSession + + +def test_prototype_set_can_preserve_existing_calling_convention(database: Path, capture_nexus, capsys) -> None: + assert ( + main( + ["function", "prototype", "set", "main", "--preserve-cc", "--decl", "int main(void);", "-c", str(database)] + ) + == 0 + ) + assert capture_nexus.instances[0].calls[0]["params"] == { + "identifier": "main", + "decl": "int main(void);", + "preserve_cc": True, + } + capsys.readouterr() + + +def test_local_update_uses_stable_selector_on_the_wire(database: Path, capture_nexus, capsys) -> None: + assert ( + main( + [ + "function", + "locals", + "update", + "main", + "--local-id", + "stack(-16)@0x401000", + "--rename", + "sum_value", + "--decl", + "unsigned int sum_value;", + "-c", + str(database), + ] + ) + == 0 + ) + + assert capture_nexus.instances[0].calls[0]["params"] == { + "identifier": "main", + "local_id": "stack(-16)@0x401000", + "new_name": "sum_value", + "decl": "unsigned int sum_value;", + } + capsys.readouterr() + + +@pytest.mark.parametrize( + ("selector_args", "expected_selector"), + [ + (["v4"], {"old_name": "v4"}), + (["--index", "4"], {"index": 4}), + ], +) +def test_local_rename_selector_forms_reach_the_wire( + selector_args: list[str], + expected_selector: dict[str, object], + database: Path, + capture_nexus, + capsys, +) -> None: + assert ( + main( + [ + "function", + "locals", + "rename", + "main", + *selector_args, + "--new-name", + "msg_buffer", + "-c", + str(database), + ] + ) + == 0 + ) + + assert capture_nexus.instances[0].calls[0]["params"] == { + "identifier": "main", + "new_name": "msg_buffer", + **expected_selector, + } + capsys.readouterr() + + +def test_local_retype_expands_type_shorthand_on_the_wire(database: Path, capture_nexus, capsys) -> None: + assert ( + main( + [ + "function", + "locals", + "retype", + "main", + "v4", + "--type", + "unsigned int", + "-c", + str(database), + ] + ) + == 0 + ) + + assert capture_nexus.instances[0].calls[0]["params"] == { + "identifier": "main", + "local_name": "v4", + "decl": "unsigned int __idac_local;", + } + capsys.readouterr() + + +@pytest.mark.parametrize( + ("command", "message"), + [ + ( + ["function", "locals", "rename", "main", "--new-name", "renamed"], + "local selector is required", + ), + ( + [ + "function", + "locals", + "rename", + "main", + "v4", + "--index", + "4", + "--new-name", + "renamed", + ], + "do not combine a positional selector", + ), + ( + [ + "function", + "locals", + "retype", + "main", + "--local-id", + "stack(-16)@0x401000", + "--index", + "4", + "--type", + "int", + ], + "--local-id and --index are mutually exclusive", + ), + ], +) +def test_local_selector_conflicts_fail_before_dispatch( + command: list[str], + message: str, + database: Path, + capture_nexus, + capsys, +) -> None: + assert main([*command, "-c", str(database)]) == 1 + + assert message in capsys.readouterr().err + assert all(not session.calls for session in capture_nexus.instances) + + +def test_decompilemany_writes_requested_artifacts_and_manifest( + database: Path, + capture_nexus, + tmp_path: Path, + capsys, +) -> None: + out_dir = tmp_path / "out" + + assert ( + main( + [ + "decompilemany", + "demo", + "--out-dir", + str(out_dir), + "--regex", + "--f5", + "--disasm", + "--ctree", + "-c", + str(database), + ] + ) + == 0 + ) + + manifest = json.loads((out_dir / "manifest.json").read_text(encoding="utf-8")) + assert manifest["pattern"] == "demo" + assert manifest["functions_total"] == 1 + assert manifest["functions_succeeded"] == 1 + assert manifest["functions_failed"] == 0 + + [function] = manifest["functions"] + assert function["identifier"] == "demo" + assert function["ok"] is True + assert set(function["artifacts"]) == {"decompile", "disasm", "ctree"} + for kind, path in function["artifacts"].items(): + assert Path(path).read_text(encoding="utf-8") == f"{kind} output\n" + + calls = capture_nexus.instances[0].calls + assert { + "op": "function_list", + "params": {"pattern": "demo", "regex": True, "ignore_case": False}, + } in calls + assert {"op": "decompile", "params": {"identifier": "demo", "no_cache": True}} in calls + capsys.readouterr() + + +def test_decompilemany_rejects_extra_positional_filters(database: Path, capture_nexus, tmp_path: Path, capsys) -> None: + assert ( + main( + [ + "decompilemany", + "main", + "helper", + "--out-dir", + str(tmp_path / "out"), + "-c", + str(database), + ] + ) + == 1 + ) + + assert "accepts one FUNCTION_FILTER" in capsys.readouterr().err + assert all(not session.calls for session in capture_nexus.instances) + + +@pytest.mark.parametrize( + ("selector_args", "expected_op", "expected_params"), + [ + (["main"], "disasm", {"identifier": "main"}), + (["--start", "0x1000", "--end", "0x1010"], "disasm_range", {"start": "0x1000", "end": "0x1010"}), + ], +) +def test_disasm_selector_forms_choose_the_wire_operation( + selector_args: list[str], + expected_op: str, + expected_params: dict[str, object], + database: Path, + capture_nexus, + capsys, +) -> None: + assert main(["disasm", *selector_args, "-c", str(database), "--json"]) == 0 + + assert capture_nexus.instances[0].calls[0]["op"] == expected_op + assert capture_nexus.instances[0].calls[0]["params"] == expected_params + capsys.readouterr() + + +def test_disasm_rejects_missing_selector(database: Path, capture_nexus, capsys) -> None: + assert main(["disasm", "-c", str(database), "--json"]) == 1 + + assert "disasm requires a function or --start/--end" in capsys.readouterr().err + assert all(not session.calls for session in capture_nexus.instances) + + +def test_locals_apply_accepts_a_list_plan(database: Path, capture_nexus, tmp_path: Path, capsys) -> None: + plan_path = tmp_path / "locals.json" + items = [{"local_id": "stack(16)@0x401000", "rename": "count"}] + plan_path.write_text(json.dumps(items), encoding="utf-8") + + assert ( + main( + [ + "function", + "locals", + "apply", + "sub_401000", + "--json-file", + str(plan_path), + "-c", + str(database), + ] + ) + == 0 + ) + + assert capture_nexus.instances[0].calls[0]["params"] == { + "identifier": "sub_401000", + "items": items, + } + capsys.readouterr() + + +def test_locals_apply_rejects_the_legacy_object_shape(database: Path, capture_nexus, tmp_path: Path, capsys) -> None: + plan_path = tmp_path / "locals.json" + plan_path.write_text(json.dumps({"items": [{"index": 3, "type": "uint64_t"}]}), encoding="utf-8") + + assert ( + main( + [ + "function", + "locals", + "apply", + "sub_401000", + "--json-file", + str(plan_path), + "-c", + str(database), + ] + ) + == 1 + ) + + assert "local apply JSON must be a list" in capsys.readouterr().err + assert all(not session.calls for session in capture_nexus.instances) + + +@pytest.mark.parametrize( + ("search_args", "expected_params"), + [ + ( + ["hello", "--segment", "__TEXT", "--ignore-case"], + {"pattern": "hello", "regex": False, "ignore_case": True, "segment": "__TEXT"}, + ), + ( + ["needle", "--scan", "--segment", "__TEXT", "--start", "0x1000", "--end", "0x2000"], + { + "pattern": "needle", + "regex": False, + "ignore_case": False, + "segment": "__TEXT", + "scan": True, + "start": "0x1000", + "end": "0x2000", + }, + ), + ], +) +def test_string_search_modes_reach_the_wire( + search_args: list[str], + expected_params: dict[str, object], + database: Path, + capture_nexus, + capsys, +) -> None: + assert ( + main( + [ + "search", + "strings", + *search_args, + "--timeout", + "10", + "-c", + str(database), + "--json", + ] + ) + == 0 + ) + + assert capture_nexus.instances[0].calls[0]["params"] == expected_params + capsys.readouterr() + + +def test_string_search_rejects_scan_bounds_without_scan(database: Path, capture_nexus, capsys) -> None: + assert ( + main( + [ + "search", + "strings", + "needle", + "--segment", + "__TEXT", + "--start", + "0x1000", + "--end", + "0x2000", + "--timeout", + "10", + "-c", + str(database), + ] + ) + == 1 + ) + + assert "only valid with `search strings --scan`" in capsys.readouterr().err + assert all(not session.calls for session in capture_nexus.instances) + + +@pytest.mark.parametrize( + ("command", "expected_op", "expected_params"), + [ + ( + [ + "type", + "declare", + "--decl", + "typedef int OLD;", + "--replace", + "--alias", + "OLD=NEW", + "--bisect", + "--clang", + ], + "type_declare", + { + "decl": "typedef int OLD;", + "replace": True, + "aliases": [{"from": "OLD", "to": "NEW"}], + "bisect": True, + "clang": True, + }, + ), + ( + ["type", "check", "--decl", "typedef int OLD;", "--alias", "OLD=NEW"], + "type_declare_check", + { + "decl": "typedef int OLD;", + "aliases": [{"from": "OLD", "to": "NEW"}], + "clang": False, + }, + ), + ], +) +def test_type_declaration_modes_build_the_wire_contract( + command: list[str], + expected_op: str, + expected_params: dict[str, object], + database: Path, + capture_nexus, + capsys, +) -> None: + assert main([*command, "-c", str(database), "--json"]) == 0 + + assert capture_nexus.instances[0].calls[0]["op"] == expected_op + assert capture_nexus.instances[0].calls[0]["params"] == expected_params + capsys.readouterr() + + +@pytest.mark.parametrize( + ("option", "expected_params"), + [ + ([], {"identifier": "add", "decl": "void __fastcall add(int value);"}), + ( + ["--propagate-callers"], + { + "identifier": "add", + "decl": "void __fastcall add(int value);", + "propagate_callers": True, + }, + ), + ], +) +def test_prototype_caller_propagation_is_opt_in_on_the_wire( + option: list[str], + expected_params: dict[str, object], + database: Path, + capture_nexus, + capsys, +) -> None: + assert ( + main( + [ + "function", + "prototype", + "set", + "add", + "--decl", + "void __fastcall add(int value);", + *option, + "-c", + str(database), + ] + ) + == 0 + ) + + assert capture_nexus.instances[0].calls[0]["params"] == expected_params + capsys.readouterr() + + +def test_comment_scope_and_repeatability_reach_the_wire(database: Path, capture_nexus, capsys) -> None: + assert ( + main( + [ + "comment", + "show", + "main", + "--scope", + "function", + "--repeatable", + "-c", + str(database), + "--json", + ] + ) + == 0 + ) + assert ( + main( + [ + "comment", + "set", + "main", + "entry", + "--posterior", + "-c", + str(database), + "--json", + ] + ) + == 0 + ) + + assert capture_nexus.instances[0].calls[0]["params"] == { + "address": "main", + "scope": "function", + "repeatable": True, + } + assert capture_nexus.instances[1].calls[0]["params"] == { + "address": "main", + "text": "entry", + "scope": "posterior", + } + capsys.readouterr() + + +def test_repeatable_extra_comments_are_rejected(database: Path, capture_nexus, capsys) -> None: + assert ( + main( + [ + "comment", + "show", + "main", + "--anterior", + "--repeatable", + "-c", + str(database), + ] + ) + == 1 + ) + + assert "--repeatable is only valid for line or function comments" in capsys.readouterr().err + assert all(not session.calls for session in capture_nexus.instances) diff --git a/tests/test_comments_atomic.py b/tests/test_comments_atomic.py index dd6b02d..51c10a6 100644 --- a/tests/test_comments_atomic.py +++ b/tests/test_comments_atomic.py @@ -2,8 +2,15 @@ import pytest -from idac.ops.families import comments -from idac.ops.runtime import IdaOperationError, IdaRuntime +from idac import remote_ops +from tests.remote_ops_harness import dispatch_with_runtime + +IdaOperationError = remote_ops.IdaOperationError +IdaRuntime = remote_ops.IdaRuntime + + +def _run_op(name: str, runtime: IdaRuntime, params: dict[str, object]): + return dispatch_with_runtime(runtime, name, params) class _FakeIdaLines: @@ -46,7 +53,7 @@ def update_extra_cmt(self, ea: int, index: int, line: str) -> bool: class _FakeRuntime(IdaRuntime): def __init__(self, ida_lines: _FakeIdaLines) -> None: super().__init__() - self._mods = {"ida_lines": ida_lines} + self._mods = {"ida_bytes": object(), "ida_lines": ida_lines} def mod(self, name: str): return self._mods[name] @@ -61,15 +68,25 @@ def test_write_extra_comment_restores_previous_text_on_failure() -> None: runtime = _FakeRuntime(ida_lines) with pytest.raises(IdaOperationError, match="failed to set anterior comment"): - comments._write_extra_comment(runtime, 0x1000, scope="anterior", text="new one\nnew two") + _run_op( + "comment_set", + runtime, + {"address": "0x1000", "scope": "anterior", "text": "new one\nnew two"}, + ) - assert comments._read_extra_comment(runtime, 0x1000, scope="anterior") == "old one\nold two" + assert _run_op("comment_get", runtime, {"address": "0x1000", "scope": "anterior"})["comment"] == ( + "old one\nold two" + ) -def test_write_extra_comment_replaces_multiline_text() -> None: +def test_write_extra_comment_preserves_terminal_blank_lines() -> None: ida_lines = _FakeIdaLines(initial={(0x1000, 1): ["old one", "old two"]}) runtime = _FakeRuntime(ida_lines) - comments._write_extra_comment(runtime, 0x1000, scope="anterior", text="new one\nnew two\nnew three") + result = _run_op( + "comment_set", + runtime, + {"address": "0x1000", "scope": "anterior", "text": "new one\nnew two\n"}, + ) - assert comments._read_extra_comment(runtime, 0x1000, scope="anterior") == "new one\nnew two\nnew three" + assert result["comment"] == "new one\nnew two\n" diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py deleted file mode 100644 index 429b860..0000000 --- a/tests/test_dispatch.py +++ /dev/null @@ -1,182 +0,0 @@ -from __future__ import annotations - -import threading -import time - -from idac.transport.dispatch import DispatcherBusyError, DispatcherStoppedError, SerializedDispatcher - - -def test_serialized_dispatcher_runs_calls_in_submission_order() -> None: - dispatcher = SerializedDispatcher("test-dispatcher") - dispatcher.start() - seen: list[int] = [] - ready = threading.Event() - - def first() -> int: - ready.set() - time.sleep(0.05) - seen.append(1) - return 1 - - def second() -> int: - seen.append(2) - return 2 - - first_thread = threading.Thread(target=lambda: dispatcher.call("first", first)) - second_result: list[int] = [] - second_thread = threading.Thread(target=lambda: second_result.append(dispatcher.call("second", second))) - - first_thread.start() - ready.wait(timeout=1.0) - second_thread.start() - first_thread.join(timeout=1.0) - second_thread.join(timeout=1.0) - dispatcher.stop() - - assert seen == [1, 2] - assert second_result == [2] - - -def test_serialized_dispatcher_rejects_calls_after_stop() -> None: - dispatcher = SerializedDispatcher("test-dispatcher") - dispatcher.start() - dispatcher.stop() - - try: - dispatcher.call("stopped", lambda: None) - except DispatcherStoppedError as exc: - assert "not running" in str(exc) - else: # pragma: no cover - defensive failure branch - raise AssertionError("expected dispatcher stop to reject new calls") - - -def test_serialized_dispatcher_rejects_calls_when_queue_is_full() -> None: - dispatcher = SerializedDispatcher("test-dispatcher", max_pending=1) - dispatcher.start() - entered = threading.Event() - release = threading.Event() - - def blocking() -> int: - entered.set() - release.wait(timeout=1.0) - return 1 - - thread = threading.Thread(target=lambda: dispatcher.call("blocking", blocking)) - thread.start() - entered.wait(timeout=1.0) - - try: - dispatcher.call("overflow", lambda: 2) - except DispatcherBusyError as exc: - assert "queue is full" in str(exc) - else: # pragma: no cover - defensive failure branch - raise AssertionError("expected full dispatcher queue to reject new calls") - finally: - release.set() - thread.join(timeout=1.0) - dispatcher.stop() - - -def test_serialized_dispatcher_reports_queue_metrics_for_queued_call() -> None: - dispatcher = SerializedDispatcher("test-dispatcher") - dispatcher.start() - entered = threading.Event() - release = threading.Event() - result_holder: list[int] = [] - metrics_holder = [] - - def blocking() -> int: - entered.set() - release.wait(timeout=1.0) - return 1 - - first_thread = threading.Thread(target=lambda: dispatcher.call("blocking", blocking)) - - def second_call() -> None: - result, metrics = dispatcher.call_with_metrics("queued", lambda: 2) - result_holder.append(result) - metrics_holder.append(metrics) - - second_thread = threading.Thread(target=second_call) - - first_thread.start() - entered.wait(timeout=1.0) - second_thread.start() - - deadline = time.monotonic() + 1.0 - while dispatcher.pending_count() != 2 and time.monotonic() < deadline: - time.sleep(0.01) - - time.sleep(0.02) - release.set() - first_thread.join(timeout=1.0) - second_thread.join(timeout=1.0) - dispatcher.stop() - - assert result_holder == [2] - assert len(metrics_holder) == 1 - metrics = metrics_holder[0] - assert metrics.queue_depth_at_enqueue == 1 - assert metrics.queue_wait_seconds > 0.0 - assert metrics.run_seconds >= 0.0 - - -def test_serialized_dispatcher_wait_for_idle_times_out_while_call_is_running() -> None: - dispatcher = SerializedDispatcher("test-dispatcher") - dispatcher.start() - entered = threading.Event() - release = threading.Event() - - def blocking() -> int: - entered.set() - release.wait(timeout=1.0) - return 1 - - thread = threading.Thread(target=lambda: dispatcher.call("blocking", blocking)) - thread.start() - entered.wait(timeout=1.0) - - assert dispatcher.wait_for_idle(timeout=0.01) is False - - release.set() - thread.join(timeout=1.0) - - assert dispatcher.wait_for_idle(timeout=1.0) is True - dispatcher.stop() - - -def test_serialized_dispatcher_stop_fails_queued_calls_with_shutdown_error() -> None: - dispatcher = SerializedDispatcher("test-dispatcher") - dispatcher.start() - entered = threading.Event() - release = threading.Event() - errors: list[str] = [] - - def blocking() -> int: - entered.set() - release.wait(timeout=1.0) - return 1 - - def queued_call() -> None: - try: - dispatcher.call("queued", lambda: 2) - except DispatcherStoppedError as exc: - errors.append(str(exc)) - - first_thread = threading.Thread(target=lambda: dispatcher.call("blocking", blocking)) - second_thread = threading.Thread(target=queued_call) - - first_thread.start() - entered.wait(timeout=1.0) - second_thread.start() - - deadline = time.monotonic() + 1.0 - while dispatcher.pending_count() != 2 and time.monotonic() < deadline: - time.sleep(0.01) - - dispatcher.stop(join_timeout=0.01) - release.set() - first_thread.join(timeout=1.0) - second_thread.join(timeout=1.0) - - assert errors == ["test-dispatcher dispatcher is shutting down"] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index ec93224..e17daa4 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,527 +1,345 @@ from __future__ import annotations import json -import shutil import subprocess import sys from pathlib import Path from types import SimpleNamespace +import pytest + from idac import doctor -from idac.metadata import ( - BRIDGE_PLUGIN_NAME, - GUI_BACKEND_NAME, - IDALIB_BACKEND_NAME, - bridge_registry_payload, - idalib_registry_payload, -) -from idac.version import VERSION - - -def test_doctor_reports_gui_install_and_running_instances(monkeypatch, tmp_path: Path) -> None: - source_dir = tmp_path / "plugin-src" - source_dir.mkdir() - bootstrap_source = tmp_path / "idac_bridge_plugin.py" - bootstrap_source.write_text("# bootstrap\n", encoding="utf-8") - runtime_package_source = tmp_path / "idac-src" - runtime_package_source.mkdir() - (runtime_package_source / "__init__.py").write_text("# idac\n", encoding="utf-8") - - install_dir = tmp_path / "plugins" / "idac_bridge" - install_dir.parent.mkdir(parents=True) - install_dir.symlink_to(source_dir, target_is_directory=True) - - install_bootstrap = tmp_path / "plugins" / "idac_bridge_plugin.py" - install_bootstrap.symlink_to(bootstrap_source) - install_runtime_package = tmp_path / "plugins" / "idac" - install_runtime_package.symlink_to(runtime_package_source, target_is_directory=True) - - instance = doctor.gui.BridgeInstance( - pid=4321, - socket_path=tmp_path / "idac-bridge-4321.sock", - registry_path=tmp_path / "idac-bridge-4321.json", - plugin_name=BRIDGE_PLUGIN_NAME, - plugin_version=VERSION, - started_at=None, - meta={}, - ) - monkeypatch.setattr(doctor, "plugin_source_dir", lambda: source_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_source_path", lambda: bootstrap_source) - monkeypatch.setattr(doctor, "plugin_runtime_package_source_dir", lambda: runtime_package_source) - monkeypatch.setattr(doctor, "plugin_install_dir", lambda: install_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_install_path", lambda: install_bootstrap) - monkeypatch.setattr(doctor, "plugin_runtime_package_install_dir", lambda: install_runtime_package) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: tmp_path / "runtime") - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: [instance.registry_path]) - monkeypatch.setattr(doctor.gui, "list_instances", lambda: [instance]) - monkeypatch.setattr( - doctor.gui, - "list_targets", - lambda timeout=None, warnings=None: [ - { - "target_id": "4321:active", - "selector": "pid:4321", - "module": "tiny", - "instance_pid": 4321, - "active": True, - } - ], - ) - result = doctor.run_doctor(scope="gui", timeout=1.0) +@pytest.fixture(autouse=True) +def agent_client_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Keep doctor tests independent of clients installed on the developer's machine.""" - assert result["healthy"] is True - assert result["status"] == "ok" - assert result["backend"] == ["gui"] - assert any(item["name"] == "bridge_targets" and item["status"] == "ok" for item in result["checks"]) - - -def test_doctor_warns_for_runtime_package_drift_when_bridge_is_live(monkeypatch, tmp_path: Path) -> None: - source_dir = tmp_path / "plugin-src" - source_dir.mkdir() - bootstrap_source = tmp_path / "idac_bridge_plugin.py" - bootstrap_source.write_text("# bootstrap\n", encoding="utf-8") - runtime_package_source = tmp_path / "idac-src" - runtime_package_source.mkdir() - (runtime_package_source / "__init__.py").write_text("# idac\n", encoding="utf-8") - - install_dir = tmp_path / "plugins" / "idac_bridge" - install_dir.parent.mkdir(parents=True) - install_dir.symlink_to(source_dir, target_is_directory=True) - - install_bootstrap = tmp_path / "plugins" / "idac_bridge_plugin.py" - install_bootstrap.symlink_to(bootstrap_source) - install_runtime_package = tmp_path / "plugins" / "idac" - install_runtime_package.symlink_to(tmp_path / "missing-runtime-package", target_is_directory=True) - - instance = doctor.gui.BridgeInstance( - pid=4321, - socket_path=tmp_path / "idac-bridge-4321.sock", - registry_path=tmp_path / "idac-bridge-4321.json", - plugin_name=BRIDGE_PLUGIN_NAME, - plugin_version=VERSION, - started_at=None, - meta={}, - ) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.setattr("hcli.lib.ida.find_current_ida_version", lambda: "9.4") + return tmp_path - monkeypatch.setattr(doctor, "plugin_source_dir", lambda: source_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_source_path", lambda: bootstrap_source) - monkeypatch.setattr(doctor, "plugin_runtime_package_source_dir", lambda: runtime_package_source) - monkeypatch.setattr(doctor, "plugin_install_dir", lambda: install_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_install_path", lambda: install_bootstrap) - monkeypatch.setattr(doctor, "plugin_runtime_package_install_dir", lambda: install_runtime_package) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: tmp_path / "runtime") - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: [instance.registry_path]) - monkeypatch.setattr(doctor.gui, "list_instances", lambda: [instance]) - monkeypatch.setattr( - doctor.gui, - "list_targets", - lambda timeout=None, warnings=None: [ - { - "target_id": "4321:active", - "selector": "pid:4321", - "module": "tiny", - "instance_pid": 4321, - "active": True, - } - ], + +def _instance(record_id: str = "gui-123") -> SimpleNamespace: + return SimpleNamespace( + record_id=record_id, + backend="gui", + pid=123, + port=4567, + _token="secret", + version=6, + idb_path="/tmp/demo.i64", + exe_path="/tmp/demo", + managed=False, + started_at=1234.5, ) - result = doctor.run_doctor(scope="gui", timeout=1.0) - assert result["healthy"] is True - assert result["status"] == "warn" - runtime_package = next(item for item in result["checks"] if item["name"] == "plugin_runtime_package") - assert runtime_package["status"] == "warn" - assert "does not block the current running GUI bridge" in runtime_package["summary"] - bridge_targets = next(item for item in result["checks"] if item["name"] == "bridge_targets") - assert bridge_targets["status"] == "ok" - - -def test_doctor_accepts_copy_install_layout(monkeypatch, tmp_path: Path) -> None: - source_dir = tmp_path / "plugin-src" - source_dir.mkdir() - (source_dir / "__init__.py").write_text("# package\n", encoding="utf-8") - bootstrap_source = tmp_path / "idac_bridge_plugin.py" - bootstrap_source.write_text("# bootstrap\n", encoding="utf-8") - runtime_package_source = tmp_path / "idac-src" - runtime_package_source.mkdir() - (runtime_package_source / "__init__.py").write_text("# idac\n", encoding="utf-8") - - install_dir = tmp_path / "plugins" / "idac_bridge" - shutil.copytree(source_dir, install_dir) - install_bootstrap = tmp_path / "plugins" / "idac_bridge_plugin.py" - shutil.copy2(bootstrap_source, install_bootstrap) - install_runtime_package = tmp_path / "plugins" / "idac" - shutil.copytree(runtime_package_source, install_runtime_package) - - monkeypatch.setattr(doctor, "plugin_source_dir", lambda: source_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_source_path", lambda: bootstrap_source) - monkeypatch.setattr(doctor, "plugin_runtime_package_source_dir", lambda: runtime_package_source) - monkeypatch.setattr(doctor, "plugin_install_dir", lambda: install_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_install_path", lambda: install_bootstrap) - monkeypatch.setattr(doctor, "plugin_runtime_package_install_dir", lambda: install_runtime_package) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: tmp_path / "runtime") - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: []) - monkeypatch.setattr(doctor.gui, "list_instances", lambda: []) - monkeypatch.setattr(doctor.gui, "list_targets", lambda timeout=None, warnings=None: []) - - result = doctor.run_doctor(scope="gui", timeout=1.0) - - statuses = {(item["name"], item["status"]) for item in result["checks"]} - assert ("plugin_package", "ok") in statuses - assert ("plugin_bootstrap", "ok") in statuses - assert ("plugin_runtime_package", "ok") in statuses - - -def test_doctor_all_treats_missing_gui_install_as_headless_warning(monkeypatch, tmp_path: Path) -> None: - source_dir = tmp_path / "plugin-src" - source_dir.mkdir() - bootstrap_source = tmp_path / "idac_bridge_plugin.py" - bootstrap_source.write_text("# bootstrap\n", encoding="utf-8") - runtime_package_source = tmp_path / "idac-src" - runtime_package_source.mkdir() - (runtime_package_source / "__init__.py").write_text("# idac\n", encoding="utf-8") - - install_dir = tmp_path / "plugins" / "idac_bridge" - install_bootstrap = tmp_path / "plugins" / "idac_bridge_plugin.py" - install_runtime_package = tmp_path / "plugins" / "idac" - - monkeypatch.setattr(doctor, "plugin_source_dir", lambda: source_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_source_path", lambda: bootstrap_source) - monkeypatch.setattr(doctor, "plugin_runtime_package_source_dir", lambda: runtime_package_source) - monkeypatch.setattr(doctor, "plugin_install_dir", lambda: install_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_install_path", lambda: install_bootstrap) - monkeypatch.setattr(doctor, "plugin_runtime_package_install_dir", lambda: install_runtime_package) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: tmp_path / "runtime") - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: []) - monkeypatch.setattr(doctor.gui, "list_instances", lambda: []) - monkeypatch.setattr(doctor.gui, "list_targets", lambda timeout=None, warnings=None: []) - monkeypatch.setattr( - doctor, - "_doctor_idalib", - lambda database: [ - doctor._check("ok", "idalib", "install_dirs", "found at least one usable IDA install directory"), - doctor._check("ok", "idalib", "license", "IDA license check passed"), - doctor._check("ok", "idalib", "idapro_import", "idapro imported successfully"), - doctor._check("ok", "idalib", "eula", "IDA EULA acceptance was found in the registry"), - ], +def _discovered(state: str = "ready", *, record_id: str = "gui-123") -> SimpleNamespace: + return SimpleNamespace( + instance=_instance(record_id), + state=SimpleNamespace(value=state), + detail=None if state == "ready" else "unsupported protocol version 7; expected 6", + registry_file="/private/registry.json", ) - result = doctor.run_doctor(scope="all", timeout=1.0) - assert result["healthy"] is True - assert result["status"] == "warn" - assert result["backend"] == ["idalib"] - gui_install_checks = [ - item - for item in result["checks"] - if item["component"] == "gui" - and item["name"] in {"plugin_package", "plugin_bootstrap", "plugin_runtime_package"} - ] - assert {item["status"] for item in gui_install_checks} == {"warn"} - assert all("optional for headless idalib use" in item["summary"] for item in gui_install_checks) - - -def test_doctor_warns_after_purging_refused_bridge_registry(monkeypatch, tmp_path: Path) -> None: - source_dir = tmp_path / "plugin-src" - source_dir.mkdir() - bootstrap_source = tmp_path / "idac_bridge_plugin.py" - bootstrap_source.write_text("# bootstrap\n", encoding="utf-8") - runtime_package_source = tmp_path / "idac-src" - runtime_package_source.mkdir() - (runtime_package_source / "__init__.py").write_text("# idac\n", encoding="utf-8") - - install_dir = tmp_path / "plugins" / "idac_bridge" - install_dir.parent.mkdir(parents=True) - install_dir.symlink_to(source_dir, target_is_directory=True) - - install_bootstrap = tmp_path / "plugins" / "idac_bridge_plugin.py" - install_bootstrap.symlink_to(bootstrap_source) - install_runtime_package = tmp_path / "plugins" / "idac" - install_runtime_package.symlink_to(runtime_package_source, target_is_directory=True) - - registry = tmp_path / "idac-bridge-4321.json" - socket_path = tmp_path / "idac-bridge-4321.sock" - socket_path.write_text("", encoding="utf-8") - registry.write_text( - json.dumps( +def _versions(distribution: str) -> str: + return doctor.importlib.metadata.version(distribution) + + +def _hcli_success(command, **kwargs): + payload = { + "plugins": [ { - "pid": 4321, - "socket_path": str(socket_path), - "plugin_name": BRIDGE_PLUGIN_NAME, - "plugin_version": VERSION, + "name": "ida-nexus", + "version": _versions("ida-nexus"), + "installed": True, + "kind": "installed", } - ), - encoding="utf-8", + ] + } + return subprocess.CompletedProcess(command, 0, json.dumps(payload), "") + + +def _remote_environment(_instance, _timeout): + return { + "ida_nexus": _versions("ida-nexus"), + "ida_domain": _versions("ida-domain"), + "ida": "9.4", + "python": "3.11.9", + } + + +def test_doctor_reports_a_healthy_nexus_stack() -> None: + def run_hcli(command, **kwargs): + assert kwargs["timeout"] == 2.5 + return _hcli_success(command, **kwargs) + + def discover(timeout): + assert timeout == 2.5 + return [_discovered()] + + def probe(instance, timeout): + assert instance.record_id == "gui-123" + assert timeout == 2.5 + return _remote_environment(instance, timeout) + + result = doctor.run_doctor( + timeout=2.5, + version_getter=_versions, + runner=run_hcli, + discover_databases_fn=discover, + remote_probe_fn=probe, ) - monkeypatch.setattr(doctor, "plugin_source_dir", lambda: source_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_source_path", lambda: bootstrap_source) - monkeypatch.setattr(doctor, "plugin_runtime_package_source_dir", lambda: runtime_package_source) - monkeypatch.setattr(doctor, "plugin_install_dir", lambda: install_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_install_path", lambda: install_bootstrap) - monkeypatch.setattr(doctor, "plugin_runtime_package_install_dir", lambda: install_runtime_package) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: tmp_path / "runtime") - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(doctor.gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(doctor, "pid_is_live", lambda pid: pid == 4321) - monkeypatch.setattr( - doctor.gui, - "_send_request_to_instance", - lambda *args, **kwargs: (_ for _ in ()).throw(doctor.gui.StaleBridgeInstanceError("refused bridge socket")), - ) - - result = doctor.run_doctor(scope="gui", timeout=1.0) - assert result["healthy"] is True - assert result["status"] == "warn" - bridge_targets = next(item for item in result["checks"] if item["name"] == "bridge_targets") - assert bridge_targets["status"] == "warn" - assert bridge_targets["summary"] == "no running GUI bridge instances found" - discovery = next(item for item in result["checks"] if item["name"] == "bridge_discovery") - assert discovery["status"] == "warn" - assert discovery["details"]["warnings"] - assert not any(item["name"] == "bridge_version" for item in result["checks"]) - assert not registry.exists() + assert result["status"] == "ok" + statuses = {(item["component"], item["name"]): item["status"] for item in result["checks"]} + expected = { + ("runtime", "python"): "ok", + ("runtime", "ida_nexus"): "ok", + ("runtime", "ida_domain"): "ok", + ("runtime", "ida_hcli"): "ok", + ("runtime", "ida"): "ok", + ("gui", "plugin"): "ok", + ("nexus", "discovery"): "ok", + ("nexus", "remote_environment"): "ok", + } + assert expected.items() <= statuses.items() + + +@pytest.mark.parametrize("version,supported", [("9.3", False), ("9.4", True), ("10.0", True)]) +def test_doctor_checks_local_ida_without_a_running_database(monkeypatch, version: str, supported: bool) -> None: + monkeypatch.setattr("hcli.lib.ida.find_current_ida_version", lambda: version) + result = doctor.run_doctor(runner=_hcli_success, discover_databases_fn=lambda _timeout: []) + check = next(item for item in result["checks"] if (item["component"], item["name"]) == ("runtime", "ida")) + assert check["status"] == ("ok" if supported else "error") + assert check["details"]["installed"] == version + assert result["healthy"] is supported + if not supported: + assert "requires IDA 9.4 or newer" in check["summary"] + + +@pytest.mark.parametrize("version", [None, "invalid"]) +def test_doctor_reports_unavailable_local_ida_version(monkeypatch, version) -> None: + def detect(): + if version is None: + raise RuntimeError("no IDA installation configured") + return version + + monkeypatch.setattr("hcli.lib.ida.find_current_ida_version", detect) + result = doctor.run_doctor(runner=_hcli_success, discover_databases_fn=lambda _timeout: []) + check = next(item for item in result["checks"] if (item["component"], item["name"]) == ("runtime", "ida")) + assert check["status"] == "error" + assert "could not determine" in check["summary"] + assert result["healthy"] is False -def test_bridge_registry_payload_uses_current_version() -> None: - payload = bridge_registry_payload(pid=7, socket_path="/tmp/idac.sock", started_at="now") +@pytest.mark.parametrize("client", ["codex", "claude"]) +@pytest.mark.parametrize("matching", [True, False]) +def test_doctor_compares_installed_skill_and_cli_versions(agent_client_path: Path, client: str, matching: bool) -> None: + executable = agent_client_path / client + executable.touch(mode=0o755) + cli_version = _versions("idac") + installed = cli_version if matching else "0.1.0" + identity_key = "pluginId" if client == "codex" else "id" + entries = [ + {identity_key: "other@marketplace", "version": "0.1.0"}, + {identity_key: "idac@marketplace", "version": installed, "enabled": True}, + ] + payload = ( + {"installed": entries, "available": [{"pluginId": "idac@unused", "version": "0.0.1"}]} + if client == "codex" + else entries + ) - assert payload["plugin_name"] == BRIDGE_PLUGIN_NAME - assert payload["plugin_version"] == VERSION - assert payload["backend"] == GUI_BACKEND_NAME + def run(command, **kwargs): + if command[0] == client: + return subprocess.CompletedProcess(command, 0, json.dumps(payload), "") + return _hcli_success(command, **kwargs) + result = doctor.run_doctor( + runner=run, + discover_databases_fn=lambda _timeout: [_discovered()], + remote_probe_fn=_remote_environment, + ) -def test_idalib_registry_payload_includes_backend_marker() -> None: - payload = idalib_registry_payload( - pid=8, - socket_path="/tmp/idac-idalib.sock", - started_at="now", - database_path="/tmp/sample.i64", + assert result["healthy"] is True + assert result["status"] == ("ok" if matching else "warn") + agent_checks = [item for item in result["checks"] if item["component"] == "agent"] + assert len(agent_checks) == 1 + check = agent_checks[0] + assert check["status"] == ("ok" if matching else "warn") + assert check["details"]["installed"] == installed + assert check["details"]["expected"] == cli_version + assert installed in check["summary"] + assert cli_version in check["summary"] + + +@pytest.mark.parametrize("inventory", ["empty", "malformed", "failed", "timeout"]) +def test_doctor_keeps_optional_skill_inventory_failures_nonfatal(agent_client_path: Path, inventory: str) -> None: + executable = agent_client_path / "codex" + executable.touch(mode=0o755) + + def run(command, **kwargs): + if command[0] != "codex": + return _hcli_success(command, **kwargs) + if inventory == "timeout": + raise subprocess.TimeoutExpired(command, kwargs["timeout"]) + if inventory == "failed": + return subprocess.CompletedProcess(command, 1, "", "plugin list failed") + stdout = json.dumps({"installed": []}) if inventory == "empty" else "not JSON" + return subprocess.CompletedProcess(command, 0, stdout, "") + + result = doctor.run_doctor( + runner=run, + discover_databases_fn=lambda _timeout: [_discovered()], + remote_probe_fn=_remote_environment, ) - assert payload["backend"] == IDALIB_BACKEND_NAME - assert payload["database_path"] == "/tmp/sample.i64" + assert result["healthy"] is True + if inventory == "empty": + assert result["status"] == "ok" + else: + check = next(item for item in result["checks"] if item["component"] == "agent") + assert check["status"] == "warn" + assert "could not check" in check["summary"] -def test_doctor_reports_missing_idalib_install(monkeypatch) -> None: - monkeypatch.setattr(doctor, "_candidate_ida_dirs", lambda: [Path("/missing/ida")]) +@pytest.mark.parametrize("timeout,expected", [(None, 2.0), (0.5, 0.5), (4.0, 4.0)]) +def test_doctor_bounds_optional_agent_inventory(agent_client_path: Path, timeout, expected: float) -> None: + (agent_client_path / "codex").touch(mode=0o755) - def fail_bootstrap(): - raise RuntimeError("missing idapro") + def run(command, **kwargs): + if command[0] == "codex": + assert kwargs["timeout"] == expected + raise subprocess.TimeoutExpired(command, kwargs["timeout"]) + return _hcli_success(command, **kwargs) - monkeypatch.setattr(doctor, "_bootstrap_idapro", fail_bootstrap) + result = doctor.run_doctor(timeout=timeout, runner=run, discover_databases_fn=lambda _timeout: []) + assert result["healthy"] is True + check = next(item for item in result["checks"] if item["component"] == "agent") + assert check["status"] == "warn" - result = doctor.run_doctor(scope="idalib") - assert result["healthy"] is False - assert result["status"] == "error" - assert result["backend"] == [] - names = {item["name"] for item in result["checks"]} - assert "install_dirs" in names - assert "idapro_import" in names - - -def test_doctor_reports_invalid_ida_license(monkeypatch, tmp_path: Path) -> None: - ida_dir = tmp_path / "IDA" - python_dir = ida_dir / "idalib" / "python" - python_dir.mkdir(parents=True) - idat = ida_dir / "idat" - idat.write_text("#!/bin/sh\n", encoding="utf-8") - - monkeypatch.setattr(doctor, "_candidate_ida_dirs", lambda: [ida_dir]) - monkeypatch.setattr(doctor, "_bootstrap_idapro", lambda: (_ for _ in ()).throw(RuntimeError("not reached"))) - monkeypatch.setattr( - doctor, - "_ida_license_probe", - lambda executable: subprocess.CompletedProcess( - args=["idat"], - returncode=1, - stdout="", - stderr="Cannot continue without a valid license", - ), +def test_doctor_reports_blocked_protocol_without_probing() -> None: + result = doctor.run_doctor( + version_getter=_versions, + runner=_hcli_success, + discover_databases_fn=lambda _timeout: [_discovered("blocked")], + remote_probe_fn=lambda _instance, _timeout: (_ for _ in ()).throw(AssertionError("must not probe")), ) - result = doctor.run_doctor(scope="idalib") - assert result["healthy"] is False - assert result["backend"] == [] - license_check = next(item for item in result["checks"] if item["name"] == "license") - assert license_check["status"] == "error" - assert license_check["summary"] == "IDA could not find a valid license" - - -def test_doctor_reports_accepted_ida_eula(monkeypatch) -> None: - monkeypatch.setitem( - sys.modules, - "ida_registry", - SimpleNamespace(reg_read_int=lambda key, default=0: 1 if key == "EULA 93" else default), + discovery = next(item for item in result["checks"] if item["name"] == "discovery") + assert discovery["status"] == "error" + serialized = json.dumps(discovery) + assert "secret" not in serialized + assert "registry.json" not in serialized + assert '"port"' not in serialized + assert "unsupported protocol version 7" in serialized + + +def test_doctor_warns_when_no_database_is_running() -> None: + result = doctor.run_doctor( + version_getter=_versions, + runner=_hcli_success, + discover_databases_fn=lambda _timeout: [], + remote_probe_fn=lambda _instance, _timeout: (_ for _ in ()).throw(AssertionError("must not probe")), ) - check = doctor._ida_eula_check() - - assert check["status"] == "ok" - assert check["name"] == "eula" - assert check["details"]["accepted_keys"] == ["EULA 93"] - - -def test_doctor_reports_missing_ida_eula_acceptance(monkeypatch) -> None: - monkeypatch.setitem( - sys.modules, - "ida_registry", - SimpleNamespace(reg_read_int=lambda key, default=0: default), - ) - - check = doctor._ida_eula_check() - - assert check["status"] == "error" - assert check["name"] == "eula" - assert check["summary"] == ("IDA EULA acceptance was not found in the registry. Run `hcli ida accept-eula`.") - assert check["details"]["remediation"] == "Run `hcli ida accept-eula`." - - -def test_doctor_cleanup_removes_stale_registry_and_orphan_socket(monkeypatch, tmp_path: Path) -> None: - runtime_dir = tmp_path / "runtime" - runtime_dir.mkdir() - - live_socket = runtime_dir / "idac-bridge-4321.sock" - live_socket.write_text("", encoding="utf-8") - live_registry = runtime_dir / "idac-bridge-4321.json" - live_registry.write_text( - json.dumps( - { - "pid": 4321, - "socket_path": str(live_socket), - } + assert result["healthy"] is True + assert result["status"] == "warn" + discovery = next(item for item in result["checks"] if item["name"] == "discovery") + assert discovery["status"] == "warn" + assert "no running" in discovery["summary"].lower() + + +def test_doctor_reports_missing_or_malformed_hcli_status() -> None: + cases = [ + ( + subprocess.CompletedProcess( + [], + 1, + '{"plugins":[{"name":"ida-nexus","installed":false}]}', + "not installed", + ), + "not installed", ), - encoding="utf-8", - ) - - stale_socket = runtime_dir / "idac-bridge-9876.sock" - stale_socket.write_text("", encoding="utf-8") - stale_registry = runtime_dir / "idac-bridge-9876.json" - stale_registry.write_text( - json.dumps( - { - "pid": 9876, - "socket_path": str(stale_socket), - } + ( + subprocess.CompletedProcess([], 1, "", "configured hcli default IDA installation does not exist"), + "configured hcli default ida installation does not exist", ), - encoding="utf-8", - ) - - orphan_socket = runtime_dir / "idac-bridge-5555.sock" - orphan_socket.write_text("", encoding="utf-8") + ] - live_idalib_socket = runtime_dir / "idac-idalib-7777.sock" - live_idalib_socket.write_text("", encoding="utf-8") - live_idalib_registry = runtime_dir / "idac-idalib-7777.json" - live_idalib_registry.write_text( - json.dumps( - { - "pid": 7777, - "socket_path": str(live_idalib_socket), - "database_path": "/tmp/live.i64", - } - ), - encoding="utf-8", + for completed, diagnostic in cases: + result = doctor.run_doctor( + version_getter=_versions, + runner=lambda _command, _completed=completed, **_kwargs: _completed, + discover_databases_fn=lambda _timeout: [], + ) + + plugin = next(item for item in result["checks"] if item["component"] == "gui") + assert plugin["status"] == "error" + assert diagnostic in plugin["summary"].lower() + + +def test_doctor_rejects_old_remote_python_and_ida() -> None: + result = doctor.run_doctor( + version_getter=_versions, + runner=_hcli_success, + discover_databases_fn=lambda _timeout: [_discovered()], + remote_probe_fn=lambda _instance, _timeout: { + "ida_nexus": _versions("ida-nexus"), + "ida_domain": _versions("ida-domain"), + "ida": "9.3", + "python": "3.10.14", + }, ) - stale_idalib_socket = runtime_dir / "idac-idalib-8888.sock" - stale_idalib_socket.write_text("", encoding="utf-8") - stale_idalib_registry = runtime_dir / "idac-idalib-8888.json" - stale_idalib_registry.write_text( - json.dumps( - { - "pid": 8888, - "socket_path": str(stale_idalib_socket), - "database_path": "/tmp/stale.i64", + assert result["healthy"] is False + remote = next(item for item in result["checks"] if item["name"] == "remote_environment") + assert remote["status"] == "error" + mismatches = " ".join(remote["details"]["mismatches"]) + assert "IDA" in mismatches + assert "Python" in mismatches + + +def test_doctor_default_probe_releases_the_remote_database_handle(monkeypatch) -> None: + calls: dict[str, object] = {} + discovered = _discovered() + + class Handle: + @classmethod + def attach(cls, selected, *, keepalive): + calls["selected"] = selected + return cls() + + def __enter__(self): + return self + + def __exit__(self, *_args): + calls["closed"] = True + + def execute_python(self, code, **kwargs): + calls["executed"] = True + return { + "result": _remote_environment(discovered.instance, kwargs["timeout"]), + "stdout": "", + "stderr": "", } - ), - encoding="utf-8", - ) - orphan_idalib_socket = runtime_dir / "idac-idalib-9999.sock" - orphan_idalib_socket.write_text("", encoding="utf-8") + monkeypatch.setitem(sys.modules, "ida_nexus", SimpleNamespace(DatabaseHandle=Handle)) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: runtime_dir) - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: [live_registry, stale_registry]) - monkeypatch.setattr( - doctor, - "idalib_registry_paths", - lambda: [live_idalib_registry, stale_idalib_registry], - ) - monkeypatch.setattr(doctor, "pid_is_live", lambda pid: pid in {4321, 7777}) - - result = doctor.run_doctor_cleanup() - - assert result["removed_count"] == 6 - assert result["kept_count"] == 2 - assert live_registry.exists() - assert live_socket.exists() - assert live_idalib_registry.exists() - assert live_idalib_socket.exists() - assert not stale_registry.exists() - assert not stale_socket.exists() - assert not orphan_socket.exists() - assert not stale_idalib_registry.exists() - assert not stale_idalib_socket.exists() - assert not orphan_idalib_socket.exists() - - -def test_doctor_cleanup_removes_malformed_registry(monkeypatch, tmp_path: Path) -> None: - runtime_dir = tmp_path / "runtime" - runtime_dir.mkdir() - bad_registry = runtime_dir / "idac-bridge-1111.json" - bad_registry.write_text("{not-json}", encoding="utf-8") - bad_idalib_registry = runtime_dir / "idac-idalib-2222.json" - bad_idalib_registry.write_text("{not-json}", encoding="utf-8") - - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: runtime_dir) - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: [bad_registry]) - monkeypatch.setattr(doctor, "idalib_registry_paths", lambda: [bad_idalib_registry]) - monkeypatch.setattr(doctor, "pid_is_live", lambda pid: False) - - result = doctor.run_doctor_cleanup() - - assert result["removed_count"] == 2 - assert not bad_registry.exists() - assert not bad_idalib_registry.exists() - - -def test_doctor_cleanup_removes_gui_artifacts_for_live_idalib_pid_reuse(monkeypatch, tmp_path: Path) -> None: - runtime_dir = tmp_path / "runtime" - runtime_dir.mkdir() - - gui_socket = runtime_dir / "idac-bridge-22536.sock" - gui_socket.write_text("", encoding="utf-8") - gui_registry = runtime_dir / "idac-bridge-22536.json" - gui_registry.write_text( - json.dumps( - { - "pid": 22536, - "socket_path": str(gui_socket), - } - ), - encoding="utf-8", + result = doctor.run_doctor( + timeout=4.0, + version_getter=_versions, + runner=_hcli_success, + discover_databases_fn=lambda _timeout: [discovered], ) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: runtime_dir) - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: [gui_registry]) - monkeypatch.setattr(doctor, "idalib_registry_paths", lambda: []) - monkeypatch.setattr(doctor, "pid_is_live", lambda pid: pid == 22536) - monkeypatch.setattr( - doctor.gui, - "_pid_non_gui_bridge_reason", - lambda pid: "process is running the idalib worker, not an IDA GUI session", - ) - - result = doctor.run_doctor_cleanup() - - assert result["removed_count"] == 2 - assert result["kept_count"] == 0 - assert not gui_registry.exists() - assert not gui_socket.exists() + assert result["healthy"] is True + assert calls["selected"].record_id == discovered.instance.record_id + assert calls["executed"] is True + assert calls["closed"] is True diff --git a/tests/test_gui_transport.py b/tests/test_gui_transport.py deleted file mode 100644 index 6dc9ed1..0000000 --- a/tests/test_gui_transport.py +++ /dev/null @@ -1,642 +0,0 @@ -from __future__ import annotations - -import errno -import json -from pathlib import Path - -import pytest - -from idac.transport import common, gui -from idac.transport.schema import RequestEnvelope -from idac.version import VERSION - - -def _write_instance(tmp_path: Path, pid: int, *, module: str) -> Path: - socket_path = tmp_path / f"idac-bridge-{pid}.sock" - socket_path.write_text("", encoding="utf-8") - registry_path = tmp_path / f"idac-bridge-{pid}.json" - registry_path.write_text( - json.dumps( - { - "pid": pid, - "socket_path": str(socket_path), - "plugin_name": "idac_bridge", - "plugin_version": VERSION, - "instance_id": f"instance-{pid}", - "started_at": "2026-04-04T00:00:00Z", - "state": "ready", - "module": module, - } - ), - encoding="utf-8", - ) - return registry_path - - -def _status_payload(instance: gui.BridgeInstance) -> dict[str, object]: - return { - "pid": instance.pid, - "socket_path": str(instance.socket_path), - "instance_id": instance.instance_id or f"instance-{instance.pid}", - "state": "ready", - "plugin_name": "idac_bridge", - "plugin_version": instance.plugin_version, - "started_at": instance.started_at, - "queue_depth": 0, - "queue_capacity": 16, - } - - -def _install_fake_instances(monkeypatch, tmp_path: Path, modules: dict[int, str]) -> None: - registries = [_write_instance(tmp_path, pid, module=module) for pid, module in modules.items()] - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: registries) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid in modules) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - if request.op == "bridge_status": - return {"ok": True, "result": _status_payload(instance)} - assert request.op == "list_targets" - module = modules[instance.pid] - return { - "ok": True, - "result": [ - { - "target_id": "active", - "selector": module, - "filename": f"/tmp/{module}.i64", - "module": module, - "active": True, - } - ], - } - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - -def test_list_targets_aggregates_gui_instances(monkeypatch, tmp_path: Path) -> None: - _install_fake_instances(monkeypatch, tmp_path, {14741: "tiny.stripped", 14679: "tiny"}) - - rows = gui.list_targets() - - assert [row["instance_pid"] for row in rows] == [14679, 14741] - assert [row["module"] for row in rows] == ["tiny", "tiny.stripped"] - assert rows[0]["target_id"] == "14679:active" - assert rows[1]["target_id"] == "14741:active" - assert rows[0]["selector"] == "pid:14679" - assert rows[1]["selector"] == "pid:14741" - - -def test_choose_instance_requires_explicit_pid_when_multiple(monkeypatch, tmp_path: Path) -> None: - _install_fake_instances(monkeypatch, tmp_path, {14679: "tiny", 14741: "tiny.stripped"}) - - try: - gui.choose_instance(None) - except RuntimeError as exc: - assert "pass -c pid:" in str(exc) - else: # pragma: no cover - defensive failure branch. - raise AssertionError("expected multiple instance selection failure") - - -def test_choose_instance_reports_more_specific_selector_when_ambiguous(monkeypatch, tmp_path: Path) -> None: - _install_fake_instances(monkeypatch, tmp_path, {14679: "tiny", 14741: "tiny"}) - - with pytest.raises(RuntimeError, match="more specific selector"): - gui.choose_instance("tiny") - - -def test_choose_instance_accepts_pid_selector(monkeypatch, tmp_path: Path) -> None: - _install_fake_instances(monkeypatch, tmp_path, {14679: "tiny", 14741: "tiny.stripped"}) - - instance, target = gui.choose_instance("pid:14741") - - assert instance.pid == 14741 - assert target == "active" - - -def test_choose_instance_accepts_global_target_id(monkeypatch, tmp_path: Path) -> None: - _install_fake_instances(monkeypatch, tmp_path, {14679: "tiny", 14741: "tiny.stripped"}) - - instance, target = gui.choose_instance("14679:active") - - assert instance.pid == 14679 - assert target == "active" - - -def test_choose_instance_accepts_local_selector(monkeypatch, tmp_path: Path) -> None: - _install_fake_instances(monkeypatch, tmp_path, {14679: "tiny", 14741: "tiny.stripped"}) - - instance, target = gui.choose_instance("tiny.stripped") - - assert instance.pid == 14741 - assert target == "active" - - -def test_choose_instance_pid_selector_skips_unrelated_bridge_probes(monkeypatch, tmp_path: Path) -> None: - registries = [ - _write_instance(tmp_path, 14679, module="tiny"), - _write_instance(tmp_path, 14741, module="tiny.stripped"), - ] - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: registries) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid in {14679, 14741}) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - seen: list[tuple[int, str]] = [] - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - seen.append((instance.pid, request.op)) - if instance.pid == 14679: - raise AssertionError("unrelated instance should not be probed") - if request.op == "bridge_status": - return {"ok": True, "result": _status_payload(instance)} - raise AssertionError(f"unexpected op: {request.op}") - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - instance, target = gui.choose_instance("pid:14741") - - assert instance.pid == 14741 - assert target == "active" - assert seen == [(14741, "bridge_status")] - - -def test_list_targets_skips_and_purges_stale_instances(monkeypatch, tmp_path: Path) -> None: - good_registry = _write_instance(tmp_path, 14679, module="tiny") - stale_registry = _write_instance(tmp_path, 14741, module="tiny.stripped") - modules = {14679: "tiny", 14741: "tiny.stripped"} - - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [good_registry, stale_registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid in modules) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - if request.op == "bridge_status": - return {"ok": True, "result": _status_payload(instance)} - assert request.op == "list_targets" - if instance.pid == 14741: - raise gui.StaleBridgeInstanceError("stale socket") - return { - "ok": True, - "result": [ - { - "target_id": "active", - "selector": "tiny", - "filename": "/tmp/tiny.i64", - "module": "tiny", - "active": True, - } - ], - } - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - warnings: list[str] = [] - rows = gui.list_targets(warnings=warnings) - - assert [row["instance_pid"] for row in rows] == [14679] - assert not stale_registry.exists() - assert any("bridge socket stopped responding" in warning for warning in warnings) - - -def test_list_targets_surfaces_live_bridge_failures(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - if request.op == "bridge_status": - return {"ok": True, "result": _status_payload(instance)} - assert request.op == "list_targets" - raise RuntimeError("malformed JSON") - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - with pytest.raises(RuntimeError, match="malformed JSON"): - gui.list_targets() - - assert registry.exists() - - -def test_list_instances_purges_registry_when_bridge_identity_changes(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - socket_path = tmp_path / "idac-bridge-14679.sock" - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - payload = _status_payload(instance) - payload["instance_id"] = "restarted" - return {"ok": True, "result": payload} - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - warnings: list[str] = [] - rows = gui.list_instances(warnings=warnings) - - assert rows == [] - assert not registry.exists() - assert socket_path.exists() - assert any("bridge identity changed" in warning for warning in warnings) - - -def test_list_instances_skips_starting_bridge_with_warning(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - return { - "ok": False, - "error": "IDA GUI bridge is still starting", - "error_kind": "startup_incomplete", - } - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - warnings: list[str] = [] - rows = gui.list_instances(warnings=warnings) - - assert rows == [] - assert registry.exists() - assert any("still starting" in warning for warning in warnings) - - -def test_list_instances_skips_draining_bridge_with_warning(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - return { - "ok": False, - "error": "IDA GUI bridge is draining", - "error_kind": "draining", - } - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - warnings: list[str] = [] - rows = gui.list_instances(warnings=warnings) - - assert rows == [] - assert registry.exists() - assert any("is draining" in warning for warning in warnings) - - -def test_list_instances_warns_on_malformed_bridge_status_payload(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - return {"ok": True, "result": {"instance_id": "broken"}} - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - warnings: list[str] = [] - rows = gui.list_instances(warnings=warnings) - - assert rows == [] - assert registry.exists() - assert any("missing required identity fields" in warning for warning in warnings) - - -def test_list_instances_forwards_requested_probe_timeout(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - observed_timeouts: list[float | None] = [] - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - observed_timeouts.append(request.timeout) - return {"ok": True, "result": _status_payload(instance)} - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - rows_without_timeout = gui.list_instances() - rows_with_timeout = gui.list_instances(timeout=12.5) - - assert len(rows_without_timeout) == 1 - assert len(rows_with_timeout) == 1 - assert observed_timeouts == [None, 12.5] - - -def test_list_instances_keeps_legacy_bridge_without_bridge_status(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - return {"ok": False, "error": "unknown operation 'bridge_status'"} - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - rows = gui.list_instances() - - assert [row.pid for row in rows] == [14679] - assert rows[0].plugin_version == VERSION - - -def test_validate_instance_status_rejects_started_at_mismatch(tmp_path: Path) -> None: - instance = gui.BridgeInstance( - pid=14679, - socket_path=tmp_path / "idac-bridge-14679.sock", - registry_path=tmp_path / "idac-bridge-14679.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at="2026-04-04T00:00:00Z", - instance_id="instance-14679", - ) - - with pytest.raises(gui.StaleBridgeInstanceError, match="start time changed"): - gui._validate_instance_status( - instance, - { - "pid": 14679, - "socket_path": str(instance.socket_path), - "instance_id": "instance-14679", - "started_at": "2026-04-04T00:00:01Z", - }, - ) - - -def test_load_instance_keeps_unreadable_registry_files(tmp_path: Path) -> None: - registry = tmp_path / "idac-bridge-1234.json" - registry.write_text('{"pid": 1234}', encoding="utf-8") - - assert gui._load_instance(registry) is None - assert registry.exists() - - -def test_load_instance_reports_warning_for_unreadable_registry(tmp_path: Path) -> None: - registry = tmp_path / "idac-bridge-1234.json" - registry.write_text('{"pid": 1234}', encoding="utf-8") - warnings: list[str] = [] - - assert gui._load_instance(registry, warnings=warnings) is None - assert warnings - assert "ignored unreadable or malformed GUI bridge registry" in warnings[0] - - -def test_load_instance_purges_gui_registry_for_live_idalib_worker(monkeypatch, tmp_path: Path) -> None: - socket_path = tmp_path / gui.bridge_socket_filename(22536) - socket_path.write_text("", encoding="utf-8") - registry = tmp_path / "idac-bridge-22536.json" - registry.write_text( - json.dumps( - { - "pid": 22536, - "socket_path": str(socket_path), - "plugin_name": "idac_bridge", - "plugin_version": VERSION, - } - ), - encoding="utf-8", - ) - warnings: list[str] = [] - monkeypatch.setattr(gui, "user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 22536) - monkeypatch.setattr( - gui, - "_pid_non_gui_bridge_reason", - lambda pid: "process is running the idalib worker, not an IDA GUI session", - ) - - assert gui._load_instance(registry, warnings=warnings) is None - assert not registry.exists() - assert not socket_path.exists() - assert any("idalib worker" in warning for warning in warnings) - - -def test_load_instance_keeps_untrusted_socket_path_when_purging_registry(monkeypatch, tmp_path: Path) -> None: - runtime_dir = tmp_path / "runtime" - runtime_dir.mkdir() - outside_socket = tmp_path / "outside.sock" - outside_socket.write_text("", encoding="utf-8") - registry = runtime_dir / "idac-bridge-22536.json" - registry.write_text( - json.dumps( - { - "pid": 22536, - "socket_path": str(outside_socket), - "plugin_name": "idac_bridge", - "plugin_version": VERSION, - } - ), - encoding="utf-8", - ) - warnings: list[str] = [] - monkeypatch.setattr(gui, "user_runtime_dir", lambda: runtime_dir) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: False) - - assert gui._load_instance(registry, warnings=warnings) is None - assert not registry.exists() - assert outside_socket.exists() - assert any("process is not running" in warning for warning in warnings) - - -def test_choose_instance_reports_runtime_dir_and_discovery_diagnostics(monkeypatch, tmp_path: Path) -> None: - registry = tmp_path / "idac-bridge-1234.json" - registry.write_text("{not-json}", encoding="utf-8") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "user_runtime_dir", lambda: tmp_path / "runtime") - - with pytest.raises(RuntimeError, match=r"runtime_dir=.*runtime"): - gui.choose_instance(None, warnings=[]) - - -def test_decode_response_rejects_malformed_json() -> None: - with pytest.raises(RuntimeError, match="malformed JSON"): - gui._decode_response([b"{not-json}"]) - - -def test_send_request_retries_transient_socket_errors(monkeypatch, tmp_path: Path) -> None: - instance = gui.BridgeInstance( - pid=1234, - socket_path=tmp_path / "idac-bridge-1234.sock", - registry_path=tmp_path / "idac-bridge-1234.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at=None, - meta={}, - ) - attempts = {"count": 0} - - def fake_socket_request(_socket_path, _encoded, *, timeout): - assert timeout == 1.25 - attempts["count"] += 1 - if attempts["count"] < 3: - raise OSError(errno.ECONNREFUSED, "refused") - return [json.dumps({"ok": True, "result": []}).encode("utf-8")] - - monkeypatch.setattr(gui, "_socket_request", fake_socket_request) - - response = gui._send_request_to_instance( - instance, - RequestEnvelope(op="database_info", backend="gui", timeout=1.25), - ) - - assert response["ok"] is True - assert attempts["count"] == 3 - - -def test_send_request_keeps_refused_live_socket_as_runtime_error(monkeypatch, tmp_path: Path) -> None: - instance = gui.BridgeInstance( - pid=1234, - socket_path=tmp_path / "idac-bridge-1234.sock", - registry_path=tmp_path / "idac-bridge-1234.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at=None, - meta={}, - ) - instance.socket_path.write_text("", encoding="utf-8") - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 1234) - - def fake_socket_request(_socket_path, _encoded, *, timeout): - assert timeout == 1.25 - raise OSError(errno.ECONNREFUSED, "refused") - - monkeypatch.setattr(gui, "_socket_request", fake_socket_request) - - with pytest.raises(RuntimeError, match=r"Connection refused|refused"): - gui._send_request_to_instance( - instance, - RequestEnvelope(op="database_info", backend="gui", timeout=1.25), - ) - - -def test_send_request_surfaces_permission_denied_without_retry(monkeypatch, tmp_path: Path) -> None: - instance = gui.BridgeInstance( - pid=1234, - socket_path=tmp_path / "idac-bridge-1234.sock", - registry_path=tmp_path / "idac-bridge-1234.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at=None, - meta={}, - ) - instance.socket_path.write_text("", encoding="utf-8") - attempts = {"count": 0} - - def fake_socket_request(_socket_path, _encoded, *, timeout): - assert timeout == 1.25 - attempts["count"] += 1 - raise OSError(errno.EACCES, "Permission denied") - - monkeypatch.setattr(gui, "_socket_request", fake_socket_request) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: True) - - with pytest.raises(RuntimeError, match=r"Permission denied|Errno 13"): - gui._send_request_to_instance( - instance, - RequestEnvelope(op="database_info", backend="gui", timeout=1.25), - ) - - assert attempts["count"] == 1 - - -def test_send_request_uses_blocking_socket_by_default(monkeypatch, tmp_path: Path) -> None: - instance = gui.BridgeInstance( - pid=1234, - socket_path=tmp_path / "idac-bridge-1234.sock", - registry_path=tmp_path / "idac-bridge-1234.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at=None, - meta={}, - ) - - def fake_socket_request(_socket_path, _encoded, *, timeout): - assert timeout is None - return [json.dumps({"ok": True, "result": []}).encode("utf-8")] - - monkeypatch.setattr(gui, "_socket_request", fake_socket_request) - - response = gui._send_request_to_instance( - instance, - RequestEnvelope(op="database_info", backend="gui"), - ) - - assert response["ok"] is True - - -def test_pid_is_live_treats_permission_denied_as_live(monkeypatch) -> None: - def fake_kill(_pid: int, _signal: int) -> None: - raise PermissionError(errno.EPERM, "Operation not permitted") - - monkeypatch.setattr(common.os, "kill", fake_kill) - - assert common.pid_is_live(1234) is True - - -def test_send_request_classifies_refused_dead_socket_as_stale(monkeypatch, tmp_path: Path) -> None: - instance = gui.BridgeInstance( - pid=1234, - socket_path=tmp_path / "idac-bridge-1234.sock", - registry_path=tmp_path / "idac-bridge-1234.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at=None, - meta={}, - ) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: False) - - def fake_socket_request(_socket_path, _encoded, *, timeout): - assert timeout == 1.25 - raise OSError(errno.ECONNREFUSED, "refused") - - monkeypatch.setattr(gui, "_socket_request", fake_socket_request) - - with pytest.raises(gui.StaleBridgeInstanceError, match=r"Connection refused|refused"): - gui._send_request_to_instance( - instance, - RequestEnvelope(op="database_info", backend="gui", timeout=1.25), - ) - - -def test_list_targets_rejects_version_mismatch_when_requested(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - payload = _status_payload(instance) - payload["plugin_version"] = "0.0.0" - return {"ok": True, "result": payload} - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - with pytest.raises(RuntimeError, match="version mismatch"): - gui.list_targets(require_matching_version=True) - - -def test_list_targets_preserves_legacy_version_mismatch_reporting(monkeypatch, tmp_path: Path) -> None: - registry = _write_instance(tmp_path, 14679, module="tiny") - payload = json.loads(registry.read_text(encoding="utf-8")) - payload["plugin_version"] = "0.0.0" - registry.write_text(json.dumps(payload), encoding="utf-8") - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda pid: pid == 14679) - monkeypatch.setattr(gui, "_pid_non_gui_bridge_reason", lambda pid: None) - - def fake_send(instance: gui.BridgeInstance, request, *, connect_retries: int = 4): - assert request.op == "bridge_status" - return {"ok": False, "error": "unknown operation 'bridge_status'"} - - monkeypatch.setattr(gui, "_send_request_to_instance", fake_send) - - with pytest.raises(RuntimeError, match=r"plugin=0\.0\.0, cli="): - gui.list_targets(require_matching_version=True) diff --git a/tests/test_gui_transport_live.py b/tests/test_gui_transport_live.py deleted file mode 100644 index c346acb..0000000 --- a/tests/test_gui_transport_live.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import importlib -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from idac.transport import gui -from idac.transport.schema import RequestEnvelope - - -def _import_bridge_module(monkeypatch): - repo_root = Path(__file__).resolve().parents[1] - sys.path.insert(0, str(repo_root / "src")) - monkeypatch.setitem( - sys.modules, - "ida_kernwin", - SimpleNamespace(MFF_WRITE=1, execute_sync=lambda fn, _flags: fn(), msg=lambda _text: None), - ) - try: - return importlib.import_module("idac.ida_plugin.idac_bridge.bridge") - finally: - sys.path.pop(0) - - -@pytest.mark.gui_live -def test_gui_transport_round_trip_over_real_unix_socket(monkeypatch, tmp_path: Path) -> None: - bridge_module = _import_bridge_module(monkeypatch) - runtime = tmp_path / "runtime" - pid = 4242 - registry = runtime / f"idac-bridge-{pid}.json" - sock = runtime / f"idac-bridge-{pid}.sock" - - monkeypatch.setattr(bridge_module.os, "getpid", lambda: pid) - monkeypatch.setattr(bridge_module, "runtime_dir", lambda: runtime) - monkeypatch.setattr(bridge_module, "registry_path", lambda _pid: registry) - monkeypatch.setattr(bridge_module, "socket_path", lambda _pid: sock) - - service = bridge_module.BridgeService( - bridge=bridge_module.IdacBridge( - handlers={ - "list_targets": lambda _params: [ - { - "target_id": "active", - "selector": "tiny", - "filename": "/tmp/tiny.i64", - "module": "tiny", - "active": True, - } - ], - "database_info": lambda params: { - "module": "tiny", - "target": "active", - "params": params, - }, - "bookmark_get": lambda params: { - "bookmarks": [ - { - "slot": 0, - "present": True, - "address": "0x100000460", - "comment": "entry point", - } - ], - "count": 1, - "target": "active", - "params": params, - }, - }, - validate_target=lambda target: ( - None - if target in (None, "", "active") - else (_ for _ in ()).throw(AssertionError(f"unexpected target: {target}")) - ), - ) - ) - service.start() - try: - monkeypatch.setattr(gui, "bridge_registry_paths", lambda: [registry]) - monkeypatch.setattr(gui, "pid_is_live", lambda candidate: candidate == pid) - - rows = gui.list_targets(require_matching_version=True) - assert len(rows) == 1 - assert rows[0]["instance_pid"] == pid - assert rows[0]["target_id"] == f"{pid}:active" - assert rows[0]["module"] == "tiny" - - instance = gui.list_instances()[0] - assert instance.instance_id - assert instance.state == "ready" - assert instance.started_at - - status_response = gui._send_request_to_instance( - instance, - RequestEnvelope( - op="bridge_status", - params={}, - backend="gui", - timeout=2.0, - ), - ) - - assert status_response["ok"] is True - assert status_response["backend"] == "gui" - assert status_response["result"]["pid"] == pid - assert status_response["result"]["state"] == "ready" - assert status_response["result"]["instance_id"] == instance.instance_id - - response = gui._send_request_to_instance( - instance, - RequestEnvelope( - op="database_info", - params={"ping": "pong"}, - backend="gui", - target="active", - timeout=2.0, - ), - ) - - assert response["ok"] is True - assert response["backend"] == "gui" - assert response["result"] == { - "module": "tiny", - "target": "active", - "params": {"ping": "pong"}, - } - - bookmark_response = gui._send_request_to_instance( - instance, - RequestEnvelope( - op="bookmark_get", - params={}, - backend="gui", - target="active", - timeout=2.0, - ), - ) - - assert bookmark_response["ok"] is True - assert bookmark_response["backend"] == "gui" - assert bookmark_response["result"] == { - "bookmarks": [ - { - "slot": 0, - "present": True, - "address": "0x100000460", - "comment": "entry point", - } - ], - "count": 1, - "target": "active", - "params": {}, - } - finally: - service.stop() diff --git a/tests/test_helpers.py b/tests/test_helpers.py deleted file mode 100644 index c72bdd5..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,24 +0,0 @@ -from tests.helpers import normalize_pseudocode_call_arguments - - -def test_normalize_pseudocode_call_arguments_handles_ida_version_formats() -> None: - pseudocode = ( - "legacy = add(2, 3);\n" - 'value = add(a: 2, b: 3);\nprintf(format: "tiny:%d\\n", value);\n' - 'describe(text: "example(a: 2), escaped: \\"quote\\"");\n' - "marker = '(label: value)';\n" - "selected = choose(flag ? left : right, fallback: other);\n" - "documented = call(argument: value /* example(label: value) */);\n" - "result = call(value); // example(label: value)\n" - "consume(std::move(value));" - ) - - assert normalize_pseudocode_call_arguments(pseudocode) == ( - 'legacy = add(2, 3);\nvalue = add(2, 3);\nprintf("tiny:%d\\n", value);\n' - 'describe("example(a: 2), escaped: \\"quote\\"");\n' - "marker = '(label: value)';\n" - "selected = choose(flag ? left : right, other);\n" - "documented = call(value /* example(label: value) */);\n" - "result = call(value); // example(label: value)\n" - "consume(std::move(value));" - ) diff --git a/tests/test_idalib_backend.py b/tests/test_idalib_backend.py deleted file mode 100644 index 2a182a6..0000000 --- a/tests/test_idalib_backend.py +++ /dev/null @@ -1,280 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from tests.helpers import normalize_pseudocode_call_arguments, run_cli - - -def test_idalib_database_show_json( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "database", - "show", - "-c", - f"db:{database}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - assert payload["module"] == "tiny" - assert payload["bits"] == 64 - assert payload["processor"] == "ARM" - - -def test_idalib_segment_list_json_filters_by_regex( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "segment", - "list", - "__text", - "--regex", - "-c", - f"db:{database}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - assert payload - assert all("__text" in item["name"] for item in payload) - assert {"name", "start", "end", "size"} <= set(payload[0]) - - -def test_idalib_function_list_json( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "function", - "list", - "-c", - f"db:{database}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - names = {item["name"] for item in payload} - assert {"main", "add"} <= names - assert all("display_name" in item for item in payload) - assert all("section" in item for item in payload) - assert {item["section"] for item in payload if item["name"] in {"main", "add"}} == {"__text"} - - -def test_idalib_function_list_json_accepts_query_and_limit( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "function", - "list", - "--query", - "a", - "--limit", - "1", - "-c", - f"db:{database}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - assert len(payload) == 1 - assert "a" in payload[0]["name"] - - -def test_idalib_function_list_json_with_segment_scope( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "function", - "list", - "--segment", - "__text", - "-c", - f"db:{database}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - names = {item["name"] for item in payload} - assert {"main", "add"} <= names - - -def test_idalib_decompile_text( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "decompile", - "main", - "-c", - f"db:{database}", - ) - assert proc.returncode == 0, proc.stderr - decompiled = normalize_pseudocode_call_arguments(proc.stdout) - assert "printf" in decompiled - assert "add(2, 3)" in decompiled - - -def test_idalib_decompile_text_with_f5( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "decompile", - "main", - "--f5", - "-c", - f"db:{database}", - ) - assert proc.returncode == 0, proc.stderr - decompiled = normalize_pseudocode_call_arguments(proc.stdout) - assert "printf" in decompiled - assert "add(2, 3)" in decompiled - - -def test_idalib_decompile_bulk_query_writes_manifest_and_artifacts( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, - tmp_path: Path, -) -> None: - database = copy_database(tiny_database) - out_dir = tmp_path / "decompile-query" - proc = run_cli( - idac_cmd, - idac_env, - "decompilemany", - "a", - "--out-dir", - str(out_dir), - "-c", - f"db:{database}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - assert payload["functions_total"] >= 2 - assert payload["functions_failed"] == 0 - assert len(payload["functions"]) >= 2 - manifest_path = Path(payload["manifest_path"]) - assert manifest_path.name == "manifest.json" - assert manifest_path.exists() - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - assert manifest["pattern"] == "a" - artifacts = [Path(item["artifact_path"]) for item in manifest["functions"] if item["ok"]] - assert len(artifacts) >= 2 - assert all(path.exists() for path in artifacts) - contents = "\n".join(path.read_text(encoding="utf-8") for path in artifacts) - assert "printf" in contents - assert "add(" in contents - - -def test_idalib_decompile_bulk_input_writes_requested_functions( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, - tmp_path: Path, -) -> None: - database = copy_database(tiny_database) - out_dir = tmp_path / "decompile-input" - input_path = tmp_path / "funcs.txt" - input_path.write_text("# requested\nmain\nadd\n", encoding="utf-8") - proc = run_cli( - idac_cmd, - idac_env, - "decompilemany", - "--file", - str(input_path), - "--out-dir", - str(out_dir), - "-c", - f"db:{database}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - assert payload["functions_total"] == 2 - assert len(payload["functions"]) == 2 - manifest = json.loads(Path(payload["manifest_path"]).read_text(encoding="utf-8")) - names = {item["name"] for item in manifest["functions"] if item["ok"]} - assert names == {"main", "add"} - artifact_text = { - item["name"]: Path(item["artifact_path"]).read_text(encoding="utf-8") - for item in manifest["functions"] - if item["ok"] - } - assert "printf" in artifact_text["main"] - assert "return a + b;" in artifact_text["add"] - - -def test_idalib_python_exec_json( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_cli( - idac_cmd, - idac_env, - "py", - "exec", - "-c", - f"db:{database}", - "--code", - "result = {'count': len(list(idautils.Functions()))}", - "--format", - "json", - ) - assert proc.returncode == 0, proc.stderr - payload = json.loads(proc.stdout) - assert payload["result"]["count"] >= 2 diff --git a/tests/test_idalib_binary_workflow.py b/tests/test_idalib_binary_workflow.py deleted file mode 100644 index 1a99a05..0000000 --- a/tests/test_idalib_binary_workflow.py +++ /dev/null @@ -1,217 +0,0 @@ -from __future__ import annotations - -import os -import platform -import subprocess -from pathlib import Path - -from tests.helpers import normalize_pseudocode_call_arguments, run_cli, run_cli_json - - -def _idalib_rows_for(rows: object, binary: Path) -> list[dict[str, object]]: - assert isinstance(rows, list) - return [ - row - for row in rows - if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("filename") == str(binary) - ] - - -def _build_tiny_binary(build_dir: Path) -> Path: - env = dict(os.environ) - env["FIXTURES_BUILD_DIR"] = str(build_dir) - proc = subprocess.run( - ["bash", "fixtures/scripts/build_tiny.sh"], - check=False, - capture_output=True, - text=True, - env=env, - ) - assert proc.returncode == 0, proc.stderr or proc.stdout - binary = build_dir / "tiny" - assert binary.is_file() - return binary - - -def _build_handler_hierarchy_binaries(build_dir: Path) -> tuple[Path, Path]: - env = dict(os.environ) - env["FIXTURES_BUILD_DIR"] = str(build_dir) - proc = subprocess.run( - ["bash", "fixtures/scripts/build_handler_hierarchy.sh"], - check=False, - capture_output=True, - text=True, - env=env, - ) - assert proc.returncode == 0, proc.stderr or proc.stdout - binary = build_dir / "handler_hierarchy" - stripped = build_dir / "handler_hierarchy.stripped" - assert binary.is_file() - assert stripped.is_file() - assert stripped.stat().st_size > 10_000 - return binary, stripped - - -def test_binary_first_skill_workflow_lists_headless_target_and_reads( - idac_cmd: list[str], - idac_env: dict[str, str], - tmp_path: Path, -) -> None: - binary = _build_tiny_binary(tmp_path / "build") - opened = False - try: - opened_payload = run_cli_json( - idac_cmd, - idac_env, - "--timeout", - "120", - "database", - "open", - str(binary), - ) - opened = True - assert opened_payload["opened"] is True - assert opened_payload["database"] == str(binary) - - targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") - headless_rows = _idalib_rows_for(targets, binary) - assert len(headless_rows) == 1 - assert headless_rows[0]["module"] == "tiny" - assert headless_rows[0]["active"] is True - - scoped_targets = run_cli_json( - idac_cmd, - idac_env, - "targets", - "list", - "-c", - f"db:{binary}", - "--timeout", - "10", - ) - scoped_rows = _idalib_rows_for(scoped_targets, binary) - assert scoped_rows == headless_rows - - info = run_cli_json( - idac_cmd, - idac_env, - "database", - "show", - "--timeout", - "30", - "-c", - f"db:{binary}", - ) - assert info["path"] == str(binary) - assert info["module"] == "tiny" - machine = platform.machine().lower() - expected_processor = "ARM" if machine in {"aarch64", "arm64"} else "metapc" - assert info["processor"] == expected_processor - assert info["start_ea"] - assert info["entry_ea"] - main_identifier = info["main_ea"] - assert main_identifier - - decompiled = run_cli( - idac_cmd, - idac_env, - "decompile", - main_identifier, - "--f5", - "--timeout", - "30", - "-c", - f"db:{binary}", - ) - assert decompiled.returncode == 0, decompiled.stderr or decompiled.stdout - pseudocode = normalize_pseudocode_call_arguments(decompiled.stdout) - assert "printf" in pseudocode - assert "add(2, 3" in pseudocode - finally: - if opened: - close = run_cli( - idac_cmd, - idac_env, - "database", - "close", - "--discard", - "--timeout", - "30", - "-c", - f"db:{binary}", - ) - assert close.returncode == 0, close.stderr or close.stdout - closed_targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") - assert _idalib_rows_for(closed_targets, binary) == [] - - -def test_binary_workflow_can_keep_multiple_larger_targets_open( - idac_cmd: list[str], - idac_env: dict[str, str], - tmp_path: Path, -) -> None: - binaries = _build_handler_hierarchy_binaries(tmp_path / "build") - opened: list[Path] = [] - try: - for binary in binaries: - opened_payload = run_cli_json( - idac_cmd, - idac_env, - "--timeout", - "60", - "database", - "open", - str(binary), - "--no-auto-analysis", - ) - opened.append(binary) - assert opened_payload["opened"] is True - assert opened_payload["database"] == str(binary) - - targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "20") - rows_by_binary = {binary: _idalib_rows_for(targets, binary) for binary in binaries} - assert all(len(rows) == 1 for rows in rows_by_binary.values()) - assert {rows[0]["filename"] for rows in rows_by_binary.values()} == {str(binary) for binary in binaries} - assert len({rows[0]["instance_pid"] for rows in rows_by_binary.values()}) == len(binaries) - - for binary in binaries: - scoped_targets = run_cli_json( - idac_cmd, - idac_env, - "targets", - "list", - "-c", - f"db:{binary}", - "--timeout", - "20", - ) - scoped_rows = _idalib_rows_for(scoped_targets, binary) - assert scoped_rows == rows_by_binary[binary] - - info = run_cli_json( - idac_cmd, - idac_env, - "database", - "show", - "--timeout", - "60", - "-c", - f"db:{binary}", - ) - assert info["path"] == str(binary) - assert str(info["module"]).startswith("handler_hierarchy") - assert info["start_ea"] or info["entry_ea"] - finally: - for binary in reversed(opened): - close = run_cli( - idac_cmd, - idac_env, - "database", - "close", - "--discard", - "--timeout", - "30", - "-c", - f"db:{binary}", - ) - assert close.returncode == 0, close.stderr or close.stdout diff --git a/tests/test_idalib_reanalyze_python.py b/tests/test_idalib_reanalyze_python.py deleted file mode 100644 index a0e2783..0000000 --- a/tests/test_idalib_reanalyze_python.py +++ /dev/null @@ -1,447 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from tests.helpers import ( - normalize_pseudocode_call_arguments, - run_idalib, - run_idalib_json, - run_idalib_text, -) - - -def test_reanalyze_function_mode_reports_function_bounds( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - - result = run_idalib_json(idac_cmd, idac_env, database, "misc", "reanalyze", "main") - - assert result["mode"] == "function" - assert result["function"] == "main" - assert result["start"] == "0x100000460" - assert result["end"] == "0x1000004b0" - assert result["waited"] is True - - -def test_reanalyze_address_mode_reports_single_item_range( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - - result = run_idalib_json(idac_cmd, idac_env, database, "misc", "reanalyze", "0x1000004dc") - - assert result == { - "mode": "address", - "start": "0x1000004dc", - "end": "0x1000004dd", - "waited": True, - } - - -def test_reanalyze_rejects_non_increasing_range( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - proc = run_idalib( - idac_cmd, - idac_env, - database, - "misc", - "reanalyze", - "0x100000460", - "--end", - "0x100000460", - ) - - assert proc.returncode == 1 - assert "reanalyze range end must be greater than the start" in proc.stderr - - -def test_idalib_python_exec_supports_stdin_and_script( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, - tmp_path: Path, -) -> None: - database = copy_database(tiny_database) - script_path = tmp_path / "emit_script.py" - script_path.write_text( - "print('script-stdout')\nresult = {'mode': 'script', 'count': len(list(idautils.Functions()))}\n", - encoding="utf-8", - ) - - stdin_payload = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--stdin", - input_text="print('stdin-stdout')\nresult = {'mode': 'stdin', 'count': len(list(idautils.Functions()))}\n", - ) - script_payload = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--script", - str(script_path), - ) - - assert stdin_payload == { - "stdout": "stdin-stdout\n", - "result": {"mode": "stdin", "count": 3}, - "result_repr": "{'mode': 'stdin', 'count': 3}", - } - assert script_payload == { - "stdout": "script-stdout\n", - "result": {"mode": "script", "count": 3}, - "result_repr": "{'mode': 'script', 'count': 3}", - } - - -def test_idalib_python_exec_persist_reuses_scope_within_open_session( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - first = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "counter = 41\nresult = {'counter': counter}", - ) - second = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "counter += 1\nresult = {'counter': counter}", - ) - fresh = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--code", - "result = {'has_counter': 'counter' in globals()}", - ) - - assert first == { - "stdout": "", - "result": {"counter": 41}, - "result_repr": "{'counter': 41}", - } - assert second == { - "stdout": "", - "result": {"counter": 42}, - "result_repr": "{'counter': 42}", - } - assert fresh == { - "stdout": "", - "result": {"has_counter": False}, - "result_repr": "{'has_counter': False}", - } - - -def test_idalib_python_exec_persist_can_update_and_reread_session_variable( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - seeded = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "session_counter = 7\nresult = {'seeded': session_counter}", - ) - reread = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "result = {'session_counter': session_counter}", - ) - updated = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "session_counter += 5\nresult = {'updated': session_counter}", - ) - reread_updated = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "result = {'session_counter': session_counter}", - ) - - assert seeded == { - "stdout": "", - "result": {"seeded": 7}, - "result_repr": "{'seeded': 7}", - } - assert reread == { - "stdout": "", - "result": {"session_counter": 7}, - "result_repr": "{'session_counter': 7}", - } - assert updated == { - "stdout": "", - "result": {"updated": 12}, - "result_repr": "{'updated': 12}", - } - assert reread_updated == { - "stdout": "", - "result": {"session_counter": 12}, - "result_repr": "{'session_counter': 12}", - } - - -def test_idalib_python_exec_persist_can_call_session_function_in_later_call( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - seeded = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "session_counter = 7\n" - "def read_session_counter():\n" - " return session_counter\n" - "result = {'seeded': read_session_counter()}", - ) - called = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "result = {'session_counter': read_session_counter()}", - ) - updated = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "session_counter += 5\nresult = {'updated': read_session_counter()}", - ) - called_after_update = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--persist", - "--code", - "result = {'session_counter': read_session_counter()}", - ) - - assert seeded == { - "stdout": "", - "result": {"seeded": 7}, - "result_repr": "{'seeded': 7}", - } - assert called == { - "stdout": "", - "result": {"session_counter": 7}, - "result_repr": "{'session_counter': 7}", - } - assert updated == { - "stdout": "", - "result": {"updated": 12}, - "result_repr": "{'updated': 12}", - } - assert called_after_update == { - "stdout": "", - "result": {"session_counter": 12}, - "result_repr": "{'session_counter': 12}", - } - - -def test_idalib_python_exec_changes_require_explicit_db_save( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - payload = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--code", - "ea = idc.get_name_ea_simple('main')\nidc.set_cmt(ea, 'session only', 0)\nresult = {'comment_ea': hex(ea)}", - ) - in_session = run_idalib_json(idac_cmd, idac_env, database, "comment", "show", "main") - closed = run_idalib_json(idac_cmd, idac_env, database, "database", "close", "--discard") - reopened = run_idalib_json(idac_cmd, idac_env, database, "comment", "show", "main") - - assert payload == { - "stdout": "", - "result": {"comment_ea": "0x100000460"}, - "result_repr": "{'comment_ea': '0x100000460'}", - } - assert in_session == { - "address": "0x100000460", - "scope": "line", - "repeatable": False, - "comment": "session only", - } - assert closed == { - "closed": True, - "database": str(database.resolve(strict=False)), - "saved": False, - } - assert reopened == { - "address": "0x100000460", - "scope": "line", - "repeatable": False, - "comment": None, - } - - -def test_idalib_db_save_persists_python_exec_changes( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--code", - "ea = idc.get_name_ea_simple('main')\n" - "idc.set_cmt(ea, 'saved by database save', 0)\n" - "result = {'comment_ea': hex(ea)}", - ) - saved = run_idalib_json(idac_cmd, idac_env, database, "database", "save") - closed = run_idalib_json(idac_cmd, idac_env, database, "database", "close", "--discard") - reopened = run_idalib_json(idac_cmd, idac_env, database, "comment", "show", "main") - - assert saved == { - "saved": True, - "path": str(database.resolve(strict=False)), - } - assert closed == { - "closed": True, - "database": str(database.resolve(strict=False)), - "saved": False, - } - assert reopened == { - "address": "0x100000460", - "scope": "line", - "repeatable": False, - "comment": "saved by database save", - } - - -def test_reanalyze_range_restores_function_after_session_item_deletion( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - database = copy_database(tiny_database) - deleted = run_idalib_json( - idac_cmd, - idac_env, - database, - "py", - "exec", - "--code", - "import ida_bytes\n" - "ea = idc.get_name_ea_simple('main')\n" - "ida_bytes.del_items(ea, 0, 4)\n" - "result = {'ea': hex(ea)}", - ) - missing = run_idalib(idac_cmd, idac_env, database, "function", "metadata", "main", use_json=True) - reanalyzed = run_idalib_json( - idac_cmd, - idac_env, - database, - "misc", - "reanalyze", - "0x100000460", - "--end", - "0x100000468", - ) - restored = run_idalib_json(idac_cmd, idac_env, database, "function", "metadata", "main") - decompiled = normalize_pseudocode_call_arguments(run_idalib_text(idac_cmd, idac_env, database, "decompile", "main")) - - assert deleted == { - "stdout": "", - "result": {"ea": "0x100000460"}, - "result_repr": "{'ea': '0x100000460'}", - } - assert missing.returncode == 1 - assert "function not found: main" in missing.stderr - - assert reanalyzed == { - "mode": "range", - "start": "0x100000460", - "end": "0x100000468", - "waited": True, - } - assert restored["address"] == "0x100000460" - assert restored["name"] == "main" - assert restored["prototype"] == ("int __fastcall main(int argc, const char **argv, const char **envp)") - assert restored["size"] == 80 - assert restored["flags"] in {"0x1410", "0x210"} - assert 'printf("tiny:%d\\n", v4);' in decompiled diff --git a/tests/test_installers.py b/tests/test_installers.py deleted file mode 100644 index bc842b1..0000000 --- a/tests/test_installers.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -import importlib -import sys -from pathlib import Path -from types import SimpleNamespace - -from tests.helpers import run_cli, run_cli_json - - -def test_skill_install_symlink(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: - env = dict(idac_env) - env["CLAUDE_HOME"] = str(tmp_path / ".claude") - env["CODEX_HOME"] = str(tmp_path / ".codex") - - result = run_cli_json(idac_cmd, env, "misc", "skill", "install") - - destinations = [Path(str(item)) for item in result["destinations"]] - assert result["installed"] is True - assert len(destinations) == 2 - assert all(dest.is_symlink() for dest in destinations) - expected = (Path(__file__).resolve().parents[1] / "src" / "idac" / "skills" / "idac").resolve() - assert all(dest.resolve() == expected for dest in destinations) - - -def test_skill_install_symlink_single_host(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: - env = dict(idac_env) - env["CLAUDE_HOME"] = str(tmp_path / ".claude") - env["CODEX_HOME"] = str(tmp_path / ".codex") - - result = run_cli_json(idac_cmd, env, "misc", "skill", "install", "--host", "claude") - - destinations = [Path(str(item)) for item in result["destinations"]] - assert result["installed"] is True - assert len(destinations) == 1 - dest = destinations[0] - assert dest == tmp_path / ".claude" / "skills" / "idac" - assert dest.is_symlink() - - -def test_skill_install_copy_custom_dest(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: - env = dict(idac_env) - dest = tmp_path / "skills" / "idac" - - result = run_cli_json(idac_cmd, env, "misc", "skill", "install", "--mode", "copy", "--dest", str(dest)) - - assert result["installed"] is True - assert result["destinations"] == [str(dest)] - assert dest.exists() - assert not dest.is_symlink() - assert (dest / "SKILL.md").exists() - assert (dest / "agents" / "openai.yaml").exists() - - -def test_skill_install_refuses_existing_destination_without_force( - idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path -) -> None: - env = dict(idac_env) - dest = tmp_path / "skills" / "idac" - dest.mkdir(parents=True) - (dest / "stale.txt").write_text("keep", encoding="utf-8") - - proc = run_cli(idac_cmd, env, "misc", "skill", "install", "--mode", "copy", "--dest", str(dest)) - - assert proc.returncode == 1 - assert f"destination already exists: {dest}" in proc.stderr - assert (dest / "stale.txt").read_text(encoding="utf-8") == "keep" - - -def test_skill_install_force_replaces_existing_custom_copy( - idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path -) -> None: - env = dict(idac_env) - dest = tmp_path / "skills" / "idac" - dest.mkdir(parents=True) - (dest / "stale.txt").write_text("remove me", encoding="utf-8") - - result = run_cli_json( - idac_cmd, - env, - "misc", - "skill", - "install", - "--mode", - "copy", - "--dest", - str(dest), - "--force", - ) - - assert result["installed"] is True - assert result["destinations"] == [str(dest)] - assert not (dest / "stale.txt").exists() - assert (dest / "SKILL.md").exists() - assert (dest / "agents" / "openai.yaml").exists() - - -def test_plugin_install_symlink(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: - env = dict(idac_env) - env["IDAUSR"] = str(tmp_path / ".idapro") - - result = run_cli_json(idac_cmd, env, "misc", "plugin", "install") - - package_dest = Path(str(result["package_destination"])) - bootstrap_dest = Path(str(result["bootstrap_destination"])) - runtime_package_dest = Path(str(result["runtime_package_destination"])) - assert package_dest.is_symlink() - assert bootstrap_dest.is_symlink() - assert runtime_package_dest.is_symlink() - assert ( - package_dest.resolve() - == (Path(__file__).resolve().parents[1] / "src" / "idac" / "ida_plugin" / "idac_bridge").resolve() - ) - assert ( - bootstrap_dest.resolve() - == (Path(__file__).resolve().parents[1] / "src" / "idac" / "ida_plugin" / "idac_bridge_plugin.py").resolve() - ) - assert runtime_package_dest.resolve() == (Path(__file__).resolve().parents[1] / "src" / "idac").resolve() - - -def test_plugin_install_copy_custom_dest(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: - env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" - - result = run_cli_json(idac_cmd, env, "misc", "plugin", "install", "--mode", "copy", "--dest", str(dest)) - - package_dest = Path(str(result["package_destination"])) - bootstrap_dest = Path(str(result["bootstrap_destination"])) - runtime_package_dest = Path(str(result["runtime_package_destination"])) - assert package_dest == dest - assert bootstrap_dest == dest.parent / "idac_bridge_plugin.py" - assert runtime_package_dest == dest.parent / "idac" - assert package_dest.exists() - assert not package_dest.is_symlink() - assert (package_dest / "__init__.py").exists() - assert bootstrap_dest.exists() - assert not bootstrap_dest.is_symlink() - assert runtime_package_dest.exists() - assert not runtime_package_dest.is_symlink() - assert (runtime_package_dest / "cli.py").exists() - - -def test_plugin_install_refuses_existing_destination_without_force( - idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path -) -> None: - env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" - dest.mkdir(parents=True) - (dest / "stale.txt").write_text("keep", encoding="utf-8") - - proc = run_cli(idac_cmd, env, "misc", "plugin", "install", "--mode", "copy", "--dest", str(dest)) - - assert proc.returncode == 1 - assert f"destination already exists: {dest}" in proc.stderr - assert (dest / "stale.txt").read_text(encoding="utf-8") == "keep" - assert not (dest.parent / "idac_bridge_plugin.py").exists() - assert not (dest.parent / "idac").exists() - - -def test_plugin_install_force_replaces_existing_copy_targets( - idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path -) -> None: - env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" - bootstrap_dest = dest.parent / "idac_bridge_plugin.py" - runtime_dest = dest.parent / "idac" - dest.mkdir(parents=True) - runtime_dest.mkdir() - (dest / "stale.txt").write_text("remove me", encoding="utf-8") - bootstrap_dest.write_text("stale bootstrap\n", encoding="utf-8") - (runtime_dest / "stale.txt").write_text("remove me", encoding="utf-8") - - result = run_cli_json( - idac_cmd, - env, - "misc", - "plugin", - "install", - "--mode", - "copy", - "--dest", - str(dest), - "--force", - ) - - assert result["installed"] is True - assert result["package_destination"] == str(dest) - assert result["bootstrap_destination"] == str(bootstrap_dest) - assert result["runtime_package_destination"] == str(runtime_dest) - assert not (dest / "stale.txt").exists() - assert not (runtime_dest / "stale.txt").exists() - assert (dest / "__init__.py").exists() - assert (bootstrap_dest).exists() - assert (runtime_dest / "cli.py").exists() - - -def test_plugin_install_copy_custom_dest_is_importable_without_repo_root( - idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path, monkeypatch -) -> None: - env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" - - run_cli_json(idac_cmd, env, "misc", "plugin", "install", "--mode", "copy", "--dest", str(dest)) - - plugins_dir = dest.parent - saved_modules = { - name: sys.modules.pop(name) - for name in list(sys.modules) - if name == "idac" or name.startswith(("idac.", "idac_bridge", "idac_bridge_plugin")) - } - sys.path.insert(0, str(plugins_dir)) - monkeypatch.setitem( - sys.modules, - "idaapi", - SimpleNamespace( - plugin_t=object, - PLUGIN_FIX=1, - PLUGIN_KEEP=2, - msg=lambda _text: None, - ), - ) - monkeypatch.setitem( - sys.modules, - "ida_kernwin", - SimpleNamespace(MFF_WRITE=1, execute_sync=lambda fn, _flags: fn()), - ) - try: - module = importlib.import_module("idac_bridge_plugin") - imported_idac = importlib.import_module("idac") - finally: - sys.path.pop(0) - for name in list(sys.modules): - if name == "idac" or name.startswith(("idac.", "idac_bridge", "idac_bridge_plugin")): - sys.modules.pop(name, None) - sys.modules.update(saved_modules) - - assert Path(imported_idac.__file__).resolve().is_relative_to((plugins_dir / "idac").resolve()) - assert callable(module.PLUGIN_ENTRY) diff --git a/tests/test_idalib_batch.py b/tests/test_nexus_batch.py similarity index 68% rename from tests/test_idalib_batch.py rename to tests/test_nexus_batch.py index 934654a..2cc2045 100644 --- a/tests/test_idalib_batch.py +++ b/tests/test_nexus_batch.py @@ -6,8 +6,8 @@ from tests.helpers import ( normalize_pseudocode_call_arguments, run_cli, - run_idalib_json, - run_idalib_text, + run_nexus_json, + run_nexus_text, ) @@ -44,7 +44,7 @@ def test_batch_reuses_batch_dir_and_updates_prototypes_and_locals( (batch_dir / "recovery.idac").write_text( "\n".join( [ - "# batch commands omit backend/database flags", + "# batch commands inherit the wrapper's Nexus target", "type declare --decl-file recovered_types.h", "function prototype set add --decl-file proto_decl.h", "function locals rename main v4 --new-name sum_value", @@ -64,15 +64,15 @@ def test_batch_reuses_batch_dir_and_updates_prototypes_and_locals( "--out", str(out_path), "-c", - f"db:{database}", + str(database), ) assert proc.returncode == 0, proc.stderr or proc.stdout assert proc.stdout == "" result = json.loads(out_path.read_text(encoding="utf-8")) - updated_proto = run_idalib_json(idac_cmd, idac_env, database, "function", "prototype", "show", "add") - updated_locals = run_idalib_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") - type_info = run_idalib_json(idac_cmd, idac_env, database, "type", "show", "cli_batch_record") - decompiled = normalize_pseudocode_call_arguments(run_idalib_text(idac_cmd, idac_env, database, "decompile", "main")) + updated_proto = run_nexus_json(idac_cmd, idac_env, database, "function", "prototype", "show", "add") + updated_locals = run_nexus_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") + type_info = run_nexus_json(idac_cmd, idac_env, database, "type", "show", "cli_batch_record") + decompiled = normalize_pseudocode_call_arguments(run_nexus_text(idac_cmd, idac_env, database, "decompile", "main")) assert isinstance(result, dict) assert result["ok"] is True @@ -95,6 +95,36 @@ def test_batch_reuses_batch_dir_and_updates_prototypes_and_locals( assert "sum_value = add(2, 3);" in decompiled +def test_batch_saves_coordinated_function_rename_prototype_and_comment( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, + tmp_path: Path, +) -> None: + database = copy_database(tiny_database) + batch = tmp_path / "edits.idac" + batch.write_text( + "misc rename 0x1000004b0 eval_add\n" + "function prototype set 0x1000004b0 --preserve-cc --decl 'int __cdecl eval_add(int left, int right);'\n" + "comment set 0x1000004b0 'coordinated edits' --scope function\n" + "function prototype show 0x1000004b0\n" + "comment show 0x1000004b0 --scope function\n" + ) + journal = tmp_path / "edits.json" + proc = run_cli(idac_cmd, idac_env, "batch", str(batch), "--fail-fast", "--out", str(journal), "-c", str(database)) + assert proc.returncode == 0, proc.stderr or proc.stdout + result = json.loads(journal.read_text()) + assert result["ok"] is True + assert result["commands_failed"] == 0 + prototype = run_nexus_json(idac_cmd, idac_env, database, "function", "prototype", "show", "eval_add") + comment = run_nexus_json(idac_cmd, idac_env, database, "comment", "show", "eval_add", "--scope", "function") + assert prototype["prototype"] == "int __cdecl eval_add(int left, int right)" + assert result["results"][-2]["result"] == prototype + assert result["results"][-1]["result"] == comment + assert comment["comment"] == "coordinated edits" + + def test_batch_defaults_to_stdout_json_without_out( idac_cmd: list[str], idac_env: dict[str, str], @@ -112,7 +142,7 @@ def test_batch_defaults_to_stdout_json_without_out( "batch", str(batch_path), "-c", - f"db:{database}", + str(database), ) assert proc.returncode == 0, proc.stderr or proc.stdout @@ -120,7 +150,8 @@ def test_batch_defaults_to_stdout_json_without_out( assert payload["ok"] is True assert payload["commands_total"] == 1 assert payload["results"][0]["status"] == "ok" - assert payload["results"][0]["result"]["address"] == "0x1000004b0" + assert payload["results"][0]["result"]["address"] + assert "add(" in payload["results"][0]["result"]["prototype"] def test_batch_lint_fixture_context_accepts_valid_batch( @@ -141,7 +172,7 @@ def test_batch_lint_fixture_context_accepts_valid_batch( str(batch_path), "--lint", "-c", - f"db:{database}", + str(database), ) assert proc.returncode == 0, proc.stderr or proc.stdout @@ -173,7 +204,7 @@ def test_batch_lint_fixture_context_reports_command_local_errors( "--out", str(out_path), "-c", - f"db:{database}", + str(database), ) assert proc.returncode == 1 @@ -181,63 +212,9 @@ def test_batch_lint_fixture_context_reports_command_local_errors( assert payload["ok"] is False assert payload["errors_total"] == 1 assert payload["results"][0]["status"] == "failed" - assert "at least one of --rename or declaration input is required" in payload["results"][0]["stderr"] - - -def test_batch_lint_fixture_context_reports_disasm_validation_error( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, - tmp_path: Path, -) -> None: - database = copy_database(tiny_database) - batch_path = tmp_path / "lint_disasm_invalid.idac" - batch_path.write_text("disasm\n", encoding="utf-8") - - proc = run_cli( - idac_cmd, - idac_env, - "batch", - str(batch_path), - "--lint", - "-c", - f"db:{database}", - ) - - assert proc.returncode == 1 - payload = json.loads(proc.stdout) - assert payload["ok"] is False - assert payload["errors_total"] == 1 - assert "disasm requires a function or --start/--end" in payload["results"][0]["stderr"] - - -def test_batch_lint_fixture_context_rejects_forwarded_context_for_no_context_command( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, - tmp_path: Path, -) -> None: - database = copy_database(tiny_database) - batch_path = tmp_path / "lint_docs_no_context.idac" - batch_path.write_text("docs cli\n", encoding="utf-8") - - proc = run_cli( - idac_cmd, - idac_env, - "batch", - str(batch_path), - "--lint", - "-c", - f"db:{database}", - ) - - assert proc.returncode == 1 - payload = json.loads(proc.stdout) - assert payload["ok"] is False - assert payload["errors_total"] == 1 - assert "`idac docs` does not accept -c/--context" in payload["results"][0]["stderr"] + diagnostic = payload["results"][0]["stderr"] + assert "rename" in diagnostic + assert "declaration" in diagnostic def test_batch_writes_per_line_output_artifacts( @@ -261,7 +238,7 @@ def test_batch_writes_per_line_output_artifacts( "--out", str(out_path), "-c", - f"db:{database}", + str(database), ) assert proc.returncode == 0, proc.stderr or proc.stdout @@ -273,7 +250,7 @@ def test_batch_writes_per_line_output_artifacts( assert artifact["format"] == "json" assert artifact["ok"] is True written = json.loads(step_out.read_text(encoding="utf-8")) - assert written["address"] == "0x1000004b0" + assert written["address"] assert "add(" in written["prototype"] @@ -297,50 +274,18 @@ def test_batch_allows_per_line_json_without_step_out( "--out", str(out_path), "-c", - f"db:{database}", + str(database), ) assert proc.returncode == 0, proc.stderr or proc.stdout payload = json.loads(out_path.read_text(encoding="utf-8")) assert payload["ok"] is True assert payload["results"][0]["status"] == "ok" - assert payload["results"][0]["result"]["address"] == "0x1000004b0" - - -def test_batch_preflights_step_output_before_mutation( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, - tmp_path: Path, -) -> None: - database = copy_database(tiny_database) - batch_path = tmp_path / "bad_mutation.idac" - out_path = tmp_path / "batch.json" - bad_out = tmp_path / "step-dir" - bad_out.mkdir() - batch_path.write_text("comment set main entry point -ostep-dir\n", encoding="utf-8") - - proc = run_cli( - idac_cmd, - idac_env, - "batch", - str(batch_path), - "--out", - str(out_path), - "-c", - f"db:{database}", - ) - - assert proc.returncode == 1 - payload = json.loads(out_path.read_text(encoding="utf-8")) - assert payload["ok"] is False - assert payload["results"][0]["status"] == "failed" - comment = run_idalib_json(idac_cmd, idac_env, database, "comment", "show", "main") - assert comment["comment"] is None + assert payload["results"][0]["result"]["address"] + assert "add(" in payload["results"][0]["result"]["prototype"] -def test_batch_reports_backend_operation_failure( +def test_batch_reports_nexus_operation_failure( idac_cmd: list[str], idac_env: dict[str, str], copy_database, @@ -360,13 +305,13 @@ def test_batch_reports_backend_operation_failure( "--out", str(out_path), "-c", - f"db:{database}", + str(database), ) assert proc.returncode == 1 assert "batch line 1:" in proc.stderr - assert "symbol not found: missing_symbol" in proc.stderr + assert "missing_symbol" in proc.stderr payload = json.loads(out_path.read_text(encoding="utf-8")) assert payload["ok"] is False assert payload["commands_failed"] == 1 - assert payload["results"][0]["stderr"] == "symbol not found: missing_symbol" + assert "missing_symbol" in payload["results"][0]["stderr"] diff --git a/tests/test_nexus_binary_workflow.py b/tests/test_nexus_binary_workflow.py new file mode 100644 index 0000000..39416ad --- /dev/null +++ b/tests/test_nexus_binary_workflow.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from tests.helpers import normalize_pseudocode_call_arguments, run_cli, run_cli_json + + +def _headless_rows_for(rows: object, binary: Path) -> list[dict[str, object]]: + assert isinstance(rows, list) + return [ + row + for row in rows + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("exe_path") == str(binary) + ] + + +def _build_tiny_binary(build_dir: Path) -> Path: + env = dict(os.environ) + env["FIXTURES_BUILD_DIR"] = str(build_dir) + proc = subprocess.run( + ["bash", "fixtures/scripts/build_tiny.sh"], + check=False, + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, proc.stderr or proc.stdout + binary = build_dir / "tiny" + assert binary.is_file() + return binary + + +def _build_handler_hierarchy_binaries(build_dir: Path) -> tuple[Path, Path]: + env = dict(os.environ) + env["FIXTURES_BUILD_DIR"] = str(build_dir) + proc = subprocess.run( + ["bash", "fixtures/scripts/build_handler_hierarchy.sh"], + check=False, + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, proc.stderr or proc.stdout + binary = build_dir / "handler_hierarchy" + stripped = build_dir / "handler_hierarchy.stripped" + assert binary.is_file() + assert stripped.is_file() + return binary, stripped + + +def test_binary_path_opens_headless_nexus_target_and_reads( + idac_cmd: list[str], + idac_env: dict[str, str], + tmp_path: Path, +) -> None: + binary = _build_tiny_binary(tmp_path / "build") + info = run_cli_json( + idac_cmd, + idac_env, + "database", + "show", + "--timeout", + "120", + "-c", + str(binary), + ) + + assert info["path"] == str(binary) + assert info["database_path"] == f"{binary}.i64" + assert info["start_ea"] + assert info["entry_ea"] + + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + headless_rows = _headless_rows_for(targets, binary) + assert len(headless_rows) == 1 + assert headless_rows[0]["state"] == "ready" + assert headless_rows[0]["idb_path"] == f"{binary}.i64" + assert headless_rows[0]["managed"] is True + + main_identifier = info["main_ea"] + assert main_identifier + decompiled = run_cli( + idac_cmd, + idac_env, + "decompile", + main_identifier, + "--f5", + "--timeout", + "30", + "-c", + str(binary), + ) + assert decompiled.returncode == 0, decompiled.stderr or decompiled.stdout + pseudocode = normalize_pseudocode_call_arguments(decompiled.stdout) + assert "printf" in pseudocode + assert "add(2, 3" in pseudocode + + +def test_binary_paths_keep_distinct_headless_nexus_targets_ready( + idac_cmd: list[str], + idac_env: dict[str, str], + tmp_path: Path, +) -> None: + binaries = _build_handler_hierarchy_binaries(tmp_path / "build") + for binary in binaries: + info = run_cli_json( + idac_cmd, + idac_env, + "database", + "show", + "--timeout", + "120", + "-c", + str(binary), + ) + assert info["path"] == str(binary) + assert info["database_path"] == f"{binary}.i64" + assert info["start_ea"] or info["entry_ea"] + + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "20") + rows_by_binary = {binary: _headless_rows_for(targets, binary) for binary in binaries} + assert all(len(rows) == 1 for rows in rows_by_binary.values()) + assert len({rows[0]["record_id"] for rows in rows_by_binary.values()}) == len(binaries) + + +def test_database_selected_output_preserves_original_binary( + idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path +) -> None: + binary = _build_tiny_binary(tmp_path / "build") + binary_before = binary.read_bytes() + saved = run_cli_json(idac_cmd, idac_env, "database", "save", "-c", str(binary)) + database = Path(saved["path"]) + database_before = database.read_bytes() + + proc = run_cli(idac_cmd, idac_env, "database", "show", "-c", str(database), "--json", "--out", str(binary)) + + assert proc.returncode == 1 + assert "must not overwrite the selected input or database" in proc.stderr + assert binary.read_bytes() == binary_before + assert database.read_bytes() == database_before diff --git a/tests/test_idalib_bookmarks.py b/tests/test_nexus_bookmarks.py similarity index 80% rename from tests/test_idalib_bookmarks.py rename to tests/test_nexus_bookmarks.py index 9499510..9d05f97 100644 --- a/tests/test_idalib_bookmarks.py +++ b/tests/test_nexus_bookmarks.py @@ -2,7 +2,7 @@ from pathlib import Path -from tests.helpers import preview_round_trip_cli2, run_idalib_json +from tests.helpers import preview_round_trip, run_nexus_json def test_bookmark_list_returns_all_live_slots_only( @@ -12,10 +12,10 @@ def test_bookmark_list_returns_all_live_slots_only( tiny_database: Path, ) -> None: database = copy_database(tiny_database) - run_idalib_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "main", "--comment", "entry point") - run_idalib_json(idac_cmd, idac_env, database, "bookmark", "set", "2", "add", "--comment", "helper") + run_nexus_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "main", "--comment", "entry point") + run_nexus_json(idac_cmd, idac_env, database, "bookmark", "set", "2", "add", "--comment", "helper") - result = run_idalib_json(idac_cmd, idac_env, database, "bookmark", "list") + result = run_nexus_json(idac_cmd, idac_env, database, "bookmark", "list") assert isinstance(result, dict) assert result["count"] == 2 @@ -41,7 +41,7 @@ def test_bookmark_show_reports_empty_slot( copy_database, tiny_database: Path, ) -> None: - result = run_idalib_json( + result = run_nexus_json( idac_cmd, idac_env, copy_database(tiny_database), @@ -65,10 +65,10 @@ def test_bookmark_add_uses_lowest_free_slot( tiny_database: Path, ) -> None: database = copy_database(tiny_database) - run_idalib_json(idac_cmd, idac_env, database, "bookmark", "set", "2", "add", "--comment", "helper") - run_idalib_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "main", "--comment", "entry point") + run_nexus_json(idac_cmd, idac_env, database, "bookmark", "set", "2", "add", "--comment", "helper") + run_nexus_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "main", "--comment", "entry point") - added = run_idalib_json(idac_cmd, idac_env, database, "bookmark", "add", "main", "--comment", "auto slot") + added = run_nexus_json(idac_cmd, idac_env, database, "bookmark", "add", "main", "--comment", "auto slot") assert added == { "slot": 0, @@ -77,7 +77,7 @@ def test_bookmark_add_uses_lowest_free_slot( "comment": "auto slot", "changed": True, } - assert run_idalib_json(idac_cmd, idac_env, database, "bookmark", "show", "0") == { + assert run_nexus_json(idac_cmd, idac_env, database, "bookmark", "show", "0") == { "slot": 0, "present": True, "address": "0x100000460", @@ -93,7 +93,7 @@ def test_bookmark_set_preview_then_persist_round_trip( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -138,9 +138,9 @@ def test_bookmark_set_preview_then_persist_overwrites_existing_slot( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - run_idalib_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "add", "--comment", "helper") + run_nexus_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "add", "--comment", "helper") - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -181,7 +181,7 @@ def test_bookmark_add_preview_then_persist_round_trip( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -195,8 +195,27 @@ def test_bookmark_add_preview_then_persist_round_trip( persisted = result["persisted"] after_persist = result["after_persist"] - assert preview["before"] == {"bookmarks": [], "count": 0} + assert preview["before"] == { + "slot": 0, + "present": False, + "address": None, + "comment": None, + } assert preview["after"] == { + "slot": 0, + "present": True, + "address": "0x100000460", + "comment": "entry point", + } + assert after_preview == {"bookmarks": [], "count": 0} + assert persisted == { + "slot": 0, + "present": True, + "address": "0x100000460", + "comment": "entry point", + "changed": True, + } + assert after_persist == { "bookmarks": [ { "slot": 0, @@ -207,15 +226,6 @@ def test_bookmark_add_preview_then_persist_round_trip( ], "count": 1, } - assert after_preview == preview["before"] - assert persisted == { - "slot": 0, - "present": True, - "address": "0x100000460", - "comment": "entry point", - "changed": True, - } - assert after_persist == preview["after"] def test_bookmark_delete_empty_slot_is_a_noop( @@ -226,7 +236,7 @@ def test_bookmark_delete_empty_slot_is_a_noop( ) -> None: database = copy_database(tiny_database) - deleted = run_idalib_json(idac_cmd, idac_env, database, "bookmark", "delete", "5") + deleted = run_nexus_json(idac_cmd, idac_env, database, "bookmark", "delete", "5") assert deleted == { "slot": 5, @@ -245,9 +255,9 @@ def test_bookmark_delete_preview_then_persist_round_trip( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - run_idalib_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "main", "--comment", "entry point") + run_nexus_json(idac_cmd, idac_env, database, "bookmark", "set", "5", "main", "--comment", "entry point") - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, diff --git a/tests/test_idalib_classes.py b/tests/test_nexus_classes.py similarity index 78% rename from tests/test_idalib_classes.py rename to tests/test_nexus_classes.py index b807b54..5312323 100644 --- a/tests/test_idalib_classes.py +++ b/tests/test_nexus_classes.py @@ -2,18 +2,18 @@ from pathlib import Path -from tests.helpers import run_idalib_json +from tests.helpers import run_nexus_json def _declare_handler_types(idac_cmd: list[str], idac_env: dict[str, str], database: Path, decl_file: Path) -> None: - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, database, ["type", "declare", "--replace", "--decl-file", str(decl_file)] ) assert isinstance(payload, dict) assert payload.get("success") is True -def test_idalib_class_list_and_show_from_fixture( +def test_nexus_class_list_and_show_from_fixture( idac_cmd: list[str], idac_env: dict[str, str], copy_database, @@ -23,12 +23,12 @@ def test_idalib_class_list_and_show_from_fixture( database = copy_database(handler_hierarchy_stripped_database) _declare_handler_types(idac_cmd, idac_env, database, handler_hierarchy_types) - list_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "class", "list", "Handler"]) + list_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "class", "list", "Handler"]) assert isinstance(list_payload, list) names = {item.get("name") for item in list_payload if isinstance(item, dict)} assert {"Handler", "Handler_Text", "Handler_Stream", "LegacyGroup__Handler_Pack"} <= names - show_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "class", "show", "Handler_Stream"]) + show_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "class", "show", "Handler_Stream"]) assert isinstance(show_payload, dict) assert show_payload.get("name") == "Handler_Stream" assert show_payload.get("kind") == "class" @@ -37,7 +37,7 @@ def test_idalib_class_list_and_show_from_fixture( assert {"primary_hits", "bytes_remaining", "has_result"} <= member_names -def test_idalib_class_hierarchy_fields_and_vtable_from_fixture( +def test_nexus_class_hierarchy_fields_and_vtable_from_fixture( idac_cmd: list[str], idac_env: dict[str, str], copy_database, @@ -47,12 +47,12 @@ def test_idalib_class_hierarchy_fields_and_vtable_from_fixture( database = copy_database(handler_hierarchy_stripped_database) _declare_handler_types(idac_cmd, idac_env, database, handler_hierarchy_types) - hierarchy_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "class", "hierarchy", "Handler"]) + hierarchy_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "class", "hierarchy", "Handler"]) assert isinstance(hierarchy_payload, dict) derived = set(hierarchy_payload.get("derived") or []) assert {"Handler_Text", "Handler_Stream", "LegacyGroup__Handler_Pack"} <= derived - derived_fields = run_idalib_json( + derived_fields = run_nexus_json( idac_cmd, idac_env, database, @@ -68,7 +68,7 @@ def test_idalib_class_hierarchy_fields_and_vtable_from_fixture( } <= derived_names assert "category_names" not in derived_names - vtable_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "class", "vtable", "Handler_Stream"]) + vtable_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "class", "vtable", "Handler_Stream"]) assert isinstance(vtable_payload, dict) assert vtable_payload.get("vtable_type") == "Handler_Stream_vtbl" member_names = {member.get("name") for member in vtable_payload.get("members") or []} @@ -81,7 +81,7 @@ def test_idalib_class_hierarchy_fields_and_vtable_from_fixture( } <= member_names -def test_idalib_type_declare_replace_with_fixture_class_header( +def test_nexus_type_declare_replace_with_fixture_class_header( idac_cmd: list[str], idac_env: dict[str, str], copy_database, @@ -90,12 +90,16 @@ def test_idalib_type_declare_replace_with_fixture_class_header( ) -> None: database = copy_database(handler_hierarchy_stripped_database) _declare_handler_types(idac_cmd, idac_env, database, handler_hierarchy_types) - replace_decl = handler_hierarchy_types.read_text(encoding="utf-8").replace( - "bool has_result;", - "bool has_result;\n unsigned debug_cookie;", + original_decl = handler_hierarchy_types.read_text(encoding="utf-8") + fixture_member = "bool has_result;" + assert fixture_member in original_decl, "class fixture no longer contains the member used by this replacement test" + replace_decl = original_decl.replace( + fixture_member, + f"{fixture_member}\n unsigned debug_cookie;", + 1, ) - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, database, @@ -106,7 +110,7 @@ def test_idalib_type_declare_replace_with_fixture_class_header( replaced = set(payload.get("replaced_types") or []) assert "Handler_Stream" in replaced - fields_payload = run_idalib_json( + fields_payload = run_nexus_json( idac_cmd, idac_env, database, @@ -117,28 +121,28 @@ def test_idalib_type_declare_replace_with_fixture_class_header( assert "debug_cookie" in field_names -def test_idalib_class_candidates_finds_symbols_in_unstripped_fixture( +def test_nexus_class_candidates_finds_symbols_in_unstripped_fixture( idac_cmd: list[str], idac_env: dict[str, str], copy_database, handler_hierarchy_database: Path, ) -> None: database = copy_database(handler_hierarchy_database) - payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "class", "candidates", "Handler_Stream"]) + payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "class", "candidates", "Handler_Stream"]) assert isinstance(payload, list) kinds_and_names = {(item.get("kind"), item.get("name")) for item in payload if isinstance(item, dict)} assert ("vtable_symbol", "__ZTV14Handler_Stream") in kinds_and_names assert ("function_symbol", "__ZN14Handler_Stream12acceptBufferEPKhmPKcj") in kinds_and_names -def test_idalib_class_candidates_kind_filter_limits_results( +def test_nexus_class_candidates_kind_filter_limits_results( idac_cmd: list[str], idac_env: dict[str, str], copy_database, handler_hierarchy_database: Path, ) -> None: database = copy_database(handler_hierarchy_database) - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, database, @@ -159,7 +163,7 @@ def test_idalib_class_candidates_kind_filter_limits_results( assert "__ZTV14Handler_Stream" not in names -def test_idalib_class_vtable_runtime_includes_raw_targets( +def test_nexus_class_vtable_runtime_includes_raw_targets( idac_cmd: list[str], idac_env: dict[str, str], copy_database, @@ -168,7 +172,7 @@ def test_idalib_class_vtable_runtime_includes_raw_targets( ) -> None: database = copy_database(handler_hierarchy_database) _declare_handler_types(idac_cmd, idac_env, database, handler_hierarchy_types) - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, database, diff --git a/tests/test_idalib_ctree.py b/tests/test_nexus_ctree.py similarity index 80% rename from tests/test_idalib_ctree.py rename to tests/test_nexus_ctree.py index bfb3e17..0ee145e 100644 --- a/tests/test_idalib_ctree.py +++ b/tests/test_nexus_ctree.py @@ -3,13 +3,13 @@ import json from pathlib import Path -from tests.helpers import run_idalib, run_idalib_json +from tests.helpers import run_nexus, run_nexus_json -def test_idalib_ctree_dump(idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path) -> None: +def test_nexus_ctree_dump(idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path) -> None: database = copy_database(tiny_database) - result = run_idalib_json(idac_cmd, idac_env, database, "ctree", "main") + result = run_nexus_json(idac_cmd, idac_env, database, "ctree", "main") assert isinstance(result, dict) assert result["level"] == "ctree" @@ -19,7 +19,7 @@ def test_idalib_ctree_dump(idac_cmd: list[str], idac_env: dict[str, str], copy_d assert any(item["op"] == "call" for item in result["nodes"] if isinstance(item, dict)) -def test_idalib_microcode_dump( +def test_nexus_microcode_dump( idac_cmd: list[str], idac_env: dict[str, str], copy_database, @@ -28,7 +28,7 @@ def test_idalib_microcode_dump( ) -> None: database = copy_database(tiny_database) out_path = tmp_path / "microcode.json" - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, database, diff --git a/tests/test_idalib_function_inspection_search.py b/tests/test_nexus_function_inspection_search.py similarity index 79% rename from tests/test_idalib_function_inspection_search.py rename to tests/test_nexus_function_inspection_search.py index 2be1c84..0ff6f77 100644 --- a/tests/test_idalib_function_inspection_search.py +++ b/tests/test_nexus_function_inspection_search.py @@ -2,16 +2,16 @@ from pathlib import Path -from tests.helpers import run_idalib_json +from tests.helpers import run_nexus_json -def test_idalib_function_frame( +def test_nexus_function_frame( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "frame", "main") + payload = run_nexus_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "frame", "main") assert isinstance(payload, dict) assert payload["function"] == "main" @@ -25,13 +25,13 @@ def test_idalib_function_frame( assert [item["kind"] for item in members] == ["local", "local", "local", "special"] -def test_idalib_function_stackvars( +def test_nexus_function_stackvars( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "stackvars", "main") + payload = run_nexus_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "stackvars", "main") assert isinstance(payload, dict) assert payload["function"] == "main" @@ -48,14 +48,14 @@ def test_idalib_function_stackvars( assert [item["access"] for item in target["xrefs"]] == ["write", "read", "read"] -def test_idalib_function_callees_and_xrefs( +def test_nexus_function_callees_and_xrefs( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: database = copy_database(tiny_database) - callees = run_idalib_json(idac_cmd, idac_env, database, "function", "callees", "main") + callees = run_nexus_json(idac_cmd, idac_env, database, "function", "callees", "main") assert isinstance(callees, dict) assert callees["function"] == "main" assert {(item["callee"], item["callee_address"]) for item in callees["edges"]} == { @@ -63,7 +63,7 @@ def test_idalib_function_callees_and_xrefs( ("add", "0x1000004b0"), } - callers = run_idalib_json(idac_cmd, idac_env, database, "function", "callers", "add") + callers = run_nexus_json(idac_cmd, idac_env, database, "function", "callers", "add") assert isinstance(callers, dict) assert callers["function"] == "add" assert callers["edges"] == [ @@ -74,7 +74,7 @@ def test_idalib_function_callees_and_xrefs( } ] - xrefs = run_idalib_json(idac_cmd, idac_env, database, "xrefs", "add") + xrefs = run_nexus_json(idac_cmd, idac_env, database, "xrefs", "add") assert xrefs == [ { "from": "0x100000478", @@ -87,13 +87,13 @@ def test_idalib_function_callees_and_xrefs( ] -def test_idalib_search_bytes_supports_ida_hex_pattern( +def test_nexus_search_bytes_supports_ida_hex_pattern( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, copy_database(tiny_database), @@ -103,7 +103,7 @@ def test_idalib_search_bytes_supports_ida_hex_pattern( "--segment", "__cstring", "--timeout", - "1", + "10", ) assert isinstance(payload, dict) @@ -112,13 +112,13 @@ def test_idalib_search_bytes_supports_ida_hex_pattern( assert payload["results"] == [{"address": "0x1000004dc"}] -def test_idalib_search_bytes_supports_ida_wildcards( +def test_nexus_search_bytes_supports_ida_wildcards( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, copy_database(tiny_database), @@ -128,7 +128,7 @@ def test_idalib_search_bytes_supports_ida_wildcards( "--segment", "__cstring", "--timeout", - "1", + "10", ) assert isinstance(payload, dict) @@ -137,13 +137,13 @@ def test_idalib_search_bytes_supports_ida_wildcards( assert payload["results"] == [{"address": "0x1000004dc"}] -def test_idalib_search_strings_respects_segment_scope( +def test_nexus_search_strings_respects_segment_scope( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, copy_database(tiny_database), @@ -153,7 +153,7 @@ def test_idalib_search_strings_respects_segment_scope( "--segment", "__cstring", "--timeout", - "1", + "10", ) assert payload == [{"address": "0x1000004dc", "text": "tiny:%d\n"}] diff --git a/tests/test_nexus_gui_live.py b/tests/test_nexus_gui_live.py new file mode 100644 index 0000000..40aa3c2 --- /dev/null +++ b/tests/test_nexus_gui_live.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import json +import os +import shutil +import sys +import uuid +from pathlib import Path + +import pytest +from ida_nexus import DatabaseHandle, find_database_owner, wait_database_released + +from idac.nexus import NexusSession +from tests.helpers import run_cli, run_cli_json + + +def _read_snapshot_comment(snapshot: Path, address: str) -> str | None: + """Read a saved copy in a fresh worker, then terminate that worker without saving.""" + + try: + with NexusSession(locator=snapshot, timeout=120.0) as session: + result = session.execute_operation( + "comment_get", + {"address": address, "scope": "line", "repeatable": False}, + ) + assert isinstance(result, dict) + comment = result.get("comment") + assert comment is None or isinstance(comment, str) + return comment + finally: + owner = find_database_owner(snapshot, timeout=5.0) + if owner is not None: + handle = DatabaseHandle.attach(owner, keepalive=0.0) + try: + handle.shutdown_database(save=False) + finally: + handle.close() + assert wait_database_released(owner, timeout=30.0) + + +@pytest.mark.nexus_gui_live +def test_gui_attach_preview_save_close_and_reattach_lifecycle(tmp_path: Path) -> None: + record_id = os.environ.get("IDAC_NEXUS_GUI_RECORD_ID", "").strip() + assert record_id, "set IDAC_NEXUS_GUI_RECORD_ID to a disposable READY GUI database record" + idac_cmd = [sys.executable, "-m", "idac"] + cli_env = dict(os.environ) + targets = run_cli_json(idac_cmd, cli_env, "targets", "list", "--timeout", "2") + assert isinstance(targets, list) + matches = [target for target in targets if target["record_id"] == record_id] + assert len(matches) == 1, f"GUI Nexus record {record_id!r} was not found" + assert matches[0]["state"] == "ready" and matches[0]["backend"] == "gui", ( + f"Nexus record {record_id!r} is not a READY GUI database" + ) + idb_path = matches[0].get("idb_path") + assert isinstance(idb_path, str) and idb_path + gui_database = Path(idb_path).expanduser().resolve() + assert gui_database.is_file() and gui_database.suffix.lower() == ".i64", ( + "the disposable GUI target must be a saved .i64 database" + ) + + address: str | None = None + original_comment: str | None = None + original_captured = False + sentinel = f"idac GUI lifecycle {uuid.uuid4().hex}" + try: + baseline_snapshot = tmp_path / "gui-baseline-snapshot.i64" + shutil.copy2(gui_database, baseline_snapshot) + info = run_cli_json( + idac_cmd, + cli_env, + "database", + "show", + "--instance", + record_id, + "--timeout", + "10", + ) + assert isinstance(info, dict) + selected_address = info.get("main_ea") or info.get("start_ea") or info.get("entry_ea") + assert isinstance(selected_address, str) and selected_address + address = selected_address + before = run_cli_json( + idac_cmd, + cli_env, + "comment", + "show", + address, + "--instance", + record_id, + "--timeout", + "10", + ) + assert isinstance(before, dict) + original = before.get("comment") + assert original is None or isinstance(original, str) + original_comment = original + original_captured = True + disk_comment_before = _read_snapshot_comment(baseline_snapshot, address) + + preview_path = tmp_path / "gui-preview.json" + preview_proc = run_cli( + idac_cmd, + cli_env, + "preview", + "--instance", + record_id, + "--timeout", + "10", + "--out", + preview_path, + "comment", + "set", + address, + sentinel, + ) + assert preview_proc.returncode == 0, preview_proc.stderr or preview_proc.stdout + preview = json.loads(preview_path.read_text(encoding="utf-8")) + assert preview["before"] == before + assert preview["after"]["comment"] == sentinel + assert ( + run_cli_json( + idac_cmd, + cli_env, + "comment", + "show", + address, + "--instance", + record_id, + "--timeout", + "10", + ) + == before + ) + + changed = run_cli_json( + idac_cmd, + cli_env, + "comment", + "set", + address, + sentinel, + "--instance", + record_id, + "--timeout", + "10", + ) + assert isinstance(changed, dict) and changed["comment"] == sentinel + + assert address is not None + unsaved_snapshot = tmp_path / "gui-unsaved-snapshot.i64" + shutil.copy2(gui_database, unsaved_snapshot) + assert _read_snapshot_comment(unsaved_snapshot, address) == disk_comment_before + + live_change = run_cli_json( + idac_cmd, + cli_env, + "comment", + "show", + address, + "--instance", + record_id, + "--timeout", + "10", + ) + assert isinstance(live_change, dict) and live_change["comment"] == sentinel + saved = run_cli_json( + idac_cmd, + cli_env, + "database", + "save", + "--instance", + record_id, + "--timeout", + "10", + ) + assert isinstance(saved, dict) and saved["saved"] is True + + saved_snapshot = tmp_path / "gui-saved-snapshot.i64" + shutil.copy2(gui_database, saved_snapshot) + assert _read_snapshot_comment(saved_snapshot, address) == sentinel + finally: + if original_captured and address is not None: + action = ( + ["comment", "delete", address] + if original_comment is None + else [ + "comment", + "set", + address, + original_comment, + ] + ) + restored = run_cli_json( + idac_cmd, + cli_env, + *action, + "--instance", + record_id, + "--timeout", + "10", + ) + assert isinstance(restored, dict) + run_cli_json( + idac_cmd, + cli_env, + "database", + "save", + "--instance", + record_id, + "--timeout", + "10", + ) + + assert original_captured and address is not None + final_readback = run_cli_json( + idac_cmd, + cli_env, + "comment", + "show", + address, + "--instance", + record_id, + "--timeout", + "10", + ) + assert isinstance(final_readback, dict) and final_readback["comment"] == original_comment + + restored_snapshot = tmp_path / "gui-restored-snapshot.i64" + shutil.copy2(gui_database, restored_snapshot) + assert _read_snapshot_comment(restored_snapshot, address) == original_comment diff --git a/tests/test_nexus_headless_lifecycle.py b/tests/test_nexus_headless_lifecycle.py new file mode 100644 index 0000000..790ce9f --- /dev/null +++ b/tests/test_nexus_headless_lifecycle.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +from tests.helpers import run_cli_json, run_nexus_json, run_preview_json + + +def _shutdown_without_save(database: Path, env: dict[str, str]) -> dict[str, object]: + """Release the exact managed worker without another save checkpoint.""" + + code = r""" +import json +import sys + +from ida_nexus import DatabaseHandle, find_database_owner, wait_database_released + +database = sys.argv[1] +instance = find_database_owner(database, timeout=5.0) +if instance is None: + raise RuntimeError(f"no Nexus worker owns {database}") + +handle = DatabaseHandle.attach(instance, keepalive=0.0) +try: + shutdown = handle.shutdown_database(save=False) +finally: + handle.close() + +if not wait_database_released(instance, timeout=30.0): + raise TimeoutError(f"Nexus worker did not release {instance.record_id}") + +print(json.dumps({ + "record_id": instance.record_id, + "shutting_down": shutdown["shutting_down"], + "save": shutdown["save"], +})) +""" + proc = subprocess.run( + [sys.executable, "-c", code, str(database)], + check=False, + capture_output=True, + text=True, + env=env, + timeout=45, + ) + assert proc.returncode == 0, proc.stderr or proc.stdout + result = json.loads(proc.stdout) + assert result["shutting_down"] is True + assert result["save"] is False + return result + + +def test_headless_autosave_survives_worker_shutdown_and_fresh_reopen( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + mutation = run_nexus_json(idac_cmd, idac_env, database, "misc", "rename", "add", "lifecycle_saved") + + assert mutation["changed"] is True + retired = _shutdown_without_save(database, idac_env) + + readback = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "lifecycle_saved") + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + reopened = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + + assert readback["name"] == "lifecycle_saved" + assert len(reopened) == 1 + assert reopened[0]["record_id"] != retired["record_id"] + + +def test_headless_preview_is_absent_after_worker_shutdown_and_fresh_reopen( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, + tmp_path: Path, +) -> None: + database = copy_database(tiny_database) + proc, preview = run_preview_json( + idac_cmd, + idac_env, + database, + tmp_path / "lifecycle-preview.json", + "misc", + "rename", + "add", + "lifecycle_preview", + ) + + assert proc.returncode == 0, proc.stderr or proc.stdout + assert preview["after"]["name"] == "lifecycle_preview" + assert preview["undo"] == {"status": "ok", "mode": "rollback", "persisted": False} + live_readback = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + assert live_readback["name"] == "add" + retired = _shutdown_without_save(database, idac_env) + + readback = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + reopened = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + + assert readback["name"] == "add" + assert len(reopened) == 1 + assert reopened[0]["record_id"] != retired["record_id"] + + +def test_failed_headless_preview_discards_worker_after_checkpointing_prior_success( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, + tmp_path: Path, +) -> None: + database = copy_database(tiny_database) + initial = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + address = initial["address"] + remote_module = tmp_path / "failed_preview_remote.py" + remote_module.write_text( + """ +def dispatch(db, op, params, preview): + import ida_name + + address = int(params["address"], 0) + if not ida_name.set_name(address, params["name"], ida_name.SN_FORCE): + raise RuntimeError("test mutation failed") + if preview: + raise RuntimeError("simulated preview rollback failure") + return {"name": params["name"]} +""".lstrip(), + encoding="utf-8", + ) + committed_name = "lifecycle_before_failed_preview" + discarded_name = "lifecycle_failed_preview" + lifecycle_code = r""" +import json +import sys + +from idac.nexus import NexusSession, NexusSessionError + +database, remote_module, address, committed_name, discarded_name = sys.argv[1:] +session = NexusSession(database, timeout=30.0, remote_module_path=remote_module) +session.execute_operation("name_set", {"address": address, "name": committed_name}) +record_id = session.handle.instance.record_id +try: + session.execute_operation( + "comment_set", + {"address": address, "name": discarded_name}, + preview=True, + ) +except NexusSessionError as exc: + error = str(exc) +else: + raise AssertionError("the simulated failed preview unexpectedly succeeded") +session.close() +print(json.dumps({"record_id": record_id, "error": error})) +""" + lifecycle = subprocess.run( + [ + sys.executable, + "-c", + lifecycle_code, + str(database), + str(remote_module), + address, + committed_name, + discarded_name, + ], + check=False, + capture_output=True, + text=True, + env=idac_env, + timeout=45, + ) + assert lifecycle.returncode == 0, lifecycle.stderr or lifecycle.stdout + retired = json.loads(lifecycle.stdout) + assert "simulated preview rollback failure" in retired["error"] + + readback = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", committed_name) + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + reopened = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + + assert readback["name"] == committed_name + assert readback["name"] != discarded_name + assert len(reopened) == 1 + assert reopened[0]["record_id"] != retired["record_id"] + + +def test_binary_created_database_autosave_survives_fresh_reopen( + idac_cmd: list[str], + idac_env: dict[str, str], + tmp_path: Path, +) -> None: + build_dir = tmp_path / "build" + build_env = dict(os.environ) + build_env["FIXTURES_BUILD_DIR"] = str(build_dir) + build = subprocess.run( + ["bash", "fixtures/scripts/build_tiny.sh"], + check=False, + capture_output=True, + text=True, + env=build_env, + ) + assert build.returncode == 0, build.stderr or build.stdout + binary = build_dir / "tiny" + assert binary.is_file() + + mutation = run_nexus_json( + idac_cmd, + idac_env, + binary, + "misc", + "rename", + "add", + "binary_lifecycle_saved", + "--timeout", + "120", + ) + assert mutation["changed"] is True + database = Path(f"{binary}.i64") + assert database.is_file() + retired = _shutdown_without_save(binary, idac_env) + + readback = run_nexus_json( + idac_cmd, + idac_env, + binary, + "function", + "metadata", + "binary_lifecycle_saved", + "--timeout", + "120", + ) + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + reopened = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + + assert readback["name"] == "binary_lifecycle_saved" + assert len(reopened) == 1 + assert reopened[0]["record_id"] != retired["record_id"] + + +def test_timed_out_python_mutation_is_saved_and_lease_is_released( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + initial = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + address = initial["address"] + code = ( + f"ida_name.set_name({int(address, 16)}, 'lifecycle_timeout_saved', ida_name.SN_FORCE)\nwhile True:\n pass\n" + ) + + timed_out = subprocess.run( + [ + *idac_cmd, + "py", + "exec", + "--code", + code, + "-c", + str(database), + "--timeout", + "1", + "--format", + "json", + ], + check=False, + capture_output=True, + text=True, + env=idac_env, + timeout=20, + ) + + assert timed_out.returncode == 1 + assert "timed out" in timed_out.stderr.lower() + retired = _shutdown_without_save(database, idac_env) + + readback = run_nexus_json( + idac_cmd, + idac_env, + database, + "function", + "metadata", + "lifecycle_timeout_saved", + ) + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + reopened = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + + assert readback["name"] == "lifecycle_timeout_saved" + assert len(reopened) == 1 + assert reopened[0]["record_id"] != retired["record_id"] + + +def test_interrupted_python_discards_uncertain_worker_and_exits_cleanly( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, + tmp_path: Path, +) -> None: + database = copy_database(tiny_database) + initial = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + address = initial["address"] + started_marker = tmp_path / "interrupt-operation-started" + code = ( + f"ida_name.set_name({int(address, 16)}, 'discarded_interrupt', ida_name.SN_FORCE)\n" + f"with open({str(started_marker)!r}, 'w') as marker:\n marker.write('started')\n" + "while True:\n pass\n" + ) + process = subprocess.Popen( + [ + *idac_cmd, + "py", + "exec", + "--code", + code, + "-c", + str(database), + "--timeout", + "10", + "--format", + "json", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=idac_env, + ) + interrupted_record: dict[str, object] | None = None + release_watcher: subprocess.Popen[str] | None = None + watcher_stdout = "" + watcher_stderr = "" + try: + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline and process.poll() is None and not started_marker.is_file(): + time.sleep(0.1) + + assert started_marker.is_file(), "the interrupted operation never reached its mutation marker" + assert started_marker.read_text(encoding="utf-8") == "started" + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "2") + matches = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + if matches: + interrupted_record = matches[0] + assert interrupted_record is not None, "the Nexus worker never became discoverable" + + watcher_marker = tmp_path / "interrupt-release-watcher-started" + watcher_code = r""" +import json +import sys +from pathlib import Path + +from ida_nexus import find_database_owner, wait_database_released + +instance = find_database_owner(sys.argv[1], timeout=5.0) +if instance is None: + raise RuntimeError("the interrupted worker has no database owner") +Path(sys.argv[2]).write_text(instance.record_id, encoding="utf-8") +released = wait_database_released(instance, timeout=30.0) +print(json.dumps({"record_id": instance.record_id, "released": released})) +""" + release_watcher = subprocess.Popen( + [sys.executable, "-c", watcher_code, str(database), str(watcher_marker)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=idac_env, + ) + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline and release_watcher.poll() is None and not watcher_marker.is_file(): + time.sleep(0.05) + assert watcher_marker.is_file(), "the worker-release watcher never recorded the interrupted worker" + assert watcher_marker.read_text(encoding="utf-8") == interrupted_record["record_id"] + + os.kill(process.pid, signal.SIGINT) + stdout, stderr = process.communicate(timeout=20) + watcher_stdout, watcher_stderr = release_watcher.communicate(timeout=35) + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + if release_watcher is not None and release_watcher.poll() is None: + release_watcher.kill() + release_watcher.communicate(timeout=5) + + assert process.returncode == 130 + assert stdout == "" + assert "interrupted" in stderr.lower() + assert "Traceback" not in stderr + assert release_watcher is not None and release_watcher.returncode == 0, watcher_stderr + release_result = json.loads(watcher_stdout) + assert release_result == {"record_id": interrupted_record["record_id"], "released": True} + + readback = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + reopened = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + + assert readback["name"] == "add" + assert len(reopened) == 1 + assert reopened[0]["record_id"] != interrupted_record["record_id"] + + +def test_crashed_headless_worker_fails_once_and_fresh_command_recovers( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + process = subprocess.Popen( + [ + *idac_cmd, + "py", + "exec", + "--code", + "while True:\n pass\n", + "-c", + str(database), + "--timeout", + "30", + "--format", + "json", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=idac_env, + ) + crashed_record: dict[str, object] | None = None + try: + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline and process.poll() is None: + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "2") + matches = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + if matches: + crashed_record = matches[0] + break + time.sleep(0.1) + + assert crashed_record is not None, "the Nexus worker never became discoverable" + pid = crashed_record["pid"] + assert isinstance(pid, int) and pid > 0 + os.kill(pid, signal.SIGKILL) + stdout, stderr = process.communicate(timeout=15) + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + + assert process.returncode not in {None, 0} + assert stderr.strip() or stdout.strip() + + readback = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + targets = run_cli_json(idac_cmd, idac_env, "targets", "list", "--timeout", "10") + reopened = [ + row + for row in targets + if isinstance(row, dict) and row.get("backend") == "idalib" and row.get("idb_path") == str(database) + ] + + assert readback["name"] == "add" + assert len(reopened) == 1 + assert reopened[0]["record_id"] != crashed_record["record_id"] diff --git a/tests/test_idalib_locals.py b/tests/test_nexus_locals.py similarity index 65% rename from tests/test_idalib_locals.py rename to tests/test_nexus_locals.py index ec7dc94..d7a76d6 100644 --- a/tests/test_idalib_locals.py +++ b/tests/test_nexus_locals.py @@ -2,52 +2,35 @@ from pathlib import Path -from tests.helpers import run_idalib_json +from tests.helpers import run_nexus_json -def test_idalib_local_list( +def test_nexus_local_list( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "locals", "list", "main") + payload = run_nexus_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "locals", "list", "main") assert isinstance(payload, dict) assert payload["function"] == "main" locals_rows = payload["locals"] assert any(item["name"] == "argc" for item in locals_rows if isinstance(item, dict)) assert any(item["name"] == "v4" and item["is_stack"] for item in locals_rows if isinstance(item, dict)) assert all(item.get("local_id") for item in locals_rows if isinstance(item, dict)) - assert any("@" in item["local_id"] for item in locals_rows if isinstance(item, dict)) -def test_idalib_local_rename_and_retype( +def test_nexus_local_id_remains_valid_after_rename( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: database = copy_database(tiny_database) - listed = run_idalib_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") + listed = run_nexus_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") target = next(item for item in listed["locals"] if isinstance(item, dict) and item.get("name") == "v4") - renamed_by_index = run_idalib_json( - idac_cmd, - idac_env, - database, - "function", - "locals", - "rename", - "main", - "--index", - str(target["index"]), - "--new-name", - "sum_value", - ) - assert isinstance(renamed_by_index, dict) - assert any(item["name"] == "sum_value" for item in renamed_by_index["locals"] if isinstance(item, dict)) - - renamed = run_idalib_json( + renamed = run_nexus_json( idac_cmd, idac_env, database, @@ -58,12 +41,12 @@ def test_idalib_local_rename_and_retype( "--local-id", target["local_id"], "--new-name", - "sum_value_2", + "sum_value", ) assert isinstance(renamed, dict) - assert any(item["name"] == "sum_value_2" for item in renamed["locals"] if isinstance(item, dict)) + assert any(item["name"] == "sum_value" for item in renamed["locals"] if isinstance(item, dict)) - renamed = run_idalib_json( + renamed = run_nexus_json( idac_cmd, idac_env, database, @@ -74,40 +57,23 @@ def test_idalib_local_rename_and_retype( "--local-id", target["local_id"], "--new-name", - "sum_value_3", + "sum_value_again", ) assert isinstance(renamed, dict) - assert any(item["name"] == "sum_value_3" for item in renamed["locals"] if isinstance(item, dict)) - - retyped = run_idalib_json( - idac_cmd, - idac_env, - database, - "function", - "locals", - "retype", - "main", - "--index", - str(target["index"]), - "--decl", - "unsigned int sum_value;", - ) - assert isinstance(retyped, dict) - target = next(item for item in retyped["locals"] if isinstance(item, dict) and item["name"] == "sum_value_3") - assert target["type"] == "unsigned int" + assert any(item["name"] == "sum_value_again" for item in renamed["locals"] if isinstance(item, dict)) -def test_idalib_local_update_accepts_stable_selectors_without_positional_selector( +def test_nexus_local_update_accepts_stable_selectors_without_positional_selector( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: database = copy_database(tiny_database) - listed = run_idalib_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") + listed = run_nexus_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") target = next(item for item in listed["locals"] if isinstance(item, dict) and item.get("name") == "v4") - updated = run_idalib_json( + updated = run_nexus_json( idac_cmd, idac_env, database, @@ -126,7 +92,7 @@ def test_idalib_local_update_accepts_stable_selectors_without_positional_selecto renamed = next(item for item in updated["locals"] if isinstance(item, dict) and item["name"] == "sum_value") assert renamed["type"] == "unsigned int" - updated = run_idalib_json( + updated = run_nexus_json( idac_cmd, idac_env, database, @@ -143,17 +109,17 @@ def test_idalib_local_update_accepts_stable_selectors_without_positional_selecto assert any(item["name"] == "sum_value_2" for item in updated["locals"] if isinstance(item, dict)) -def test_idalib_local_rename_accepts_stable_selector_without_positional_selector( +def test_nexus_local_rename_accepts_stable_selector_without_positional_selector( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: database = copy_database(tiny_database) - listed = run_idalib_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") + listed = run_nexus_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") target = next(item for item in listed["locals"] if isinstance(item, dict) and item.get("name") == "v4") - renamed = run_idalib_json( + renamed = run_nexus_json( idac_cmd, idac_env, database, @@ -170,17 +136,17 @@ def test_idalib_local_rename_accepts_stable_selector_without_positional_selector assert any(item["name"] == "sum_value" for item in renamed["locals"] if isinstance(item, dict)) -def test_idalib_local_retype_accepts_stable_selector_without_positional_selector( +def test_nexus_local_retype_accepts_stable_selector_without_positional_selector( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: database = copy_database(tiny_database) - listed = run_idalib_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") + listed = run_nexus_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") target = next(item for item in listed["locals"] if isinstance(item, dict) and item.get("name") == "v4") - retyped = run_idalib_json( + retyped = run_nexus_json( idac_cmd, idac_env, database, @@ -200,17 +166,17 @@ def test_idalib_local_retype_accepts_stable_selector_without_positional_selector assert updated["type"] == "unsigned int" -def test_idalib_local_retype_accepts_type_shorthand( +def test_nexus_local_retype_accepts_type_shorthand( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: database = copy_database(tiny_database) - listed = run_idalib_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") + listed = run_nexus_json(idac_cmd, idac_env, database, "function", "locals", "list", "main") target = next(item for item in listed["locals"] if isinstance(item, dict) and item.get("name") == "v4") - retyped = run_idalib_json( + retyped = run_nexus_json( idac_cmd, idac_env, database, diff --git a/tests/test_idalib_name_locals_semantics.py b/tests/test_nexus_name_locals_semantics.py similarity index 87% rename from tests/test_idalib_name_locals_semantics.py rename to tests/test_nexus_name_locals_semantics.py index d206b00..dd0fded 100644 --- a/tests/test_idalib_name_locals_semantics.py +++ b/tests/test_nexus_name_locals_semantics.py @@ -4,10 +4,10 @@ from tests.helpers import ( normalize_pseudocode_call_arguments, - preview_round_trip_cli2, - run_idalib, - run_idalib_json, - run_idalib_text, + preview_round_trip, + run_nexus, + run_nexus_json, + run_nexus_text, ) @@ -28,10 +28,10 @@ def test_name_set_persists_and_updates_function_metadata_and_decompile( tiny_database: Path, ) -> None: database = copy_database(tiny_database) - before = run_idalib_json(idac_cmd, idac_env, database, "function", "metadata", "add") - persisted = run_idalib_json(idac_cmd, idac_env, database, "misc", "rename", "add", "add_numbers") - after_persist = run_idalib_json(idac_cmd, idac_env, database, "function", "metadata", "add_numbers") - decompiled = normalize_pseudocode_call_arguments(run_idalib_text(idac_cmd, idac_env, database, "decompile", "main")) + before = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add") + persisted = run_nexus_json(idac_cmd, idac_env, database, "misc", "rename", "add", "add_numbers") + after_persist = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "add_numbers") + decompiled = normalize_pseudocode_call_arguments(run_nexus_text(idac_cmd, idac_env, database, "decompile", "main")) assert isinstance(before, dict) assert before["address"] == "0x1000004b0" @@ -60,7 +60,7 @@ def test_local_rename_preview_then_persist_updates_local_list_and_decompile( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -74,7 +74,7 @@ def test_local_rename_preview_then_persist_updates_local_list_and_decompile( after_preview = result["after_preview"] persisted = result["persisted"] after_persist = result["after_persist"] - decompiled = normalize_pseudocode_call_arguments(run_idalib_text(idac_cmd, idac_env, database, "decompile", "main")) + decompiled = normalize_pseudocode_call_arguments(run_nexus_text(idac_cmd, idac_env, database, "decompile", "main")) before_names = _local_names(before) assert "v4" in before_names @@ -101,7 +101,7 @@ def test_local_retype_preview_then_persist_updates_local_list_and_decompile( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -122,7 +122,7 @@ def test_local_retype_preview_then_persist_updates_local_list_and_decompile( after_preview = result["after_preview"] persisted = result["persisted"] after_persist = result["after_persist"] - decompiled = run_idalib_text(idac_cmd, idac_env, database, "decompile", "main") + decompiled = run_nexus_text(idac_cmd, idac_env, database, "decompile", "main") before_types = _local_type_map(before) assert before_types["v4"] == "int" @@ -147,7 +147,7 @@ def test_local_update_preview_then_persist_updates_name_and_type_together( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -170,7 +170,7 @@ def test_local_update_preview_then_persist_updates_name_and_type_together( after_preview = result["after_preview"] persisted = result["persisted"] after_persist = result["after_persist"] - decompiled = normalize_pseudocode_call_arguments(run_idalib_text(idac_cmd, idac_env, database, "decompile", "main")) + decompiled = normalize_pseudocode_call_arguments(run_nexus_text(idac_cmd, idac_env, database, "decompile", "main")) before_names = _local_names(before) before_types = _local_type_map(before) @@ -203,7 +203,7 @@ def test_local_update_requires_at_least_one_change( copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), @@ -224,7 +224,7 @@ def test_local_rename_failure_lists_available_locals( copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), diff --git a/tests/test_idalib_proto_comments.py b/tests/test_nexus_proto_comments.py similarity index 89% rename from tests/test_idalib_proto_comments.py rename to tests/test_nexus_proto_comments.py index 2b811c9..5e0008b 100644 --- a/tests/test_idalib_proto_comments.py +++ b/tests/test_nexus_proto_comments.py @@ -5,10 +5,10 @@ from tests.helpers import ( normalize_pseudocode_call_arguments, - preview_round_trip_cli2, - run_idalib, - run_idalib_json, - run_idalib_text, + preview_round_trip, + run_nexus, + run_nexus_json, + run_nexus_text, ) @@ -20,7 +20,7 @@ def test_comment_delete_preview_then_persist( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - seeded = run_idalib_json( + seeded = run_nexus_json( idac_cmd, idac_env, database, @@ -29,7 +29,7 @@ def test_comment_delete_preview_then_persist( "main", "entry point", ) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -48,7 +48,6 @@ def test_comment_delete_preview_then_persist( assert isinstance(preview, dict) assert preview["before"]["comment"] == "entry point" assert preview["after"]["comment"] is None - assert preview["undo"]["mode"] == "undo" assert after_preview["comment"] == "entry point" assert isinstance(deleted, dict) @@ -67,7 +66,7 @@ def test_function_comment_repeatable_round_trip( ) -> None: database = copy_database(tiny_database) - seeded = run_idalib_json( + seeded = run_nexus_json( idac_cmd, idac_env, database, @@ -79,7 +78,7 @@ def test_function_comment_repeatable_round_trip( "function", "--repeatable", ) - shown = run_idalib_json( + shown = run_nexus_json( idac_cmd, idac_env, database, @@ -90,7 +89,7 @@ def test_function_comment_repeatable_round_trip( "function", "--repeatable", ) - deleted = run_idalib_json( + deleted = run_nexus_json( idac_cmd, idac_env, database, @@ -119,7 +118,7 @@ def test_anterior_comment_preview_then_persist( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -150,7 +149,7 @@ def test_proto_set_preview_then_persist_updates_proto_get( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, database, @@ -184,6 +183,8 @@ def test_proto_set_preview_then_persist_updates_proto_get( assert preview["after"]["prototype"] != before["prototype"] assert isinstance(preview["before"]["decompile"], str) assert isinstance(preview["after"]["decompile"], str) + assert preview["after"]["decompile"] != preview["before"]["decompile"] + assert "add(" in preview["after"]["decompile"] assert after_preview == before assert isinstance(persisted, dict) @@ -195,18 +196,17 @@ def test_proto_set_preview_then_persist_updates_proto_get( assert after_persist["prototype"] == preview["result"]["prototype"] -def test_proto_set_preview_decompile_opt_in_captures_before_after_pseudocode( +def test_proto_set_preserves_calling_convention_in_preview_and_saved_type( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, tmp_path: Path, ) -> None: - database = copy_database(tiny_database) - result = preview_round_trip_cli2( + result = preview_round_trip( idac_cmd, idac_env, - database, + copy_database(tiny_database), tmp_path, read_args=["function", "prototype", "show", "add"], persist_args=[ @@ -214,17 +214,16 @@ def test_proto_set_preview_decompile_opt_in_captures_before_after_pseudocode( "prototype", "set", "add", + "--preserve-cc", "--decl", - "long long __cdecl add(long long a, long long b)", + "int __cdecl add(int left, int right);", ], ) - preview = result["preview"] - - assert isinstance(preview, dict) - assert isinstance(preview["before"]["decompile"], str) - assert isinstance(preview["after"]["decompile"], str) - assert preview["after"]["decompile"] != preview["before"]["decompile"] - assert "add(" in preview["after"]["decompile"] + assert "__cdecl" in result["before"]["prototype"] + assert result["after_preview"] == result["before"] + expected = "int __cdecl add(int left, int right)" + assert result["preview"]["after"]["prototype"] == expected + assert result["after_persist"]["prototype"] == expected def test_proto_check_accepts_valid_fixture_declaration( @@ -233,7 +232,7 @@ def test_proto_check_accepts_valid_fixture_declaration( copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, copy_database(tiny_database), @@ -258,7 +257,7 @@ def test_proto_check_rejects_invalid_fixture_declaration( copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), @@ -285,7 +284,7 @@ def test_proto_set_reports_unknown_named_types( copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), @@ -308,7 +307,7 @@ def test_proto_set_reports_parser_failure_for_bad_syntax( copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), @@ -331,7 +330,7 @@ def test_proto_set_reports_parser_failure_for_void_usercall_return_argloc( copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), @@ -362,7 +361,7 @@ def test_proto_set_can_optionally_propagate_to_callers( ) -> None: database = copy_database(tiny_database) - result = run_idalib_json( + result = run_nexus_json( idac_cmd, idac_env, database, @@ -374,9 +373,7 @@ def test_proto_set_can_optionally_propagate_to_callers( "long long __cdecl add(long long a, long long b)", "--propagate-callers", ) - caller_text = normalize_pseudocode_call_arguments( - run_idalib_text(idac_cmd, idac_env, database, "decompile", "main") - ) + caller_text = normalize_pseudocode_call_arguments(run_nexus_text(idac_cmd, idac_env, database, "decompile", "main")) assert result["changed"] is True assert result["callers_considered"] >= 1 diff --git a/tests/test_idalib_reads.py b/tests/test_nexus_reads.py similarity index 63% rename from tests/test_idalib_reads.py rename to tests/test_nexus_reads.py index 171598e..d9574bd 100644 --- a/tests/test_idalib_reads.py +++ b/tests/test_nexus_reads.py @@ -2,16 +2,16 @@ from pathlib import Path -from tests.helpers import run_idalib, run_idalib_json +from tests.helpers import run_nexus, run_nexus_json -def test_idalib_function_metadata( +def test_nexus_function_metadata( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, copy_database(tiny_database), @@ -25,16 +25,15 @@ def test_idalib_function_metadata( assert payload["address"] == "0x100000460" assert payload["size"] == 80 assert payload["prototype"] == "int __fastcall main(int argc, const char **argv, const char **envp)" - assert payload["flags"] == "0x1410" -def test_idalib_disasm( +def test_nexus_disasm( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json(idac_cmd, idac_env, copy_database(tiny_database), "disasm", "main") + payload = run_nexus_json(idac_cmd, idac_env, copy_database(tiny_database), "disasm", "main") assert isinstance(payload, dict) text = payload["text"] @@ -44,57 +43,13 @@ def test_idalib_disasm( assert "BL _printf" in text -def test_idalib_xrefs( +def test_nexus_strings_scan_range_query( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json(idac_cmd, idac_env, copy_database(tiny_database), "xrefs", "add") - - assert isinstance(payload, list) - assert payload == [ - { - "from": "0x100000478", - "kind": "call", - "function": "main", - "to": "0x1000004b0", - "type": "Code_Near_Call", - "user": False, - } - ] - - -def test_idalib_strings_query( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - payload = run_idalib_json( - idac_cmd, - idac_env, - copy_database(tiny_database), - "search", - "strings", - "tiny", - "--segment", - "__cstring", - "--timeout", - "1", - ) - - assert isinstance(payload, list) - assert payload == [{"address": "0x1000004dc", "text": "tiny:%d\n"}] - - -def test_idalib_strings_scan_range_query( - idac_cmd: list[str], - idac_env: dict[str, str], - copy_database, - tiny_database: Path, -) -> None: - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, copy_database(tiny_database), @@ -109,20 +64,20 @@ def test_idalib_strings_scan_range_query( "--end", "0x100000500", "--timeout", - "1", + "10", ) assert isinstance(payload, list) assert payload == [{"address": "0x1000004dc", "text": "tiny:%d\n"}] -def test_idalib_imports( +def test_nexus_imports( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json(idac_cmd, idac_env, copy_database(tiny_database), "imports") + payload = run_nexus_json(idac_cmd, idac_env, copy_database(tiny_database), "imports") assert isinstance(payload, list) assert payload == [ @@ -139,13 +94,13 @@ def test_idalib_imports( ] -def test_idalib_proto_show( +def test_nexus_proto_show( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - payload = run_idalib_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "prototype", "show", "add") + payload = run_nexus_json(idac_cmd, idac_env, copy_database(tiny_database), "function", "prototype", "show", "add") assert isinstance(payload, dict) assert payload == { @@ -154,13 +109,13 @@ def test_idalib_proto_show( } -def test_idalib_function_metadata_reports_missing_symbol( +def test_nexus_function_metadata_reports_missing_symbol( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), @@ -173,13 +128,13 @@ def test_idalib_function_metadata_reports_missing_symbol( assert "symbol not found: missing_symbol" in proc.stderr -def test_idalib_disasm_reports_missing_symbol( +def test_nexus_disasm_reports_missing_symbol( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), @@ -191,13 +146,13 @@ def test_idalib_disasm_reports_missing_symbol( assert "symbol not found: missing_symbol" in proc.stderr -def test_idalib_xrefs_reports_missing_symbol( +def test_nexus_xrefs_reports_missing_symbol( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, ) -> None: - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, copy_database(tiny_database), diff --git a/tests/test_nexus_reanalyze_python.py b/tests/test_nexus_reanalyze_python.py new file mode 100644 index 0000000..f52aaea --- /dev/null +++ b/tests/test_nexus_reanalyze_python.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from pathlib import Path + +from tests.helpers import ( + normalize_pseudocode_call_arguments, + run_nexus, + run_nexus_json, + run_nexus_text, +) + + +def test_reanalyze_function_mode_reports_function_bounds( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + + result = run_nexus_json(idac_cmd, idac_env, database, "misc", "reanalyze", "main") + + assert result["mode"] == "function" + assert result["function"] == "main" + assert result["start"] == "0x100000460" + assert result["end"] == "0x1000004b0" + assert result["waited"] is True + + +def test_reanalyze_address_mode_reports_single_item_range( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + + result = run_nexus_json(idac_cmd, idac_env, database, "misc", "reanalyze", "0x1000004dc") + + assert result == { + "mode": "address", + "start": "0x1000004dc", + "end": "0x1000004dd", + "waited": True, + } + + +def test_reanalyze_rejects_non_increasing_range( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + proc = run_nexus( + idac_cmd, + idac_env, + database, + "misc", + "reanalyze", + "0x100000460", + "--end", + "0x100000460", + ) + + assert proc.returncode == 1 + assert "reanalyze range end must be greater than the start" in proc.stderr + + +def test_nexus_python_exec_supports_stdin_and_script( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, + tmp_path: Path, +) -> None: + database = copy_database(tiny_database) + script_path = tmp_path / "emit_script.py" + script_path.write_text( + "print('script-stdout')\nresult = {'mode': 'script', 'count': len(list(idautils.Functions()))}\n", + encoding="utf-8", + ) + + stdin_payload = run_nexus_json( + idac_cmd, + idac_env, + database, + "py", + "exec", + "--stdin", + input_text="print('stdin-stdout')\nresult = {'mode': 'stdin', 'count': len(list(idautils.Functions()))}\n", + ) + script_payload = run_nexus_json( + idac_cmd, + idac_env, + database, + "py", + "exec", + "--script", + str(script_path), + ) + + assert stdin_payload == { + "stdout": "stdin-stdout\n", + "stderr": "", + "result": {"mode": "stdin", "count": 3}, + "result_repr": "{'mode': 'stdin', 'count': 3}", + } + assert script_payload == { + "stdout": "script-stdout\n", + "stderr": "", + "result": {"mode": "script", "count": 3}, + "result_repr": "{'mode': 'script', 'count': 3}", + } + + +def test_nexus_python_exec_changes_are_checkpointed_for_headless_database( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + payload = run_nexus_json( + idac_cmd, + idac_env, + database, + "py", + "exec", + "--code", + "ea = idc.get_name_ea_simple('main')\nidc.set_cmt(ea, 'session only', 0)\nresult = {'comment_ea': hex(ea)}", + ) + persisted = run_nexus_json(idac_cmd, idac_env, database, "comment", "show", "main") + + assert payload == { + "stdout": "", + "stderr": "", + "result": {"comment_ea": "0x100000460"}, + "result_repr": "{'comment_ea': '0x100000460'}", + } + assert persisted == { + "address": "0x100000460", + "scope": "line", + "repeatable": False, + "comment": "session only", + } + + +def test_reanalyze_range_restores_function_after_session_item_deletion( + idac_cmd: list[str], + idac_env: dict[str, str], + copy_database, + tiny_database: Path, +) -> None: + database = copy_database(tiny_database) + deleted = run_nexus_json( + idac_cmd, + idac_env, + database, + "py", + "exec", + "--code", + "import ida_bytes\n" + "ea = idc.get_name_ea_simple('main')\n" + "ida_bytes.del_items(ea, 0, 4)\n" + "result = {'ea': hex(ea)}", + ) + missing = run_nexus(idac_cmd, idac_env, database, "function", "metadata", "main", use_json=True) + reanalyzed = run_nexus_json( + idac_cmd, + idac_env, + database, + "misc", + "reanalyze", + "0x100000460", + "--end", + "0x100000468", + ) + restored = run_nexus_json(idac_cmd, idac_env, database, "function", "metadata", "main") + decompiled = normalize_pseudocode_call_arguments(run_nexus_text(idac_cmd, idac_env, database, "decompile", "main")) + + assert deleted == { + "stdout": "", + "stderr": "", + "result": {"ea": "0x100000460"}, + "result_repr": "{'ea': '0x100000460'}", + } + assert missing.returncode == 1 + assert "function not found: main" in missing.stderr + + assert reanalyzed == { + "mode": "range", + "start": "0x100000460", + "end": "0x100000468", + "waited": True, + } + assert restored["address"] == "0x100000460" + assert restored["name"] == "main" + assert restored["prototype"] == ("int __fastcall main(int argc, const char **argv, const char **envp)") + assert restored["size"] == 80 + assert 'printf("tiny:%d\\n", v4);' in decompiled diff --git a/tests/test_nexus_session.py b/tests/test_nexus_session.py new file mode 100644 index 0000000..06c0356 --- /dev/null +++ b/tests/test_nexus_session.py @@ -0,0 +1,947 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from importlib import metadata +from pathlib import Path +from typing import Any + +import pytest + +import idac.nexus as nexus_module +from idac.nexus import ( + KEEPALIVE_SECONDS, + NexusApi, + NexusSelectionError, + NexusSession, + NexusSessionError, +) + + +class FakeNexusError(RuntimeError): + pass + + +class FakeRemoteError(FakeNexusError): + def __init__(self, operation: str = "broken") -> None: + super().__init__("remote operation failed") + self.code = "operation_failed" + self.status = 409 + self.details = {"operation": operation} + + +class FakeTimeoutError(FakeNexusError): + def __init__(self) -> None: + super().__init__("operation timed out") + self.code = "operation_timeout" + self.status = 408 + self.details = {"kind": "remote_module"} + + +class FakeState(Enum): + READY = "ready" + BLOCKED = "blocked" + DEAD = "dead" + + +@dataclass(frozen=True) +class FakeInstance: + record_id: str + backend: str + pid: int = 123 + idb_path: str = "/tmp/sample.i64" + exe_path: str = "/tmp/sample" + managed: bool = True + started_at: float = 42.0 + + +@dataclass(frozen=True) +class FakeDiscovered: + instance: FakeInstance + state: FakeState = FakeState.READY + detail: str | None = None + + +class FakeOptions: + def __init__(self, **values: object) -> None: + self.values = values + + +class FakeHandle: + def __init__(self, instance: FakeInstance) -> None: + self.instance = instance + self.wait_calls: list[float | None] = [] + self.save_calls = 0 + self.close_calls = 0 + self.wait_error: BaseException | None = None + self.wait_result: dict[str, object] = {"status": "complete", "complete": True} + self.python_error: BaseException | None = None + self.save_error: BaseException | None = None + self.shutdown_error: BaseException | None = None + self.close_error: BaseException | None = None + self.shutdown_observer: Callable[[FakeInstance, bool], None] | None = None + self.save_result: dict[str, object] = {"saved": True, "idb_path": self.instance.idb_path} + self.remote_environment: dict[str, object] = { + "ida_nexus": metadata.version("ida-nexus"), + "ida_domain": metadata.version("ida-domain"), + "ida": "9.4", + "python": "3.11.9", + } + + def wait_autoanalysis(self, timeout: float | None = None) -> dict[str, object]: + self.wait_calls.append(timeout) + if self.wait_error is not None: + raise self.wait_error + return dict(self.wait_result) + + def execute_python( + self, + code: str, + timeout: float | None = None, + *, + operation_id: str | None = None, + operation_label: str | None = None, + persist_globals: bool = False, + filename: str | None = None, + ) -> dict[str, object]: + if "importlib.metadata.version" in code and "idaapi.get_kernel_version" in code: + return { + "result": dict(self.remote_environment), + "stdout": "", + "stderr": "", + } + if self.python_error is not None: + raise self.python_error + return {"result": 7, "stdout": "hello\n", "stderr": ""} + + def save_database(self) -> dict[str, object]: + self.save_calls += 1 + if self.save_error is not None: + raise self.save_error + return dict(self.save_result) + + def shutdown_database(self, *, save: bool = True) -> dict[str, object]: + if self.shutdown_observer is not None: + self.shutdown_observer(self.instance, save) + if self.shutdown_error is not None: + raise self.shutdown_error + return {"shutting_down": True, "save": save} + + def close(self, *, wait_for_database: bool = False, timeout: float = 305.0) -> None: + self.close_calls += 1 + if self.close_error is not None: + raise self.close_error + + +@dataclass +class FakeRuntime: + discovered: list[FakeDiscovered] = field(default_factory=list) + open_handle: FakeHandle | None = None + attached_handle: FakeHandle | None = None + open_error: BaseException | None = None + attach_error: BaseException | None = None + dispatch_errors: dict[str, BaseException] = field(default_factory=dict) + dispatch_results: dict[str, object] = field(default_factory=dict) + open_calls: list[tuple[str, FakeOptions]] = field(default_factory=list) + attach_calls: list[tuple[FakeInstance, float]] = field(default_factory=list) + discovery_calls: list[float | None] = field(default_factory=list) + dispatch_calls: list[dict[str, object]] = field(default_factory=list) + shutdown_requests: list[tuple[FakeInstance, bool]] = field(default_factory=list) + release_waits: list[tuple[FakeInstance, float | None]] = field(default_factory=list) + release_wait_result: bool = True + + +def fake_api(runtime: FakeRuntime) -> NexusApi: + def observe_shutdowns(handle: FakeHandle) -> FakeHandle: + handle.shutdown_observer = lambda instance, save: runtime.shutdown_requests.append((instance, save)) + return handle + + class DatabaseHandle: + @classmethod + def open(cls, path: str, *, options: FakeOptions) -> FakeHandle: + runtime.open_calls.append((path, options)) + if runtime.open_error is not None: + raise runtime.open_error + assert runtime.open_handle is not None + return observe_shutdowns(runtime.open_handle) + + @classmethod + def attach(cls, instance: FakeInstance, *, keepalive: float) -> FakeHandle: + runtime.attach_calls.append((instance, keepalive)) + if runtime.attach_error is not None: + raise runtime.attach_error + if runtime.attached_handle is None: + runtime.attached_handle = FakeHandle(instance) + return observe_shutdowns(runtime.attached_handle) + + class RemoteModule: + def __init__( + self, + path: str | Path, + *, + codec: str, + operation_label: Any, + ) -> None: + pass + + def function(self, *, database: bool, timeout: float | None): + def bind(declaration): + assert declaration.__name__ == "dispatch" + + def invoke( + handle: FakeHandle, + op: str, + params: dict[str, object], + preview: bool, + ) -> object: + runtime.dispatch_calls.append( + { + "handle": handle, + "op": op, + "params": params, + "preview": preview, + } + ) + error = runtime.dispatch_errors.get(op) + if error is not None: + raise error + return runtime.dispatch_results.get(op, {"op": op, "params": params, "preview": preview}) + + return invoke + + return bind + + def discover_databases(timeout: float | None = None) -> list[FakeDiscovered]: + runtime.discovery_calls.append(timeout) + return list(runtime.discovered) + + def wait_database_released(instance: FakeInstance, timeout: float | None = None) -> bool: + runtime.release_waits.append((instance, timeout)) + return runtime.release_wait_result + + return NexusApi( + DatabaseHandle=DatabaseHandle, + DatabaseOpenOptions=FakeOptions, + RemoteModule=RemoteModule, + InstanceState=FakeState, + NexusError=FakeNexusError, + discover_databases=discover_databases, + wait_database_released=wait_database_released, + ) + + +@pytest.fixture +def remote_module_path(tmp_path: Path) -> Path: + path = tmp_path / "remote_ops.py" + path.write_text("def dispatch(db, op, params, preview):\n return {}\n", encoding="utf-8") + return path + + +def assert_equivalent_session_error(actual: NexusSessionError, expected: NexusSessionError) -> None: + assert (actual.kind, str(actual), actual.status, actual.details) == ( + expected.kind, + str(expected), + expected.status, + expected.details, + ) + + +def assert_worker_retired_without_save(runtime: FakeRuntime, instance: FakeInstance) -> None: + assert runtime.shutdown_requests == [(instance, False)] + assert [released for released, _timeout in runtime.release_waits] == [instance] + + +def test_headless_session_reuses_one_handle_waits_for_analysis_and_saves_mutations( + remote_module_path: Path, +) -> None: + instance = FakeInstance("worker-1", "idalib") + handle = FakeHandle(instance) + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + timeout=12, + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + assert session.execute_operation("function_list", {"limit": 2}) == { + "op": "function_list", + "params": {"limit": 2}, + "preview": False, + } + session.execute_operation( + "name_set", + {"address": "0x401000", "name": "entry"}, + operation_label="idac: name set", + ) + session.close() + + assert len(runtime.open_calls) == 1 + path, options = runtime.open_calls[0] + assert path == "/tmp/sample.i64" + assert options.values["keepalive"] == KEEPALIVE_SECONDS + assert options.values["auto_analysis"] is True + assert options.values["startup_timeout"] == 12.0 + assert handle.wait_calls == [12.0] + assert handle.save_calls == 1 + assert handle.close_calls == 1 + + +def test_exact_gui_instance_is_attached_without_analysis_or_automatic_save( + remote_module_path: Path, +) -> None: + instance = FakeInstance("gui-1", "gui", managed=False) + handle = FakeHandle(instance) + runtime = FakeRuntime( + discovered=[FakeDiscovered(instance)], + attached_handle=handle, + ) + + with NexusSession( + instance_id="gui-1", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) as session: + session.execute_operation("comment_set", {}) + + assert runtime.attach_calls == [(instance, KEEPALIVE_SECONDS)] + assert handle.wait_calls == [] + assert handle.save_calls == 0 + assert handle.close_calls == 1 + + +def test_failed_gui_preview_is_not_saved_and_reports_uncertain_in_memory_state( + remote_module_path: Path, +) -> None: + instance = FakeInstance("gui-1", "gui", managed=False) + handle = FakeHandle(instance) + runtime = FakeRuntime( + discovered=[FakeDiscovered(instance)], + attached_handle=handle, + dispatch_errors={"comment_set": FakeRemoteError("comment_set")}, + ) + + with ( + pytest.raises(NexusSessionError) as caught, + NexusSession( + instance_id="gui-1", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) as session, + ): + session.execute_operation("comment_set", {}, preview=True) + + assert any("unsaved in-memory changes" in note for note in caught.value.__notes__) + assert handle.save_calls == 0 + assert runtime.shutdown_requests == [] + assert handle.close_calls == 1 + + +def test_omitted_context_selects_the_only_ready_instance() -> None: + ready = FakeInstance("ready", "gui") + blocked = FakeInstance("blocked", "idalib") + runtime = FakeRuntime( + discovered=[ + FakeDiscovered(blocked, FakeState.BLOCKED, "protocol mismatch"), + FakeDiscovered(ready), + ] + ) + session = NexusSession(api=fake_api(runtime)) + + assert session.handle.instance.record_id == ready.record_id + assert runtime.attach_calls == [(ready, KEEPALIVE_SECONDS)] + session.close() + + +@pytest.mark.parametrize( + ("discovered", "instance_id", "kind"), + [ + ([], None, "no_ready_instance"), + ( + [ + FakeDiscovered(FakeInstance("one", "gui")), + FakeDiscovered(FakeInstance("two", "idalib")), + ], + None, + "ambiguous_instance", + ), + ( + [FakeDiscovered(FakeInstance("blocked", "gui"), FakeState.BLOCKED, "old protocol")], + "blocked", + "instance_not_ready", + ), + ([FakeDiscovered(FakeInstance("one", "gui"))], "missing", "instance_not_found"), + ], +) +def test_context_selection_fails_closed( + discovered: list[FakeDiscovered], + instance_id: str | None, + kind: str, +) -> None: + runtime = FakeRuntime(discovered=discovered) + session = NexusSession(instance_id=instance_id, api=fake_api(runtime)) + + with pytest.raises(NexusSelectionError) as caught: + _ = session.handle + + assert caught.value.kind == kind + assert runtime.attach_calls == [] + + +def test_preview_mutation_does_not_mark_headless_session_dirty( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + session.execute_operation("name_set", {}, preview=True) + session.close() + + assert handle.save_calls == 0 + assert runtime.shutdown_requests == [] + + +def test_failed_preview_poisons_and_discards_headless_session( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + preview_error = FakeRemoteError("comment_set") + runtime = FakeRuntime( + open_handle=handle, + dispatch_errors={"comment_set": preview_error}, + ) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + with pytest.raises(NexusSessionError) as caught: + session.execute_operation("comment_set", {}, preview=True) + with pytest.raises(NexusSessionError) as repeated: + session.execute_operation("database_info", {}) + session.close() + + assert_equivalent_session_error(repeated.value, caught.value) + assert len(runtime.dispatch_calls) == 1 + assert handle.save_calls == 0 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_failed_preview_checkpoints_prior_headless_mutations_before_discard( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + runtime = FakeRuntime( + open_handle=handle, + dispatch_errors={"comment_set": FakeRemoteError("comment_set")}, + ) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + session.execute_operation("name_set", {}) + with pytest.raises(NexusSessionError): + session.execute_operation("comment_set", {}, preview=True) + session.close() + + assert handle.save_calls == 1 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_failed_prepreview_checkpoint_is_terminal_and_not_retried( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + session.execute_operation("name_set", {}) + handle.save_error = FakeRemoteError("save_database") + with pytest.raises(NexusSessionError) as caught: + session.execute_operation("comment_set", {}, preview=True) + with pytest.raises(NexusSessionError) as repeated: + session.execute_operation("database_info", {}) + with pytest.raises(NexusSessionError) as close_error: + session.close() + + assert_equivalent_session_error(repeated.value, caught.value) + assert_equivalent_session_error(close_error.value, caught.value) + assert handle.save_calls == 1 + assert handle.close_calls == 1 + assert [call["op"] for call in runtime.dispatch_calls] == ["name_set"] + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_interrupted_remote_request_discards_headless_worker( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + interrupt = KeyboardInterrupt() + runtime = FakeRuntime( + open_handle=handle, + dispatch_errors={"comment_set": interrupt}, + ) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + with pytest.raises(KeyboardInterrupt) as caught: + session.execute_operation("comment_set", {}, preview=False) + with pytest.raises(KeyboardInterrupt) as repeated: + session.execute_operation("database_info", {}) + session.close() + + assert repeated.value.args == caught.value.args + assert [call["op"] for call in runtime.dispatch_calls] == ["comment_set"] + assert handle.save_calls == 0 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_discard_timeout_remains_visible_on_the_primary_operation_error( + remote_module_path: Path, +) -> None: + instance = FakeInstance("worker", "idalib") + handle = FakeHandle(instance) + runtime = FakeRuntime( + open_handle=handle, + dispatch_errors={"comment_set": FakeRemoteError("comment_set")}, + release_wait_result=False, + ) + + with ( + pytest.raises(NexusSessionError) as caught, + NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) as session, + ): + session.execute_operation("comment_set", {}, preview=True) + + assert caught.value.kind == "operation_failed" + assert any("did not terminate" in note for note in caught.value.__notes__) + assert handle.save_calls == 0 + assert_worker_retired_without_save(runtime, instance) + + +def test_interrupt_during_headless_initialization_discards_worker() -> None: + instance = FakeInstance("worker", "idalib") + handle = FakeHandle(instance) + handle.wait_error = KeyboardInterrupt() + runtime = FakeRuntime(open_handle=handle) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + + with pytest.raises(KeyboardInterrupt): + _ = session.handle + session.close() + + assert handle.close_calls == 1 + assert handle.save_calls == 0 + assert_worker_retired_without_save(runtime, instance) + + +def test_failed_transactional_mutation_does_not_mark_headless_session_dirty( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + runtime = FakeRuntime( + open_handle=handle, + dispatch_results={"type_declare": {"success": False, "errors": 1}}, + ) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + assert session.execute_operation("type_declare", {}) == {"success": False, "errors": 1} + session.close() + + assert handle.save_calls == 0 + + +def test_prior_successful_mutation_is_saved_when_later_operation_fails( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + runtime = FakeRuntime( + open_handle=handle, + dispatch_errors={"comment_set": FakeRemoteError("comment_set")}, + ) + + with ( + pytest.raises(NexusSessionError, match="remote operation failed") as caught, + NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) as session, + ): + session.execute_operation("name_set", {}) + session.execute_operation("comment_set", {}) + + assert caught.value.kind == "operation_failed" + assert caught.value.status == 409 + assert caught.value.details == {"operation": "comment_set"} + assert handle.save_calls >= 1 + assert handle.close_calls == 1 + + +def test_timed_out_mutation_is_treated_as_uncertain_and_saved_before_release( + remote_module_path: Path, +) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + runtime = FakeRuntime( + open_handle=handle, + dispatch_errors={"name_set": FakeTimeoutError()}, + ) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + + with pytest.raises(NexusSessionError) as caught: + session.execute_operation("name_set", {}, operation_label="idac: timed rename") + session.close() + + assert caught.value.kind == "operation_timeout" + assert caught.value.status == 408 + assert handle.save_calls == 1 + assert handle.close_calls == 1 + + +def test_explicit_save_normalizes_result_and_prevents_resave() -> None: + instance = FakeInstance("worker", "idalib") + handle = FakeHandle(instance) + runtime = FakeRuntime(open_handle=handle) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + + result = session.execute_python("result = 7") + saved = session.save_database() + session.close() + + assert result == {"result": 7, "stdout": "hello\n", "stderr": ""} + assert saved == {"saved": True, "path": instance.idb_path} + assert handle.save_calls == 1 + assert handle.close_calls == 1 + + +def test_final_save_failure_discards_headless_worker_without_retry(remote_module_path: Path) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.save_error = FakeRemoteError() + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + session.execute_operation("name_set", {}) + + with pytest.raises(NexusSessionError) as caught: + session.close() + session.close() + + assert caught.value.kind == "operation_failed" + assert handle.save_calls == 1 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_interrupted_final_save_discards_headless_worker(remote_module_path: Path) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + interrupt = KeyboardInterrupt() + handle.save_error = interrupt + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + session.execute_operation("name_set", {}) + + with pytest.raises(KeyboardInterrupt) as caught: + session.close() + + assert caught.value.args == interrupt.args + assert handle.save_calls == 1 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_explicit_save_failure_is_terminal_and_is_not_retried(remote_module_path: Path) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.save_error = FakeRemoteError("save_database") + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + session.execute_operation("name_set", {}) + + with pytest.raises(NexusSessionError, match="remote operation failed") as save_error: + session.save_database() + with pytest.raises(NexusSessionError) as terminal_error: + session.execute_operation("function_list", {}) + with pytest.raises(NexusSessionError) as close_error: + session.close() + + assert_equivalent_session_error(terminal_error.value, save_error.value) + assert_equivalent_session_error(close_error.value, save_error.value) + assert handle.save_calls == 1 + assert handle.close_calls == 1 + assert [call["op"] for call in runtime.dispatch_calls] == ["name_set"] + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_malformed_save_response_is_terminal_and_is_not_retried(remote_module_path: Path) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.save_result = {"saved": False} + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + session.execute_operation("name_set", {}) + + with pytest.raises(NexusSessionError, match="did not confirm") as save_error: + session.save_database() + with pytest.raises(NexusSessionError) as close_error: + session.close() + + assert save_error.value.kind == "save_failed" + assert_equivalent_session_error(close_error.value, save_error.value) + assert handle.save_calls == 1 + assert handle.close_calls == 1 + assert [call["op"] for call in runtime.dispatch_calls] == ["name_set"] + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_save_failure_remains_primary_when_lease_release_also_fails(remote_module_path: Path) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.save_error = FakeRemoteError("save_database") + handle.close_error = FakeNexusError("lease release failed") + runtime = FakeRuntime(open_handle=handle) + session = NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) + session.execute_operation("name_set", {}) + + with pytest.raises(NexusSessionError) as caught: + session.close() + + assert caught.value.kind == "operation_failed" + assert caught.value.details == {"operation": "save_database"} + assert any("lease release failed" in note for note in caught.value.__notes__) + assert handle.save_calls == 1 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_body_error_remains_primary_when_session_finalization_fails(remote_module_path: Path) -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.save_error = FakeRemoteError("save_database") + runtime = FakeRuntime(open_handle=handle) + + with ( + pytest.raises(ValueError, match="handler failed") as caught, + NexusSession( + "/tmp/sample.i64", + api=fake_api(runtime), + remote_module_path=remote_module_path, + ) as session, + ): + session.execute_operation("name_set", {}) + raise ValueError("handler failed") + + assert any("remote operation failed" in note for note in caught.value.__notes__) + assert handle.save_calls == 1 + assert handle.close_calls == 1 + + +def test_failed_python_attempt_is_checkpointed_before_headless_release() -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.python_error = FakeRemoteError("py_exec") + runtime = FakeRuntime(open_handle=handle) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + + with pytest.raises(NexusSessionError, match="remote operation failed"): + session.execute_python("raise RuntimeError('after a possible mutation')") + session.close() + + assert handle.save_calls == 1 + assert handle.close_calls == 1 + + +def test_open_error_is_translated_without_retry_or_fallback() -> None: + runtime = FakeRuntime(open_error=FakeRemoteError()) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + + with pytest.raises(NexusSessionError) as caught: + _ = session.handle + with pytest.raises(NexusSessionError) as repeated: + _ = session.handle + + assert caught.value.kind == "operation_failed" + assert_equivalent_session_error(repeated.value, caught.value) + assert len(runtime.open_calls) == 1 + assert runtime.attach_calls == [] + + +def test_worker_version_failure_surfaces_the_requirement_and_preserves_output() -> None: + output = ( + "idalib worker launcher 26692 exited with status 1\n\n" + ": RuntimeWarning: Unexpected value in sys.prefix\n" + "[ida-nexus] IDA Nexus requires IDA 9.4 or newer\n" + ) + runtime = FakeRuntime(open_error=FakeNexusError(output)) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + with pytest.raises(NexusSessionError) as caught: + _ = session.handle + assert str(caught.value) == "IDA Nexus requires IDA 9.4 or newer" + assert caught.value.details["worker_output"] == output.strip() + assert len(runtime.open_calls) == 1 + + +def test_attach_error_is_terminal_without_retry_or_target_fallback() -> None: + selected = FakeInstance("gui", "gui", managed=False) + fallback = FakeInstance("other", "gui", managed=False) + runtime = FakeRuntime( + discovered=[FakeDiscovered(selected), FakeDiscovered(fallback)], + attach_error=FakeRemoteError("attach"), + ) + session = NexusSession(instance_id="gui", api=fake_api(runtime)) + + with pytest.raises(NexusSessionError) as caught: + _ = session.handle + with pytest.raises(NexusSessionError) as repeated: + _ = session.handle + + assert caught.value.kind == "operation_failed" + assert_equivalent_session_error(repeated.value, caught.value) + assert runtime.attach_calls == [(selected, KEEPALIVE_SECONDS)] + + +def test_analysis_failure_discards_the_new_headless_worker() -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.wait_error = FakeNexusError("analysis failed") + runtime = FakeRuntime(open_handle=handle) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + + with pytest.raises(NexusSessionError, match="analysis failed"): + _ = session.handle + + assert handle.save_calls == 0 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_incomplete_analysis_response_closes_and_poison_session() -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.wait_result = {"status": "running", "complete": False} + runtime = FakeRuntime(open_handle=handle) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + + with pytest.raises(NexusSessionError, match="did not confirm") as caught: + _ = session.handle + with pytest.raises(NexusSessionError) as repeated: + _ = session.handle + + assert caught.value.kind == "analysis_incomplete" + assert_equivalent_session_error(repeated.value, caught.value) + assert len(runtime.open_calls) == 1 + assert handle.save_calls == 0 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_remote_environment_mismatch_fails_closed_and_releases_handle() -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + handle.remote_environment["ida_nexus"] = "0.6.0" + runtime = FakeRuntime(open_handle=handle) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + + with pytest.raises(NexusSessionError) as caught: + _ = session.handle + with pytest.raises(NexusSessionError) as repeated: + _ = session.handle + + assert caught.value.kind == "unsupported_remote_environment" + assert_equivalent_session_error(repeated.value, caught.value) + assert len(runtime.open_calls) == 1 + assert handle.save_calls == 0 + assert handle.close_calls == 1 + assert_worker_retired_without_save(runtime, handle.instance) + + +def test_list_targets_exposes_only_public_normalized_fields(monkeypatch: pytest.MonkeyPatch) -> None: + instance = FakeInstance("worker", "idalib") + runtime = FakeRuntime(discovered=[FakeDiscovered(instance, FakeState.BLOCKED, "protocol mismatch")]) + api = fake_api(runtime) + monkeypatch.setattr(nexus_module, "load_nexus_api", lambda: api) + + assert nexus_module.list_targets(timeout=2) == [ + { + "record_id": "worker", + "state": "blocked", + "detail": "protocol mismatch", + "backend": "idalib", + "pid": 123, + "idb_path": "/tmp/sample.i64", + "exe_path": "/tmp/sample", + "managed": True, + "started_at": 42.0, + } + ] + assert runtime.discovery_calls == [2.0] + assert runtime.open_calls == [] + assert runtime.attach_calls == [] + + +def test_constructor_rejects_legacy_or_ambiguous_contexts() -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + NexusSession("sample.i64", instance_id="record") + with pytest.raises(ValueError, match=r"\.idb"): + NexusSession("sample.idb") + with pytest.raises(ValueError, match="legacy"): + NexusSession("pid:123") + with pytest.raises(ValueError, match="legacy"): + NexusSession("module:sample") + with pytest.raises(ValueError, match="positive finite"): + NexusSession(timeout=0) + + +def test_closed_session_rejects_further_use_without_remote_io() -> None: + handle = FakeHandle(FakeInstance("worker", "idalib")) + runtime = FakeRuntime(open_handle=handle) + session = NexusSession("/tmp/sample.i64", api=fake_api(runtime)) + _ = session.handle + session.close() + + with pytest.raises(NexusSessionError) as caught: + _ = session.handle + + assert caught.value.kind == "session_closed" + assert len(runtime.open_calls) == 1 + assert handle.close_calls == 1 diff --git a/tests/test_idalib_struct_enum_semantics.py b/tests/test_nexus_struct_enum_semantics.py similarity index 88% rename from tests/test_idalib_struct_enum_semantics.py rename to tests/test_nexus_struct_enum_semantics.py index 1a81647..c6aeb4b 100644 --- a/tests/test_idalib_struct_enum_semantics.py +++ b/tests/test_nexus_struct_enum_semantics.py @@ -2,7 +2,7 @@ from pathlib import Path -from tests.helpers import preview_round_trip_cli2, run_idalib, run_idalib_json +from tests.helpers import preview_round_trip, run_nexus, run_nexus_json MUTABLE_STRUCT_DECL = "typedef struct test_cli_mutable {} test_cli_mutable;" ENUM_DECL = "typedef enum test_cli_enum { CLI_ENUM_A = 0 } test_cli_enum;" @@ -27,7 +27,7 @@ def test_struct_field_mutations_preview_then_persist_round_trip( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - declared = run_idalib_json( + declared = run_nexus_json( idac_cmd, idac_env, database, @@ -36,9 +36,9 @@ def test_struct_field_mutations_preview_then_persist_round_trip( "--decl", MUTABLE_STRUCT_DECL, ) - before = run_idalib_json(idac_cmd, idac_env, database, "type", "struct", "show", "test_cli_mutable") + before = run_nexus_json(idac_cmd, idac_env, database, "type", "struct", "show", "test_cli_mutable") - set_result = preview_round_trip_cli2( + set_result = preview_round_trip( idac_cmd, idac_env, database, @@ -57,7 +57,7 @@ def test_struct_field_mutations_preview_then_persist_round_trip( "int count;", ], ) - rename_result = preview_round_trip_cli2( + rename_result = preview_round_trip( idac_cmd, idac_env, database, @@ -65,7 +65,7 @@ def test_struct_field_mutations_preview_then_persist_round_trip( read_args=["type", "struct", "show", "test_cli_mutable"], persist_args=["type", "struct", "field", "rename", "test_cli_mutable", "count", "total"], ) - delete_result = preview_round_trip_cli2( + delete_result = preview_round_trip( idac_cmd, idac_env, database, @@ -111,7 +111,7 @@ def test_enum_member_mutations_preview_then_persist_round_trip( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - declared = run_idalib_json( + declared = run_nexus_json( idac_cmd, idac_env, database, @@ -120,9 +120,9 @@ def test_enum_member_mutations_preview_then_persist_round_trip( "--decl", ENUM_DECL, ) - before = run_idalib_json(idac_cmd, idac_env, database, "type", "enum", "show", "test_cli_enum") + before = run_nexus_json(idac_cmd, idac_env, database, "type", "enum", "show", "test_cli_enum") - set_result = preview_round_trip_cli2( + set_result = preview_round_trip( idac_cmd, idac_env, database, @@ -141,7 +141,7 @@ def test_enum_member_mutations_preview_then_persist_round_trip( "0", ], ) - rename_result = preview_round_trip_cli2( + rename_result = preview_round_trip( idac_cmd, idac_env, database, @@ -157,7 +157,7 @@ def test_enum_member_mutations_preview_then_persist_round_trip( "CLI_ENUM_ALT", ], ) - delete_result = preview_round_trip_cli2( + delete_result = preview_round_trip( idac_cmd, idac_env, database, @@ -195,7 +195,7 @@ def test_enum_member_set_uses_sdk_default_mask_when_unspecified( tiny_database: Path, ) -> None: database = copy_database(tiny_database) - declared = run_idalib_json( + declared = run_nexus_json( idac_cmd, idac_env, database, @@ -204,7 +204,7 @@ def test_enum_member_set_uses_sdk_default_mask_when_unspecified( "--decl", ENUM_DECL, ) - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, database, @@ -230,9 +230,9 @@ def test_struct_field_delete_reports_missing_member( tiny_database: Path, ) -> None: database = copy_database(tiny_database) - run_idalib_json(idac_cmd, idac_env, database, "type", "declare", "--decl", MUTABLE_STRUCT_DECL) + run_nexus_json(idac_cmd, idac_env, database, "type", "declare", "--decl", MUTABLE_STRUCT_DECL) - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, database, @@ -255,9 +255,9 @@ def test_struct_field_set_reports_unknown_type( tiny_database: Path, ) -> None: database = copy_database(tiny_database) - run_idalib_json(idac_cmd, idac_env, database, "type", "declare", "--decl", MUTABLE_STRUCT_DECL) + run_nexus_json(idac_cmd, idac_env, database, "type", "declare", "--decl", MUTABLE_STRUCT_DECL) - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, database, @@ -285,9 +285,9 @@ def test_enum_member_delete_reports_missing_member( tiny_database: Path, ) -> None: database = copy_database(tiny_database) - run_idalib_json(idac_cmd, idac_env, database, "type", "declare", "--decl", ENUM_DECL) + run_nexus_json(idac_cmd, idac_env, database, "type", "declare", "--decl", ENUM_DECL) - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, database, diff --git a/tests/test_idalib_types.py b/tests/test_nexus_types.py similarity index 62% rename from tests/test_idalib_types.py rename to tests/test_nexus_types.py index 94107bf..2fc4a5f 100644 --- a/tests/test_idalib_types.py +++ b/tests/test_nexus_types.py @@ -3,7 +3,9 @@ import json from pathlib import Path -from tests.helpers import run_idalib, run_idalib_json +import pytest + +from tests.helpers import run_nexus, run_nexus_json SHARED_TYPE_DECL = ( "typedef enum test_cli_mode { CLI_MODE_A = 0, CLI_MODE_B = 1 } test_cli_mode;" @@ -22,29 +24,79 @@ def _declare_types(idac_cmd: list[str], idac_env: dict[str, str], database: Path) -> dict[str, object]: - return run_idalib_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", SHARED_TYPE_DECL]) + return run_nexus_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", SHARED_TYPE_DECL]) def _declare_enum(idac_cmd: list[str], idac_env: dict[str, str], database: Path) -> dict[str, object]: - return run_idalib_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", ENUM_DECL]) + return run_nexus_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", ENUM_DECL]) -def test_idalib_type_declare_and_list( +def test_nexus_type_declare_and_list( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) _declare_types(idac_cmd, idac_env, database) - payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "list", "test_cli"]) + payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "list", "test_cli"]) names = {item.get("name") for item in payload if isinstance(item, dict)} assert {"test_cli_mode", "test_cli_record"} <= names -def test_idalib_type_show_includes_members( +@pytest.mark.parametrize("clang", [False, True]) +def test_nexus_type_check_resolves_header_dependencies_without_importing( + idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, clang: bool +) -> None: + database = copy_database(tiny_database) + decl = ( + "enum check_mode { CHECK_A = 1, CHECK_B = 2 };" + "struct check_record { enum check_mode mode; unsigned length; const char *data; };" + ) + args = ["type", "check", "--decl", decl] + if clang: + args.append("--clang") + checked = run_nexus_json(idac_cmd, idac_env, database, args) + assert checked["success"] is True + assert checked["errors"] == 0 + assert run_nexus_json(idac_cmd, idac_env, database, ["type", "list", "check_"]) == [] + + +@pytest.mark.parametrize("forward_decl", ["", "struct test_cli_record; "]) +def test_nexus_type_check_resolves_existing_types_and_preserves_them_on_failure( + idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path, forward_decl: str +) -> None: + database = copy_database(tiny_database) + _declare_types(idac_cmd, idac_env, database) + before = run_nexus_json(idac_cmd, idac_env, database, ["type", "show", "test_cli_record"]) + checked = run_nexus_json( + idac_cmd, + idac_env, + database, + ["type", "check", "--decl", forward_decl + "struct check_wrapper { test_cli_record record; };"], + ) + assert checked["success"] is True + proc = run_nexus( + idac_cmd, + idac_env, + database, + [ + "type", + "check", + "--decl", + "struct test_cli_record { long long changed; }; struct invalid { Missing value; };", + ], + use_json=True, + ) + assert proc.returncode == 1 + assert json.loads(proc.stdout)["success"] is False + assert run_nexus_json(idac_cmd, idac_env, database, ["type", "show", "test_cli_record"]) == before + assert run_nexus_json(idac_cmd, idac_env, database, ["type", "list", "check_wrapper"]) == [] + + +def test_nexus_type_show_includes_members( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) _declare_types(idac_cmd, idac_env, database) - payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "show", "test_cli_record"]) + payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "show", "test_cli_record"]) assert payload.get("name") == "test_cli_record" assert payload.get("kind") == "struct" assert payload.get("size_known") is True @@ -53,37 +105,37 @@ def test_idalib_type_show_includes_members( assert any(member.get("name") == "mode" for member in members) -def test_idalib_struct_list_and_show( +def test_nexus_struct_list_and_show( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) _declare_types(idac_cmd, idac_env, database) - list_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "struct", "list", "test_cli"]) + list_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "struct", "list", "test_cli"]) assert any(item.get("name") == "test_cli_record" for item in list_payload if isinstance(item, dict)) - show_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "struct", "show", "test_cli_record"]) + show_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "struct", "show", "test_cli_record"]) assert show_payload.get("kind") in {"struct", "union"} assert "members" in show_payload -def test_idalib_enum_list_and_show( +def test_nexus_enum_list_and_show( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) _declare_enum(idac_cmd, idac_env, database) - list_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "enum", "list", "test_cli"]) + list_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "enum", "list", "test_cli"]) assert any(item.get("name") == "test_cli_enum" for item in list_payload if isinstance(item, dict)) - show_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "enum", "show", "test_cli_enum"]) + show_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "enum", "show", "test_cli_enum"]) assert show_payload.get("kind") == "enum" assert any(member.get("name") == "CLI_ENUM_A" for member in show_payload.get("members") or []) -def test_idalib_type_declare_replace_updates_existing_type( +def test_nexus_type_declare_replace_updates_existing_type( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) - first = run_idalib_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", REPLACE_STRUCT_DECL_V1]) + first = run_nexus_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", REPLACE_STRUCT_DECL_V1]) assert first.get("success") is True - second = run_idalib_json( + second = run_nexus_json( idac_cmd, idac_env, database, @@ -91,16 +143,16 @@ def test_idalib_type_declare_replace_updates_existing_type( ) assert second.get("success") is True assert "test_cli_replace" in set(second.get("replaced_types") or []) - show_payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "show", "test_cli_replace"]) + show_payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "show", "test_cli_replace"]) member_names = {member.get("name") for member in show_payload.get("members") or []} assert {"value", "extra"} <= member_names -def test_idalib_type_declare_supports_alias_rewrites( +def test_nexus_type_declare_supports_alias_rewrites( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) - payload = run_idalib_json( + payload = run_nexus_json( idac_cmd, idac_env, database, @@ -110,22 +162,22 @@ def test_idalib_type_declare_supports_alias_rewrites( aliases = payload.get("aliases_applied") or [] assert any(item.get("from") == "ns::bad_record" and item.get("to") == "ns__bad_record" for item in aliases) names = { - item.get("name") for item in run_idalib_json(idac_cmd, idac_env, database, ["type", "list", "ns__bad_record"]) + item.get("name") for item in run_nexus_json(idac_cmd, idac_env, database, ["type", "list", "ns__bad_record"]) } assert "ns__bad_record" in names -def test_idalib_type_declare_reports_diagnostics_for_broken_input( +def test_nexus_type_declare_reports_diagnostics_for_broken_input( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) - payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", BROKEN_DECL]) + payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", BROKEN_DECL]) diagnostics = payload.get("diagnostics") or [] assert diagnostics assert any(item.get("kind") == "unterminated_declaration" for item in diagnostics) -def test_idalib_type_declare_strips_preprocessor_lines_and_comments( +def test_nexus_type_declare_strips_preprocessor_lines_and_comments( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) @@ -139,7 +191,7 @@ def test_idalib_type_declare_strips_preprocessor_lines_and_comments( } comment_guard_record; // trailing comment #endif """ - payload = run_idalib_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", decl]) + payload = run_nexus_json(idac_cmd, idac_env, database, ["type", "declare", "--decl", decl]) assert payload.get("success") is True assert payload.get("errors") == 0 @@ -147,11 +199,11 @@ def test_idalib_type_declare_strips_preprocessor_lines_and_comments( assert "comment_guard_record" in set(payload.get("imported_types") or []) -def test_idalib_type_declare_reports_cppobj_and_forward_decl_hints( +def test_nexus_type_declare_reports_cppobj_and_forward_decl_hints( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, database, @@ -172,11 +224,11 @@ def test_idalib_type_declare_reports_cppobj_and_forward_decl_hints( assert "forward_declaration_hint" in kinds -def test_idalib_type_declare_bisect_reports_first_failing_declaration( +def test_nexus_type_declare_bisect_reports_first_failing_declaration( idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path ) -> None: database = copy_database(tiny_database) - proc = run_idalib( + proc = run_nexus( idac_cmd, idac_env, database, @@ -190,27 +242,9 @@ def test_idalib_type_declare_bisect_reports_first_failing_declaration( bisect = payload.get("bisect") or {} failing = bisect.get("failing_declaration") or {} diagnostics = payload.get("diagnostics") or [] + blocking = bisect.get("blocking_members") or [] assert failing.get("index") == 3 assert any(item.get("kind") == "bisect_culprit" for item in diagnostics) - - -def test_idalib_type_declare_bisect_reports_opaque_by_value_member_hint( - idac_cmd: list[str], idac_env: dict[str, str], copy_database, tiny_database: Path -) -> None: - database = copy_database(tiny_database) - proc = run_idalib( - idac_cmd, - idac_env, - database, - ["type", "declare", "--decl", BISECT_DECL, "--diagnose"], - use_json=True, - ) - assert proc.returncode == 1 - payload = json.loads(proc.stdout) - - diagnostics = payload.get("diagnostics") or [] - blocking = (payload.get("bisect") or {}).get("blocking_members") or [] - assert {"type_name": "Missing", "member_name": "value"} in blocking assert any(item.get("kind") == "opaque_by_value_member_hint" for item in diagnostics) diff --git a/tests/test_operation_registry.py b/tests/test_operation_registry.py deleted file mode 100644 index 0f096cb..0000000 --- a/tests/test_operation_registry.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -import pytest - -from idac.ops import ( - OperationContext, - OperationRegistry, - OperationSpec, - PreviewOutcome, - PreviewSpec, - PreviewUnsupportedError, - payload_from_model, -) - - -@dataclass(frozen=True) -class CounterRequest: - amount: int - - -@dataclass(frozen=True) -class CounterResult: - value: int - - -class _CounterRuntime: - def __init__(self) -> None: - self.value = 0 - - -def _parse_counter(params: dict[str, object]) -> CounterRequest: - return CounterRequest(amount=int(params["amount"])) - - -def _increment(context: OperationContext, request: CounterRequest) -> CounterResult: - runtime = context.runtime - assert isinstance(runtime, _CounterRuntime) - runtime.value += request.amount - return CounterResult(value=runtime.value) - - -def _capture_value(context: OperationContext, request: CounterRequest) -> int: - del request - runtime = context.runtime - assert isinstance(runtime, _CounterRuntime) - return runtime.value - - -def _rollback_increment( - context: OperationContext, - request: CounterRequest, - before: int, - result: CounterResult, -) -> None: - del result, before - runtime = context.runtime - assert isinstance(runtime, _CounterRuntime) - runtime.value -= request.amount - - -def test_operation_registry_executes_typed_operation() -> None: - registry = OperationRegistry( - [ - OperationSpec( - name="counter.increment", - parse=_parse_counter, - run=_increment, - mutating=True, - ) - ] - ) - runtime = _CounterRuntime() - - result = registry.execute( - "counter.increment", - params={"amount": "3"}, - context=OperationContext(runtime=runtime), - ) - - assert result == CounterResult(value=3) - assert runtime.value == 3 - - -def test_operation_registry_runs_preview_and_rolls_back_state() -> None: - registry = OperationRegistry( - [ - OperationSpec( - name="counter.increment", - parse=_parse_counter, - run=_increment, - mutating=True, - preview=PreviewSpec( - capture_before=_capture_value, - capture_after=_capture_value, - rollback=_rollback_increment, - ), - ) - ] - ) - runtime = _CounterRuntime() - - result = registry.execute( - "counter.increment", - params={"amount": "5"}, - context=OperationContext(runtime=runtime, preview=True), - ) - - assert result == PreviewOutcome( - result=CounterResult(value=5), - before=0, - after=5, - ) - assert runtime.value == 0 - - -def test_preview_requires_preview_support() -> None: - registry = OperationRegistry( - [ - OperationSpec( - name="counter.increment", - parse=_parse_counter, - run=_increment, - mutating=True, - ) - ] - ) - - with pytest.raises(PreviewUnsupportedError, match="preview is not supported"): - registry.execute( - "counter.increment", - params={"amount": "1"}, - context=OperationContext(runtime=_CounterRuntime(), preview=True), - ) - - -def test_payload_from_model_serializes_nested_dataclasses() -> None: - payload = payload_from_model( - PreviewOutcome( - result=CounterResult(value=9), - before=4, - after=9, - ) - ) - - assert payload == { - "result": {"value": 9}, - "before": 4, - "after": 9, - "persisted": False, - "preview": True, - "preview_mode": "undo", - } diff --git a/tests/test_ops_helpers.py b/tests/test_ops_helpers.py index b42aeb4..fd6e148 100644 --- a/tests/test_ops_helpers.py +++ b/tests/test_ops_helpers.py @@ -1,41 +1,30 @@ from __future__ import annotations -from typing import get_args +from types import SimpleNamespace +from unittest.mock import Mock import pytest -from idac.ops import OperationContext, payload_from_model -from idac.ops.families import ( - bookmarks, - classes, - database, - functions, - locals, - names, - prototypes, - search, - segments, - type_declare, -) -from idac.ops.families import named_types as types -from idac.ops.families.type_declare import _split_declarations -from idac.ops.helpers import matching -from idac.ops.manifest import OPERATION_SPEC_MAP, SUPPORTED_OPERATIONS, OperationName -from idac.ops.preview import PreviewSpec, run_preview -from idac.ops.runtime import ( - IdaOperationError, - IdaRuntime, - SegmentRange, - XrefRecord, - is_recoverable_ida_error, - suppress_recoverable_ida_errors, -) - - -def _run_op(name: OperationName, runtime, params: dict[str, object]): - """Parse and run an operation through its spec, as production dispatch does.""" - spec = OPERATION_SPEC_MAP[name] - return payload_from_model(spec.run(OperationContext(runtime=runtime), spec.parse(params))) +from idac import remote_ops +from tests.remote_ops_harness import dispatch_with_runtime + +IdaOperationError = remote_ops.IdaOperationError +IdaRuntime = remote_ops.IdaRuntime +XrefRecord = remote_ops.XrefRecord + + +def _run_op(name: str, runtime, params: dict[str, object], *, preview: bool = False): + return dispatch_with_runtime(runtime, name, params, preview=preview) + + +class _SuccessfulUndo: + @staticmethod + def create_undo_point(**_kwargs) -> bool: + return True + + @staticmethod + def perform_undo() -> bool: + return True class _DummyRuntime: @@ -60,191 +49,6 @@ def test_struct_field_set_rejects_negative_offsets() -> None: ) -def test_parse_member_type_uses_parse_decl_with_field_name() -> None: - calls: list[tuple[str, int]] = [] - tif = object() - - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_SEMICOLON = 0x4000 - - @staticmethod - def tinfo_t() -> object: - return tif - - @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert out_tif is tif - assert til is None - calls.append((decl, flags)) - return True - - class FakeRuntime(IdaRuntime): - def mod(self, name: str) -> FakeIdaTypeInf: - assert name == "ida_typeinf" - return FakeIdaTypeInf() - - assert types._parse_member_type(FakeRuntime(), "unsigned int", "count") is tif - assert calls == [("unsigned int count;", 0x4009)] - - -def test_parse_member_type_accepts_full_field_declaration() -> None: - calls: list[tuple[str, int]] = [] - tif = object() - - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_SEMICOLON = 0x4000 - - @staticmethod - def tinfo_t() -> object: - return tif - - @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert out_tif is tif - assert til is None - calls.append((decl, flags)) - return decl == "unsigned int count;" - - class FakeRuntime(IdaRuntime): - def mod(self, name: str) -> FakeIdaTypeInf: - assert name == "ida_typeinf" - return FakeIdaTypeInf() - - assert types._parse_member_type(FakeRuntime(), "unsigned int count;", "count") is tif - assert calls == [("unsigned int count;", 0x4009)] - - -def test_parse_var_decl_uses_parse_decl_with_silent_var_mode() -> None: - calls: list[tuple[str, int]] = [] - tif = object() - - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_SEMICOLON = 0x4000 - - @staticmethod - def tinfo_t() -> object: - return tif - - @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert out_tif is tif - assert til is None - calls.append((decl, flags)) - return True - - class FakeRuntime(IdaRuntime): - def mod(self, name: str) -> FakeIdaTypeInf: - assert name == "ida_typeinf" - return FakeIdaTypeInf() - - assert ( - locals._parse_var_decl( - FakeRuntime(), - "unsigned int value", - error_message="boom", - ) - is tif - ) - assert calls == [("unsigned int value;", 0x4009)] - - -def test_op_decompile_passes_no_cache_flag_when_requested() -> None: - calls: list[tuple[int, int]] = [] - - class FakeLine: - def __init__(self, line: str) -> None: - self.line = line - - class FakeCfunc: - def get_pseudocode(self) -> list[FakeLine]: - return [FakeLine("int main(void)")] - - class FakeHexrays: - DECOMP_NO_CACHE = 0x2 - - @staticmethod - def decompile(ea: int, hf=None, flags: int = 0) -> FakeCfunc: - assert hf is None - calls.append((ea, flags)) - return FakeCfunc() - - class FakeRuntime(IdaRuntime): - def require_hexrays(self) -> FakeHexrays: - return FakeHexrays() - - def function_ea(self, identifier: str) -> int: - assert identifier == "main" - return 0x401000 - - @staticmethod - def mod(name: str): - assert name == "ida_lines" - - class FakeIdaLines: - @staticmethod - def tag_remove(text: str) -> str: - return text - - return FakeIdaLines() - - payload = _run_op("decompile", FakeRuntime(), {"identifier": "main", "no_cache": True}) - - assert payload == {"text": "int main(void)"} - assert calls == [(0x401000, 0x2)] - - -def test_disasm_uses_direct_ida_lines_api() -> None: - calls: list[tuple[str, int]] = [] - - class FakeIdaLines: - @staticmethod - def generate_disasm_line(ea: int, flags: int) -> str: - calls.append(("generate", ea)) - assert flags == 0 - return f"insn_{ea:x}" - - @staticmethod - def tag_remove(text: str) -> str: - calls.append(("tag_remove", 0)) - return text.replace("", "").replace("", "") - - class FakeRuntime(IdaRuntime): - def function_ea(self, identifier: str) -> int: - assert identifier == "main" - return 0x401000 - - @staticmethod - def mod(name: str) -> FakeIdaLines: - assert name == "ida_lines" - return FakeIdaLines() - - class idautils: - @staticmethod - def FuncItems(func_ea: int) -> list[int]: - assert func_ea == 0x401000 - return [0x401000, 0x401004] - - rendered = functions._disasm(OperationContext(runtime=FakeRuntime()), functions.FunctionIdentifierRequest("main")) - - assert rendered.text == "0x401000: insn_401000\n0x401004: insn_401004" - assert calls == [("generate", 0x401000), ("tag_remove", 0), ("generate", 0x401004), ("tag_remove", 0)] - - -def test_text_matches_supports_regex_alternation() -> None: - assert matching.text_matches( - "GlobalHEIFInfo", - pattern="GlobalHEIFInfo|HEIFGroupItem|HEIFGroup|HEIFStereoAggressor|HEIFReadPlugin|HEIFWritePlugin", - regex=True, - ) - assert not matching.text_matches("UnrelatedName", pattern="GlobalHEIFInfo|HEIFGroupItem", regex=True) - - def test_function_list_honors_limit() -> None: class FakeFunc: def __init__(self, ea: int, flags: int = 0) -> None: @@ -267,13 +71,17 @@ def get_func(ea: int) -> FakeFunc: class FakeIdaSegment: @staticmethod - def getseg(_ea: int): - return None + def get_segment_name(_ea: int, _flags: int) -> str: + return "" + + class FakeIdaName: + GN_VISIBLE = 1 class FakeRuntime(IdaRuntime): idautils = FakeIdaUtils() ida_funcs = FakeIdaFuncs() ida_segment = FakeIdaSegment() + ida_name = FakeIdaName() @staticmethod def function_name(ea: int) -> str: @@ -283,20 +91,9 @@ def function_name(ea: int) -> str: def display_function_name(ea: int, *, demangle: bool = False) -> str: return FakeRuntime.function_name(ea) - rows = functions._function_list( - OperationContext(runtime=FakeRuntime()), - functions.FunctionListRequest( - pattern="", - glob=False, - regex=False, - ignore_case=False, - segment=None, - limit=2, - demangle=False, - ), - ) + rows = _run_op("function_list", FakeRuntime(), {"limit": 2}) - assert [row.name for row in rows] == ["alpha", "beta"] + assert [row["name"] for row in rows] == ["alpha", "beta"] def test_function_list_reports_section_name() -> None: @@ -305,9 +102,6 @@ class FakeFunc: end_ea = 0x401010 flags = 0 - class FakeSegment: - start_ea = 0x401000 - class FakeIdaUtils: @staticmethod def Functions(): @@ -322,17 +116,17 @@ def get_func(_ea: int) -> FakeFunc: class FakeIdaSegment: @staticmethod - def getseg(_ea: int) -> FakeSegment: - return FakeSegment() - - @staticmethod - def get_segm_name(_segment: FakeSegment) -> str: + def get_segment_name(_ea: int, _flags: int) -> str: return ".text" + class FakeIdaName: + GN_VISIBLE = 1 + class FakeRuntime(IdaRuntime): idautils = FakeIdaUtils() ida_funcs = FakeIdaFuncs() ida_segment = FakeIdaSegment() + ida_name = FakeIdaName() @staticmethod def function_name(_ea: int) -> str: @@ -342,23 +136,12 @@ def function_name(_ea: int) -> str: def display_function_name(ea: int, *, demangle: bool = False) -> str: return FakeRuntime.function_name(ea) - rows = functions._function_list( - OperationContext(runtime=FakeRuntime()), - functions.FunctionListRequest( - pattern="", - glob=False, - regex=False, - ignore_case=False, - segment=None, - limit=None, - demangle=False, - ), - ) + rows = _run_op("function_list", FakeRuntime(), {}) - assert [(row.name, row.section) for row in rows] == [("main", ".text")] + assert [(row["name"], row["section"]) for row in rows] == [("main", ".text")] -def test_function_list_filter_uses_raw_name_without_demangle() -> None: +def test_function_list_demangle_controls_matching_and_rendering() -> None: class FakeFunc: start_ea = 0x401000 end_ea = 0x401010 @@ -375,62 +158,17 @@ def get_func(_ea: int) -> FakeFunc: class FakeIdaSegment: @staticmethod - def getseg(_ea: int): - return None - - class FakeRuntime(IdaRuntime): - idautils = FakeIdaUtils() - ida_funcs = FakeIdaFuncs() - ida_segment = FakeIdaSegment() - - @staticmethod - def function_name(_ea: int) -> str: - return "__ZN3Foo3barEv" - - @staticmethod - def display_function_name(_ea: int, *, demangle: bool = False) -> str: - return "Foo::bar()" - - rows = functions._function_list( - OperationContext(runtime=FakeRuntime()), - functions.FunctionListRequest( - pattern="Foo::bar", - glob=False, - regex=False, - ignore_case=False, - segment=None, - limit=None, - demangle=False, - ), - ) - - assert rows == () - - -def test_function_list_filter_uses_display_name_with_demangle() -> None: - class FakeFunc: - start_ea = 0x401000 - end_ea = 0x401010 - - class FakeIdaUtils: - @staticmethod - def Functions(): - return [0x401000] - - class FakeIdaFuncs: - @staticmethod - def get_func(_ea: int) -> FakeFunc: - return FakeFunc() + def get_segment_name(_ea: int, _flags: int) -> str: + return "" - class FakeIdaSegment: - @staticmethod - def getseg(_ea: int): - return None + class FakeIdaName: + GN_VISIBLE = 1 class FakeRuntime(IdaRuntime): idautils = FakeIdaUtils() ida_funcs = FakeIdaFuncs() ida_segment = FakeIdaSegment() + ida_name = FakeIdaName() @staticmethod def function_name(_ea: int) -> str: @@ -440,32 +178,21 @@ def function_name(_ea: int) -> str: def display_function_name(_ea: int, *, demangle: bool = False) -> str: return "Foo::bar()" - rows = functions._function_list( - OperationContext(runtime=FakeRuntime()), - functions.FunctionListRequest( - pattern="Foo::bar", - glob=False, - regex=False, - ignore_case=False, - segment=None, - limit=None, - demangle=True, - ), - ) + raw_matches = _run_op("function_list", FakeRuntime(), {"pattern": "Foo::bar"}) + demangled_matches = _run_op("function_list", FakeRuntime(), {"pattern": "Foo::bar", "demangle": True}) - assert [(row.name, row.render_name) for row in rows] == [("__ZN3Foo3barEv", "Foo::bar()")] + assert raw_matches == [] + assert [(row["name"], row["render_name"]) for row in demangled_matches] == [("__ZN3Foo3barEv", "Foo::bar()")] def test_database_info_reports_start_ea_separately_from_first_entry() -> None: class FakeIdaEntry: @staticmethod - def get_entry_ordinal(index: int) -> int: - assert index == 0 + def get_entry_ordinal(_index: int) -> int: return 0 @staticmethod - def get_entry(ordinal: int) -> int: - assert ordinal == 0 + def get_entry(_ordinal: int) -> int: return 0x402000 class FakeIdaIda: @@ -501,13 +228,13 @@ class FakeIdaLoader: PATH_TYPE_IDB = 1 @staticmethod - def get_path(path_type: int) -> str: - assert path_type == 1 + def get_path(_path_type: int) -> str: return "/tmp/sample.i64" class FakeIdaApi: BADADDR = -1 + class FakeIdaNalt: @staticmethod def get_input_file_path() -> str: return "/tmp/sample" @@ -527,41 +254,19 @@ def mod(name: str): "ida_entry": FakeIdaEntry(), "ida_ida": FakeIdaIda(), "ida_loader": FakeIdaLoader(), + "ida_nalt": FakeIdaNalt(), "idaapi": FakeIdaApi(), } return modules[name] - result = database._database_info(OperationContext(runtime=FakeRuntime()), database.DatabaseInfoRequest()) - - assert result.start_ea == "0x401000" - assert result.entry_ea == "0x402000" - assert result.main_ea == "0x403000" - - -def test_segment_list_filters_with_regex_pattern() -> None: - class FakeRuntime(IdaRuntime): - @staticmethod - def iter_segments() -> tuple[SegmentRange, ...]: - return ( - SegmentRange(name="__TEXT:__text", start_ea=0x1000, end_ea=0x2000), - SegmentRange(name="__TEXT:__cstring", start_ea=0x3000, end_ea=0x3400), - SegmentRange(name="__DATA:__data", start_ea=0x4000, end_ea=0x4800), - ) - - rows = segments._segment_list( - OperationContext(runtime=FakeRuntime()), - segments.SegmentListRequest(pattern="__TEXT|__cstring", glob=False, regex=True, ignore_case=False), - ) - - assert rows == ( - segments.SegmentListEntry(name="__TEXT:__text", start="0x1000", end="0x2000", size=0x1000), - segments.SegmentListEntry(name="__TEXT:__cstring", start="0x3000", end="0x3400", size=0x400), - ) + result = _run_op("database_info", FakeRuntime(), {}) + assert result["start_ea"] == "0x401000" + assert result["entry_ea"] == "0x402000" + assert result["main_ea"] == "0x403000" -def test_xrefs_collect_code_and_data_references_explicitly() -> None: - seen_flags: list[int] = [] +def test_xrefs_collect_unique_code_and_data_references() -> None: class FakeRuntime(IdaRuntime): class ida_xref: XREF_FLOW = 0 @@ -580,1180 +285,174 @@ def resolve_address(identifier: str) -> int: def xrefs_to(self, ea: int, *, flags: int = 0) -> tuple[XrefRecord, ...]: assert ea == 0x401004 - seen_flags.append(flags) - if flags == self.ida_xref.XREF_FLOW: - return ( - XrefRecord( - from_ea=0x400FF0, - to_ea=0x401004, - type="Ordinary_Flow", - kind="flow", - user=False, - ), - ) - if flags == self.ida_xref.XREF_CODE: - return ( - XrefRecord( - from_ea=0x401000, - to_ea=0x401004, - type="Code_Near_Call", - kind="call", - user=False, - ), - ) - if flags == self.ida_xref.XREF_DATA: - return ( - XrefRecord( - from_ea=0x402000, - to_ea=0x401004, - type="Data_Read", - kind="read", - user=False, - ), - ) - raise AssertionError(f"unexpected flags: {flags}") - - rows = search._xrefs(OperationContext(runtime=FakeRuntime()), search.XrefsRequest(identifier="target")) - - assert seen_flags == [0, 4, 2] - assert rows == ( - search.XrefRow( - from_="0x401000", - to="0x401004", - type="Code_Near_Call", - kind="call", - user=False, - function=None, - ), - search.XrefRow( - from_="0x400ff0", - to="0x401004", - type="Ordinary_Flow", - kind="flow", - user=False, - function=None, - ), - search.XrefRow( - from_="0x402000", - to="0x401004", - type="Data_Read", - kind="read", - user=False, - function=None, - ), - ) - + del flags + return ( + XrefRecord( + from_ea=0x400FF0, + to_ea=0x401004, + type="Ordinary_Flow", + kind="flow", + user=False, + ), + XrefRecord( + from_ea=0x401000, + to_ea=0x401004, + type="Code_Near_Call", + kind="call", + user=False, + ), + XrefRecord( + from_ea=0x402000, + to_ea=0x401004, + type="Data_Read", + kind="read", + user=False, + ), + ) -def test_runtime_xrefs_to_requests_flow_by_default() -> None: - seen: list[tuple[int, int]] = [] + rows = _run_op("xrefs", FakeRuntime(), {"identifier": "target"}) - class FakeXref: - frm = 0x401000 - to = 0x401004 - iscode = True - type = 99 - user = False + assert [(row["from"], row["kind"]) for row in rows] == [ + ("0x401000", "call"), + ("0x400ff0", "flow"), + ("0x402000", "read"), + ] - class FakeXrefBlock: - def refs_to(self, ea: int, flags: int): - seen.append((ea, flags)) - return (FakeXref(),) - def refs_from(self, ea: int, flags: int): - seen.append((ea, flags)) - return (FakeXref(),) +def test_comment_preview_restores_state_after_readback_failure() -> None: + state = {0x401000: "before"} - class FakeIdaXref: - XREF_FLOW = 1234 + class FakeIdaBytes: + @staticmethod + def get_cmt(ea: int, repeatable: bool) -> str: + assert repeatable is False + if state[ea] == "after": + raise RuntimeError("comment readback failed") + return state[ea] @staticmethod - def xrefblk_t() -> FakeXrefBlock: - return FakeXrefBlock() + def set_cmt(ea: int, text: str, repeatable: bool) -> bool: + assert repeatable is False + state[ea] = text + return True class FakeRuntime(IdaRuntime): - def mod(self, name: str): - assert name == "ida_xref" - return FakeIdaXref() - @staticmethod - def _normalize_xref(_xref) -> XrefRecord: - return XrefRecord( - from_ea=0x401000, - to_ea=0x401004, - type="Ordinary_Flow", - kind="flow", - user=False, - ) - - runtime = FakeRuntime() - assert runtime.xrefs_to(0x401004) == ( - XrefRecord( - from_ea=0x401000, - to_ea=0x401004, - type="Ordinary_Flow", - kind="flow", - user=False, - ), - ) - assert runtime.xrefs_from(0x401000) == ( - XrefRecord( - from_ea=0x401000, - to_ea=0x401004, - type="Ordinary_Flow", - kind="flow", - user=False, - ), - ) - assert seen == [(0x401004, 1234), (0x401000, 1234)] + def resolve_address(identifier: str) -> int: + assert identifier in {"main", "0x401000"} + return 0x401000 + @staticmethod + def mod(name: str) -> FakeIdaBytes: + assert name == "ida_bytes" + return FakeIdaBytes() -def test_op_function_frame_uses_get_func_frame_not_func_frame_object() -> None: - calls: list[tuple[str, int]] = [] + with pytest.raises(RuntimeError, match="comment readback failed"): + _run_op("comment_set", FakeRuntime(), {"address": "main", "text": "after"}, preview=True) - class FakeMemberType: - @staticmethod - def dstr() -> str: - return "int" + assert state == {0x401000: "before"} - class FakeFrameMember: - name = "var_4" - type = FakeMemberType() +def test_type_show_does_not_suppress_type_errors_from_ida() -> None: + class BrokenType: @staticmethod - def begin() -> int: - return 32 + def get_type_name() -> str: + return "Widget" @staticmethod - def end() -> int: - return 64 - - class FakeFrameTif: - def __init__(self) -> None: - self._members = [FakeFrameMember()] + def get_size() -> int: + return 4 @staticmethod - def get_size() -> int: - return 24 + def _print(_name: str, _flags: int) -> str: + raise TypeError("broken type printer") - def get_func_frame(self, func: object) -> bool: - calls.append(("get_func_frame", int(func.start_ea))) - return True + class FakeIdaTypeInf: + PRTYPE_TYPE = 0x1 + PRTYPE_DEF = 0x2 + PRTYPE_MULTI = 0x4 - def iter_struct(self): - return iter(self._members) + class FakeRuntime(IdaRuntime): + def __init__(self) -> None: + super().__init__() @staticmethod - def get_udm_tid(index: int) -> int: - assert index == 0 - return 0 + def get_named_type(name: str) -> BrokenType: + assert name == "Widget" + return BrokenType() - class FakeIdaTypeInf: @staticmethod - def tinfo_t() -> FakeFrameTif: - return FakeFrameTif() + def classify_tinfo(_tif: object) -> str: + return "struct" - class FakeIdaFrame: @staticmethod - def is_special_frame_member(_tid: int) -> bool: - return False + def mod(name: str) -> FakeIdaTypeInf: + assert name == "ida_typeinf" + return FakeIdaTypeInf() - @staticmethod - def is_funcarg_off(_func: object, _offset: int) -> bool: - return False + with pytest.raises(TypeError, match="broken type printer"): + _run_op("type_show", FakeRuntime(), {"name": "Widget"}) - @staticmethod - def soff_to_fpoff(_func: object, offset: int) -> int: - return offset - class FakeIdaFuncs: - @staticmethod - def get_func_name(ea: int) -> str: - assert ea == 0x401000 - return "main" +def test_type_declare_diagnostics_ignore_braces_inside_comments_and_strings() -> None: + typeinf = Mock(HTI_DCL=0x1, HTI_SEMICOLON=0x2) + typeinf.get_idati.return_value.cc = 0 + typeinf.tinfo_t.return_value.get_named_type.return_value = False + typeinf.parse_decls.return_value = 1 + runtime = SimpleNamespace(mod={"ida_typeinf": typeinf}.__getitem__) + + result = _run_op( + "type_declare_check", + runtime, + {"decl": 'struct Widget { const char *value; }; const char *text = "{" /* } */'}, + ) + diagnostics = result["diagnostics"] - class FakeIdaXref: - dr_R = 1 - dr_W = 2 + assert not any(item["kind"] == "unbalanced_braces" for item in diagnostics) + assert any(item["kind"] == "unterminated_declaration" for item in diagnostics) - class FakeFunc: - start_ea = 0x401000 - end_ea = 0x401020 - frsize = 16 - frregs = 0 - argsize = 0 - @property - def frame_object(self) -> object: - raise AssertionError("function_frame should not use func.frame_object") +def test_type_declare_clang_reports_unavailable_parser() -> None: + class FakeIdaTypeInf: + HTI_DCL = 0x400 + HTI_SEMICOLON = 0x200000 - class FakeRuntime(IdaRuntime): - def resolve_function(self, identifier: str) -> FakeFunc: - assert identifier == "main" - return FakeFunc() + class FakeIdaSrclang: + @staticmethod + def parse_decls_with_parser_ext(_parser_name: str, _til: object, _decl: str, _flags: int) -> int: + return -1 + class FakeRuntime: def mod(self, name: str): - if name == "ida_frame": - return FakeIdaFrame() if name == "ida_typeinf": return FakeIdaTypeInf() - if name == "ida_funcs": - return FakeIdaFuncs() - if name == "ida_xref": - return FakeIdaXref() + if name == "ida_srclang": + return FakeIdaSrclang() + if name == "ida_undo": + return _SuccessfulUndo() raise AssertionError(name) @staticmethod - def tinfo_decl(_tif, *, multi: bool = True) -> str: - assert multi is False - return "int" + def list_named_types() -> list[dict[str, object]]: + return [] - payload = _run_op("function_frame", FakeRuntime(), {"identifier": "main"}) - - assert payload["frame_size"] == 24 - assert payload["members"] == [ - { - "index": 0, - "name": "var_4", - "offset": 4, - "end_offset": 8, - "size": 4, - "type": "int", - "kind": "local", - "is_special": False, - "is_arg": False, - "fp_offset": 4, - "xrefs": [], - "xref_count": None, - } - ] - assert calls == [("get_func_frame", 0x401000), ("get_func_frame", 0x401000)] + with pytest.raises(IdaOperationError, match="clang parser is unavailable"): + _run_op( + "type_declare", + FakeRuntime(), + {"decl": "struct Widget { int value; };", "clang": True}, + ) -def test_local_rename_uses_direct_hexrays_rename_for_name_selector(monkeypatch) -> None: - class FakeHexrays: - def rename_lvar(self, func_ea: int, old_name: str, new_name: str) -> bool: - assert func_ea == 0x401000 - assert old_name == "v4" - assert new_name == "sum_value" - return True +def test_local_rename_rolls_back_after_readback_failure() -> None: + mutated = False - class FakeRuntime(IdaRuntime): - def function_ea(self, identifier: str) -> int: - assert identifier == "main" - return 0x401000 + class FakeCfunc: + def get_lvars(self) -> list[object]: + return [] - def require_hexrays(self) -> FakeHexrays: - return FakeHexrays() - - monkeypatch.setattr( - locals, - "_select_local", - lambda runtime, func_ea, selector: locals.SelectedLocal("v4", "loc"), - ) - monkeypatch.setattr( - locals, - "_local_list_result", - lambda runtime, func_ea: locals.LocalListResult( - function="main", - address="0x401000", - locals=(), - ), - ) - - payload = _run_op( - "local_rename", - FakeRuntime(), - {"identifier": "main", "old_name": "v4", "new_name": "sum_value"}, - ) - - assert payload["changed"] is True - - -def test_operation_literal_matches_spec_keys() -> None: - assert set(get_args(OperationName)) == set(SUPPORTED_OPERATIONS) - - -def test_only_list_targets_lacks_a_direct_operation_handler() -> None: - assert set(SUPPORTED_OPERATIONS) - set(OPERATION_SPEC_MAP) == {"list_targets"} - - -def test_python_exec_is_marked_mutating() -> None: - spec = OPERATION_SPEC_MAP["python_exec"] - - assert spec.mutating is True - - -def test_python_exec_scope_exposes_explicit_ida_runtime_modules() -> None: - imported: list[str] = [] - require_hexrays_calls = 0 - - class FakeRuntime(IdaRuntime): - def mod(self, name: str): - imported.append(name) - if name == "ida_broken": - raise ImportError(name) - return {"module": name} - - def require_hexrays(self): - nonlocal require_hexrays_calls - require_hexrays_calls += 1 - return {"module": "ida_hexrays"} - - scope = FakeRuntime().python_exec_scope(persist=False) - - assert scope["idaapi"] == {"module": "idaapi"} - assert scope["ida_bytes"] == {"module": "ida_bytes"} - assert scope["ida_hexrays"] == {"module": "ida_hexrays"} - assert "ida_broken" not in scope - assert scope["idc"] == {"module": "idc"} - assert scope["idautils"] == {"module": "idautils"} - assert scope["result"] is None - assert require_hexrays_calls == 1 - assert imported == [ - "idaapi", - "ida_auto", - "ida_bytes", - "ida_entry", - "ida_frame", - "ida_funcs", - "ida_ida", - "ida_idc", - "ida_idp", - "ida_kernwin", - "ida_lines", - "ida_loader", - "ida_moves", - "ida_name", - "ida_nalt", - "ida_range", - "ida_segment", - "ida_strlist", - "ida_srclang", - "ida_typeinf", - "ida_ua", - "ida_undo", - "ida_xref", - "idc", - "idautils", - ] - - -def test_python_exec_scope_omits_ida_hexrays_when_unavailable() -> None: - class FakeRuntime(IdaRuntime): - def mod(self, name: str): - return {"module": name} - - def require_hexrays(self): - raise IdaOperationError("Hex-Rays decompiler is unavailable") - - scope = FakeRuntime().python_exec_scope(persist=False) - - assert "ida_hexrays" not in scope - - -def test_name_set_preview_resolves_identifier_before_capture() -> None: - spec = OPERATION_SPEC_MAP["name_set"].preview - assert spec is not None - assert spec.prepare is not None - - class FakeRuntime(IdaRuntime): - @staticmethod - def mod(name: str): - assert name == "ida_name" - - class FakeIdaName: - @staticmethod - def get_name(ea: int) -> str: - assert ea == 0x401000 - return "" - - return FakeIdaName() - - @staticmethod - def resolve_address(identifier: str) -> int: - assert identifier in {"main", "0x401000"} - return 0x401000 - - context = OperationContext(runtime=FakeRuntime()) - request = names._parse_name_set({"identifier": "main", "new_name": "renamed"}) - prepared = spec.prepare_request(context, request) - - assert prepared == names.NameSetRequest(identifier="0x401000", new_name="renamed") - assert spec.capture_before(context, prepared) == names.NameState(address="0x401000", name="") - - -def test_name_set_preview_prepare_preserves_mutation_params() -> None: - spec = OPERATION_SPEC_MAP["name_set"].preview - assert spec is not None - assert spec.prepare is not None - - class FakeRuntime(IdaRuntime): - @staticmethod - def resolve_address(identifier: str) -> int: - assert identifier == "main" - return 0x401000 - - context = OperationContext(runtime=FakeRuntime()) - request = names._parse_name_set({"identifier": "main", "new_name": "add_numbers"}) - prepared = spec.prepare_request(context, request) - - assert prepared == names.NameSetRequest(identifier="0x401000", new_name="add_numbers") - - -def test_local_rename_preview_registers_cleanup() -> None: - spec = OPERATION_SPEC_MAP["local_rename"].preview - - assert spec is not None - assert spec.cleanup is not None - - -def test_strings_manifest_marks_operation_read_only() -> None: - spec = OPERATION_SPEC_MAP["strings"] - - assert spec.mutating is False - assert spec.preview is None - - -def test_preview_cleanup_runs_after_failed_mutation() -> None: - events: list[str] = [] - - class FakeUndo: - def create_undo_point(self, **_kwargs) -> bool: - events.append("create") - return True - - def perform_undo(self) -> bool: - events.append("undo") - return True - - class FakeRuntime(IdaRuntime): - def mod(self, name: str) -> FakeUndo: - assert name == "ida_undo" - return FakeUndo() - - def capture(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - events.append("capture") - return {"captured": True} - - def cleanup(_context: OperationContext, _request: dict[str, object]) -> None: - events.append("cleanup") - - def handler(_context: OperationContext, _request: dict[str, object]) -> object: - events.append("mutate") - raise RuntimeError("boom") - - with pytest.raises(RuntimeError, match="boom"): - run_preview( - OperationContext(runtime=FakeRuntime(), preview=True), - "test_preview", - {}, - handler, - PreviewSpec(capture_before=capture, capture_after=capture, cleanup=cleanup, use_undo=True), - ) - - assert events == ["create", "capture", "mutate", "undo", "cleanup"] - - -def test_preview_cleanup_does_not_mask_mutation_failure() -> None: - class FakeUndo: - def create_undo_point(self, **_kwargs) -> bool: - return True - - def perform_undo(self) -> bool: - return True - - class FakeRuntime(IdaRuntime): - def mod(self, name: str) -> FakeUndo: - assert name == "ida_undo" - return FakeUndo() - - def capture(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - return {"captured": True} - - def cleanup(_context: OperationContext, _request: dict[str, object]) -> None: - raise RuntimeError("cleanup failed") - - def handler(_context: OperationContext, _request: dict[str, object]) -> object: - raise ValueError("mutation failed") - - with pytest.raises(ValueError, match="mutation failed"): - run_preview( - OperationContext(runtime=FakeRuntime(), preview=True), - "test_preview", - {}, - handler, - PreviewSpec(capture_before=capture, capture_after=capture, cleanup=cleanup, use_undo=True), - ) - - -def test_manual_preview_rolls_back_when_after_capture_fails() -> None: - events: list[str] = [] - - def capture_before(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - events.append("before") - return {"before": True} - - def capture_after(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - events.append("after") - raise RuntimeError("after failed") - - def rollback( - _context: OperationContext, - _request: dict[str, object], - _before: dict[str, bool], - _result: dict[str, bool], - ) -> None: - events.append("rollback") - - def handler(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - events.append("mutate") - return {"changed": True} - - with pytest.raises(RuntimeError, match="after failed"): - run_preview( - OperationContext(runtime=IdaRuntime(), preview=True), - "test_preview", - {}, - handler, - PreviewSpec(capture_before=capture_before, capture_after=capture_after, rollback=rollback), - ) - - assert events == ["before", "mutate", "after", "rollback"] - - -def test_manual_preview_reports_rollback_failure_after_capture_failure() -> None: - def capture_before(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - return {"before": True} - - def capture_after(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - raise RuntimeError("after failed") - - def rollback( - _context: OperationContext, - _request: dict[str, object], - _before: dict[str, bool], - _result: dict[str, bool], - ) -> None: - raise RuntimeError("rollback failed") - - def handler(_context: OperationContext, _request: dict[str, object]) -> dict[str, bool]: - return {"changed": True} - - with pytest.raises(RuntimeError, match="rollback failed") as excinfo: - run_preview( - OperationContext(runtime=IdaRuntime(), preview=True), - "test_preview", - {}, - handler, - PreviewSpec(capture_before=capture_before, capture_after=capture_after, rollback=rollback), - ) - - assert isinstance(excinfo.value.__cause__, RuntimeError) - assert str(excinfo.value.__cause__) == "after failed" - - -def test_recoverable_ida_errors_do_not_treat_typeerror_as_recoverable() -> None: - assert is_recoverable_ida_error(TypeError("boom")) is False - - with pytest.raises(TypeError, match="boom"), suppress_recoverable_ida_errors(): - raise TypeError("boom") - - -def test_split_declarations_tracks_next_chunk_line_after_newline() -> None: - chunks = _split_declarations("typedef int a;\n\n typedef int b;") - - assert chunks[0]["start_line"] == 1 - assert chunks[1]["start_line"] == 3 - - -def test_type_declare_diagnostics_ignore_braces_inside_comments_and_strings() -> None: - diagnostics = type_declare._type_declare_diagnostics( - 'const char *s = "{"; /* } */', - errors=1, - aliases_applied=[], - ) - - assert not any(item["kind"] == "unbalanced_braces" for item in diagnostics) - assert any(item["kind"] == "unterminated_declaration" for item in diagnostics) - - -def test_type_declare_diagnostics_report_cppobj_and_forward_decl_hints() -> None: - diagnostics = type_declare._type_declare_diagnostics( - "struct helper; class __cppobj Broken : helper { int value; };", - errors=1, - aliases_applied=[], - ) - - kinds = {item["kind"] for item in diagnostics} - assert "cppobj_hint" in kinds - assert "forward_declaration_hint" in kinds - - -def test_type_declare_detects_forward_declared_opaque_by_value_member() -> None: - chunks = type_declare._parse_declaration_chunks( - "struct Missing; typedef struct wrapper_bad { struct Missing value; } wrapper_bad;" - )[0] - - blocking = type_declare._opaque_by_value_members(chunks[1], earlier_chunks=chunks[:1]) - - assert blocking == [{"type_name": "Missing", "member_name": "value"}] - - -def test_type_declare_bisect_isolates_first_failing_declaration() -> None: - chunks = type_declare._parse_declaration_chunks( - "typedef struct good_one { int value; } good_one;" - "struct Missing;" - "typedef struct wrapper_bad { struct Missing value; } wrapper_bad;" - )[0] - - class FakeUndo: - def create_undo_point(self, **_kwargs) -> bool: - return True - - def perform_undo(self) -> bool: - return True - - class FakeIdaTypeInf: - PT_REPLACE = 1 - - @staticmethod - def idc_parse_types(decl: str, _flags: int) -> int: - return 1 if "wrapper_bad" in decl else 0 - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_undo": - return FakeUndo() - if name == "ida_typeinf": - return FakeIdaTypeInf() - raise AssertionError(name) - - payload = type_declare._bisect_type_declarations(FakeRuntime(), chunks, replace=False, clang=False) - - assert payload["supported"] is True - assert payload["failing_declaration"]["index"] == 3 - assert payload["blocking_members"] == [{"type_name": "Missing", "member_name": "value"}] - - -def test_type_declare_clang_uses_srclang_parser_ext() -> None: - calls: list[tuple[str, object, str, int]] = [] - - class FakeIdaTypeInf: - HTI_DCL = 0x400 - HTI_SEMICOLON = 0x200000 - HTI_RELAXED = 0x80000 - - class FakeIdaSrclang: - @staticmethod - def parse_decls_with_parser_ext(parser_name: str, til: object, decl: str, flags: int) -> int: - calls.append((parser_name, til, decl, flags)) - return 0 - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_srclang": - return FakeIdaSrclang() - raise AssertionError(name) - - errors = type_declare._parse_type_declarations( - FakeRuntime(), - "struct ns::Widget { int value; };", - replace=False, - clang=True, - ) - - assert errors == 0 - assert calls == [("clang", None, "struct ns::Widget { int value; };", 0x280400)] - - -def test_type_declare_check_uses_parse_decls_test_flag() -> None: - calls: list[tuple[object, str, object, int]] = [] - - class FakeIdaTypeInf: - HTI_DCL = 0x400 - HTI_SEMICOLON = 0x200000 - HTI_RELAXED = 0x80000 - HTI_TST = 0x20 - - @staticmethod - def parse_decls(til: object, decl: str, printer: object, flags: int) -> int: - calls.append((til, decl, printer, flags)) - return 0 - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_typeinf": - return FakeIdaTypeInf() - raise AssertionError(name) - - errors = type_declare._test_type_declarations( - FakeRuntime(), - "struct ns::Widget { int value; };", - replace=False, - clang=False, - ) - - assert errors == 0 - assert calls == [(None, "struct ns::Widget { int value; };", None, 0x280420)] - - -def test_type_declare_check_clang_runs_parser_under_undo() -> None: - calls: list[tuple[object, ...]] = [] - - class FakeUndo: - @staticmethod - def create_undo_point(**kwargs) -> bool: - calls.append(("undo_create", kwargs["action_name"], kwargs["label"])) - return True - - @staticmethod - def perform_undo() -> bool: - calls.append(("undo",)) - return True - - class FakeIdaTypeInf: - HTI_DCL = 0x400 - HTI_SEMICOLON = 0x200000 - HTI_RELAXED = 0x80000 - HTI_TST = 0x20 - - class FakeIdaSrclang: - @staticmethod - def parse_decls_with_parser_ext(parser_name: str, til: object, decl: str, flags: int) -> int: - calls.append(("parse", parser_name, til, decl, flags)) - return 0 - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_undo": - return FakeUndo() - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_srclang": - return FakeIdaSrclang() - raise AssertionError(name) - - errors = type_declare._test_type_declarations( - FakeRuntime(), - "struct ns::Widget { int value; };", - replace=False, - clang=True, - ) - - assert errors == 0 - assert calls == [ - ("undo_create", "idac_type_check_clang", "idac type check clang"), - ("parse", "clang", None, "struct ns::Widget { int value; };", 0x280420), - ("undo",), - ] - - -def test_type_declare_clang_reports_unavailable_parser() -> None: - class FakeIdaTypeInf: - HTI_DCL = 0x400 - HTI_SEMICOLON = 0x200000 - - class FakeIdaSrclang: - @staticmethod - def parse_decls_with_parser_ext(parser_name: str, til: object, decl: str, flags: int) -> int: - assert parser_name == "clang" - assert til is None - assert decl == "struct Widget { int value; };" - assert flags == 0x200400 - return -1 - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_srclang": - return FakeIdaSrclang() - raise AssertionError(name) - - with pytest.raises(IdaOperationError, match="clang parser is unavailable"): - type_declare._parse_type_declarations( - FakeRuntime(), - "struct Widget { int value; };", - replace=False, - clang=True, - ) - - -def test_type_declare_clang_replace_deletes_existing_types_before_parse() -> None: - calls: list[tuple[object, ...]] = [] - - class FakeIdaUndo: - @staticmethod - def create_undo_point(**kwargs) -> bool: - calls.append(("undo_create", kwargs["action_name"], kwargs["label"])) - return True - - @staticmethod - def perform_undo() -> bool: - calls.append(("undo",)) - return True - - class FakeIdaTypeInf: - HTI_DCL = 0x400 - HTI_SEMICOLON = 0x200000 - NTF_TYPE = 0x1 - - @staticmethod - def del_named_type(_til: object, name: str, flags: int) -> bool: - calls.append(("delete", name, flags)) - return True - - class FakeIdaSrclang: - @staticmethod - def parse_decls_with_parser_ext(parser_name: str, til: object, decl: str, flags: int) -> int: - calls.append(("parse", parser_name, til, decl, flags)) - return 0 - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_undo": - return FakeIdaUndo() - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_srclang": - return FakeIdaSrclang() - raise AssertionError(name) - - @staticmethod - def find_named_type(name: str): - return object() if name == "Widget" else None - - errors = type_declare._parse_type_declarations( - FakeRuntime(), - "typedef struct Widget { int value; } Widget;", - replace=True, - clang=True, - ) - - assert errors == 0 - assert calls == [ - ("undo_create", "idac_type_declare_clang_replace", "idac type declare clang replace"), - ("delete", "Widget", 0x1), - ("parse", "clang", None, "typedef struct Widget { int value; } Widget;", 0x200400), - ] - - -def test_type_declare_clang_replace_restores_deleted_types_on_parse_error() -> None: - calls: list[tuple[object, ...]] = [] - - class FakeIdaUndo: - @staticmethod - def create_undo_point(**_kwargs) -> bool: - calls.append(("undo_create",)) - return True - - @staticmethod - def perform_undo() -> bool: - calls.append(("undo",)) - return True - - class FakeIdaTypeInf: - HTI_DCL = 0x400 - HTI_SEMICOLON = 0x200000 - NTF_TYPE = 0x1 - - @staticmethod - def del_named_type(_til: object, name: str, flags: int) -> bool: - calls.append(("delete", name, flags)) - return True - - class FakeIdaSrclang: - @staticmethod - def parse_decls_with_parser_ext(parser_name: str, til: object, decl: str, flags: int) -> int: - calls.append(("parse", parser_name, til, decl, flags)) - return 2 - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_undo": - return FakeIdaUndo() - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_srclang": - return FakeIdaSrclang() - raise AssertionError(name) - - @staticmethod - def find_named_type(name: str): - return object() if name == "Widget" else None - - errors = type_declare._parse_type_declarations( - FakeRuntime(), - "typedef struct Widget { int value; } Widget;", - replace=True, - clang=True, - ) - - assert errors == 2 - assert calls == [ - ("undo_create",), - ("delete", "Widget", 0x1), - ("parse", "clang", None, "typedef struct Widget { int value; } Widget;", 0x200400), - ("undo",), - ] - - -def test_type_declare_clang_bisect_returns_structured_unavailable_result() -> None: - class FakeIdaTypeInf: - HTI_DCL = 0x400 - HTI_SEMICOLON = 0x200000 - - class FakeIdaSrclang: - @staticmethod - def parse_decls_with_parser_ext(parser_name: str, til: object, decl: str, flags: int) -> int: - assert parser_name == "clang" - assert til is None - assert decl == "struct Widget { int value; };" - assert flags == 0x200400 - return -1 - - class FakeUndo: - def create_undo_point(self, **_kwargs) -> bool: - return True - - def perform_undo(self) -> bool: - return True - - class FakeRuntime: - def mod(self, name: str): - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_srclang": - return FakeIdaSrclang() - if name == "ida_undo": - return FakeUndo() - raise AssertionError(name) - - @staticmethod - def list_named_types() -> list[dict[str, object]]: - return [{"name": "Existing", "decl": "struct Existing;"}] - - chunks = type_declare._parse_declaration_chunks("struct Widget { int value; };")[0] - errors, before, after, bisect = type_declare._apply_type_declarations_with_optional_bisect( - FakeRuntime(), - "struct Widget { int value; };", - replace=False, - clang=True, - chunks=chunks, - bisect_requested=True, - ) - - assert errors == 1 - assert before == {"Existing": "struct Existing;"} - assert after == before - assert bisect == { - "requested": True, - "supported": False, - "mode": "ordered_prefix", - "declaration_count": 1, - "message": "clang parser is unavailable for type declare", - "diagnostics": [{"kind": "bisect_unavailable", "message": "clang parser is unavailable for type declare"}], - } - - -def test_local_rename_reports_success_when_readback_fails(monkeypatch) -> None: - class FakeHexrays: - MLI_NAME = 1 - - class lvar_saved_info_t: - def __init__(self) -> None: - self.ll = None - self.name = "" - - def modify_user_lvar_info(self, func_ea: int, kind: int, info: object) -> bool: - assert func_ea == 0x401000 - assert kind == self.MLI_NAME - assert info.name == "sum_value" - return True - - class FakeRuntime(IdaRuntime): - def function_ea(self, identifier: str) -> int: - assert identifier == "main" - return 0x401000 - - def require_hexrays(self) -> FakeHexrays: - return FakeHexrays() - - monkeypatch.setattr(locals, "_select_local", lambda runtime, func_ea, selector: locals.SelectedLocal("v4", "loc")) - monkeypatch.setattr( - locals, - "_local_list_result", - lambda runtime, func_ea: (_ for _ in ()).throw(RuntimeError("decompiler refresh failed")), - ) - - with pytest.raises( - IdaOperationError, - match="failed to read back locals: decompiler refresh failed", - ): - _run_op( - "local_rename", - FakeRuntime(), - {"identifier": "main", "old_name": "v4", "new_name": "sum_value"}, - ) - - -def test_local_name_from_selector_rejects_multiple_stable_selector_kinds(monkeypatch) -> None: - with pytest.raises( - IdaOperationError, - match="--local-id and --index are mutually exclusive", - ): - locals._parse_local_selector( - {"index": 0, "local_id": "stack(16)@0x401000"}, - name_key="old_name", - ) - - -def test_resolve_lvar_selection_uses_stable_locator_for_index_selector() -> None: - class FakeLocation: - pass - - class FakeLvar: - def __init__(self) -> None: - self.name = "v4" - self.defea = 0x401000 - self.location = FakeLocation() - - def is_stk_var(self) -> bool: - return False - - class FakeCfunc: - def get_lvars(self) -> list[FakeLvar]: - return [FakeLvar()] - - class FakeHexrays: - class lvar_locator_t: - def __init__(self) -> None: - self.defea = 0 - self.location = None - - def decompile(self, func_ea: int) -> FakeCfunc: - assert func_ea == 0x401000 - return FakeCfunc() - - class FakeRuntime: - def require_hexrays(self) -> FakeHexrays: - return FakeHexrays() - - selected = locals._select_local( - FakeRuntime(), - 0x401000, - locals._parse_local_selector({"index": 0}, name_key="old_name"), - ) - - assert selected.name == "v4" - assert selected.locator.defea == 0x401000 - assert isinstance(selected.locator.location, FakeLocation) - - -def test_resolve_lvar_selection_allows_name_hint_with_stable_selector() -> None: - class FakeLocation: - pass - - class FakeLvar: - def __init__(self) -> None: - self.name = "v4" - self.defea = 0x401000 - self.location = FakeLocation() - - def is_stk_var(self) -> bool: - return False - - class FakeCfunc: - def get_lvars(self) -> list[FakeLvar]: - return [FakeLvar()] - - class FakeHexrays: - class lvar_locator_t: - def __init__(self) -> None: - self.defea = 0 - self.location = None - - def decompile(self, func_ea: int) -> FakeCfunc: - assert func_ea == 0x401000 - return FakeCfunc() - - class FakeRuntime: - def require_hexrays(self) -> FakeHexrays: - return FakeHexrays() - - selected = locals._select_local( - FakeRuntime(), - 0x401000, - locals._parse_local_selector({"old_name": "v6", "index": 0}, name_key="old_name"), - ) - - assert selected.name == "v4" - assert selected.locator.defea == 0x401000 - - -def test_resolve_lvar_selection_accepts_local_id_text() -> None: - class FakeLocation: - def is_stkoff(self) -> bool: - return True - - def stkoff(self) -> int: - return -16 - - class FakeLvar: - def __init__(self) -> None: - self.name = "v4" - self.defea = 0x401000 - self.location = FakeLocation() - - def is_stk_var(self) -> bool: - return True - - class FakeCfunc: - def get_lvars(self) -> list[FakeLvar]: - return [FakeLvar()] - - class FakeHexrays: - class lvar_locator_t: - def __init__(self) -> None: - self.defea = 0 - self.location = None - - def decompile(self, func_ea: int) -> FakeCfunc: - assert func_ea == 0x401000 - return FakeCfunc() - - class FakeRuntime: - def require_hexrays(self) -> FakeHexrays: - return FakeHexrays() - - selected = locals._select_local( - FakeRuntime(), - 0x401000, - locals._parse_local_selector({"local_id": "stack(-16)@0X401000"}, name_key="old_name"), - ) - - assert selected.name == "v4" - assert selected.locator.defea == 0x401000 - - -def test_local_update_allows_unnamed_local_selected_by_stable_selector(monkeypatch) -> None: - class FakeLocation: - pass - - class FakeLvar: - def __init__(self) -> None: - self.name = "" - self.defea = 0x401000 - self.location = FakeLocation() - - class FakeCfunc: - def get_lvars(self) -> list[FakeLvar]: - return [FakeLvar()] - - class FakeHexrays: - MLI_NAME = 1 + class FakeHexrays: + MLI_NAME = 1 class lvar_locator_t: def __init__(self) -> None: @@ -1764,450 +463,246 @@ class lvar_saved_info_t: def __init__(self) -> None: self.ll = None self.name = "" - self.type = None - def decompile(self, func_ea: int) -> FakeCfunc: + @staticmethod + def locate_lvar(locator: object, func_ea: int, name: str) -> bool: assert func_ea == 0x401000 - return FakeCfunc() + assert name == "v4" + locator.defea = func_ea + locator.location = object() + return True def modify_user_lvar_info(self, func_ea: int, kind: int, info: object) -> bool: + nonlocal mutated assert func_ea == 0x401000 assert kind == self.MLI_NAME - assert info.name == "recovered_name" + assert info.name == "sum_value" + mutated = True return True - class FakeRuntime(IdaRuntime): - def function_ea(self, identifier: str) -> int: - assert identifier == "main" - return 0x401000 - - def require_hexrays(self) -> FakeHexrays: - return FakeHexrays() - - monkeypatch.setattr( - locals, - "_local_list_result", - lambda runtime, func_ea: locals.LocalListResult(function="main", address="0x401000", locals=()), - ) - - payload = _run_op( - "local_update", - FakeRuntime(), - {"identifier": "main", "index": 0, "new_name": "recovered_name"}, - ) - - assert payload["changed"] is True - - -def test_local_apply_plan_parser_accepts_stable_selector_items() -> None: - request = locals._parse_local_apply_plan( - { - "identifier": "main", - "items": [ - {"local_id": "stack(16)@0x401000", "rename": "count", "decl": "unsigned int count;"}, - {"selector": {"index": "3"}, "type": "uint64_t"}, - ], - } - ) - - assert request.identifier == "main" - assert request.items[0].selector.local_id == "stack(16)@0x401000" - assert request.items[0].new_name == "count" - assert request.items[0].decl == "unsigned int count;" - assert request.items[1].selector.index == 3 - assert request.items[1].type_text == "uint64_t" - - -def test_proto_set_parses_silently_and_applies_tinfo() -> None: - calls: list[tuple[object, ...]] = [] - tif = object() - - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_SEMICOLON = 0x4000 - TINFO_DEFINITE = 0x1 - PRTYPE_1LINE = 1 - - @staticmethod - def tinfo_t() -> object: - return tif - @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert out_tif is tif - assert til is None - calls.append(("parse", decl, flags)) - return True + def mark_cfunc_dirty(_func_ea: int, _close_views: bool) -> None: + return None @staticmethod - def apply_tinfo(ea: int, parsed_tif: object, flags: int) -> bool: - calls.append(("apply", ea, parsed_tif, flags)) - return True + def clear_cached_cfuncs() -> None: + return None @staticmethod - def print_type(ea: int, flags: int) -> str: - assert ea == 0x401000 - assert flags == 1 - return "void __fastcall target(void)" + def decompile(_func_ea: int): + if mutated: + raise RuntimeError("decompiler refresh failed") + return FakeCfunc() class FakeRuntime(IdaRuntime): - def function_ea(self, identifier: str) -> int: - assert identifier == "target" - return 0x401000 - - def mod(self, name: str) -> object: - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_name": - - class FakeIdaName: - @staticmethod - def get_name(_ea: int) -> str: - return "target" - - return FakeIdaName() - raise AssertionError(name) - - def find_named_type(self, name: str): - return object() - - payload = _run_op( - "proto_set", - FakeRuntime(), - {"identifier": "target", "decl": "void __fastcall target(void)", "propagate_callers": False}, - ) - - assert payload == { - "address": "0x401000", - "prototype": "void __fastcall target(void)", - "changed": True, - "callers_considered": 0, - "callers_updated": 0, - "callers_failed": 0, - } - assert calls == [ - ("parse", "void __fastcall target(void);", 0x4009), - ("apply", 0x401000, tif, 0x1), - ] - - -def test_proto_set_retries_with_relaxed_namespace_parse() -> None: - calls: list[tuple[str, int]] = [] - tif = object() - - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_RELAXED = 0x1000 - PT_SEMICOLON = 0x4000 - TINFO_DEFINITE = 0x1 - PRTYPE_1LINE = 1 - - @staticmethod - def tinfo_t() -> object: - return tif - - @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert til is None - assert out_tif is tif - calls.append((decl, flags)) - return flags == 0x5009 - - @staticmethod - def apply_tinfo(ea: int, parsed_tif: object, flags: int) -> bool: - assert ea == 0x401000 - assert parsed_tif is tif - assert flags == 0x1 - return True - - @staticmethod - def print_type(_ea: int, _flags: int) -> str: - return "ns::Type *__fastcall target(ns::Type *value)" + class FakeUndo(_SuccessfulUndo): + @staticmethod + def perform_undo() -> bool: + nonlocal mutated + mutated = False + return True - class FakeRuntime(IdaRuntime): def function_ea(self, identifier: str) -> int: - assert identifier == "target" + assert identifier == "main" return 0x401000 - def mod(self, name: str) -> object: - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_name": - - class FakeIdaName: - @staticmethod - def get_name(_ea: int) -> str: - return "target" - - return FakeIdaName() - raise AssertionError(name) - - def find_named_type(self, name: str): - return object() - - payload = _run_op( - "proto_set", - FakeRuntime(), - { - "identifier": "target", - "decl": "ns::Type *__fastcall target(ns::Type *value)", - "propagate_callers": False, - }, - ) - - assert payload["changed"] is True - assert calls == [ - ("ns::Type *__fastcall target(ns::Type *value);", 0x4009), - ("ns::Type *__fastcall target(ns::Type *value);", 0x5009), - ] - - -def test_proto_check_allows_successful_parse_with_heuristic_unknowns() -> None: - class FakeTif: - @staticmethod - def is_func() -> bool: - return True - - tif = FakeTif() - - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_SEMICOLON = 0x4000 - - @staticmethod - def tinfo_t() -> object: - return tif + def require_hexrays(self) -> FakeHexrays: + return FakeHexrays() @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert out_tif is tif - assert til is None - assert decl == "void __fastcall target(void (*cb)(int));" - assert flags == 0x4009 - return True - - class FakeRuntime(IdaRuntime): - def function_ea(self, identifier: str) -> int: - assert identifier == "target" - return 0x401000 - - def mod(self, name: str) -> object: - if name == "ida_typeinf": - return FakeIdaTypeInf() - raise AssertionError(name) - - def find_named_type(self, name: str): - assert name == "cb" - return None - - result = prototypes._proto_check( - OperationContext(runtime=FakeRuntime()), - prototypes.PrototypeCheckRequest( - identifier="target", - decl="void __fastcall target(void (*cb)(int))", - ), - ) - - assert result.success is True - assert result.parsed is True - assert result.is_function is True - assert result.arglocs_calculated is None - assert result.unknown_types == ("cb",) - assert result.diagnostics == () + def function_name(func_ea: int) -> str: + assert func_ea == 0x401000 + return "main" + def mod(self, name: str): + assert name == "ida_undo" + return self.FakeUndo() -def test_proto_set_optionally_propagates_to_callers() -> None: - tif = object() + with pytest.raises( + IdaOperationError, + match="failed to read back locals: decompiler refresh failed", + ): + _run_op( + "local_rename", + FakeRuntime(), + {"identifier": "main", "old_name": "v4", "new_name": "sum_value"}, + ) - class FakeInsn: - pass + assert mutated is False - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_SEMICOLON = 0x4000 - TINFO_DEFINITE = 0x1 - PRTYPE_1LINE = 1 - def __init__(self) -> None: - self.applied: list[int] = [] +def test_local_rename_rejects_multiple_stable_selector_kinds() -> None: + with pytest.raises( + IdaOperationError, + match="--local-id and --index are mutually exclusive", + ): + _run_op( + "local_rename", + object(), + { + "identifier": "main", + "new_name": "count", + "index": 0, + "local_id": "stack(16)@0x401000", + }, + ) - @staticmethod - def tinfo_t() -> object: - return tif +def test_local_update_allows_unnamed_local_selected_by_stable_selector() -> None: + class FakeLocation: @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert out_tif is tif - assert til is None - assert decl == "void __fastcall target(int value);" - assert flags == 0x4009 - return True + def is_stkoff() -> bool: + return False @staticmethod - def apply_tinfo(ea: int, parsed_tif: object, flags: int) -> bool: - assert ea == 0x401000 - assert parsed_tif is tif - assert flags == 0x1 + def is_reg1() -> bool: return True - def apply_callee_tinfo(self, call_ea: int, parsed_tif: object) -> bool: - assert parsed_tif is tif - self.applied.append(call_ea) - return call_ea != 0x402008 - @staticmethod - def print_type(ea: int, flags: int) -> str: - assert ea == 0x401000 - assert flags == 1 - return "void __fastcall target(int value)" - - ida_typeinf = FakeIdaTypeInf() + def is_reg2() -> bool: + return False - class FakeIdaUa: @staticmethod - def insn_t() -> FakeInsn: - return FakeInsn() + def reg1() -> int: + return 4 - @staticmethod - def decode_insn(_insn: FakeInsn, _ea: int) -> bool: - return True + class FakeLvar: + def __init__(self) -> None: + self.name = "" + self.defea = 0x401000 + self.location = FakeLocation() + self.tif = object() + self.is_arg_var = False + self.width = 4 - class FakeIdaIdp: @staticmethod - def is_call_insn(insn: FakeInsn) -> bool: - return getattr(insn, "ea", None) != 0x402010 - - class FakeRuntime(IdaRuntime): - def function_ea(self, identifier: str) -> int: - assert identifier == "target" - return 0x401000 - - def mod(self, name: str) -> object: - if name == "ida_typeinf": - return ida_typeinf - if name == "ida_name": - - class FakeIdaName: - @staticmethod - def get_name(_ea: int) -> str: - return "target" - - return FakeIdaName() - raise AssertionError(name) - - class idautils: - @staticmethod - def CodeRefsTo(ea: int, flow: int) -> list[int]: - assert ea == 0x401000 - assert flow == 0 - return [0x402000, 0x402008, 0x402010] + def is_stk_var() -> bool: + return False - class ida_ua(FakeIdaUa): - @staticmethod - def decode_insn(insn: FakeInsn, ea: int) -> bool: - insn.ea = ea - return True + class FakeCfunc: + def get_lvars(self) -> list[FakeLvar]: + return [FakeLvar()] - class ida_idp(FakeIdaIdp): - pass + class FakeHexrays: + MLI_NAME = 1 - def find_named_type(self, name: str): - return object() + def __init__(self, lvar: FakeLvar) -> None: + self.lvar = lvar - payload = _run_op( - "proto_set", - FakeRuntime(), - { - "identifier": "target", - "decl": "void __fastcall target(int value)", - "propagate_callers": True, - }, - ) + class lvar_locator_t: + def __init__(self) -> None: + self.defea = 0 + self.location = None - assert payload["changed"] is True - assert payload["callers_considered"] == 2 - assert payload["callers_updated"] == 1 - assert payload["callers_failed"] == 1 - assert ida_typeinf.applied == [0x402000, 0x402008] + class lvar_saved_info_t: + def __init__(self) -> None: + self.ll = None + self.name = "" + self.type = None + class lvar_uservec_t: + def __init__(self) -> None: + self.lvvec: list[object] = [] -def test_proto_set_reports_unknown_type_name() -> None: - class FakeIdaTypeInf: - PT_SIL = 0x1 - PT_VAR = 0x8 - PT_SEMICOLON = 0x4000 - PRTYPE_1LINE = 1 + def decompile(self, func_ea: int) -> FakeCfunc: + assert func_ea == 0x401000 + cfunc = FakeCfunc() + cfunc.get_lvars = lambda: [self.lvar] + return cfunc @staticmethod - def tinfo_t() -> object: - return object() + def restore_user_lvar_settings(_user_info: object, _func_ea: int) -> bool: + return False + + def modify_user_lvar_info(self, func_ea: int, kind: int, info: object) -> bool: + assert func_ea == 0x401000 + assert kind == self.MLI_NAME + assert info.name == "recovered_name" + self.lvar.name = info.name + return True @staticmethod - def parse_decl(_out_tif: object, til: object, decl: str, flags: int) -> bool: - assert til is None - assert decl == "void __fastcall target(eValueType value_type);" - assert flags == 0x4009 - return False + def mark_cfunc_dirty(_func_ea: int, _close_views: bool) -> None: + return None - def print_type(self, ea: int, flags: int) -> str: - raise AssertionError("print_type should not be reached on failure") + @staticmethod + def clear_cached_cfuncs() -> None: + return None class FakeRuntime(IdaRuntime): + def __init__(self) -> None: + self.hexrays = FakeHexrays(FakeLvar()) + def function_ea(self, identifier: str) -> int: - assert identifier == "target" + assert identifier == "main" return 0x401000 - def mod(self, name: str) -> object: - if name == "ida_typeinf": - return FakeIdaTypeInf() - if name == "ida_name": + def require_hexrays(self) -> FakeHexrays: + return self.hexrays - class FakeIdaName: - @staticmethod - def get_name(_ea: int) -> str: - return "target" + @staticmethod + def function_name(func_ea: int) -> str: + assert func_ea == 0x401000 + return "main" - return FakeIdaName() - raise AssertionError(name) + @staticmethod + def tinfo_decl(_tif: object, *, multi: bool) -> str: + return "int" - def find_named_type(self, name: str): - return None if name == "eValueType" else object() + @staticmethod + def mod(name: str): + assert name == "ida_undo" + return _SuccessfulUndo() - with pytest.raises(IdaOperationError, match="unknown type\\(s\\): eValueType"): + payload = _run_op( + "local_update", + FakeRuntime(), + {"identifier": "main", "index": 0, "new_name": "recovered_name"}, + ) + + assert payload["changed"] is True + assert payload["locals"][0]["name"] == "recovered_name" + + +def test_local_apply_plan_rejects_unknown_wire_fields() -> None: + with pytest.raises(IdaOperationError, match="unsupported field"): _run_op( - "proto_set", - FakeRuntime(), - {"identifier": "target", "decl": "void __fastcall target(eValueType value_type)"}, + "local_apply_plan", + object(), + {"identifier": "main", "items": [{"index": 3, "type_text": "uint64_t"}]}, ) -def test_proto_set_reports_generic_parse_failure() -> None: +def test_proto_check_allows_successful_parse_with_heuristic_unknowns() -> None: + class FakeTif: + @staticmethod + def is_func() -> bool: + return True + + @staticmethod + def get_func_details(_details: object, _flags: int) -> bool: + return True + + tif = FakeTif() + func_details = object() + class FakeIdaTypeInf: + GTD_CALC_ARGLOCS = 0 PT_SIL = 0x1 PT_VAR = 0x8 PT_SEMICOLON = 0x4000 - PRTYPE_1LINE = 1 @staticmethod - def tinfo_t() -> object: - return object() + def func_type_data_t() -> object: + return func_details @staticmethod - def parse_decl(_out_tif: object, til: object, decl: str, flags: int) -> bool: - assert til is None - assert decl == "void __fastcall target(ValueType value);" - assert flags == 0x4009 - return False + def tinfo_t() -> object: + return tif @staticmethod - def print_type(ea: int, flags: int) -> str: - assert ea == 0x401000 - assert flags == 1 - return "int __fastcall target(int value)" + def parse_decl(_out_tif: object, _til: object, _decl: str, _flags: int) -> bool: + return True class FakeRuntime(IdaRuntime): def function_ea(self, identifier: str) -> int: @@ -2217,25 +712,24 @@ def function_ea(self, identifier: str) -> int: def mod(self, name: str) -> object: if name == "ida_typeinf": return FakeIdaTypeInf() - if name == "ida_name": - - class FakeIdaName: - @staticmethod - def get_name(_ea: int) -> str: - return "target" - - return FakeIdaName() raise AssertionError(name) def find_named_type(self, name: str): - return object() + del name + return None - with pytest.raises(IdaOperationError, match="parser limitations"): - _run_op( - "proto_set", - FakeRuntime(), - {"identifier": "target", "decl": "void __fastcall target(ValueType value)"}, - ) + result = _run_op( + "proto_check", + FakeRuntime(), + {"identifier": "target", "decl": "void __fastcall target(void (*cb)(int))"}, + ) + + assert result["success"] is True + assert result["parsed"] is True + assert result["is_function"] is True + assert result["arglocs_calculated"] is True + assert result["unknown_types"] == ["cb"] + assert result["diagnostics"] == [] def test_proto_set_reports_apply_failure_after_successful_parse() -> None: @@ -2253,24 +747,15 @@ def tinfo_t() -> object: return tif @staticmethod - def parse_decl(out_tif: object, til: object, decl: str, flags: int) -> bool: - assert out_tif is tif - assert til is None - assert decl == "void __fastcall target(int value);" - assert flags == 0x4009 + def parse_decl(_out_tif: object, _til: object, _decl: str, _flags: int) -> bool: return True @staticmethod - def apply_tinfo(ea: int, parsed_tif: object, flags: int) -> bool: - assert ea == 0x401000 - assert parsed_tif is tif - assert flags == 0x1 + def apply_tinfo(_ea: int, _parsed_tif: object, _flags: int) -> bool: return False @staticmethod - def print_type(ea: int, flags: int) -> str: - assert ea == 0x401000 - assert flags == 1 + def print_type(_ea: int, _flags: int) -> str: return "int __fastcall target(int value)" class FakeRuntime(IdaRuntime): @@ -2289,6 +774,8 @@ def get_name(_ea: int) -> str: return "target" return FakeIdaName() + if name == "ida_undo": + return _SuccessfulUndo() raise AssertionError(name) def find_named_type(self, name: str): @@ -2302,33 +789,6 @@ def find_named_type(self, name: str): ) -def test_require_class_tinfo_explains_non_class_materialized_type() -> None: - class FakeRuntime: - def find_named_type(self, name: str): - assert name == "CMessaging" - return object() - - def is_class_tinfo(self, tif: object) -> bool: - return False - - def classify_tinfo(self, tif: object) -> str: - return "struct" - - def find_symbols(self, *, query: str | None = None): - assert query == "CMessaging" - return [ - {"name": "__ZTV10CMessaging", "is_function": False}, - {"name": "__ZN10CMessaging17SendInlineMessageEv", "is_function": True}, - ] - - with pytest.raises(IdaOperationError) as excinfo: - classes._require_class_tinfo(FakeRuntime(), "CMessaging") - message = str(excinfo.value) - assert "exists as a struct, but is not class-materialized" in message - assert "type class candidates --query CMessaging" in message - assert "symbol evidence:" in message - - def test_class_hierarchy_explains_non_class_materialized_type() -> None: class FakeRuntime: def list_named_classes(self): @@ -2344,8 +804,8 @@ def is_class_tinfo(self, tif: object) -> bool: def classify_tinfo(self, tif: object) -> str: return "struct" - def find_symbols(self, *, query: str | None = None): - assert query == "CMessaging" + def find_symbols(self, *, pattern: str | None = None, ignore_case: bool = False): + assert (pattern, ignore_case) == ("CMessaging", True) return [ {"name": "__ZTV10CMessaging", "is_function": False}, {"name": "__ZN10CMessaging17SendInlineMessageEv", "is_function": True}, @@ -2355,122 +815,97 @@ def find_symbols(self, *, query: str | None = None): _run_op("class_hierarchy", FakeRuntime(), {"name": "CMessaging"}) message = str(excinfo.value) assert "exists as a struct, but is not class-materialized" in message - assert "type class candidates --query CMessaging" in message - - -def test_symbol_evidence_swallows_recoverable_lookup_errors() -> None: - class FakeRuntime: - def find_symbols(self, *, query: str | None = None): - assert query == "CMessaging" - raise RuntimeError("temporary IDA lookup failure") - - assert classes._symbol_evidence(FakeRuntime(), "CMessaging") == [] - - -def test_symbol_evidence_reraises_nonrecoverable_lookup_errors() -> None: - class FakeRuntime: - def find_symbols(self, *, query: str | None = None): - assert query == "CMessaging" - raise TypeError("bad lookup") - - with pytest.raises(TypeError, match="bad lookup"): - classes._symbol_evidence(FakeRuntime(), "CMessaging") + assert "type class candidates CMessaging" in message -def test_class_summary_uses_requested_alias_for_name_and_decl() -> None: - runtime = IdaRuntime() - calls: list[tuple[str | None, bool]] = [] - - class FakeTif: +def test_class_vtable_runtime_lookup_uses_requested_alias_when_type_name_missing() -> None: + class AnonymousClassTif: @staticmethod def get_type_name() -> str: return "" + class FakeRuntime: @staticmethod - def get_size() -> int: - return 24 + def find_named_type(name: str): + return AnonymousClassTif() if name == "Alias" else None - runtime.class_base_names = lambda tif: ["Base"] - runtime.class_vtable_type_name = lambda tif: "Alias_vtbl" + @staticmethod + def is_class_tinfo(_tif: object) -> bool: + return True - def fake_tinfo_decl(tif, *, name=None, multi=True) -> str: - calls.append((name, multi)) - return f"struct {name}" if name else "struct " + @staticmethod + def get_named_type(_name: str) -> object: + return object() - runtime.tinfo_decl = fake_tinfo_decl + @staticmethod + def class_vtable_type_name(_tif: object) -> str: + return "Alias_vtbl" - payload = runtime.class_summary(FakeTif(), name="Alias", decl_multi=True) + @staticmethod + def class_runtime_vtable_identifier(_tif: object, *, name: str | None = None) -> str | None: + return "0x402000" if name == "Alias" else None - assert payload["name"] == "Alias" - assert payload["decl"] == "struct Alias" - assert calls == [("Alias", True)] + @staticmethod + def tinfo_decl(_tif: object, **_kwargs) -> str: + return "struct Alias_vtbl" + @staticmethod + def vtable_slot(offset_bits: int) -> int: + return offset_bits // 64 -def test_class_vtable_runtime_fallback_uses_requested_alias_when_type_name_missing( - monkeypatch, -) -> None: - class AnonymousClassTif: @staticmethod - def get_type_name() -> str: - return "" + def udt_members(_tif: object) -> list[object]: + return [] - looked_up_names: list[str] = [] - runtime = type( - "FakeRuntime", - (), - { - "find_named_type": staticmethod(lambda name: AnonymousClassTif() if name == "Alias" else None), - "is_class_tinfo": staticmethod(lambda tif: True), - "get_named_type": staticmethod(lambda name: object()), - "class_vtable_type_name": staticmethod(lambda tif: "Alias_vtbl"), - "class_runtime_vtable_identifier": staticmethod( - lambda tif, name=None: looked_up_names.append(name) or "0x402000" - ), - "tinfo_decl": staticmethod(lambda tif, **kwargs: "struct Alias_vtbl"), - "vtable_slot": staticmethod(lambda offset_bits: offset_bits // 64), - "udt_members": staticmethod(lambda tif: []), - }, - )() - monkeypatch.setattr( - classes, - "_raw_vtable_dump", - lambda runtime, identifier, slot_limit=64: { - "identifier": identifier, - "slot_limit": slot_limit, - }, - ) + @staticmethod + def resolve_address(identifier: str) -> int: + return int(identifier, 0) - payload = _run_op("class_vtable", runtime, {"name": "Alias", "runtime": True}) + @staticmethod + def pointer_size() -> int: + return 8 - assert looked_up_names == ["Alias"] - assert payload["runtime_vtable"] == {"identifier": "0x402000", "slot_limit": 64} + @staticmethod + def read_pointer(_ea: int) -> int: + return 0 + @staticmethod + def demangle_name(_name: str) -> None: + return None -def test_class_show_preserves_case_sensitive_name_lookup(monkeypatch) -> None: - tif = object() + @staticmethod + def mod(name: str): + class FakeIdaName: + @staticmethod + def get_name(_ea: int) -> str: + return "" - class FakeRuntime: - def find_named_type(self, name: str): - assert name == "MiXeDClass" - return tif + class FakeIdaBytes: + @staticmethod + def get_flags(_ea: int) -> int: + return 0 - def is_class_tinfo(self, resolved_tif: object) -> bool: - assert resolved_tif is tif - return True + @staticmethod + def is_code(_flags: int) -> bool: + return False + + class FakeIdaFuncs: + @staticmethod + def get_func(_ea: int) -> None: + return None - def class_summary(self, resolved_tif: object, *, name: str, decl_multi: bool) -> dict[str, object]: - assert resolved_tif is tif - assert name == "MiXeDClass" - assert decl_multi is True - return {"name": name, "decl": "struct MiXeDClass;"} + return { + "ida_bytes": FakeIdaBytes(), + "ida_funcs": FakeIdaFuncs(), + "ida_name": FakeIdaName(), + }[name] - monkeypatch.setattr(classes, "_flatten_class_fields", lambda runtime, resolved_tif, derived_only: []) + runtime = FakeRuntime() - payload = _run_op("class_show", FakeRuntime(), {"name": "MiXeDClass"}) + payload = _run_op("class_vtable", runtime, {"name": "Alias", "runtime": True}) - assert payload["name"] == "MiXeDClass" - assert payload["decl"] == "struct MiXeDClass;" - assert payload["members"] == [] + assert payload["runtime_vtable"]["identifier"] == "0x402000" + assert payload["runtime_vtable"]["table_address"] == "0x402000" def test_type_show_normalizes_unknown_size_to_none() -> None: @@ -2489,7 +924,6 @@ def classify_tinfo(self, tif: object) -> str: def tinfo_decl(self, tif: object, *, name: str, multi: bool) -> str: assert name == "OpaqueThing" - assert multi is True return "struct OpaqueThing;" def tinfo_members(self, tif: object) -> list[dict[str, object]]: @@ -2501,7 +935,75 @@ def tinfo_members(self, tif: object) -> list[dict[str, object]]: assert payload["size_known"] is False -def test_enum_member_rename_reports_success_when_readback_fails(monkeypatch) -> None: +def test_type_deps_uses_local_ordinal_and_dependency_export() -> None: + fake_type = Mock() + fake_type.get_ordinal.return_value = 17 + fake_type.get_til.return_value = None + + class FakeTextSink: + pass + + class FakeIdaTypeInf: + PDF_INCL_DEPS = 0x1 + PDF_DEF_FWD = 0x2 + text_sink_t = FakeTextSink + + @staticmethod + def print_decls(sink: object, til: object, ordinals: list[int], flags: int) -> int: + del til, ordinals, flags + sink._print("struct Dependency;\nstruct Widget { Dependency *value; };\n") + return 2 + + class FakeRuntime: + @staticmethod + def get_named_type(name: str): + assert name == "Widget" + return fake_type + + @staticmethod + def classify_tinfo(_tif: object) -> str: + return "struct" + + @staticmethod + def mod(name: str) -> FakeIdaTypeInf: + assert name == "ida_typeinf" + return FakeIdaTypeInf() + + payload = _run_op("type_deps", FakeRuntime(), {"name": "Widget"}) + + assert payload == { + "name": "Widget", + "kind": "struct", + "decl": "struct Dependency;\nstruct Widget { Dependency *value; };", + "dependencies_included": True, + } + + +def test_type_deps_rejects_named_type_without_local_ordinal() -> None: + class FakeType: + @staticmethod + def get_ordinal() -> int: + return 0 + + class FakeRuntime: + @staticmethod + def get_named_type(_name: str) -> FakeType: + return FakeType() + + @staticmethod + def classify_tinfo(_tif: object) -> str: + return "struct" + + @staticmethod + def mod(name: str) -> object: + assert name == "ida_typeinf" + return object() + + with pytest.raises(IdaOperationError, match="named type has no local ordinal: Widget"): + _run_op("type_deps", FakeRuntime(), {"name": "Widget"}) + + +def test_enum_member_rename_rolls_back_after_readback_failure() -> None: class FakeEnumTif: def __init__(self) -> None: self.persisted_name: str | None = None @@ -2530,16 +1032,24 @@ def tinfo_errstr(code: int) -> str: tif = FakeEnumTif() class FakeRuntime: - def mod(self, name: str) -> FakeIdaTypeInf: - assert name == "ida_typeinf" - return FakeIdaTypeInf() + class FakeUndo(_SuccessfulUndo): + @staticmethod + def perform_undo() -> bool: + tif.persisted_name = None + return True - monkeypatch.setattr(types, "_enum_type", lambda runtime, name: tif) - monkeypatch.setattr( - types, - "_enum_view", - lambda context, request: (_ for _ in ()).throw(RuntimeError("enum refresh failed")), - ) + def mod(self, name: str): + if name == "ida_typeinf": + return FakeIdaTypeInf() + if name == "ida_undo": + return self.FakeUndo() + raise AssertionError(name) + + def get_named_type(self, name: str, *, kind: str): + assert (name, kind) == ("Color", "enum") + if tif.persisted_name is not None: + raise RuntimeError("enum refresh failed") + return tif with pytest.raises( IdaOperationError, @@ -2551,28 +1061,118 @@ def mod(self, name: str) -> FakeIdaTypeInf: {"enum_name": "Color", "member_name": "RED", "new_name": "CRIMSON"}, ) - assert tif.persisted_name == "Color" + assert tif.persisted_name is None -def test_first_free_bookmark_slot_reports_full_range() -> None: - class FakeIdaMoves: - MAX_MARK_SLOT = 2 +class _FakeBookmarkPlace: + def __init__(self, ea: int = 0) -> None: + self.ea = ea - class FakeRuntime: - def mod(self, name: str): - assert name == "ida_moves" - return FakeIdaMoves() - - occupied = {0, 1, 2} - original = bookmarks._bookmark_state - try: - bookmarks._bookmark_state = lambda runtime, slot: bookmarks.BookmarkState( # type: ignore[assignment] - slot=slot, - present=slot in occupied, - address=None, - comment=None, - ) - with pytest.raises(IdaOperationError, match=r"no free bookmark slots remain \(0\.\.2\)"): - bookmarks._first_free_slot(FakeRuntime()) - finally: - bookmarks._bookmark_state = original # type: ignore[assignment] + +class _FakeBookmarkLocation: + def __init__(self) -> None: + self._place = _FakeBookmarkPlace() + + def set_place(self, place: _FakeBookmarkPlace) -> None: + self._place = place + + def place(self) -> _FakeBookmarkPlace: + return self._place + + +class _FakeBookmarkRuntime: + def __init__( + self, + *, + max_slot: int, + bookmarks: dict[int, tuple[int, str]] | None = None, + fail_read_after_write: bool = False, + ) -> None: + self.bookmarks = {} if bookmarks is None else dict(bookmarks) + self._newly_written: set[int] = set() + self._fail_read_after_write = fail_read_after_write + runtime = self + + class FakeBookmarks: + @staticmethod + def get(loc: _FakeBookmarkLocation, slot: int, _ud): + if runtime._fail_read_after_write and slot in runtime._newly_written: + raise RuntimeError("bookmark decode failed") + current = runtime.bookmarks.get(slot) + if current is None: + return None, None + ea, comment = current + loc.set_place(_FakeBookmarkPlace(ea)) + return comment, slot + + @staticmethod + def erase(_loc: _FakeBookmarkLocation, slot: int, _ud) -> bool: + existed = slot in runtime.bookmarks + runtime.bookmarks.pop(slot, None) + runtime._newly_written.discard(slot) + return existed + + class FakeIdaMoves: + MAX_MARK_SLOT = max_slot + bookmarks_t = FakeBookmarks() + lochist_entry_t = _FakeBookmarkLocation + + class FakePlaceT: + @staticmethod + def as_idaplace_t(place: _FakeBookmarkPlace) -> _FakeBookmarkPlace: + return place + + class FakeIdaKernwin: + place_t = FakePlaceT + + @staticmethod + def get_place_class_id(name: str) -> int: + assert name == "idaplace_t" + return 1 + + @staticmethod + def get_place_class_template(place_id: int) -> _FakeBookmarkPlace: + assert place_id == 1 + return _FakeBookmarkPlace() + + class FakeIdaIdc: + @staticmethod + def mark_position(ea: int, _lnnum: int, _x: int, _y: int, slot: int, comment: str) -> None: + runtime.bookmarks[slot] = (ea, comment) + runtime._newly_written.add(slot) + + self._mods = { + "ida_idc": FakeIdaIdc(), + "ida_kernwin": FakeIdaKernwin(), + "ida_moves": FakeIdaMoves(), + } + + def mod(self, name: str): + return self._mods[name] + + @staticmethod + def resolve_address(identifier: str) -> int: + return int(identifier, 0) + + +def test_bookmark_add_reports_full_slot_range() -> None: + runtime = _FakeBookmarkRuntime( + max_slot=2, + bookmarks={ + 0: (0x401000, "first"), + 1: (0x401010, "second"), + 2: (0x401020, "third"), + }, + ) + + with pytest.raises(IdaOperationError, match=r"no free bookmark slots remain \(0\.\.2\)"): + _run_op("bookmark_add", runtime, {"address": "0x401000"}) + + +def test_bookmark_add_preview_rollback_removes_slot_after_runner_readback_failure() -> None: + runtime = _FakeBookmarkRuntime(max_slot=2, fail_read_after_write=True) + + with pytest.raises(RuntimeError, match="bookmark decode failed"): + _run_op("bookmark_add", runtime, {"address": "0x401010", "comment": "new"}, preview=True) + + assert runtime.bookmarks == {} diff --git a/tests/test_output.py b/tests/test_output.py index 6d47354..ec95bea 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,15 +5,7 @@ import stat from pathlib import Path -from idac.output import resolve_output_format, write_output_result - - -def test_resolve_output_format_prefers_jsonl_suffix() -> None: - assert resolve_output_format("text", Path("rows.jsonl")) == "jsonl" - - -def test_resolve_output_format_honors_forced_format() -> None: - assert resolve_output_format("text", Path("rows.jsonl"), force_fmt=True) == "text" +from idac.output import write_output_result def test_write_output_result_renders_jsonl_rows() -> None: @@ -24,23 +16,19 @@ def test_write_output_result_renders_jsonl_rows() -> None: stem="rows", ) - assert result.artifact is None assert [json.loads(line) for line in result.rendered.splitlines()] == [{"a": 1, "b": 2}, {"a": 3}] def test_write_output_result_infers_jsonl_artifact_format(tmp_path: Path) -> None: out_path = tmp_path / "rows.jsonl" - result = write_output_result( + write_output_result( [{"b": 2, "a": 1}, {"a": 3}], fmt="text", out_path=out_path, stem="rows", ) - assert result.rendered == "" - assert result.artifact is not None - assert result.artifact["format"] == "jsonl" assert [json.loads(line) for line in out_path.read_text(encoding="utf-8").splitlines()] == [ {"a": 1, "b": 2}, {"a": 3}, @@ -50,7 +38,7 @@ def test_write_output_result_infers_jsonl_artifact_format(tmp_path: Path) -> Non def test_write_output_result_can_force_text_for_json_suffix(tmp_path: Path) -> None: out_path = tmp_path / "combined.json" - result = write_output_result( + write_output_result( "int main(void) { return 0; }\n", fmt="text", out_path=out_path, @@ -58,44 +46,22 @@ def test_write_output_result_can_force_text_for_json_suffix(tmp_path: Path) -> N force_fmt=True, ) - assert result.rendered == "" - assert result.artifact is not None - assert result.artifact["format"] == "text" assert out_path.read_text(encoding="utf-8") == "int main(void) { return 0; }\n" -def test_write_output_result_atomically_replaces_existing_artifact(tmp_path: Path) -> None: - out_path = tmp_path / "decompile.txt" - out_path.write_text("old\n", encoding="utf-8") - - result = write_output_result( - "new decompile text\n", - fmt="text", - out_path=out_path, - stem="decompile", - ) - - assert result.rendered == "" - assert result.artifact is not None - assert out_path.read_text(encoding="utf-8") == "new decompile text\n" - assert not any(path.name.startswith(f".{out_path.name}.") for path in tmp_path.iterdir()) - - def test_write_output_result_preserves_symlink_output_target(tmp_path: Path) -> None: real_path = tmp_path / "real.txt" real_path.write_text("old\n", encoding="utf-8") link_path = tmp_path / "link.txt" link_path.symlink_to(real_path.name) - result = write_output_result( + write_output_result( "new through symlink\n", fmt="text", out_path=link_path, stem="decompile", ) - assert result.rendered == "" - assert result.artifact is not None assert link_path.is_symlink() assert os.readlink(link_path) == real_path.name assert real_path.read_text(encoding="utf-8") == "new through symlink\n" @@ -107,15 +73,13 @@ def test_write_output_result_preserves_existing_mode_bits(tmp_path: Path) -> Non out_path.write_text("old\n", encoding="utf-8") os.chmod(out_path, 0o664) - result = write_output_result( + write_output_result( "new\n", fmt="text", out_path=out_path, stem="decompile", ) - assert result.rendered == "" - assert result.artifact is not None assert stat.S_IMODE(out_path.stat().st_mode) == 0o664 @@ -123,7 +87,7 @@ def test_write_output_result_honors_umask_for_new_artifact(tmp_path: Path) -> No out_path = tmp_path / "fresh.txt" previous_umask = os.umask(0o027) try: - result = write_output_result( + write_output_result( "new\n", fmt="text", out_path=out_path, @@ -132,6 +96,4 @@ def test_write_output_result_honors_umask_for_new_artifact(tmp_path: Path) -> No finally: os.umask(previous_umask) - assert result.rendered == "" - assert result.artifact is not None assert stat.S_IMODE(out_path.stat().st_mode) == 0o640 diff --git a/tests/test_output_limits.py b/tests/test_output_limits.py index 088c6ce..bb4b626 100644 --- a/tests/test_output_limits.py +++ b/tests/test_output_limits.py @@ -6,6 +6,8 @@ import pytest +OVERSIZED_RESULT_CHARS = 1_000_000 + @pytest.mark.parametrize("output_format", ["json", "jsonl"]) def test_large_json_output_requires_out_flag( @@ -22,9 +24,9 @@ def test_large_json_output_requires_out_flag( "py", "exec", "--code", - "result = 'x' * 10050", + f"result = 'x' * {OVERSIZED_RESULT_CHARS}", "-c", - f"db:{database}", + str(database), "--format", output_format, ], @@ -37,13 +39,12 @@ def test_large_json_output_requires_out_flag( payload = json.loads(proc.stderr) assert payload["code"] == "output_too_large" assert payload["rerun_with_out"] is True + assert payload["chars"] > payload["limit"] if output_format == "jsonl": assert proc.stderr.count("\n") == 1 - else: - assert proc.stderr.count("\n") > 1 -def test_large_json_output_succeeds_with_out_flag( +def test_json_output_succeeds_with_out_flag( idac_cmd: list[str], idac_env: dict[str, str], copy_database, @@ -51,16 +52,16 @@ def test_large_json_output_succeeds_with_out_flag( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - out_path = tmp_path / "large.json" + out_path = tmp_path / "result.json" proc = subprocess.run( [ *idac_cmd, "py", "exec", "--code", - "result = 'x' * 10050", + "result = 'written'", "-c", - f"db:{database}", + str(database), "--format", "json", "--out", @@ -75,7 +76,7 @@ def test_large_json_output_succeeds_with_out_flag( assert proc.stdout == "" assert out_path.exists() written = json.loads(out_path.read_text(encoding="utf-8")) - assert written["result"] == "x" * 10050 + assert written["result"] == "written" def test_out_json_suffix_forces_json_output( @@ -86,16 +87,16 @@ def test_out_json_suffix_forces_json_output( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - out_path = tmp_path / "large.json" + out_path = tmp_path / "result.json" proc = subprocess.run( [ *idac_cmd, "py", "exec", "--code", - "result = 'x' * 10050", + "result = 'json by suffix'", "-c", - f"db:{database}", + str(database), "--out", str(out_path), ], @@ -107,7 +108,7 @@ def test_out_json_suffix_forces_json_output( assert proc.returncode == 0, proc.stderr or proc.stdout assert proc.stdout == "" written = json.loads(out_path.read_text(encoding="utf-8")) - assert written["result"] == "x" * 10050 + assert written["result"] == "json by suffix" def test_type_list_requires_pattern_or_out( @@ -123,7 +124,7 @@ def test_type_list_requires_pattern_or_out( "type", "list", "-c", - f"db:{database}", + str(database), "--format", "json", ], diff --git a/tests/test_paths.py b/tests/test_paths.py deleted file mode 100644 index 30e9c61..0000000 --- a/tests/test_paths.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import idac.paths as paths -from idac.paths import ( - bridge_registry_paths, - claude_skills_dir, - codex_skills_dir, - ensure_claude_skills_dir, - ensure_codex_skills_dir, - ensure_user_runtime_dir, - hcli_config_dir, - hcli_configured_install_dir, - ida_config_path, - ida_configured_install_dir, - plugin_bootstrap_install_path, - plugin_bootstrap_source_path, - plugin_install_dir, - plugin_runtime_package_install_dir, - plugin_runtime_package_source_dir, - plugin_source_dir, - skill_install_dir, - skill_install_dirs, - skill_reference_source_dir, - skill_source_dir, - user_runtime_dir, - workspace_template_source_dir, -) - - -def test_read_only_path_getters_do_not_create_directories(monkeypatch, tmp_path: Path) -> None: - idausr = tmp_path / ".idapro" - claude_home = tmp_path / ".claude" - codex_home = tmp_path / ".codex" - hcli_dir = tmp_path / "hcli" - runtime = tmp_path / "runtime" - monkeypatch.setenv("IDAUSR", str(idausr)) - monkeypatch.setenv("CLAUDE_HOME", str(claude_home)) - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - monkeypatch.setattr(paths, "hcli_config_dir", lambda: hcli_dir) - monkeypatch.setattr(paths, "runtime_dir", lambda: runtime) - - assert user_runtime_dir() == runtime - assert bridge_registry_paths() == [] - assert ida_config_path() == idausr / "ida-config.json" - assert ida_configured_install_dir() is None - assert hcli_configured_install_dir() is None - assert plugin_install_dir() == idausr / "plugins" / "idac_bridge" - assert plugin_bootstrap_install_path() == idausr / "plugins" / "idac_bridge_plugin.py" - assert plugin_runtime_package_install_dir() == idausr / "plugins" / "idac" - assert claude_skills_dir() == claude_home / "skills" - assert codex_skills_dir() == codex_home / "skills" - assert skill_install_dir(host="claude") == claude_home / "skills" / "idac" - assert skill_install_dir() == codex_home / "skills" / "idac" - assert skill_install_dirs() == [ - claude_home / "skills" / "idac", - codex_home / "skills" / "idac", - ] - - assert not idausr.exists() - assert not claude_home.exists() - assert not codex_home.exists() - assert not hcli_dir.exists() - - -def test_ensure_helpers_create_directories(monkeypatch, tmp_path: Path) -> None: - idausr = tmp_path / ".idapro" - claude_home = tmp_path / ".claude" - codex_home = tmp_path / ".codex" - runtime = tmp_path / "runtime" - monkeypatch.setenv("IDAUSR", str(idausr)) - monkeypatch.setenv("CLAUDE_HOME", str(claude_home)) - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - monkeypatch.setattr(paths, "runtime_dir", lambda: runtime) - - assert ensure_user_runtime_dir() == runtime - assert ensure_claude_skills_dir() == claude_home / "skills" - assert ensure_codex_skills_dir() == codex_home / "skills" - assert runtime.is_dir() - assert (claude_home / "skills").is_dir() - assert (codex_home / "skills").is_dir() - - -def test_source_paths_resolve_packaged_assets() -> None: - assert plugin_source_dir().name == "idac_bridge" - assert (plugin_source_dir() / "__init__.py").exists() - assert plugin_bootstrap_source_path().name == "idac_bridge_plugin.py" - assert plugin_bootstrap_source_path().exists() - assert plugin_runtime_package_source_dir().name == "idac" - assert skill_source_dir().name == "idac" - assert skill_source_dir().parent.name == "skills" - assert (skill_source_dir() / "SKILL.md").exists() - assert skill_reference_source_dir().name == "references" - assert (skill_reference_source_dir() / "cli.md").exists() - assert workspace_template_source_dir().name == "default" - assert (workspace_template_source_dir() / ".gitignore").exists() - - -def test_ida_configured_install_dir_reads_user_config(monkeypatch, tmp_path: Path) -> None: - idausr = tmp_path / ".idapro" - install_dir = tmp_path / "IDA Professional.app" / "Contents" / "MacOS" - monkeypatch.setenv("IDAUSR", str(idausr)) - idausr.mkdir() - ida_config_path().write_text( - json.dumps({"Paths": {"ida-install-dir": str(install_dir)}}), - encoding="utf-8", - ) - - assert ida_configured_install_dir() == install_dir - - -def test_hcli_configured_install_dir_reads_default_instance(monkeypatch, tmp_path: Path) -> None: - config_dir = tmp_path / "hcli" - install_dir = tmp_path / "IDA Professional 9.2.app" - monkeypatch.setattr(paths, "hcli_config_dir", lambda: config_dir) - config_dir.mkdir() - (config_dir / "config.json").write_text( - json.dumps( - { - "ida.default": "ida-pro-9.2", - "ida.instances": { - "ida-pro-9.1": str(tmp_path / "IDA Professional 9.1.app"), - "ida-pro-9.2": str(install_dir), - }, - } - ), - encoding="utf-8", - ) - - assert hcli_configured_install_dir() == install_dir - - -def test_hcli_config_dir_uses_macos_location_before_xdg(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setattr(paths.sys, "platform", "darwin") - monkeypatch.setattr(paths.Path, "home", lambda: tmp_path) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg-config")) - - assert hcli_config_dir() == tmp_path / "Library" / "Application Support" / "hcli" - - -def test_hcli_config_dir_uses_xdg_config_home_off_macos(monkeypatch, tmp_path: Path) -> None: - xdg_config_home = tmp_path / "xdg-config" - monkeypatch.setattr(paths.sys, "platform", "linux") - monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config_home)) - - assert hcli_config_dir() == xdg_config_home / "hcli" - - -def test_hcli_config_dir_uses_windows_location_before_xdg(monkeypatch, tmp_path: Path) -> None: - local_app_data = tmp_path / "LocalAppData" - monkeypatch.setattr(paths.sys, "platform", "win32") - monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg-config")) - - assert hcli_config_dir() == local_app_data / "hex-rays" / "hcli" - - -def test_hcli_configured_install_dir_ignores_missing_default(monkeypatch, tmp_path: Path) -> None: - config_dir = tmp_path / "hcli" - monkeypatch.setattr(paths, "hcli_config_dir", lambda: config_dir) - config_dir.mkdir() - (config_dir / "config.json").write_text( - json.dumps( - { - "ida.default": "ida-pro-9.3", - "ida.instances": {"ida-pro-9.2": str(tmp_path / "IDA Professional 9.2.app")}, - } - ), - encoding="utf-8", - ) - - assert hcli_configured_install_dir() is None diff --git a/tests/test_plugin_entry.py b/tests/test_plugin_entry.py deleted file mode 100644 index 49c5e0d..0000000 --- a/tests/test_plugin_entry.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -import importlib -import sys -from pathlib import Path -from types import SimpleNamespace - -from idac.version import VERSION - - -def _import_plugin_entry(monkeypatch): - sys.modules.pop("idac.ida_plugin.idac_bridge_plugin", None) - messages: list[str] = [] - signal_calls: list[tuple[object, object]] = [] - - class FakeFormBase: - class ChkGroupControl: - def __init__(self, names): - self.names = names - - def __init__(self, _form_text, controls): - self._controls = controls - self.loggingFlags = SimpleNamespace(value=0) - - def Compile(self): - return self, None - - def Execute(self): - return 1 - - def Free(self): - return None - - monkeypatch.setitem( - sys.modules, - "idaapi", - SimpleNamespace( - plugin_t=object, - PLUGIN_FIX=1, - PLUGIN_KEEP=2, - msg=lambda text: messages.append(text), - ), - ) - monkeypatch.setitem( - sys.modules, - "ida_kernwin", - SimpleNamespace( - MFF_WRITE=1, - execute_sync=lambda fn, _flags: fn(), - Form=FakeFormBase, - ), - ) - monkeypatch.setitem( - sys.modules, - "signal", - SimpleNamespace( - SIGPIPE=object(), - SIG_IGN=object(), - signal=lambda sig, handler: signal_calls.append((sig, handler)), - ), - ) - module = importlib.import_module("idac.ida_plugin.idac_bridge_plugin") - return module, messages, signal_calls - - -def test_plugin_init_reports_bridge_status(monkeypatch) -> None: - module, messages, _signal_calls = _import_plugin_entry(monkeypatch) - - class FakeBridgeService: - request_logging_enabled = False - response_logging_enabled = False - - def start(self) -> None: - return None - - def stop(self) -> None: - return None - - monkeypatch.setattr(module, "BridgeService", FakeBridgeService) - monkeypatch.setattr(module.os, "getpid", lambda: 4321) - monkeypatch.setattr(module, "socket_path", lambda pid: Path(f"/tmp/idac-bridge-{pid}.sock")) - monkeypatch.setattr(module, "registry_path", lambda pid: Path(f"/tmp/idac-bridge-{pid}.json")) - - plugin = module.IdacBridgePlugin() - - assert plugin.init() == module.idaapi.PLUGIN_KEEP - - plugin._emit_status() - - assert messages == [ - f"[idac] idac bridge v{VERSION} loaded\n" - f"[idac] GUI bridge running (v{VERSION}): " - "pid=4321, socket=/tmp/idac-bridge-4321.sock, " - "registry=/tmp/idac-bridge-4321.json\n", - f"[idac] idac bridge v{VERSION} loaded\n" - f"[idac] GUI bridge running (v{VERSION}): " - "pid=4321, socket=/tmp/idac-bridge-4321.sock, " - "registry=/tmp/idac-bridge-4321.json\n", - ] - - -def test_plugin_run_opens_dialog_applies_flags_and_emits_status(monkeypatch) -> None: - module, messages, _signal_calls = _import_plugin_entry(monkeypatch) - executed: list[int] = [] - freed: list[bool] = [] - - class FakeDialog: - def __init__(self): - self.loggingFlags = SimpleNamespace(value=0) - - def Compile(self): - return self, None - - def Execute(self): - executed.append(self.loggingFlags.value) - self.loggingFlags.value = module.REQUEST_LOG_FLAG | module.RESPONSE_LOG_FLAG - return 1 - - def Free(self): - freed.append(True) - - class FakeBridgeService: - def __init__(self) -> None: - self.request_logging_enabled = True - self.response_logging_enabled = False - - def start(self) -> None: - return None - - def stop(self) -> None: - return None - - def set_request_logging_enabled(self, enabled: bool) -> bool: - self.request_logging_enabled = enabled - return enabled - - def set_response_logging_enabled(self, enabled: bool) -> bool: - self.response_logging_enabled = enabled - return enabled - - monkeypatch.setattr(module, "_BridgeLoggingForm", FakeDialog) - monkeypatch.setattr(module, "BridgeService", FakeBridgeService) - monkeypatch.setattr(module.os, "getpid", lambda: 5555) - monkeypatch.setattr(module, "socket_path", lambda pid: Path(f"/tmp/idac-bridge-{pid}.sock")) - monkeypatch.setattr(module, "registry_path", lambda pid: Path(f"/tmp/idac-bridge-{pid}.json")) - - plugin = module.IdacBridgePlugin() - assert plugin.init() == module.idaapi.PLUGIN_KEEP - - plugin.run(0) - - assert executed == [module.REQUEST_LOG_FLAG] - assert freed == [True] - assert plugin._service is not None - assert plugin._service.request_logging_enabled is True - assert plugin._service.response_logging_enabled is True - assert messages[-1] == ( - f"[idac] idac bridge v{VERSION} loaded\n" - f"[idac] GUI bridge running (v{VERSION}): " - "pid=5555, socket=/tmp/idac-bridge-5555.sock, " - "registry=/tmp/idac-bridge-5555.json\n" - ) - - -def test_plugin_entry_ignores_sigpipe_when_available(monkeypatch) -> None: - module, _messages, signal_calls = _import_plugin_entry(monkeypatch) - - assert signal_calls == [(module.signal.SIGPIPE, module.signal.SIG_IGN)] diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py deleted file mode 100644 index bc3ec7c..0000000 --- a/tests/test_plugin_package.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import importlib -import sys -from types import SimpleNamespace - -from idac.metadata import BRIDGE_PLUGIN_NAME as EXPECTED_PLUGIN_NAME -from idac.ops.manifest import SUPPORTED_OPERATIONS as EXPECTED_SUPPORTED_OPERATIONS - - -def _pop_bridge_modules() -> dict[str, object]: - saved: dict[str, object] = {} - for name in list(sys.modules): - if name.startswith("idac.ida_plugin.idac_bridge") or name in {"ida_kernwin", "idaapi"}: - saved[name] = sys.modules.pop(name) - return saved - - -def _restore_bridge_modules(saved: dict[str, object]) -> None: - for name in list(sys.modules): - if name.startswith("idac.ida_plugin.idac_bridge") or name in {"ida_kernwin", "idaapi"}: - sys.modules.pop(name, None) - sys.modules.update(saved) - - -def test_plugin_package_import_is_ida_safe(monkeypatch) -> None: - saved_modules = _pop_bridge_modules() - monkeypatch.delenv("IDAUSR", raising=False) - - try: - module = importlib.import_module("idac.ida_plugin.idac_bridge") - - assert module.PLUGIN_NAME == EXPECTED_PLUGIN_NAME - assert module.SUPPORTED_OPERATIONS == EXPECTED_SUPPORTED_OPERATIONS - assert module.registry_path().name.startswith("idac-bridge-") - assert module.socket_path().name.startswith("idac-bridge-") - assert "idac.ida_plugin.idac_bridge.bridge" not in sys.modules - assert "idac.ida_plugin.idac_bridge.handlers" not in sys.modules - assert "ida_kernwin" not in sys.modules - assert "idaapi" not in sys.modules - finally: - _restore_bridge_modules(saved_modules) - - -def test_plugin_package_imports_bridge_module_only_on_attribute_access() -> None: - saved_modules = _pop_bridge_modules() - sys.modules["ida_kernwin"] = SimpleNamespace( - MFF_WRITE=1, - execute_sync=lambda fn, _flags: fn(), - msg=lambda _text: None, - ) - - try: - module = importlib.import_module("idac.ida_plugin.idac_bridge") - - assert "idac.ida_plugin.idac_bridge.bridge" not in sys.modules - bridge_service = module.BridgeService - - assert bridge_service.__name__ == "BridgeService" - assert "idac.ida_plugin.idac_bridge.bridge" in sys.modules - finally: - _restore_bridge_modules(saved_modules) diff --git a/tests/test_preview.py b/tests/test_preview.py index 6c5ccae..f9a0012 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -2,7 +2,24 @@ from pathlib import Path -from tests.helpers import preview_snapshot_cli2, run_idalib_json, run_preview_json +import pytest + +from tests.helpers import preview_snapshot, run_nexus_json, run_preview_json + + +@pytest.mark.parametrize("family", [[], ["struct"], ["enum"]]) +def test_unfiltered_type_list_preview_writes_complete_readback( + family: list[str], idac_cmd, idac_env, copy_database, tiny_database: Path, tmp_path: Path +) -> None: + database = copy_database(tiny_database) + proc, preview = run_preview_json( + idac_cmd, idac_env, database, tmp_path / "list-preview.json", "type", *family, "list" + ) + + assert proc.returncode == 0, proc.stderr or proc.stdout + assert isinstance(preview["result"], list) + assert preview["before"] == preview["after"] == preview["result"] + assert preview["undo"] == {"status": "noop", "mode": "read_only", "persisted": False} def test_comment_preview_does_not_persist( @@ -13,7 +30,7 @@ def test_comment_preview_does_not_persist( tmp_path: Path, ) -> None: database = copy_database(tiny_database) - result = preview_snapshot_cli2( + result = preview_snapshot( idac_cmd, idac_env, database, @@ -34,7 +51,7 @@ def test_comment_preview_does_not_persist( "comment": "preview comment", } assert preview["result"]["comment"] == "preview comment" - assert preview["undo"]["mode"] == "undo" + assert preview["undo"]["mode"] == "rollback" assert after == before @@ -56,7 +73,7 @@ def test_type_declare_preview_does_not_persist( "--decl", "typedef struct preview_record { int value; } preview_record;", ) - listed = run_idalib_json(idac_cmd, idac_env, database, "type", "list", "preview_record") + listed = run_nexus_json(idac_cmd, idac_env, database, "type", "list", "preview_record") assert proc.returncode == 0, proc.stderr or proc.stdout assert isinstance(preview, dict) diff --git a/tests/test_regressions.py b/tests/test_regressions.py deleted file mode 100644 index 973ff4a..0000000 --- a/tests/test_regressions.py +++ /dev/null @@ -1,811 +0,0 @@ -from __future__ import annotations - -import builtins -import importlib -import json -import os -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from idac import doctor -from idac.cli import build_parser -from idac.metadata import WIRE_PROTOCOL_VERSION, bridge_registry_payload -from idac.ops import OperationContext -from idac.ops.families import classes -from idac.ops.families.type_declare import _apply_type_aliases, _split_declarations -from idac.ops.helpers.params import parse_aliases -from idac.ops.runtime import IdaOperationError, IdaRuntime -from idac.ops.runtime_classes import find_vtable_symbol -from idac.transport import idalib, idalib_common, idalib_server -from idac.transport.idalib import IdaLibBackend, IdaLibInstance -from idac.transport.schema import RequestEnvelope -from idac.version import VERSION - - -def _runtime_with_modules(modules: dict[str, object]) -> IdaRuntime: - runtime = IdaRuntime() - runtime.mod = lambda name: modules[name] # type: ignore[method-assign] - return runtime - - -def test_runtime_mod_caches_imports(monkeypatch) -> None: - calls: list[str] = [] - - def fake_import_module(name: str) -> object: - calls.append(name) - return object() - - monkeypatch.setattr(importlib, "import_module", fake_import_module) - runtime = IdaRuntime() - - first = runtime.mod("ida_name") - second = runtime.mod("ida_name") - - assert first is second - assert calls == ["ida_name"] - - -def test_runtime_module_attrs_cache_via_mod_override() -> None: - calls: list[str] = [] - ida_name = object() - runtime = IdaRuntime() - runtime.mod = lambda name: (calls.append(name), ida_name)[1] # type: ignore[method-assign] - - assert runtime.ida_name is ida_name - assert runtime.ida_name is ida_name - assert calls == ["ida_name"] - - -def test_idalib_backend_reports_malformed_json(monkeypatch) -> None: - instance = IdaLibInstance( - pid=1234, - socket_path=Path("/tmp/idac-idalib-1234.sock"), - registry_path=Path("/tmp/idac-idalib-1234.json"), - database_path="/tmp/fixture.i64", - started_at=None, - meta={}, - ) - - monkeypatch.setattr( - "idac.transport.idalib._ensure_instance_for_database", - lambda database_path, *, timeout, run_auto_analysis, start_if_missing: (instance, True), - ) - monkeypatch.setattr( - "idac.transport.idalib._socket_request", - lambda socket_path, payload, *, timeout: (_ for _ in ()).throw( - RuntimeError("idalib daemon returned a non-object JSON payload") - ), - ) - - with pytest.raises(RuntimeError, match="idalib daemon returned a non-object JSON payload"): - IdaLibBackend().send(RequestEnvelope(op="database_info", backend="idalib", database="fixture.i64")) - - -def test_idalib_startup_failure_includes_startup_hint_without_detail() -> None: - message = idalib._format_startup_failure("/tmp/sample.i64") - - assert "idalib daemon failed to start for `/tmp/sample.i64`" in message - assert "license validation" in message - assert "Run `idac doctor`" in message - - -def test_idalib_startup_failure_preserves_child_detail() -> None: - message = idalib._format_startup_failure("/tmp/sample.i64", "Cannot continue without a valid license") - - assert message.endswith("Cannot continue without a valid license") - - -def test_candidate_ida_dirs_prefers_hcli_before_legacy_config(monkeypatch, tmp_path: Path) -> None: - hcli_app = tmp_path / "IDA Professional 9.2.app" - hcli_macos = hcli_app / "Contents" / "MacOS" - legacy_dir = tmp_path / "IDA Professional 9.1.app" / "Contents" / "MacOS" - env_dir = tmp_path / "IDA env" - fallback_dir = tmp_path / "IDA fallback" - - monkeypatch.setattr(idalib_common, "hcli_configured_install_dir", lambda: hcli_app) - monkeypatch.setattr(idalib_common, "ida_configured_install_dir", lambda: legacy_dir) - monkeypatch.setattr(idalib_common, "default_ida_install_dirs", lambda: [fallback_dir]) - monkeypatch.setenv("IDAC_IDA_INSTALL_DIR", str(env_dir)) - monkeypatch.delenv("IDADIR", raising=False) - monkeypatch.setattr(idalib_common.sys, "platform", "darwin") - - assert idalib_common.candidate_ida_dirs() == [env_dir, hcli_macos, legacy_dir, fallback_dir] - - -def test_candidate_ida_dirs_deduplicates_hcli_and_legacy_config(monkeypatch, tmp_path: Path) -> None: - install_dir = tmp_path / "IDA" - - monkeypatch.setattr(idalib_common, "hcli_configured_install_dir", lambda: install_dir) - monkeypatch.setattr(idalib_common, "ida_configured_install_dir", lambda: install_dir) - monkeypatch.setattr(idalib_common, "default_ida_install_dirs", lambda: [install_dir]) - monkeypatch.delenv("IDAC_IDA_INSTALL_DIR", raising=False) - monkeypatch.delenv("IDADIR", raising=False) - monkeypatch.setattr(idalib_common.sys, "platform", "linux") - - assert idalib_common.candidate_ida_dirs() == [install_dir] - - -def test_idalib_server_rejects_empty_operation() -> None: - with pytest.raises(Exception, match="idalib backend requires an operation name"): - idalib_server._parse_request({"version": WIRE_PROTOCOL_VERSION, "op": " ", "params": {}}) - - -def test_idalib_server_returns_json_for_expected_operation_failures() -> None: - service = object.__new__(idalib_server.IdaLibService) - service.database_path = "/tmp/fixture.i64" - service.exit_requested = False - service.list_targets = lambda: [] - service._build_registry = lambda: {"database_info": lambda params: (_ for _ in ()).throw(IdaOperationError("boom"))} - - response = service._dispatch({"version": WIRE_PROTOCOL_VERSION, "op": "database_info", "params": {}}) - assert response["ok"] is False - assert response["error"] == "boom" - - -def test_idalib_server_returns_json_for_unexpected_operation_failures() -> None: - service = object.__new__(idalib_server.IdaLibService) - service.database_path = "/tmp/fixture.i64" - service.exit_requested = False - service.list_targets = lambda: [] - service._build_registry = lambda: { - "database_info": lambda params: (_ for _ in ()).throw(ModuleNotFoundError("hexrays")) - } - - response = service._dispatch({"version": WIRE_PROTOCOL_VERSION, "op": "database_info", "params": {}}) - assert response["ok"] is False - assert "unexpected idalib server failure" in response["error"] - assert "hexrays" in response["error"] - - -def test_resolve_address_uses_ida_parser_for_numeric_hex_and_symbols() -> None: - seen: list[tuple[str, int, int]] = [] - runtime = _runtime_with_modules( - { - "idaapi": SimpleNamespace(BADADDR=0xFFFFFFFFFFFFFFFF), - "ida_kernwin": SimpleNamespace( - S2EAOPT_NOCALC=0x1, - str2ea_ex=lambda text, screen_ea, flags: ( - seen.append((text, screen_ea, flags)) - or { - "100000460": 0x100000460, - "main": 0x100000460, - }.get(text, 0xFFFFFFFFFFFFFFFF) - ), - ), - } - ) - - assert runtime.resolve_address("100000460") == 0x100000460 - assert runtime.resolve_address("main") == 0x100000460 - assert seen == [ - ("100000460", 0xFFFFFFFFFFFFFFFF, 0x1), - ("main", 0xFFFFFFFFFFFFFFFF, 0x1), - ] - - -def test_resolve_address_raises_for_unknown_identifier() -> None: - runtime = _runtime_with_modules( - { - "idaapi": SimpleNamespace(BADADDR=0xFFFFFFFFFFFFFFFF), - "ida_kernwin": SimpleNamespace( - S2EAOPT_NOCALC=0x1, - str2ea_ex=lambda text, screen_ea, flags: 0xFFFFFFFFFFFFFFFF, - ), - } - ) - - with pytest.raises(IdaOperationError, match="symbol not found: missing_symbol"): - runtime.resolve_address("missing_symbol") - - -def test_resolve_address_handles_none_from_ida_parser() -> None: - runtime = _runtime_with_modules( - { - "idaapi": SimpleNamespace(BADADDR=0xFFFFFFFFFFFFFFFF), - "ida_kernwin": SimpleNamespace( - S2EAOPT_NOCALC=0x1, - str2ea_ex=lambda text, screen_ea, flags: None, - ), - } - ) - - with pytest.raises(IdaOperationError, match="symbol not found: missing_symbol"): - runtime.resolve_address("missing_symbol") - - -def test_resolve_segment_ranges_matches_visible_name_prefix_and_suffix() -> None: - segments = [ - SimpleNamespace(start_ea=0x1000, end_ea=0x1100, visible_name="__TEXT:__text"), - SimpleNamespace(start_ea=0x2000, end_ea=0x2100, visible_name="__TEXT:__cstring"), - SimpleNamespace(start_ea=0x3000, end_ea=0x3100, visible_name="__DATA_CONST:__got"), - ] - runtime = _runtime_with_modules( - { - "ida_segment": SimpleNamespace( - get_first_seg=lambda: segments[0], - get_next_seg=lambda ea: next((item for item in segments if item.start_ea > ea), None), - get_visible_segm_name=lambda seg: seg.visible_name, - ), - "ida_ida": SimpleNamespace( - inf_get_min_ea=lambda: 0x1000, - inf_get_max_ea=lambda: 0x4000, - ), - } - ) - runtime.resolve_address = lambda text: int(text, 0) # type: ignore[method-assign] - - prefix_ranges = runtime.resolve_segment_ranges("__TEXT") - suffix_ranges = runtime.resolve_segment_ranges("__cstring") - - assert [(item.name, item.start_ea, item.end_ea) for item in prefix_ranges] == [ - ("__TEXT:__text", 0x1000, 0x1100), - ("__TEXT:__cstring", 0x2000, 0x2100), - ] - assert [(item.name, item.start_ea, item.end_ea) for item in suffix_ranges] == [ - ("__TEXT:__cstring", 0x2000, 0x2100), - ] - - -def test_resolve_segment_ranges_rejects_out_of_database_bounds() -> None: - segment = SimpleNamespace(start_ea=0x1000, end_ea=0x2000, visible_name="__TEXT:__text") - runtime = _runtime_with_modules( - { - "ida_segment": SimpleNamespace( - get_first_seg=lambda: segment, - get_next_seg=lambda ea: None, - get_visible_segm_name=lambda seg: seg.visible_name, - ), - "ida_ida": SimpleNamespace( - inf_get_min_ea=lambda: 0x1000, - inf_get_max_ea=lambda: 0x2000, - ), - } - ) - runtime.resolve_address = lambda text: int(text, 0) # type: ignore[method-assign] - - with pytest.raises(IdaOperationError, match="range start 0xfff is outside database bounds 0x1000-0x2000"): - runtime.resolve_segment_ranges("__TEXT", start="0xfff") - with pytest.raises(IdaOperationError, match="range end 0x2001 is outside database bounds 0x1000-0x2000"): - runtime.resolve_segment_ranges("__TEXT", end="0x2001") - - -def test_resolve_range_rejects_out_of_database_bounds() -> None: - runtime = _runtime_with_modules( - { - "ida_ida": SimpleNamespace( - inf_get_min_ea=lambda: 0x1000, - inf_get_max_ea=lambda: 0x2000, - ), - } - ) - runtime.resolve_address = lambda text: int(text, 0) # type: ignore[method-assign] - - with pytest.raises(IdaOperationError, match="range start 0xfff is outside database bounds 0x1000-0x2000"): - runtime.resolve_range(start="0xfff") - with pytest.raises(IdaOperationError, match="range end 0x2001 is outside database bounds 0x1000-0x2000"): - runtime.resolve_range(end="0x2001") - - -def test_resolve_function_falls_back_to_short_demangled_name() -> None: - runtime = _runtime_with_modules( - { - "idaapi": SimpleNamespace(BADADDR=0xFFFFFFFFFFFFFFFF), - "ida_kernwin": SimpleNamespace( - S2EAOPT_NOCALC=0x1, - str2ea_ex=lambda text, screen_ea, flags: 0xFFFFFFFFFFFFFFFF, - ), - "idautils": SimpleNamespace(Functions=lambda: [0x100000460]), - "ida_name": SimpleNamespace( - GN_DEMANGLED=0x1, - GN_SHORT=0x2, - GN_LONG=0x4, - GN_VISIBLE=0x8, - get_ea_name=lambda ea, flags=0: ( - "IIOReadPlugin::optInForBandedDecoding(uchar const*,ulong,char const*,uint)" - if flags & 0x2 - else ( - "bool IIOReadPlugin::optInForBandedDecoding(" - "unsigned char const*, unsigned long, char const*, unsigned int)" - ) - ), - demangle_name=lambda text, disable_mask: ( - "IIOReadPlugin::optInForBandedDecoding(unsigned char const*,unsigned long,char const*,unsigned int)" - ), - get_name=lambda ea: "__ZN13IIOReadPlugin23optInForBandedDecodingEv", - ), - "ida_funcs": SimpleNamespace( - get_func=lambda ea: SimpleNamespace(start_ea=ea) if ea == 0x100000460 else None - ), - } - ) - - assert int(runtime.resolve_function("IIOReadPlugin::optInForBandedDecoding").start_ea) == 0x100000460 - - -def test_resolve_function_rejects_ambiguous_short_demangled_name() -> None: - runtime = _runtime_with_modules( - { - "idaapi": SimpleNamespace(BADADDR=0xFFFFFFFFFFFFFFFF), - "ida_kernwin": SimpleNamespace( - S2EAOPT_NOCALC=0x1, - str2ea_ex=lambda text, screen_ea, flags: 0xFFFFFFFFFFFFFFFF, - ), - "idautils": SimpleNamespace(Functions=lambda: [0x1000, 0x2000]), - "ida_name": SimpleNamespace( - GN_DEMANGLED=0x1, - GN_SHORT=0x2, - GN_LONG=0x4, - GN_VISIBLE=0x8, - get_ea_name=lambda ea, flags=0: ( - "Foo::bar" - if flags & 0x2 - else { - 0x1000: "int Foo::bar(int)", - 0x2000: "double Foo::bar(double)", - }[ea] - ), - demangle_name=lambda text, disable_mask: text, - get_name=lambda ea: { - 0x1000: "__ZN3Foo3barEi", - 0x2000: "__ZN3Foo3barEd", - }[ea], - ), - "ida_funcs": SimpleNamespace( - get_func=lambda ea: SimpleNamespace(start_ea=ea) if ea in {0x1000, 0x2000} else None - ), - } - ) - - with pytest.raises(IdaOperationError, match="multiple functions matched demangled name"): - runtime.resolve_function("Foo::bar") - - -def test_display_function_name_uses_demangled_short_name_flags() -> None: - calls: list[tuple[int, int]] = [] - - def get_short_name(ea: int, flags: int = 0) -> str: - calls.append((ea, flags)) - return "Foo::bar()" if flags == 0xB else "__ZN3Foo3barEv" - - runtime = _runtime_with_modules( - { - "ida_funcs": SimpleNamespace(get_func_name=lambda ea: "__ZN3Foo3barEv"), - "ida_name": SimpleNamespace( - GN_DEMANGLED=0x1, - GN_SHORT=0x2, - GN_VISIBLE=0x8, - get_short_name=get_short_name, - ), - } - ) - - assert runtime.display_function_name(0x1000, demangle=True) == "Foo::bar()" - assert calls == [(0x1000, 0xB)] - - -def test_vtable_ea_prefers_ida_metadata() -> None: - runtime = _runtime_with_modules( - { - "idaapi": SimpleNamespace(BADADDR=0xFFFFFFFFFFFFFFFF), - "ida_typeinf": SimpleNamespace( - get_vftable_ea=lambda ordinal: { - 10: 0, - 11: 0x1234, - }.get(ordinal, 0), - ), - } - ) - tif = SimpleNamespace(get_ordinal=lambda: 10, get_final_ordinal=lambda: 11) - - assert runtime.vtable_ea(tif) == 0x1234 - - -def test_class_vtable_runtime_prefers_ida_metadata_before_symbol_scan(monkeypatch) -> None: - class_tif = SimpleNamespace(name="Handler_Stream") - runtime = SimpleNamespace( - find_named_type=lambda name: class_tif if name == "Handler_Stream" else None, - is_class_tinfo=lambda tif: True, - get_named_type=lambda name: SimpleNamespace(name=name), - class_vtable_type_name=lambda tif: "Handler_Stream_vtbl", - class_runtime_vtable_identifier=lambda tif, name=None: "0x401000", - tinfo_decl=lambda tif, **kwargs: "struct Handler_Stream_vtbl", - vtable_slot=lambda offset_bits: offset_bits // 64, - udt_members=lambda tif: [], - ) - monkeypatch.setattr( - "idac.ops.families.classes._raw_vtable_dump", - lambda runtime, identifier, slot_limit=64: { - "identifier": identifier, - "slot_limit": slot_limit, - }, - ) - - payload = classes._class_vtable( - OperationContext(runtime=runtime), - classes._parse_class_vtable({"name": "Handler_Stream", "runtime": True}), - ) - - assert payload["runtime_vtable"] == {"identifier": "0x401000", "slot_limit": 64} - - -def test_idalib_server_updates_database_path_after_save() -> None: - service = object.__new__(idalib_server.IdaLibService) - service.database_path = "/tmp/old.i64" - service.exit_requested = False - service.list_targets = lambda: [] - writes: list[str] = [] - service._write_registry = lambda: writes.append(service.database_path) - service._validate_db_save = lambda params: "/tmp/new.i64" - service._build_registry = lambda: {"db_save": lambda params: {"saved": True, "path": "/tmp/new.i64"}} - - response = service._dispatch( - {"version": WIRE_PROTOCOL_VERSION, "op": "db_save", "params": {"path": "/tmp/new.i64"}} - ) - - assert response["ok"] is True - normalized = str(Path("/tmp/new.i64").resolve(strict=False)) - assert service.database_path == normalized - assert writes == [normalized] - - -def test_idalib_backend_db_close_returns_already_closed_when_database_is_missing() -> None: - response = IdaLibBackend().send(RequestEnvelope(op="db_close", backend="idalib", database="/tmp/missing.i64")) - - assert response["ok"] is True - assert response["result"] == { - "closed": False, - "database": str(Path("/tmp/missing.i64").resolve(strict=False)), - "already_closed": True, - } - - -def test_cli_and_backend_share_alias_validation_contract() -> None: - parser = build_parser() - args = parser.parse_args(["type", "declare", "--decl", "typedef int value_t;", "--alias", "bad"]) - - with pytest.raises(ValueError, match="invalid alias `bad`; expected OLD=NEW"): - parse_aliases(args.alias) - - with pytest.raises(ValueError, match="invalid alias `bad`; expected OLD=NEW"): - parse_aliases(["bad"]) - - -def test_apply_type_aliases_preserves_namespace_qualified_names() -> None: - rewritten, aliases = _apply_type_aliases( - "struct ns::Foo { Foo inner; };", - [{"from": "Foo", "to": "Bar"}], - ) - - assert rewritten == "struct ns::Foo { Bar inner; };" - assert aliases == [{"from": "Foo", "to": "Bar", "count": 1}] - - -def test_apply_type_aliases_rewrites_globally_qualified_names() -> None: - rewritten, aliases = _apply_type_aliases( - "struct Use { ::Foo *global_ptr; Foo value; ns::Foo scoped; };", - [{"from": "Foo", "to": "Bar"}], - ) - - assert rewritten == "struct Use { ::Bar *global_ptr; Bar value; ns::Foo scoped; };" - assert aliases == [{"from": "Foo", "to": "Bar", "count": 2}] - - -def test_parse_vtable_dump_defaults_slot_limit_to_64() -> None: - request = classes._parse_vtable_dump({"identifier": "0x401000"}) - - assert request.identifier == "0x401000" - assert request.slot_limit == 64 - - -def test_find_vtable_symbol_requires_exact_class_name_match() -> None: - class FakeRuntime: - @staticmethod - def iter_names(): - return iter( - [ - (0x1000, "__ZTV6FooBar", "vtable for FooBar"), - (0x2000, "__ZTV3Foo", "vtable for Foo"), - ] - ) - - result = find_vtable_symbol(FakeRuntime(), "Foo") - - assert result == { - "address": "0x2000", - "name": "__ZTV3Foo", - "demangled": "vtable for Foo", - } - - -def test_find_vtable_symbol_accepts_msvc_vftable_demangling() -> None: - class FakeRuntime: - @staticmethod - def iter_names(): - return iter( - [ - (0x1000, "??_7Foo@@6B@", "FooBar::`vftable'"), - (0x2000, "??_7Bar@@6B@", "Foo::`vftable'"), - ] - ) - - result = find_vtable_symbol(FakeRuntime(), "Foo") - - assert result == { - "address": "0x2000", - "name": "??_7Bar@@6B@", - "demangled": "Foo::`vftable'", - } - - -def test_flatten_class_fields_propagates_inherited_base_offsets() -> None: - base = _NamedTif( - "Base", - [ - SimpleNamespace( - name="base_value", - offset=32, - size=32, - type=_NamedTif("int"), - ) - ], - ) - derived = _NamedTif( - "Derived", - [ - SimpleNamespace( - name="Base", - offset=64, - size=0, - type=_NamedTif("Base"), - is_baseclass=True, - ), - SimpleNamespace( - name="derived_value", - offset=128, - size=32, - type=_NamedTif("int"), - ), - ], - ) - - class FakeRuntime: - @staticmethod - def udt_members(tif): - udt: list[object] = [] - tif.get_udt_details(udt) - return udt - - @staticmethod - def member_has(member, attr: str) -> bool: - value = getattr(member, attr, False) - return bool(value() if callable(value) else value) - - @staticmethod - def find_named_type(name: str, kind=None): - del kind - return {"Base": base}.get(name) - - @staticmethod - def tinfo_decl(tif, *, name=None, multi=False) -> str: - del name, multi - return tif.dstr() - - fields = classes._flatten_class_fields(FakeRuntime(), derived, derived_only=False) - - assert [(field["name"], field["offset"]) for field in fields] == [ - ("base_value", 12), - ("derived_value", 16), - ] - - -def test_bridge_registry_payload_emits_fixed_contract_fields() -> None: - payload = bridge_registry_payload(pid=7, socket_path="/tmp/idac.sock", started_at="now") - - assert payload == { - "pid": 7, - "socket_path": "/tmp/idac.sock", - "plugin_name": "idac_bridge", - "plugin_version": VERSION, - "started_at": "now", - "backend": "gui", - } - - -def test_doctor_reports_broken_plugin_symlink(monkeypatch, tmp_path: Path) -> None: - source_dir = tmp_path / "plugin-src" - source_dir.mkdir() - bootstrap_source = tmp_path / "idac_bridge_plugin.py" - bootstrap_source.write_text("# bootstrap\n", encoding="utf-8") - - install_dir = tmp_path / "plugins" / "idac_bridge" - install_dir.parent.mkdir(parents=True) - install_dir.symlink_to(tmp_path / "missing-plugin", target_is_directory=True) - - install_bootstrap = tmp_path / "plugins" / "idac_bridge_plugin.py" - install_bootstrap.symlink_to(bootstrap_source) - - monkeypatch.setattr(doctor, "plugin_source_dir", lambda: source_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_source_path", lambda: bootstrap_source) - monkeypatch.setattr(doctor, "plugin_install_dir", lambda: install_dir) - monkeypatch.setattr(doctor, "plugin_bootstrap_install_path", lambda: install_bootstrap) - monkeypatch.setattr(doctor, "user_runtime_dir", lambda: tmp_path / "runtime") - monkeypatch.setattr(doctor, "bridge_registry_paths", lambda: []) - monkeypatch.setattr(doctor.gui, "list_instances", lambda: []) - monkeypatch.setattr(doctor.gui, "list_targets", lambda timeout=None, warnings=None: []) - - result = doctor.run_doctor(scope="gui", timeout=1.0) - - plugin_package = next(item for item in result["checks"] if item["name"] == "plugin_package") - assert plugin_package["status"] == "error" - assert "missing install path" in plugin_package["summary"] - - -def test_split_declarations_exits_block_comment_mode() -> None: - chunks = _split_declarations("struct A { int x; }; /* comment */ struct B { int y; };") - - assert [chunk["text"] for chunk in chunks] == [ - "struct A { int x; };", - "/* comment */ struct B { int y; };", - ] - - -class _FakeIdaTypeinf: - PRTYPE_TYPE = 1 - PRTYPE_DEF = 2 - PRTYPE_MULTI = 4 - PRTYPE_1LINE = 8 - - @staticmethod - def udt_type_data_t() -> list[object]: - return [] - - -class _BrokenAnonymousTif: - def get_type_name(self) -> str: - return "" - - def _print(self, *_args, **_kwargs) -> str: - raise RuntimeError("print failed") - - def dstr(self) -> str: - raise RuntimeError("dstr failed") - - -class _NamedTif: - def __init__(self, name: str, members: list[object] | None = None) -> None: - self._name = name - self._members = list(members or []) - - def get_type_name(self) -> str: - return self._name - - def _print(self, *_args, **_kwargs) -> str: - return self._name - - def dstr(self) -> str: - return self._name - - def get_udt_details(self, udt: list[object]) -> bool: - udt.extend(self._members) - return True - - -class _PointerType: - def __init__(self, pointed) -> None: - self._pointed = pointed - - def get_pointed_object(self): - return self._pointed - - -def test_tinfo_members_falls_back_to_unknown_for_unrenderable_anonymous_types() -> None: - runtime = IdaRuntime() - runtime.mod = lambda name: _FakeIdaTypeinf() if name == "ida_typeinf" else None # type: ignore[method-assign] - - container = _NamedTif( - "Container", - [ - SimpleNamespace( - name="field", - offset=0, - size=0, - type=_BrokenAnonymousTif(), - cmt="", - ) - ], - ) - - members = runtime.tinfo_members(container) - - assert members[0]["type"] == "" - - -def test_class_vtable_type_name_skips_unknown_pointed_type_and_uses_base_fallback( - monkeypatch, -) -> None: - runtime = IdaRuntime() - runtime.mod = lambda name: _FakeIdaTypeinf() if name == "ida_typeinf" else None # type: ignore[method-assign] - - derived = _NamedTif( - "Derived", - [ - SimpleNamespace( - name="__vftable", - type=_PointerType(_BrokenAnonymousTif()), - is_vftable=lambda: True, - ), - SimpleNamespace( - name="Base", - type=_NamedTif("Base"), - is_baseclass=lambda: True, - ), - ], - ) - base = _NamedTif("Base") - known_types = { - "Base": base, - "Base_vtbl": SimpleNamespace(), - } - monkeypatch.setattr(runtime, "find_named_type", lambda name, kind=None: known_types.get(name)) - - assert runtime.class_vtable_type_name(derived) == "Base_vtbl" - - -@pytest.mark.parametrize("exc_type", [RuntimeError, ValueError]) -def test_bootstrap_idapro_retries_candidate_installs_on_runtime_import_errors( - monkeypatch, tmp_path: Path, exc_type: type[Exception] -) -> None: - bad_root = tmp_path / "bad" - good_root = tmp_path / "good" - bad_python = bad_root / "idalib" / "python" - good_python = good_root / "idalib" / "python" - bad_python.mkdir(parents=True) - good_python.mkdir(parents=True) - - imported_module = SimpleNamespace(name="idapro") - attempts: list[str] = [] - real_import = builtins.__import__ - - def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - if name != "idapro": - return real_import(name, globals, locals, fromlist, level) - attempts.append(sys.path[0] if sys.path else "") - if sys.path and sys.path[0] == str(good_python): - return imported_module - raise exc_type("broken idapro") - - fake_environ = dict(os.environ) - fake_environ.pop("IDADIR", None) - monkeypatch.setattr(idalib_common, "candidate_ida_dirs", lambda: [bad_root, good_root]) - monkeypatch.setattr(idalib_common.os, "environ", fake_environ) - monkeypatch.delitem(sys.modules, "idapro", raising=False) - monkeypatch.setattr(sys, "path", list(sys.path)) - monkeypatch.setattr(builtins, "__import__", fake_import) - - result = idalib_common.bootstrap_idapro() - - assert result is imported_module - assert str(bad_python) in attempts - assert str(good_python) in attempts - - -def test_candidate_ida_dirs_reads_ida_config_after_explicit_env(monkeypatch, tmp_path: Path) -> None: - explicit_root = tmp_path / "explicit" - configured_root = tmp_path / "configured" - fallback_root = tmp_path / "fallback" - idausr = tmp_path / ".idapro" - idausr.mkdir() - (idausr / "ida-config.json").write_text( - json.dumps({"Paths": {"ida-install-dir": str(configured_root)}}), - encoding="utf-8", - ) - monkeypatch.setenv("IDAUSR", str(idausr)) - monkeypatch.setenv("IDAC_IDA_INSTALL_DIR", str(explicit_root)) - monkeypatch.delenv("IDADIR", raising=False) - monkeypatch.setattr(idalib_common, "hcli_configured_install_dir", lambda: None) - monkeypatch.setattr(idalib_common, "default_ida_install_dirs", lambda: [configured_root, fallback_root]) - - assert idalib_common.candidate_ida_dirs() == [explicit_root, configured_root, fallback_root] diff --git a/tests/test_remote_ops.py b/tests/test_remote_ops.py new file mode 100644 index 0000000..b2c368f --- /dev/null +++ b/tests/test_remote_ops.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +from idac.operations import MUTATING_OPERATIONS, REMOTE_OPERATIONS +from tests.remote_ops_harness import dispatch_with_runtime + +REMOTE_OPS_PATH = Path(__file__).parents[1] / "src/idac/remote_ops.py" + + +@pytest.fixture(scope="module") +def remote_ops() -> ModuleType: + module_name = "_idac_remote_ops_test" + spec = importlib.util.spec_from_file_location(module_name, REMOTE_OPS_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def test_remote_operation_inventory_matches_client_contract(remote_ops: ModuleType) -> None: + # REMOTE_OPERATIONS is a hand-maintained literal, so the dedup check is not redundant. + assert len(REMOTE_OPERATIONS) == len(set(REMOTE_OPERATIONS)) + assert set(remote_ops.SUPPORTED_OPERATIONS) == set(REMOTE_OPERATIONS) + assert set(remote_ops.MUTATING_OPERATIONS) == MUTATING_OPERATIONS + + +def test_dispatch_preview_is_json_native_and_rolls_back(remote_ops: ModuleType) -> None: + class FakeRuntime: + def __init__(self) -> None: + self.value = "before" + + def parse(params): + return str(params["value"]) + + def run(context, request): + context.runtime.value = request + return {"value": request} + + def capture(context, _request): + return {"value": context.runtime.value} + + def rollback(context, _request, before): + context.runtime.value = before["value"] + + operation = remote_ops.OperationSpec( + name="probe", + parse=parse, + run=run, + mutating=True, + preview=remote_ops.PreviewSpec( + capture_before=capture, + capture_after=capture, + rollback=rollback, + ), + ) + runtime = FakeRuntime() + result = dispatch_with_runtime( + runtime, + "probe", + {"value": "after"}, + preview=True, + module=remote_ops, + operation=operation, + ) + + assert result == { + "result": {"value": "after"}, + "before": {"value": "before"}, + "after": {"value": "after"}, + "persisted": False, + "preview": True, + "preview_mode": "rollback", + } + assert runtime.value == "before" + + +def test_dispatch_rejects_unknown_and_unsupported_preview(remote_ops: ModuleType) -> None: + with pytest.raises(remote_ops.IdaOperationError, match="preview must be a boolean"): + remote_ops.dispatch(object(), "database_info", {}, 1) + with pytest.raises(remote_ops.IdaOperationError, match="preview must be passed as the dispatch argument"): + remote_ops.dispatch(object(), "database_info", {"preview": True}, False) + with pytest.raises(remote_ops.IdaOperationError, match="does not accept replace or bisect"): + remote_ops.dispatch(object(), "type_declare_check", {"decl": "typedef int value;", "replace": True}, False) + + with pytest.raises(remote_ops.IdaOperationError, match="unsupported operation: missing"): + remote_ops.dispatch(object(), "missing", {}, False) + + with pytest.raises(remote_ops.IdaOperationError, match="preview is not supported"): + remote_ops.dispatch(object(), "reanalyze", {"identifier": "entry"}, True) + + +@pytest.mark.parametrize("raise_after_mutation", [False, True]) +def test_dispatch_rolls_back_failed_undo_backed_mutations(remote_ops: ModuleType, raise_after_mutation: bool) -> None: + class FakeUndo: + def __init__(self, runtime) -> None: + self.runtime = runtime + + def create_undo_point(self, **_kwargs) -> bool: + return True + + def perform_undo(self) -> bool: + self.runtime.value = "before" + return True + + class FakeRuntime: + def __init__(self) -> None: + self.value = "before" + + def mod(self, name: str) -> FakeUndo: + assert name == "ida_undo" + return FakeUndo(self) + + def run(context, _request): + context.runtime.value = "mutated" + if raise_after_mutation: + raise RuntimeError("readback failed") + return {"success": False, "errors": 1} + + operation = remote_ops.OperationSpec( + name="probe", + parse=lambda params: params, + run=run, + mutating=True, + preview=remote_ops.PreviewSpec( + capture_before=lambda context, _request: context.runtime.value, + capture_after=lambda context, _request: context.runtime.value, + use_undo=True, + ), + ) + runtime = FakeRuntime() + if raise_after_mutation: + with pytest.raises(RuntimeError, match="readback failed"): + dispatch_with_runtime(runtime, "probe", {}, module=remote_ops, operation=operation) + else: + assert dispatch_with_runtime(runtime, "probe", {}, module=remote_ops, operation=operation) == { + "success": False, + "errors": 1, + } + + assert runtime.value == "before" + + +def test_dispatch_undo_preview_restores_on_base_exception_and_runs_cleanup(remote_ops: ModuleType) -> None: + class AbortPreview(BaseException): + pass + + class FakeUndo: + def __init__(self, runtime) -> None: + self.runtime = runtime + + @staticmethod + def create_undo_point(**_kwargs) -> bool: + return True + + def perform_undo(self) -> bool: + self.runtime.value = "before" + return True + + class FakeRuntime: + def __init__(self) -> None: + self.value = "before" + self.cleaned_up = False + + def mod(self, name: str) -> FakeUndo: + assert name == "ida_undo" + return FakeUndo(self) + + def run(context, _request): + context.runtime.value = "mutated" + raise AbortPreview + + def cleanup(context, _request) -> None: + context.runtime.cleaned_up = True + raise RuntimeError("cleanup failed") + + operation = remote_ops.OperationSpec( + name="probe", + parse=lambda params: params, + run=run, + mutating=True, + preview=remote_ops.PreviewSpec( + capture_before=lambda context, _request: context.runtime.value, + capture_after=lambda context, _request: context.runtime.value, + cleanup=cleanup, + use_undo=True, + ), + ) + runtime = FakeRuntime() + + with pytest.raises(AbortPreview): + dispatch_with_runtime(runtime, "probe", {}, preview=True, module=remote_ops, operation=operation) + + assert runtime.value == "before" + assert runtime.cleaned_up is True + + +@pytest.mark.parametrize("failure_stage", ["runner", "after_capture"]) +def test_dispatch_manual_preview_rolls_back_primary_failures(remote_ops: ModuleType, failure_stage: str) -> None: + class FakeRuntime: + def __init__(self) -> None: + self.value = "before" + + def capture(context, _request): + if failure_stage == "after_capture" and context.runtime.value == "mutated": + raise RuntimeError("after capture failed") + return context.runtime.value + + def run(context, _request): + context.runtime.value = "mutated" + if failure_stage == "runner": + raise RuntimeError("runner failed") + return {"changed": True} + + def rollback(context, _request, before) -> None: + context.runtime.value = before + + operation = remote_ops.OperationSpec( + name="probe", + parse=lambda params: params, + run=run, + mutating=True, + preview=remote_ops.PreviewSpec(capture_before=capture, capture_after=capture, rollback=rollback), + ) + runtime = FakeRuntime() + + with pytest.raises(RuntimeError, match=r"runner failed|after capture failed"): + dispatch_with_runtime(runtime, "probe", {}, preview=True, module=remote_ops, operation=operation) + + assert runtime.value == "before" + + +def test_dispatch_manual_preview_reports_rollback_failure_with_primary_cause(remote_ops: ModuleType) -> None: + class FakeRuntime: + value = "before" + + def capture(context, _request): + if context.runtime.value == "mutated": + raise RuntimeError("after capture failed") + return context.runtime.value + + def run(context, _request): + context.runtime.value = "mutated" + return {"changed": True} + + def rollback(_context, _request, _before) -> None: + raise RuntimeError("rollback failed") + + operation = remote_ops.OperationSpec( + name="probe", + parse=lambda params: params, + run=run, + mutating=True, + preview=remote_ops.PreviewSpec(capture_before=capture, capture_after=capture, rollback=rollback), + ) + runtime = FakeRuntime() + + with pytest.raises(RuntimeError, match="rollback failed") as excinfo: + dispatch_with_runtime(runtime, "probe", {}, preview=True, module=remote_ops, operation=operation) + + assert isinstance(excinfo.value.__cause__, RuntimeError) + assert str(excinfo.value.__cause__) == "after capture failed" diff --git a/tests/test_renderers.py b/tests/test_renderers.py index aa4fcf5..e4c5660 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -1,77 +1,15 @@ from __future__ import annotations -from idac.cli2.renderers import ( - render_bookmarks, +from idac.cli.renderers import ( render_class_vtable, render_database_info, - render_function_frame, render_function_list, render_function_show, - render_segment_list, - render_target_list, render_type_declare, - render_vtable_dump, - render_workspace_init, render_xrefs, - renderer_registry_drift, ) -def test_render_target_list_formats_active_targets() -> None: - rendered = render_target_list( - [ - { - "selector": "target:active", - "module": "tiny", - "instance_pid": 1234, - "active": True, - } - ] - ) - - assert rendered == "target:active [active] (tiny, pid=1234)" - - -def test_render_function_frame_formats_member_suffixes() -> None: - rendered = render_function_frame( - { - "function": "main", - "address": "0x401000", - "frame_size": 32, - "members": [ - { - "offset": -8, - "kind": "local", - "name": "sum", - "type": "int", - "fp_offset": -16, - "is_special": True, - } - ], - } - ) - - assert rendered.splitlines() == [ - "main @ 0x401000", - "frame_size: 32", - "members:", - " -8 local sum int [fp=-16, special]", - ] - - -def test_render_bookmarks_formats_single_value_as_row() -> None: - rendered = render_bookmarks( - { - "slot": 2, - "present": True, - "address": "0x401000", - "comment": "entry", - } - ) - - assert rendered == "2 0x401000 entry" - - def test_render_database_info_includes_main_start_and_entry_addresses() -> None: rendered = render_database_info( { @@ -89,34 +27,9 @@ def test_render_database_info_includes_main_start_and_entry_addresses() -> None: } ) - assert rendered.splitlines() == [ - "path: /tmp/sample", - "database_path: /tmp/sample.i64", - "module: sample", - "processor: arm", - "bits: 64", - "base: 0x100000000", - "min_ea: 0x100000000", - "max_ea: 0x100001000", - "main_ea: 0x100000100", - "start_ea: 0x100000120", - "entry_ea: 0x100000200", - ] - - -def test_render_segment_list_formats_ranges() -> None: - rendered = render_segment_list( - [ - { - "name": "__TEXT:__text", - "start": "0x1000", - "end": "0x2000", - "size": 4096, - } - ] - ) - - assert rendered == "__TEXT:__text 0x1000-0x2000 size=4096" + assert "0x100000100" in rendered + assert "0x100000120" in rendered + assert "0x100000200" in rendered def test_render_function_list_uses_render_name_when_present() -> None: @@ -132,7 +45,8 @@ def test_render_function_list_uses_render_name_when_present() -> None: ] ) - assert rendered == "0x401000 .text Foo::bar()" + assert "Foo::bar()" in rendered + assert "__ZN3Foo3barEv" not in rendered def test_render_function_show_includes_display_name() -> None: @@ -147,13 +61,8 @@ def test_render_function_show_includes_display_name() -> None: } ) - assert rendered.splitlines() == [ - "__ZN3Foo3barEv @ 0x401000", - "display_name: Foo::bar()", - "prototype: void __fastcall Foo::bar(Foo *this)", - "size: 32", - "flags: 0x0", - ] + assert "__ZN3Foo3barEv" in rendered + assert "Foo::bar()" in rendered def test_render_xrefs_includes_normalized_kind_and_raw_type() -> None: @@ -170,10 +79,11 @@ def test_render_xrefs_includes_normalized_kind_and_raw_type() -> None: ] ) - assert rendered == "0x401020 | 0x401000 | call | Code_Near_Call | main" + assert "call" in rendered + assert "Code_Near_Call" in rendered -def test_render_type_declare_formats_bisect_and_diagnostics() -> None: +def test_render_type_declare_includes_bisect_diagnostics() -> None: rendered = render_type_declare( { "success": False, @@ -194,22 +104,13 @@ def test_render_type_declare_formats_bisect_and_diagnostics() -> None: } ) - assert rendered.splitlines() == [ - "success: False", - "errors: 1", - "replace: True", - "aliases: old->new x2", - "imported: alpha", - "replaced: none", - "bisect: declaration #3 at lines 4-5", - "blocking members: Missing value", - "diagnostics:", - "- line 4: bad token", - "- first failing declaration", - ] + assert "Missing" in rendered + assert "value" in rendered + assert "bad token" in rendered + assert "first failing declaration" in rendered -def test_render_class_vtable_and_vtable_dump_include_sections() -> None: +def test_render_class_vtable_includes_runtime_section() -> None: class_rendered = render_class_vtable( { "name": "Foo", @@ -223,70 +124,7 @@ def test_render_class_vtable_and_vtable_dump_include_sections() -> None: }, } ) - dump_rendered = render_vtable_dump( - { - "symbol": "__ZTV3Foo", - "abi": "itanium", - "table_address": "0x1000", - "slot_address": "0x1010", - "header": [{"index": 0, "name": "offset_to_top", "value": "0x0"}], - "members": [{"slot": 0, "target": "0x2000", "name": "sub_2000"}], - "stop_reason": "null_target", - } - ) - - assert class_rendered.splitlines() == [ - "Foo vtable=Foo_vtbl", - "struct Foo_vtbl;", - "members:", - " 0 f0 void (*)()", - "runtime:", - " symbol: __ZTV3Foo @ 0x1000", - " 0 sub_100", - ] - assert dump_rendered.splitlines() == [ - "__ZTV3Foo abi=itanium", - "table: 0x1000", - "slots: 0x1010", - "header:", - " 0: offset_to_top = 0x0", - "members:", - " 0 0x2000 sub_2000", - "stop_reason: null_target", - ] - - -def test_render_workspace_init_lists_created_directories_and_files() -> None: - rendered = render_workspace_init( - { - "display_destination": "workspace", - "created": [ - ".claude/", - ".claude/settings.json", - "references/", - "references/cli.md", - ".idac/", - ".idac/tmp/", - ], - "git": {"initialized": True}, - "next_steps": [], - } - ) - - assert rendered.splitlines() == [ - "Created workspace/", - " .claude/", - " .claude/settings.json", - " references/", - " references/cli.md", - " .idac/", - " .idac/tmp/", - "Initialized git repository.", - ] - - -def test_renderer_registry_stays_in_sync_with_supported_operations() -> None: - missing, extra = renderer_registry_drift() - - assert missing == [] - assert extra == [] + assert "Foo_vtbl" in class_rendered + assert "__ZTV3Foo" in class_rendered + assert "0x1000" in class_rendered + assert "sub_100" in class_rendered diff --git a/tests/test_search_bytes.py b/tests/test_search_bytes.py index dce8be9..6434c4e 100644 --- a/tests/test_search_bytes.py +++ b/tests/test_search_bytes.py @@ -4,15 +4,21 @@ import pytest -from idac.ops.base import OperationContext -from idac.ops.families.search import SearchBytesRequest, SearchMatch, _search_bytes -from idac.ops.runtime import IdaOperationError, IdaRuntime, SegmentRange +from idac import remote_ops +from tests.remote_ops_harness import dispatch_with_runtime + +IdaOperationError = remote_ops.IdaOperationError +IdaRuntime = remote_ops.IdaRuntime +SegmentRange = remote_ops.SegmentRange + + +def _run_search(runtime: IdaRuntime, params: dict[str, object]): + return dispatch_with_runtime(runtime, "search_bytes", params) class _CompiledPattern: - def __init__(self, pattern: str, ea: int) -> None: + def __init__(self, pattern: str) -> None: self.pattern = pattern - self.ea = ea def __len__(self) -> int: return 1 @@ -21,13 +27,12 @@ def __len__(self) -> int: class _CompiledBinpatVecFactory: def __init__(self, *, error: str | None = None) -> None: self.error = error - self.calls: list[tuple[int, str, int, int]] = [] def parse(self, ea: int, text: str, radix: int, strlit_encoding: int) -> _CompiledPattern: - self.calls.append((ea, text, radix, strlit_encoding)) + del ea, radix, strlit_encoding if self.error is not None: raise RuntimeError(self.error) - return _CompiledPattern(text, ea) + return _CompiledPattern(text) class _FakeIdaBytes: @@ -38,21 +43,15 @@ class _FakeIdaBytes: def __init__(self, *, matches: list[int] | None = None, parse_error: str | None = None) -> None: self.compiled_binpat_vec_t = _CompiledBinpatVecFactory(error=parse_error) self._matches = [] if matches is None else list(matches) - self.bin_search_calls: list[tuple[int, int, str, int, int]] = [] - self.find_bytes_called = False def bin_search(self, start_ea: int, end_ea: int, compiled, flags: int) -> tuple[int, int]: - self.bin_search_calls.append((start_ea, end_ea, compiled.pattern, compiled.ea, flags)) + del compiled, flags for index, match in enumerate(self._matches): if start_ea <= match < end_ea: self._matches.pop(index) return match, 0 return _FakeIdaApi.BADADDR, 0 - def find_bytes(self, *args, **kwargs) -> int: - self.find_bytes_called = True - raise AssertionError("search bytes should not call ida_bytes.find_bytes") - class _FakeIdaApi: BADADDR = -1 @@ -105,26 +104,16 @@ def resolve_segment_ranges( ) -def test_search_bytes_compiles_pattern_once_and_uses_bin_search() -> None: +def test_search_bytes_returns_matches_in_address_order() -> None: ida_bytes = _FakeIdaBytes(matches=[0x1010, 0x1020]) runtime = _FakeRuntime(ida_bytes) - result = _search_bytes( - OperationContext(runtime=runtime), - SearchBytesRequest(pattern="aa bb", segment="__TEXT", start="0x1000", end="0x1030", limit=2), + result = _run_search( + runtime, + {"pattern": "aa bb", "segment": "__TEXT", "start": "0x1000", "end": "0x1030", "limit": 2}, ) - assert result.results == ( - SearchMatch(address="0x1010"), - SearchMatch(address="0x1020"), - ) - assert ida_bytes.compiled_binpat_vec_t.calls == [(0x1000, "aa bb", 16, -1)] - assert [call[:4] for call in ida_bytes.bin_search_calls] == [ - (0x1000, 0x1030, "aa bb", 0x1000), - (0x1011, 0x1030, "aa bb", 0x1000), - (0x1021, 0x1030, "aa bb", 0x1000), - ] - assert ida_bytes.find_bytes_called is False + assert result["results"] == [{"address": "0x1010"}, {"address": "0x1020"}] def test_search_bytes_reports_invalid_pattern_as_user_error() -> None: @@ -132,41 +121,36 @@ def test_search_bytes_reports_invalid_pattern_as_user_error() -> None: runtime = _FakeRuntime(ida_bytes) with pytest.raises(IdaOperationError, match="invalid byte pattern: bad digit"): - _search_bytes( - OperationContext(runtime=runtime), - SearchBytesRequest(pattern="zz", segment="__TEXT", start="0x1000", end="0x1030", limit=5), + _run_search( + runtime, + {"pattern": "zz", "segment": "__TEXT", "start": "0x1000", "end": "0x1030", "limit": 5}, ) - assert ida_bytes.bin_search_calls == [] - assert ida_bytes.find_bytes_called is False - def test_search_bytes_returns_empty_results_when_no_matches_found() -> None: ida_bytes = _FakeIdaBytes(matches=[]) runtime = _FakeRuntime(ida_bytes) - result = _search_bytes( - OperationContext(runtime=runtime), - SearchBytesRequest(pattern="aa bb", segment="__TEXT", start="0x1000", end="0x1030", limit=5), + result = _run_search( + runtime, + {"pattern": "aa bb", "segment": "__TEXT", "start": "0x1000", "end": "0x1030", "limit": 5}, ) - assert result.results == () - assert result.truncated is False - assert ida_bytes.find_bytes_called is False + assert result["results"] == [] + assert result["truncated"] is False def test_search_bytes_marks_results_truncated_when_limit_is_hit() -> None: ida_bytes = _FakeIdaBytes(matches=[0x1010, 0x1020]) runtime = _FakeRuntime(ida_bytes) - result = _search_bytes( - OperationContext(runtime=runtime), - SearchBytesRequest(pattern="aa bb", segment="__TEXT", start="0x1000", end="0x1030", limit=1), + result = _run_search( + runtime, + {"pattern": "aa bb", "segment": "__TEXT", "start": "0x1000", "end": "0x1030", "limit": 1}, ) - assert result.results == (SearchMatch(address="0x1010"),) - assert result.truncated is True - assert ida_bytes.find_bytes_called is False + assert result["results"] == [{"address": "0x1010"}] + assert result["truncated"] is True def test_search_bytes_walks_each_matching_segment_range() -> None: @@ -177,18 +161,13 @@ def test_search_bytes_walks_each_matching_segment_range() -> None: SegmentRange(name="__TEXT:__stubs", start_ea=0x2000, end_ea=0x2030), ) - result = _search_bytes( - OperationContext(runtime=runtime), - SearchBytesRequest(pattern="aa bb", segment="__TEXT", start=None, end=None, limit=10), - ) + result = _run_search(runtime, {"pattern": "aa bb", "segment": "__TEXT", "limit": 10}) - assert result.results == ( - SearchMatch(address="0x1010"), - SearchMatch(address="0x2018"), - ) - assert [call[:2] for call in ida_bytes.bin_search_calls] == [ - (0x1000, 0x1030), - (0x1011, 0x1030), - (0x2000, 0x2030), - (0x2019, 0x2030), + assert result["results"] == [ + {"address": "0x1010"}, + {"address": "0x2018"}, + ] + assert result["ranges"] == [ + {"name": "__TEXT:__text", "start": "0x1000", "end": "0x1030"}, + {"name": "__TEXT:__stubs", "start": "0x2000", "end": "0x2030"}, ] diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..76c0db1 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import subprocess +import sys +from importlib import metadata +from pathlib import Path + +import pytest +from packaging.requirements import Requirement + +from idac import setup + + +def test_setup_gui_installs_the_supported_stack() -> None: + nexus_version = metadata.version("ida-nexus") + domain_requirement = next( + requirement.specifier + for dependency in metadata.requires("idac") or () + if (requirement := Requirement(dependency)).name == "ida-domain" + ) + source = f"https://github.com/HexRaysSA/ida-nexus@v{nexus_version}" + observed: dict[str, object] = {} + + def runner(command, **kwargs): + observed["command"] = command + observed["timeout"] = kwargs["timeout"] + environment = kwargs["env"] + observed["constraints"] = { + name: Path(environment[name]).read_text(encoding="utf-8") for name in ("PIP_CONSTRAINT", "UV_CONSTRAINT") + } + return subprocess.CompletedProcess(command, 0, "Installed plugin: ida-nexus", "") + + result = setup.setup_gui(timeout=30.0, runner=runner, environ={"PATH": "/bin"}) + + assert observed["timeout"] == 30.0 + assert result["installed"] is True + assert result["plugin"] == "ida-nexus" + assert observed["command"] == [sys.executable, "-m", "hcli", "plugin", "install", source] + assert result["version"] == nexus_version + assert result["source"] == source + assert result["ida_domain_requirement"] == str(domain_requirement) + assert observed["constraints"] == dict.fromkeys( + ("PIP_CONSTRAINT", "UV_CONSTRAINT"), f"ida-domain{domain_requirement}\n" + ) + + +def test_setup_gui_surfaces_installer_failure() -> None: + def runner(command, **_kwargs): + return subprocess.CompletedProcess(command, 2, "", "dependency resolution failed") + + with pytest.raises(OSError, match=r"ida-hcli failed.*dependency resolution failed"): + setup.setup_gui(runner=runner, environ={}) diff --git a/tests/test_string_search.py b/tests/test_string_search.py index 6ff969e..fc71e29 100644 --- a/tests/test_string_search.py +++ b/tests/test_string_search.py @@ -1,16 +1,20 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest -from idac.ops import OperationContext, payload_from_model -from idac.ops.families import search -from idac.ops.runtime import IdaOperationError, IdaRuntime, SegmentRange +from idac import remote_ops +from tests.remote_ops_harness import dispatch_with_runtime + +IdaOperationError = remote_ops.IdaOperationError +IdaRuntime = remote_ops.IdaRuntime +SegmentRange = remote_ops.SegmentRange def _op_strings(runtime: IdaRuntime, params: dict[str, object]): """Parse and run the `strings` operation the way production dispatch does.""" - request = search._parse_strings(params) - return payload_from_model(search._strings(OperationContext(runtime=runtime), request)) + return dispatch_with_runtime(runtime, "strings", params) class _FakeIdaBytes: @@ -111,18 +115,20 @@ def get_strlist_item(self, info: _FakeStringInfo, index: int) -> bool: return True -class _FakeIdaIda: - @staticmethod - def inf_get_min_ea() -> int: - return 0x1000 - - @staticmethod - def inf_get_max_ea() -> int: - return 0x2000 - - -class _FakeIdaApi: - BADADDR = -1 +class _FakeIdaNalt: + STRTYPE_TERMCHR = 7 + STRTYPE_C = 0 + STRTYPE_C_16 = 1 + STRTYPE_C_32 = 2 + STRTYPE_PASCAL = 3 + STRTYPE_PASCAL_16 = 4 + STRTYPE_PASCAL_32 = 5 + STRTYPE_LEN2 = 6 + STRTYPE_LEN2_16 = 8 + STRTYPE_LEN2_32 = 9 + STRTYPE_LEN4 = 10 + STRTYPE_LEN4_16 = 11 + STRTYPE_LEN4_32 = 12 def __init__(self, input_path: str = "/tmp/tiny") -> None: self._input_path = input_path @@ -145,17 +151,12 @@ def __init__( self._ida_strlist = _FakeIdaStrlist(self._items) self._mods = { "ida_bytes": _FakeIdaBytes(self._items, scan_lengths=scan_lengths), - "ida_ida": _FakeIdaIda(), - "idaapi": _FakeIdaApi(input_path), + "ida_ida": SimpleNamespace( + inf_get_min_ea=lambda: min(item.start_ea for item in self._segment_ranges), + inf_get_max_ea=lambda: max(item.end_ea for item in self._segment_ranges), + ), "ida_strlist": self._ida_strlist, - "ida_nalt": type( - "FakeIdaNalt", - (), - { - "STRTYPE_TERMCHR": 7, - "STRTYPE_C": 0, - }, - )(), + "ida_nalt": _FakeIdaNalt(input_path), } def mod(self, name: str): @@ -165,34 +166,11 @@ def mod(self, name: str): def resolve_address(identifier: str) -> int: return int(identifier, 0) - def resolve_segment_ranges( - self, - selector: str, - *, - start: str | None = None, - end: str | None = None, - require_bounds: bool = False, - missing_message: str = "range requires both start and end addresses", - ) -> tuple[SegmentRange, ...]: - assert selector == "__TEXT" - if require_bounds and (start is None or end is None): - raise IdaOperationError(missing_message) - range_start = self._segment_ranges[0].start_ea if start is None else int(start, 0) - range_end = self._segment_ranges[-1].end_ea if end is None else int(end, 0) - if range_end <= range_start: - raise IdaOperationError("range end must be greater than the start") - return tuple( - SegmentRange( - name=item.name, - start_ea=max(item.start_ea, range_start), - end_ea=min(item.end_ea, range_end), - ) - for item in self._segment_ranges - if min(item.end_ea, range_end) > max(item.start_ea, range_start) - ) + def iter_segments(self) -> tuple[SegmentRange, ...]: + return self._segment_ranges -def test_op_strings_lists_defined_strings_without_global_string_list() -> None: +def test_op_strings_filters_defined_strings_and_restores_global_options() -> None: runtime = _FakeRuntime( items={ 0x1010: (7, 5, b"alpha"), @@ -200,15 +178,25 @@ def test_op_strings_lists_defined_strings_without_global_string_list() -> None: } ) - rows = _op_strings(runtime, {"query": "tiny", "segment": "__TEXT"}) + options = runtime._ida_strlist.options + before = ( + list(options.strtypes), + options.minlen, + options.display_only_existing_strings, + options.only_7bit, + options.ignore_heads, + ) + + rows = _op_strings(runtime, {"pattern": "tiny", "ignore_case": True, "segment": "__TEXT"}) assert rows == [{"address": "0x1020", "text": "Tiny token"}] - assert runtime._ida_strlist.build_calls == 2 - assert runtime._ida_strlist.options.strtypes == [0x55] - assert runtime._ida_strlist.options.minlen == 5 - assert runtime._ida_strlist.options.display_only_existing_strings is False - assert runtime._ida_strlist.options.only_7bit is True - assert runtime._ida_strlist.options.ignore_heads is True + assert ( + list(options.strtypes), + options.minlen, + options.display_only_existing_strings, + options.only_7bit, + options.ignore_heads, + ) == before def test_op_strings_includes_termchr_string_literals() -> None: @@ -218,23 +206,11 @@ def test_op_strings_includes_termchr_string_literals() -> None: } ) - rows = _op_strings(runtime, {"query": "term", "segment": "__TEXT"}) + rows = _op_strings(runtime, {"pattern": "term", "segment": "__TEXT"}) assert rows == [{"address": "0x1010", "text": "term text"}] -def test_op_strings_returns_empty_list_when_no_matches_are_found() -> None: - runtime = _FakeRuntime( - items={ - 0x1010: (0, 5, b"alpha"), - } - ) - - rows = _op_strings(runtime, {"query": "missing", "segment": "__TEXT"}) - - assert rows == [] - - def test_op_strings_scan_walks_addresses_and_finds_strings() -> None: runtime = _FakeRuntime( items={ @@ -254,7 +230,8 @@ def test_op_strings_scan_walks_addresses_and_finds_strings() -> None: "segment": "__TEXT", "start": "0x1008", "end": "0x102a", - "query": "tiny", + "pattern": "tiny", + "ignore_case": True, }, ) @@ -275,7 +252,7 @@ def test_op_strings_rejects_defined_string_listing_on_dsc() -> None: ) with pytest.raises(IdaOperationError, match="defined string listing is disabled for dyld shared caches"): - _op_strings(runtime, {"query": "alpha", "segment": "__TEXT"}) + _op_strings(runtime, {"pattern": "alpha", "segment": "__TEXT"}) assert runtime._ida_strlist.build_calls == 0 @@ -286,7 +263,7 @@ def test_op_strings_does_not_treat_dsc_substring_as_shared_cache() -> None: items={0x1010: (0, 5, b"alpha")}, ) - rows = _op_strings(runtime, {"query": "alpha", "segment": "__TEXT"}) + rows = _op_strings(runtime, {"pattern": "alpha", "segment": "__TEXT"}) assert rows == [{"address": "0x1010", "text": "alpha"}] @@ -314,22 +291,18 @@ def test_op_strings_dsc_scan_rejects_large_ranges() -> None: ) -def test_string_text_returns_empty_string_when_ida_returns_none() -> None: - runtime = _FakeRuntime(items={0x1010: (0, 5, b"alpha")}) - runtime._mods["ida_bytes"].get_strlit_contents = lambda ea, length, strtype: None - - assert search._string_text(runtime, 0x1010, 5, 0) == "" - - def test_op_strings_filters_to_selected_segment_ranges() -> None: runtime = _FakeRuntime( items={ - 0x1010: (0, 5, b"alpha"), + 0x1010: (0, 10, b"Tiny token"), 0x2010: (0, 10, b"Tiny token"), } ) - runtime._segment_ranges = (SegmentRange(name="__TEXT:__cstring", start_ea=0x2000, end_ea=0x2100),) + runtime._segment_ranges = ( + SegmentRange(name="__DATA:__cstring", start_ea=0x1000, end_ea=0x1100), + SegmentRange(name="__TEXT:__cstring", start_ea=0x2000, end_ea=0x2100), + ) - rows = _op_strings(runtime, {"query": "tiny", "segment": "__TEXT"}) + rows = _op_strings(runtime, {"pattern": "tiny", "ignore_case": True, "segment": "__TEXT"}) assert rows == [{"address": "0x2010", "text": "Tiny token"}] diff --git a/tests/test_timeouts.py b/tests/test_timeouts.py deleted file mode 100644 index d25eb13..0000000 --- a/tests/test_timeouts.py +++ /dev/null @@ -1,971 +0,0 @@ -from __future__ import annotations - -import json -import os -import socket -import struct -import subprocess -import sys -import tempfile -import threading -from pathlib import Path - -import pytest - -from idac.cli import build_parser -from idac.metadata import WIRE_PROTOCOL_VERSION -from idac.transport import BackendError, idalib, send_request -from idac.transport.gui import BridgeInstance, GuiBackend -from idac.transport.idalib import IdaLibBackend, IdaLibInstance -from idac.transport.schema import RequestEnvelope -from idac.version import VERSION - - -def test_cli_rejects_non_positive_timeout() -> None: - parser = build_parser() - - with pytest.raises(SystemExit): - parser.parse_args(["database", "show", "-c", "db:db.i64", "--timeout", "0"]) - - with pytest.raises(SystemExit): - parser.parse_args(["database", "show", "-c", "db:db.i64", "--timeout", "-1"]) - - -def test_strings_command_requires_timeout(capsys) -> None: - from idac.cli import main - - exit_code = main(["search", "strings", "tiny", "--segment", "__TEXT", "-c", "db:db.i64"]) - - assert exit_code == 1 - assert "`idac search strings` requires --timeout" in capsys.readouterr().err - - -def test_search_bytes_command_requires_timeout(capsys) -> None: - from idac.cli import main - - exit_code = main(["search", "bytes", "74 69 6e 79", "--segment", "__TEXT", "-c", "db:db.i64"]) - - assert exit_code == 1 - assert "`idac search bytes` requires --timeout" in capsys.readouterr().err - - -def test_required_timeout_commands_fail_before_gui_autodiscovery(monkeypatch, capsys) -> None: - from idac.cli import main - - def fail_if_called(*args, **kwargs): - raise AssertionError("GUI autodiscovery should not run before timeout validation") - - monkeypatch.setattr("idac.cli2.context.list_gui_instances", fail_if_called) - - exit_code = main(["search", "bytes", "74 69 6e 79", "--segment", "__TEXT"]) - - assert exit_code == 1 - assert "`idac search bytes` requires --timeout" in capsys.readouterr().err - - -def test_required_timeout_commands_forward_timeout_to_gui_autodiscovery(monkeypatch, capsys) -> None: - from idac.cli import main - - seen: dict[str, object] = {} - discovered = [object()] - - def fake_list_gui_instances(*, timeout=None, warnings=None): - seen["timeout"] = timeout - seen["warnings"] = warnings - return list(discovered) - - def fake_list_gui_discovered_instances(*, warnings=None): - seen["discovery_warnings"] = warnings - return list(discovered) - - def fake_send_request(request): - seen["request_timeout"] = request.timeout - seen["request_backend"] = request.backend - return {"ok": True, "result": {"pattern": request.params["pattern"], "results": []}, "warnings": []} - - monkeypatch.setattr("idac.cli2.context.list_gui_discovered_instances", fake_list_gui_discovered_instances) - monkeypatch.setattr("idac.cli2.context.list_gui_instances", fake_list_gui_instances) - monkeypatch.setattr("idac.cli2.context.list_idalib_instances", lambda: []) - monkeypatch.setattr("idac.cli2.commands.common.send_request", fake_send_request) - - exit_code = main(["search", "bytes", "74 69 6e 79", "--segment", "__TEXT", "--timeout", "2.5", "--format", "json"]) - - assert exit_code == 0 - assert seen == { - "discovery_warnings": [], - "timeout": 2.5, - "warnings": [], - "request_timeout": 2.5, - "request_backend": "gui", - } - assert capsys.readouterr().err == "" - - -def test_required_timeout_commands_report_gui_autodiscovery_timeout(monkeypatch, capsys) -> None: - from idac.cli import main - - monkeypatch.setattr("idac.cli2.context.list_gui_discovered_instances", lambda warnings=None: [object()]) - monkeypatch.setattr("idac.cli2.context.list_idalib_instances", lambda: []) - - def fake_list_gui_instances(*, timeout=None, warnings=None): - assert timeout == 2.5 - assert warnings is not None - warnings.append("Failed to contact IDA GUI bridge pid 1234 at /tmp/idac-bridge-1234.sock: timed out") - return [] - - monkeypatch.setattr("idac.cli2.context.list_gui_instances", fake_list_gui_instances) - - exit_code = main(["search", "bytes", "74 69 6e 79", "--segment", "__TEXT", "--timeout", "2.5"]) - - assert exit_code == 1 - err = capsys.readouterr().err - assert "IDA GUI autodiscovery timed out" in err - assert "increase --timeout" in err - assert "timed out" in err - - -def test_required_timeout_commands_do_not_auto_select_after_timeout_pruned_discovery(monkeypatch, capsys) -> None: - from idac.cli import main - - monkeypatch.setattr("idac.cli2.context.list_gui_discovered_instances", lambda warnings=None: [object(), object()]) - monkeypatch.setattr("idac.cli2.context.list_idalib_instances", lambda: []) - - def fake_list_gui_instances(*, timeout=None, warnings=None): - assert timeout == 2.5 - assert warnings is not None - warnings.append("Failed to contact IDA GUI bridge pid 2222 at /tmp/idac-bridge-2222.sock: timed out") - return [object()] - - def fail_send_request(request): - raise AssertionError("command should not auto-select a GUI target after timeout-pruned discovery") - - monkeypatch.setattr("idac.cli2.context.list_gui_instances", fake_list_gui_instances) - monkeypatch.setattr("idac.cli2.commands.common.send_request", fail_send_request) - - exit_code = main(["search", "bytes", "74 69 6e 79", "--segment", "__TEXT", "--timeout", "2.5"]) - - assert exit_code == 1 - err = capsys.readouterr().err - assert "autodiscovery is ambiguous" in err - assert "pass an explicit context" in err - assert "timed out" in err - - -def test_gui_backend_rejects_missing_timeout_for_strings() -> None: - with pytest.raises(ValueError, match=r"operation `strings` requires a request timeout"): - GuiBackend().send(RequestEnvelope(op="strings", backend="gui")) - - -def test_idalib_backend_rejects_missing_timeout_for_search_bytes() -> None: - with pytest.raises(ValueError, match=r"operation `search_bytes` requires a request timeout"): - IdaLibBackend().send(RequestEnvelope(op="search_bytes", backend="idalib", database="fixture.i64")) - - -def test_idalib_backend_forwards_timeout(monkeypatch, tmp_path: Path) -> None: - seen: dict[str, object] = {} - - instance = IdaLibInstance( - pid=1234, - socket_path=tmp_path / "idac-idalib-1234.sock", - registry_path=tmp_path / "idac-idalib-1234.json", - database_path=str(tmp_path / "fixture.i64"), - started_at=None, - meta={}, - ) - - def fake_socket_request(socket_path, payload, *, timeout): - seen["socket_path"] = socket_path - seen["timeout"] = timeout - seen["payload"] = payload - return { - "version": WIRE_PROTOCOL_VERSION, - "id": None, - "ok": True, - "result": None, - "error": None, - "backend": "idalib", - "warnings": [], - } - - monkeypatch.setattr( - "idac.transport.idalib._ensure_instance_for_database", - lambda database_path, *, timeout, run_auto_analysis, start_if_missing: (instance, True), - ) - monkeypatch.setattr("idac.transport.idalib._socket_request", fake_socket_request) - - response = IdaLibBackend().send( - RequestEnvelope(op="database_info", backend="idalib", database="fixture.i64", timeout=1.5) - ) - - assert seen["socket_path"] == instance.socket_path - assert seen["timeout"] == 1.5 - assert seen["payload"] == {"version": 1, "op": "database_info", "params": {}} - assert response["ok"] is True - - -def test_idalib_backend_uses_blocking_socket_by_default(monkeypatch, tmp_path: Path) -> None: - seen: dict[str, object] = {} - - instance = IdaLibInstance( - pid=1234, - socket_path=tmp_path / "idac-idalib-1234.sock", - registry_path=tmp_path / "idac-idalib-1234.json", - database_path=str(tmp_path / "fixture.i64"), - started_at=None, - meta={}, - ) - - def fake_socket_request(socket_path, payload, *, timeout): - seen["socket_path"] = socket_path - seen["timeout"] = timeout - seen["payload"] = payload - return { - "version": WIRE_PROTOCOL_VERSION, - "id": None, - "ok": True, - "result": None, - "error": None, - "backend": "idalib", - "warnings": [], - } - - monkeypatch.setattr( - "idac.transport.idalib._ensure_instance_for_database", - lambda database_path, *, timeout, run_auto_analysis, start_if_missing: (instance, True), - ) - monkeypatch.setattr("idac.transport.idalib._socket_request", fake_socket_request) - - response = IdaLibBackend().send(RequestEnvelope(op="database_info", backend="idalib", database="fixture.i64")) - - assert seen["socket_path"] == instance.socket_path - assert seen["timeout"] is None - assert seen["payload"] == {"version": 1, "op": "database_info", "params": {}} - assert response["ok"] is True - - -def test_idalib_existing_instance_probe_honors_blocking_mode(monkeypatch, tmp_path: Path) -> None: - instance = IdaLibInstance( - pid=1234, - socket_path=tmp_path / "idac-idalib-1234.sock", - registry_path=tmp_path / "idac-idalib-1234.json", - database_path=str(tmp_path / "fixture.i64"), - started_at=None, - meta={}, - ) - seen: dict[str, object] = {} - - monkeypatch.setattr(idalib, "_find_instance_for_database", lambda database_path: instance) - - def fake_probe(instance_arg, *, timeout): - assert instance_arg is instance - seen["timeout"] = timeout - return True - - monkeypatch.setattr(idalib, "_probe_instance", fake_probe) - - found, already_open = idalib._ensure_instance_for_database( - str(tmp_path / "fixture.i64"), - timeout=None, - run_auto_analysis=True, - start_if_missing=True, - ) - - assert found is instance - assert already_open is True - assert seen == {"timeout": None} - - -def test_idalib_new_instance_startup_uses_request_timeout(monkeypatch, tmp_path: Path) -> None: - database_path = str(tmp_path / "fixture.i64") - instance = IdaLibInstance( - pid=1234, - socket_path=tmp_path / "idac-idalib-1234.sock", - registry_path=tmp_path / "idac-idalib-1234.json", - database_path=database_path, - started_at=None, - meta={}, - ) - seen: dict[str, object] = {} - - monkeypatch.setattr(idalib, "_find_instance_for_database", lambda database_path_arg: None) - - def fake_start_daemon(database_path_arg, *, startup_timeout, run_auto_analysis): - seen["database_path"] = database_path_arg - seen["startup_timeout"] = startup_timeout - seen["run_auto_analysis"] = run_auto_analysis - return instance - - monkeypatch.setattr(idalib, "_start_daemon_for_database", fake_start_daemon) - - found, already_open = idalib._ensure_instance_for_database( - database_path, - timeout=120.0, - run_auto_analysis=False, - start_if_missing=True, - ) - - assert found is instance - assert already_open is False - assert seen == { - "database_path": database_path, - "startup_timeout": 120.0, - "run_auto_analysis": False, - } - - -def test_idalib_probe_timeout_treats_busy_daemon_as_reachable_without_resend(monkeypatch) -> None: - # Real _socket_request against a real Unix socket. The server accepts and - # reads each request but never replies, so the probe times out. A daemon - # that accepts the connection is alive-but-busy: the probe must return True - # (reachable), never purge, and never resend. The server keeps draining so a - # buggy resend would be counted. - monkeypatch.setattr(idalib, "IDALIB_PROBE_TIMEOUT_CAP", 0.25) - with tempfile.TemporaryDirectory(prefix="idac-timeout-", dir="/tmp") as tmp: - tmp_path = Path(tmp) - registry_path = tmp_path / "idac-idalib-1.json" - registry_path.write_text("{}", encoding="utf-8") - socket_path = tmp_path / "idac-idalib-1.sock" - - stop = threading.Event() - received: list[bytes] = [] - held: list[socket.socket] = [] - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server.bind(str(socket_path)) - server.listen(8) - server.settimeout(0.05) - - def serve() -> None: - while not stop.is_set(): - try: - conn, _ = server.accept() - except TimeoutError: - continue - except OSError: - return - held.append(conn) - conn.settimeout(1.0) - chunks: list[bytes] = [] - try: - while True: - data = conn.recv(65536) - if not data: - break - chunks.append(data) - except (TimeoutError, OSError): - pass - if chunks: - received.append(b"".join(chunks)) - # Deliberately do not reply or close: the client blocks in recv - # until its own timeout fires. - - thread = threading.Thread(target=serve, daemon=True) - thread.start() - instance = IdaLibInstance( - pid=os.getpid(), - socket_path=socket_path, - registry_path=registry_path, - database_path=str(tmp_path / "fixture.i64"), - started_at=None, - meta={}, - ) - try: - # Blocking mode (timeout=None) must still be bounded by the probe cap - # and return "reachable" rather than hang. - assert idalib._probe_instance(instance, timeout=None) is True - finally: - stop.set() - thread.join(timeout=5.0) - for conn in held: - conn.close() - server.close() - - assert registry_path.exists() - assert socket_path.exists() - assert len(received) == 1 - assert json.loads(received[0])["op"] == "daemon_status" - - -def test_idalib_socket_request_does_not_resend_after_connection_reset() -> None: - # The H2 hazard: the server reads the (possibly mutating) request, then - # aborts the connection with RST. _socket_request must surface an error - # without re-sending, because the daemon may already be executing it. - with tempfile.TemporaryDirectory(prefix="idac-timeout-", dir="/tmp") as tmp: - socket_path = Path(tmp) / "idac-idalib-rst.sock" - stop = threading.Event() - received: list[bytes] = [] - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server.bind(str(socket_path)) - server.listen(8) - server.settimeout(0.05) - - def serve() -> None: - while not stop.is_set(): - try: - conn, _ = server.accept() - except TimeoutError: - continue - except OSError: - return - with conn: - chunks: list[bytes] = [] - try: - while True: - data = conn.recv(65536) - if not data: - break - chunks.append(data) - except OSError: - pass - if chunks: - received.append(b"".join(chunks)) - # Force an RST rather than a clean close: SO_LINGER with a - # zero timeout makes close() send a reset. - conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) - - thread = threading.Thread(target=serve, daemon=True) - thread.start() - try: - with pytest.raises(RuntimeError): - idalib._socket_request( - socket_path, - {"version": WIRE_PROTOCOL_VERSION, "op": "python_exec", "params": {"code": "mutate()"}}, - timeout=1.0, - ) - finally: - stop.set() - thread.join(timeout=5.0) - server.close() - - assert len(received) == 1 - - -def _refused_socket_instance(tmp_path: Path, pid: int) -> IdaLibInstance: - registry_path = tmp_path / "idac-idalib-2.json" - registry_path.write_text("{}", encoding="utf-8") - socket_path = tmp_path / "idac-idalib-2.sock" - # Bind then close so the socket file exists with no listener behind it. - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server.bind(str(socket_path)) - server.close() - return IdaLibInstance( - pid=pid, - socket_path=socket_path, - registry_path=registry_path, - database_path=str(tmp_path / "fixture.i64"), - started_at=None, - meta={}, - ) - - -def test_idalib_probe_refused_with_live_worker_pid_does_not_purge_instance_files(monkeypatch) -> None: - # A live pid whose command line is the idalib worker, but whose socket - # refuses the connection: a daemon shutting down or wedged. Its files must - # survive so no second daemon opens the same database. - monkeypatch.setattr( - idalib, - "pid_command_line", - lambda pid: f"{sys.executable} -m idac.transport.idalib_server --database x.i64", - ) - with tempfile.TemporaryDirectory(prefix="idac-timeout-", dir="/tmp") as tmp: - tmp_path = Path(tmp) - instance = _refused_socket_instance(tmp_path, os.getpid()) - - with pytest.raises(RuntimeError, match="is not answering on its socket"): - idalib._probe_instance(instance, timeout=0.25) - - assert instance.registry_path.exists() - assert instance.socket_path.exists() - - -def test_idalib_probe_failure_with_recycled_foreign_pid_purges_instance_files(monkeypatch) -> None: - # A live pid whose command line is NOT the idalib worker: the original - # daemon died and its pid was recycled. Purge so a fresh daemon can start, - # rather than wedging the database forever. - monkeypatch.setattr(idalib, "pid_command_line", lambda pid: "/usr/bin/some-unrelated-process") - with tempfile.TemporaryDirectory(prefix="idac-timeout-", dir="/tmp") as tmp: - tmp_path = Path(tmp) - instance = _refused_socket_instance(tmp_path, os.getpid()) - - assert idalib._probe_instance(instance, timeout=0.25) is False - - assert not instance.registry_path.exists() - assert not instance.socket_path.exists() - - -def test_idalib_probe_failure_with_dead_pid_purges_instance_files() -> None: - with tempfile.TemporaryDirectory(prefix="idac-timeout-", dir="/tmp") as tmp: - tmp_path = Path(tmp) - proc = subprocess.Popen([sys.executable, "-c", "pass"]) - proc.wait() - instance = _refused_socket_instance(tmp_path, proc.pid) - - assert idalib._probe_instance(instance, timeout=0.25) is False - - assert not instance.registry_path.exists() - assert not instance.socket_path.exists() - - -def test_idalib_db_close_warns_when_daemon_died_uncleanly(monkeypatch, tmp_path: Path) -> None: - # A registry survives for the database but no live daemon answers: the daemon - # died without a clean close, so unsaved changes may be lost. db_close must - # flag this rather than silently report a clean already-closed. - database = str(tmp_path / "fixture.i64") - registry_path = tmp_path / "idac-idalib-999999.json" - registry_path.write_text( - json.dumps({"pid": 999999, "socket_path": str(tmp_path / "x.sock"), "database_path": database}), - encoding="utf-8", - ) - monkeypatch.setattr(idalib, "idalib_registry_paths", lambda: [registry_path]) - monkeypatch.setattr(idalib, "list_instances", lambda: []) - - response = IdaLibBackend().send(RequestEnvelope(op="db_close", backend="idalib", database=database)) - - assert response["ok"] is True - assert response["result"]["already_closed"] is True - assert response["result"]["unclean"] is True - assert response["warnings"] - assert "without a clean close" in response["warnings"][0] - - -def test_idalib_db_close_without_registry_is_clean_already_closed(monkeypatch, tmp_path: Path) -> None: - database = str(tmp_path / "fixture.i64") - monkeypatch.setattr(idalib, "idalib_registry_paths", lambda: []) - monkeypatch.setattr(idalib, "list_instances", lambda: []) - - response = IdaLibBackend().send(RequestEnvelope(op="db_close", backend="idalib", database=database)) - - assert response["ok"] is True - assert response["result"]["already_closed"] is True - assert "unclean" not in response["result"] - assert response["warnings"] == [] - - -def test_idalib_database_start_lock_is_exclusive(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setattr(idalib, "ensure_user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(idalib, "idalib_open_lock_path", lambda db: tmp_path / "open.lock") - database = str(tmp_path / "fixture.i64") - - order: list[str] = [] - holding = threading.Event() - release = threading.Event() - second_acquired = threading.Event() - - def hold_first() -> None: - with idalib._database_start_lock(database): - order.append("first-acquired") - holding.set() - release.wait(timeout=5.0) - order.append("first-releasing") - - def acquire_second() -> None: - with idalib._database_start_lock(database): - order.append("second-acquired") - second_acquired.set() - - first = threading.Thread(target=hold_first) - first.start() - assert holding.wait(timeout=5.0) - - second = threading.Thread(target=acquire_second) - second.start() - # The second acquisition must block while the first holds the lock. - assert not second_acquired.wait(timeout=0.3) - release.set() - assert second_acquired.wait(timeout=5.0) - - first.join(timeout=5.0) - second.join(timeout=5.0) - assert order == ["first-acquired", "first-releasing", "second-acquired"] - - -def test_idalib_ensure_instance_rechecks_under_lock_and_skips_double_start(monkeypatch, tmp_path: Path) -> None: - # A daemon started by a racing process between the first lookup and the lock - # must be reused, not double-started (which would fail with rc=4). - monkeypatch.setattr(idalib, "ensure_user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(idalib, "idalib_open_lock_path", lambda db: tmp_path / "open.lock") - database = str(tmp_path / "fixture.i64") - instance = IdaLibInstance( - pid=1234, - socket_path=tmp_path / "s.sock", - registry_path=tmp_path / "r.json", - database_path=idalib.normalize_database_path(database), - started_at=None, - meta={}, - ) - - finds = [None, instance] - monkeypatch.setattr(idalib, "_find_instance_for_database", lambda db: finds.pop(0)) - monkeypatch.setattr(idalib, "_probe_instance", lambda inst, *, timeout: True) - - def no_start(*args, **kwargs): - raise AssertionError("a second daemon must not be started under the lock") - - monkeypatch.setattr(idalib, "_start_daemon_for_database", no_start) - - found, already_open = idalib._ensure_instance_for_database( - database, - timeout=None, - run_auto_analysis=True, - start_if_missing=True, - ) - - assert found is instance - assert already_open is True - assert finds == [] - - -def test_idalib_daemon_startup_uses_readiness_pipe(monkeypatch, tmp_path: Path) -> None: - class FakeProc: - pid = 1234 - - database_path = str(tmp_path / "fixture.i64") - instance = IdaLibInstance( - pid=1234, - socket_path=tmp_path / "idac-idalib-1234.sock", - registry_path=tmp_path / "idac-idalib-1234.json", - database_path=database_path, - started_at=None, - meta={}, - ) - proc = FakeProc() - seen: dict[str, object] = {} - - def fake_popen(cmd, **kwargs): - ready_fd = int(cmd[cmd.index("--ready-fd") + 1]) - seen["cmd"] = cmd - seen["pass_fds"] = kwargs["pass_fds"] - seen["ready_fd"] = ready_fd - assert ready_fd in kwargs["pass_fds"] - return proc - - def fake_read_ready_payload(read_fd, *, timeout): - seen["read_fd"] = read_fd - seen["timeout"] = timeout - with pytest.raises(OSError): - os.write(int(seen["ready_fd"]), b"parent closed this fd") - os.close(read_fd) - return {"ok": True} - - def fail_if_called(*args, **kwargs): - raise AssertionError("startup readiness should not probe sockets") - - monkeypatch.setattr(idalib, "ensure_user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(idalib.subprocess, "Popen", fake_popen) - monkeypatch.setattr(idalib, "idalib_registry_path", lambda pid: tmp_path / f"idac-idalib-{pid}.json") - monkeypatch.setattr(idalib, "_read_ready_payload", fake_read_ready_payload) - monkeypatch.setattr(idalib, "_instance_from_registry", lambda path: instance) - monkeypatch.setattr(idalib, "_probe_instance", fail_if_called) - - started = idalib._start_daemon_for_database( - database_path, - startup_timeout=120.0, - run_auto_analysis=True, - ) - - assert started is instance - assert seen["timeout"] == 120.0 - assert "--ready-fd" in seen["cmd"] - - -def test_idalib_daemon_startup_retries_silent_first_import_exit(monkeypatch, tmp_path: Path) -> None: - class FakeProc: - def __init__(self, pid: int) -> None: - self.pid = pid - self.wait_timeouts: list[float] = [] - - def wait(self, *, timeout): - self.wait_timeouts.append(timeout) - return 1 - - database_path = str(tmp_path / "tiny") - instance = IdaLibInstance( - pid=2346, - socket_path=tmp_path / "idac-idalib-2346.sock", - registry_path=tmp_path / "idac-idalib-2346.json", - database_path=database_path, - started_at=None, - meta={}, - ) - procs: list[FakeProc] = [] - read_attempts = 0 - - def fake_popen(cmd, **kwargs): - proc = FakeProc(2345 + len(procs)) - procs.append(proc) - return proc - - def fake_read_ready_payload(read_fd, *, timeout): - nonlocal read_attempts - read_attempts += 1 - os.close(read_fd) - if read_attempts == 1: - raise EOFError("idalib daemon exited before reporting readiness") - return {"ok": True} - - monkeypatch.setattr(idalib, "ensure_user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(idalib.subprocess, "Popen", fake_popen) - monkeypatch.setattr(idalib, "idalib_registry_path", lambda pid: tmp_path / f"idac-idalib-{pid}.json") - monkeypatch.setattr(idalib, "_read_ready_payload", fake_read_ready_payload) - monkeypatch.setattr(idalib, "_instance_from_registry", lambda path: instance) - - started = idalib._start_daemon_for_database( - database_path, - startup_timeout=120.0, - run_auto_analysis=True, - ) - - assert started is instance - assert read_attempts == 2 - assert [proc.pid for proc in procs] == [2345, 2346] - assert procs[0].wait_timeouts == [0.5] - - -def test_idalib_daemon_startup_timeout_terminates_worker(monkeypatch, tmp_path: Path) -> None: - class FakeProc: - pid = 1234 - - def __init__(self) -> None: - self.terminated = False - self.killed = False - self.wait_timeouts: list[float] = [] - - def terminate(self): - self.terminated = True - - def kill(self): - self.killed = True - - def wait(self, *, timeout): - self.wait_timeouts.append(timeout) - return 1 - - proc = FakeProc() - database_path = str(tmp_path / "fixture.i64") - seen: dict[str, object] = {} - - def fake_read_ready_payload(read_fd, *, timeout): - seen["timeout"] = timeout - os.close(read_fd) - raise TimeoutError() - - monkeypatch.setattr(idalib, "ensure_user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(idalib.subprocess, "Popen", lambda *args, **kwargs: proc) - monkeypatch.setattr(idalib, "idalib_registry_path", lambda pid: tmp_path / f"idac-idalib-{pid}.json") - monkeypatch.setattr(idalib, "_read_ready_payload", fake_read_ready_payload) - - with pytest.raises(RuntimeError, match="timed out after 120s waiting for idalib daemon"): - idalib._start_daemon_for_database( - database_path, - startup_timeout=120.0, - run_auto_analysis=True, - ) - - assert seen["timeout"] == 120.0 - assert proc.terminated is True - assert proc.killed is False - assert proc.wait_timeouts == [5.0] - - -def test_idalib_daemon_startup_terminates_worker_on_keyboard_interrupt(monkeypatch, tmp_path: Path) -> None: - # Ctrl-C during the readiness wait (KeyboardInterrupt is a BaseException, - # not Exception) must still reap the spawned daemon so it does not linger - # holding the database lock. - class FakeProc: - pid = 1234 - - def __init__(self) -> None: - self.terminated = False - self.wait_timeouts: list[float] = [] - - def terminate(self): - self.terminated = True - - def wait(self, *, timeout): - self.wait_timeouts.append(timeout) - return 1 - - proc = FakeProc() - database_path = str(tmp_path / "fixture.i64") - - def fake_read_ready_payload(read_fd, *, timeout): - os.close(read_fd) - raise KeyboardInterrupt - - monkeypatch.setattr(idalib, "ensure_user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(idalib.subprocess, "Popen", lambda *args, **kwargs: proc) - monkeypatch.setattr(idalib, "idalib_registry_path", lambda pid: tmp_path / f"idac-idalib-{pid}.json") - monkeypatch.setattr(idalib, "_read_ready_payload", fake_read_ready_payload) - - with pytest.raises(KeyboardInterrupt): - idalib._start_daemon_for_database( - database_path, - startup_timeout=120.0, - run_auto_analysis=True, - ) - - assert proc.terminated is True - - -def test_idalib_daemon_startup_reads_stderr_when_worker_exits_before_readiness(monkeypatch, tmp_path: Path) -> None: - class FakeStderrLog: - closed = False - - def seek(self, _offset): - pass - - def read(self): - return "startup failed" - - def close(self): - self.closed = True - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - self.close() - return False - - class FakeProc: - pid = 1234 - - def wait(self, *, timeout): - return 1 - - database_path = str(tmp_path / "fixture.i64") - stderr_log = FakeStderrLog() - - def fake_popen(cmd, **kwargs): - return FakeProc() - - def fake_read_ready_payload(read_fd, *, timeout): - os.close(read_fd) - raise EOFError("closed") - - monkeypatch.setattr(idalib, "ensure_user_runtime_dir", lambda: tmp_path) - monkeypatch.setattr(idalib.tempfile, "TemporaryFile", lambda *args, **kwargs: stderr_log) - monkeypatch.setattr(idalib.subprocess, "Popen", fake_popen) - monkeypatch.setattr(idalib, "idalib_registry_path", lambda pid: tmp_path / f"idac-idalib-{pid}.json") - monkeypatch.setattr(idalib, "_read_ready_payload", fake_read_ready_payload) - - with pytest.raises(RuntimeError, match="startup failed"): - idalib._start_daemon_for_database(database_path, startup_timeout=0.25, run_auto_analysis=True) - - assert stderr_log.closed is True - - -def test_idalib_backend_reports_timeout(monkeypatch, tmp_path: Path) -> None: - instance = IdaLibInstance( - pid=1234, - socket_path=tmp_path / "idac-idalib-1234.sock", - registry_path=tmp_path / "idac-idalib-1234.json", - database_path=str(tmp_path / "fixture.i64"), - started_at=None, - meta={}, - ) - - def fake_socket_request(socket_path, payload, *, timeout): - raise TimeoutError() - - monkeypatch.setattr( - "idac.transport.idalib._ensure_instance_for_database", - lambda database_path, *, timeout, run_auto_analysis, start_if_missing: (instance, True), - ) - monkeypatch.setattr("idac.transport.idalib._socket_request", fake_socket_request) - - with pytest.raises(RuntimeError, match=r"idalib request timed out after 0.25s: database_info"): - IdaLibBackend().send( - RequestEnvelope(op="database_info", backend="idalib", database="fixture.i64", timeout=0.25) - ) - - -def test_gui_target_list_forwards_timeout(monkeypatch, tmp_path) -> None: - instance = BridgeInstance( - pid=1234, - socket_path=tmp_path / "idac-bridge-1234.sock", - registry_path=tmp_path / "idac-bridge-1234.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at=None, - meta={}, - ) - seen: list[float | None] = [] - - monkeypatch.setattr("idac.transport.gui.list_instances", lambda timeout=None, warnings=None: [instance]) - - def fake_send(instance_arg, request, *, connect_retries=4): - assert instance_arg is instance - seen.append(request.timeout) - return { - "ok": True, - "result": [ - { - "target_id": "active", - "selector": "tiny", - "filename": "/tmp/tiny.i64", - "module": "tiny", - "active": True, - } - ], - } - - monkeypatch.setattr("idac.transport.gui._send_request_to_instance", fake_send) - - response = GuiBackend().send(RequestEnvelope(op="list_targets", backend="gui", timeout=2.75)) - - assert response["ok"] is True - assert seen == [2.75] - - -def test_gui_target_list_uses_blocking_requests_by_default(monkeypatch, tmp_path) -> None: - instance = BridgeInstance( - pid=1234, - socket_path=tmp_path / "idac-bridge-1234.sock", - registry_path=tmp_path / "idac-bridge-1234.json", - plugin_name="idac_bridge", - plugin_version=VERSION, - started_at=None, - meta={}, - ) - seen: list[float | None] = [] - - monkeypatch.setattr("idac.transport.gui.list_instances", lambda timeout=None, warnings=None: [instance]) - - def fake_send(instance_arg, request, *, connect_retries=4): - assert instance_arg is instance - seen.append(request.timeout) - return { - "ok": True, - "result": [ - { - "target_id": "active", - "selector": "tiny", - "filename": "/tmp/tiny.i64", - "module": "tiny", - "active": True, - } - ], - } - - monkeypatch.setattr("idac.transport.gui._send_request_to_instance", fake_send) - - response = GuiBackend().send(RequestEnvelope(op="list_targets", backend="gui")) - - assert response["ok"] is True - assert seen == [None] - - -def test_gui_backend_rejects_non_positive_timeout() -> None: - with pytest.raises(ValueError, match="greater than 0"): - GuiBackend().send(RequestEnvelope(op="list_targets", backend="gui", timeout=0)) - - -def test_send_request_wraps_backend_runtime_errors(monkeypatch) -> None: - class FakeBackend: - def send(self, request): - raise RuntimeError(f"boom: {request.op}") - - monkeypatch.setattr("idac.transport.get_backend", lambda name: FakeBackend()) - - with pytest.raises(BackendError, match=r"boom: database_info"): - send_request(RequestEnvelope(op="database_info", backend="gui")) diff --git a/tests/test_vtable_helpers.py b/tests/test_vtable_helpers.py index 0b1a79b..3520957 100644 --- a/tests/test_vtable_helpers.py +++ b/tests/test_vtable_helpers.py @@ -3,7 +3,12 @@ from dataclasses import dataclass from typing import Any -from idac.ops.families.classes import _raw_vtable_dump, _vtable_members +from idac.remote_ops import IdaRuntime +from tests.remote_ops_harness import dispatch_with_runtime + + +def _run_class_vtable(runtime, *, include_runtime: bool = False): + return dispatch_with_runtime(runtime, "class_vtable", {"name": "Foo", "runtime": include_runtime}) class FakeIdaIda: @@ -75,6 +80,10 @@ class FakeMember: type: FakeMemberType cmt: str = "" + @staticmethod + def is_baseclass() -> bool: + return False + class FakeVtableTif: def __init__(self, members: list[FakeMember]) -> None: @@ -85,14 +94,17 @@ def get_udt_details(self, udt: list[Any]) -> bool: return True -class FakeRuntime: +class FakeRuntime(IdaRuntime): def __init__( self, bits: int, *, values: dict[int, int] | None = None, names: dict[int, str] | None = None, + vtable_members: list[FakeMember] | None = None, + runtime_identifier: str | None = None, ) -> None: + super().__init__() code_targets = {ea for ea, name in (names or {}).items() if name.startswith("sub_")} self._mods = { "ida_ida": FakeIdaIda(bits), @@ -101,41 +113,13 @@ def __init__( "ida_name": FakeIdaName(names or {}), "ida_typeinf": FakeIdaTypeinf(), } + self._class_tif = object() + self._vtable_tif = FakeVtableTif(vtable_members or []) + self._runtime_identifier = runtime_identifier def mod(self, name: str) -> Any: return self._mods[name] - @staticmethod - def member_has(member, attr: str) -> bool: - return bool(getattr(member, attr, lambda: False)()) - - def udt_members(self, tif): - udt = self.mod("ida_typeinf").udt_type_data_t() - return udt if tif.get_udt_details(udt) else () - - def pointer_size(self) -> int: - ida_ida = self.mod("ida_ida") - if ida_ida.inf_is_64bit(): - return 8 - if ida_ida.inf_is_32bit_exactly(): - return 4 - return 2 - - def pointer_bits(self) -> int: - return self.pointer_size() * 8 - - def read_pointer(self, ea: int) -> int: - ida_bytes = self.mod("ida_bytes") - width = self.pointer_size() - if width == 8: - return int(ida_bytes.get_qword(ea)) - if width == 4: - return int(ida_bytes.get_wide_dword(ea)) - return int(ida_bytes.get_wide_word(ea)) - - def vtable_slot(self, offset_bits: int) -> int: - return int(offset_bits) // self.pointer_bits() - def resolve_address(self, identifier: str) -> int: return int(identifier, 0) @@ -143,28 +127,38 @@ def demangle_name(self, name: str) -> str | None: return name if name else None def tinfo_decl(self, tif, *, name=None, multi=True) -> str: - return tif.dstr() + del name, multi + return tif.dstr() if hasattr(tif, "dstr") else "struct Foo_vtbl;" def get_named_type(self, name: str): - raise AssertionError(f"unexpected type lookup: {name}") + assert name == "Foo_vtbl" + return self._vtable_tif def find_named_type(self, name: str): - return None + return self._class_tif if name == "Foo" else None + + def is_class_tinfo(self, tif) -> bool: + return tif is self._class_tif + + def class_vtable_type_name(self, _tif) -> str: + return "Foo_vtbl" + + def class_runtime_vtable_identifier(self, _tif, *, name: str | None = None) -> str | None: + return self._runtime_identifier def test_vtable_members_use_pointer_width_for_slot_numbers() -> None: - runtime = FakeRuntime(32) - tif = FakeVtableTif( - [ + runtime = FakeRuntime( + 32, + vtable_members=[ FakeMember(offset=0, name="scalar_del", type=FakeMemberType("void (*)()")), FakeMember(offset=32, name="vector_del", type=FakeMemberType("void (*)()")), - ] + ], ) - members = _vtable_members(runtime, tif) + payload = _run_class_vtable(runtime) - assert runtime.pointer_bits() == 32 - assert [member["slot"] for member in members] == [0, 1] + assert [member["slot"] for member in payload["members"]] == [0, 1] def test_raw_vtable_dump_reads_32bit_entries_with_4byte_stride() -> None: @@ -183,9 +177,10 @@ def test_raw_vtable_dump_reads_32bit_entries_with_4byte_stride() -> None: 0x3000: "sub_3000", 0x4000: "sub_4000", }, + runtime_identifier="0x1000", ) - payload = _raw_vtable_dump(runtime, "0x1000", slot_limit=4) + payload = _run_class_vtable(runtime, include_runtime=True)["runtime_vtable"] assert payload["abi"] == "itanium" assert payload["slot_address"] == "0x1008" @@ -213,9 +208,10 @@ def test_raw_vtable_dump_stops_before_adjacent_rtti_symbol() -> None: 0x4000: "sub_4000", 0x5000: "__ZTI3Bar", }, + runtime_identifier="0x1000", ) - payload = _raw_vtable_dump(runtime, "0x1000", slot_limit=4) + payload = _run_class_vtable(runtime, include_runtime=True)["runtime_vtable"] assert payload["abi"] == "itanium" assert payload["slot_count"] == 2 diff --git a/tests/test_workspace_init.py b/tests/test_workspace_init.py index 32545bd..1ddf856 100644 --- a/tests/test_workspace_init.py +++ b/tests/test_workspace_init.py @@ -25,30 +25,12 @@ def test_workspace_init_creates_expected_tree(idac_cmd: list[str], idac_env: dic assert proc.returncode == 0, proc.stderr or proc.stdout assert (dest / ".claude" / "settings.json").is_file() assert (dest / ".codex" / "config.toml").is_file() - assert (dest / ".codex" / "rules" / "default.rules").is_file() - assert (dest / "CLAUDE.md").is_file() assert (dest / "AGENTS.md").is_file() - assert (dest / ".gitignore").is_file() assert (dest / ".idac" / "tmp").is_dir() assert (dest / "audit").is_dir() assert (dest / "headers" / "recovered").is_dir() - assert (dest / "headers" / "vendor").is_dir() - assert (dest / "scripts").is_dir() - assert not any(path.is_file() and path.name != ".gitkeep" for path in (dest / "scripts").iterdir()) - assert (dest / "prompts" / "recovery-pass.md").is_file() - assert (dest / "references" / "cli.md").is_file() - assert (dest / "references" / "class-recovery.md").is_file() assert (dest / ".git").is_dir() assert _git_repo_root(dest) == dest.resolve() - assert "Initialized git repository." in proc.stdout - assert ".claude/" in proc.stdout - assert ".codex/rules/" in proc.stdout - assert "audit/.gitkeep" in proc.stdout - assert "prompts/recovery-pass.md" in proc.stdout - assert "references/cli.md" in proc.stdout - assert "references/workflows.md" in proc.stdout - assert ".idac/" in proc.stdout - assert ".idac/tmp/" in proc.stdout def test_workspace_init_into_existing_empty_directory_succeeds( @@ -61,8 +43,7 @@ def test_workspace_init_into_existing_empty_directory_succeeds( assert proc.returncode == 0, proc.stderr or proc.stdout assert (dest / ".idac" / "tmp").is_dir() - assert (dest / "CLAUDE.md").is_file() - assert (dest / "references" / "cli.md").is_file() + assert (dest / "AGENTS.md").is_file() def test_workspace_init_refuses_existing_workspace_without_force( @@ -75,7 +56,7 @@ def test_workspace_init_refuses_existing_workspace_without_force( second = run_cli(idac_cmd, idac_env, "workspace", "init", str(dest)) assert second.returncode == 1 - assert "workspace already initialized (use --force to overwrite config)" in second.stderr + assert "--force" in second.stderr def test_workspace_init_force_overwrites_config_but_preserves_content( @@ -85,26 +66,18 @@ def test_workspace_init_force_overwrites_config_but_preserves_content( proc = run_cli(idac_cmd, idac_env, "workspace", "init", str(dest)) assert proc.returncode == 0, proc.stderr or proc.stdout - claude_path = dest / "CLAUDE.md" - prompt_path = dest / "prompts" / "recovery-pass.md" - reference_path = dest / "references" / "cli.md" + guidance_path = dest / "AGENTS.md" custom_audit_note = dest / "audit" / "finding.txt" - original_claude = claude_path.read_text(encoding="utf-8") + original_guidance = guidance_path.read_text(encoding="utf-8") - claude_path.write_text("custom claude content\n", encoding="utf-8") - prompt_path.write_text("custom prompt content\n", encoding="utf-8") - reference_path.write_text("custom reference content\n", encoding="utf-8") + guidance_path.write_text("custom agent guidance\n", encoding="utf-8") custom_audit_note.write_text("keep me\n", encoding="utf-8") forced = run_cli(idac_cmd, idac_env, "workspace", "init", str(dest), "--force") assert forced.returncode == 0, forced.stderr or forced.stdout - assert claude_path.read_text(encoding="utf-8") == original_claude - assert prompt_path.read_text(encoding="utf-8") == "custom prompt content\n" - assert reference_path.read_text(encoding="utf-8") == "custom reference content\n" + assert guidance_path.read_text(encoding="utf-8") == original_guidance assert custom_audit_note.read_text(encoding="utf-8") == "keep me\n" - assert "Overwrote:" in forced.stdout - assert "CLAUDE.md" in forced.stdout def test_workspace_init_adopts_existing_parent_repo_without_nesting( @@ -126,4 +99,3 @@ def test_workspace_init_adopts_existing_parent_repo_without_nesting( assert proc.returncode == 0, proc.stderr or proc.stdout assert not (dest / ".git").exists() assert _git_repo_root(dest) == repo_root.resolve() - assert "Using existing git repository:" in proc.stdout diff --git a/uv.lock b/uv.lock index c0b7f82..3b4a05f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,46 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P1W" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] [[package]] name = "colorama" @@ -17,115 +53,116 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/d2/c76bf165ff01664ca8b1ca7f2b2b5f311353d3959dbac1187dd21c6cc7f8/coverage-7.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:22d8802827404be32f5a4d6ddc037f6fa0074b7d06702c0224cb598def8b665d", size = 223019, upload-time = "2026-08-28T21:51:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/a47cebf71cb789b6e25de07035d350bff110d02f9c28bf32f92b4c818874/coverage-7.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a739bf08cdca0fad51b73322e4fade0102dd87794e278450b5ee87ef827954db", size = 223524, upload-time = "2026-08-28T21:51:03.632Z" }, + { url = "https://files.pythonhosted.org/packages/51/b3/42e46d7e247ba33758156a0cc88dc64715f7e7b04640fbe430c4da437ab1/coverage-7.16.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f99d12f8234c00b88b8077fedf288b25c77f746de312053b7db90fa756ecbdb3", size = 253934, upload-time = "2026-08-28T21:51:05.365Z" }, + { url = "https://files.pythonhosted.org/packages/9a/27/ade10badacc00076854f0c5086fcf8975bb1a379d5288b587509e6ee9763/coverage-7.16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7cae7715afa51dd7c9c42e6603bb46daf424c3449fdf06519cc658aa8d46e2e4", size = 255846, upload-time = "2026-08-28T21:51:06.922Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/38e5d8cf45af5db7419e9580bba4017113f8f1e2697cb6c52213bf7e7e40/coverage-7.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55957d350452017f523b9b03ffac078f9a214e23c04a3d0a674569203550c719", size = 257953, upload-time = "2026-08-28T21:51:08.51Z" }, + { url = "https://files.pythonhosted.org/packages/9b/bb/2f44b99723d0306095dacdf90f994631e299ff8f087a384b42ecc2d1ccb9/coverage-7.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b670bd5fa93d9b6855b2837217b45a90863118e2de5e9e033aebd46d07cd08d3", size = 259915, upload-time = "2026-08-28T21:51:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7d/3f1c312944d88b2d3cae8af72007c15dcf5f92bda6da6d433c2d5f050ee7/coverage-7.16.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe5aa402d02318db2f41e471320b2ecca6085b8f595a034c037085732e49c04a", size = 254028, upload-time = "2026-08-28T21:51:11.845Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f6/52a7e26baeeca7f3114b15da5e840bebcfe6491eb234f6922d33c79ee8fc/coverage-7.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fddd26ed9a2527a7e23f7e4c1fd0734c4a5b45f77b261da1c536b20a7d2e6f0c", size = 255648, upload-time = "2026-08-28T21:51:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d1/0673e78d9ca29d56f663623791338647753c673f0bc964e860086da07bce/coverage-7.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b2af58ecdcec37fe633d4865fccbc8c00d8aa3b31c099bcacb2720c9a0be6ab9", size = 253708, upload-time = "2026-08-28T21:51:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6c/23/b74c87828369059415b20884b6f48260f049bff750d6eb454be8554732ab/coverage-7.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a3cd34b9025d62180ce2b5dae8a985bfa6cb8c05ecd57fd34ffc1ff751b5a74d", size = 257479, upload-time = "2026-08-28T21:51:16.988Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/09e172472c45a956e226dddf82d449f245764208b7cea47b32a73df955a3/coverage-7.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ebaf39dd13f8af65fe5f0316b81046228ef4d91d3c3766192b418753649896d6", size = 253428, upload-time = "2026-08-28T21:51:18.803Z" }, + { url = "https://files.pythonhosted.org/packages/62/22/e378e4f7ffa290ea4775b34e319fa182640bba650a2c6781af791b66b79a/coverage-7.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5dad64d9c17cb1983adef07998e6e2e1cf870a156f1ea80f81ce1970f4c545ce", size = 254337, upload-time = "2026-08-28T21:51:20.785Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/9a6ca653d86e46c3383a905f726a28bcf7bb2528088794d30a53687b381c/coverage-7.16.0-cp311-cp311-win32.whl", hash = "sha256:38b8e1e73750b8965d1154ed733f5303acd4e24ee2d5ee872bb1bfab744a31ce", size = 225103, upload-time = "2026-08-28T21:51:22.685Z" }, + { url = "https://files.pythonhosted.org/packages/08/0c/6d4627be89ac02f579d88806875a5d6e328c59d7d79c594643c7a4460ef6/coverage-7.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc12e5e32acdd62fe5895939695579560639853219288519685c75b7e968d63a", size = 225577, upload-time = "2026-08-28T21:51:24.334Z" }, + { url = "https://files.pythonhosted.org/packages/f2/3d/d7be38564d00a17775426685776b4bf18e8a6048a085eccf65d75eb0fa5a/coverage-7.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:17fc3628f99812fec24f40092af34c1c73274d331babab3d1d768a75de650cf7", size = 225126, upload-time = "2026-08-28T21:51:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" }, + { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" }, + { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z" }, + { url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z" }, + { url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z" }, + { url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z" }, + { url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z" }, + { url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z" }, + { url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z" }, + { url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z" }, + { url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] [package.optional-dependencies] @@ -134,21 +171,101 @@ toml = [ ] [[package]] -name = "exceptiongroup" -version = "1.3.1" +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "ida-domain" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "idapro" }, + { name = "packaging" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/34/be087d3ea1c3a6573e0660cb5b40f0c4ade9ae5772cf1c5d98d52472d28b/ida_domain-0.5.1.tar.gz", hash = "sha256:c49f2c417047d882e954f651b50a709a3f27903b33ba533b794aa54d6536d16f", size = 396413, upload-time = "2026-08-10T13:32:48.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/27/78/9c698d818b0fddc6648f703a0821edeeb65b18404b43f556d249c5446c96/ida_domain-0.5.1-py3-none-any.whl", hash = "sha256:bfbb17c7d0cb2ed7d3f21342e1c8787f9018d5c2a94cdfd29e06537dd026a06d", size = 201275, upload-time = "2026-08-10T13:32:46.955Z" }, +] + +[[package]] +name = "ida-hcli" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "idapro" }, + { name = "packaging" }, + { name = "pip" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "questionary" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "semantic-version" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/e5/d96aefc8a156fe9a6a683abc4e2575b0664e9230c6547212d59c32d1646f/ida_hcli-0.24.0.tar.gz", hash = "sha256:c2ccc7bf37505d59199c89c8d5d3b211f1c29832d5dd699b5ad9996d65024dd4", size = 224355, upload-time = "2026-09-14T07:21:38.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/fb/9d67be1c2091e8ee44ed80e870b56e6723f6e11f2b15c5d2b69fe657e681/ida_hcli-0.24.0-py3-none-any.whl", hash = "sha256:327ae77d92bb50eb06e4912e5c5b0f24be9dbafe2c7614e1abcb61cb93fad05d", size = 276847, upload-time = "2026-09-14T07:21:37.011Z" }, +] + +[[package]] +name = "ida-nexus" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ida-domain" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/e0/d5fb73f5eafc4dcffe0d8a3db8237a62fadea3f07c3d4bc87e204d5ba76e/ida_nexus-0.13.2.tar.gz", hash = "sha256:e50edb9a764aa104c087fe6211302400166274a1dcddaf904afc63054a5b9876", size = 186933, upload-time = "2026-10-01T15:30:29.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/0a/d64b1bb0640f1e0eb04b77f803144f7af89fb15b3430c8dd9812951181d0/ida_nexus-0.13.2-py3-none-any.whl", hash = "sha256:eeb616058defc89f8fa219c3645903c878ae2e014371233e84d6706177bd3e2d", size = 104244, upload-time = "2026-10-01T15:30:30.425Z" }, ] [[package]] name = "idac" version = "0.19.1" source = { editable = "." } +dependencies = [ + { name = "ida-domain" }, + { name = "ida-hcli" }, + { name = "ida-nexus" }, + { name = "packaging" }, +] [package.dev-dependencies] dev = [ @@ -167,6 +284,12 @@ test = [ ] [package.metadata] +requires-dist = [ + { name = "ida-domain", specifier = ">=0.5.1" }, + { name = "ida-hcli", specifier = ">=0.24.0" }, + { name = "ida-nexus", specifier = ">=0.13.2" }, + { name = "packaging" }, +] [package.metadata.requires-dev] dev = [ @@ -184,6 +307,24 @@ test = [ { name = "pytest-cov", specifier = ">=7.0.0" }, ] +[[package]] +name = "idapro" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/75/249c605cc144a6b3778c48381d31ff9242f3e0b7ae23a9ca9c27224e641a/idapro-0.0.10.tar.gz", hash = "sha256:417c03c4605d18417e470f6a748e397b39d6d5829ebd3bbdedd92ff5b9092d11", size = 1060989, upload-time = "2026-07-15T12:55:22.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/83/7b02832cc8b057f686cccdb771fe80282f801a9b798961f8070bb468c73c/idapro-0.0.10-py3-none-any.whl", hash = "sha256:43f227953a0e348ced21c050d277b7ce34103e2ce05fc739b7d8c186ef0e1542", size = 2194897, upload-time = "2026-07-15T12:55:20.88Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -193,13 +334,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pip" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, ] [[package]] @@ -211,13 +391,142 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -226,12 +535,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -252,29 +559,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/3e/5688fdd83aea416de336582a274f2bc8236b5c261b04c11e17bc262786ad/rich_click-1.9.9.tar.gz", hash = "sha256:324cba7513cd4187ee92b2eef21f071714e45be062458c8b157bd7e0c81103e3", size = 75866, upload-time = "2026-09-05T19:20:14.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/51/b2b91aec091e86bc961e5164f00207d89f133cc82b5ddad541d2132ea819/rich_click-1.9.9-py3-none-any.whl", hash = "sha256:365e7a9d0adb42e41ea832a0a12e02c44c079a26536dee688125eb9814f97274", size = 71910, upload-time = "2026-09-05T19:20:13.587Z" }, +] + [[package]] name = "ruff" -version = "0.16.0" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + +[[package]] +name = "semantic-version" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, - { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, - { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, - { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, - { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, - { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] @@ -333,34 +697,55 @@ wheels = [ [[package]] name = "ty" -version = "0.0.63" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/ce/cbeaa5c7576fec643609dfbf200d59493523b1cc0481d4e7a5effcbf0630/ty-0.0.63.tar.gz", hash = "sha256:c2f66439393b3acac69306c117d4ae44638ce5fffa4a20c21046e85bd473359f", size = 6280695, upload-time = "2026-07-23T11:41:39.845Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/e4/d17a8e113ab15c692fe6fb9c422112d4bee7e829d80ced35826b45d98d98/ty-0.0.63-py3-none-linux_armv6l.whl", hash = "sha256:9a4ef7782e3af314fb63d006bec5ac3025bd70fee774b4dcfb5e44e8564f1994", size = 12056302, upload-time = "2026-07-23T11:41:03.5Z" }, - { url = "https://files.pythonhosted.org/packages/be/0b/357234c815dc4bfcc88c3f860aa0983fe4228c8aa2a5bb16c35ee08a94af/ty-0.0.63-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:01eab0ab70d51ad10298aa2d4b058b387a1fe93e5ee52d2a1ee23e9c69ba8354", size = 11737674, upload-time = "2026-07-23T11:41:05.862Z" }, - { url = "https://files.pythonhosted.org/packages/1c/13/193d9aeeb6774690351cff9fafabd3ae9b54cc225d125e07fa004ce23bdc/ty-0.0.63-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a671b61eaad16178389e05b9c108c9cb75ae8d84968fe55906484f55d6268338", size = 11264191, upload-time = "2026-07-23T11:41:07.963Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/c5843e16759a2b25fc8a7197473474038e585ac9fdaa6fa16aeb6384bf6a/ty-0.0.63-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b255cc83d95c51bb9ee4931303fdbece8cb1d6d7c655eb77a3f2c8f349fb64d6", size = 11818890, upload-time = "2026-07-23T11:41:09.885Z" }, - { url = "https://files.pythonhosted.org/packages/06/20/adf83ae1fc570d9bb449605e1eeeaa523ad2329ca838a636698b5c135d11/ty-0.0.63-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0da1e8183aa4f893421a173904478021498f542ee41273660538da6649a9631c", size = 11853141, upload-time = "2026-07-23T11:41:12.151Z" }, - { url = "https://files.pythonhosted.org/packages/ea/90/f8effd846e3ee13486ea08257c13094d58b9f188c5641bf609d6a7d5c09f/ty-0.0.63-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128f38eb5a67199e3811426386f7ec96f41251ddf45e04ddc0bcbb29d4853245", size = 12545184, upload-time = "2026-07-23T11:41:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/b8/76/aac3a30d40431eb1d329acea016b248f5b6255e30ef3d28e19447e344be2/ty-0.0.63-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bedf34aff8b0557f2a7119b314a68a9c9e58c1d21554d0e2748f2c5fe6a1f638", size = 13062375, upload-time = "2026-07-23T11:41:16.441Z" }, - { url = "https://files.pythonhosted.org/packages/62/3d/0158733932893e17f6008dadd74c8d7aed422fefc66e47fe77888014fe8c/ty-0.0.63-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7328d63c34587606dce02935a25404f54cd0161bfe413b8f7fc21d174aead612", size = 12619461, upload-time = "2026-07-23T11:41:18.538Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/e280ad095b050778f16f493d49a073fa5f6d8f301d3e2e59be6a672ba05c/ty-0.0.63-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504c4457f3a62afe836c1f26a2c9a12549299095f9cc4146778558df51a7515c", size = 12376402, upload-time = "2026-07-23T11:41:20.482Z" }, - { url = "https://files.pythonhosted.org/packages/57/57/619788bf335cb86b090470b557ae1eed33613dc24e12142505d32b462e58/ty-0.0.63-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7394ed39424b027c89d5f0c818871c791e33958b88f5bb13e14bd4a12a3ca631", size = 12671377, upload-time = "2026-07-23T11:41:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/07/a2/0aff2cdd3c98e2c4729337542b8da0ce1980790e1600e0c4a717a1f46293/ty-0.0.63-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:94c3f155230490f8fb505911655e940c630c25cc0c35a76690cb413254fb4437", size = 11768090, upload-time = "2026-07-23T11:41:24.558Z" }, - { url = "https://files.pythonhosted.org/packages/43/4a/cdb5f1d26154144dfee08e1bd671daf827238170f7cee9dad43990bb2016/ty-0.0.63-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c16e1d8b4f0ae99106d5f13a894034a202baf6cdab61e2bb1a239de82904f839", size = 11867747, upload-time = "2026-07-23T11:41:26.794Z" }, - { url = "https://files.pythonhosted.org/packages/7e/26/ecc09ecb70bc9fbfdf42fa57ff29568f173e8200df575a0d72dc2b8486f9/ty-0.0.63-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b66293dad89eaed4b9fbf3b661614dbeb85b59ba037178a3f9a0adedf264da5c", size = 12120000, upload-time = "2026-07-23T11:41:28.731Z" }, - { url = "https://files.pythonhosted.org/packages/14/07/862822f9c2c397785b69b25cf79b4dfc3c0d55684b9adf11ac194450f8e1/ty-0.0.63-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8b29cc832e2ec73502c97dd7325d6ae0e085b34a33529a0f785192317d9862fc", size = 12477862, upload-time = "2026-07-23T11:41:30.847Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c2/50b08b641578d6f48e8aa28a91d0de32c91aa24dd926ed199e8afd2d37ea/ty-0.0.63-py3-none-win32.whl", hash = "sha256:f0a8fbfd1f990c0c5d85cce018d438969ac79e49247e2192c9745394bb7d17ab", size = 11433155, upload-time = "2026-07-23T11:41:33.28Z" }, - { url = "https://files.pythonhosted.org/packages/92/2d/d422a5568f0d1f317186be22241288dc6e08b71dc24aca223f22038ac2d2/ty-0.0.63-py3-none-win_amd64.whl", hash = "sha256:2aa2370bdd6f42e9f37518c812379bef70b9162f9e5aad511495229b0b71cb93", size = 12450036, upload-time = "2026-07-23T11:41:35.559Z" }, - { url = "https://files.pythonhosted.org/packages/35/97/2c9748e28ead0650c7ad3e5f74f178832ceabd7cb5c272a882f29eb32ee4/ty-0.0.63-py3-none-win_arm64.whl", hash = "sha256:95ac1a62162c3c7ac204731e95ebf766d62a46a7bfa238a6acbe923fcb772cb1", size = 11826243, upload-time = "2026-07-23T11:41:37.749Z" }, +version = "0.0.79" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/d3/4fff47468a976c7a5ded9fe350734ca09b9e8460750327f2245ea3288d5d/ty-0.0.79.tar.gz", hash = "sha256:159a1aca70edebae32be08bfba2e5d543ffd8f9f380af160e0e713e85313b733", size = 7162150, upload-time = "2026-09-07T21:51:58.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/a3/2fa5b5495fe37351d77571eb0985476461be02c822bb39ad669b9fdb5bb8/ty-0.0.79-py3-none-linux_armv6l.whl", hash = "sha256:f60d968bbc6d52b663d3df4cae647d3cc68df4a7135bd9098e8d570bf9a5e0da", size = 13557478, upload-time = "2026-09-07T21:51:14.089Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f0/1659a926d2b19351839ca64787e1531c97fbe8bf35c3c47d20954f338428/ty-0.0.79-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4c95ae0ca67483c4f1231c4b2fd332b76f212e8d93154c385d77511d90dcbd8e", size = 13163780, upload-time = "2026-09-07T21:51:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b2/e0a8a8cf58b39f0d1f509a22550aed1e2cc12bb4a868170b43aede9fcd93/ty-0.0.79-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685888adb29b6e732ee325c6b6db92a422f72f3e4031c12450728c846e5e93fe", size = 12964040, upload-time = "2026-09-07T21:51:19.818Z" }, + { url = "https://files.pythonhosted.org/packages/88/a7/7d8fb6c958d88a63ad932421eff8848267bb9229136ef65d9f372aa2be7a/ty-0.0.79-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0535640db5dc02f9e14bf419ad2cf3acdd49681e70f233c96e96d16cfcefc00", size = 13010025, upload-time = "2026-09-07T21:51:22.468Z" }, + { url = "https://files.pythonhosted.org/packages/e3/4c/94aee26446f058e67b2c8ef0b25bdc1cf555e40eb692112d7a609f30c238/ty-0.0.79-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48cb24e21488f9d11cffa81d5a5f71f314c87383b7a5d28dabe6feaa3f3d7a34", size = 13332191, upload-time = "2026-09-07T21:51:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/9e7c0415fa6275500f10ce13f2bad62e03211ba5f3ea61a702d819328407/ty-0.0.79-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a35b3b116a591a68da5958c082247f0c3a50f134d0a60ff45d060eb3d2ad612", size = 14153955, upload-time = "2026-09-07T21:51:27.421Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/b27b510f973a314200cfda4f0500de3c65f6caa45a590355f6acc5e96289/ty-0.0.79-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcb760f059d660dfd5c9c6c33609478a267c3185c7448d72e6f7ff4572796bae", size = 14594578, upload-time = "2026-09-07T21:51:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fa/0ccc2e510c1c24682ae21c836c09be6dcf0b1aacab071c51a297d301601f/ty-0.0.79-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcdc1ae0c740f9b6d6536ffecf485616a8909bed5de88c6e870a65546a6a2d91", size = 14266409, upload-time = "2026-09-07T21:51:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c3/3a404d44e9768578daf7dfda06cba714eda6feac53272eda295947a8d4c7/ty-0.0.79-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c1e77d58e192c81b958783328b303ceda8706328a307c852ceb257505765458", size = 13658441, upload-time = "2026-09-07T21:51:35.283Z" }, + { url = "https://files.pythonhosted.org/packages/f6/23/b90a512055fdf6356e95c0f745cb9b443b9a4ac9d3a79f07ae6ed53ae9ca/ty-0.0.79-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a87f52183b976d9eda5fad28405b37621a265c3de5e9e279660556c715ed1805", size = 14195669, upload-time = "2026-09-07T21:51:37.848Z" }, + { url = "https://files.pythonhosted.org/packages/b0/05/d051248dcff852e8a3165a2feb164dc5b857ef237fa82e7ea2b3b7bac375/ty-0.0.79-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb429a44bc9e90649e69f739d70db4a305102a36586a810caee00c984c379997", size = 13127929, upload-time = "2026-09-07T21:51:40.431Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d1/60b5c980df55b1d5010a41b10d7c8cf6111b5d02ec0807f6f782bb3d88aa/ty-0.0.79-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1d857fa34d60b0981cf12ca19180ae5de70dd46306a9277bee772bb09e82ae5c", size = 13334425, upload-time = "2026-09-07T21:51:42.956Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/fcbfa731128f06b1c9be5074f51ce47da06842dc8f33c81e7c1c483b6e91/ty-0.0.79-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a95cffa2f30289b0b949db7611ae96ede0352f0f1d7ba07ea7cabb27616fccb9", size = 13629671, upload-time = "2026-09-07T21:51:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/0e/00/3004a1d375c2a835c7dbf01c480205a190f24b74c8b5c849fd2dbe078d69/ty-0.0.79-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d33df0f1bdee62dc366551d35b9830b1fa2e9ee2936a437abd435f71b3fce73e", size = 13939769, upload-time = "2026-09-07T21:51:48.033Z" }, + { url = "https://files.pythonhosted.org/packages/10/7d/f357f5768872ffa3a026707477a880aba7582af5f6c7c7eab74f61167f3d/ty-0.0.79-py3-none-win32.whl", hash = "sha256:ca266de079a187ed6f0f5b802f6fc3b26164753c7347a04daed21d86c938d33d", size = 12833644, upload-time = "2026-09-07T21:51:50.766Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f5/2a2d967be6286ee8fb626161e610fd48624ba5e6712cea932adb2d6b40b6/ty-0.0.79-py3-none-win_amd64.whl", hash = "sha256:88cb357d36ad79181015581365769fff4b1ec580cd12de83addfa98663214fd3", size = 13494258, upload-time = "2026-09-07T21:51:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/8b/74/49b88f104f88d9757497758bf57ba53e3612442b49500d1092d4d6f1daa3/ty-0.0.79-py3-none-win_arm64.whl", hash = "sha256:d29da73f2840ae2631bc62ef8f8d509a94edea596e5b3072851f67e4729d744c", size = 13327517, upload-time = "2026-09-07T21:51:55.602Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, ]