provance 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.
provance/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """PROVANCE for Python: tamper-evident audit ledgers.
2
+
3
+ Write audit events into a hash chained, signed ledger, and verify ledgers written by any PROVANCE implementation
4
+ (.NET, Java, Python). See docs/SPEC.md for the format.
5
+
6
+ from provance import AuditedPayload, ProvanceLedger, SqlLedgerStore
7
+
8
+ ledger = ProvanceLedger(ledger_id="orders-api-prod", secret_key=os.environ["PROVANCE_SECRET_KEY"],
9
+ store=SqlLedgerStore.from_url("postgresql://app@db/audit"))
10
+ ledger.add_entry("ORDER_REFUNDED", AuditedPayload(actor_id="user-42", description="Refund issued"))
11
+ result = ledger.verify()
12
+ """
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ from .checkpoints import FileCheckpointSink, LedgerCheckpointSink, read_latest_checkpoint
17
+ from .errors import (
18
+ LedgerConcurrencyError,
19
+ LedgerError,
20
+ LedgerOverloadedError,
21
+ LedgerStoreError,
22
+ LedgerUnavailableError,
23
+ PayloadError,
24
+ )
25
+ from .format import (
26
+ FORMAT_VERSION,
27
+ canonical_checkpoint,
28
+ canonical_envelope,
29
+ compute_checkpoint_digest,
30
+ compute_entry_hash,
31
+ derive_genesis_hash,
32
+ format_timestamp,
33
+ parse_timestamp,
34
+ sha256_hex,
35
+ )
36
+ from .ledger import ProvanceLedger
37
+ from .models import AuditedPayload, LedgerCheckpoint, LedgerEntry
38
+ from .options import ProvanceOptions, QueueFullBehavior
39
+ from .payload import canonical_payload
40
+ from .signing import HmacLedgerSigner, HmacVerifier, LedgerSigner, SignatureCheck, SignatureVerifier
41
+ from .stores import (
42
+ InMemoryLedgerStore,
43
+ JsonLinesReader,
44
+ LedgerStore,
45
+ ListSource,
46
+ SqlLedgerReader,
47
+ SqlLedgerStore,
48
+ connect,
49
+ connection_factory,
50
+ write_json_lines,
51
+ )
52
+ from .verifier import LedgerTamperedError, LedgerVerifier, VerificationFailure, VerificationResult
53
+
54
+ try:
55
+ __version__ = version("provance")
56
+ except PackageNotFoundError: # running from a source checkout
57
+ __version__ = "0.0.0"
58
+
59
+ __all__ = [
60
+ "FORMAT_VERSION",
61
+ "AuditedPayload",
62
+ "FileCheckpointSink",
63
+ "HmacLedgerSigner",
64
+ "HmacVerifier",
65
+ "InMemoryLedgerStore",
66
+ "JsonLinesReader",
67
+ "LedgerCheckpoint",
68
+ "LedgerCheckpointSink",
69
+ "LedgerConcurrencyError",
70
+ "LedgerEntry",
71
+ "LedgerError",
72
+ "LedgerOverloadedError",
73
+ "LedgerSigner",
74
+ "LedgerStore",
75
+ "LedgerStoreError",
76
+ "LedgerTamperedError",
77
+ "LedgerUnavailableError",
78
+ "LedgerVerifier",
79
+ "ListSource",
80
+ "PayloadError",
81
+ "ProvanceLedger",
82
+ "ProvanceOptions",
83
+ "QueueFullBehavior",
84
+ "SignatureCheck",
85
+ "SignatureVerifier",
86
+ "SqlLedgerReader",
87
+ "SqlLedgerStore",
88
+ "VerificationFailure",
89
+ "VerificationResult",
90
+ "canonical_checkpoint",
91
+ "canonical_envelope",
92
+ "canonical_payload",
93
+ "compute_checkpoint_digest",
94
+ "compute_entry_hash",
95
+ "connect",
96
+ "connection_factory",
97
+ "derive_genesis_hash",
98
+ "format_timestamp",
99
+ "parse_timestamp",
100
+ "read_latest_checkpoint",
101
+ "sha256_hex",
102
+ "write_json_lines",
103
+ ]
provance/_writer.py ADDED
@@ -0,0 +1,249 @@
1
+ """Background writer of one process. Same algorithm as the .NET and Java writers.
2
+
3
+ Linearity across processes comes from the store: it rejects a sequence or id that already exists. On any failure
4
+ the writer reloads the head from the store, acknowledges the entries that were in fact persisted (looked up by
5
+ their fixed id) and reseals the others on top of the real head. Several application instances can therefore
6
+ write to the same ledger.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import queue
13
+ import random
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from concurrent.futures import Future
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from typing import Callable
21
+
22
+ from .errors import LedgerConcurrencyError, LedgerOverloadedError, LedgerUnavailableError
23
+ from .format import FORMAT_VERSION, MAX_SEQUENCE, compute_entry_hash, normalize_timestamp
24
+ from .models import LedgerEntry
25
+ from .options import ProvanceOptions, QueueFullBehavior
26
+ from .signing import LedgerSigner
27
+ from .stores import LedgerStore
28
+
29
+ log = logging.getLogger("provance.writer")
30
+
31
+ _POLL_SECONDS = 0.1
32
+
33
+
34
+ @dataclass
35
+ class PendingEntry:
36
+ """An entry prepared by a producer (payload canonicalized and hashed) waiting for the writer.
37
+
38
+ The id is fixed here so that the writer can recognize the entry in the store after an ambiguous failure.
39
+ """
40
+
41
+ event_type: str
42
+ payload_json: str
43
+ payload_hash: str
44
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
45
+ future: Future = field(default_factory=Future)
46
+
47
+ def succeed(self, entry: LedgerEntry) -> None:
48
+ if not self.future.done():
49
+ self.future.set_result(entry)
50
+
51
+ def fail(self, error: BaseException) -> None:
52
+ if not self.future.done():
53
+ self.future.set_exception(error)
54
+
55
+
56
+ class _Aborted(Exception):
57
+ pass
58
+
59
+
60
+ class LedgerWriter:
61
+ def __init__(self, store: LedgerStore, signer: LedgerSigner, options: ProvanceOptions,
62
+ clock: Callable[[], datetime]):
63
+ self._queue: queue.Queue[PendingEntry] = queue.Queue(maxsize=options.queue_capacity)
64
+ self._store = store
65
+ self._signer = signer
66
+ self._options = options
67
+ self._clock = clock
68
+ self._genesis = options.effective_genesis_hash()
69
+ self._accepting = True
70
+ self._aborted = False
71
+ self._head: tuple[int, str] | None = None # writer thread only
72
+ self.thread = threading.Thread(target=self._run, name=f"provance-writer-{options.ledger_id}", daemon=True)
73
+
74
+ def start(self) -> None:
75
+ self.thread.start()
76
+
77
+ def enqueue(self, entry: PendingEntry) -> None:
78
+ if not self._accepting:
79
+ raise _stopped()
80
+ if self._options.queue_full_behavior is QueueFullBehavior.FAIL_FAST:
81
+ try:
82
+ self._queue.put_nowait(entry)
83
+ except queue.Full:
84
+ raise (LedgerOverloadedError("PROVANCE queue is full.") if self._accepting else _stopped()) from None
85
+ return
86
+ while True:
87
+ try:
88
+ self._queue.put(entry, timeout=_POLL_SECONDS)
89
+ return
90
+ except queue.Full:
91
+ if not self._accepting:
92
+ raise _stopped() from None
93
+
94
+ def stop_accepting(self) -> None:
95
+ self._accepting = False
96
+
97
+ def abort(self) -> None:
98
+ self._accepting = False
99
+ self._aborted = True
100
+
101
+ def fail_queued(self) -> None:
102
+ while True:
103
+ try:
104
+ self._queue.get_nowait().fail(LedgerUnavailableError("PROVANCE writer stopped before the entry was written."))
105
+ except queue.Empty:
106
+ return
107
+
108
+ def _run(self) -> None:
109
+ log.info("PROVANCE writer started for ledger %s.", self._options.ledger_id)
110
+ try:
111
+ while not self._aborted:
112
+ try:
113
+ first = self._queue.get(timeout=_POLL_SECONDS)
114
+ except queue.Empty:
115
+ if not self._accepting and self._queue.empty():
116
+ break
117
+ continue
118
+ batch = [first]
119
+ while len(batch) < self._options.max_batch_size:
120
+ try:
121
+ batch.append(self._queue.get_nowait())
122
+ except queue.Empty:
123
+ break
124
+ try:
125
+ self._process(batch)
126
+ except _Aborted:
127
+ for item in batch:
128
+ item.fail(LedgerUnavailableError("PROVANCE writer stopped before the entry was written."))
129
+ break
130
+ except Exception as e: # never let the writer die
131
+ log.exception("Unexpected error in the PROVANCE writer. The batch is failed and the writer continues.")
132
+ self._head = None
133
+ for item in batch:
134
+ item.fail(LedgerUnavailableError(f"Unexpected PROVANCE writer error: {e}"))
135
+ finally:
136
+ self._accepting = False
137
+ self.fail_queued()
138
+ log.info("PROVANCE writer stopped for ledger %s.", self._options.ledger_id)
139
+
140
+ def _process(self, batch: list[PendingEntry]) -> None:
141
+ pending = list(batch)
142
+ transient_failures = conflicts = 0
143
+
144
+ while pending:
145
+ if self._aborted:
146
+ raise _Aborted()
147
+ sealed: list[LedgerEntry] | None = None
148
+ try:
149
+ head = self._head if self._head is not None else self._load_head()
150
+ sealed = self._seal(pending, head)
151
+ self._store.append(sealed)
152
+ for item, entry in zip(pending, sealed):
153
+ item.succeed(entry)
154
+ self._head = (sealed[-1].sequence, sealed[-1].hash)
155
+ return
156
+ except LedgerConcurrencyError as e:
157
+ failure: Exception = e
158
+ is_conflict = True
159
+ except Exception as e:
160
+ failure = e
161
+ is_conflict = False
162
+
163
+ if self._aborted:
164
+ raise _Aborted()
165
+
166
+ # Whatever happened, our view of the head is no longer trustworthy.
167
+ self._head = None
168
+
169
+ if sealed is not None:
170
+ self._acknowledge_persisted(pending)
171
+ if not pending:
172
+ return
173
+
174
+ if is_conflict:
175
+ conflicts += 1
176
+ if conflicts > self._options.max_conflict_retries:
177
+ log.error("PROVANCE writer gave up after %d sequence conflicts.", conflicts)
178
+ error = LedgerUnavailableError(f"Gave up after {conflicts} sequence conflicts with other writers.")
179
+ error.__cause__ = failure
180
+ for item in pending:
181
+ item.fail(error)
182
+ return
183
+ log.debug("Sequence conflict (another writer appended first). Resealing %d entries.", len(pending))
184
+ continue
185
+
186
+ transient_failures += 1
187
+ if transient_failures >= self._options.max_append_attempts:
188
+ log.error("PROVANCE append failed %d times; failing %d entries: %s", transient_failures, len(pending), failure)
189
+ error = LedgerUnavailableError(f"The ledger store failed {transient_failures} times: {failure}")
190
+ error.__cause__ = failure
191
+ for item in pending:
192
+ item.fail(error)
193
+ return
194
+
195
+ delay = self._backoff(transient_failures)
196
+ log.warning("PROVANCE append failed (attempt %d/%d), retrying in %.0f ms: %s",
197
+ transient_failures, self._options.max_append_attempts, delay * 1000, failure)
198
+ time.sleep(delay)
199
+
200
+ def _acknowledge_persisted(self, pending: list[PendingEntry]) -> None:
201
+ acknowledged = 0
202
+ try:
203
+ for item in pending:
204
+ stored = self._store.get_by_id(item.id)
205
+ if stored is None:
206
+ break
207
+ item.succeed(stored)
208
+ acknowledged += 1
209
+ except Exception as e:
210
+ # If an entry was persisted after all, the next append reuses its id, the store reports a conflict and
211
+ # this method runs again.
212
+ log.warning("Could not check which entries were persisted after a failed append: %s", e)
213
+ if acknowledged:
214
+ log.info("%d entries were persisted despite the reported failure.", acknowledged)
215
+ del pending[:acknowledged]
216
+
217
+ def _load_head(self) -> tuple[int, str]:
218
+ head = self._store.get_head()
219
+ if head is None:
220
+ return 0, self._genesis
221
+ if head.ledger_id != self._options.ledger_id:
222
+ raise RuntimeError(f"The store contains ledger '{head.ledger_id}' but this application is configured for "
223
+ f"'{self._options.ledger_id}'. Use one table per ledger.")
224
+ return head.sequence, head.hash
225
+
226
+ def _seal(self, pending: list[PendingEntry], head: tuple[int, str]) -> list[LedgerEntry]:
227
+ sequence, previous = head
228
+ timestamp = normalize_timestamp(self._clock())
229
+ key_id = self._signer.key_id
230
+ sealed = []
231
+ for item in pending:
232
+ sequence += 1
233
+ if sequence > MAX_SEQUENCE:
234
+ raise RuntimeError("The ledger reached the maximum sequence number.")
235
+ draft = LedgerEntry(FORMAT_VERSION, self._options.ledger_id, sequence, item.id, timestamp, item.event_type,
236
+ item.payload_json, item.payload_hash, previous, key_id, "", "")
237
+ digest = compute_entry_hash(draft)
238
+ entry = draft.with_changes(hash=digest, signature=self._signer.sign(bytes.fromhex(digest)).hex())
239
+ sealed.append(entry)
240
+ previous = digest
241
+ return sealed
242
+
243
+ def _backoff(self, failures: int) -> float:
244
+ base = self._options.retry_base_delay.total_seconds() * 2 ** (failures - 1)
245
+ return min(base + base * 0.2 * random.random(), 2.0)
246
+
247
+
248
+ def _stopped() -> LedgerUnavailableError:
249
+ return LedgerUnavailableError("PROVANCE ledger is closed; the entry was not queued.")
@@ -0,0 +1,101 @@
1
+ """Checkpoint sinks and automatic publication.
2
+
3
+ A checkpoint is only as trustworthy as the place it is stored: use storage the application cannot overwrite
4
+ (object storage with a retention lock, WORM storage, a timestamping service, a system owned by another team).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import threading
11
+ from datetime import timedelta
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Callable, Protocol, Sequence
14
+
15
+ from .models import LedgerCheckpoint
16
+
17
+ if TYPE_CHECKING:
18
+ from .ledger import ProvanceLedger
19
+
20
+ log = logging.getLogger("provance.checkpoints")
21
+
22
+
23
+ class LedgerCheckpointSink(Protocol):
24
+ """Destination of automatic checkpoints. Must not overwrite an existing checkpoint."""
25
+
26
+ def publish(self, checkpoint: LedgerCheckpoint) -> None: ...
27
+
28
+
29
+ class FileCheckpointSink:
30
+ """Writes each checkpoint to ``{directory}/{ledger_id}/{sequence:016}.json`` and never overwrites a file.
31
+
32
+ Same layout as the .NET and Java file sinks. Useful when the directory is mounted from immutable storage.
33
+ """
34
+
35
+ def __init__(self, directory: str | Path):
36
+ self.directory = Path(directory)
37
+
38
+ def publish(self, checkpoint: LedgerCheckpoint) -> None:
39
+ folder = _folder(self.directory, checkpoint.ledger_id)
40
+ folder.mkdir(parents=True, exist_ok=True)
41
+ path = folder / f"{checkpoint.sequence:016d}.json"
42
+ try:
43
+ with open(path, "x", encoding="utf-8", newline="\n") as f: # "x": fails if the file exists
44
+ f.write(checkpoint.to_json())
45
+ except FileExistsError:
46
+ pass # same head already published, possibly by another instance
47
+
48
+
49
+ def read_latest_checkpoint(directory: str | Path, ledger_id: str) -> LedgerCheckpoint | None:
50
+ """Reads the checkpoint with the highest sequence written by a file sink, or ``None``."""
51
+ folder = _folder(Path(directory), ledger_id)
52
+ files = [f for f in folder.glob("*.json") if f.stem.isdigit()] if folder.is_dir() else []
53
+ if not files:
54
+ return None
55
+ return LedgerCheckpoint.from_json(max(files, key=lambda f: int(f.stem)).read_text(encoding="utf-8"))
56
+
57
+
58
+ def _folder(directory: Path, ledger_id: str) -> Path:
59
+ if not ledger_id or ledger_id in (".", "..") or "/" in ledger_id or "\\" in ledger_id:
60
+ raise ValueError(f"Ledger id '{ledger_id}' cannot be used as a folder name.")
61
+ return directory / ledger_id
62
+
63
+
64
+ class CheckpointPublisher:
65
+ """Publishes a checkpoint of the head to every sink at the interval, when the head has moved."""
66
+
67
+ def __init__(self, create: Callable[[], LedgerCheckpoint | None], sinks: Sequence[LedgerCheckpointSink],
68
+ interval: timedelta, name: str):
69
+ self._create = create
70
+ self._sinks = list(sinks)
71
+ self._interval = interval.total_seconds()
72
+ self._stop = threading.Event()
73
+ self._last_sequence = 0
74
+ self._lock = threading.Lock()
75
+ self._thread = threading.Thread(target=self._run, name=f"provance-checkpoints-{name}", daemon=True)
76
+
77
+ def start(self) -> None:
78
+ if self._sinks:
79
+ self._thread.start()
80
+
81
+ def stop(self) -> None:
82
+ self._stop.set()
83
+ if self._thread.is_alive():
84
+ self._thread.join(timeout=5)
85
+
86
+ def publish_once(self) -> None:
87
+ with self._lock:
88
+ try:
89
+ checkpoint = self._create()
90
+ if checkpoint is None or checkpoint.sequence == self._last_sequence:
91
+ return
92
+ for sink in self._sinks:
93
+ sink.publish(checkpoint)
94
+ self._last_sequence = checkpoint.sequence
95
+ log.info("PROVANCE checkpoint published at sequence %d.", checkpoint.sequence)
96
+ except Exception:
97
+ log.exception("PROVANCE checkpoint publication failed; it will be retried at the next interval.")
98
+
99
+ def _run(self) -> None:
100
+ while not self._stop.wait(self._interval):
101
+ self.publish_once()
provance/cli.py ADDED
@@ -0,0 +1,172 @@
1
+ """Command line: ``provance verify`` and ``provance export``.
2
+
3
+ Exit codes: 0 the ledger is valid, 1 verification failed, 2 usage or connection error.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Sequence
14
+
15
+ from . import __version__
16
+ from .models import LedgerCheckpoint
17
+ from .signing import HmacVerifier
18
+ from .stores import DEFAULT_TABLE, JsonLinesReader, SqlLedgerReader, connect, write_json_lines
19
+ from .verifier import LedgerVerifier
20
+
21
+ DEFAULT_KEY_ENV = "PROVANCE_SECRET_KEY"
22
+
23
+
24
+ class UsageError(Exception):
25
+ pass
26
+
27
+
28
+ def main(argv: Sequence[str] | None = None) -> int:
29
+ parser = _parser()
30
+ args = parser.parse_args(argv)
31
+ if not getattr(args, "command", None):
32
+ parser.print_help()
33
+ return 2
34
+ try:
35
+ return args.handler(args)
36
+ except (UsageError, ValueError, FileNotFoundError, RuntimeError) as e:
37
+ print(f"provance: {e}", file=sys.stderr)
38
+ return 2
39
+ except Exception as e: # driver errors (connection refused, unknown table, ...)
40
+ print(f"provance: {type(e).__name__}: {e}", file=sys.stderr)
41
+ return 2
42
+
43
+
44
+ def _parser() -> argparse.ArgumentParser:
45
+ p = argparse.ArgumentParser(prog="provance", description="Verify and export PROVANCE tamper-evident ledgers.")
46
+ p.add_argument("--version", action="version", version=f"provance {__version__}")
47
+ sub = p.add_subparsers(dest="command")
48
+
49
+ v = sub.add_parser("verify", help="verify a ledger from a database or a JSON Lines export",
50
+ description="Verify the hash chain, signatures and, with --checkpoint, truncation and rewrites.")
51
+ _add_source(v)
52
+ v.add_argument("--ledger-id", required=True, help="ledger id configured in the application")
53
+ v.add_argument("--genesis-hash", help="only if the application configured an explicit genesis hash")
54
+ v.add_argument("--key-id", default="k1", help="id of the current HMAC key (default: k1)")
55
+ v.add_argument("--key-env", default=DEFAULT_KEY_ENV, metavar="VAR",
56
+ help=f"environment variable holding the HMAC secret (default: {DEFAULT_KEY_ENV})")
57
+ v.add_argument("--key-file", metavar="PATH", help="file holding the HMAC secret, instead of --key-env")
58
+ v.add_argument("--retired-key", action="append", default=[], metavar="ID=VAR",
59
+ help="retired key: key id and the environment variable holding its secret (repeatable)")
60
+ v.add_argument("--checkpoint", metavar="PATH",
61
+ help="trusted checkpoint: a checkpoint JSON file, or a checkpoint directory (latest one is used)")
62
+ v.add_argument("--from-checkpoint", action="store_true", help="verify only from the checkpoint onward")
63
+ v.add_argument("--json", action="store_true", help="print the result as JSON")
64
+ v.set_defaults(handler=_verify)
65
+
66
+ e = sub.add_parser("export", help="export a ledger table to JSON Lines (for an offline audit)")
67
+ _add_db(e, required=True)
68
+ e.add_argument("--from", dest="from_sequence", type=int, default=1, metavar="SEQ", help="first sequence (default: 1)")
69
+ e.add_argument("-o", "--output", metavar="PATH", help="output file (default: standard output)")
70
+ e.set_defaults(handler=_export)
71
+ return p
72
+
73
+
74
+ def _add_db(p: argparse.ArgumentParser, required: bool) -> None:
75
+ p.add_argument("--db", required=required, metavar="URL",
76
+ help="database URL: sqlite:///audit.db, postgresql://user@host/db, mysql://..., mssql://...")
77
+ p.add_argument("--table", default=DEFAULT_TABLE, help=f"ledger table (default: {DEFAULT_TABLE})")
78
+ p.add_argument("--schema", help="schema of the ledger table")
79
+
80
+
81
+ def _add_source(p: argparse.ArgumentParser) -> None:
82
+ _add_db(p, required=False)
83
+ p.add_argument("--file", metavar="PATH", help="JSON Lines export produced by 'provance export'")
84
+
85
+
86
+ def _open_source(args: argparse.Namespace):
87
+ file = getattr(args, "file", None)
88
+ if bool(args.db) == bool(file):
89
+ raise UsageError("give exactly one of --db or --file")
90
+ if file:
91
+ return JsonLinesReader(file), None
92
+ connection, dialect = connect(args.db)
93
+ return SqlLedgerReader(connection, dialect, table=args.table, schema=args.schema), connection
94
+
95
+
96
+ def _secret_from_env(var: str, what: str) -> str:
97
+ value = os.environ.get(var)
98
+ if not value:
99
+ raise UsageError(f"environment variable {var} is not set ({what})")
100
+ return value
101
+
102
+
103
+ def _keys(args: argparse.Namespace) -> dict[str, str]:
104
+ if args.key_file:
105
+ secret = Path(args.key_file).read_text(encoding="utf-8").rstrip("\r\n")
106
+ else:
107
+ secret = _secret_from_env(args.key_env, f"secret of key '{args.key_id}'")
108
+ keys = {args.key_id: secret}
109
+ for spec in args.retired_key:
110
+ key_id, sep, var = spec.partition("=")
111
+ if not sep or not key_id or not var:
112
+ raise UsageError(f"--retired-key expects ID=ENV_VAR, got '{spec}'")
113
+ keys[key_id] = _secret_from_env(var, f"secret of retired key '{key_id}'")
114
+ return keys
115
+
116
+
117
+ def _load_checkpoint(path_text: str | None, ledger_id: str) -> LedgerCheckpoint | None:
118
+ if not path_text:
119
+ return None
120
+ path = Path(path_text)
121
+ if path.is_dir():
122
+ folder = path / ledger_id if (path / ledger_id).is_dir() else path
123
+ files = [f for f in folder.glob("*.json") if f.stem.isdigit()]
124
+ if not files:
125
+ raise UsageError(f"no checkpoint file (NNNN.json) in {folder}")
126
+ path = max(files, key=lambda f: int(f.stem))
127
+ return LedgerCheckpoint.from_json(path.read_text(encoding="utf-8"))
128
+
129
+
130
+ def _verify(args: argparse.Namespace) -> int:
131
+ if args.from_checkpoint and not args.checkpoint:
132
+ raise UsageError("--from-checkpoint needs --checkpoint")
133
+ keys = _keys(args)
134
+ checkpoint = _load_checkpoint(args.checkpoint, args.ledger_id)
135
+ source, connection = _open_source(args)
136
+ try:
137
+ verifier = LedgerVerifier(source, HmacVerifier(keys), args.ledger_id, args.genesis_hash)
138
+ result = verifier.verify(checkpoint=checkpoint, start_from_checkpoint=args.from_checkpoint)
139
+ finally:
140
+ if connection is not None:
141
+ connection.close()
142
+
143
+ if args.json:
144
+ print(json.dumps(result.to_dict(), indent=2))
145
+ else:
146
+ status = "OK" if result.valid else f"FAILED ({result.failure.value})"
147
+ print(f"{status}: {result.message}")
148
+ if checkpoint is not None and result.valid:
149
+ print(f"Checkpoint at sequence {checkpoint.sequence} matched.")
150
+ if result.redacted_payloads:
151
+ print(f"{result.redacted_payloads} payload(s) redacted.")
152
+ if result.valid and result.last_hash:
153
+ print(f"Head: sequence {result.last_sequence}, hash {result.last_hash}")
154
+ return 0 if result.valid else 1
155
+
156
+
157
+ def _export(args: argparse.Namespace) -> int:
158
+ source, connection = _open_source(args)
159
+ try:
160
+ if args.output:
161
+ with open(args.output, "w", encoding="utf-8", newline="\n") as out:
162
+ count = write_json_lines(source.read(args.from_sequence), out)
163
+ print(f"{count} entries written to {args.output}", file=sys.stderr)
164
+ else:
165
+ write_json_lines(source.read(args.from_sequence), sys.stdout)
166
+ finally:
167
+ connection.close()
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())
provance/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Exceptions raised by the ledger, the writer and the stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LedgerError(Exception):
7
+ """Base class of PROVANCE errors."""
8
+
9
+
10
+ class LedgerConcurrencyError(LedgerError):
11
+ """Raised by a store when an entry with the same sequence or id already exists.
12
+
13
+ The writer handles it by reloading the head; applications rarely see it.
14
+ """
15
+
16
+
17
+ class LedgerStoreError(LedgerError):
18
+ """A store operation failed (database unavailable, timeout, ...). The writer retries appends that fail this way."""
19
+
20
+
21
+ class LedgerUnavailableError(LedgerError):
22
+ """The entry could not be written: the ledger is closed, or the store kept failing after all retries.
23
+
24
+ When the cause is a store failure, the entry may or may not have been persisted.
25
+ """
26
+
27
+
28
+ class LedgerOverloadedError(LedgerUnavailableError):
29
+ """The queue is full and ``queue_full_behavior`` is ``FAIL_FAST``. Map it to HTTP 503 or 429."""
30
+
31
+
32
+ class PayloadError(ValueError):
33
+ """The payload cannot be serialized to canonical JSON."""