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.
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