ida-code 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {ida_code-0.2.1 → ida_code-0.2.2}/CHANGELOG.md +14 -1
  2. {ida_code-0.2.1 → ida_code-0.2.2}/PKG-INFO +6 -2
  3. {ida_code-0.2.1 → ida_code-0.2.2}/README.md +4 -0
  4. {ida_code-0.2.1 → ida_code-0.2.2}/pyproject.toml +2 -2
  5. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/server.py +3 -2
  6. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/session.py +39 -2
  7. ida_code-0.2.2/tests/test_e2e.py +79 -0
  8. ida_code-0.2.2/tests/test_session.py +125 -0
  9. ida_code-0.2.1/tests/test_session.py +0 -59
  10. {ida_code-0.2.1 → ida_code-0.2.2}/.gitignore +0 -0
  11. {ida_code-0.2.1 → ida_code-0.2.2}/LICENSE +0 -0
  12. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/__init__.py +0 -0
  13. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/_search_utils.py +0 -0
  14. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/comments.py +0 -0
  15. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/config.py +0 -0
  16. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/doc_search.py +0 -0
  17. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/example_search.py +0 -0
  18. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/executor.py +0 -0
  19. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/guidelines.py +0 -0
  20. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/macho.py +0 -0
  21. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/prompts.py +0 -0
  22. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/snapshots.py +0 -0
  23. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/structures.py +0 -0
  24. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/undo.py +0 -0
  25. {ida_code-0.2.1 → ida_code-0.2.2}/src/ida_code/variables.py +0 -0
  26. {ida_code-0.2.1 → ida_code-0.2.2}/tests/__init__.py +0 -0
  27. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_comments.py +0 -0
  28. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_doc_search.py +0 -0
  29. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_example_search.py +0 -0
  30. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_executor.py +0 -0
  31. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_macho.py +0 -0
  32. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_search_utils.py +0 -0
  33. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_structures.py +0 -0
  34. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_undo.py +0 -0
  35. {ida_code-0.2.1 → ida_code-0.2.2}/tests/test_variables.py +0 -0
@@ -4,7 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.2.2] - 2026-05-07
8
+
9
+ ### Added
10
+
11
+ - **End-to-end regression test** — `tests/test_e2e.py` opens a real binary through fastmcp's in-process `Client` + `FastMCPTransport`, asserting the call returns within 15s. Catches future regressions that route idalib off the main thread (which would hang). Auto-skips when idalib isn't available.
12
+
13
+ ### Changed
14
+
15
+ - **Pin fastmcp back to `>=2.0,<3`** — v3 dispatches sync tool functions to `anyio.to_thread.run_sync`, but idalib hangs indefinitely when called from a non-main thread, so every idalib-touching tool wedged. v2 runs sync tools on the main thread and works in 0.7s on the same call. Reverts the v2→v3 bump from 0.2.1.
16
+ - **`open_database` refuses paths with unpacked fragments present** — if `.id0`/`.id1`/`.id2`/`.nam`/`.til` files exist for the target and `overwrite=False`, raise a clear `ToolError` listing them instead of letting idalib return an opaque `-1`. The message warns that the fragments may belong to another active IDA instance and only suggests `overwrite=True` if nothing else owns them.
17
+
18
+ ### Fixed
19
+
20
+ - **`open_database` overwrite cleans up unpacked fragments** — `overwrite=True` now also deletes `.id0`, `.id1`, `.id2`, `.nam`, and `.til` files, not just `.i64`/`.idb`. A failed open could leave these partial fragments behind, after which every subsequent attempt would fail immediately with a generic `-1` because IDA refused to overwrite the half-written unpacked database.
8
21
 
9
22
  ## [0.2.1] - 2026-05-05
10
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ida-code
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: MCP server for AI-assisted IDAPython scripting via idalib
5
5
  Project-URL: Homepage, https://github.com/Dil4rd/ida-code
6
6
  Project-URL: Repository, https://github.com/Dil4rd/ida-code
