codevanta 0.1.0__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.
- codevanta/__init__.py +1 -0
- codevanta/analysis/__init__.py +1 -0
- codevanta/analysis/binary_diff.py +1168 -0
- codevanta/analysis/callgraph.py +542 -0
- codevanta/analysis/cfg.py +1201 -0
- codevanta/analysis/dataflow.py +762 -0
- codevanta/analysis/debugger.py +289 -0
- codevanta/analysis/debuginfo.py +304 -0
- codevanta/analysis/decompile.py +1094 -0
- codevanta/analysis/decompilers/__init__.py +250 -0
- codevanta/analysis/decompilers/angr_backend.py +251 -0
- codevanta/analysis/decompilers/base.py +278 -0
- codevanta/analysis/decompilers/ghidra_backend.py +316 -0
- codevanta/analysis/dynamic.py +756 -0
- codevanta/analysis/emulate.py +1349 -0
- codevanta/analysis/evidence_bundle.py +736 -0
- codevanta/analysis/function_hash.py +562 -0
- codevanta/analysis/graph_export.py +441 -0
- codevanta/analysis/html_view.py +695 -0
- codevanta/analysis/jump_tables.py +546 -0
- codevanta/analysis/solver.py +715 -0
- codevanta/analysis/symbolic.py +1809 -0
- codevanta/analysis/trace.py +646 -0
- codevanta/analysis/type_propagation.py +1055 -0
- codevanta/analysis/type_recovery.py +1214 -0
- codevanta/analysis/vtables.py +572 -0
- codevanta/analysis/xrefs.py +434 -0
- codevanta/api/__init__.py +1 -0
- codevanta/cli/__init__.py +1 -0
- codevanta/cli/main.py +3038 -0
- codevanta/core/__init__.py +31 -0
- codevanta/core/disassembler.py +121 -0
- codevanta/core/entropy.py +237 -0
- codevanta/core/functions.py +176 -0
- codevanta/core/loader.py +1351 -0
- codevanta/core/report.py +306 -0
- codevanta/core/strings.py +109 -0
- codevanta/evidence/__init__.py +7 -0
- codevanta/evidence/confidence.py +36 -0
- codevanta/evidence/models.py +43 -0
- codevanta/evidence/provenance.py +37 -0
- codevanta/gui/__init__.py +46 -0
- codevanta/gui/layout.py +1384 -0
- codevanta/gui/server.py +1305 -0
- codevanta/ir/__init__.py +6 -0
- codevanta/ir/adapters/__init__.py +31 -0
- codevanta/ir/base.py +31 -0
- codevanta/ir/instruction.py +42 -0
- codevanta/mcp_server/__init__.py +5 -0
- codevanta/mcp_server/server.py +710 -0
- codevanta/mcp_server/tools.py +1364 -0
- codevanta/plugins/__init__.py +748 -0
- codevanta/setup/__init__.py +28 -0
- codevanta/setup/apply.py +22 -0
- codevanta/setup/clients.py +285 -0
- codevanta/setup/plan.py +79 -0
- codevanta/setup/verify.py +43 -0
- codevanta/storage/__init__.py +1 -0
- codevanta/storage/session.py +514 -0
- codevanta-0.1.0.dist-info/METADATA +688 -0
- codevanta-0.1.0.dist-info/RECORD +65 -0
- codevanta-0.1.0.dist-info/WHEEL +5 -0
- codevanta-0.1.0.dist-info/entry_points.txt +2 -0
- codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevanta-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""SQLite-backed session storage for CODEVANTA analyses.
|
|
2
|
+
|
|
3
|
+
A *session* is a persisted snapshot of one analysis run: the normalized
|
|
4
|
+
fingerprint produced by :mod:`codevanta.core.loader` plus the function and
|
|
5
|
+
string counts gathered by later phases. Sessions are additive — saving the
|
|
6
|
+
same binary twice creates two rows with distinct identifiers, which keeps an
|
|
7
|
+
audit trail of repeated analyses.
|
|
8
|
+
|
|
9
|
+
Sessions can also carry *annotations*: one free-text note per address, kept
|
|
10
|
+
in the ``annotations`` table and keyed by ``(session_id, address)`` so that
|
|
11
|
+
re-annotating an address replaces the previous note instead of appending a
|
|
12
|
+
duplicate row.
|
|
13
|
+
|
|
14
|
+
The module uses the standard library only (``sqlite3``, ``uuid``,
|
|
15
|
+
``datetime``, ``pathlib``) and issues parameterized SQL exclusively.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sqlite3
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from codevanta.core.loader import BinaryInfo
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DEFAULT_LIMIT",
|
|
31
|
+
"MAX_LIMIT",
|
|
32
|
+
"MAX_NOTE_LENGTH",
|
|
33
|
+
"SESSION_ID_LENGTH",
|
|
34
|
+
"Annotation",
|
|
35
|
+
"SessionError",
|
|
36
|
+
"SessionRecord",
|
|
37
|
+
"SessionStore",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: Default number of rows returned by :meth:`SessionStore.list`.
|
|
41
|
+
DEFAULT_LIMIT = 50
|
|
42
|
+
|
|
43
|
+
#: Hard ceiling applied by :meth:`SessionStore.list` so one call cannot
|
|
44
|
+
#: exhaust memory on a very large database.
|
|
45
|
+
MAX_LIMIT = 10_000
|
|
46
|
+
|
|
47
|
+
#: Number of hex characters used for generated session identifiers.
|
|
48
|
+
SESSION_ID_LENGTH = 12
|
|
49
|
+
|
|
50
|
+
#: Maximum accepted length of a single annotation note, in characters.
|
|
51
|
+
MAX_NOTE_LENGTH = 4000
|
|
52
|
+
|
|
53
|
+
_SCHEMA = """
|
|
54
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
55
|
+
session_id TEXT PRIMARY KEY,
|
|
56
|
+
created_at TEXT NOT NULL,
|
|
57
|
+
binary_path TEXT NOT NULL,
|
|
58
|
+
sha256 TEXT NOT NULL,
|
|
59
|
+
format TEXT NOT NULL,
|
|
60
|
+
architecture TEXT NOT NULL,
|
|
61
|
+
bitness INTEGER NOT NULL,
|
|
62
|
+
entry_point INTEGER,
|
|
63
|
+
functions INTEGER NOT NULL,
|
|
64
|
+
strings INTEGER NOT NULL,
|
|
65
|
+
notes TEXT
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions (created_at);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
71
|
+
session_id TEXT NOT NULL,
|
|
72
|
+
address INTEGER NOT NULL,
|
|
73
|
+
note TEXT NOT NULL,
|
|
74
|
+
created_at TEXT NOT NULL,
|
|
75
|
+
PRIMARY KEY (session_id, address)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_annotations_session ON annotations (session_id);
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
_COLUMNS = (
|
|
82
|
+
"session_id, created_at, binary_path, sha256, format, architecture, "
|
|
83
|
+
"bitness, entry_point, functions, strings, notes"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
_INSERT = (
|
|
87
|
+
"INSERT INTO sessions (session_id, created_at, binary_path, sha256, format, "
|
|
88
|
+
"architecture, bitness, entry_point, functions, strings, notes) "
|
|
89
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
_ANNOTATION_COLUMNS = "session_id, address, note, created_at"
|
|
93
|
+
|
|
94
|
+
_ANNOTATION_UPSERT = (
|
|
95
|
+
"INSERT INTO annotations (session_id, address, note, created_at) "
|
|
96
|
+
"VALUES (?, ?, ?, ?) "
|
|
97
|
+
"ON CONFLICT (session_id, address) DO UPDATE SET "
|
|
98
|
+
"note = excluded.note, created_at = excluded.created_at"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SessionError(Exception):
|
|
103
|
+
"""Raised when a session store cannot be opened, written or queried."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class SessionRecord:
|
|
108
|
+
"""A single persisted analysis session."""
|
|
109
|
+
|
|
110
|
+
session_id: str
|
|
111
|
+
created_at: str
|
|
112
|
+
binary_path: str
|
|
113
|
+
sha256: str
|
|
114
|
+
format: str
|
|
115
|
+
architecture: str
|
|
116
|
+
bitness: int
|
|
117
|
+
entry_point: int | None
|
|
118
|
+
functions: int
|
|
119
|
+
strings: int
|
|
120
|
+
notes: str | None = None
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, Any]:
|
|
123
|
+
"""Serialize the record, rendering the entry point as hex."""
|
|
124
|
+
return {
|
|
125
|
+
"session_id": self.session_id,
|
|
126
|
+
"created_at": self.created_at,
|
|
127
|
+
"binary_path": self.binary_path,
|
|
128
|
+
"sha256": self.sha256,
|
|
129
|
+
"format": self.format,
|
|
130
|
+
"architecture": self.architecture,
|
|
131
|
+
"bitness": self.bitness,
|
|
132
|
+
"entry_point": f"0x{self.entry_point:x}" if self.entry_point is not None else None,
|
|
133
|
+
"functions": self.functions,
|
|
134
|
+
"strings": self.strings,
|
|
135
|
+
"notes": self.notes,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Annotation:
|
|
141
|
+
"""A free-text note attached to one address inside a session."""
|
|
142
|
+
|
|
143
|
+
session_id: str
|
|
144
|
+
address: int
|
|
145
|
+
note: str
|
|
146
|
+
created_at: str
|
|
147
|
+
|
|
148
|
+
def to_dict(self) -> dict[str, Any]:
|
|
149
|
+
"""Serialize the annotation, rendering the address as hex."""
|
|
150
|
+
return {
|
|
151
|
+
"session_id": self.session_id,
|
|
152
|
+
"address": f"0x{self.address:x}",
|
|
153
|
+
"note": self.note,
|
|
154
|
+
"created_at": self.created_at,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ``SessionStore.list`` shadows the ``list`` builtin inside the class body, so the
|
|
159
|
+
# type of an annotation collection is aliased here to keep it spellable there.
|
|
160
|
+
_AnnotationList = list[Annotation]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SessionStore:
|
|
164
|
+
"""Create, read, list and delete analysis sessions in SQLite.
|
|
165
|
+
|
|
166
|
+
The database file (and any missing parent directories) is created on
|
|
167
|
+
construction. Use the store as a context manager to close it on exit.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
171
|
+
self._db_path = _resolve_db_path(db_path)
|
|
172
|
+
self._closed = False
|
|
173
|
+
try:
|
|
174
|
+
self._conn = sqlite3.connect(str(self._db_path), timeout=5.0)
|
|
175
|
+
except sqlite3.Error as exc: # pragma: no cover - environment dependent
|
|
176
|
+
raise SessionError(f"cannot open session database {self._db_path}: {exc}") from exc
|
|
177
|
+
self._conn.row_factory = sqlite3.Row
|
|
178
|
+
try:
|
|
179
|
+
self._conn.executescript(_SCHEMA)
|
|
180
|
+
self._conn.commit()
|
|
181
|
+
except sqlite3.Error as exc:
|
|
182
|
+
self._conn.close()
|
|
183
|
+
self._closed = True
|
|
184
|
+
raise SessionError(
|
|
185
|
+
f"cannot initialize session database {self._db_path}: {exc}"
|
|
186
|
+
) from exc
|
|
187
|
+
|
|
188
|
+
# ---- lifecycle ----------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> SessionStore:
|
|
191
|
+
self._require_open()
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(
|
|
195
|
+
self,
|
|
196
|
+
exc_type: type[BaseException] | None,
|
|
197
|
+
exc_value: BaseException | None,
|
|
198
|
+
traceback: Any,
|
|
199
|
+
) -> None:
|
|
200
|
+
self.close()
|
|
201
|
+
|
|
202
|
+
def close(self) -> None:
|
|
203
|
+
"""Close the database connection. Calling it twice is harmless."""
|
|
204
|
+
if self._closed:
|
|
205
|
+
return
|
|
206
|
+
self._closed = True
|
|
207
|
+
try:
|
|
208
|
+
self._conn.close()
|
|
209
|
+
except sqlite3.Error as exc: # pragma: no cover - defensive
|
|
210
|
+
raise SessionError(f"cannot close session database: {exc}") from exc
|
|
211
|
+
|
|
212
|
+
# ---- CRUD ---------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def save(
|
|
215
|
+
self,
|
|
216
|
+
info: BinaryInfo,
|
|
217
|
+
functions_count: int,
|
|
218
|
+
strings_count: int,
|
|
219
|
+
notes: str | None = None,
|
|
220
|
+
session_id: str | None = None,
|
|
221
|
+
) -> SessionRecord:
|
|
222
|
+
"""Persist ``info`` as a new session and return the stored record."""
|
|
223
|
+
self._require_open()
|
|
224
|
+
if info is None:
|
|
225
|
+
raise SessionError("binary info is required")
|
|
226
|
+
functions = _require_count(functions_count, "functions_count")
|
|
227
|
+
strings = _require_count(strings_count, "strings_count")
|
|
228
|
+
if notes is not None and not isinstance(notes, str):
|
|
229
|
+
raise SessionError("notes must be a string or None")
|
|
230
|
+
sid = _require_session_id(session_id)
|
|
231
|
+
|
|
232
|
+
record = SessionRecord(
|
|
233
|
+
session_id=sid,
|
|
234
|
+
created_at=_utc_now(),
|
|
235
|
+
binary_path=str(info.path),
|
|
236
|
+
sha256=str(info.sha256),
|
|
237
|
+
format=str(info.format),
|
|
238
|
+
architecture=str(info.architecture),
|
|
239
|
+
bitness=int(info.bitness),
|
|
240
|
+
entry_point=None if info.entry_point is None else int(info.entry_point),
|
|
241
|
+
functions=functions,
|
|
242
|
+
strings=strings,
|
|
243
|
+
notes=notes,
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
self._conn.execute(
|
|
247
|
+
_INSERT,
|
|
248
|
+
(
|
|
249
|
+
record.session_id,
|
|
250
|
+
record.created_at,
|
|
251
|
+
record.binary_path,
|
|
252
|
+
record.sha256,
|
|
253
|
+
record.format,
|
|
254
|
+
record.architecture,
|
|
255
|
+
record.bitness,
|
|
256
|
+
record.entry_point,
|
|
257
|
+
record.functions,
|
|
258
|
+
record.strings,
|
|
259
|
+
record.notes,
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
self._conn.commit()
|
|
263
|
+
except sqlite3.Error as exc:
|
|
264
|
+
self._conn.rollback()
|
|
265
|
+
raise SessionError(f"cannot save session {sid}: {exc}") from exc
|
|
266
|
+
return record
|
|
267
|
+
|
|
268
|
+
def get(self, session_id: str) -> SessionRecord | None:
|
|
269
|
+
"""Return the session with ``session_id``, or ``None`` when absent."""
|
|
270
|
+
self._require_open()
|
|
271
|
+
if not _is_usable_id(session_id):
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
row = self._conn.execute(
|
|
275
|
+
f"SELECT {_COLUMNS} FROM sessions WHERE session_id = ? LIMIT 1",
|
|
276
|
+
(session_id,),
|
|
277
|
+
).fetchone()
|
|
278
|
+
except sqlite3.Error as exc: # pragma: no cover - defensive
|
|
279
|
+
raise SessionError(f"cannot read session {session_id}: {exc}") from exc
|
|
280
|
+
return None if row is None else _row_to_record(row)
|
|
281
|
+
|
|
282
|
+
def list(self, limit: int = DEFAULT_LIMIT) -> list[SessionRecord]:
|
|
283
|
+
"""Return up to ``limit`` sessions, newest first.
|
|
284
|
+
|
|
285
|
+
Ordering falls back to insertion order (``rowid``) when two sessions
|
|
286
|
+
share a timestamp, which happens on platforms with coarse clocks.
|
|
287
|
+
"""
|
|
288
|
+
self._require_open()
|
|
289
|
+
if isinstance(limit, bool) or not isinstance(limit, int):
|
|
290
|
+
raise SessionError("limit must be an integer")
|
|
291
|
+
if limit <= 0:
|
|
292
|
+
raise SessionError("limit must be a positive integer")
|
|
293
|
+
capped = min(limit, MAX_LIMIT)
|
|
294
|
+
try:
|
|
295
|
+
rows = self._conn.execute(
|
|
296
|
+
f"SELECT {_COLUMNS} FROM sessions "
|
|
297
|
+
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
|
298
|
+
(capped,),
|
|
299
|
+
).fetchall()
|
|
300
|
+
except sqlite3.Error as exc: # pragma: no cover - defensive
|
|
301
|
+
raise SessionError(f"cannot list sessions: {exc}") from exc
|
|
302
|
+
return [_row_to_record(row) for row in rows]
|
|
303
|
+
|
|
304
|
+
def delete(self, session_id: str) -> bool:
|
|
305
|
+
"""Delete a session and return ``True`` when a row was removed."""
|
|
306
|
+
self._require_open()
|
|
307
|
+
if not _is_usable_id(session_id):
|
|
308
|
+
return False
|
|
309
|
+
try:
|
|
310
|
+
cursor = self._conn.execute(
|
|
311
|
+
"DELETE FROM sessions WHERE session_id = ?",
|
|
312
|
+
(session_id,),
|
|
313
|
+
)
|
|
314
|
+
self._conn.commit()
|
|
315
|
+
except sqlite3.Error as exc:
|
|
316
|
+
self._conn.rollback()
|
|
317
|
+
raise SessionError(f"cannot delete session {session_id}: {exc}") from exc
|
|
318
|
+
return cursor.rowcount > 0
|
|
319
|
+
|
|
320
|
+
# ---- annotations --------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def annotate(self, session_id: str, address: int, note: str) -> Annotation:
|
|
323
|
+
"""Attach ``note`` to ``address`` inside ``session_id``.
|
|
324
|
+
|
|
325
|
+
Notes are unique per ``(session_id, address)`` pair: annotating an
|
|
326
|
+
address a second time replaces the stored note (and its timestamp)
|
|
327
|
+
instead of adding another row.
|
|
328
|
+
"""
|
|
329
|
+
self._require_open()
|
|
330
|
+
sid = _require_session_id(session_id)
|
|
331
|
+
addr = _require_address(address)
|
|
332
|
+
text = _require_note(note)
|
|
333
|
+
if not self._session_exists(sid):
|
|
334
|
+
raise SessionError(f"unknown session {sid}")
|
|
335
|
+
|
|
336
|
+
annotation = Annotation(
|
|
337
|
+
session_id=sid,
|
|
338
|
+
address=addr,
|
|
339
|
+
note=text,
|
|
340
|
+
created_at=_utc_now(),
|
|
341
|
+
)
|
|
342
|
+
try:
|
|
343
|
+
self._conn.execute(
|
|
344
|
+
_ANNOTATION_UPSERT,
|
|
345
|
+
(
|
|
346
|
+
annotation.session_id,
|
|
347
|
+
annotation.address,
|
|
348
|
+
annotation.note,
|
|
349
|
+
annotation.created_at,
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
self._conn.commit()
|
|
353
|
+
except sqlite3.Error as exc:
|
|
354
|
+
self._conn.rollback()
|
|
355
|
+
raise SessionError(
|
|
356
|
+
f"cannot annotate address 0x{addr:x} in session {sid}: {exc}"
|
|
357
|
+
) from exc
|
|
358
|
+
return annotation
|
|
359
|
+
|
|
360
|
+
def annotations(self, session_id: str) -> _AnnotationList:
|
|
361
|
+
"""Return every annotation of ``session_id``, lowest address first."""
|
|
362
|
+
self._require_open()
|
|
363
|
+
sid = _require_session_id(session_id)
|
|
364
|
+
if not self._session_exists(sid):
|
|
365
|
+
raise SessionError(f"unknown session {sid}")
|
|
366
|
+
try:
|
|
367
|
+
rows = self._conn.execute(
|
|
368
|
+
f"SELECT {_ANNOTATION_COLUMNS} FROM annotations "
|
|
369
|
+
"WHERE session_id = ? ORDER BY address ASC",
|
|
370
|
+
(sid,),
|
|
371
|
+
).fetchall()
|
|
372
|
+
except sqlite3.Error as exc: # pragma: no cover - defensive
|
|
373
|
+
raise SessionError(f"cannot list annotations for session {sid}: {exc}") from exc
|
|
374
|
+
return [_row_to_annotation(row) for row in rows]
|
|
375
|
+
|
|
376
|
+
def remove_annotation(self, session_id: str, address: int) -> bool:
|
|
377
|
+
"""Drop the note at ``address`` and return ``True`` when one existed.
|
|
378
|
+
|
|
379
|
+
Unknown sessions and unknown addresses simply report ``False`` so the
|
|
380
|
+
call stays idempotent.
|
|
381
|
+
"""
|
|
382
|
+
self._require_open()
|
|
383
|
+
if not _is_usable_id(session_id):
|
|
384
|
+
return False
|
|
385
|
+
if isinstance(address, bool) or not isinstance(address, int) or address < 0:
|
|
386
|
+
return False
|
|
387
|
+
try:
|
|
388
|
+
cursor = self._conn.execute(
|
|
389
|
+
"DELETE FROM annotations WHERE session_id = ? AND address = ?",
|
|
390
|
+
(session_id, address),
|
|
391
|
+
)
|
|
392
|
+
self._conn.commit()
|
|
393
|
+
except sqlite3.Error as exc:
|
|
394
|
+
self._conn.rollback()
|
|
395
|
+
raise SessionError(
|
|
396
|
+
f"cannot remove annotation at 0x{address:x} in session {session_id}: {exc}"
|
|
397
|
+
) from exc
|
|
398
|
+
return cursor.rowcount > 0
|
|
399
|
+
|
|
400
|
+
# ---- helpers ------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _session_exists(self, session_id: str) -> bool:
|
|
403
|
+
"""Return ``True`` when a row with ``session_id`` is stored."""
|
|
404
|
+
try:
|
|
405
|
+
row = self._conn.execute(
|
|
406
|
+
"SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1",
|
|
407
|
+
(session_id,),
|
|
408
|
+
).fetchone()
|
|
409
|
+
except sqlite3.Error as exc: # pragma: no cover - defensive
|
|
410
|
+
raise SessionError(f"cannot read session {session_id}: {exc}") from exc
|
|
411
|
+
return row is not None
|
|
412
|
+
|
|
413
|
+
def _require_open(self) -> None:
|
|
414
|
+
if self._closed:
|
|
415
|
+
raise SessionError("session store is closed")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _resolve_db_path(db_path: str | Path) -> str | Path:
|
|
419
|
+
"""Validate ``db_path`` and create its missing parent directories."""
|
|
420
|
+
if isinstance(db_path, Path):
|
|
421
|
+
path = db_path
|
|
422
|
+
elif isinstance(db_path, str):
|
|
423
|
+
text = db_path.strip()
|
|
424
|
+
if not text:
|
|
425
|
+
raise SessionError("database path must not be empty")
|
|
426
|
+
path = Path(text)
|
|
427
|
+
else:
|
|
428
|
+
raise SessionError(f"database path must be str or Path, not {type(db_path).__name__}")
|
|
429
|
+
|
|
430
|
+
if str(path) == ":memory:": # pragma: no cover - in-memory store
|
|
431
|
+
return ":memory:"
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
if path.exists() and path.is_dir():
|
|
435
|
+
raise SessionError(f"database path is a directory: {path}")
|
|
436
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
437
|
+
except SessionError:
|
|
438
|
+
raise
|
|
439
|
+
except OSError as exc:
|
|
440
|
+
raise SessionError(f"cannot create directory for database {path}: {exc}") from exc
|
|
441
|
+
return path
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _is_usable_id(session_id: Any) -> bool:
|
|
445
|
+
return isinstance(session_id, str) and bool(session_id.strip())
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _require_session_id(session_id: str | None) -> str:
|
|
449
|
+
if session_id is None:
|
|
450
|
+
return uuid.uuid4().hex[:SESSION_ID_LENGTH]
|
|
451
|
+
if not _is_usable_id(session_id):
|
|
452
|
+
raise SessionError("session_id must be a non-empty string")
|
|
453
|
+
return session_id
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _require_count(value: int, name: str) -> int:
|
|
457
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
458
|
+
raise SessionError(f"{name} must be an integer")
|
|
459
|
+
if value < 0:
|
|
460
|
+
raise SessionError(f"{name} must not be negative")
|
|
461
|
+
return value
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _utc_now() -> str:
|
|
465
|
+
"""Current UTC time as an ISO-8601 string with a numeric offset."""
|
|
466
|
+
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _require_address(address: Any) -> int:
|
|
470
|
+
"""Validate an annotation address, which must be a non-negative integer."""
|
|
471
|
+
if isinstance(address, bool) or not isinstance(address, int):
|
|
472
|
+
raise SessionError("address must be an integer")
|
|
473
|
+
if address < 0:
|
|
474
|
+
raise SessionError("address must not be negative")
|
|
475
|
+
return address
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _require_note(note: Any) -> str:
|
|
479
|
+
"""Validate an annotation note: a non-blank string of bounded length."""
|
|
480
|
+
if not isinstance(note, str):
|
|
481
|
+
raise SessionError("note must be a string")
|
|
482
|
+
if not note.strip():
|
|
483
|
+
raise SessionError("note must not be empty")
|
|
484
|
+
if len(note) > MAX_NOTE_LENGTH:
|
|
485
|
+
raise SessionError(
|
|
486
|
+
f"note must be at most {MAX_NOTE_LENGTH} characters long"
|
|
487
|
+
)
|
|
488
|
+
return note
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _row_to_annotation(row: sqlite3.Row) -> Annotation:
|
|
492
|
+
return Annotation(
|
|
493
|
+
session_id=row["session_id"],
|
|
494
|
+
address=int(row["address"]),
|
|
495
|
+
note=row["note"],
|
|
496
|
+
created_at=row["created_at"],
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _row_to_record(row: sqlite3.Row) -> SessionRecord:
|
|
501
|
+
entry_point = row["entry_point"]
|
|
502
|
+
return SessionRecord(
|
|
503
|
+
session_id=row["session_id"],
|
|
504
|
+
created_at=row["created_at"],
|
|
505
|
+
binary_path=row["binary_path"],
|
|
506
|
+
sha256=row["sha256"],
|
|
507
|
+
format=row["format"],
|
|
508
|
+
architecture=row["architecture"],
|
|
509
|
+
bitness=int(row["bitness"]),
|
|
510
|
+
entry_point=None if entry_point is None else int(entry_point),
|
|
511
|
+
functions=int(row["functions"]),
|
|
512
|
+
strings=int(row["strings"]),
|
|
513
|
+
notes=row["notes"],
|
|
514
|
+
)
|