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/__init__.py +2 -0
- ida_code/_search_utils.py +33 -0
- ida_code/comments.py +191 -0
- ida_code/config.py +9 -0
- ida_code/doc_search.py +255 -0
- ida_code/example_search.py +570 -0
- ida_code/executor.py +145 -0
- ida_code/guidelines.py +370 -0
- ida_code/macho.py +67 -0
- ida_code/prompts.py +176 -0
- ida_code/server.py +1011 -0
- ida_code/session.py +293 -0
- ida_code/snapshots.py +110 -0
- ida_code/structures.py +227 -0
- ida_code/undo.py +102 -0
- ida_code/variables.py +206 -0
- ida_code-0.2.1.dist-info/METADATA +167 -0
- ida_code-0.2.1.dist-info/RECORD +21 -0
- ida_code-0.2.1.dist-info/WHEEL +4 -0
- ida_code-0.2.1.dist-info/entry_points.txt +2 -0
- ida_code-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Index and search official IDAPython example scripts.
|
|
2
|
+
|
|
3
|
+
Parses metadata from two sources:
|
|
4
|
+
1. ``index.md`` — structured markdown with titles, descriptions, keywords,
|
|
5
|
+
difficulty levels, categories, and curated "APIs Used" lists.
|
|
6
|
+
2. Each ``.py`` file — ``ast`` module extracts the structured docstring,
|
|
7
|
+
imports, top-level definitions, and ``ida_*`` attribute accesses.
|
|
8
|
+
|
|
9
|
+
The index is built lazily on first search and cached in a module global.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ida_code._search_utils import term_matches
|
|
19
|
+
from ida_code.config import IDA_EXAMPLES_DIR
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_index: list["ExampleEntry"] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ExampleEntry:
|
|
28
|
+
"""A single indexed example script."""
|
|
29
|
+
|
|
30
|
+
# Identity
|
|
31
|
+
id: str
|
|
32
|
+
filename: str
|
|
33
|
+
rel_path: str
|
|
34
|
+
abs_path: str
|
|
35
|
+
|
|
36
|
+
# From index.md
|
|
37
|
+
title: str = ""
|
|
38
|
+
summary: str = ""
|
|
39
|
+
description: str = ""
|
|
40
|
+
level: str = ""
|
|
41
|
+
category: str = ""
|
|
42
|
+
keywords: list[str] = field(default_factory=list)
|
|
43
|
+
apis_used: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
# From AST parsing
|
|
46
|
+
imports: list[str] = field(default_factory=list)
|
|
47
|
+
definitions: list[str] = field(default_factory=list)
|
|
48
|
+
api_calls: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
# Full source text
|
|
51
|
+
source: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# index.md parsing
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
# Matches section headers like: ## User interface {#ui}
|
|
59
|
+
_CATEGORY_RE = re.compile(r"^##\s+.+?\{#(\w+)\}")
|
|
60
|
+
|
|
61
|
+
# Matches example headers like: ### Some title {#some_id}
|
|
62
|
+
_EXAMPLE_RE = re.compile(r"^###\s+(.+?)\s*\{#(\w+)\}")
|
|
63
|
+
|
|
64
|
+
# Matches source code table row:
|
|
65
|
+
# | [file.py](url) | kw1 kw2 | Level |
|
|
66
|
+
_SOURCE_ROW_RE = re.compile(
|
|
67
|
+
r"\|\s*\[([^\]]+)\]\([^)]*\)\s*\|\s*(.*?)\s*\|\s*(\w+)\s*\|"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Matches API lines like: * `ida_kernwin.add_hotkey`
|
|
71
|
+
_API_RE = re.compile(r"^\*\s+`([^`]+)`")
|
|
72
|
+
|
|
73
|
+
# Matches TOC links like: <a href='#add_hotkey'>...</a>
|
|
74
|
+
_TOC_LINK_RE = re.compile(r"<a\s+href=['\"]#(\w+)['\"]>")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_toc_categories(text: str) -> dict[str, str]:
|
|
78
|
+
"""Extract id -> category mapping from the TOC tables.
|
|
79
|
+
|
|
80
|
+
The TOC section has ``## Category {#cat}`` headers followed by HTML tables
|
|
81
|
+
containing ``<a href='#example_id'>`` links. The detail section later
|
|
82
|
+
(``## Examples list``) has no category headers.
|
|
83
|
+
"""
|
|
84
|
+
id_to_cat: dict[str, str] = {}
|
|
85
|
+
current_category = ""
|
|
86
|
+
|
|
87
|
+
for line in text.splitlines():
|
|
88
|
+
m = _CATEGORY_RE.match(line)
|
|
89
|
+
if m:
|
|
90
|
+
current_category = m.group(1)
|
|
91
|
+
continue
|
|
92
|
+
# Stop at the flat listing section — everything after is details
|
|
93
|
+
if line.strip() == "## Examples list":
|
|
94
|
+
break
|
|
95
|
+
if current_category:
|
|
96
|
+
for link_match in _TOC_LINK_RE.finditer(line):
|
|
97
|
+
id_to_cat[link_match.group(1)] = current_category
|
|
98
|
+
|
|
99
|
+
return id_to_cat
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_index_md(text: str) -> dict[str, dict]:
|
|
103
|
+
"""Parse index.md into a dict keyed by example id.
|
|
104
|
+
|
|
105
|
+
Returns a dict mapping id -> {title, description, keywords, level,
|
|
106
|
+
category, apis_used, source_file}.
|
|
107
|
+
"""
|
|
108
|
+
# First pass: build id -> category from the TOC tables
|
|
109
|
+
toc_categories = _parse_toc_categories(text)
|
|
110
|
+
|
|
111
|
+
entries: dict[str, dict] = {}
|
|
112
|
+
current_id = ""
|
|
113
|
+
current: dict | None = None
|
|
114
|
+
in_apis = False
|
|
115
|
+
|
|
116
|
+
for line in text.splitlines():
|
|
117
|
+
# Detect example block start
|
|
118
|
+
m = _EXAMPLE_RE.match(line)
|
|
119
|
+
if m:
|
|
120
|
+
if current:
|
|
121
|
+
entries[current_id] = current
|
|
122
|
+
title = m.group(1)
|
|
123
|
+
current_id = m.group(2)
|
|
124
|
+
current = {
|
|
125
|
+
"title": title,
|
|
126
|
+
"description": "",
|
|
127
|
+
"keywords": [],
|
|
128
|
+
"level": "",
|
|
129
|
+
"category": toc_categories.get(current_id, ""),
|
|
130
|
+
"apis_used": [],
|
|
131
|
+
"source_file": "",
|
|
132
|
+
}
|
|
133
|
+
in_apis = False
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if current is None:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Detect APIs Used section
|
|
140
|
+
if line.strip().startswith("**APIs Used:**"):
|
|
141
|
+
in_apis = True
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# Detect end of block (horizontal rule)
|
|
145
|
+
if line.strip() == "***":
|
|
146
|
+
in_apis = False
|
|
147
|
+
if current:
|
|
148
|
+
entries[current_id] = current
|
|
149
|
+
current = None
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# Parse API entries
|
|
153
|
+
if in_apis:
|
|
154
|
+
m = _API_RE.match(line.strip())
|
|
155
|
+
if m:
|
|
156
|
+
current["apis_used"].append(m.group(1))
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Parse source code table row
|
|
160
|
+
m = _SOURCE_ROW_RE.search(line)
|
|
161
|
+
if m:
|
|
162
|
+
current["source_file"] = m.group(1)
|
|
163
|
+
kw_str = m.group(2).strip()
|
|
164
|
+
if kw_str:
|
|
165
|
+
current["keywords"] = kw_str.split()
|
|
166
|
+
current["level"] = m.group(3).lower()
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Accumulate description text (skip table headers and empty lines)
|
|
170
|
+
stripped = line.strip()
|
|
171
|
+
if (
|
|
172
|
+
stripped
|
|
173
|
+
and not stripped.startswith("|")
|
|
174
|
+
and not stripped.startswith("<")
|
|
175
|
+
):
|
|
176
|
+
if current["description"]:
|
|
177
|
+
current["description"] += " " + stripped
|
|
178
|
+
else:
|
|
179
|
+
current["description"] = stripped
|
|
180
|
+
|
|
181
|
+
# Flush last entry
|
|
182
|
+
if current:
|
|
183
|
+
entries[current_id] = current
|
|
184
|
+
|
|
185
|
+
return entries
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Docstring parsing
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_docstring(docstring: str) -> dict[str, str]:
|
|
194
|
+
"""Parse a structured IDAPython example docstring.
|
|
195
|
+
|
|
196
|
+
Expected format::
|
|
197
|
+
|
|
198
|
+
summary: one-line summary
|
|
199
|
+
description:
|
|
200
|
+
multi-line description
|
|
201
|
+
level: beginner
|
|
202
|
+
"""
|
|
203
|
+
result: dict[str, str] = {}
|
|
204
|
+
if not docstring:
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
current_key = ""
|
|
208
|
+
current_val: list[str] = []
|
|
209
|
+
|
|
210
|
+
for line in docstring.splitlines():
|
|
211
|
+
stripped = line.strip()
|
|
212
|
+
|
|
213
|
+
# Try to match a key: value line
|
|
214
|
+
m = re.match(r"^(\w+):\s*(.*)", stripped)
|
|
215
|
+
if m:
|
|
216
|
+
# Save previous key
|
|
217
|
+
if current_key:
|
|
218
|
+
result[current_key] = " ".join(current_val).strip()
|
|
219
|
+
current_key = m.group(1)
|
|
220
|
+
val = m.group(2)
|
|
221
|
+
current_val = [val] if val else []
|
|
222
|
+
elif current_key and stripped:
|
|
223
|
+
current_val.append(stripped)
|
|
224
|
+
|
|
225
|
+
if current_key:
|
|
226
|
+
result[current_key] = " ".join(current_val).strip()
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# AST parsing
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def parse_ast(source: str) -> dict:
|
|
237
|
+
"""Extract imports, definitions, and ida_* API calls from source code.
|
|
238
|
+
|
|
239
|
+
Returns dict with keys: imports, definitions, api_calls.
|
|
240
|
+
"""
|
|
241
|
+
result: dict[str, list[str]] = {
|
|
242
|
+
"imports": [],
|
|
243
|
+
"definitions": [],
|
|
244
|
+
"api_calls": [],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
tree = ast.parse(source)
|
|
249
|
+
except SyntaxError:
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
for node in ast.walk(tree):
|
|
253
|
+
# Collect imports
|
|
254
|
+
if isinstance(node, ast.Import):
|
|
255
|
+
for alias in node.names:
|
|
256
|
+
result["imports"].append(alias.name)
|
|
257
|
+
elif isinstance(node, ast.ImportFrom):
|
|
258
|
+
if node.module:
|
|
259
|
+
result["imports"].append(node.module)
|
|
260
|
+
|
|
261
|
+
# Collect top-level function/class definitions
|
|
262
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
263
|
+
# Only top-level (direct children of Module)
|
|
264
|
+
if _is_top_level(tree, node):
|
|
265
|
+
result["definitions"].append(node.name)
|
|
266
|
+
|
|
267
|
+
# Collect ida_* attribute accesses like ida_hexrays.decompile
|
|
268
|
+
elif isinstance(node, ast.Attribute):
|
|
269
|
+
if isinstance(node.value, ast.Name) and node.value.id.startswith("ida_"):
|
|
270
|
+
api = f"{node.value.id}.{node.attr}"
|
|
271
|
+
if api not in result["api_calls"]:
|
|
272
|
+
result["api_calls"].append(api)
|
|
273
|
+
elif isinstance(node.value, ast.Name) and node.value.id in ("idc", "idautils"):
|
|
274
|
+
api = f"{node.value.id}.{node.attr}"
|
|
275
|
+
if api not in result["api_calls"]:
|
|
276
|
+
result["api_calls"].append(api)
|
|
277
|
+
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _is_top_level(tree: ast.Module, node: ast.AST) -> bool:
|
|
282
|
+
"""Check if a node is a direct child of the module body."""
|
|
283
|
+
return node in tree.body
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Index building
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
# Map category directory names to canonical category ids
|
|
291
|
+
_DIR_TO_CATEGORY = {
|
|
292
|
+
"ui": "ui",
|
|
293
|
+
"disassembler": "disassembler",
|
|
294
|
+
"decompiler": "decompiler",
|
|
295
|
+
"debugger": "debugger",
|
|
296
|
+
"types": "types",
|
|
297
|
+
"misc": "misc",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _infer_category(rel_path: str) -> str:
|
|
302
|
+
"""Infer category from the relative path."""
|
|
303
|
+
parts = Path(rel_path).parts
|
|
304
|
+
if parts:
|
|
305
|
+
return _DIR_TO_CATEGORY.get(parts[0], "misc")
|
|
306
|
+
return "misc"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _build_index(examples_dir: Path) -> list[ExampleEntry]:
|
|
310
|
+
"""Build the full example index from index.md and .py files."""
|
|
311
|
+
entries: list[ExampleEntry] = []
|
|
312
|
+
|
|
313
|
+
# Parse index.md if available
|
|
314
|
+
index_md_path = examples_dir / "index.md"
|
|
315
|
+
index_data: dict[str, dict] = {}
|
|
316
|
+
if index_md_path.is_file():
|
|
317
|
+
try:
|
|
318
|
+
index_data = parse_index_md(index_md_path.read_text(errors="replace"))
|
|
319
|
+
except OSError:
|
|
320
|
+
log.warning("Could not read %s", index_md_path)
|
|
321
|
+
|
|
322
|
+
# Walk all .py files
|
|
323
|
+
py_files = sorted(examples_dir.rglob("*.py"))
|
|
324
|
+
for py_path in py_files:
|
|
325
|
+
rel_path = py_path.relative_to(examples_dir)
|
|
326
|
+
filename = py_path.stem # e.g. "vds1"
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
source = py_path.read_text(errors="replace")
|
|
330
|
+
except OSError:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
entry = ExampleEntry(
|
|
334
|
+
id=filename,
|
|
335
|
+
filename=py_path.name,
|
|
336
|
+
rel_path=str(rel_path),
|
|
337
|
+
abs_path=str(py_path),
|
|
338
|
+
source=source,
|
|
339
|
+
category=_infer_category(str(rel_path)),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Merge index.md metadata
|
|
343
|
+
md = index_data.get(filename, {})
|
|
344
|
+
if md:
|
|
345
|
+
entry.title = md.get("title", "")
|
|
346
|
+
entry.description = md.get("description", "")
|
|
347
|
+
entry.keywords = md.get("keywords", [])
|
|
348
|
+
entry.level = md.get("level", "")
|
|
349
|
+
if md.get("category"):
|
|
350
|
+
entry.category = md["category"]
|
|
351
|
+
entry.apis_used = md.get("apis_used", [])
|
|
352
|
+
|
|
353
|
+
# Parse docstring
|
|
354
|
+
try:
|
|
355
|
+
tree = ast.parse(source)
|
|
356
|
+
ds = ast.get_docstring(tree)
|
|
357
|
+
except SyntaxError:
|
|
358
|
+
ds = None
|
|
359
|
+
if ds:
|
|
360
|
+
parsed_ds = parse_docstring(ds)
|
|
361
|
+
if not entry.title and parsed_ds.get("summary"):
|
|
362
|
+
entry.title = parsed_ds["summary"]
|
|
363
|
+
entry.summary = parsed_ds.get("summary", "")
|
|
364
|
+
if not entry.description and parsed_ds.get("description"):
|
|
365
|
+
entry.description = parsed_ds["description"]
|
|
366
|
+
if not entry.level and parsed_ds.get("level"):
|
|
367
|
+
entry.level = parsed_ds["level"]
|
|
368
|
+
|
|
369
|
+
# Parse AST for imports, definitions, API calls
|
|
370
|
+
ast_info = parse_ast(source)
|
|
371
|
+
entry.imports = ast_info["imports"]
|
|
372
|
+
entry.definitions = ast_info["definitions"]
|
|
373
|
+
entry.api_calls = ast_info["api_calls"]
|
|
374
|
+
|
|
375
|
+
entries.append(entry)
|
|
376
|
+
|
|
377
|
+
return entries
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _ensure_index():
|
|
381
|
+
global _index
|
|
382
|
+
if _index is None:
|
|
383
|
+
log.info("Building example index from %s", IDA_EXAMPLES_DIR)
|
|
384
|
+
_index = _build_index(IDA_EXAMPLES_DIR)
|
|
385
|
+
log.info("Indexed %d example scripts", len(_index))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# Scoring
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def score_example(entry: ExampleEntry, terms: list[str]) -> float:
|
|
394
|
+
"""Score an example against search terms using weighted fields."""
|
|
395
|
+
total = 0.0
|
|
396
|
+
matched_terms = 0
|
|
397
|
+
|
|
398
|
+
for term in terms:
|
|
399
|
+
term_score = 0.0
|
|
400
|
+
t = term.lower()
|
|
401
|
+
|
|
402
|
+
# API match: apis_used (curated from index.md)
|
|
403
|
+
for api in entry.apis_used:
|
|
404
|
+
if term_matches(t, api.lower()):
|
|
405
|
+
term_score = max(term_score, 5.0)
|
|
406
|
+
break
|
|
407
|
+
|
|
408
|
+
# API match: api_calls (AST-derived)
|
|
409
|
+
for api in entry.api_calls:
|
|
410
|
+
if term_matches(t, api.lower()):
|
|
411
|
+
term_score = max(term_score, 4.0)
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
# Title match
|
|
415
|
+
if term_matches(t, entry.title.lower()):
|
|
416
|
+
term_score = max(term_score, 4.0)
|
|
417
|
+
|
|
418
|
+
# Keyword match
|
|
419
|
+
for kw in entry.keywords:
|
|
420
|
+
if term_matches(t, kw.lower()):
|
|
421
|
+
term_score = max(term_score, 3.0)
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
# Summary match
|
|
425
|
+
if term_matches(t, entry.summary.lower()):
|
|
426
|
+
term_score = max(term_score, 3.0)
|
|
427
|
+
|
|
428
|
+
# Import match
|
|
429
|
+
for imp in entry.imports:
|
|
430
|
+
if term_matches(t, imp.lower()):
|
|
431
|
+
term_score = max(term_score, 2.0)
|
|
432
|
+
break
|
|
433
|
+
|
|
434
|
+
# Description match
|
|
435
|
+
if term_matches(t, entry.description.lower()):
|
|
436
|
+
term_score = max(term_score, 1.5)
|
|
437
|
+
|
|
438
|
+
# Definition match
|
|
439
|
+
for defn in entry.definitions:
|
|
440
|
+
if term_matches(t, defn.lower()):
|
|
441
|
+
term_score = max(term_score, 1.5)
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
# Source fallback (uses plain substring — source is too large for boundary matching)
|
|
445
|
+
if term_score == 0 and t in entry.source.lower():
|
|
446
|
+
term_score = 0.5
|
|
447
|
+
|
|
448
|
+
if term_score > 0:
|
|
449
|
+
matched_terms += 1
|
|
450
|
+
total += term_score
|
|
451
|
+
|
|
452
|
+
# All-terms-match bonus
|
|
453
|
+
if len(terms) > 1 and matched_terms == len(terms):
|
|
454
|
+
total *= 1.5
|
|
455
|
+
|
|
456
|
+
return total
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
# Snippet extraction
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def extract_snippet(source: str, terms: list[str], max_lines: int = 15) -> str:
|
|
465
|
+
"""Extract a relevant snippet from source, skipping the module docstring."""
|
|
466
|
+
lines = source.splitlines()
|
|
467
|
+
if not lines:
|
|
468
|
+
return ""
|
|
469
|
+
|
|
470
|
+
# Skip the docstring: find where it ends
|
|
471
|
+
start_line = _find_docstring_end(source)
|
|
472
|
+
|
|
473
|
+
code_lines = lines[start_line:]
|
|
474
|
+
if not code_lines:
|
|
475
|
+
code_lines = lines # fallback to full source
|
|
476
|
+
|
|
477
|
+
if not terms:
|
|
478
|
+
snippet = code_lines[:max_lines]
|
|
479
|
+
return "\n".join(snippet)
|
|
480
|
+
|
|
481
|
+
# Find the line with the best term overlap
|
|
482
|
+
best_idx = 0
|
|
483
|
+
best_count = 0
|
|
484
|
+
for i, line in enumerate(code_lines):
|
|
485
|
+
lower = line.lower()
|
|
486
|
+
count = sum(1 for t in terms if term_matches(t.lower(), lower))
|
|
487
|
+
if count > best_count:
|
|
488
|
+
best_count = count
|
|
489
|
+
best_idx = i
|
|
490
|
+
|
|
491
|
+
# Window around the best line
|
|
492
|
+
half = max_lines // 2
|
|
493
|
+
win_start = max(0, best_idx - half)
|
|
494
|
+
win_end = min(len(code_lines), win_start + max_lines)
|
|
495
|
+
# Adjust start if we're near the end
|
|
496
|
+
if win_end - win_start < max_lines:
|
|
497
|
+
win_start = max(0, win_end - max_lines)
|
|
498
|
+
|
|
499
|
+
snippet_lines = code_lines[win_start:win_end]
|
|
500
|
+
return "\n".join(snippet_lines)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _find_docstring_end(source: str) -> int:
|
|
504
|
+
"""Return the line number (0-based) where the module docstring ends."""
|
|
505
|
+
try:
|
|
506
|
+
tree = ast.parse(source)
|
|
507
|
+
except SyntaxError:
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
if not tree.body:
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
first = tree.body[0]
|
|
514
|
+
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant):
|
|
515
|
+
# end_lineno is 1-based, we want the next line (0-based)
|
|
516
|
+
return first.end_lineno
|
|
517
|
+
return 0
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
# Public search API
|
|
522
|
+
# ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def search(
|
|
526
|
+
query: str,
|
|
527
|
+
max_results: int = 5,
|
|
528
|
+
category: str = "",
|
|
529
|
+
level: str = "",
|
|
530
|
+
max_snippet_lines: int = 10,
|
|
531
|
+
) -> dict:
|
|
532
|
+
"""Search example scripts and return structured dict."""
|
|
533
|
+
_ensure_index()
|
|
534
|
+
|
|
535
|
+
terms = query.lower().split()
|
|
536
|
+
if not terms:
|
|
537
|
+
return {"query": query, "results": []}
|
|
538
|
+
|
|
539
|
+
results: list[tuple[float, ExampleEntry]] = []
|
|
540
|
+
|
|
541
|
+
for entry in _index:
|
|
542
|
+
# Apply filters
|
|
543
|
+
if category and entry.category != category.lower():
|
|
544
|
+
continue
|
|
545
|
+
if level and entry.level != level.lower():
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
s = score_example(entry, terms)
|
|
549
|
+
if s > 0:
|
|
550
|
+
results.append((s, entry))
|
|
551
|
+
|
|
552
|
+
results.sort(key=lambda r: r[0], reverse=True)
|
|
553
|
+
results = results[:max_results]
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
"query": query,
|
|
557
|
+
"results": [
|
|
558
|
+
{
|
|
559
|
+
"title": entry.title or entry.filename,
|
|
560
|
+
"file": entry.rel_path,
|
|
561
|
+
"level": entry.level,
|
|
562
|
+
"category": entry.category,
|
|
563
|
+
"summary": entry.summary,
|
|
564
|
+
"apis": entry.apis_used[:10],
|
|
565
|
+
"snippet": extract_snippet(entry.source, terms, max_lines=max_snippet_lines),
|
|
566
|
+
"score": score,
|
|
567
|
+
}
|
|
568
|
+
for score, entry in results
|
|
569
|
+
],
|
|
570
|
+
}
|
ida_code/executor.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import io
|
|
3
|
+
import logging
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
_MAX_OUTPUT = 50_000
|
|
11
|
+
_DEFAULT_TIMEOUT = 30 # seconds; 0 = no timeout
|
|
12
|
+
|
|
13
|
+
# Modules to pre-populate in the execution namespace.
|
|
14
|
+
_PRELOADED_MODULES = [
|
|
15
|
+
"ida_funcs",
|
|
16
|
+
"ida_bytes",
|
|
17
|
+
"ida_name",
|
|
18
|
+
"ida_segment",
|
|
19
|
+
"ida_auto",
|
|
20
|
+
"ida_idaapi",
|
|
21
|
+
"ida_nalt",
|
|
22
|
+
"ida_xref",
|
|
23
|
+
"ida_ua",
|
|
24
|
+
"ida_entry",
|
|
25
|
+
"ida_lines",
|
|
26
|
+
"ida_typeinf",
|
|
27
|
+
"ida_hexrays",
|
|
28
|
+
"idautils",
|
|
29
|
+
"idc",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_namespace: dict = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_namespace() -> dict:
|
|
36
|
+
ns = {"__builtins__": __builtins__}
|
|
37
|
+
for mod_name in _PRELOADED_MODULES:
|
|
38
|
+
try:
|
|
39
|
+
ns[mod_name] = __import__(mod_name)
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass # Some modules (e.g. ida_hexrays) may not be available.
|
|
42
|
+
return ns
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def reset() -> None:
|
|
46
|
+
"""Clear the execution namespace (called when the database changes)."""
|
|
47
|
+
global _namespace
|
|
48
|
+
_namespace = _build_namespace()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Timeout(Exception):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _alarm_handler(signum, frame):
|
|
56
|
+
raise _Timeout()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _exec_repl(code: str, namespace: dict, stdout: io.StringIO) -> None:
|
|
60
|
+
"""Execute *code* with REPL-like last-expression printing.
|
|
61
|
+
|
|
62
|
+
If the last statement is a bare expression (not an assignment, not a
|
|
63
|
+
function call used for side-effects via print, etc.), its repr is
|
|
64
|
+
written to *stdout* — just like the interactive Python prompt.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
tree = ast.parse(code)
|
|
68
|
+
except SyntaxError:
|
|
69
|
+
# Fall back to plain exec if the code can't be parsed (exec will
|
|
70
|
+
# produce the same SyntaxError with a proper traceback).
|
|
71
|
+
exec(code, namespace)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if not tree.body:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
last = tree.body[-1]
|
|
78
|
+
if not isinstance(last, ast.Expr):
|
|
79
|
+
# Last statement is not a bare expression — exec everything.
|
|
80
|
+
exec(code, namespace)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Split: exec all statements except the last, then eval the last.
|
|
84
|
+
if len(tree.body) > 1:
|
|
85
|
+
head = ast.Module(body=tree.body[:-1], type_ignores=tree.type_ignores)
|
|
86
|
+
ast.fix_missing_locations(head)
|
|
87
|
+
exec(compile(head, "<exec>", "exec"), namespace)
|
|
88
|
+
|
|
89
|
+
expr_code = compile(ast.Expression(body=last.value), "<eval>", "eval")
|
|
90
|
+
result = eval(expr_code, namespace) # noqa: S307
|
|
91
|
+
if result is not None:
|
|
92
|
+
stdout.write(repr(result) + "\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def execute(code: str, timeout: int = _DEFAULT_TIMEOUT) -> str:
|
|
96
|
+
"""Execute IDAPython code and return captured output.
|
|
97
|
+
|
|
98
|
+
*timeout* sets the maximum wall-clock seconds (0 = unlimited).
|
|
99
|
+
On expiry the code is interrupted and an error message is returned.
|
|
100
|
+
"""
|
|
101
|
+
global _namespace
|
|
102
|
+
|
|
103
|
+
# Lazy-init namespace on first call.
|
|
104
|
+
if not _namespace:
|
|
105
|
+
_namespace = _build_namespace()
|
|
106
|
+
|
|
107
|
+
stdout_capture = io.StringIO()
|
|
108
|
+
stderr_capture = io.StringIO()
|
|
109
|
+
old_stdout, old_stderr = sys.stdout, sys.stderr
|
|
110
|
+
old_handler = None
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
sys.stdout = stdout_capture
|
|
114
|
+
sys.stderr = stderr_capture
|
|
115
|
+
|
|
116
|
+
if timeout > 0:
|
|
117
|
+
old_handler = signal.signal(signal.SIGALRM, _alarm_handler)
|
|
118
|
+
signal.alarm(timeout)
|
|
119
|
+
|
|
120
|
+
log.debug("Executing code (%d chars, timeout=%ds)", len(code), timeout)
|
|
121
|
+
_exec_repl(code, _namespace, stdout_capture)
|
|
122
|
+
except _Timeout:
|
|
123
|
+
log.warning("Execution timed out after %ds", timeout)
|
|
124
|
+
stderr_capture.write(f"\n\nExecution timed out after {timeout} seconds.")
|
|
125
|
+
except (KeyboardInterrupt, SystemExit) as exc:
|
|
126
|
+
log.warning("%s intercepted from user code", type(exc).__name__)
|
|
127
|
+
stderr_capture.write(f"\n\n{type(exc).__name__} intercepted — the server is still running.\n")
|
|
128
|
+
stderr_capture.write(traceback.format_exc())
|
|
129
|
+
except Exception:
|
|
130
|
+
log.debug("User code raised exception", exc_info=True)
|
|
131
|
+
stderr_capture.write(traceback.format_exc())
|
|
132
|
+
finally:
|
|
133
|
+
if timeout > 0:
|
|
134
|
+
signal.alarm(0) # Cancel any pending alarm.
|
|
135
|
+
if old_handler is not None:
|
|
136
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
137
|
+
sys.stdout = old_stdout
|
|
138
|
+
sys.stderr = old_stderr
|
|
139
|
+
|
|
140
|
+
output = stdout_capture.getvalue() + stderr_capture.getvalue()
|
|
141
|
+
|
|
142
|
+
if len(output) > _MAX_OUTPUT:
|
|
143
|
+
output = output[:_MAX_OUTPUT] + f"\n\n[Output truncated at {_MAX_OUTPUT} characters]"
|
|
144
|
+
|
|
145
|
+
return output
|