@@ -21,7 +21,7 @@ Classifier: Topic :: Security
21
21
  Classifier: Topic :: Software Development :: Disassemblers
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: fastmcp<4,>=3.0
24
+ Requires-Dist: fastmcp<3,>=2.0
25
25
  Requires-Dist: lief>=0.15
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -156,6 +156,10 @@ uv sync --extra dev
156
156
  uv run pytest
157
157
  ```
158
158
 
159
+ ## Known issues
160
+
161
+ See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for caveats and workarounds (e.g. why we pin `fastmcp<3`).
162
+
159
163
  The test suite covers the executor, doc/example search, comments, snapshots, structures, undo, variables, and Mach-O parsing. Tests that need idalib are skipped if it's not available.
160
164
 
161
165
  ## Credits
@@ -127,6 +127,10 @@ uv sync --extra dev
127
127
  uv run pytest
128
128
  ```
129
129
 
130
+ ## Known issues
131
+
132
+ See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for caveats and workarounds (e.g. why we pin `fastmcp<3`).
133
+
130
134
  The test suite covers the executor, doc/example search, comments, snapshots, structures, undo, variables, and Mach-O parsing. Tests that need idalib are skipped if it's not available.
131
135
 
132
136
  ## Credits
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ida-code"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "MCP server for AI-assisted IDAPython scripting via idalib"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Topic :: Software Development :: Disassemblers",
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  ]
23
- dependencies = ["fastmcp>=3.0,<4", "lief>=0.15"]
23
+ dependencies = ["fastmcp>=2.0,<3", "lief>=0.15"]
24
24
 
25
25
  [project.urls]
26
26
  Homepage = "https://github.com/Dil4rd/ida-code"
