countersign-cli 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.
countersign/plain.py ADDED
@@ -0,0 +1,111 @@
1
+ # audited on 20260905
2
+ """The receipt in plain words.
3
+
4
+ The terminal summary and the pack tables are written for engineers. The
5
+ person who most needs a receipt is often the one who cannot read a stack
6
+ trace: they asked an agent for a feature and were told it is done. This
7
+ module renders the same facts as short sentences, one idea each, with no
8
+ jargon the reader has to look up. Every sentence is derived from the run's
9
+ recorded facts; nothing is softened, nothing is inferred.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .claims import FAIL, MISSING, PASS, TIMEOUT
15
+ from .engine import FAIL_VERDICT, GateResult
16
+ from .stubscan import RULES
17
+
18
+ # What each finding kind means to someone who did not write the rule. The
19
+ # marker rules carry their own words; the structural kinds are named here.
20
+ KIND_IN_WORDS = {rule.rule_id: rule.plain for rule in RULES}
21
+ KIND_IN_WORDS["empty-body"] = "a function that does nothing"
22
+ KIND_IN_WORDS["unparseable"] = "a Python file that does not even parse"
23
+
24
+ FINDINGS_SHOWN = 5
25
+ CLAIMS_SHOWN = 3
26
+
27
+
28
+ def _count(n: int, singular: str, plural: str | None = None) -> str:
29
+ return f"{n} {singular if n == 1 else (plural or singular + 's')}"
30
+
31
+
32
+ def _last_line(text: str) -> str:
33
+ lines = [line.strip() for line in (text or "").strip().splitlines() if line.strip()]
34
+ return lines[-1][:120] if lines else ""
35
+
36
+
37
+ def plain_sentences(result: GateResult) -> list[str]:
38
+ """Short sentences, each ending with a full stop, telling a non-engineer
39
+ what was checked, what held, and what did not."""
40
+ sentences: list[str] = []
41
+ results = result.claim_results or []
42
+ failed = [c for c in results if c.status == FAIL]
43
+ timed_out = [c for c in results if c.status == TIMEOUT]
44
+ missing = [c for c in results if c.status == MISSING]
45
+ held = [c for c in results if c.status == PASS]
46
+ weakened = result.weakened_claims
47
+
48
+ if result.verdict == FAIL_VERDICT:
49
+ reasons: list[str] = []
50
+ if result.findings:
51
+ reasons.append(f"{_count(len(result.findings), 'place')} in the code look unfinished")
52
+ if failed or timed_out:
53
+ reasons.append(f"{_count(len(failed) + len(timed_out), 'claim')} out of {len(results)} did not hold")
54
+ if missing:
55
+ reasons.append(f"{_count(len(missing), 'required claim')} {'was' if len(missing) == 1 else 'were'} never declared")
56
+ if weakened:
57
+ reasons.append(f"the claims file was weakened compared with {result.claims_base}")
58
+ sentences.append("Not countersigned: " + "; ".join(reasons) + ".")
59
+ else:
60
+ scanned = f"{_count(result.files_scanned, 'file')} {'was' if result.files_scanned == 1 else 'were'} scanned and none carries unfinished work"
61
+ if result.claim_results is None:
62
+ sentences.append(f"Countersigned on the scan alone: {scanned}.")
63
+ elif held:
64
+ shown = "; ".join(f"'{c.statement}'" for c in held[:CLAIMS_SHOWN])
65
+ more = f" and {len(held) - CLAIMS_SHOWN} more" if len(held) > CLAIMS_SHOWN else ""
66
+ sentences.append(f"Countersigned: {scanned}, and {_count(len(held), 'claim')} held: {shown}{more}.")
67
+ else:
68
+ sentences.append(f"Countersigned: {scanned}, and no claim was declared.")
69
+
70
+ for finding in result.findings[:FINDINGS_SHOWN]:
71
+ kind = KIND_IN_WORDS.get(finding.rule_id, finding.why)
72
+ sentences.append(f"{finding.path} line {finding.line}: {kind}.")
73
+ if len(result.findings) > FINDINGS_SHOWN:
74
+ sentences.append(f"{len(result.findings) - FINDINGS_SHOWN} more such places are listed in the findings table.")
75
+
76
+ for claim in failed:
77
+ tail = _last_line(claim.output_excerpt)
78
+ ending = f"; its last line of output was '{tail}'" if tail else "; it printed nothing"
79
+ code = f"exit code {claim.exit_code}" if claim.exit_code is not None else "no exit code"
80
+ sentences.append(f"The claim '{claim.statement}' did not hold: the command '{claim.command}' ended with {code}{ending}.")
81
+ for claim in timed_out:
82
+ sentences.append(f"The claim '{claim.statement}' was stopped after {max(1, round(claim.duration_ms / 1000))} seconds without finishing.")
83
+ for claim in missing:
84
+ sentences.append(f"The claim '{claim.claim_id}' is required by your configuration but was never declared.")
85
+ if result.verdict == FAIL_VERDICT and held:
86
+ sentences.append(f"{_count(len(held), 'other claim')} held.")
87
+
88
+ if result.claim_results is None:
89
+ sentences.append("No claims were checked: no claims file was found, and a skipped check is not a pass.")
90
+
91
+ if weakened:
92
+ parts: list[str] = []
93
+ for change in weakened:
94
+ if change.kind == "removed":
95
+ parts.append(f"'{change.claim_id}' was removed")
96
+ elif "expect" in change.fields:
97
+ parts.append(f"'{change.claim_id}' now expects the opposite outcome")
98
+ elif "needle" in change.fields:
99
+ parts.append(f"'{change.claim_id}' now looks for a different phrase in the output")
100
+ else:
101
+ parts.append(f"'{change.claim_id}' was changed")
102
+ sentences.append(f"The claims file was weakened compared with {result.claims_base}: " + "; ".join(parts) + ".")
103
+
104
+ if result.git_dirty:
105
+ sentences.append(
106
+ f"The files checked include uncommitted changes, so this receipt describes the working tree, "
107
+ f"not commit {result.git_commit[:8]} exactly."
108
+ )
109
+
110
+ sentences.append("No AI judged anything here; every sentence comes from a command's exit code or a text match you can re-run yourself.")
111
+ return sentences
countersign/receipt.py ADDED
@@ -0,0 +1,255 @@
1
+ # audited on 20260903
2
+ """The receipt: what a countersignature actually is.
3
+
4
+ Three renderings of one run:
5
+
6
+ JSON the machine-readable receipt, digest-bound to the register
7
+ Markdown for CI step summaries, pull request comments and status pages
8
+ Terminal for the human who just ran the command
9
+
10
+ The wording rule inherited from the evidence-pack tradition: the receipt
11
+ never says "verified" without saying what was checked, and a skipped check
12
+ is printed as a skip. A receipt that overstates is worse than no receipt.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from pathlib import Path
20
+
21
+ from . import __version__
22
+ from .claims import FAIL, MISSING, PASS, TIMEOUT
23
+ from .engine import FAIL_VERDICT, GateResult
24
+ from .plain import plain_sentences
25
+
26
+ STATUS_MARK = {PASS: "PASS", FAIL: "FAIL", TIMEOUT: "TIMEOUT", MISSING: "MISSING"}
27
+
28
+ DIRTY_SUFFIX = " (uncommitted changes in the working tree)"
29
+
30
+
31
+ def commit_label(result: GateResult) -> str:
32
+ """The commit as it should be read: with the dirty flag attached when the
33
+ scanned files were not exactly that commit."""
34
+ return result.git_commit + (DIRTY_SUFFIX if result.git_dirty else "")
35
+
36
+
37
+ def _code_span(text: str) -> str:
38
+ """A Markdown code span that survives backticks and newlines in ``text``.
39
+
40
+ Evidence lines carry template literals and reStructuredText ``code``;
41
+ commands can be multi-line TOML strings. The fence is one backtick
42
+ longer than the longest run inside, which is the CommonMark rule.
43
+ """
44
+ flat = " ".join(text.split())
45
+ longest_run = max((len(run) for run in re.findall(r"`+", flat)), default=0)
46
+ fence = "`" * (longest_run + 1)
47
+ return f"{fence} {flat} {fence}"
48
+
49
+
50
+ def receipt_json(result: GateResult) -> dict:
51
+ return {
52
+ "run_id": result.run_id,
53
+ "recorded_at": result.recorded_at,
54
+ "countersign_version": __version__,
55
+ "verdict": result.verdict,
56
+ "config": {"path": result.config_path, "sha256": result.config_sha256},
57
+ "claims_file": (
58
+ {"sha256": result.claims_sha256} if result.claims_sha256 else None
59
+ ),
60
+ "git_commit": result.git_commit,
61
+ "git_dirty": result.git_dirty,
62
+ "scan": {
63
+ "files_scanned": result.files_scanned,
64
+ "findings": len(result.findings),
65
+ "exemptions": result.exemptions,
66
+ "tests_excluded": result.tests_excluded,
67
+ },
68
+ "findings": [
69
+ {
70
+ "path": f.path,
71
+ "line": f.line,
72
+ "rule_id": f.rule_id,
73
+ "why": f.why,
74
+ "evidence": f.evidence,
75
+ }
76
+ for f in result.findings
77
+ ],
78
+ "claims": (
79
+ None
80
+ if result.claim_results is None
81
+ else [
82
+ {
83
+ "claim_id": c.claim_id,
84
+ "statement": c.statement,
85
+ "command": c.command,
86
+ "expect": c.expect,
87
+ "status": c.status,
88
+ "exit_code": c.exit_code,
89
+ "duration_ms": c.duration_ms,
90
+ "output_excerpt": c.output_excerpt,
91
+ }
92
+ for c in result.claim_results
93
+ ]
94
+ ),
95
+ "claims_status": result.claims_status,
96
+ "plain": plain_sentences(result),
97
+ "claims_diff": (
98
+ None
99
+ if result.claims_diff is None
100
+ else {
101
+ "base": result.claims_base,
102
+ "base_problem": result.claims_base_problem,
103
+ "weakened": len(result.weakened_claims),
104
+ "changes": [
105
+ {
106
+ "claim_id": c.claim_id,
107
+ "kind": c.kind,
108
+ "fields": list(c.fields),
109
+ "weakened": c.weakened,
110
+ "detail": c.detail,
111
+ }
112
+ for c in result.claims_diff
113
+ ],
114
+ }
115
+ ),
116
+ "register": {"index": result.register_index, "hash": result.register_hash},
117
+ "duration_ms": result.duration_ms,
118
+ "notes": result.notes,
119
+ }
120
+
121
+
122
+ def write_receipt(result: GateResult, path: Path) -> Path:
123
+ path = Path(path)
124
+ path.parent.mkdir(parents=True, exist_ok=True)
125
+ path.write_text(json.dumps(receipt_json(result), indent=2, sort_keys=True), encoding="utf-8")
126
+ return path
127
+
128
+
129
+ def load_receipt(path: Path) -> dict:
130
+ with Path(path).open(encoding="utf-8") as handle:
131
+ return json.load(handle)
132
+
133
+
134
+ def find_receipt(receipts_root: Path, run_id: str) -> Path | None:
135
+ candidate = Path(receipts_root) / f"{run_id}.json"
136
+ return candidate if candidate.exists() else None
137
+
138
+
139
+ def markdown_summary(result: GateResult) -> str:
140
+ lines: list[str] = []
141
+ verdict_word = "COUNTERSIGNED" if result.verdict != FAIL_VERDICT else "NOT COUNTERSIGNED"
142
+ lines.append(f"## Countersign: {verdict_word}")
143
+ lines.append("")
144
+ lines.append(" ".join(plain_sentences(result)))
145
+ lines.append("")
146
+ lines.append("| | |")
147
+ lines.append("|---|---|")
148
+ lines.append(f"| Run | `{result.run_id}` |")
149
+ lines.append(f"| Git commit | `{result.git_commit}`{DIRTY_SUFFIX if result.git_dirty else ''} |")
150
+ lines.append(f"| Files scanned | {result.files_scanned} |")
151
+ lines.append(f"| Marker findings | {len(result.findings)} |")
152
+ lines.append(f"| Line exemptions used | {result.exemptions} |")
153
+ claims_line = "skipped, not passed (see notes)" if result.claim_results is None else f"{len(result.claim_results)} declared"
154
+ lines.append(f"| Claims | {claims_line} |")
155
+ if result.claims_diff is not None:
156
+ lines.append(f"| Claims changed against `{result.claims_base}` | {len(result.claims_diff)}, {len(result.weakened_claims)} weakened |")
157
+ lines.append(f"| Register head | `{result.register_hash[:16]}...` at entry {result.register_index} |")
158
+ lines.append("")
159
+
160
+ if result.findings:
161
+ lines.append("### Findings")
162
+ lines.append("")
163
+ lines.append("| Location | Rule | Evidence |")
164
+ lines.append("|---|---|---|")
165
+ for f in result.findings[:25]:
166
+ evidence = f.evidence.replace("|", "\\|")[:120]
167
+ lines.append(f"| `{f.path}:{f.line}` | {f.rule_id} | {_code_span(evidence)} |")
168
+ if len(result.findings) > 25:
169
+ lines.append(f"| ... | ... | {len(result.findings) - 25} more in the receipt JSON |")
170
+ lines.append("")
171
+
172
+ if result.claim_results is not None:
173
+ lines.append("### Claims")
174
+ lines.append("")
175
+ lines.append("| Status | Claim | Command |")
176
+ lines.append("|---|---|---|")
177
+ for c in result.claim_results:
178
+ command = c.command.replace("|", "\\|")[:100]
179
+ statement = " ".join(c.statement.replace("|", "\\|").split())
180
+ lines.append(f"| {STATUS_MARK.get(c.status, c.status)} | {statement} | {_code_span(command) if command else 'none declared'} |")
181
+ lines.append("")
182
+
183
+ if result.claims_diff:
184
+ lines.append(f"### Claims changed against `{result.claims_base}`")
185
+ lines.append("")
186
+ lines.append("| Claim | Change | Weakened | Detail |")
187
+ lines.append("|---|---|---|---|")
188
+ for change in result.claims_diff:
189
+ detail = " ".join(change.detail.replace("|", "\\|").split())[:200]
190
+ lines.append(f"| `{change.claim_id}` | {change.kind} | {'YES' if change.weakened else 'no'} | {detail} |")
191
+ lines.append("")
192
+
193
+ for note in result.notes:
194
+ lines.append(f"> {note}")
195
+ if result.notes:
196
+ lines.append("")
197
+ return "\n".join(lines)
198
+
199
+
200
+ def terminal_summary(result: GateResult, use_color: bool = True) -> str:
201
+ green, red, yellow, bold, reset = ("", "", "", "", "")
202
+ if use_color:
203
+ green, red, yellow, bold, reset = "\033[32m", "\033[31m", "\033[33m", "\033[1m", "\033[0m"
204
+
205
+ lines: list[str] = []
206
+ lines.append(f"{bold}Countersign{reset} run {result.run_id}")
207
+ lines.append(f" commit {commit_label(result)} · {result.files_scanned} files scanned · {result.duration_ms} ms")
208
+ lines.append("")
209
+
210
+ if result.findings:
211
+ mark = f"{red}✗{reset}"
212
+ lines.append(f"{mark} marker scan: {len(result.findings)} finding(s)")
213
+ for f in result.findings[:15]:
214
+ lines.append(f" {f.path}:{f.line} [{f.rule_id}] {f.evidence[:100]}")
215
+ if len(result.findings) > 15:
216
+ lines.append(f" ... {len(result.findings) - 15} more")
217
+ else:
218
+ lines.append(f"{green}✓{reset} marker scan: clean ({result.files_scanned} files)")
219
+
220
+ if result.claim_results is None:
221
+ lines.append(f"{yellow}–{reset} claims: skipped, not passed (see note below)")
222
+ else:
223
+ for c in result.claim_results:
224
+ if c.status == PASS:
225
+ lines.append(f"{green}✓{reset} claim {c.claim_id}: {c.statement}")
226
+ elif c.status == TIMEOUT:
227
+ lines.append(f"{red}✗{reset} claim {c.claim_id}: TIMED OUT after {c.duration_ms} ms: {c.statement}")
228
+ elif c.status == MISSING:
229
+ lines.append(f"{red}✗{reset} claim {c.claim_id}: MISSING, {c.statement}")
230
+ else:
231
+ excerpt = (c.output_excerpt or "").strip().splitlines()
232
+ tail = excerpt[-1][:120] if excerpt else "no output"
233
+ lines.append(f"{red}✗{reset} claim {c.claim_id}: {c.statement}")
234
+ lines.append(f" command: {c.command}")
235
+ lines.append(f" output ends: {tail}")
236
+
237
+ if result.claims_diff is not None:
238
+ weakened = result.weakened_claims
239
+ mark = f"{red}✗{reset}" if weakened else f"{green}✓{reset}"
240
+ lines.append(f"{mark} claims against {result.claims_base}: {len(result.claims_diff)} change(s), {len(weakened)} weakened")
241
+ for change in result.claims_diff:
242
+ flag = "WEAKENED " if change.weakened else ""
243
+ lines.append(f" {flag}{change.kind} {change.claim_id}: {change.detail[:140]}")
244
+
245
+ for note in result.notes:
246
+ lines.append(f"{yellow}–{reset} note: {note}")
247
+
248
+ lines.append("")
249
+ if result.verdict == FAIL_VERDICT:
250
+ weakened_part = f", {len(result.weakened_claims)} weakened claim(s)" if result.weakened_claims else ""
251
+ lines.append(f"{red}{bold}NOT COUNTERSIGNED{reset} · {len(result.findings)} finding(s), {len(result.failed_claims)} failed claim(s){weakened_part}")
252
+ lines.append("The work did not pass its own declared checks. Fix the code or the claims.")
253
+ else:
254
+ lines.append(f"{green}{bold}COUNTERSIGNED{reset} · register entry {result.register_index}, head {result.register_hash[:16]}...")
255
+ return "\n".join(lines)
@@ -0,0 +1,205 @@
1
+ # audited on 20260903
2
+ """The evidence register: append only, hash chained, plain files.
3
+
4
+ Every check Countersign runs, every finding it produces and every claim
5
+ verdict is written here as one line of JSON. Each line carries the hash of
6
+ the line before it, so any later edit to any earlier line breaks the chain
7
+ and ``verify_chain`` says so. This is what makes a receipt tamper evident:
8
+ not a claim on a website, but arithmetic anyone can redo.
9
+
10
+ Adapted from Gaigentic Verify's register (2026), which ran this exact design
11
+ through a file-by-file production review.
12
+
13
+ Deliberately a file, not a database. It has to run inside any repository on
14
+ any machine on day one, and a file is something an auditor can copy, diff
15
+ and keep.
16
+
17
+ Appends take an exclusive lock on a sibling ``.lock`` file for the read-head,
18
+ write-entry pair. Without it, two runs started at the same moment (two CI
19
+ jobs on one checkout, a human and a hook) could both chain onto the same
20
+ head and leave a register that is broken forever, indistinguishable from
21
+ tampering.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import hashlib
28
+ import json
29
+ import os
30
+ from dataclasses import dataclass
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Any, Iterator
34
+
35
+ try:
36
+ import fcntl
37
+ except ImportError: # Windows has no fcntl; msvcrt provides byte-range locks
38
+ fcntl = None
39
+ import msvcrt
40
+
41
+ GENESIS = "0" * 64
42
+
43
+
44
+ class RegisterDamaged(ValueError):
45
+ """The file cannot be extended without breaking the chain it carries."""
46
+
47
+
48
+ def _canonical(payload: dict[str, Any]) -> str:
49
+ """Stable JSON so the same entry always hashes to the same value."""
50
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
51
+
52
+
53
+ def entry_hash(previous_hash: str, body: dict[str, Any]) -> str:
54
+ return hashlib.sha256((previous_hash + _canonical(body)).encode("utf-8")).hexdigest()
55
+
56
+
57
+ @contextlib.contextmanager
58
+ def _exclusive(lock_path: Path) -> Iterator[None]:
59
+ """Hold an exclusive advisory lock on ``lock_path`` for the block."""
60
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
61
+ with lock_path.open("a+b") as handle:
62
+ if fcntl is not None:
63
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
64
+ try:
65
+ yield
66
+ finally:
67
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
68
+ else:
69
+ handle.seek(0)
70
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
71
+ try:
72
+ yield
73
+ finally:
74
+ handle.seek(0)
75
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
76
+
77
+
78
+ @dataclass
79
+ class Register:
80
+ """Append-only log of everything a verification run did."""
81
+
82
+ path: Path
83
+
84
+ def __post_init__(self) -> None:
85
+ self.path = Path(self.path)
86
+
87
+ @property
88
+ def lock_path(self) -> Path:
89
+ return self.path.parent / (self.path.name + ".lock")
90
+
91
+ # ---- writing ----
92
+
93
+ def append(self, kind: str, body: dict[str, Any], *, at: datetime | None = None) -> dict[str, Any]:
94
+ """Add one entry and return it, including its position and hash.
95
+
96
+ The entry is on the disk before this returns. Evidence that a caller
97
+ has been told was written, and that a power cut then removes, would
98
+ be worse than evidence never written at all: the register would be
99
+ short an entry and nobody would know which.
100
+ """
101
+ with _exclusive(self.lock_path):
102
+ previous = self.head()
103
+ previous_hash = previous["hash"] if previous else GENESIS
104
+ index = (previous["index"] + 1) if previous else 0
105
+ recorded_at = (at or datetime.now(timezone.utc)).isoformat()
106
+
107
+ core = {"index": index, "kind": kind, "recorded_at": recorded_at, "body": body}
108
+ entry = {**core, "previous_hash": previous_hash, "hash": entry_hash(previous_hash, core)}
109
+
110
+ with self.path.open("a", encoding="utf-8") as handle:
111
+ handle.write(json.dumps(entry, sort_keys=True, default=str) + "\n")
112
+ handle.flush()
113
+ os.fsync(handle.fileno())
114
+ return entry
115
+
116
+ # ---- reading ----
117
+
118
+ def entries(self) -> Iterator[dict[str, Any]]:
119
+ """Every entry, in order, without verifying the chain. Use
120
+ ``verify_chain`` first when the file may have been touched."""
121
+ if not self.path.exists():
122
+ return
123
+ with self.path.open(encoding="utf-8", errors="replace") as handle:
124
+ for line in handle:
125
+ line = line.strip()
126
+ if line:
127
+ yield json.loads(line)
128
+
129
+ def _last_line(self) -> str | None:
130
+ """The final line, read by seeking rather than by reading everything.
131
+
132
+ Appending needs only the entry before it. Reading the whole file to
133
+ find that entry makes each append cost more than the last, so a
134
+ register that runs for years gets slower every month it is used.
135
+ """
136
+ if not self.path.exists():
137
+ return None
138
+ with self.path.open("rb") as handle:
139
+ handle.seek(0, os.SEEK_END)
140
+ position = handle.tell()
141
+ collected = b""
142
+ while position > 0:
143
+ step = min(4096, position)
144
+ position -= step
145
+ handle.seek(position)
146
+ collected = handle.read(step) + collected
147
+ stripped = collected.rstrip(b"\n")
148
+ if b"\n" in stripped:
149
+ return stripped.rsplit(b"\n", 1)[1].decode("utf-8", errors="replace")
150
+ final = collected.strip()
151
+ return final.decode("utf-8", errors="replace") if final else None
152
+
153
+ def head(self) -> dict[str, Any] | None:
154
+ line = self._last_line()
155
+ if line is None:
156
+ return None
157
+ try:
158
+ entry = json.loads(line)
159
+ if not isinstance(entry, dict) or not isinstance(entry.get("index"), int) or not isinstance(entry.get("hash"), str):
160
+ raise ValueError("the line is not a register entry")
161
+ except ValueError as exc: # json.JSONDecodeError is a ValueError
162
+ raise RegisterDamaged(
163
+ f"the last line of {self.path.name} cannot be read as an entry, so nothing can "
164
+ "be added after it without breaking the chain. Keep the file and investigate "
165
+ f"what wrote it: {exc}"
166
+ ) from None
167
+ return entry
168
+
169
+ def verify_chain(self) -> tuple[bool, str]:
170
+ """Recompute every hash. Returns (intact, human readable reason).
171
+
172
+ A line that cannot even be parsed is itself a broken chain, not a
173
+ crash: the likeliest way a register gets corrupted is someone opening
174
+ the file in an editor, and the verdict has to survive whatever they
175
+ saved, including bytes that are not text.
176
+ """
177
+ previous_hash = GENESIS
178
+ expected_index = 0
179
+ if not self.path.exists():
180
+ return True, "0 entries, chain intact"
181
+ with self.path.open(encoding="utf-8", errors="replace") as handle:
182
+ for line_number, line in enumerate(handle, start=1):
183
+ line = line.strip()
184
+ if not line:
185
+ continue
186
+ try:
187
+ entry = json.loads(line)
188
+ index = entry["index"]
189
+ core = {k: entry[k] for k in ("index", "kind", "recorded_at", "body")}
190
+ recorded_previous = entry["previous_hash"]
191
+ recorded_hash = entry["hash"]
192
+ except (json.JSONDecodeError, KeyError, TypeError):
193
+ return False, (
194
+ f"line {line_number} cannot be read as an entry; the register has been "
195
+ "altered since it was written"
196
+ )
197
+ if index != expected_index:
198
+ return False, f"entry {index} is out of order, expected {expected_index}"
199
+ if recorded_previous != previous_hash:
200
+ return False, f"entry {index} does not follow the entry before it"
201
+ if entry_hash(previous_hash, core) != recorded_hash:
202
+ return False, f"entry {index} has been altered since it was written"
203
+ previous_hash = recorded_hash
204
+ expected_index += 1
205
+ return True, f"{expected_index} entries, chain intact"