aics-cli 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.
- aics_cli/__init__.py +8 -0
- aics_cli/__main__.py +3 -0
- aics_cli/bundle_build.py +216 -0
- aics_cli/card.py +933 -0
- aics_cli/checkpoints.py +498 -0
- aics_cli/cli.py +298 -0
- aics_cli/commands.py +1656 -0
- aics_cli/consistency.py +280 -0
- aics_cli/dnstxt.py +319 -0
- aics_cli/encoding.py +125 -0
- aics_cli/errors.py +57 -0
- aics_cli/evidence.py +88 -0
- aics_cli/home.py +112 -0
- aics_cli/keystore.py +189 -0
- aics_cli/message.py +875 -0
- aics_cli/net.py +117 -0
- aics_cli/policy.py +618 -0
- aics_cli/published_keys.py +34 -0
- aics_cli/tiles_http.py +59 -0
- aics_cli/trust.py +243 -0
- aics_cli-0.1.0.dist-info/METADATA +420 -0
- aics_cli-0.1.0.dist-info/RECORD +25 -0
- aics_cli-0.1.0.dist-info/WHEEL +4 -0
- aics_cli-0.1.0.dist-info/entry_points.txt +3 -0
- aics_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
aics_cli/checkpoints.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Checkpoints: fetch, verify against the pinned policy, store append-only.
|
|
2
|
+
|
|
3
|
+
Verification of a checkpoint note (c2sp.org/tlog-checkpoint):
|
|
4
|
+
|
|
5
|
+
1. the log signature, Ed25519 over the note text, from the pinned log key,
|
|
6
|
+
and the pinned origin (``aics_tlog.verify_checkpoint``, which also rejects
|
|
7
|
+
duplicate signature lines). A note that passes this is *log-signed*: it
|
|
8
|
+
is what the log said, whatever else is wrong with it;
|
|
9
|
+
2. the AICS-1 profile (§6.2): exactly three text lines (no extension
|
|
10
|
+
lines), a size within 2^53-1, and the empty-tree root for size 0
|
|
11
|
+
(§14.2 step 1);
|
|
12
|
+
3. witness cosignatures (c2sp.org/tlog-cosignature, AICS-1 §6.3): a line
|
|
13
|
+
under a pinned witness's (name, key ID) that does not verify rejects the
|
|
14
|
+
note (``bad_cosignature``); each valid cosignature counts toward the
|
|
15
|
+
quorum only if ``now - max_checkpoint_age <= timestamp <= now + 30``; one
|
|
16
|
+
public key counts once. With quorum 0 the head's freshness is reported
|
|
17
|
+
as unproven: a log-only note carries no authenticated time.
|
|
18
|
+
|
|
19
|
+
:func:`check_note` keeps these apart: ``cp`` is set for every log-signed
|
|
20
|
+
note, ``ok`` only for one that also meets the profile and the witness
|
|
21
|
+
policy. Callers that accept a head test ``ok``; callers that reason about
|
|
22
|
+
history (the saved head, saved notes, gossip evidence) need only ``cp`` -
|
|
23
|
+
a junk line under some witness key must not make the log's own signature
|
|
24
|
+
disappear.
|
|
25
|
+
|
|
26
|
+
Saved notes live in ``~/.aics/checkpoints/<origin>/<size>-<sha256>.note``;
|
|
27
|
+
a file is created once and never rewritten. Every save appends a line to
|
|
28
|
+
``index.jsonl`` with a role: ``head`` (the local trusted head: the first
|
|
29
|
+
checkpoint, or one proven consistent with the previous head - AICS-1
|
|
30
|
+
§14.1 step 4), ``evidence`` (verified but not adopted, e.g. a fork) or
|
|
31
|
+
``observed`` (verified by status checks, or an older head kept as
|
|
32
|
+
historical), and the log key it was verified with. A ``reset`` line
|
|
33
|
+
(``--reset-history``) drops the files it lists from continuity checks; the
|
|
34
|
+
files themselves are kept.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import time
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from aics_tlog import EMPTY_HASH, Note, parse_checkpoint, parse_note, verify_checkpoint, verify_cosignature
|
|
47
|
+
|
|
48
|
+
from . import home as _home
|
|
49
|
+
from .encoding import b64e
|
|
50
|
+
from .errors import AicsError, FetchError
|
|
51
|
+
from .net import Fetcher
|
|
52
|
+
from .policy import Policy, parse_log_vkey, policy_digest
|
|
53
|
+
|
|
54
|
+
DEFAULT_MAX_SKEW = 30 # AICS-1 §6.3: timestamp <= now + 30
|
|
55
|
+
UINT_MAX = 9007199254740991
|
|
56
|
+
NOTE_MAX_BYTES = 1024 * 1024
|
|
57
|
+
JSON_MAX_DEPTH = 32
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Cp:
|
|
62
|
+
"""A checkpoint whose log signature verified against a pinned log key."""
|
|
63
|
+
|
|
64
|
+
note_text: str
|
|
65
|
+
origin: str
|
|
66
|
+
size: int
|
|
67
|
+
root: bytes
|
|
68
|
+
source: str
|
|
69
|
+
log_vkey: str | None = None # the pinned log key the signature verified with
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def sha256(self) -> str:
|
|
73
|
+
return hashlib.sha256(self.note_text.encode("utf-8")).hexdigest()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def root_b64(self) -> str:
|
|
77
|
+
return b64e(self.root)
|
|
78
|
+
|
|
79
|
+
def brief(self) -> dict[str, Any]:
|
|
80
|
+
return {"origin": self.origin, "size": self.size, "root": self.root_b64,
|
|
81
|
+
"sha256": self.sha256, "source": self.source}
|
|
82
|
+
|
|
83
|
+
def evidence(self) -> dict[str, Any]:
|
|
84
|
+
d = self.brief()
|
|
85
|
+
d["note"] = self.note_text
|
|
86
|
+
if self.log_vkey:
|
|
87
|
+
d["log_vkey"] = self.log_vkey
|
|
88
|
+
return d
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class NoteCheck:
|
|
93
|
+
ok: bool
|
|
94
|
+
cp: Cp | None = None # set for every log-signed note, even one that is otherwise invalid
|
|
95
|
+
error: str | None = None
|
|
96
|
+
# malformed | bad_signature | wrong_origin (not log-signed);
|
|
97
|
+
# bad_empty_root | bad_cosignature | quorum (log-signed, rejected)
|
|
98
|
+
reason: str | None = None
|
|
99
|
+
log_ok: bool = False
|
|
100
|
+
witness_ok: bool = False # no invalid cosignature line and the quorum is met
|
|
101
|
+
witnesses: list[dict[str, Any]] = field(default_factory=list)
|
|
102
|
+
counted: int = 0
|
|
103
|
+
quorum: int = 0
|
|
104
|
+
max_age: int | None = None
|
|
105
|
+
freshness: str = "unproven" # fresh | unproven | historical
|
|
106
|
+
declared_trust_domains: list[str] = field(default_factory=list)
|
|
107
|
+
declared_independent: int = 0
|
|
108
|
+
assessed_independent: list[str] = field(default_factory=list)
|
|
109
|
+
warnings: list[str] = field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
def report(self) -> dict[str, Any]:
|
|
112
|
+
d: dict[str, Any] = {
|
|
113
|
+
"log_signature": "ok" if self.log_ok else "failed",
|
|
114
|
+
"witness_policy_met": self.witness_ok,
|
|
115
|
+
"witnesses": self.witnesses,
|
|
116
|
+
"fresh_cosignatures": self.counted,
|
|
117
|
+
"quorum": self.quorum,
|
|
118
|
+
"max_checkpoint_age": self.max_age,
|
|
119
|
+
"freshness": self.freshness,
|
|
120
|
+
# what the operator's card says about the counted witnesses: claims, not facts (AICS-1 §6.3)
|
|
121
|
+
"declared_trust_domains": self.declared_trust_domains,
|
|
122
|
+
"declared_independent_witnesses": self.declared_independent,
|
|
123
|
+
# witnesses the user marked independent (policy init --independent)
|
|
124
|
+
"assessed_independent_witnesses": self.assessed_independent,
|
|
125
|
+
}
|
|
126
|
+
if self.reason:
|
|
127
|
+
d["reason"] = self.reason
|
|
128
|
+
if self.cp:
|
|
129
|
+
d.update(origin=self.cp.origin, size=self.cp.size, root=self.cp.root_b64, note_sha256=self.cp.sha256)
|
|
130
|
+
return d
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse(note_text: str) -> tuple[Note | None, Any, NoteCheck | None]:
|
|
134
|
+
try:
|
|
135
|
+
note: Note = parse_note(note_text)
|
|
136
|
+
cp = parse_checkpoint(note.text)
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
return None, None, NoteCheck(ok=False, error=f"not a signed checkpoint note: {exc}", reason="malformed")
|
|
139
|
+
if cp.extension_lines:
|
|
140
|
+
return None, None, NoteCheck(
|
|
141
|
+
ok=False, reason="malformed",
|
|
142
|
+
error="an AICS checkpoint has exactly three text lines (AICS-1 §6.2); this one has extensions")
|
|
143
|
+
if cp.size > UINT_MAX:
|
|
144
|
+
return None, None, NoteCheck(ok=False, reason="malformed", error="tree size exceeds 2^53-1")
|
|
145
|
+
return note, cp, None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def log_signed(note_text: str, *, origin: str, log_vkey: str, source: str) -> tuple[Cp | None, str | None, str]:
|
|
149
|
+
"""Authenticate a note by the log signature alone, under ``log_vkey``
|
|
150
|
+
and ``origin``. Returns (cp, error, reason)."""
|
|
151
|
+
note, cp, bad = _parse(note_text)
|
|
152
|
+
if bad is not None:
|
|
153
|
+
return None, bad.error, "malformed"
|
|
154
|
+
res = verify_checkpoint(note, parse_log_vkey(log_vkey), [], 0, expected_origin=origin)
|
|
155
|
+
if not res.log_ok:
|
|
156
|
+
reason = "wrong_origin" if res.origin is not None and res.origin != origin else "bad_signature"
|
|
157
|
+
return None, res.error or "checkpoint verification failed", reason
|
|
158
|
+
return Cp(note_text=note_text, origin=cp.origin, size=cp.size, root=cp.root, source=source,
|
|
159
|
+
log_vkey=log_vkey), None, ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check_note(
|
|
163
|
+
note_text: str,
|
|
164
|
+
policy: Policy,
|
|
165
|
+
*,
|
|
166
|
+
source: str,
|
|
167
|
+
now: int,
|
|
168
|
+
require_fresh: bool = True,
|
|
169
|
+
max_skew: int = DEFAULT_MAX_SKEW,
|
|
170
|
+
) -> NoteCheck:
|
|
171
|
+
"""Verify one signed checkpoint note against ``policy``.
|
|
172
|
+
|
|
173
|
+
``require_fresh=False`` counts every valid cosignature regardless of its
|
|
174
|
+
age (for notes judged as historical statements, e.g. gossip evidence).
|
|
175
|
+
"""
|
|
176
|
+
note, cp, bad = _parse(note_text)
|
|
177
|
+
if bad is not None:
|
|
178
|
+
return bad
|
|
179
|
+
chk = NoteCheck(ok=False, quorum=policy.quorum, max_age=policy.max_checkpoint_age if require_fresh else None)
|
|
180
|
+
res = verify_checkpoint(note, policy.log_key(), [], 0, expected_origin=policy.origin)
|
|
181
|
+
if not res.log_ok:
|
|
182
|
+
chk.error = res.error or "checkpoint verification failed"
|
|
183
|
+
chk.reason = "wrong_origin" if res.origin is not None and res.origin != policy.origin else "bad_signature"
|
|
184
|
+
return chk
|
|
185
|
+
chk.log_ok = True
|
|
186
|
+
chk.cp = Cp(note_text=note_text, origin=cp.origin, size=cp.size, root=cp.root, source=source,
|
|
187
|
+
log_vkey=policy.log_vkey)
|
|
188
|
+
|
|
189
|
+
# Witness accounting: every line under a pinned witness's (name, key ID)
|
|
190
|
+
# must be a valid cosignature of this checkpoint; then age, and each
|
|
191
|
+
# public key counts once.
|
|
192
|
+
invalid: list[str] = []
|
|
193
|
+
counted_keys: set[bytes] = set()
|
|
194
|
+
for wk in policy.witness_keys():
|
|
195
|
+
lines = [s for s in note.signatures if s.name == wk.name and s.key_id == wk.key_id]
|
|
196
|
+
if not lines:
|
|
197
|
+
chk.witnesses.append({"name": wk.name, "status": "absent"})
|
|
198
|
+
continue
|
|
199
|
+
blob = lines[0].signature
|
|
200
|
+
try:
|
|
201
|
+
valid = verify_cosignature(blob, wk.name, wk.public_key, cp.origin, cp.size, cp.root, cp.extension_lines)
|
|
202
|
+
except (ValueError, TypeError):
|
|
203
|
+
valid = False
|
|
204
|
+
if not valid:
|
|
205
|
+
chk.witnesses.append({"name": wk.name, "status": "invalid"})
|
|
206
|
+
invalid.append(wk.name)
|
|
207
|
+
continue
|
|
208
|
+
ts = int.from_bytes(blob[:8], "big")
|
|
209
|
+
entry: dict[str, Any] = {"name": wk.name, "timestamp": ts, "age_seconds": now - ts}
|
|
210
|
+
if require_fresh and ts > now + max_skew:
|
|
211
|
+
entry["status"] = "future"
|
|
212
|
+
elif require_fresh and policy.max_checkpoint_age is not None and now - ts > policy.max_checkpoint_age:
|
|
213
|
+
entry["status"] = "stale"
|
|
214
|
+
elif wk.public_key in counted_keys:
|
|
215
|
+
entry["status"] = "duplicate-key"
|
|
216
|
+
else:
|
|
217
|
+
entry["status"] = "fresh" if require_fresh else "valid"
|
|
218
|
+
counted_keys.add(wk.public_key)
|
|
219
|
+
domain, independent = policy.declared(wk.name)
|
|
220
|
+
if domain:
|
|
221
|
+
chk.declared_trust_domains.append(domain)
|
|
222
|
+
if independent is True:
|
|
223
|
+
chk.declared_independent += 1
|
|
224
|
+
if policy.assessed_independent(wk.name):
|
|
225
|
+
chk.assessed_independent.append(wk.name)
|
|
226
|
+
chk.witnesses.append(entry)
|
|
227
|
+
chk.declared_trust_domains = sorted(set(chk.declared_trust_domains))
|
|
228
|
+
chk.counted = len(counted_keys)
|
|
229
|
+
chk.witness_ok = not invalid and chk.counted >= policy.quorum
|
|
230
|
+
|
|
231
|
+
# The verdict, most serious first.
|
|
232
|
+
if cp.size == 0 and cp.root != EMPTY_HASH:
|
|
233
|
+
chk.reason = "bad_empty_root"
|
|
234
|
+
chk.error = ("the log signed a checkpoint of size 0 whose root is not SHA-256(\"\"), which no log can "
|
|
235
|
+
"produce (AICS-1 §14.2 step 1)")
|
|
236
|
+
return chk
|
|
237
|
+
if invalid:
|
|
238
|
+
chk.reason = "bad_cosignature"
|
|
239
|
+
chk.error = (f"the log signature is valid, but the line under pinned witness {', '.join(invalid)} is not a "
|
|
240
|
+
"valid cosignature of this checkpoint")
|
|
241
|
+
return chk
|
|
242
|
+
if chk.counted < policy.quorum:
|
|
243
|
+
late = [w for w in chk.witnesses if w["status"] in ("stale", "future")]
|
|
244
|
+
detail = "; ".join(f"{w['name']} {w['status']} (age {w['age_seconds']}s)" for w in late)
|
|
245
|
+
what = "fresh " if require_fresh else ""
|
|
246
|
+
chk.error = f"witness quorum not met: {chk.counted} valid {what}cosignatures < quorum {policy.quorum}"
|
|
247
|
+
if detail:
|
|
248
|
+
chk.error += f": {detail}"
|
|
249
|
+
chk.reason = "quorum"
|
|
250
|
+
return chk
|
|
251
|
+
if not require_fresh:
|
|
252
|
+
chk.freshness = "historical"
|
|
253
|
+
elif policy.quorum == 0:
|
|
254
|
+
chk.freshness = "unproven"
|
|
255
|
+
chk.warnings.append(
|
|
256
|
+
"witness quorum 0: the head carries no authenticated time, so its freshness is unproven, "
|
|
257
|
+
"and single view (G5) is promised, not proven"
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
chk.freshness = "fresh"
|
|
261
|
+
if chk.assessed_independent:
|
|
262
|
+
chk.warnings.append(
|
|
263
|
+
f"single view (G5) rests on your own assessment that {', '.join(chk.assessed_independent)} "
|
|
264
|
+
"is independent and on the honest quorum-intersection assumption (AICS-1 §6.3)"
|
|
265
|
+
)
|
|
266
|
+
elif chk.declared_independent:
|
|
267
|
+
chk.warnings.append(
|
|
268
|
+
f"single view (G5) stays promised: the card declares {chk.declared_independent} counted witness(es) "
|
|
269
|
+
"independent, but independence is the operator's own claim and needs outside assessment "
|
|
270
|
+
"(AICS-1 §6.3); mark a witness you assessed yourself with: aics policy init --independent NAME"
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
chk.warnings.append(
|
|
274
|
+
"no counted witness is independent: single view (G5) stays promised (AICS-1 §6.3)"
|
|
275
|
+
)
|
|
276
|
+
chk.ok = True
|
|
277
|
+
return chk
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# fetching
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass
|
|
286
|
+
class Fetched:
|
|
287
|
+
note_text: str
|
|
288
|
+
url: str
|
|
289
|
+
api_claims: dict[str, Any] = field(default_factory=dict)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
_NOTE_FIELDS = ("signed_checkpoint", "checkpoint", "note", "signed_note")
|
|
293
|
+
_KEY_FIELDS = ("server_vkey", "log_vkey", "vkey")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def extract_note(body: bytes, url: str) -> Fetched:
|
|
297
|
+
"""The response is the signed note itself (c2sp.org/tlog-tiles
|
|
298
|
+
``/checkpoint``), or JSON carrying it in ``signed_checkpoint`` (the
|
|
299
|
+
current server's ``/v2/tlog/checkpoint``). Keys found in the JSON are
|
|
300
|
+
recorded only to be compared, never used."""
|
|
301
|
+
from .card import check_json_depth
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
text = body.decode("utf-8")
|
|
305
|
+
except UnicodeDecodeError:
|
|
306
|
+
raise AicsError(f"{url}: checkpoint response is not UTF-8") from None
|
|
307
|
+
stripped = text.lstrip()
|
|
308
|
+
if stripped.startswith("{"):
|
|
309
|
+
try:
|
|
310
|
+
check_json_depth(text, JSON_MAX_DEPTH)
|
|
311
|
+
obj = json.loads(text)
|
|
312
|
+
except RecursionError:
|
|
313
|
+
raise AicsError(f"{url}: invalid JSON: nested too deeply") from None
|
|
314
|
+
except ValueError as exc:
|
|
315
|
+
raise AicsError(f"{url}: invalid JSON: {exc}") from None
|
|
316
|
+
if not isinstance(obj, dict):
|
|
317
|
+
raise AicsError(f"{url}: unexpected JSON")
|
|
318
|
+
for k in _NOTE_FIELDS:
|
|
319
|
+
v = obj.get(k)
|
|
320
|
+
if isinstance(v, str) and "\n\n" in v:
|
|
321
|
+
claims = {kk: obj[kk] for kk in _KEY_FIELDS if isinstance(obj.get(kk), str)}
|
|
322
|
+
return Fetched(note_text=v, url=url, api_claims=claims)
|
|
323
|
+
raise AicsError(f"{url}: JSON response has no signed checkpoint note")
|
|
324
|
+
return Fetched(note_text=text, url=url)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def fetch_checkpoint(fetcher: Fetcher, url: str) -> Fetched:
|
|
328
|
+
resp = fetcher.get(url, max_bytes=NOTE_MAX_BYTES, accept="text/plain, application/json;q=0.5")
|
|
329
|
+
if resp.status != 200:
|
|
330
|
+
raise FetchError(f"{url}: HTTP {resp.status}")
|
|
331
|
+
return extract_note(resp.body, url)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def api_key_warnings(fetched: Fetched, policy: Policy) -> list[str]:
|
|
335
|
+
from .encoding import escape_untrusted
|
|
336
|
+
|
|
337
|
+
out: list[str] = []
|
|
338
|
+
for k, v in fetched.api_claims.items():
|
|
339
|
+
if v.strip() != policy.log_vkey:
|
|
340
|
+
out.append(
|
|
341
|
+
f"the API response carries {k}={escape_untrusted(v.strip())!r}, which differs from the pinned log "
|
|
342
|
+
"key; it was ignored (keys are never taken from the API host)"
|
|
343
|
+
)
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# append-only store
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class Saved:
|
|
354
|
+
path: Path
|
|
355
|
+
created: bool
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass
|
|
359
|
+
class SavedNote:
|
|
360
|
+
"""A saved note file and the index records that mention it."""
|
|
361
|
+
|
|
362
|
+
path: Path
|
|
363
|
+
text: str
|
|
364
|
+
file: str # path relative to the store root, as the index records it
|
|
365
|
+
records: list[dict[str, Any]] = field(default_factory=list)
|
|
366
|
+
|
|
367
|
+
def recorded_log_vkeys(self) -> list[str]:
|
|
368
|
+
"""Log keys this note was verified with when it was saved, newest first."""
|
|
369
|
+
out: list[str] = []
|
|
370
|
+
for rec in reversed(self.records):
|
|
371
|
+
v = rec.get("log_vkey")
|
|
372
|
+
if isinstance(v, str) and v not in out:
|
|
373
|
+
out.append(v)
|
|
374
|
+
return out
|
|
375
|
+
|
|
376
|
+
def policy_digests(self) -> list[str]:
|
|
377
|
+
out: list[str] = []
|
|
378
|
+
for rec in reversed(self.records):
|
|
379
|
+
v = rec.get("policy_sha256")
|
|
380
|
+
if isinstance(v, str) and v not in out:
|
|
381
|
+
out.append(v)
|
|
382
|
+
return out
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class CheckpointStore:
|
|
386
|
+
def __init__(self, root: Path) -> None:
|
|
387
|
+
self.root = root
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def index(self) -> Path:
|
|
391
|
+
return self.root / "index.jsonl"
|
|
392
|
+
|
|
393
|
+
def records(self) -> list[dict[str, Any]]:
|
|
394
|
+
return _home.read_jsonl(self.index)
|
|
395
|
+
|
|
396
|
+
def save(self, cp: Cp, *, now: int, role: str = "head", continuity: str | None = None,
|
|
397
|
+
policy: Policy | None = None) -> Saved:
|
|
398
|
+
"""Save the exact note bytes (never rewritten) and log the observation
|
|
399
|
+
with the digest of the policy it was verified under and the log key
|
|
400
|
+
(AICS-1 §14.1 step 5)."""
|
|
401
|
+
_home.ensure_dir(self.root)
|
|
402
|
+
path = self.root / _home.slug(cp.origin) / f"{cp.size}-{cp.sha256}.note"
|
|
403
|
+
created = _home.write_new(path, cp.note_text.encode("utf-8"))
|
|
404
|
+
_home.append_jsonl(self.index, {
|
|
405
|
+
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
|
406
|
+
"role": role,
|
|
407
|
+
"continuity": continuity,
|
|
408
|
+
"policy_sha256": policy_digest(policy) if policy is not None else None,
|
|
409
|
+
"log_vkey": cp.log_vkey or (policy.log_vkey if policy is not None else None),
|
|
410
|
+
"source": cp.source,
|
|
411
|
+
"origin": cp.origin,
|
|
412
|
+
"size": cp.size,
|
|
413
|
+
"root": cp.root_b64,
|
|
414
|
+
"sha256": cp.sha256,
|
|
415
|
+
"file": str(path.relative_to(self.root)),
|
|
416
|
+
"new": created,
|
|
417
|
+
})
|
|
418
|
+
return Saved(path=path, created=created)
|
|
419
|
+
|
|
420
|
+
def reset(self, origin: str, *, now: int, reason: str, files: list[str], policy: Policy | None = None) -> None:
|
|
421
|
+
"""Record that ``files`` (relative paths) no longer take part in
|
|
422
|
+
continuity checks for ``origin``. Nothing is deleted."""
|
|
423
|
+
_home.ensure_dir(self.root)
|
|
424
|
+
_home.append_jsonl(self.index, {
|
|
425
|
+
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
|
426
|
+
"role": "reset",
|
|
427
|
+
"origin": origin,
|
|
428
|
+
"reason": reason,
|
|
429
|
+
"dropped": sorted(set(files)),
|
|
430
|
+
"policy_sha256": policy_digest(policy) if policy is not None else None,
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _dropped_after(records: list[dict[str, Any]], origin: str) -> list[set[str]]:
|
|
435
|
+
"""For each position i, the files dropped by reset records after i:
|
|
436
|
+
a head record is void once a later reset drops its file (saving the
|
|
437
|
+
same note again later writes a new record, which counts on its own)."""
|
|
438
|
+
out: list[set[str]] = [set() for _ in records]
|
|
439
|
+
acc: set[str] = set()
|
|
440
|
+
for i in range(len(records) - 1, -1, -1):
|
|
441
|
+
out[i] = set(acc)
|
|
442
|
+
rec = records[i]
|
|
443
|
+
if rec.get("role") == "reset" and rec.get("origin") == origin:
|
|
444
|
+
acc |= {str(f) for f in rec.get("dropped") or []}
|
|
445
|
+
return out
|
|
446
|
+
|
|
447
|
+
def head(self, origin: str) -> SavedNote | None:
|
|
448
|
+
"""The note of the latest ``head`` record for ``origin`` that no
|
|
449
|
+
later reset dropped."""
|
|
450
|
+
recs = self.records()
|
|
451
|
+
dropped = self._dropped_after(recs, origin)
|
|
452
|
+
for i in range(len(recs) - 1, -1, -1):
|
|
453
|
+
rec = recs[i]
|
|
454
|
+
if rec.get("origin") != origin or rec.get("role", "head") != "head":
|
|
455
|
+
continue
|
|
456
|
+
file = str(rec.get("file", ""))
|
|
457
|
+
if not file or file in dropped[i]:
|
|
458
|
+
continue
|
|
459
|
+
p = self.root / file
|
|
460
|
+
try:
|
|
461
|
+
text = p.read_text("utf-8")
|
|
462
|
+
except (OSError, UnicodeDecodeError):
|
|
463
|
+
continue
|
|
464
|
+
return SavedNote(path=p, text=text, file=file, records=[r for r in recs if r.get("file") == file])
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
def notes(self, origin: str) -> list[SavedNote]:
|
|
468
|
+
"""Every saved note for ``origin`` that is not dropped by a reset,
|
|
469
|
+
smallest size first."""
|
|
470
|
+
d = self.root / _home.slug(origin)
|
|
471
|
+
if not d.is_dir():
|
|
472
|
+
return []
|
|
473
|
+
recs = self.records()
|
|
474
|
+
by_file: dict[str, list[dict[str, Any]]] = {}
|
|
475
|
+
last: dict[str, str] = {} # file -> "save" | "reset"
|
|
476
|
+
for rec in recs:
|
|
477
|
+
if rec.get("role") == "reset" and rec.get("origin") == origin:
|
|
478
|
+
for f in rec.get("dropped") or []:
|
|
479
|
+
last[str(f)] = "reset"
|
|
480
|
+
elif rec.get("file") is not None:
|
|
481
|
+
by_file.setdefault(str(rec["file"]), []).append(rec)
|
|
482
|
+
last[str(rec["file"])] = "save"
|
|
483
|
+
out: list[tuple[int, SavedNote]] = []
|
|
484
|
+
for p in d.glob("*.note"):
|
|
485
|
+
rel = str(p.relative_to(self.root))
|
|
486
|
+
if last.get(rel) == "reset":
|
|
487
|
+
continue
|
|
488
|
+
try:
|
|
489
|
+
size = int(p.name.split("-", 1)[0])
|
|
490
|
+
text = p.read_text("utf-8")
|
|
491
|
+
except (ValueError, OSError, UnicodeDecodeError):
|
|
492
|
+
continue
|
|
493
|
+
out.append((size, SavedNote(path=p, text=text, file=rel, records=by_file.get(rel, []))))
|
|
494
|
+
out.sort(key=lambda t: (t[0], t[1].path.name))
|
|
495
|
+
return [n for _, n in out]
|
|
496
|
+
|
|
497
|
+
def count(self, origin: str) -> int:
|
|
498
|
+
return len(self.notes(origin))
|