quaill 0.0.2__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.
quaill/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Quaill Records: checks a robot's MCAP log for who was in control, what changed, and what is missing.
2
+
3
+ Deterministic. No AI inside. Same file -> same report. It reports facts, not accusations.
4
+ """
5
+
6
+ __version__ = "0.0.2"
quaill/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
quaill/chain.py ADDED
@@ -0,0 +1,449 @@
1
+ """Hash chain: build (for recorders) and verify (for the checker), plus the outside witness.
2
+
3
+ One link per message on every non-chain channel, in the order the recorder saw them:
4
+ msg_sha256 = sha256(topic || 0 || serialized bytes)
5
+ link = sha256(prev || msg_sha256 || seq || recv_time || topic)
6
+ The checker pairs links to messages BY CONTENT HASH, never by the bag's log_time: a sidecar
7
+ node that publishes links cannot know what time rosbag2 will stamp on the original message.
8
+ Editing or removing a message breaks the chain. Cutting the END of the log does not: only an
9
+ outside witness (a party the robot maker does not control) holding a later chain head shows that.
10
+
11
+ Sessions: a recorder that restarts mid-bag starts a NEW chain (seq 1, prev = zeros) under a new
12
+ session_id. Each session's chain is verified on its own; witness entries carry the session_id.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import bisect
17
+ import json
18
+ from collections import Counter, defaultdict
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from .findings import NS, Finding, fmt_rel
23
+ from .mcapio import CHAIN_TOPIC, GENESIS, LogFile, Msg, link_hash, message_hash
24
+
25
+ LINK_KEYS = ("seq", "topic", "msg_sha256", "prev", "link", "recv_time")
26
+ ORDER_TOLERANCE = 1 * NS # bag log_time may differ a little from the sidecar's receive order
27
+ SKEW_TOLERANCE = 5 * NS # sidecar clock vs bag clock on the same robot
28
+ NOT_EXPECTED_CHAINED = {"/rosout", "/parameter_events"} # ROS plumbing that nobody chains
29
+
30
+
31
+ class ChainBuilder:
32
+ def __init__(self, session_id: str = "", signer=None) -> None:
33
+ self.session_id = session_id
34
+ self.signer = signer # quaill.keys.Signer or None
35
+ self.prev = GENESIS
36
+ self.seq = 0
37
+ self.links: list[dict] = []
38
+
39
+ def add(self, topic: str, data: bytes, recv_time: int) -> dict:
40
+ mh = message_hash(topic, data)
41
+ self.seq += 1
42
+ link = link_hash(self.prev, mh, self.seq, recv_time, topic)
43
+ rec = {"session_id": self.session_id, "seq": self.seq, "topic": topic, "msg_sha256": mh, "prev": self.prev, "link": link, "recv_time": int(recv_time), "key_id": "", "sig": ""}
44
+ if self.signer is not None:
45
+ rec["key_id"] = self.signer.key_id
46
+ rec["sig"] = self.signer.sign_link(link)
47
+ self.prev = link
48
+ self.links.append(rec)
49
+ return rec
50
+
51
+
52
+ @dataclass
53
+ class Segment:
54
+ session_id: str
55
+ links: list[dict]
56
+
57
+ @property
58
+ def last(self) -> dict:
59
+ return self.links[-1]
60
+
61
+
62
+ @dataclass
63
+ class ChainResult:
64
+ present: bool = False
65
+ links: list[dict] = field(default_factory=list)
66
+ segments: list[Segment] = field(default_factory=list)
67
+ last_seq: int | None = None
68
+ last_link: str | None = None
69
+ last_time: int | None = None # recv_time of the last link of the last segment
70
+ matched_time: dict[tuple[str, int], int] = field(default_factory=dict) # (session, seq) -> log_time of the matched message
71
+ witness_present: bool = False
72
+ witnessed_through_seq: int | None = None # in the last segment
73
+ witnessed_through_time: int | None = None
74
+ truncated: bool = False
75
+ findings: list[Finding] = field(default_factory=list)
76
+
77
+ @property
78
+ def complete_per_witness(self) -> bool:
79
+ return self.witness_present and not self.truncated and self.witnessed_through_seq == self.last_seq
80
+
81
+
82
+ def load_witness(path: str | None) -> dict | None:
83
+ """One witness file, or a directory of them (one per recorder session). Entries get a session_id."""
84
+ if not path:
85
+ return None
86
+ p = Path(path)
87
+ files = sorted(p.glob("*.json")) if p.is_dir() else [p]
88
+ entries: list[dict] = []
89
+ for f in files:
90
+ with f.open("r", encoding="utf-8") as fh:
91
+ w = json.load(fh)
92
+ if not isinstance(w, dict) or "entries" not in w:
93
+ if p.is_dir():
94
+ continue # unrelated json in the directory
95
+ raise ValueError(f"{f} has no 'entries' list")
96
+ sid = str(w.get("session_id") or "")
97
+ for e in w["entries"]:
98
+ if isinstance(e, dict) and "seq" in e and "link" in e:
99
+ e = dict(e)
100
+ e.setdefault("session_id", sid)
101
+ entries.append(e)
102
+ if p.is_dir() and not entries:
103
+ raise ValueError(f"no witness files with entries in {p}")
104
+ return {"entries": entries, "files": [str(f) for f in files]}
105
+
106
+
107
+ def _span(times: list[int], start: int) -> str:
108
+ t0, t1 = min(times), max(times)
109
+ return fmt_rel(t0, start) if t0 == t1 else f"{fmt_rel(t0, start)} to {fmt_rel(t1, start)}"
110
+
111
+
112
+ def _announced_keys(log: LogFile) -> dict[str, str]:
113
+ """session_id -> device_pubkey hex announced on START events inside the bag."""
114
+ from .mcapio import SESSION_TOPIC
115
+
116
+ out: dict[str, str] = {}
117
+ for m in log.by_topic(SESSION_TOPIC):
118
+ d = m.body()
119
+ if isinstance(d, dict) and d.get("event") == "START" and d.get("device_pubkey"):
120
+ out[str(d.get("session_id") or "")] = str(d["device_pubkey"])
121
+ return out
122
+
123
+
124
+ def verify_signatures(log: LogFile, segs: list[Segment], trusted_pub: str | None, F: list[Finding], start: int) -> None:
125
+ """Checks link signatures. trusted_pub: path to a public key the caller trusts (PEM or hex), or None."""
126
+ from .keys import CRYPTO, Verifier, key_id
127
+
128
+ signed = [d for s in segs for d in s.links if d.get("sig")]
129
+ if not signed:
130
+ F.append(Finding("UNSIGNED", "L1", "info", "chain links carry no device signature: anyone holding the file could extend or rebuild the chain; only the witness can tell"))
131
+ return
132
+ if not CRYPTO:
133
+ F.append(Finding("SIGNATURE_UNCHECKED", "L1", "warn", "links are signed but the 'cryptography' package is not installed, so signatures were not checked"))
134
+ return
135
+ trusted = None
136
+ if trusted_pub:
137
+ try:
138
+ trusted = Verifier.load(trusted_pub)
139
+ except Exception as e:
140
+ F.append(Finding("PUBKEY_UNREADABLE", "L1", "warn", f"could not read the supplied public key: {e}"))
141
+ announced = _announced_keys(log)
142
+ for s in segs:
143
+ sig_links = [d for d in s.links if d.get("sig")]
144
+ if not sig_links:
145
+ F.append(Finding("UNSIGNED", "L1", "info", f"session {s.session_id or '?'}: links carry no device signature"))
146
+ continue
147
+ if len(sig_links) != len(s.links):
148
+ F.append(Finding("SIGNATURE_PARTIAL", "L1", "fail", f"session {s.session_id or '?'}: {len(s.links) - len(sig_links)} of {len(s.links)} links are unsigned while the rest are signed"))
149
+ kids = {d.get("key_id") for d in sig_links}
150
+ if len(kids) > 1:
151
+ F.append(Finding("KEY_CHANGED", "L1", "fail", f"session {s.session_id or '?'}: links are signed by {len(kids)} different keys ({', '.join(sorted(k for k in kids if k))})"))
152
+ kid = sorted(kids)[0] or "?"
153
+ ann_hex = announced.get(s.session_id)
154
+ if ann_hex is None and len(set(announced.values())) == 1: # one key announced for the whole bag
155
+ ann_hex = next(iter(announced.values()))
156
+ verifier = None
157
+ source = None
158
+ if trusted is not None:
159
+ if trusted.key_id in kids:
160
+ verifier, source = trusted, "the supplied public key"
161
+ else:
162
+ F.append(Finding("KEY_MISMATCH", "L1", "fail", f"session {s.session_id or '?'}: links are signed by key {kid} but the supplied device key is {trusted.key_id}: this chain was not produced by the expected device key", s.links[0]["recv_time"], s.links[-1]["recv_time"]))
163
+ continue
164
+ elif ann_hex:
165
+ try:
166
+ v = Verifier.from_hex(ann_hex)
167
+ if v.key_id in kids:
168
+ verifier, source = v, "the key announced in the bag's START marker"
169
+ else:
170
+ F.append(Finding("KEY_MISMATCH", "L1", "fail", f"session {s.session_id or '?'}: links are signed by key {kid} but the START marker announces key {v.key_id}"))
171
+ continue
172
+ except Exception as e:
173
+ F.append(Finding("PUBKEY_UNREADABLE", "L1", "warn", f"session {s.session_id or '?'}: the announced device key does not parse: {e}"))
174
+ if verifier is None:
175
+ F.append(Finding("SIGNED_UNVERIFIED", "L1", "warn", f"session {s.session_id or '?'}: {len(sig_links)} links signed by key {kid}, but no public key is available (none announced in the bag, none supplied with --pubkey); signatures not checked"))
176
+ continue
177
+ bad = [d for d in sig_links if not verifier.verify_link(d["link"], d["sig"])]
178
+ if bad:
179
+ F.append(Finding("SIGNATURE_INVALID", "L1", "fail", f"session {s.session_id or '?'}: {len(bad)} of {len(sig_links)} link signatures do not verify against {source} (first at {fmt_rel(bad[0]['recv_time'], start)})", bad[0]["recv_time"], bad[-1]["recv_time"]))
180
+ elif source.startswith("the supplied"):
181
+ F.append(Finding("SIGNED_TRUSTED", "L1", "info", f"session {s.session_id or '?'}: all {len(sig_links)} links signed by device key {kid}, verified against the supplied public key"))
182
+ else:
183
+ F.append(Finding("SIGNED_ANNOUNCED", "L1", "info", f"session {s.session_id or '?'}: all {len(sig_links)} links verify against key {kid} announced inside the bag; a rebuilt chain could announce its own key, so supply the device's public key (--pubkey) or a witness to confirm it"))
184
+
185
+
186
+ def verify_chain(log: LogFile, witness: dict | None, trusted_pub: str | None = None) -> ChainResult:
187
+ r = ChainResult()
188
+ F = r.findings
189
+ start = log.start
190
+ chain_msgs = log.by_topic(CHAIN_TOPIC)
191
+ if not chain_msgs:
192
+ F.append(Finding("CHAIN_ABSENT", "L1", "info", f"no {CHAIN_TOPIC} channel: edits and deletions cannot be checked from this file"))
193
+ return r
194
+ r.present = True
195
+
196
+ links: list[dict] = []
197
+ bad = 0
198
+ for m in chain_msgs:
199
+ d = m.body()
200
+ if not isinstance(d, dict) or any(k not in d for k in LINK_KEYS):
201
+ bad += 1
202
+ continue
203
+ try:
204
+ d["seq"] = int(d["seq"])
205
+ d["recv_time"] = int(d["recv_time"])
206
+ d["session_id"] = str(d.get("session_id") or "")
207
+ except Exception:
208
+ bad += 1
209
+ continue
210
+ links.append(d)
211
+ if bad:
212
+ F.append(Finding("CHAIN_MALFORMED", "L1", "fail", f"{bad} chain message(s) could not be parsed as links"))
213
+ if not links:
214
+ return r
215
+
216
+ # Split into sessions (segments), each ordered by seq, segments ordered by first recv_time.
217
+ by_sid: dict[str, list[dict]] = defaultdict(list)
218
+ for d in links:
219
+ by_sid[d["session_id"]].append(d)
220
+ segs = [Segment(sid, sorted(ls, key=lambda d: d["seq"])) for sid, ls in by_sid.items()]
221
+ segs.sort(key=lambda s: min(d["recv_time"] for d in s.links))
222
+ r.segments = segs
223
+ r.links = [d for s in segs for d in s.links]
224
+ if len(segs) > 1:
225
+ F.append(Finding("CHAIN_RESTART", "L1", "info", f"{len(segs)} recorder sessions in this log (" + ", ".join(f"{s.session_id or '?'} from {fmt_rel(s.links[0]['recv_time'], start)}" for s in segs) + "); each chain is checked on its own"))
226
+
227
+ # 1. Each chain must be continuous and self-consistent; the recorder's clock must not run backwards.
228
+ breaks = jumps = 0
229
+ for s in segs:
230
+ prev_seq, prev_link, prev_recv = 0, GENESIS, None
231
+ for d in s.links:
232
+ problems = []
233
+ if d["seq"] != prev_seq + 1:
234
+ problems.append(f"link sequence jumps from {prev_seq} to {d['seq']} ({d['seq'] - prev_seq - 1} link(s) removed)")
235
+ if d["prev"] != prev_link:
236
+ problems.append(f"link {d['seq']} does not reference the previous link")
237
+ try:
238
+ expect = link_hash(d["prev"], d["msg_sha256"], d["seq"], d["recv_time"], d["topic"])
239
+ except Exception:
240
+ expect = None
241
+ if expect != d["link"]:
242
+ problems.append(f"link {d['seq']} hash does not match its contents")
243
+ if problems:
244
+ breaks += 1
245
+ if breaks <= 5:
246
+ F.append(Finding("CHAIN_BROKEN", "L1", "fail", "; ".join(problems) + f" at {fmt_rel(d['recv_time'], start)}" + (f" (session {s.session_id})" if len(segs) > 1 else ""), d["recv_time"], d["recv_time"]))
247
+ if prev_recv is not None and d["recv_time"] < prev_recv - ORDER_TOLERANCE:
248
+ jumps += 1
249
+ if jumps <= 3:
250
+ F.append(Finding("TIME_JUMP", "L1", "warn", f"recorder clock stepped back {(prev_recv - d['recv_time']) / NS:.1f} s at link {d['seq']} (recorder time {fmt_rel(d['recv_time'], start)}); timestamps after this point cannot be taken at face value", d["recv_time"], prev_recv))
251
+ prev_seq, prev_link, prev_recv = d["seq"], d["link"], d["recv_time"]
252
+ if breaks > 5:
253
+ F.append(Finding("CHAIN_BROKEN", "L1", "fail", f"... and {breaks - 5} more chain break(s)"))
254
+ if jumps > 3:
255
+ F.append(Finding("TIME_JUMP", "L1", "warn", f"... and {jumps - 3} more backwards clock step(s)"))
256
+ last = segs[-1].last
257
+ r.last_seq, r.last_link, r.last_time = last["seq"], last["link"], last["recv_time"]
258
+
259
+ # 1b. Device signatures on the links.
260
+ verify_signatures(log, segs, trusted_pub, F, start)
261
+
262
+ # 2. Every link must match a message in the file, byte for byte. Pairing is by content hash.
263
+ # Identical messages (a stopped robot publishes thousands of all-zero commands) share a hash, so:
264
+ # phase 1 pairs every hash that is unique on both sides; phase 2 places the duplicates inside the
265
+ # time window their already-paired neighbours in the chain define.
266
+ pool: dict[str, list[Msg]] = defaultdict(list)
267
+ for m in log.payload(): # already in log_time order
268
+ pool[m.hash].append(m)
269
+ link_count: Counter = Counter(d["msg_sha256"] for d in r.links)
270
+ taken: set[int] = set()
271
+ missing: list[dict] = []
272
+ order_viol = 0
273
+ max_skew = 0
274
+ for s in segs:
275
+ n = len(s.links)
276
+ times: list[int | None] = [None] * n
277
+ # phase 1: unambiguous
278
+ for i, d in enumerate(s.links):
279
+ cands = pool.get(d["msg_sha256"])
280
+ if cands and len(cands) == 1 and link_count[d["msg_sha256"]] == 1:
281
+ m = cands[0]
282
+ if id(m) not in taken:
283
+ taken.add(id(m))
284
+ times[i] = m.log_time
285
+ # phase 2: duplicates, bounded by the nearest anchored neighbours
286
+ next_anchor: list[int | None] = [None] * n
287
+ nxt = None
288
+ for i in range(n - 1, -1, -1):
289
+ if times[i] is not None:
290
+ nxt = times[i]
291
+ next_anchor[i] = nxt
292
+ prev_anchor = None
293
+ for i, d in enumerate(s.links):
294
+ if times[i] is not None:
295
+ prev_anchor = times[i]
296
+ continue
297
+ cands = [m for m in pool.get(d["msg_sha256"], []) if id(m) not in taken]
298
+ if not cands:
299
+ missing.append(d)
300
+ continue
301
+ lo = (prev_anchor if prev_anchor is not None else start) - ORDER_TOLERANCE
302
+ hi = (next_anchor[i] if next_anchor[i] is not None else log.end) + ORDER_TOLERANCE
303
+ inside = [m for m in cands if lo <= m.log_time <= hi]
304
+ m = inside[0] if inside else min(cands, key=lambda m: abs(m.log_time - d["recv_time"]))
305
+ taken.add(id(m))
306
+ times[i] = m.log_time
307
+ prev_anchor = m.log_time
308
+ prev_t = None
309
+ for i, d in enumerate(s.links):
310
+ t = times[i]
311
+ if t is None:
312
+ continue
313
+ r.matched_time[(s.session_id, d["seq"])] = t
314
+ if prev_t is not None and t < prev_t - ORDER_TOLERANCE:
315
+ order_viol += 1
316
+ prev_t = max(prev_t or t, t)
317
+ max_skew = max(max_skew, abs(t - d["recv_time"]))
318
+ uncovered = [m for ms in pool.values() for m in ms if id(m) not in taken]
319
+ uncovered.sort(key=lambda m: m.log_time)
320
+ if order_viol:
321
+ F.append(Finding("TIME_ORDER", "L1", "warn", f"{order_viol} message(s) carry a log time earlier than messages chained before them (bag order and recorder order disagree by more than {ORDER_TOLERANCE / NS:.0f} s)"))
322
+ if max_skew > SKEW_TOLERANCE:
323
+ F.append(Finding("CLOCK_SKEW", "L1", "warn", f"recorder clock and bag clock differ by up to {max_skew / NS:.1f} s"))
324
+
325
+ # 3. A link with no message plus an unchained message on the same topic in the same window = an edit.
326
+ unc_by_topic: dict[str, list[Msg]] = defaultdict(list)
327
+ for m in uncovered:
328
+ unc_by_topic[m.topic].append(m)
329
+ consumed: set[int] = set()
330
+ edited: list[tuple[dict, Msg]] = []
331
+ deleted: list[dict] = []
332
+ for s in segs:
333
+ matched_seqs = sorted(seq for (sid, seq) in r.matched_time if sid == s.session_id)
334
+ for d in missing:
335
+ if d["session_id"] != s.session_id:
336
+ continue
337
+ i = bisect.bisect_left(matched_seqs, d["seq"])
338
+ t_lo = r.matched_time[(s.session_id, matched_seqs[i - 1])] if i > 0 else start
339
+ t_hi = r.matched_time[(s.session_id, matched_seqs[i])] if i < len(matched_seqs) else log.end
340
+ cand = None
341
+ for m in unc_by_topic.get(d["topic"], []):
342
+ if id(m) in consumed:
343
+ continue
344
+ if t_lo - ORDER_TOLERANCE <= m.log_time <= t_hi + ORDER_TOLERANCE:
345
+ cand = m
346
+ break
347
+ if cand is not None:
348
+ consumed.add(id(cand))
349
+ edited.append((d, cand))
350
+ else:
351
+ deleted.append(d)
352
+ unchained = [m for m in uncovered if id(m) not in consumed]
353
+
354
+ by_topic: dict[str, list] = defaultdict(list)
355
+ for d, m in edited:
356
+ by_topic[d["topic"]].append(m.log_time)
357
+ for topic, ts in by_topic.items():
358
+ F.append(Finding("MESSAGE_EDITED", "L1", "fail", f"{len(ts)} message(s) on {topic} differ from what the chain recorded when they were logged (edited after the fact) ({_span(ts, start)})", min(ts), max(ts)))
359
+ by_topic = defaultdict(list)
360
+ for d in deleted:
361
+ by_topic[d["topic"]].append(d["recv_time"])
362
+ for topic, ts in by_topic.items():
363
+ F.append(Finding("MESSAGE_MISSING", "L1", "fail", f"{len(ts)} message(s) on {topic} are recorded in the chain but are not in the file (deleted) ({_span(ts, start)})", min(ts), max(ts)))
364
+ counts: dict[str, int] = defaultdict(int)
365
+ for m in unchained:
366
+ counts[m.topic] += 1
367
+ for topic, n in counts.items():
368
+ sev = "info" if topic in NOT_EXPECTED_CHAINED else "warn"
369
+ F.append(Finding("MESSAGE_UNCHAINED", "L1", sev, f"{n} message(s) on {topic} are not covered by the chain (added later, recorded while the chain node was down, or never chained)"))
370
+
371
+ # 4. Outside witness: the only way to see a cut-off end or a rebuilt chain.
372
+ if witness is None:
373
+ F.append(Finding("UNWITNESSED", "L1", "warn", "no outside witness: the chain is intact, but cutting off the end of the log would not be detectable from the file alone"))
374
+ return r
375
+ r.witness_present = True
376
+ entries = sorted((e for e in witness.get("entries", []) if "seq" in e and "link" in e), key=lambda e: (str(e.get("session_id") or ""), int(e["seq"])))
377
+ if not entries:
378
+ F.append(Finding("WITNESS_EMPTY", "L1", "warn", "witness file has no entries"))
379
+ return r
380
+ seg_by_sid = {s.session_id: s for s in segs}
381
+ reported_trunc: set[str] = set()
382
+ unknown_sessions: set[str] = set()
383
+ for e in entries:
384
+ sid = str(e.get("session_id") or "")
385
+ seq = int(e["seq"])
386
+ seg = seg_by_sid.get(sid) if sid else (segs[0] if len(segs) == 1 else None)
387
+ if seg is None:
388
+ if sid and sid not in seg_by_sid:
389
+ unknown_sessions.add(sid)
390
+ else:
391
+ F.append(Finding("WITNESS_AMBIGUOUS", "L1", "warn", f"witness entry seq {seq} names no session and the log has {len(segs)} sessions; cannot be matched"))
392
+ continue
393
+ by_seq = {d["seq"]: d for d in seg.links}
394
+ d = by_seq.get(seq)
395
+ if d is None:
396
+ if seq > seg.last["seq"]:
397
+ if seg.session_id not in reported_trunc:
398
+ reported_trunc.add(seg.session_id)
399
+ r.truncated = True
400
+ wt = e.get("recv_time")
401
+ where = "the end of the log was cut off" if seg is segs[-1] else f"messages at the end of session {seg.session_id or '?'} are missing"
402
+ F.append(
403
+ Finding(
404
+ "TRUNCATED",
405
+ "L1",
406
+ "fail",
407
+ f"the witness holds chain link {seq} (recorder time {fmt_rel(wt, start) if wt else '?'}, witnessed {e.get('witnessed_at', '?')}) "
408
+ f"but the file's chain ends at link {seg.last['seq']} ({fmt_rel(seg.last['recv_time'], start)}): {where}",
409
+ seg.last["recv_time"],
410
+ wt,
411
+ )
412
+ )
413
+ else:
414
+ F.append(Finding("WITNESS_MISMATCH", "L1", "fail", f"witness refers to link {seq}, which the file does not have"))
415
+ continue
416
+ if d["link"] != e["link"]:
417
+ F.append(
418
+ Finding(
419
+ "WITNESS_MISMATCH",
420
+ "L1",
421
+ "fail",
422
+ f"witness link {seq} ({fmt_rel(d['recv_time'], start)}) differs from the file's: the chain was rebuilt after it was witnessed (something before this point was changed or removed)",
423
+ d["recv_time"],
424
+ d["recv_time"],
425
+ )
426
+ )
427
+ continue
428
+ if e.get("key_id") and d.get("key_id") and e["key_id"] != d["key_id"]:
429
+ F.append(Finding("WITNESS_KEY_MISMATCH", "L1", "fail", f"witness saw link {seq} signed by key {e['key_id']} but the file's link is signed by {d['key_id']}", d["recv_time"], d["recv_time"]))
430
+ continue
431
+ if seg is segs[-1] and (r.witnessed_through_seq is None or seq > r.witnessed_through_seq):
432
+ r.witnessed_through_seq = seq
433
+ r.witnessed_through_time = r.matched_time.get((seg.session_id, seq), d["recv_time"])
434
+ for sid in sorted(unknown_sessions):
435
+ F.append(Finding("WITNESS_OTHER_SESSION", "L1", "info", f"witness has entries for session {sid}, which is not in this log (another bag, or the recorder ran without the bag)"))
436
+ if r.witnessed_through_seq is None and not r.truncated:
437
+ F.append(Finding("UNWITNESSED", "L1", "warn", "witness supplied, but none of its entries match the last session in this log; the end cannot be confirmed"))
438
+ elif r.witnessed_through_seq is not None and not r.truncated and r.witnessed_through_seq < r.last_seq:
439
+ F.append(
440
+ Finding(
441
+ "UNWITNESSED_TAIL",
442
+ "L1",
443
+ "info",
444
+ f"messages after {fmt_rel(r.witnessed_through_time, start)} (links {r.witnessed_through_seq + 1}-{r.last_seq}) are chained but no witness has seen them",
445
+ r.witnessed_through_time,
446
+ r.last_time,
447
+ )
448
+ )
449
+ return r
File without changes
@@ -0,0 +1,109 @@
1
+ """L2 completeness: is anything missing? Gaps, crashes, cut-off endings, each explained or flagged.
2
+
3
+ A recording gap is a stretch with no messages at all. The chain cannot see it (nothing was
4
+ written), so this check looks for the markers an honest recorder leaves:
5
+ CLEAN_SHUTDOWN or POWER_LOST right before the gap, or CRASH_NOTE right after it.
6
+ Neither marker -> unexplained gap. The checker states the gap; it does not guess why.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+
12
+ from ..chain import ChainResult
13
+ from ..findings import NS, Finding, fmt_dur, fmt_rel
14
+ from ..mcapio import CHAIN_TOPIC, END_MARKERS, SESSION_TOPIC, LogFile
15
+
16
+ MARKER_TOLERANCE = 1 * NS
17
+
18
+
19
+ @dataclass
20
+ class Gap:
21
+ t_start: int
22
+ t_end: int
23
+ explanation: str | None
24
+
25
+ @property
26
+ def explained(self) -> bool:
27
+ return self.explanation is not None
28
+
29
+ def to_dict(self) -> dict:
30
+ d = asdict(self)
31
+ d["explained"] = self.explained
32
+ return d
33
+
34
+
35
+ @dataclass
36
+ class CompletenessResult:
37
+ gaps: list[Gap] = field(default_factory=list)
38
+ sessions: list[dict] = field(default_factory=list)
39
+ ending: str = "unknown"
40
+ findings: list[Finding] = field(default_factory=list)
41
+
42
+
43
+ def check_completeness(log: LogFile, chain: ChainResult, gap_threshold_ns: int = 2 * NS) -> CompletenessResult:
44
+ r = CompletenessResult()
45
+ F = r.findings
46
+ msgs = log.payload()
47
+ if not msgs:
48
+ return r
49
+ start, end = log.start, log.end
50
+
51
+ events = []
52
+ malformed = 0
53
+ for m in log.by_topic(SESSION_TOPIC):
54
+ d = m.body()
55
+ if not isinstance(d, dict) or not d.get("event"):
56
+ malformed += 1
57
+ continue
58
+ ev = {"t": m.log_time, "event": d["event"], "session_id": d.get("session_id") or None, "prev_session_id": d.get("prev_session_id") or None, "software_version": d.get("software_version") or None, "note": d.get("note") or None}
59
+ events.append(ev)
60
+ r.sessions.append(ev)
61
+ if malformed:
62
+ F.append(Finding("SESSION_MALFORMED", "L2", "warn", f"{malformed} session event(s) could not be parsed"))
63
+ markers = bool(events)
64
+ if not markers:
65
+ F.append(Finding("SESSION_ABSENT", "L2", "info", f"no {SESSION_TOPIC} markers: a crash, a clean stop and a cut-off end cannot be told apart in this log"))
66
+ elif not any(e["event"] == "START" and abs(e["t"] - start) <= MARKER_TOLERANCE for e in events):
67
+ F.append(Finding("SESSION_NO_START", "L2", "warn", "log does not begin with a START marker"))
68
+
69
+ def marker_near(kinds, t: int) -> dict | None:
70
+ for e in events:
71
+ if e["event"] in kinds and abs(e["t"] - t) <= MARKER_TOLERANCE:
72
+ return e
73
+ return None
74
+
75
+ times = [m.log_time for m in msgs]
76
+ for a, b in zip(times, times[1:]):
77
+ if b - a <= gap_threshold_ns:
78
+ continue
79
+ before = marker_near(END_MARKERS, a)
80
+ after = marker_near(("CRASH_NOTE",), b)
81
+ if before:
82
+ expl = f"{before['event'].lower().replace('_', ' ')} marker at {fmt_rel(a, start)}" + (f", recording resumed at {fmt_rel(b, start)}" if marker_near(("START",), b) else "")
83
+ elif after:
84
+ expl = f"crash: recorder restarted at {fmt_rel(b, start)} and noted that session {after.get('prev_session_id') or '?'} ended unexpectedly"
85
+ else:
86
+ expl = None
87
+ g = Gap(a, b, expl)
88
+ r.gaps.append(g)
89
+ if expl:
90
+ F.append(Finding("GAP_EXPLAINED", "L2", "info", f"no messages from {fmt_rel(a, start)} to {fmt_rel(b, start)} ({fmt_dur(b - a)}); explained: {expl}", a, b))
91
+ elif not markers:
92
+ F.append(Finding("GAP_UNEXPLAINED", "L2", "warn", f"no messages from {fmt_rel(a, start)} to {fmt_rel(b, start)} ({fmt_dur(b - a)}); this log has no session markers that could explain it", a, b))
93
+ else:
94
+ F.append(Finding("GAP_UNEXPLAINED", "L2", "fail", f"no messages from {fmt_rel(a, start)} to {fmt_rel(b, start)} ({fmt_dur(b - a)}); no shutdown marker before it and no crash note after it", a, b))
95
+
96
+ # How does the log end?
97
+ if not markers:
98
+ r.ending = "unknown (no session markers)"
99
+ elif marker_near(END_MARKERS, end):
100
+ r.ending = "marked"
101
+ elif chain.truncated:
102
+ r.ending = "cut" # the chain finding already states it
103
+ elif chain.complete_per_witness:
104
+ r.ending = "unmarked, complete per witness"
105
+ F.append(Finding("ENDING_UNMARKED", "L2", "warn", f"log ends at {fmt_rel(end, start)} without a shutdown marker; the outside witness confirms this is where the recording stopped (recorder died or was killed)", end, end))
106
+ else:
107
+ r.ending = "unexplained"
108
+ F.append(Finding("ENDING_UNEXPLAINED", "L2", "fail", f"log ends at {fmt_rel(end, start)} without a shutdown marker or crash note; either the recorder died or the end was cut, and there is no outside witness to tell which", end, end))
109
+ return r