idea33-buildledger-verifiab.../buildledger/ledger.py

196 lines
7.3 KiB
Python

from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from .models import LedgerEntry, canonical_json, sha256_text
GENESIS_HASH = "0" * 64
class LedgerError(RuntimeError):
pass
class ChainDivergenceError(LedgerError):
pass
class SQLiteLedger:
def __init__(self, path: str | Path = ":memory:") -> None:
self.path = str(path)
self._conn = sqlite3.connect(self.path)
self._conn.row_factory = sqlite3.Row
self._init_schema()
def close(self) -> None:
self._conn.close()
def __enter__(self) -> "SQLiteLedger":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def _init_schema(self) -> None:
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
sequence INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
previous_hash TEXT NOT NULL,
entry_hash TEXT NOT NULL UNIQUE,
anchor_ref TEXT
)
"""
)
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS anchors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
method TEXT NOT NULL,
reference TEXT NOT NULL,
anchored_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
metadata TEXT NOT NULL
)
"""
)
self._conn.commit()
@property
def head(self) -> LedgerEntry | None:
row = self._conn.execute("SELECT * FROM entries ORDER BY sequence DESC LIMIT 1").fetchone()
return self._row_to_entry(row) if row else None
def append(self, kind: str, payload: dict[str, Any], anchor_ref: str | None = None) -> LedgerEntry:
last = self.head
sequence = 0 if last is None else last.sequence + 1
previous_hash = GENESIS_HASH if last is None else last.entry_hash
entry = LedgerEntry.create(sequence=sequence, kind=kind, payload=payload, previous_hash=previous_hash, anchor_ref=anchor_ref)
self._conn.execute(
"INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
entry.sequence,
entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
entry.kind,
canonical_json(entry.payload),
entry.previous_hash,
entry.entry_hash,
entry.anchor_ref,
),
)
self._conn.commit()
return entry
def all_entries(self) -> list[LedgerEntry]:
rows = self._conn.execute("SELECT * FROM entries ORDER BY sequence ASC").fetchall()
return [self._row_to_entry(row) for row in rows]
def entry_at(self, sequence: int) -> LedgerEntry | None:
row = self._conn.execute("SELECT * FROM entries WHERE sequence = ?", (sequence,)).fetchone()
return self._row_to_entry(row) if row else None
def entries_after(self, sequence: int) -> list[LedgerEntry]:
rows = self._conn.execute("SELECT * FROM entries WHERE sequence > ? ORDER BY sequence ASC", (sequence,)).fetchall()
return [self._row_to_entry(row) for row in rows]
def verify_chain(self) -> tuple[bool, list[str]]:
issues: list[str] = []
previous_hash = GENESIS_HASH
for expected_sequence, entry in enumerate(self.all_entries()):
if entry.sequence != expected_sequence:
issues.append(f"sequence gap at {entry.sequence}")
if entry.previous_hash != previous_hash:
issues.append(f"previous hash mismatch at {entry.sequence}")
recomputed = LedgerEntry.compute_hash(
sequence=entry.sequence,
timestamp=entry.timestamp,
kind=entry.kind,
payload=entry.payload,
previous_hash=entry.previous_hash,
)
if recomputed != entry.entry_hash:
issues.append(f"entry hash mismatch at {entry.sequence}")
previous_hash = entry.entry_hash
return (not issues, issues)
def anchor_head(self, method: str, reference: str, metadata: dict[str, str] | None = None) -> str:
head = self.head
if head is None:
raise LedgerError("cannot anchor an empty ledger")
metadata_json = json.dumps(metadata or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
self._conn.execute(
"INSERT INTO anchors(method, reference, anchored_hash, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
(
method,
reference,
head.entry_hash,
datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
metadata_json,
),
)
self._conn.commit()
return head.entry_hash
def anchors(self) -> list[dict[str, Any]]:
rows = self._conn.execute("SELECT * FROM anchors ORDER BY id ASC").fetchall()
return [dict(row) for row in rows]
def export_json(self) -> str:
return canonical_json([entry.model_dump(mode="python") for entry in self.all_entries()])
def import_entries(self, entries: Iterable[LedgerEntry]) -> None:
for entry in entries:
self._insert_entry(entry)
self._conn.commit()
def _insert_entry(self, entry: LedgerEntry) -> None:
last = self.head
expected_sequence = 0 if last is None else last.sequence + 1
expected_previous_hash = GENESIS_HASH if last is None else last.entry_hash
if entry.sequence != expected_sequence or entry.previous_hash != expected_previous_hash:
raise ChainDivergenceError("incoming entry does not extend the current chain")
recomputed = LedgerEntry.compute_hash(
sequence=entry.sequence,
timestamp=entry.timestamp,
kind=entry.kind,
payload=entry.payload,
previous_hash=entry.previous_hash,
)
if recomputed != entry.entry_hash:
raise LedgerError("incoming entry hash is invalid")
self._conn.execute(
"INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
entry.sequence,
entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
entry.kind,
canonical_json(entry.payload),
entry.previous_hash,
entry.entry_hash,
entry.anchor_ref,
),
)
def _row_to_entry(self, row: sqlite3.Row | None) -> LedgerEntry:
if row is None:
raise LedgerError("entry not found")
payload = json.loads(row["payload"])
timestamp = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00"))
return LedgerEntry(
sequence=row["sequence"],
timestamp=timestamp,
kind=row["kind"],
payload=payload,
previous_hash=row["previous_hash"],
entry_hash=row["entry_hash"],
anchor_ref=row["anchor_ref"],
)