@@ -66,8 +66,9 @@ def open_database(
66
66
  Returns summary info (architecture, segments, entry points, function count).
67
67
  If a database is already open, it is closed first.
68
68
 
69
- Set overwrite=True to delete any existing .i64/.idb database and force
70
- a fresh analysis from the original binary.
69
+ Set overwrite=True to delete any existing .i64/.idb database (and any
70
+ unpacked .id0/.id1/.id2/.nam/.til fragments left by a failed open)
71
+ and force a fresh analysis from the original binary.
71
72
 
72
73
  *timeout* limits auto-analysis wait time in seconds (default 0 = unlimited).
73
74
  When the timeout expires the database stays open with partial analysis
@@ -149,6 +149,17 @@ def open(
149
149
 
150
150
  if overwrite:
151
151
  _remove_existing_databases(open_path)
152
+ else:
153
+ fragments = _list_unpacked_fragments(open_path)
154
+ if fragments:
155
+ raise ToolError(
156
+ f"Unpacked database fragments exist: {fragments}. "
157
+ f"This usually means another IDA instance has this database "
158
+ f"open, or a previous open failed and left them behind. "
159
+ f"If no other IDA process is using this database, re-call "
160
+ f"with overwrite=True to clean them up — but doing so while "
161
+ f"another IDA has it open will destroy that session's work."
162
+ )
152
163
 
153
164
  use_polling = auto_analysis and timeout > 0
154
165
  run_auto = auto_analysis and not use_polling
@@ -263,11 +274,37 @@ def _collect_summary(path: str) -> dict:
263
274
  }
264
275
 
265
276
 
277
+ _DB_EXTENSIONS = (".i64", ".idb", ".id0", ".id1", ".id2", ".nam", ".til")
278
+ _UNPACKED_FRAGMENT_EXTS = (".id0", ".id1", ".id2", ".nam", ".til")
279
+
280
+
281
+ def _list_unpacked_fragments(path: str) -> list[str]:
282
+ """Return any unpacked database fragments existing for *path*.
283
+
284
+ These exist while a database is open in IDA, and can also be left
285
+ behind when a previous open failed mid-unpacking. Their presence
286
+ makes ``idapro.open_database`` refuse the path with rc=-1.
287
+ """
288
+ from pathlib import Path
289
+ p = Path(path)
290
+ found = []
291
+ for ext in _UNPACKED_FRAGMENT_EXTS:
292
+ for candidate in {p.with_suffix(ext), Path(str(p) + ext)}:
293
+ if candidate.is_file():
294
+ found.append(str(candidate))
295
+ return found
296
+
297
+
266
298
  def _remove_existing_databases(path: str) -> None:
267
- """Remove existing IDA database files so a fresh analysis starts."""
299
+ """Remove existing IDA database files and unpacked fragments.
300
+
301
+ Includes the unpacked fragment extensions (.id0/.id1/.id2/.nam/.til) so
302
+ that a half-written database from a previously failed open does not
303
+ block the next attempt.
304
+ """
268
305
  from pathlib import Path
269
306
  p = Path(path)
270
- for ext in (".i64", ".idb"):
307
+ for ext in _DB_EXTENSIONS:
271
308
  for candidate in {p.with_suffix(ext), Path(str(p) + ext)}:
272
309
  if candidate.is_file():
273
310
  candidate.unlink()
@@ -0,0 +1,79 @@
1
+ """End-to-end test exercising the MCP server through fastmcp's in-process
2
+ client. Regression guard: any change that pushes idalib calls onto a
3
+ worker thread (e.g. a future fastmcp version doing async-hygiene
4
+ dispatch of sync tools) hangs idalib and trips the asyncio timeout
5
+ below — failing fast instead of hanging CI indefinitely.
6
+
7
+ Auto-skipped when idalib isn't loadable.
8
+ """
9
+
10
+ import asyncio
11
+ import shutil
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ # Importing ida_code.session has the side effect of inserting idalib's
18
+ # python dir into sys.path and `import idapro`. If idalib isn't present
19
+ # the import fails — skip the whole module instead of erroring.
20
+ try:
21
+ from ida_code import session
22
+ from ida_code.server import mcp
23
+ except ImportError as exc:
24
+ pytest.skip(f"idalib not available: {exc}", allow_module_level=True)
25
+
26
+ from fastmcp.client import Client # noqa: E402
27
+ from fastmcp.client.transports import FastMCPTransport # noqa: E402
28
+
29
+
30
+ def _venv_binary() -> Path:
31
+ """A small native extension shipped in the venv — analyzes in ~2s cold."""
32
+ import charset_normalizer.md as _md
33
+ return Path(_md.__file__)
34
+
35
+
36
+ @pytest.fixture
37
+ def target(tmp_path):
38
+ src = _venv_binary()
39
+ if not src.is_file():
40
+ pytest.skip(f"venv binary not found: {src}")
41
+ dst = tmp_path / "target.so"
42
+ shutil.copy(src, dst)
43
+ yield str(dst)
44
+ # Make sure no database stays open across tests.
45
+ if session.get_state() == session.State.DATABASE_OPEN:
46
+ try:
47
+ session.close()
48
+ except Exception:
49
+ pass
50
+
51
+
52
+ async def _open_and_close(target_path: str) -> float:
53
+ async with Client(FastMCPTransport(mcp)) as client:
54
+ t0 = time.monotonic()
55
+ r = await asyncio.wait_for(
56
+ client.call_tool("open_database", {"path": target_path}),
57
+ timeout=30,
58
+ )
59
+ elapsed = time.monotonic() - t0
60
+
61
+ data = r.data if hasattr(r, "data") else r
62
+ assert isinstance(data, dict), f"unexpected result type: {type(data)}"
63
+ assert data.get("function_count", 0) > 0, f"no functions analyzed: {data}"
64
+
65
+ await asyncio.wait_for(
66
+ client.call_tool("close_database", {}),
67
+ timeout=10,
68
+ )
69
+ return elapsed
70
+
71
+
72
+ def test_open_database_via_in_process_client(target):
73
+ """Open a real binary through the MCP tool surface and verify it returns
74
+ a populated summary in well under the timeout. A future regression that
75
+ routes idalib off the main thread would hit the 30s ``wait_for``."""
76
+ elapsed = asyncio.run(_open_and_close(target))
77
+ # Generous bound: 16KB binary, ~2s cold standalone. Anything near the
78
+ # 30s ceiling means dispatch is wrong, not just slow.
79
+ assert elapsed < 15, f"open took {elapsed:.1f}s — possible thread regression"
@@ -0,0 +1,125 @@
1
+ """Unit tests for ida_code.session.
2
+
3
+ These tests work without idalib — they mock the session module globals
4
+ and os.path.isfile to test the file-existence guard logic.
5
+ """
6
+
7
+ import os
8
+ import tempfile
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+ from fastmcp.exceptions import ToolError
13
+
14
+ from ida_code import session
15
+
16
+
17
+ class TestRequireOpen:
18
+ def setup_method(self):
19
+ """Save and restore session module globals around each test."""
20
+ self._orig_state = session._state
21
+ self._orig_db_file_path = session._db_file_path
22
+ self._orig_orphaned = session._orphaned
23
+
24
+ def teardown_method(self):
25
+ session._state = self._orig_state
26
+ session._db_file_path = self._orig_db_file_path
27
+ session._orphaned = self._orig_orphaned
28
+
29
+ def test_raises_when_no_database(self):
30
+ session._state = session.State.NO_DATABASE
31
+ with pytest.raises(ToolError, match="No database is open"):
32
+ session.require_open()
33
+
34
+ @patch("ida_code.session.os.path.isfile", return_value=True)
35
+ def test_passes_when_open_and_file_exists(self, mock_isfile):
36
+ session._state = session.State.DATABASE_OPEN
37
+ session._db_file_path = "/tmp/test.i64"
38
+ session.require_open() # should not raise
39
+ mock_isfile.assert_called_once_with("/tmp/test.i64")
40
+
41
+ @patch("ida_code.session.os.path.isfile", return_value=False)
42
+ @patch("ida_code.executor.reset")
43
+ def test_resets_state_when_file_missing(self, mock_reset, mock_isfile):
44
+ session._state = session.State.DATABASE_OPEN
45
+ session._db_file_path = "/tmp/gone.i64"
46
+ session._orphaned = False
47
+
48
+ with pytest.raises(ToolError, match="moved or deleted"):
49
+ session.require_open()
50
+
51
+ assert session._state == session.State.NO_DATABASE
52
+ assert session._orphaned is True
53
+ assert session._db_file_path is None
54
+ mock_reset.assert_called_once()
55
+
56
+ def test_passes_when_db_file_path_is_none(self):
57
+ """Graceful degradation: if _db_file_path was never set, skip the
58
+ file check and rely only on the state enum."""
59
+ session._state = session.State.DATABASE_OPEN
60
+ session._db_file_path = None
61
+ session.require_open() # should not raise
62
+
63
+
64
+ class TestUnpackedFragmentPrecheck:
65
+ """`open()` must refuse paths with leftover unpacked fragments unless
66
+ overwrite=True — calling idapro on such a path returns rc=-1 and can
67
+ leave idalib's internal state corrupted."""
68
+
69
+ def setup_method(self):
70
+ self._orig_state = session._state
71
+
72
+ def teardown_method(self):
73
+ session._state = self._orig_state
74
+
75
+ def test_lists_unpacked_fragments(self):
76
+ with tempfile.TemporaryDirectory() as tmp:
77
+ binary = os.path.join(tmp, "x.so")
78
+ open(binary, "w").close()
79
+ for ext in (".id0", ".id1", ".nam"):
80
+ open(binary + ext, "w").close()
81
+ found = session._list_unpacked_fragments(binary)
82
+ assert sorted(found) == sorted(binary + ext for ext in (".id0", ".id1", ".nam"))
83
+
84
+ def test_returns_empty_for_clean_path(self):
85
+ with tempfile.TemporaryDirectory() as tmp:
86
+ binary = os.path.join(tmp, "x.so")
87
+ open(binary, "w").close()
88
+ assert session._list_unpacked_fragments(binary) == []
89
+
90
+ def test_does_not_flag_packed_databases(self):
91
+ """A `.i64` next to the binary is a valid warm cache, not a fragment."""
92
+ with tempfile.TemporaryDirectory() as tmp:
93
+ binary = os.path.join(tmp, "x.so")
94
+ open(binary, "w").close()
95
+ open(binary + ".i64", "w").close()
96
+ assert session._list_unpacked_fragments(binary) == []
97
+
98
+ def test_open_raises_with_fragments_no_overwrite(self):
99
+ with tempfile.TemporaryDirectory() as tmp:
100
+ binary = os.path.join(tmp, "x.so")
101
+ open(binary, "w").close()
102
+ open(binary + ".id0", "w").close()
103
+
104
+ session._state = session.State.NO_DATABASE
105
+ with patch("ida_code.session.idapro.open_database") as mock_open:
106
+ with pytest.raises(ToolError, match="Unpacked database fragments"):
107
+ session.open(binary, auto_analysis=False)
108
+ mock_open.assert_not_called() # idalib must not be invoked
109
+
110
+ def test_open_proceeds_with_fragments_when_overwrite_true(self):
111
+ """overwrite=True clears fragments and proceeds with the open."""
112
+ with tempfile.TemporaryDirectory() as tmp:
113
+ binary = os.path.join(tmp, "x.so")
114
+ open(binary, "w").close()
115
+ open(binary + ".id0", "w").close()
116
+
117
+ session._state = session.State.NO_DATABASE
118
+ with patch("ida_code.session.idapro.open_database", return_value=0), \
119
+ patch("ida_code.session._collect_summary", return_value={"path": binary}), \
120
+ patch("ida_code.executor.reset"):
121
+ # Should not raise — fragment is cleaned up before open.
122
+ session.open(binary, auto_analysis=False, overwrite=True)
123
+ # Fragment file should have been removed.
124
+ assert not os.path.isfile(binary + ".id0")
125
+ session._state = session.State.NO_DATABASE # reset for teardown
@@ -1,59 +0,0 @@
1
- """Unit tests for ida_code.session.require_open().
2
-
3
- These tests work without idalib — they mock the session module globals
4
- and os.path.isfile to test the file-existence guard logic.
5
- """
6
-
7
- from unittest.mock import patch
8
-
9
- import pytest
10
- from fastmcp.exceptions import ToolError
11
-
12
- from ida_code import session
13
-
14
-
15
- class TestRequireOpen:
16
- def setup_method(self):
17
- """Save and restore session module globals around each test."""
18
- self._orig_state = session._state
19
- self._orig_db_file_path = session._db_file_path
20
- self._orig_orphaned = session._orphaned
21
-
22
- def teardown_method(self):
23
- session._state = self._orig_state
24
- session._db_file_path = self._orig_db_file_path
25
- session._orphaned = self._orig_orphaned
26
-
27
- def test_raises_when_no_database(self):
28
- session._state = session.State.NO_DATABASE
29
- with pytest.raises(ToolError, match="No database is open"):
30
- session.require_open()
31
-
32
- @patch("ida_code.session.os.path.isfile", return_value=True)
33
- def test_passes_when_open_and_file_exists(self, mock_isfile):
34
- session._state = session.State.DATABASE_OPEN
35
- session._db_file_path = "/tmp/test.i64"
36
- session.require_open() # should not raise
37
- mock_isfile.assert_called_once_with("/tmp/test.i64")
38
-
39
- @patch("ida_code.session.os.path.isfile", return_value=False)
40
- @patch("ida_code.executor.reset")
41
- def test_resets_state_when_file_missing(self, mock_reset, mock_isfile):
42
- session._state = session.State.DATABASE_OPEN
43
- session._db_file_path = "/tmp/gone.i64"
44
- session._orphaned = False
45
-
46
- with pytest.raises(ToolError, match="moved or deleted"):
47
- session.require_open()
48
-
49
- assert session._state == session.State.NO_DATABASE
50
- assert session._orphaned is True
51
- assert session._db_file_path is None
52
- mock_reset.assert_called_once()
53
-
54
- def test_passes_when_db_file_path_is_none(self):
55
- """Graceful degradation: if _db_file_path was never set, skip the
56
- file check and rely only on the state enum."""
57
- session._state = session.State.DATABASE_OPEN
58
- session._db_file_path = None
59
- session.require_open() # should not raise
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes