backplane-tracecheck 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.
- backplane_tracecheck-0.2.0.dist-info/METADATA +167 -0
- backplane_tracecheck-0.2.0.dist-info/RECORD +15 -0
- backplane_tracecheck-0.2.0.dist-info/WHEEL +4 -0
- backplane_tracecheck-0.2.0.dist-info/entry_points.txt +2 -0
- backplane_tracecheck-0.2.0.dist-info/licenses/LICENSE +202 -0
- backplane_tracecheck-0.2.0.dist-info/licenses/NOTICE +5 -0
- tracecheck/__init__.py +1 -0
- tracecheck/__main__.py +131 -0
- tracecheck/contract.py +92 -0
- tracecheck/engine.py +310 -0
- tracecheck/reader.py +735 -0
- tracecheck/report.py +14 -0
- tracecheck/samples.py +103 -0
- tracecheck/server.py +158 -0
- tracecheck/ui.html +21 -0
tracecheck/reader.py
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""Strict, bounded, streaming MCAP reader used by Backplane Tracecheck.
|
|
2
|
+
|
|
3
|
+
The reader parses MCAP records itself (the record layouts are small and fixed by
|
|
4
|
+
the MCAP specification) so that every length is checked before any allocation,
|
|
5
|
+
and so the browser implementation in ``web/`` can mirror it exactly.
|
|
6
|
+
|
|
7
|
+
* Full mode reads the file once, sequentially, in bounded pieces. SHA-256 and the
|
|
8
|
+
MCAP data-section / summary CRCs are computed on the fly. zstd and lz4 chunks
|
|
9
|
+
are decompressed with output capped at the chunk's declared size, which must
|
|
10
|
+
itself be under a configured limit.
|
|
11
|
+
* Fast mode reads only the header, footer and summary section (plus indexed
|
|
12
|
+
metadata records), so multi-GB files are inventoried in milliseconds.
|
|
13
|
+
|
|
14
|
+
Message payloads are never decoded.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import io
|
|
21
|
+
import struct
|
|
22
|
+
import time
|
|
23
|
+
import zlib
|
|
24
|
+
from array import array
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import BinaryIO, Callable
|
|
27
|
+
|
|
28
|
+
MAGIC = b"\x89MCAP0\r\n"
|
|
29
|
+
(
|
|
30
|
+
OP_HEADER,
|
|
31
|
+
OP_FOOTER,
|
|
32
|
+
OP_SCHEMA,
|
|
33
|
+
OP_CHANNEL,
|
|
34
|
+
OP_MESSAGE,
|
|
35
|
+
OP_CHUNK,
|
|
36
|
+
OP_MESSAGE_INDEX,
|
|
37
|
+
OP_CHUNK_INDEX,
|
|
38
|
+
OP_ATTACHMENT,
|
|
39
|
+
OP_ATTACHMENT_INDEX,
|
|
40
|
+
OP_STATISTICS,
|
|
41
|
+
OP_METADATA,
|
|
42
|
+
OP_METADATA_INDEX,
|
|
43
|
+
OP_SUMMARY_OFFSET,
|
|
44
|
+
OP_DATA_END,
|
|
45
|
+
) = range(1, 16)
|
|
46
|
+
KNOWN_OPCODES = frozenset(range(1, 16))
|
|
47
|
+
CHUNK_OPCODES = frozenset({OP_SCHEMA, OP_CHANNEL, OP_MESSAGE})
|
|
48
|
+
|
|
49
|
+
MiB = 1 << 20
|
|
50
|
+
TEXT_LIMIT = 1024 # names, topics, encodings, profile, library, metadata keys
|
|
51
|
+
META_VALUE_LIMIT = 65536 # ROS 2 stores QoS YAML in channel metadata values
|
|
52
|
+
META_PAIRS_LIMIT = 100
|
|
53
|
+
FOOTER_RECORD_SIZE = 1 + 8 + 20
|
|
54
|
+
SUPPORTED_COMPRESSION = ("", "zstd", "lz4")
|
|
55
|
+
|
|
56
|
+
_U16 = struct.Struct("<H")
|
|
57
|
+
_U32 = struct.Struct("<I")
|
|
58
|
+
_U64 = struct.Struct("<Q")
|
|
59
|
+
_MSG = struct.Struct("<HIQQ")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Limits:
|
|
64
|
+
"""Resource bounds. Exceeding any limit stops the scan without conclusions."""
|
|
65
|
+
|
|
66
|
+
max_record_bytes: int = 256 * MiB
|
|
67
|
+
max_chunk_uncompressed_bytes: int = 256 * MiB
|
|
68
|
+
max_summary_bytes: int = 256 * MiB
|
|
69
|
+
max_messages: int = 10**12
|
|
70
|
+
max_records: int = 10**12
|
|
71
|
+
max_tracked_messages: int = 20_000_000
|
|
72
|
+
max_entities: int = 10_000
|
|
73
|
+
max_seconds: float | None = None
|
|
74
|
+
evidence_per_finding: int = 20
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ScanError(ValueError):
|
|
78
|
+
"""The recording could not be read within the format rules and limits."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ScanTimeout(ScanError):
|
|
82
|
+
"""The configured wall-clock limit was reached."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- parsing
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Cursor:
|
|
89
|
+
"""Strict little-endian field reader over a byte buffer."""
|
|
90
|
+
|
|
91
|
+
__slots__ = ("b", "p", "end")
|
|
92
|
+
|
|
93
|
+
def __init__(self, b: bytes | memoryview, start: int = 0, end: int | None = None):
|
|
94
|
+
self.b = b
|
|
95
|
+
self.p = start
|
|
96
|
+
self.end = len(b) if end is None else end
|
|
97
|
+
|
|
98
|
+
def take(self, n: int) -> int:
|
|
99
|
+
if n < 0 or n > self.end - self.p:
|
|
100
|
+
raise ScanError("field exceeds record boundary")
|
|
101
|
+
p = self.p
|
|
102
|
+
self.p = p + n
|
|
103
|
+
return p
|
|
104
|
+
|
|
105
|
+
def u8(self) -> int:
|
|
106
|
+
return self.b[self.take(1)]
|
|
107
|
+
|
|
108
|
+
def u16(self) -> int:
|
|
109
|
+
return _U16.unpack_from(self.b, self.take(2))[0]
|
|
110
|
+
|
|
111
|
+
def u32(self) -> int:
|
|
112
|
+
return _U32.unpack_from(self.b, self.take(4))[0]
|
|
113
|
+
|
|
114
|
+
def u64(self) -> int:
|
|
115
|
+
return _U64.unpack_from(self.b, self.take(8))[0]
|
|
116
|
+
|
|
117
|
+
def raw(self, n: int) -> bytes:
|
|
118
|
+
p = self.take(n)
|
|
119
|
+
return bytes(self.b[p : p + n])
|
|
120
|
+
|
|
121
|
+
def string(self, limit: int = TEXT_LIMIT) -> str:
|
|
122
|
+
n = self.u32()
|
|
123
|
+
p = self.take(n)
|
|
124
|
+
if n > limit * 4:
|
|
125
|
+
raise ScanError(f"record text field exceeds {limit} characters")
|
|
126
|
+
try:
|
|
127
|
+
s = bytes(self.b[p : p + n]).decode("utf-8")
|
|
128
|
+
except UnicodeDecodeError as e:
|
|
129
|
+
raise ScanError("record text field is not valid UTF-8") from e
|
|
130
|
+
if len(s) > limit:
|
|
131
|
+
raise ScanError(f"record text field exceeds {limit} characters")
|
|
132
|
+
return s
|
|
133
|
+
|
|
134
|
+
def string_map(self) -> dict[str, str]:
|
|
135
|
+
n = self.u32()
|
|
136
|
+
sub = _Cursor(self.b, self.take(n), self.p)
|
|
137
|
+
out: dict[str, str] = {}
|
|
138
|
+
pairs = 0
|
|
139
|
+
while sub.p < sub.end:
|
|
140
|
+
pairs += 1
|
|
141
|
+
if pairs > META_PAIRS_LIMIT:
|
|
142
|
+
raise ScanError("metadata field or pair count exceeds limit")
|
|
143
|
+
key = sub.string(TEXT_LIMIT)
|
|
144
|
+
out[key] = sub.string(META_VALUE_LIMIT)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
def u16_u64_map(self) -> dict[int, int]:
|
|
148
|
+
n = self.u32()
|
|
149
|
+
sub = _Cursor(self.b, self.take(n), self.p)
|
|
150
|
+
out: dict[int, int] = {}
|
|
151
|
+
while sub.p < sub.end:
|
|
152
|
+
key = sub.u16()
|
|
153
|
+
out[key] = sub.u64()
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_header(c: _Cursor) -> tuple[str, str]:
|
|
158
|
+
return c.string(), c.string()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_schema(c: _Cursor) -> tuple[int, dict]:
|
|
162
|
+
sid = c.u16()
|
|
163
|
+
name = c.string()
|
|
164
|
+
encoding = c.string()
|
|
165
|
+
data = c.raw(c.u32())
|
|
166
|
+
if sid == 0:
|
|
167
|
+
raise ScanError("schema id zero is reserved")
|
|
168
|
+
return sid, {"name": name, "encoding": encoding, "sha256": hashlib.sha256(data).hexdigest()}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_channel(c: _Cursor) -> tuple[int, dict]:
|
|
172
|
+
cid = c.u16()
|
|
173
|
+
schema_id = c.u16()
|
|
174
|
+
topic = c.string()
|
|
175
|
+
encoding = c.string()
|
|
176
|
+
metadata = c.string_map()
|
|
177
|
+
return cid, {"topic": topic, "schema_id": schema_id, "message_encoding": encoding, "metadata": metadata}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_metadata(c: _Cursor) -> tuple[str, dict[str, str]]:
|
|
181
|
+
return c.string(), c.string_map()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def iter_records(buf: bytes, start: int = 0, end: int | None = None):
|
|
185
|
+
"""Yield ``(offset, opcode, body_start, body_end)`` for consecutive records in ``buf``."""
|
|
186
|
+
end = len(buf) if end is None else end
|
|
187
|
+
p = start
|
|
188
|
+
while p < end:
|
|
189
|
+
if end - p < 9:
|
|
190
|
+
raise ScanError("truncated record header")
|
|
191
|
+
op = buf[p]
|
|
192
|
+
length = _U64.unpack_from(buf, p + 1)[0]
|
|
193
|
+
body = p + 9
|
|
194
|
+
if length > end - body:
|
|
195
|
+
raise ScanError("record extends beyond its container")
|
|
196
|
+
yield p, op, body, body + length
|
|
197
|
+
p = body + length
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def decompress(compression: str, data: bytes, size: int) -> bytes:
|
|
201
|
+
"""Decompress a chunk, producing at most ``size`` bytes; any other length is an error."""
|
|
202
|
+
if compression not in ("zstd", "lz4"):
|
|
203
|
+
raise ScanError(f"unsupported compression: {compression[:40]!r}")
|
|
204
|
+
try:
|
|
205
|
+
if compression == "zstd":
|
|
206
|
+
import zstandard
|
|
207
|
+
|
|
208
|
+
parts, total = [], 0
|
|
209
|
+
with zstandard.ZstdDecompressor().stream_reader(io.BytesIO(data), read_across_frames=True) as r:
|
|
210
|
+
while total <= size:
|
|
211
|
+
part = r.read(min(MiB, size + 1 - total))
|
|
212
|
+
if not part:
|
|
213
|
+
break
|
|
214
|
+
parts.append(part)
|
|
215
|
+
total += len(part)
|
|
216
|
+
out = b"".join(parts)
|
|
217
|
+
else:
|
|
218
|
+
import lz4.frame
|
|
219
|
+
|
|
220
|
+
out = lz4.frame.LZ4FrameDecompressor().decompress(data, max_length=size + 1)
|
|
221
|
+
except Exception as e: # codec libraries raise their own exception types
|
|
222
|
+
raise ScanError(f"{compression} chunk decompression failed: {str(e)[:120]}") from e
|
|
223
|
+
if len(out) != size:
|
|
224
|
+
raise ScanError("decompressed chunk size differs from declared uncompressed size")
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --------------------------------------------------------------------------- tracking
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class TrackConfig:
|
|
233
|
+
"""What the contract needs to know about one channel's messages."""
|
|
234
|
+
|
|
235
|
+
basis_log: bool = True
|
|
236
|
+
sequence: bool = False
|
|
237
|
+
max_gap: int | None = None # set only when a gap check will run
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Track:
|
|
241
|
+
"""Incremental per-channel statistics; memory is O(1) unless a gap check needs timestamps."""
|
|
242
|
+
|
|
243
|
+
__slots__ = (
|
|
244
|
+
"cid", "cfg", "ev", "count", "first", "last", "lo", "hi", "monotonic",
|
|
245
|
+
"seq_bad", "seq_bad_n", "reg_bad", "reg_bad_n", "gap_bad", "gap_bad_n",
|
|
246
|
+
"times", "ords", "loc_a", "loc_b",
|
|
247
|
+
) # fmt: skip
|
|
248
|
+
|
|
249
|
+
def __init__(self, cid: int, cfg: TrackConfig | None, evidence_limit: int):
|
|
250
|
+
self.cid = cid
|
|
251
|
+
self.cfg = cfg
|
|
252
|
+
self.ev = evidence_limit
|
|
253
|
+
self.count = 0
|
|
254
|
+
self.first = self.last = self.lo = self.hi = None
|
|
255
|
+
self.monotonic = True
|
|
256
|
+
self.seq_bad: list = []
|
|
257
|
+
self.reg_bad: list = []
|
|
258
|
+
self.gap_bad: list = []
|
|
259
|
+
self.seq_bad_n = self.reg_bad_n = self.gap_bad_n = 0
|
|
260
|
+
gap = cfg is not None and cfg.max_gap is not None
|
|
261
|
+
self.times = array("Q") if gap else None
|
|
262
|
+
self.ords = array("Q") if gap else None
|
|
263
|
+
self.loc_a = array("q") if gap else None
|
|
264
|
+
self.loc_b = array("q") if gap else None
|
|
265
|
+
|
|
266
|
+
def add(self, m: tuple) -> None:
|
|
267
|
+
"""``m`` is ``(ordinal, loc_a, loc_b, log_time, publish_time, sequence)``."""
|
|
268
|
+
self.count += 1
|
|
269
|
+
cfg = self.cfg
|
|
270
|
+
k = 3 if cfg.basis_log else 4
|
|
271
|
+
t = m[k]
|
|
272
|
+
prev = self.last
|
|
273
|
+
if prev is None:
|
|
274
|
+
self.first = self.lo = self.hi = m
|
|
275
|
+
else:
|
|
276
|
+
if cfg.sequence and m[5] != (prev[5] + 1) & 0xFFFFFFFF:
|
|
277
|
+
self.seq_bad_n += 1
|
|
278
|
+
if len(self.seq_bad) < self.ev:
|
|
279
|
+
self.seq_bad.append((prev, m))
|
|
280
|
+
pt = prev[k]
|
|
281
|
+
if t < pt:
|
|
282
|
+
self.monotonic = False
|
|
283
|
+
self.reg_bad_n += 1
|
|
284
|
+
if len(self.reg_bad) < self.ev:
|
|
285
|
+
self.reg_bad.append((prev, m))
|
|
286
|
+
elif self.monotonic and cfg.max_gap is not None and t - pt > cfg.max_gap:
|
|
287
|
+
self.gap_bad_n += 1
|
|
288
|
+
if len(self.gap_bad) < self.ev:
|
|
289
|
+
self.gap_bad.append((prev, m))
|
|
290
|
+
if t < self.lo[k]:
|
|
291
|
+
self.lo = m
|
|
292
|
+
if t >= self.hi[k]:
|
|
293
|
+
self.hi = m
|
|
294
|
+
self.last = m
|
|
295
|
+
if self.times is not None:
|
|
296
|
+
self.times.append(t)
|
|
297
|
+
self.ords.append(m[0])
|
|
298
|
+
self.loc_a.append(m[1])
|
|
299
|
+
self.loc_b.append(m[2])
|
|
300
|
+
|
|
301
|
+
def sorted_gaps(self) -> tuple[list, int]:
|
|
302
|
+
"""Return (evidence sample, total) of sorted-adjacent gaps above ``max_gap``.
|
|
303
|
+
|
|
304
|
+
Each sample item is ``(prev_index, cur_index, gap)`` into the stored arrays,
|
|
305
|
+
or, for the in-order fast path, ``(prev_msg, cur_msg, gap)`` tuples.
|
|
306
|
+
"""
|
|
307
|
+
k = 3 if self.cfg.basis_log else 4
|
|
308
|
+
if self.monotonic:
|
|
309
|
+
return [(a, b, b[k] - a[k]) for a, b in self.gap_bad], self.gap_bad_n
|
|
310
|
+
times, limit = self.times, self.cfg.max_gap
|
|
311
|
+
order = sorted(range(len(times)), key=times.__getitem__) # stable: ties keep file order
|
|
312
|
+
sample, total = [], 0
|
|
313
|
+
for i in range(1, len(order)):
|
|
314
|
+
a, b = order[i - 1], order[i]
|
|
315
|
+
g = times[b] - times[a]
|
|
316
|
+
if g > limit:
|
|
317
|
+
total += 1
|
|
318
|
+
if len(sample) < self.ev:
|
|
319
|
+
sample.append((a, b, g))
|
|
320
|
+
return sample, total
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- results
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class ScanResult:
|
|
328
|
+
mode: str
|
|
329
|
+
size: int
|
|
330
|
+
sha256: str | None = None
|
|
331
|
+
profile: str | None = None
|
|
332
|
+
schemas: dict = field(default_factory=dict)
|
|
333
|
+
channels: dict = field(default_factory=dict)
|
|
334
|
+
metadata: list = field(default_factory=list)
|
|
335
|
+
tracks: dict = field(default_factory=dict)
|
|
336
|
+
counts: dict | None = None # fast mode: channel id -> message count
|
|
337
|
+
crc: dict = field(default_factory=dict)
|
|
338
|
+
compression: dict = field(default_factory=dict)
|
|
339
|
+
unknown: int = 0
|
|
340
|
+
stats: dict = field(default_factory=dict)
|
|
341
|
+
fast_fallback: str | None = None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _Registry:
|
|
345
|
+
"""Schema/channel/metadata bookkeeping shared by both scan modes."""
|
|
346
|
+
|
|
347
|
+
def __init__(self, result: ScanResult, limits: Limits):
|
|
348
|
+
self.r = result
|
|
349
|
+
self.limits = limits
|
|
350
|
+
|
|
351
|
+
def _bound(self) -> None:
|
|
352
|
+
r, n = self.r, self.limits.max_entities
|
|
353
|
+
if len(r.channels) > n or len(r.schemas) > n or len(r.metadata) > n:
|
|
354
|
+
raise ScanError(f"channel/schema/metadata count exceeds {n}")
|
|
355
|
+
|
|
356
|
+
def schema(self, c: _Cursor, loc: dict) -> None:
|
|
357
|
+
sid, value = _parse_schema(c)
|
|
358
|
+
old = self.r.schemas.get(sid)
|
|
359
|
+
if old is not None and old["value"] != value:
|
|
360
|
+
raise ScanError("conflicting repeated schema id")
|
|
361
|
+
self.r.schemas.setdefault(sid, {"value": value, "location": loc})
|
|
362
|
+
self._bound()
|
|
363
|
+
|
|
364
|
+
def channel(self, c: _Cursor, loc: dict) -> int:
|
|
365
|
+
cid, value = _parse_channel(c)
|
|
366
|
+
if value["schema_id"] and value["schema_id"] not in self.r.schemas:
|
|
367
|
+
raise ScanError("channel references undefined schema")
|
|
368
|
+
old = self.r.channels.get(cid)
|
|
369
|
+
if old is not None and old["value"] != value:
|
|
370
|
+
raise ScanError("conflicting repeated channel id")
|
|
371
|
+
self.r.channels.setdefault(cid, {"value": value, "location": loc})
|
|
372
|
+
self._bound()
|
|
373
|
+
return cid
|
|
374
|
+
|
|
375
|
+
def metadata(self, c: _Cursor, loc: dict) -> None:
|
|
376
|
+
name, fields = _parse_metadata(c)
|
|
377
|
+
self.r.metadata.append({"name": name, "fields": fields, "location": loc})
|
|
378
|
+
self._bound()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class _Source:
|
|
382
|
+
"""Sequential, strictly bounded file reader that hashes and CRCs every byte."""
|
|
383
|
+
|
|
384
|
+
def __init__(self, f: BinaryIO, size: int, progress: Callable[[int, int], None] | None):
|
|
385
|
+
self.f = f
|
|
386
|
+
self.size = size
|
|
387
|
+
self.pos = 0
|
|
388
|
+
self.sha = hashlib.sha256()
|
|
389
|
+
self.crc = 0
|
|
390
|
+
self.progress = progress
|
|
391
|
+
self._next_progress = 8 * MiB
|
|
392
|
+
|
|
393
|
+
def read(self, n: int) -> bytes:
|
|
394
|
+
if n < 0 or n > self.size - self.pos:
|
|
395
|
+
raise ScanError("record extends beyond end of file")
|
|
396
|
+
data = self.f.read(n)
|
|
397
|
+
if len(data) != n:
|
|
398
|
+
raise ScanError("unexpected end of file")
|
|
399
|
+
self.pos += n
|
|
400
|
+
self.sha.update(data)
|
|
401
|
+
self.crc = zlib.crc32(data, self.crc)
|
|
402
|
+
if self.progress is not None and self.pos >= self._next_progress:
|
|
403
|
+
self._next_progress = self.pos + 8 * MiB
|
|
404
|
+
self.progress(self.pos, self.size)
|
|
405
|
+
return data
|
|
406
|
+
|
|
407
|
+
def skip(self, n: int) -> None:
|
|
408
|
+
if n < 0 or n > self.size - self.pos:
|
|
409
|
+
raise ScanError("record extends beyond end of file")
|
|
410
|
+
while n:
|
|
411
|
+
step = min(n, 4 * MiB)
|
|
412
|
+
self.read(step)
|
|
413
|
+
n -= step
|
|
414
|
+
|
|
415
|
+
def drain(self) -> None:
|
|
416
|
+
"""Hash the rest of the file after a failed scan, so the report identifies all bytes."""
|
|
417
|
+
try:
|
|
418
|
+
self.skip(self.size - self.pos)
|
|
419
|
+
except (ScanError, OSError):
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# --------------------------------------------------------------------------- full scan
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def scan_full(
|
|
427
|
+
f: BinaryIO,
|
|
428
|
+
size: int,
|
|
429
|
+
plan: Callable[[str], TrackConfig | None],
|
|
430
|
+
limits: Limits | None = None,
|
|
431
|
+
progress: Callable[[int, int], None] | None = None,
|
|
432
|
+
) -> ScanResult:
|
|
433
|
+
"""Read every record. Raises ScanError (with ``.result`` partially filled) on failure."""
|
|
434
|
+
limits = limits or Limits()
|
|
435
|
+
started = time.monotonic()
|
|
436
|
+
r = ScanResult(mode="full", size=size)
|
|
437
|
+
r.crc = {
|
|
438
|
+
"data_section": "absent",
|
|
439
|
+
"chunk_present_verified": 0,
|
|
440
|
+
"chunk_absent": 0,
|
|
441
|
+
"summary": "no summary section",
|
|
442
|
+
"attachments": "not checked",
|
|
443
|
+
}
|
|
444
|
+
r.compression = {}
|
|
445
|
+
reg = _Registry(r, limits)
|
|
446
|
+
src = _Source(f, size, progress)
|
|
447
|
+
stats = {"records": 0, "messages": 0, "unchunked_messages": 0, "chunks": 0, "attachments_skipped": 0}
|
|
448
|
+
r.stats = stats
|
|
449
|
+
tracks = r.tracks
|
|
450
|
+
tracked = 0
|
|
451
|
+
ev = limits.evidence_per_finding
|
|
452
|
+
ended = False
|
|
453
|
+
header_seen = False
|
|
454
|
+
summary_crc_start = None
|
|
455
|
+
|
|
456
|
+
def on_message(buf, body, end, a: int, b: int) -> None:
|
|
457
|
+
nonlocal tracked
|
|
458
|
+
if end - body < 22:
|
|
459
|
+
raise ScanError("message record too short")
|
|
460
|
+
cid, seq, log_t, pub_t = _MSG.unpack_from(buf, body)
|
|
461
|
+
tr = tracks.get(cid)
|
|
462
|
+
if ended or tr is None:
|
|
463
|
+
raise ScanError("message after DataEnd or undefined channel")
|
|
464
|
+
stats["messages"] += 1
|
|
465
|
+
if stats["messages"] > limits.max_messages:
|
|
466
|
+
raise ScanError(f"message count exceeds {limits.max_messages}")
|
|
467
|
+
if tr.cfg is None:
|
|
468
|
+
tr.count += 1
|
|
469
|
+
return
|
|
470
|
+
if tr.times is not None:
|
|
471
|
+
tracked += 1
|
|
472
|
+
if tracked > limits.max_tracked_messages:
|
|
473
|
+
raise ScanError(
|
|
474
|
+
f"gap-check timestamp storage exceeds {limits.max_tracked_messages} messages; "
|
|
475
|
+
"raise the max_tracked_messages limit"
|
|
476
|
+
)
|
|
477
|
+
tr.add((stats["messages"], a, b, log_t, pub_t, seq))
|
|
478
|
+
|
|
479
|
+
def on_channel(c: _Cursor, loc: dict) -> None:
|
|
480
|
+
cid = reg.channel(c, loc)
|
|
481
|
+
if cid not in tracks:
|
|
482
|
+
tracks[cid] = Track(cid, plan(r.channels[cid]["value"]["topic"]), ev)
|
|
483
|
+
|
|
484
|
+
def count_record() -> None:
|
|
485
|
+
stats["records"] += 1
|
|
486
|
+
if stats["records"] > limits.max_records:
|
|
487
|
+
raise ScanError("record count exceeds limit")
|
|
488
|
+
if limits.max_seconds is not None and (stats["records"] & 0x3FF) == 0:
|
|
489
|
+
if time.monotonic() - started > limits.max_seconds:
|
|
490
|
+
raise ScanTimeout(f"scan time exceeds {limits.max_seconds} seconds")
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
if src.read(8) != MAGIC:
|
|
494
|
+
raise ScanError("not an MCAP file (bad magic)")
|
|
495
|
+
while True:
|
|
496
|
+
if size - src.pos < 9:
|
|
497
|
+
raise ScanError("unexpected end of file (missing footer)")
|
|
498
|
+
crc_before = src.crc
|
|
499
|
+
offset = src.pos
|
|
500
|
+
head = src.read(9)
|
|
501
|
+
op = head[0]
|
|
502
|
+
length = _U64.unpack_from(head, 1)[0]
|
|
503
|
+
count_record()
|
|
504
|
+
if not header_seen and op != OP_HEADER:
|
|
505
|
+
raise ScanError("first record must be Header")
|
|
506
|
+
loc = {"file_offset": offset}
|
|
507
|
+
if op == OP_FOOTER:
|
|
508
|
+
if length < 20:
|
|
509
|
+
raise ScanError("footer record too short")
|
|
510
|
+
body = src.read(16)
|
|
511
|
+
summary_start = _U64.unpack_from(body, 0)[0]
|
|
512
|
+
crc_mid = src.crc
|
|
513
|
+
summary_crc = _U32.unpack_from(src.read(4), 0)[0]
|
|
514
|
+
src.skip(length - 20)
|
|
515
|
+
if summary_start == 0:
|
|
516
|
+
r.crc["summary"] = "no summary section"
|
|
517
|
+
elif summary_start != summary_crc_start:
|
|
518
|
+
r.crc["summary"] = "not checked"
|
|
519
|
+
elif summary_crc == 0:
|
|
520
|
+
r.crc["summary"] = "absent"
|
|
521
|
+
elif summary_crc != crc_mid:
|
|
522
|
+
raise ScanError("summary CRC mismatch")
|
|
523
|
+
else:
|
|
524
|
+
r.crc["summary"] = "verified"
|
|
525
|
+
if src.read(8) != MAGIC:
|
|
526
|
+
raise ScanError("missing trailing magic")
|
|
527
|
+
if src.pos != size:
|
|
528
|
+
raise ScanError("trailing bytes after footer magic")
|
|
529
|
+
break
|
|
530
|
+
if op not in (OP_HEADER, OP_SCHEMA, OP_CHANNEL, OP_MESSAGE, OP_CHUNK, OP_METADATA, OP_DATA_END):
|
|
531
|
+
src.skip(length)
|
|
532
|
+
if op == OP_ATTACHMENT:
|
|
533
|
+
stats["attachments_skipped"] += 1
|
|
534
|
+
elif op not in KNOWN_OPCODES:
|
|
535
|
+
r.unknown += 1
|
|
536
|
+
continue
|
|
537
|
+
if length > limits.max_record_bytes:
|
|
538
|
+
raise ScanError(f"record length exceeds {limits.max_record_bytes} byte limit")
|
|
539
|
+
buf = src.read(length)
|
|
540
|
+
c = _Cursor(buf)
|
|
541
|
+
if op == OP_HEADER:
|
|
542
|
+
if header_seen:
|
|
543
|
+
raise ScanError("repeated Header")
|
|
544
|
+
header_seen = True
|
|
545
|
+
r.profile, _library = _parse_header(c)
|
|
546
|
+
elif op == OP_SCHEMA:
|
|
547
|
+
reg.schema(c, loc)
|
|
548
|
+
elif op == OP_CHANNEL:
|
|
549
|
+
on_channel(c, loc)
|
|
550
|
+
elif op == OP_MESSAGE:
|
|
551
|
+
stats["unchunked_messages"] += 1
|
|
552
|
+
on_message(buf, 0, length, offset, -1)
|
|
553
|
+
elif op == OP_METADATA:
|
|
554
|
+
if not ended:
|
|
555
|
+
reg.metadata(c, loc)
|
|
556
|
+
elif op == OP_DATA_END:
|
|
557
|
+
if ended:
|
|
558
|
+
raise ScanError("repeated DataEnd")
|
|
559
|
+
ended = True
|
|
560
|
+
data_crc = c.u32()
|
|
561
|
+
if data_crc and data_crc != crc_before:
|
|
562
|
+
raise ScanError("data section CRC mismatch")
|
|
563
|
+
r.crc["data_section"] = "verified" if data_crc else "absent"
|
|
564
|
+
src.crc = 0
|
|
565
|
+
summary_crc_start = src.pos
|
|
566
|
+
elif op == OP_CHUNK:
|
|
567
|
+
if ended:
|
|
568
|
+
raise ScanError("chunk after DataEnd")
|
|
569
|
+
stats["chunks"] += 1
|
|
570
|
+
c.u64() # message_start_time
|
|
571
|
+
c.u64() # message_end_time
|
|
572
|
+
usize = c.u64()
|
|
573
|
+
ucrc = c.u32()
|
|
574
|
+
compression = c.string()
|
|
575
|
+
n = c.u64()
|
|
576
|
+
p = c.take(n)
|
|
577
|
+
if compression not in SUPPORTED_COMPRESSION:
|
|
578
|
+
raise ScanError(f"unsupported compression: {compression[:40]!r}")
|
|
579
|
+
if compression == "":
|
|
580
|
+
if usize != n:
|
|
581
|
+
raise ScanError("chunk size inconsistent with uncompressed size")
|
|
582
|
+
inner = memoryview(buf)[p : p + n]
|
|
583
|
+
else:
|
|
584
|
+
if usize > limits.max_chunk_uncompressed_bytes:
|
|
585
|
+
raise ScanError(
|
|
586
|
+
f"chunk declares {usize} uncompressed bytes, above the "
|
|
587
|
+
f"{limits.max_chunk_uncompressed_bytes} byte limit"
|
|
588
|
+
)
|
|
589
|
+
inner = decompress(compression, buf[p : p + n], usize)
|
|
590
|
+
r.compression[compression or "none"] = r.compression.get(compression or "none", 0) + 1
|
|
591
|
+
if ucrc:
|
|
592
|
+
if zlib.crc32(inner) != ucrc:
|
|
593
|
+
raise ScanError("chunk CRC mismatch")
|
|
594
|
+
r.crc["chunk_present_verified"] += 1
|
|
595
|
+
else:
|
|
596
|
+
r.crc["chunk_absent"] += 1
|
|
597
|
+
for ioff, iop, ib, ie in iter_records(inner):
|
|
598
|
+
count_record()
|
|
599
|
+
if iop == OP_MESSAGE:
|
|
600
|
+
on_message(inner, ib, ie, offset, ioff)
|
|
601
|
+
elif iop == OP_CHANNEL:
|
|
602
|
+
on_channel(_Cursor(inner, ib, ie), {"chunk_file_offset": offset, "chunk_inner_offset": ioff})
|
|
603
|
+
elif iop == OP_SCHEMA:
|
|
604
|
+
reg.schema(_Cursor(inner, ib, ie), {"chunk_file_offset": offset, "chunk_inner_offset": ioff})
|
|
605
|
+
elif iop in KNOWN_OPCODES:
|
|
606
|
+
raise ScanError("unsupported record inside chunk")
|
|
607
|
+
else:
|
|
608
|
+
r.unknown += 1
|
|
609
|
+
r.sha256 = src.sha.hexdigest()
|
|
610
|
+
r.stats["seconds"] = round(time.monotonic() - started, 3)
|
|
611
|
+
return r
|
|
612
|
+
except (ScanError, ValueError, OverflowError, MemoryError, struct.error) as e:
|
|
613
|
+
if not isinstance(e, (ScanTimeout, MemoryError)):
|
|
614
|
+
src.drain()
|
|
615
|
+
r.sha256 = src.sha.hexdigest() if src.pos == size else None
|
|
616
|
+
err = e if isinstance(e, ScanError) else ScanError(f"{type(e).__name__}: {e}")
|
|
617
|
+
err.result = r # type: ignore[attr-defined]
|
|
618
|
+
raise err from (None if err is e else e)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# --------------------------------------------------------------------------- fast scan
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def scan_fast(f: BinaryIO, size: int, limits: Limits | None = None) -> ScanResult:
|
|
625
|
+
"""Read header, footer and summary only. Sets ``fast_fallback`` when a full scan is needed."""
|
|
626
|
+
limits = limits or Limits()
|
|
627
|
+
started = time.monotonic()
|
|
628
|
+
r = ScanResult(mode="fast", size=size)
|
|
629
|
+
r.crc = {"data_section": "not checked (fast mode)", "chunk_present_verified": 0, "chunk_absent": 0,
|
|
630
|
+
"summary": "no summary section", "attachments": "not checked"} # fmt: skip
|
|
631
|
+
reg = _Registry(r, limits)
|
|
632
|
+
|
|
633
|
+
def read_at(offset: int, n: int) -> bytes:
|
|
634
|
+
if offset < 0 or n < 0 or offset + n > size:
|
|
635
|
+
raise ScanError("summary reference outside the file")
|
|
636
|
+
f.seek(offset)
|
|
637
|
+
data = f.read(n)
|
|
638
|
+
if len(data) != n:
|
|
639
|
+
raise ScanError("unexpected end of file")
|
|
640
|
+
return data
|
|
641
|
+
|
|
642
|
+
if size < 8 + 9 + FOOTER_RECORD_SIZE + 8:
|
|
643
|
+
raise ScanError("file too small to be MCAP")
|
|
644
|
+
head = read_at(0, 17)
|
|
645
|
+
if head[:8] != MAGIC:
|
|
646
|
+
raise ScanError("not an MCAP file (bad magic)")
|
|
647
|
+
if head[8] != OP_HEADER:
|
|
648
|
+
raise ScanError("first record must be Header")
|
|
649
|
+
hlen = _U64.unpack_from(head, 9)[0]
|
|
650
|
+
if hlen > limits.max_record_bytes:
|
|
651
|
+
raise ScanError(f"record length exceeds {limits.max_record_bytes} byte limit")
|
|
652
|
+
r.profile, _library = _parse_header(_Cursor(read_at(17, hlen)))
|
|
653
|
+
header_end = 17 + hlen
|
|
654
|
+
|
|
655
|
+
footer_at = size - 8 - FOOTER_RECORD_SIZE
|
|
656
|
+
tail = read_at(footer_at, FOOTER_RECORD_SIZE + 8)
|
|
657
|
+
if tail[-8:] != MAGIC:
|
|
658
|
+
raise ScanError("missing trailing magic")
|
|
659
|
+
if tail[0] != OP_FOOTER or _U64.unpack_from(tail, 1)[0] != 20:
|
|
660
|
+
r.fast_fallback = "footer is not at the standard position"
|
|
661
|
+
return r
|
|
662
|
+
summary_start, _summary_offset_start = struct.unpack_from("<QQ", tail, 9)
|
|
663
|
+
summary_crc = _U32.unpack_from(tail, 25)[0]
|
|
664
|
+
if summary_start == 0:
|
|
665
|
+
r.fast_fallback = "file has no summary section"
|
|
666
|
+
return r
|
|
667
|
+
if not header_end <= summary_start <= footer_at:
|
|
668
|
+
raise ScanError("summary_start outside the file")
|
|
669
|
+
if footer_at - summary_start > limits.max_summary_bytes:
|
|
670
|
+
raise ScanError(f"summary section exceeds {limits.max_summary_bytes} byte limit")
|
|
671
|
+
buf = read_at(summary_start, footer_at - summary_start)
|
|
672
|
+
if summary_crc:
|
|
673
|
+
if zlib.crc32(tail[:25], zlib.crc32(buf)) != summary_crc:
|
|
674
|
+
raise ScanError("summary CRC mismatch")
|
|
675
|
+
r.crc["summary"] = "verified"
|
|
676
|
+
else:
|
|
677
|
+
r.crc["summary"] = "absent"
|
|
678
|
+
|
|
679
|
+
statistics = None
|
|
680
|
+
metadata_index: list[tuple[int, int]] = []
|
|
681
|
+
chunk_count = 0
|
|
682
|
+
for off, op, b, e in iter_records(buf):
|
|
683
|
+
loc = {"file_offset": summary_start + off}
|
|
684
|
+
c = _Cursor(buf, b, e)
|
|
685
|
+
if op == OP_SCHEMA:
|
|
686
|
+
reg.schema(c, loc)
|
|
687
|
+
elif op == OP_CHANNEL:
|
|
688
|
+
reg.channel(c, loc)
|
|
689
|
+
elif op == OP_STATISTICS:
|
|
690
|
+
statistics = {
|
|
691
|
+
"message_count": c.u64(), "schema_count": c.u16(), "channel_count": c.u32(),
|
|
692
|
+
"attachment_count": c.u32(), "metadata_count": c.u32(), "chunk_count": c.u32(),
|
|
693
|
+
"message_start_time": c.u64(), "message_end_time": c.u64(),
|
|
694
|
+
"channel_message_counts": c.u16_u64_map(),
|
|
695
|
+
} # fmt: skip
|
|
696
|
+
elif op == OP_METADATA_INDEX:
|
|
697
|
+
metadata_index.append((c.u64(), c.u64()))
|
|
698
|
+
elif op == OP_CHUNK_INDEX:
|
|
699
|
+
chunk_count += 1
|
|
700
|
+
c.take(8 * 4) # times, chunk offset, chunk length
|
|
701
|
+
c.take(c.u32()) # message index offsets
|
|
702
|
+
c.u64() # message index length
|
|
703
|
+
name = c.string() or "none"
|
|
704
|
+
r.compression[name] = r.compression.get(name, 0) + 1
|
|
705
|
+
elif op in (OP_ATTACHMENT_INDEX, OP_SUMMARY_OFFSET):
|
|
706
|
+
pass
|
|
707
|
+
elif op in KNOWN_OPCODES:
|
|
708
|
+
raise ScanError("unexpected record type in summary section")
|
|
709
|
+
else:
|
|
710
|
+
r.unknown += 1
|
|
711
|
+
|
|
712
|
+
if statistics is None:
|
|
713
|
+
r.fast_fallback = "summary has no Statistics record"
|
|
714
|
+
return r
|
|
715
|
+
if len(r.channels) < statistics["channel_count"]:
|
|
716
|
+
r.fast_fallback = "summary does not repeat every channel"
|
|
717
|
+
return r
|
|
718
|
+
if len(metadata_index) < statistics["metadata_count"]:
|
|
719
|
+
r.fast_fallback = "summary does not index every metadata record"
|
|
720
|
+
return r
|
|
721
|
+
|
|
722
|
+
for offset, length in metadata_index:
|
|
723
|
+
if not 9 <= length <= limits.max_record_bytes:
|
|
724
|
+
raise ScanError("metadata index length invalid or above limit")
|
|
725
|
+
rec = read_at(offset, length)
|
|
726
|
+
if rec[0] != OP_METADATA or _U64.unpack_from(rec, 1)[0] != length - 9:
|
|
727
|
+
raise ScanError("metadata index does not point to a Metadata record")
|
|
728
|
+
reg.metadata(_Cursor(rec, 9), {"file_offset": offset})
|
|
729
|
+
|
|
730
|
+
r.counts = {cid: statistics["channel_message_counts"].get(cid, 0) for cid in r.channels}
|
|
731
|
+
r.stats = {
|
|
732
|
+
"records": None, "messages": statistics["message_count"], "chunks": statistics["chunk_count"],
|
|
733
|
+
"attachments_skipped": statistics["attachment_count"], "seconds": round(time.monotonic() - started, 3),
|
|
734
|
+
} # fmt: skip
|
|
735
|
+
return r
|