ida-code 0.2.1__py3-none-any.whl

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.
ida_code/session.py ADDED
@@ -0,0 +1,293 @@
1
+ import atexit
2
+ import enum
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+ from ida_code.config import IDA_INSTALL_DIR
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ # Add idalib's Python package to sys.path so `idapro` can be imported
12
+ # without requiring manual `pip install`.
13
+ _idalib_python = IDA_INSTALL_DIR / "idalib" / "python"
14
+ if _idalib_python.is_dir() and str(_idalib_python) not in sys.path:
15
+ sys.path.insert(0, str(_idalib_python))
16
+
17
+ # Set IDADIR so idapro finds the IDA install directory
18
+ # without requiring `py-activate-idalib.py`.
19
+ os.environ.setdefault("IDADIR", str(IDA_INSTALL_DIR))
20
+
21
+ try:
22
+ import idapro
23
+ except ImportError as e:
24
+ raise ImportError(
25
+ f"Could not import idapro from {_idalib_python}. "
26
+ f"Set IDA_INSTALL_DIR to your IDA Pro 9.2+ installation directory "
27
+ f"(currently {IDA_INSTALL_DIR}). "
28
+ f"Original error: {e}"
29
+ ) from e
30
+
31
+
32
+ class State(enum.Enum):
33
+ NO_DATABASE = "no_database"
34
+ DATABASE_OPEN = "database_open"
35
+
36
+
37
+ _state = State.NO_DATABASE
38
+ _db_path: str | None = None
39
+ _db_file_path: str | None = None # actual .i64/.idb path on disk
40
+ _orphaned: bool = False # True when database file vanished from disk
41
+
42
+
43
+ def get_state() -> State:
44
+ return _state
45
+
46
+
47
+ def _find_database_file(path: str) -> str | None:
48
+ """Find the .i64/.idb database file for *path*.
49
+
50
+ Checks both naming conventions IDA uses: replace-suffix and append.
51
+ Returns the path if found, else None.
52
+ """
53
+ from pathlib import Path
54
+ p = Path(path)
55
+ for ext in (".i64", ".idb"):
56
+ for candidate in (p.with_suffix(ext), Path(str(p) + ext)):
57
+ if candidate.is_file():
58
+ return str(candidate)
59
+ return None
60
+
61
+
62
+ def require_open() -> None:
63
+ """Raise ``ToolError`` if no database is usable.
64
+
65
+ Checks both the state enum *and* that the database file still exists
66
+ on disk. If the file has been moved or deleted, resets state to
67
+ ``NO_DATABASE`` and raises a descriptive error instead of letting
68
+ idalib segfault.
69
+ """
70
+ from fastmcp.exceptions import ToolError
71
+
72
+ global _state, _db_file_path, _orphaned
73
+
74
+ if _state == State.NO_DATABASE:
75
+ raise ToolError("No database is open. Call open_database first.")
76
+
77
+ if _db_file_path is not None and not os.path.isfile(_db_file_path):
78
+ log.warning(
79
+ "Database file missing: %s — resetting state", _db_file_path
80
+ )
81
+ _state = State.NO_DATABASE
82
+ _orphaned = True
83
+ _db_file_path = None
84
+ from ida_code.executor import reset
85
+ reset()
86
+ raise ToolError(
87
+ "The database file has been moved or deleted. "
88
+ "Call open_database with the new path."
89
+ )
90
+
91
+
92
+ def info() -> dict:
93
+ """Return a summary dict of the current database.
94
+
95
+ Raises ``ToolError`` if no database is open.
96
+ """
97
+ require_open()
98
+ return _collect_summary(_db_path or "<unknown>")
99
+
100
+
101
+ def open(
102
+ path: str,
103
+ auto_analysis: bool = True,
104
+ overwrite: bool = False,
105
+ timeout: int = 0,
106
+ arch: str | None = None,
107
+ ) -> dict:
108
+ """Open a binary/database via idalib. Returns a summary dict.
109
+
110
+ *timeout* limits auto-analysis wait time in seconds (0 = unlimited).
111
+ When the timeout expires, the database remains open with partial analysis.
112
+
113
+ *arch* selects a specific architecture slice from a fat (universal) Mach-O
114
+ binary (e.g. "arm64e", "x86_64"). The slice is extracted to a temporary
115
+ thin file before opening. Ignored for non-fat binaries.
116
+
117
+ Raises ``ToolError`` on failure.
118
+ """
119
+ from fastmcp.exceptions import ToolError
120
+
121
+ from ida_code import macho
122
+
123
+ global _state, _db_path, _db_file_path, _orphaned
124
+
125
+ _orphaned = False
126
+
127
+ # Close any existing database first.
128
+ if _state == State.DATABASE_OPEN:
129
+ close()
130
+
131
+ # If an architecture is requested, extract the slice from a fat Mach-O.
132
+ open_path = path
133
+ if arch:
134
+ try:
135
+ open_path = macho.extract_slice(path, arch)
136
+ except ValueError:
137
+ # Not a fat binary or arch not found.
138
+ available = macho.list_architectures(path)
139
+ if available:
140
+ raise ToolError(
141
+ f"Architecture '{arch}' not found. "
142
+ f"Available: {available}"
143
+ )
144
+ log.warning(
145
+ "arch='%s' requested but %s is not a fat Mach-O; "
146
+ "opening as-is",
147
+ arch, path,
148
+ )
149
+
150
+ if overwrite:
151
+ _remove_existing_databases(open_path)
152
+
153
+ use_polling = auto_analysis and timeout > 0
154
+ run_auto = auto_analysis and not use_polling
155
+
156
+ log.info(
157
+ "Opening database: %s (auto_analysis=%s, overwrite=%s, timeout=%d, arch=%s)",
158
+ open_path, auto_analysis, overwrite, timeout, arch,
159
+ )
160
+ rc = idapro.open_database(open_path, run_auto)
161
+ if rc != 0:
162
+ log.error("open_database failed with code %d for %s", rc, open_path)
163
+ raise ToolError(f"open_database returned code {rc}")
164
+
165
+ _state = State.DATABASE_OPEN
166
+ _db_path = open_path
167
+ _db_file_path = _find_database_file(open_path)
168
+
169
+ timed_out = False
170
+ if use_polling:
171
+ timed_out = _wait_for_analysis(timeout)
172
+
173
+ log.info("Database opened successfully: %s", open_path)
174
+
175
+ # Reset executor namespace for the new database.
176
+ from ida_code.executor import reset
177
+ reset()
178
+
179
+ summary = _collect_summary(open_path)
180
+ if arch:
181
+ summary["arch"] = arch
182
+ summary["original_path"] = path
183
+ summary["warning"] = (
184
+ "Auto-analysis timed out \u2014 results may be incomplete."
185
+ if timed_out
186
+ else None
187
+ )
188
+ return summary
189
+
190
+
191
+ def _wait_for_analysis(timeout: int) -> bool:
192
+ """Poll until auto-analysis finishes or *timeout* seconds elapse.
193
+
194
+ Returns True if the timeout expired (analysis incomplete).
195
+ """
196
+ import time
197
+ import ida_auto
198
+ import ida_funcs
199
+
200
+ deadline = time.monotonic() + timeout
201
+ interval = 0.5 # seconds between polls
202
+ last_count = 0
203
+
204
+ while not ida_auto.auto_is_ok():
205
+ now = time.monotonic()
206
+ if now >= deadline:
207
+ log.warning("Auto-analysis timed out after %ds", timeout)
208
+ return True
209
+
210
+ count = ida_funcs.get_func_qty()
211
+ if count != last_count:
212
+ log.info("Auto-analysis in progress: %d functions so far", count)
213
+ last_count = count
214
+
215
+ remaining = deadline - now
216
+ time.sleep(min(interval, remaining))
217
+
218
+ log.info("Auto-analysis completed")
219
+ return False
220
+
221
+
222
+ def _collect_summary(path: str) -> dict:
223
+ import ida_ida
224
+ import ida_entry
225
+ import ida_segment
226
+ import idautils
227
+
228
+ processor = ida_ida.inf_get_procname()
229
+ bits = 64 if ida_ida.inf_is_64bit() else (32 if ida_ida.inf_is_32bit() else 16)
230
+
231
+ # Segments
232
+ segments = []
233
+ for seg_ea in idautils.Segments():
234
+ seg = ida_segment.getseg(seg_ea)
235
+ name = ida_segment.get_segm_name(seg)
236
+ segments.append({
237
+ "name": name,
238
+ "start": f"{seg.start_ea:#x}",
239
+ "end": f"{seg.end_ea:#x}",
240
+ })
241
+
242
+ # Entry points (capped to avoid huge responses for symbol-heavy binaries)
243
+ entry_count = ida_entry.get_entry_qty()
244
+ max_entries = 20
245
+ entry_points = []
246
+ for i in range(min(entry_count, max_entries)):
247
+ ordinal = ida_entry.get_entry_ordinal(i)
248
+ ea = ida_entry.get_entry(ordinal)
249
+ name = ida_entry.get_entry_name(ordinal)
250
+ entry_points.append({"name": name, "address": f"{ea:#x}"})
251
+
252
+ # Function count
253
+ func_count = sum(1 for _ in idautils.Functions())
254
+
255
+ return {
256
+ "path": path,
257
+ "processor": processor,
258
+ "bits": bits,
259
+ "function_count": func_count,
260
+ "segments": segments,
261
+ "entry_point_count": entry_count,
262
+ "entry_points": entry_points,
263
+ }
264
+
265
+
266
+ def _remove_existing_databases(path: str) -> None:
267
+ """Remove existing IDA database files so a fresh analysis starts."""
268
+ from pathlib import Path
269
+ p = Path(path)
270
+ for ext in (".i64", ".idb"):
271
+ for candidate in {p.with_suffix(ext), Path(str(p) + ext)}:
272
+ if candidate.is_file():
273
+ candidate.unlink()
274
+
275
+
276
+ def close() -> None:
277
+ """Close the current database."""
278
+ global _state, _db_path, _db_file_path, _orphaned
279
+ if _state == State.DATABASE_OPEN:
280
+ log.info("Closing database")
281
+ if not _orphaned:
282
+ idapro.close_database()
283
+ _state = State.NO_DATABASE
284
+ _db_path = None
285
+ _db_file_path = None
286
+ _orphaned = False
287
+ from ida_code.executor import reset
288
+ reset()
289
+
290
+
291
+ @atexit.register
292
+ def _cleanup():
293
+ close()
ida_code/snapshots.py ADDED
@@ -0,0 +1,110 @@
1
+ import logging
2
+ import os
3
+
4
+ from fastmcp.exceptions import ToolError
5
+
6
+ from ida_code import session
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ def _build_snapshot_list():
12
+ """Return list of snapshot_t from the snapshot tree."""
13
+ import ida_loader
14
+
15
+ root = ida_loader.snapshot_t()
16
+ ida_loader.build_snapshot_tree(root)
17
+ return list(root.children)
18
+
19
+
20
+ def _find_by_id(snapshots, id_str: str):
21
+ """Find a snapshot by its string ID. Raises ToolError if not found."""
22
+ for ss in snapshots:
23
+ if str(ss.id) == id_str:
24
+ return ss
25
+ raise ToolError(f"Snapshot with id '{id_str}' not found.")
26
+
27
+
28
+ def _to_dict(ss) -> dict:
29
+ """Convert a snapshot_t to a plain dict."""
30
+ return {
31
+ "id": str(ss.id),
32
+ "desc": ss.desc,
33
+ "filename": ss.filename,
34
+ }
35
+
36
+
37
+ def list_snapshots() -> dict:
38
+ """List all snapshots for the current database."""
39
+ session.require_open()
40
+ snapshots = _build_snapshot_list()
41
+ return {
42
+ "snapshots": [_to_dict(ss) for ss in snapshots],
43
+ "count": len(snapshots),
44
+ }
45
+
46
+
47
+ def create_snapshot(desc: str = "") -> dict:
48
+ """Create a new database snapshot."""
49
+ session.require_open()
50
+
51
+ import ida_kernwin
52
+ import ida_loader
53
+
54
+ ss = ida_loader.snapshot_t()
55
+ ss.desc = desc[:ida_loader.MAX_DATABASE_DESCRIPTION]
56
+
57
+ ok, err = ida_kernwin.take_database_snapshot(ss)
58
+ if not ok:
59
+ raise ToolError(f"Failed to create snapshot: {err}")
60
+
61
+ log.info("Created snapshot id=%s desc=%r", ss.id, ss.desc)
62
+ return _to_dict(ss)
63
+
64
+
65
+ def restore_snapshot(snapshot_id: str) -> dict:
66
+ """Restore the database to a previous snapshot."""
67
+ session.require_open()
68
+
69
+ import ida_kernwin
70
+
71
+ snapshots = _build_snapshot_list()
72
+ ss = _find_by_id(snapshots, snapshot_id)
73
+
74
+ # restore_database_snapshot takes (snapshot, callback, userdata).
75
+ # In idalib headless mode the restore is synchronous; the callback
76
+ # receives (error_msg_or_empty, userdata).
77
+ restore_err = []
78
+
79
+ def _cb(err_msg, ud):
80
+ if err_msg:
81
+ restore_err.append(err_msg)
82
+
83
+ ida_kernwin.restore_database_snapshot(ss, _cb, None)
84
+
85
+ if restore_err:
86
+ raise ToolError(f"Failed to restore snapshot: {restore_err[0]}")
87
+
88
+ # Database state changed — reset executor namespace.
89
+ from ida_code.executor import reset
90
+ reset()
91
+
92
+ log.info("Restored snapshot id=%s", snapshot_id)
93
+ return {"status": "restored", "id": snapshot_id}
94
+
95
+
96
+ def remove_snapshot(snapshot_id: str) -> dict:
97
+ """Remove a snapshot by deleting its file from disk."""
98
+ session.require_open()
99
+
100
+ snapshots = _build_snapshot_list()
101
+ ss = _find_by_id(snapshots, snapshot_id)
102
+
103
+ filename = ss.filename
104
+ try:
105
+ os.remove(filename)
106
+ except OSError as e:
107
+ raise ToolError(f"Failed to remove snapshot file '{filename}': {e}")
108
+
109
+ log.info("Removed snapshot id=%s file=%s", snapshot_id, filename)
110
+ return {"status": "removed", "id": snapshot_id, "filename": filename}
ida_code/structures.py ADDED
@@ -0,0 +1,227 @@
1
+ """Structure (struct/union) management via IDA type info library."""
2
+
3
+ import logging
4
+ import re
5
+
6
+ from fastmcp.exceptions import ToolError
7
+
8
+ from ida_code import session
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ _NAME_RE = re.compile(r"(?:struct|union)\s+(\w+)\s*\{")
13
+
14
+
15
+ def _extract_name(c_code: str) -> str:
16
+ """Extract the struct/union name from a C definition string.
17
+
18
+ Raises ToolError if no name can be found.
19
+ """
20
+ m = _NAME_RE.search(c_code)
21
+ if not m:
22
+ raise ToolError(
23
+ "Could not extract struct/union name from definition. "
24
+ "Expected format: 'struct name { ... };' or 'union name { ... };'"
25
+ )
26
+ return m.group(1)
27
+
28
+
29
+ def _get_struct_tinfo(name: str):
30
+ """Look up a struct/union by name, returning (tinfo, ordinal).
31
+
32
+ Raises ToolError if the type is not found.
33
+ """
34
+ import ida_typeinf
35
+
36
+ til = ida_typeinf.get_idati()
37
+ tif = ida_typeinf.tinfo_t()
38
+ if not tif.get_named_type(til, name):
39
+ raise ToolError(f"Structure '{name}' not found.")
40
+ ordinal = ida_typeinf.get_type_ordinal(til, name)
41
+ return tif, ordinal
42
+
43
+
44
+ def _annotated_definition(tif, ordinal: int) -> str:
45
+ """Build a C definition with ``/* offset */`` comments on each member."""
46
+ import ida_typeinf
47
+ import idc
48
+
49
+ # Get base definition from IDA.
50
+ if ordinal <= 0:
51
+ return ""
52
+ raw = idc.print_decls(ordinal, 0) or ""
53
+ raw = raw.strip()
54
+ if not raw:
55
+ return ""
56
+
57
+ # Extract member offsets.
58
+ udt = ida_typeinf.udt_type_data_t()
59
+ if not tif.get_udt_details(udt):
60
+ return raw
61
+
62
+ offsets = {} # member name -> offset in bytes
63
+ for i in range(udt.size()):
64
+ udm = udt.at(i)
65
+ offsets[udm.name] = udm.offset // 8
66
+
67
+ # Annotate each member line inside the braces.
68
+ lines = raw.split("\n")
69
+ out = []
70
+ for line in lines:
71
+ stripped = line.strip()
72
+ # Skip opening/closing braces and empty lines.
73
+ if stripped in ("{", "};", "") or stripped.startswith("struct ") or stripped.startswith("union "):
74
+ out.append(line)
75
+ continue
76
+ # Try to match a member name in this line.
77
+ for mname, off in offsets.items():
78
+ if mname in stripped:
79
+ line = f"{line} /* {off:#x} */"
80
+ break
81
+ out.append(line)
82
+ return "\n".join(out)
83
+
84
+
85
+ def _struct_to_dict(name: str) -> dict:
86
+ """Convert a named struct/union to a result dict with C definition."""
87
+ import ida_typeinf
88
+
89
+ tif, ordinal = _get_struct_tinfo(name)
90
+ size = tif.get_size()
91
+ is_union = tif.is_union()
92
+ member_count = tif.get_udt_nmembers()
93
+
94
+ # Get alignment from UDT details.
95
+ alignment = 0
96
+ udt = ida_typeinf.udt_type_data_t()
97
+ if tif.get_udt_details(udt):
98
+ alignment = udt.effalign
99
+
100
+ definition = _annotated_definition(tif, ordinal)
101
+
102
+ return {
103
+ "name": name,
104
+ "size": size,
105
+ "is_union": is_union,
106
+ "alignment": alignment,
107
+ "member_count": member_count,
108
+ "definition": definition,
109
+ }
110
+
111
+
112
+ def list_structures(offset: int = 0, limit: int = 50, filter: str = "") -> dict:
113
+ """List structures in the database with pagination."""
114
+ session.require_open()
115
+
116
+ import ida_typeinf
117
+ import idautils
118
+
119
+ all_structs = list(idautils.Structs())
120
+ til = ida_typeinf.get_idati()
121
+ structures = []
122
+ skipped = 0
123
+
124
+ for ordinal, sid, name in all_structs:
125
+ if filter and filter.lower() not in name.lower():
126
+ continue
127
+ if skipped < offset:
128
+ skipped += 1
129
+ continue
130
+
131
+ tif = ida_typeinf.tinfo_t()
132
+ if tif.get_named_type(til, name):
133
+ size = tif.get_size()
134
+ member_count = tif.get_udt_nmembers()
135
+ udt = ida_typeinf.udt_type_data_t()
136
+ alignment = udt.effalign if tif.get_udt_details(udt) else 0
137
+ else:
138
+ size = 0
139
+ member_count = 0
140
+ alignment = 0
141
+
142
+ structures.append({
143
+ "name": name,
144
+ "size": size,
145
+ "alignment": alignment,
146
+ "member_count": member_count,
147
+ })
148
+ if len(structures) >= limit:
149
+ break
150
+
151
+ return {
152
+ "structures": structures,
153
+ "total": len(all_structs),
154
+ "showing": len(structures),
155
+ "offset": offset,
156
+ "filter": filter,
157
+ }
158
+
159
+
160
+ def get_structure(name: str) -> dict:
161
+ """Get detailed info about a structure by name."""
162
+ session.require_open()
163
+ return _struct_to_dict(name)
164
+
165
+
166
+ def create_structure(definition: str) -> dict:
167
+ """Create a new structure from a C definition string."""
168
+ session.require_open()
169
+
170
+ import ida_typeinf
171
+ import idc
172
+
173
+ name = _extract_name(definition)
174
+
175
+ # Check it doesn't already exist.
176
+ til = ida_typeinf.get_idati()
177
+ tif = ida_typeinf.tinfo_t()
178
+ if tif.get_named_type(til, name):
179
+ raise ToolError(f"Structure '{name}' already exists. Use edit_structure to modify it.")
180
+
181
+ result = idc.parse_decls(definition, idc.PT_SIL)
182
+ if result != 0:
183
+ raise ToolError(f"Failed to parse definition (error code {result}). Check C syntax.")
184
+
185
+ log.info("Created structure '%s'", name)
186
+ return _struct_to_dict(name)
187
+
188
+
189
+ def edit_structure(definition: str) -> dict:
190
+ """Edit an existing structure by replacing its definition."""
191
+ session.require_open()
192
+
193
+ import ida_typeinf
194
+ import idc
195
+
196
+ name = _extract_name(definition)
197
+
198
+ # Check it exists.
199
+ til = ida_typeinf.get_idati()
200
+ tif = ida_typeinf.tinfo_t()
201
+ if not tif.get_named_type(til, name):
202
+ raise ToolError(f"Structure '{name}' not found. Use create_structure to create it.")
203
+
204
+ result = idc.parse_decls(definition, idc.PT_SIL | idc.PT_REPLACE)
205
+ if result != 0:
206
+ raise ToolError(f"Failed to parse definition (error code {result}). Check C syntax.")
207
+
208
+ log.info("Edited structure '%s'", name)
209
+ return _struct_to_dict(name)
210
+
211
+
212
+ def delete_structure(name: str) -> dict:
213
+ """Delete a structure by name."""
214
+ session.require_open()
215
+
216
+ import ida_typeinf
217
+
218
+ # Check it exists.
219
+ til = ida_typeinf.get_idati()
220
+ tif = ida_typeinf.tinfo_t()
221
+ if not tif.get_named_type(til, name):
222
+ raise ToolError(f"Structure '{name}' not found.")
223
+
224
+ ida_typeinf.del_named_type(til, name, ida_typeinf.NTF_TYPE)
225
+
226
+ log.info("Deleted structure '%s'", name)
227
+ return {"status": "deleted", "name": name}