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 +6 -0
- quaill/__main__.py +4 -0
- quaill/chain.py +449 -0
- quaill/checks/__init__.py +0 -0
- quaill/checks/completeness.py +109 -0
- quaill/checks/control.py +181 -0
- quaill/checks/format.py +33 -0
- quaill/checks/profile.py +150 -0
- quaill/cli.py +186 -0
- quaill/engine.py +140 -0
- quaill/findings.py +49 -0
- quaill/gen.py +619 -0
- quaill/inputs.py +324 -0
- quaill/keys.py +100 -0
- quaill/mcapio.py +272 -0
- quaill/profiles/quaill-control.json +16 -0
- quaill/profiles/stvfernlv-anlage3.json +35 -0
- quaill/report.py +233 -0
- quaill-0.0.2.dist-info/METADATA +176 -0
- quaill-0.0.2.dist-info/RECORD +25 -0
- quaill-0.0.2.dist-info/WHEEL +5 -0
- quaill-0.0.2.dist-info/entry_points.txt +2 -0
- quaill-0.0.2.dist-info/licenses/LICENSE +202 -0
- quaill-0.0.2.dist-info/licenses/NOTICE +8 -0
- quaill-0.0.2.dist-info/top_level.txt +1 -0
quaill/__init__.py
ADDED
quaill/__main__.py
ADDED
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
|