memdebug 0.2.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.
memdebug/ledger.py ADDED
@@ -0,0 +1,700 @@
1
+ """Append-only, hash-chained event ledger stored in SQLite.
2
+
3
+ What the chain protects: edits to, or removal from the middle of, the ledger. Removing the
4
+ newest entries cannot be seen from the chain alone, so verify() accepts the head hash that a
5
+ snapshot recorded.
6
+
7
+ What it does not protect: someone who can rewrite the whole file can rebuild a valid chain.
8
+ Anchor the head hash somewhere else (a snapshot, a printed line) if that matters.
9
+
10
+ The file holds memory text, including text of deleted memories, so it is created with
11
+ owner-only permissions and by design cannot be edited or erased entry by entry.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ import sqlite3
21
+ import stat
22
+ from contextlib import contextmanager
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Iterator, Sequence
27
+
28
+ from pydantic import ValidationError
29
+
30
+ from .errors import LedgerConflictError, LedgerError, SnapshotError
31
+ from .models import (
32
+ META_OPS,
33
+ LedgerEntry,
34
+ Memory,
35
+ MemoryEvent,
36
+ Op,
37
+ Snapshot,
38
+ SnapshotInfo,
39
+ Trust,
40
+ derive_trust,
41
+ )
42
+ from .textsafe import has_unsafe_chars
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ GENESIS = "0" * 64
47
+ SCHEMA_VERSION = 3
48
+
49
+ MAX_SNAPSHOT_MEMORIES = 100_000
50
+ MAX_SNAPSHOT_CHARS = 100_000_000
51
+ MAX_LABEL_CHARS = 100
52
+ _SNAPSHOT_ID_RE = re.compile(r"^s([1-9][0-9]{0,8})\Z")
53
+ _EVENT_ID_RE = re.compile(r"^e([1-9][0-9]{0,11})\Z")
54
+ _IN_CHUNK = 500 # bound parameters per query (SQLite allows at least 999)
55
+ MAX_ROLLBACK_FILES = 200 # files named in a rollback record; the count of all of them is kept as well
56
+ _PATH_FORBIDDEN = frozenset('"\\:*?<>|') # the same names the markdown adapter refuses to read or write
57
+ _ROLLBACK_ACTIONS = ("restore", "recreate", "remove")
58
+ _ROLLBACK_SOURCES = ("git", "snapshot", "none")
59
+ _SHA_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
60
+ _BACKUP_REF_RE = re.compile(r"^refs/memdebug/backups/[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}\Z")
61
+
62
+
63
+ def canonical(event: MemoryEvent) -> str:
64
+ return json.dumps(event.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
65
+
66
+
67
+ def compute_hash(prev_hash: str, event_id: str, payload: str) -> str:
68
+ return hashlib.sha256(f"{prev_hash}|{event_id}|{payload}".encode()).hexdigest()
69
+
70
+
71
+ def parse_snapshot_id(value: object) -> int:
72
+ match = _SNAPSHOT_ID_RE.match(value) if isinstance(value, str) else None
73
+ if not match:
74
+ raise SnapshotError("a snapshot id looks like s1, s2, s3 ...")
75
+ return int(match.group(1))
76
+
77
+
78
+ def _text_digest(text: str) -> str:
79
+ try:
80
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
81
+ except UnicodeEncodeError as exc:
82
+ raise SnapshotError("a memory contains text that is not valid Unicode") from exc
83
+
84
+
85
+ def _canonical_json(value: object) -> str:
86
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
87
+
88
+
89
+ def snapshot_digest(
90
+ number: int, backend: str, scope: dict, taken_at: str, ledger_seq: int, ledger_head: str,
91
+ complete: bool, label: str | None, entries: list[list],
92
+ ) -> str:
93
+ """Hash of everything that defines a snapshot. The ledger chain stores this value, so a
94
+ snapshot cannot be altered without the chain noticing."""
95
+ body = {
96
+ "id": number, "backend": backend, "scope": scope, "taken_at": taken_at,
97
+ "ledger_seq": ledger_seq, "ledger_head": ledger_head, "complete": bool(complete),
98
+ "label": label, "entries": sorted(entries, key=lambda e: e[0]),
99
+ }
100
+ return hashlib.sha256(_canonical_json(body).encode()).hexdigest()
101
+
102
+
103
+ def validate_rollback_details(value: object) -> dict:
104
+ """The facts a rollback record may hold, checked strictly: it is written by us, but read back from a file that
105
+ anyone could edit, so everything is verified again when the ledger is verified."""
106
+ if not isinstance(value, dict) or set(value) != {"target", "before_snapshot", "after_snapshot", "commit",
107
+ "previous_head", "backup", "file_count", "files"}:
108
+ raise ValueError("unexpected rollback fields")
109
+ for key in ("target", "before_snapshot", "after_snapshot"):
110
+ if not (value[key] is None and key != "target") and not (isinstance(value[key], str) and _SNAPSHOT_ID_RE.match(value[key])):
111
+ raise ValueError(f"{key} is not a snapshot id")
112
+ for key in ("commit", "previous_head"):
113
+ if value[key] is not None and not (isinstance(value[key], str) and _SHA_RE.match(value[key])):
114
+ raise ValueError(f"{key} is not a commit id")
115
+ if value["backup"] is not None and not (isinstance(value["backup"], str) and _BACKUP_REF_RE.match(value["backup"])):
116
+ raise ValueError("backup is not a memdebug backup reference")
117
+ files = value["files"]
118
+ if not (isinstance(value["file_count"], int) and not isinstance(value["file_count"], bool) and value["file_count"] >= 0):
119
+ raise ValueError("file_count is not a count")
120
+ if not isinstance(files, list) or len(files) > MAX_ROLLBACK_FILES or len(files) > value["file_count"]:
121
+ raise ValueError("files is not a short list")
122
+ for item in files:
123
+ if (not isinstance(item, dict) or set(item) != {"path", "action", "source"}
124
+ or not isinstance(item["path"], str) or not 0 < len(item["path"]) <= 256 or has_unsafe_chars(item["path"])
125
+ or any(ch in _PATH_FORBIDDEN for ch in item["path"]) or item["path"].startswith("/")
126
+ or ".." in item["path"].split("/") or item["action"] not in _ROLLBACK_ACTIONS
127
+ or item["source"] not in _ROLLBACK_SOURCES):
128
+ raise ValueError("a file entry is malformed")
129
+ return value
130
+
131
+
132
+ def _snap_key(event: MemoryEvent) -> str | None:
133
+ """Index key for snapshot bookkeeping events, kept apart from backend row ids so that no
134
+ backend can ever collide with it."""
135
+ if event.op not in META_OPS:
136
+ return None
137
+ return f"{event.op.value}:{event.memory_id.removeprefix('snapshot:')}"
138
+
139
+
140
+ def _decode(raw: bytes) -> str:
141
+ # Invalid UTF-8 in a stored value must not crash a read.
142
+ return raw.decode("utf-8", "replace")
143
+
144
+
145
+ def _prepare_file(path: Path) -> None:
146
+ """Create the file with 0600, or check the existing one. Refuses symlinks and non-files."""
147
+ try:
148
+ info = os.lstat(path)
149
+ except FileNotFoundError:
150
+ if not path.parent.is_dir():
151
+ raise LedgerError(f"directory does not exist: {path.parent}") from None
152
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
153
+ try:
154
+ os.close(os.open(path, flags, 0o600))
155
+ except FileExistsError:
156
+ pass
157
+ except OSError as exc:
158
+ raise LedgerError(f"cannot create ledger file: {exc.strerror}") from exc
159
+ return
160
+ except OSError as exc:
161
+ raise LedgerError(f"cannot access ledger file: {exc.strerror}") from exc
162
+ if stat.S_ISLNK(info.st_mode):
163
+ raise LedgerError("ledger path is a symlink; refusing to follow it")
164
+ if not stat.S_ISREG(info.st_mode):
165
+ raise LedgerError("ledger path is not a regular file")
166
+ if os.name == "posix" and info.st_mode & 0o077:
167
+ logger.warning(
168
+ "ledger file is readable by other users (mode %o); it holds memory text. "
169
+ "Run: chmod 600 on it.",
170
+ stat.S_IMODE(info.st_mode),
171
+ )
172
+
173
+
174
+ @dataclass
175
+ class VerifyResult:
176
+ ok: bool = True
177
+ problems: list[str] = field(default_factory=list)
178
+
179
+
180
+ class Ledger:
181
+ def __init__(self, path: str | Path, *, readonly: bool = False, busy_timeout: float = 5.0):
182
+ self.path = Path(path)
183
+ self._readonly = readonly
184
+ self._busy_timeout = busy_timeout
185
+ if readonly:
186
+ self._open_readonly()
187
+ return
188
+ _prepare_file(self.path)
189
+ try:
190
+ self._db = sqlite3.connect(str(self.path), timeout=30, isolation_level=None)
191
+ self._db.text_factory = _decode
192
+ self._init_schema()
193
+ except sqlite3.Error as exc:
194
+ raise LedgerError(f"cannot open ledger: {exc}") from exc
195
+
196
+ @classmethod
197
+ def open_readonly(cls, path: str | Path, *, busy_timeout: float = 5.0) -> "Ledger":
198
+ """Open an existing ledger so that nothing can be written: the file is opened with SQLite's
199
+ read-only mode, never created and never upgraded. Used by the viewer."""
200
+ return cls(path, readonly=True, busy_timeout=busy_timeout)
201
+
202
+ def _open_readonly(self) -> None:
203
+ try:
204
+ info = os.lstat(self.path)
205
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
206
+ raise LedgerError("the ledger path is not a plain regular file")
207
+ uri = self.path.resolve(strict=True).as_uri() + "?mode=ro"
208
+ self._db = sqlite3.connect(uri, uri=True, timeout=self._busy_timeout, isolation_level=None)
209
+ self._db.text_factory = _decode
210
+ self._db.execute("PRAGMA query_only = ON")
211
+ version = self._db.execute("PRAGMA user_version").fetchone()[0]
212
+ tables = {r[0] for r in self._db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
213
+ except OSError as exc:
214
+ raise LedgerError(f"cannot access the ledger file: {exc.strerror}") from exc
215
+ except sqlite3.Error as exc:
216
+ raise LedgerError(f"cannot open the ledger read-only: {exc}") from exc
217
+ if "events" not in tables or version not in (1, 2, SCHEMA_VERSION):
218
+ self._db.close()
219
+ raise LedgerError("this file is not a memdebug ledger of a supported version")
220
+ if version != SCHEMA_VERSION:
221
+ self._db.close()
222
+ raise LedgerError(
223
+ "this ledger is from an older version; run any normal memdebug command once "
224
+ "(for example 'memdebug verify') to upgrade it, then try again"
225
+ )
226
+
227
+ def _require_writable(self) -> None:
228
+ if self._readonly:
229
+ raise LedgerError("this ledger was opened read-only")
230
+
231
+ def close(self) -> None:
232
+ self._db.close()
233
+
234
+ # -- internals -------------------------------------------------------------------
235
+
236
+ @contextmanager
237
+ def _tx(self) -> Iterator[None]:
238
+ self._db.execute("BEGIN IMMEDIATE")
239
+ try:
240
+ yield
241
+ except BaseException:
242
+ try:
243
+ self._db.execute("ROLLBACK")
244
+ except sqlite3.Error:
245
+ pass
246
+ raise
247
+ else:
248
+ self._db.execute("COMMIT")
249
+
250
+ def _init_schema(self) -> None:
251
+ version = self._db.execute("PRAGMA user_version").fetchone()[0]
252
+ tables = {r[0] for r in self._db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
253
+ if version == 0 and not tables:
254
+ with self._tx():
255
+ self._create_events_table()
256
+ self._create_snapshot_tables()
257
+ self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
258
+ elif version in (1, 2) and "events" in tables:
259
+ with self._tx(): # an older ledger is upgraded in place; its events and chain are untouched
260
+ if version == 1:
261
+ self._db.execute("ALTER TABLE events ADD COLUMN snap TEXT")
262
+ self._db.execute(
263
+ "CREATE UNIQUE INDEX idx_events_snap ON events(snap) WHERE snap IS NOT NULL"
264
+ )
265
+ self._create_snapshot_tables()
266
+ self._db.execute("ALTER TABLE events ADD COLUMN op TEXT")
267
+ self._db.execute("ALTER TABLE events ADD COLUMN trust TEXT")
268
+ for seq, payload in self._db.execute("SELECT seq, payload FROM events").fetchall():
269
+ try:
270
+ data = json.loads(payload)
271
+ values = (data.get("op"), data.get("trust"), seq)
272
+ except (ValueError, AttributeError):
273
+ continue # left empty; verify reports it
274
+ self._db.execute("UPDATE events SET op = ?, trust = ? WHERE seq = ?", values)
275
+ self._create_filter_indexes()
276
+ self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
277
+ elif version != SCHEMA_VERSION or "events" not in tables:
278
+ raise LedgerError("this file is not a memdebug ledger of a supported version")
279
+
280
+ def _create_events_table(self) -> None:
281
+ self._db.execute(
282
+ "CREATE TABLE events ("
283
+ "seq INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, backend TEXT NOT NULL, "
284
+ "ref TEXT, snap TEXT, payload TEXT NOT NULL, prev_hash TEXT NOT NULL, hash TEXT NOT NULL, "
285
+ "op TEXT, trust TEXT)"
286
+ )
287
+ self._db.execute("CREATE UNIQUE INDEX idx_events_ref ON events(backend, ref) WHERE ref IS NOT NULL")
288
+ self._db.execute("CREATE UNIQUE INDEX idx_events_snap ON events(snap) WHERE snap IS NOT NULL")
289
+ self._create_filter_indexes()
290
+
291
+ def _create_filter_indexes(self) -> None:
292
+ self._db.execute("CREATE INDEX idx_events_op ON events(op, seq)")
293
+ self._db.execute("CREATE INDEX idx_events_trust ON events(trust, seq)")
294
+
295
+ def _create_snapshot_tables(self) -> None:
296
+ self._db.execute("CREATE TABLE blobs (hash TEXT PRIMARY KEY, text TEXT NOT NULL)")
297
+ self._db.execute(
298
+ "CREATE TABLE snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, backend TEXT NOT NULL, "
299
+ "scope TEXT NOT NULL, taken_at TEXT NOT NULL, ledger_seq INTEGER NOT NULL, "
300
+ "ledger_head TEXT NOT NULL, complete INTEGER NOT NULL, label TEXT, count INTEGER NOT NULL, "
301
+ "entries_hash TEXT NOT NULL)"
302
+ )
303
+ self._db.execute(
304
+ "CREATE TABLE snapshot_entries (snapshot_id INTEGER NOT NULL, memory_id TEXT NOT NULL, "
305
+ "hash TEXT NOT NULL, scope TEXT NOT NULL, PRIMARY KEY (snapshot_id, memory_id))"
306
+ )
307
+
308
+ # -- writing ---------------------------------------------------------------------
309
+
310
+ def _append_locked(self, events: Sequence[MemoryEvent]) -> list[LedgerEntry]:
311
+ """Append inside a transaction the caller already holds."""
312
+ entries: list[LedgerEntry] = []
313
+ row = self._db.execute("SELECT seq, hash FROM events ORDER BY seq DESC LIMIT 1").fetchone()
314
+ seq = row[0] if row else 0
315
+ prev = row[1] if row else GENESIS
316
+ for event in events:
317
+ if event.trust == Trust.UNKNOWN and event.source is not None:
318
+ event = event.model_copy(update={"trust": derive_trust(event.source)})
319
+ seq += 1
320
+ event_id = f"e{seq}"
321
+ payload = canonical(event)
322
+ digest = compute_hash(prev, event_id, payload)
323
+ snap = _snap_key(event)
324
+ self._db.execute(
325
+ "INSERT INTO events (seq, id, backend, ref, snap, payload, prev_hash, hash, op, trust) "
326
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
327
+ (seq, event_id, event.backend, event.backend_ref, snap, payload, prev, digest,
328
+ event.op.value, event.trust.value),
329
+ )
330
+ entries.append(LedgerEntry(seq=seq, id=event_id, event=event, prev_hash=prev, hash=digest))
331
+ prev = digest
332
+ return entries
333
+
334
+ def append_many(self, events: Sequence[MemoryEvent]) -> list[LedgerEntry]:
335
+ """Append events in one transaction: all of them, or none."""
336
+ self._require_writable()
337
+ if not events:
338
+ return []
339
+ if any(e.op in META_OPS for e in events):
340
+ raise LedgerError("snapshot bookkeeping events can only be written by the snapshot methods")
341
+ try:
342
+ with self._tx():
343
+ return self._append_locked(events)
344
+ except sqlite3.IntegrityError as exc:
345
+ raise LedgerConflictError("write collided with another writer; nothing was written") from exc
346
+ except sqlite3.Error as exc:
347
+ raise LedgerError(f"ledger write failed: {exc}") from exc
348
+
349
+ def append(self, event: MemoryEvent) -> LedgerEntry:
350
+ return self.append_many([event])[0]
351
+
352
+ # -- reading ---------------------------------------------------------------------
353
+
354
+ def head(self) -> str:
355
+ row = self._db.execute("SELECT hash FROM events ORDER BY seq DESC LIMIT 1").fetchone()
356
+ return row[0] if row else GENESIS
357
+
358
+ def backends(self) -> set[str]:
359
+ """The store types that have events on record."""
360
+ return {row[0] for row in self._db.execute("SELECT DISTINCT backend FROM events") if row[0]}
361
+
362
+ def has_events(self, backend: str) -> bool:
363
+ row = self._db.execute("SELECT 1 FROM events WHERE backend = ? LIMIT 1", (backend,)).fetchone()
364
+ return row is not None
365
+
366
+ def known_refs(self, backend: str) -> set[str]:
367
+ rows = self._db.execute(
368
+ "SELECT ref FROM events WHERE backend = ? AND ref IS NOT NULL", (backend,)
369
+ ).fetchall()
370
+ return {r[0] for r in rows}
371
+
372
+ @staticmethod
373
+ def _entry_from_row(seq, event_id, payload, prev, digest) -> LedgerEntry:
374
+ try:
375
+ event = MemoryEvent.model_validate_json(payload)
376
+ except ValidationError as exc:
377
+ raise LedgerError(
378
+ f"entry seq {seq} is unreadable; run 'memdebug verify' to check the ledger"
379
+ ) from exc
380
+ return LedgerEntry(seq=seq, id=event_id, event=event, prev_hash=prev, hash=digest)
381
+
382
+ def entries(self) -> list[LedgerEntry]:
383
+ rows = self._db.execute(
384
+ "SELECT seq, id, payload, prev_hash, hash FROM events ORDER BY seq"
385
+ ).fetchall()
386
+ return [self._entry_from_row(*row) for row in rows]
387
+
388
+ def counts(self) -> dict:
389
+ """Totals for an overview, from indexed columns (verify checks they match the events)."""
390
+ by_op = {op: n for op, n in self._db.execute("SELECT op, COUNT(*) FROM events GROUP BY op") if op}
391
+ return {
392
+ "events": sum(by_op.values()),
393
+ "by_op": by_op,
394
+ "untrusted": self._db.execute("SELECT COUNT(*) FROM events WHERE trust = 'untrusted'").fetchone()[0],
395
+ "snapshots": self._db.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0],
396
+ "head": self.head(),
397
+ "head_seq": self._db.execute("SELECT COALESCE(MAX(seq), 0) FROM events").fetchone()[0],
398
+ }
399
+
400
+ def events_page(
401
+ self, *, before_seq: int | None = None, limit: int = 50, op: str | None = None, trust: str | None = None
402
+ ) -> list[LedgerEntry]:
403
+ """Newest-first page of events. All filter values are checked against fixed lists and passed
404
+ as bound parameters."""
405
+ if not (isinstance(limit, int) and 1 <= limit <= 500):
406
+ raise LedgerError("limit must be between 1 and 500")
407
+ if before_seq is not None and not (isinstance(before_seq, int) and before_seq >= 1):
408
+ raise LedgerError("before_seq must be a positive integer")
409
+ if op is not None and op not in {o.value for o in Op}:
410
+ raise LedgerError("unknown event kind")
411
+ if trust is not None and trust not in {t.value for t in Trust}:
412
+ raise LedgerError("unknown trust value")
413
+ clauses: list[str] = []
414
+ params: list = []
415
+ for column, value in (("seq <", before_seq), ("op =", op), ("trust =", trust)):
416
+ if value is not None:
417
+ clauses.append(f"{column} ?") # column text comes from this fixed tuple only
418
+ params.append(value)
419
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
420
+ rows = self._db.execute(
421
+ f"SELECT seq, id, payload, prev_hash, hash FROM events{where} ORDER BY seq DESC LIMIT ?",
422
+ [*params, limit],
423
+ ).fetchall()
424
+ return [self._entry_from_row(*row) for row in rows]
425
+
426
+ def get_entry(self, event_id: str) -> LedgerEntry | None:
427
+ if not (isinstance(event_id, str) and _EVENT_ID_RE.match(event_id)):
428
+ raise LedgerError("an event id looks like e1, e2, e3 ...")
429
+ row = self._db.execute(
430
+ "SELECT seq, id, payload, prev_hash, hash FROM events WHERE id = ?", (event_id,)
431
+ ).fetchone()
432
+ return self._entry_from_row(*row) if row else None
433
+
434
+ def verify(self, expected_head: str | None = None) -> VerifyResult:
435
+ result = VerifyResult()
436
+ rows = self._db.execute(
437
+ "SELECT seq, id, backend, ref, snap, payload, prev_hash, hash, op, trust FROM events ORDER BY seq"
438
+ ).fetchall()
439
+ expected_seq = 1
440
+ prev = GENESIS
441
+ hash_by_seq: dict[int, str] = {0: GENESIS}
442
+ recorded: dict[str, dict] = {} # snapshot id -> what the chain says about it
443
+ deleted: set[str] = set()
444
+ for seq, event_id, backend, ref, snap, payload, stored_prev, stored_hash, op_col, trust_col in rows:
445
+ if seq != expected_seq:
446
+ result.problems.append(f"entries missing before seq {seq}")
447
+ expected_seq = seq
448
+ if event_id != f"e{seq}":
449
+ result.problems.append(f"seq {seq} has a wrong id")
450
+ if stored_prev != prev:
451
+ result.problems.append(f"seq {seq} does not link to the entry before it")
452
+ if compute_hash(prev, event_id, payload) != stored_hash:
453
+ result.problems.append(f"seq {seq} was changed after it was written")
454
+ else:
455
+ self._check_event_content(result, seq, backend, ref, snap, payload, op_col, trust_col, recorded, deleted)
456
+ hash_by_seq[seq] = stored_hash
457
+ prev = stored_hash
458
+ expected_seq += 1
459
+ if expected_head is not None and prev != expected_head:
460
+ result.problems.append(
461
+ "ledger head differs from the expected head; newest entries may have been removed"
462
+ )
463
+ self._verify_snapshots(result, hash_by_seq, recorded, deleted)
464
+ result.ok = not result.problems
465
+ return result
466
+
467
+ @staticmethod
468
+ def _check_event_content(result, seq, backend, ref, snap, payload, op_col, trust_col, recorded, deleted) -> None:
469
+ try:
470
+ data = json.loads(payload)
471
+ op = data.get("op")
472
+ expected_snap = None
473
+ if op in (Op.SNAPSHOT.value, Op.SNAPSHOT_DELETED.value):
474
+ sid = str(data.get("memory_id", "")).removeprefix("snapshot:")
475
+ expected_snap = f"{op}:{sid}"
476
+ if op == Op.SNAPSHOT.value:
477
+ recorded[sid] = json.loads(data["after"])
478
+ else:
479
+ deleted.add(sid)
480
+ elif op == Op.ROLLBACK.value:
481
+ rid = str(data.get("memory_id", ""))
482
+ if not re.match(r"^rollback:r[1-9][0-9]{0,8}\Z", rid):
483
+ raise ValueError("bad rollback id")
484
+ expected_snap = f"{op}:{rid}"
485
+ validate_rollback_details(json.loads(data["after"]))
486
+ if (data.get("backend") != backend or data.get("backend_ref") != ref or snap != expected_snap
487
+ or data.get("op") != op_col or data.get("trust") != trust_col):
488
+ result.problems.append(f"seq {seq} index columns do not match its content")
489
+ except (ValueError, AttributeError, KeyError, TypeError):
490
+ result.problems.append(f"seq {seq} content is not readable")
491
+
492
+ def _verify_snapshots(self, result, hash_by_seq, recorded, deleted) -> None:
493
+ present: set[str] = set()
494
+ for row in self._db.execute("SELECT id FROM snapshots ORDER BY id").fetchall():
495
+ sid = f"s{row[0]}"
496
+ present.add(sid)
497
+ try:
498
+ info, digest = self._snapshot_from_db(row[0])
499
+ except SnapshotError as exc:
500
+ result.problems.append(str(exc))
501
+ continue
502
+ chain = recorded.get(sid)
503
+ if chain is None:
504
+ result.problems.append(f"snapshot {sid} is not recorded in the ledger chain")
505
+ elif not isinstance(chain, dict) or chain.get("hash") != digest:
506
+ result.problems.append(f"snapshot {sid} does not match its record in the ledger chain")
507
+ if sid in deleted:
508
+ result.problems.append(f"snapshot {sid} was deleted in the ledger but still exists")
509
+ if hash_by_seq.get(info.ledger_seq) != info.ledger_head:
510
+ result.problems.append(
511
+ f"snapshot {sid} refers to ledger entries that no longer match (removed or rewritten?)"
512
+ )
513
+ for sid in recorded:
514
+ if sid not in present and sid not in deleted:
515
+ result.problems.append(f"snapshot {sid} is recorded in the ledger but is missing")
516
+ for digest, text in self._db.execute("SELECT hash, text FROM blobs"):
517
+ try:
518
+ if _text_digest(text) != digest:
519
+ result.problems.append("a stored snapshot text does not match its hash")
520
+ except SnapshotError:
521
+ result.problems.append("a stored snapshot text is not valid text")
522
+ orphaned = self._db.execute(
523
+ "SELECT COUNT(*) FROM snapshot_entries WHERE hash NOT IN (SELECT hash FROM blobs)"
524
+ ).fetchone()[0]
525
+ if orphaned:
526
+ result.problems.append(f"{orphaned} snapshot entries refer to texts that are missing")
527
+
528
+ # -- snapshots -----------------------------------------------------------------------------------
529
+
530
+ def _snapshot_from_db(self, number: int) -> tuple[SnapshotInfo, str]:
531
+ """Read a snapshot's header and recompute its digest from the stored entries."""
532
+ sid = f"s{number}"
533
+ row = self._db.execute(
534
+ "SELECT backend, scope, taken_at, ledger_seq, ledger_head, complete, label, count, entries_hash "
535
+ "FROM snapshots WHERE id = ?", (number,),
536
+ ).fetchone()
537
+ if row is None:
538
+ raise SnapshotError(f"snapshot {sid} does not exist")
539
+ backend, scope_json, taken_at, ledger_seq, ledger_head, complete, label, count, stored = row
540
+ try:
541
+ scope = json.loads(scope_json)
542
+ entries = [
543
+ [mid, h, json.loads(sc)]
544
+ for mid, h, sc in self._db.execute(
545
+ "SELECT memory_id, hash, scope FROM snapshot_entries WHERE snapshot_id = ?", (number,)
546
+ )
547
+ ]
548
+ info = SnapshotInfo(
549
+ id=sid, backend=backend, scope=scope, taken_at=taken_at, ledger_seq=ledger_seq,
550
+ ledger_head=ledger_head, complete=bool(complete), label=label, count=count,
551
+ )
552
+ except (ValueError, ValidationError, TypeError) as exc:
553
+ raise SnapshotError(f"snapshot {sid} is unreadable") from exc
554
+ digest = snapshot_digest(
555
+ number, backend, scope, taken_at, ledger_seq, ledger_head, bool(complete), label, entries
556
+ )
557
+ if digest != stored or count != len(entries):
558
+ raise SnapshotError(f"snapshot {sid} was changed after it was saved")
559
+ return info, digest
560
+
561
+ def save_snapshot(
562
+ self, backend: str, scope: dict[str, str], memories: Sequence[Memory], *, complete: bool,
563
+ taken_at: datetime, label: str | None = None,
564
+ ) -> SnapshotInfo:
565
+ """Store the memories as they are right now, and chain the snapshot's hash into the ledger."""
566
+ self._require_writable()
567
+ if label is not None and (not label or len(label) > MAX_LABEL_CHARS or has_unsafe_chars(label)):
568
+ raise SnapshotError(f"a label must be 1 to {MAX_LABEL_CHARS} printable characters")
569
+ if taken_at.tzinfo is None:
570
+ raise SnapshotError("the snapshot time must carry a timezone")
571
+ if len(memories) > MAX_SNAPSHOT_MEMORIES:
572
+ raise SnapshotError(f"too many memories for one snapshot (limit {MAX_SNAPSHOT_MEMORIES})")
573
+ if sum(len(m.text) for m in memories) > MAX_SNAPSHOT_CHARS:
574
+ raise SnapshotError("the memories are too large for one snapshot")
575
+ ids = [m.id for m in memories]
576
+ if len(set(ids)) != len(ids):
577
+ raise SnapshotError("the memory list contains the same id twice")
578
+
579
+ blobs: dict[str, str] = {}
580
+ entries: list[list] = []
581
+ for memory in sorted(memories, key=lambda m: m.id):
582
+ digest = _text_digest(memory.text)
583
+ blobs[digest] = memory.text
584
+ entries.append([memory.id, digest, dict(memory.scope)])
585
+ taken = taken_at.isoformat()
586
+ try:
587
+ with self._tx():
588
+ head = self._db.execute("SELECT seq, hash FROM events ORDER BY seq DESC LIMIT 1").fetchone()
589
+ ledger_seq, ledger_head = (head[0], head[1]) if head else (0, GENESIS)
590
+ self._db.executemany("INSERT OR IGNORE INTO blobs (hash, text) VALUES (?, ?)", list(blobs.items()))
591
+ cursor = self._db.execute(
592
+ "INSERT INTO snapshots (backend, scope, taken_at, ledger_seq, ledger_head, complete, label, "
593
+ "count, entries_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')",
594
+ (backend, _canonical_json(scope), taken, ledger_seq, ledger_head, int(complete), label,
595
+ len(entries)),
596
+ )
597
+ number = cursor.lastrowid
598
+ if number is None:
599
+ raise SnapshotError("could not save the snapshot: no row id was returned")
600
+ self._db.executemany(
601
+ "INSERT INTO snapshot_entries (snapshot_id, memory_id, hash, scope) VALUES (?, ?, ?, ?)",
602
+ [(number, mid, h, _canonical_json(sc)) for mid, h, sc in entries],
603
+ )
604
+ digest = snapshot_digest(number, backend, scope, taken, ledger_seq, ledger_head,
605
+ complete, label, entries)
606
+ self._db.execute("UPDATE snapshots SET entries_hash = ? WHERE id = ?", (digest, number))
607
+ sid = f"s{number}"
608
+ self._append_locked([MemoryEvent(
609
+ backend=backend, memory_id=f"snapshot:{sid}", op=Op.SNAPSHOT, ts=taken_at, scope=scope,
610
+ after=_canonical_json({"count": len(entries), "complete": bool(complete), "hash": digest}),
611
+ )])
612
+ info, _ = self._snapshot_from_db(number)
613
+ except sqlite3.IntegrityError as exc:
614
+ raise LedgerConflictError("write collided with another writer; nothing was written") from exc
615
+ except (sqlite3.Error, ValidationError) as exc:
616
+ raise SnapshotError(f"could not save the snapshot: {exc}") from exc
617
+ return info
618
+
619
+ def list_snapshots(self) -> list[SnapshotInfo]:
620
+ infos: list[SnapshotInfo] = []
621
+ for (number,) in self._db.execute("SELECT id FROM snapshots ORDER BY id").fetchall():
622
+ infos.append(self._snapshot_from_db(number)[0])
623
+ return infos
624
+
625
+ def load_snapshot(self, snapshot_id: str) -> Snapshot:
626
+ """Load a snapshot after checking it against its own hash, its texts, and the ledger chain."""
627
+ number = parse_snapshot_id(snapshot_id)
628
+ info, digest = self._snapshot_from_db(number)
629
+ record = self._db.execute(
630
+ "SELECT payload FROM events WHERE snap = ?", (f"{Op.SNAPSHOT.value}:{info.id}",)
631
+ ).fetchone()
632
+ try:
633
+ chained = json.loads(json.loads(record[0])["after"])["hash"] if record else None
634
+ except (ValueError, KeyError, TypeError):
635
+ chained = None
636
+ if chained != digest:
637
+ raise SnapshotError(f"snapshot {info.id} does not match its record in the ledger chain")
638
+ entries = self._db.execute(
639
+ "SELECT memory_id, hash, scope FROM snapshot_entries WHERE snapshot_id = ? ORDER BY memory_id",
640
+ (number,),
641
+ ).fetchall()
642
+ texts: dict[str, str] = {}
643
+ wanted = sorted({h for _, h, _ in entries})
644
+ for i in range(0, len(wanted), _IN_CHUNK):
645
+ chunk = wanted[i:i + _IN_CHUNK]
646
+ marks = ",".join("?" for _ in chunk) # only placeholders are built; values are bound
647
+ for digest_, text in self._db.execute(f"SELECT hash, text FROM blobs WHERE hash IN ({marks})", chunk):
648
+ if _text_digest(text) != digest_:
649
+ raise SnapshotError(f"snapshot {info.id} contains a text that was changed")
650
+ texts[digest_] = text
651
+ try:
652
+ memories = [Memory(id=mid, text=texts[h], scope=json.loads(sc)) for mid, h, sc in entries]
653
+ except KeyError as exc:
654
+ raise SnapshotError(f"snapshot {info.id} is missing a stored text") from exc
655
+ except (ValueError, ValidationError) as exc:
656
+ raise SnapshotError(f"snapshot {info.id} contains an unreadable entry") from exc
657
+ return Snapshot(info=info, memories=memories)
658
+
659
+ def delete_snapshot(self, snapshot_id: str) -> SnapshotInfo:
660
+ """Remove a snapshot. The deletion is chained into the ledger, so it cannot be done silently.
661
+ Texts no other snapshot uses are removed too."""
662
+ self._require_writable()
663
+ number = parse_snapshot_id(snapshot_id)
664
+ try:
665
+ with self._tx():
666
+ info, _ = self._snapshot_from_db(number)
667
+ self._db.execute("DELETE FROM snapshot_entries WHERE snapshot_id = ?", (number,))
668
+ self._db.execute("DELETE FROM snapshots WHERE id = ?", (number,))
669
+ self._db.execute("DELETE FROM blobs WHERE hash NOT IN (SELECT hash FROM snapshot_entries)")
670
+ self._append_locked([MemoryEvent(
671
+ backend=info.backend, memory_id=f"snapshot:{info.id}", op=Op.SNAPSHOT_DELETED,
672
+ ts=datetime.now(timezone.utc), scope=info.scope,
673
+ )])
674
+ except sqlite3.IntegrityError as exc:
675
+ raise LedgerConflictError("write collided with another writer; nothing was written") from exc
676
+ except sqlite3.Error as exc:
677
+ raise SnapshotError(f"could not delete the snapshot: {exc}") from exc
678
+ return info
679
+
680
+ def record_rollback(self, backend: str, scope: dict[str, str], details: dict, *, ts: datetime) -> LedgerEntry:
681
+ """Chain a record of a completed rollback into the ledger. Only this method can write one, so a rollback
682
+ cannot be claimed (or hidden) by appending an ordinary event."""
683
+ self._require_writable()
684
+ if ts.tzinfo is None:
685
+ raise LedgerError("the rollback time must carry a timezone")
686
+ try:
687
+ clean = validate_rollback_details(details)
688
+ except ValueError as exc:
689
+ raise LedgerError(f"invalid rollback record: {exc}") from exc
690
+ try:
691
+ with self._tx():
692
+ number = self._db.execute("SELECT COUNT(*) FROM events WHERE op = ?", (Op.ROLLBACK.value,)).fetchone()[0] + 1
693
+ return self._append_locked([MemoryEvent(
694
+ backend=backend, memory_id=f"rollback:r{number}", op=Op.ROLLBACK, ts=ts, scope=scope,
695
+ after=_canonical_json(clean),
696
+ )])[0]
697
+ except sqlite3.IntegrityError as exc:
698
+ raise LedgerConflictError("write collided with another writer; nothing was written") from exc
699
+ except sqlite3.Error as exc:
700
+ raise LedgerError(f"could not record the rollback: {exc}") from exc