bdo-toolkit 1.0.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.
Files changed (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,654 @@
1
+ """Per-flow TCP stream reassembly feeding a stream scanner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from dataclasses import dataclass, field
7
+ from threading import Lock, RLock
8
+ from typing import Callable, Optional, Protocol
9
+
10
+ from ._protocol import (
11
+ GAP_RESET_SECONDS,
12
+ MAX_PENDING_SEGMENTS,
13
+ MAX_TARGET_MESSAGE_LENGTH,
14
+ TCP_SEQUENCE_HALF_RANGE,
15
+ TCP_SEQUENCE_MODULUS,
16
+ FlowKey,
17
+ PacketContext,
18
+ )
19
+
20
+
21
+ # When capture begins after the SYN, the first callback can be a later TCP
22
+ # segment that raced ahead of its prefix. Hold a bounded initial reorder set
23
+ # until framing proves an origin or an owning root services the grace period;
24
+ # capacity pressure and finish() provide the remaining bounded release paths.
25
+ _INITIAL_REORDER_GRACE_SECONDS = 0.25
26
+ _INITIAL_ANCHOR_PROBE_BYTES = (MAX_TARGET_MESSAGE_LENGTH * 2) + 5
27
+
28
+
29
+ class StreamScanner(Protocol):
30
+ """Anything that can consume reassembled stream bytes."""
31
+
32
+ def feed(self, data: bytes, context: PacketContext) -> None: ...
33
+
34
+ def scan_standalone(self, data: bytes, context: PacketContext) -> None: ...
35
+
36
+ def reset(self) -> None: ...
37
+
38
+
39
+ @dataclass
40
+ class PendingSegment:
41
+ data: bytes
42
+ context: PacketContext
43
+
44
+
45
+ @dataclass
46
+ class TCPFlowState:
47
+ scanner: StreamScanner
48
+ max_pending_segments: int = MAX_PENDING_SEGMENTS
49
+ max_pending_bytes: Optional[int] = None
50
+ next_sequence: Optional[int] = None
51
+ pending: dict[int, PendingSegment] = field(default_factory=dict)
52
+ unanchored: dict[int, PendingSegment] = field(default_factory=dict)
53
+ unanchored_started_at: Optional[float] = None
54
+ gap_started_at: Optional[float] = None
55
+ generation: int = 0
56
+ on_gap_reset: Optional[Callable[[], None]] = None
57
+ last_activity_at: Optional[float] = None
58
+ fin_sequence: Optional[int] = None
59
+ fin_observed_at: Optional[float] = None
60
+
61
+ def reset(self) -> None:
62
+ self.next_sequence = None
63
+ self.pending.clear()
64
+ self._clear_unanchored()
65
+ self.gap_started_at = None
66
+ self.fin_sequence = None
67
+ self.fin_observed_at = None
68
+ self.scanner.reset()
69
+
70
+ def anchor_sequence(self, sequence: int) -> None:
71
+ """Record a credible next-payload sequence without delivering bytes.
72
+
73
+ An empty SYN is enough to establish this anchor because SYN consumes
74
+ one TCP sequence number. Keeping the anchor lets a later payload that
75
+ arrives out of order wait for its missing prefix instead of becoming
76
+ the stream's accidental starting point.
77
+ """
78
+ if self.next_sequence is None:
79
+ if self.unanchored:
80
+ self._commit_unanchored(sequence & 0xFFFFFFFF)
81
+ else:
82
+ self.next_sequence = sequence & 0xFFFFFFFF
83
+
84
+ def add_segment(
85
+ self, sequence: int, payload: bytes, context: PacketContext
86
+ ) -> None:
87
+ if not payload:
88
+ return
89
+
90
+ if self.next_sequence is None:
91
+ self._add_unanchored_segment(sequence, payload, context)
92
+ return
93
+
94
+ self._add_anchored_segment(sequence, payload, context)
95
+
96
+ def mark_fin(self, sequence: int, timestamp: float) -> None:
97
+ """Remember the first sequence after all payload preceding FIN."""
98
+
99
+ self.fin_sequence = sequence & 0xFFFFFFFF
100
+ self.fin_observed_at = timestamp
101
+ self._start_fin_gap_timer_if_needed()
102
+
103
+ def _start_fin_gap_timer_if_needed(self) -> None:
104
+ if (
105
+ self.fin_sequence is None
106
+ or self.fin_observed_at is None
107
+ or self.next_sequence is None
108
+ or self.unanchored
109
+ or self.pending
110
+ ):
111
+ return
112
+ fin_sequence = _unwrap_tcp_sequence(
113
+ self.fin_sequence,
114
+ self.next_sequence,
115
+ )
116
+ if self.next_sequence < fin_sequence and self.gap_started_at is None:
117
+ self.gap_started_at = self.fin_observed_at
118
+
119
+ def fin_is_reassembled(self) -> bool:
120
+ """Return whether every observed range through FIN is now contiguous."""
121
+
122
+ if (
123
+ self.fin_sequence is None
124
+ or self.next_sequence is None
125
+ or self.unanchored
126
+ or self.pending
127
+ ):
128
+ return False
129
+ return self.next_sequence >= _unwrap_tcp_sequence(
130
+ self.fin_sequence,
131
+ self.next_sequence,
132
+ )
133
+
134
+ def _add_unanchored_segment(
135
+ self, sequence: int, payload: bytes, context: PacketContext
136
+ ) -> None:
137
+ """Establish a credible stream origin when no SYN was captured."""
138
+ sequence &= 0xFFFFFFFF
139
+ previous = self.unanchored.get(sequence)
140
+ if previous is None or len(payload) > len(previous.data):
141
+ self.unanchored[sequence] = PendingSegment(payload, context)
142
+ if self.unanchored_started_at is None:
143
+ self.unanchored_started_at = context.timestamp
144
+
145
+ ordered = self._ordered_unanchored()
146
+ start_sequence, probe = self._contiguous_unanchored_probe(ordered)
147
+ can_anchor = getattr(self.scanner, "can_anchor_at_start", None)
148
+ if can_anchor is not None and can_anchor(probe):
149
+ self._commit_unanchored(start_sequence)
150
+ elif self._pending_capacity_exceeded(self.unanchored):
151
+ # Preserve the same hard bounds as ordinary gap buffering.
152
+ # Under pressure, the earliest observed byte is the least-bad
153
+ # origin and the scanner's normal resynchronization remains active.
154
+ self._commit_unanchored(start_sequence)
155
+
156
+ def _pending_capacity_exceeded(
157
+ self,
158
+ segments: dict[int, PendingSegment],
159
+ ) -> bool:
160
+ if len(segments) > self.max_pending_segments:
161
+ return True
162
+ return (
163
+ self.max_pending_bytes is not None
164
+ and sum(len(segment.data) for segment in segments.values())
165
+ > self.max_pending_bytes
166
+ )
167
+
168
+ def _ordered_unanchored(self) -> list[tuple[int, PendingSegment]]:
169
+ if not self.unanchored:
170
+ return []
171
+ reference = next(iter(self.unanchored))
172
+ ordered = sorted(
173
+ (
174
+ _unwrap_tcp_sequence(sequence, reference),
175
+ segment,
176
+ )
177
+ for sequence, segment in self.unanchored.items()
178
+ )
179
+ if ordered[0][0] < 0:
180
+ ordered = [
181
+ (sequence + TCP_SEQUENCE_MODULUS, segment)
182
+ for sequence, segment in ordered
183
+ ]
184
+ return ordered
185
+
186
+ @staticmethod
187
+ def _contiguous_unanchored_probe(
188
+ ordered: list[tuple[int, PendingSegment]],
189
+ ) -> tuple[int, bytes]:
190
+ assert ordered
191
+ start_sequence = ordered[0][0]
192
+ cursor = start_sequence
193
+ probe = bytearray()
194
+ for sequence, segment in ordered:
195
+ if sequence > cursor:
196
+ break
197
+ overlap = max(0, cursor - sequence)
198
+ if overlap >= len(segment.data):
199
+ continue
200
+ remaining = segment.data[overlap:]
201
+ capacity = _INITIAL_ANCHOR_PROBE_BYTES - len(probe)
202
+ if capacity <= 0:
203
+ break
204
+ probe.extend(remaining[:capacity])
205
+ cursor = max(cursor, sequence + len(segment.data))
206
+ if len(remaining) > capacity:
207
+ break
208
+ return start_sequence, bytes(probe)
209
+
210
+ def _commit_unanchored(self, sequence: Optional[int] = None) -> None:
211
+ # Origin commitment is intentionally one-way. Bytes already delivered
212
+ # to a scanner cannot later be retracted and joined to an older prefix.
213
+ if not self.unanchored:
214
+ return
215
+ ordered = self._ordered_unanchored()
216
+ if sequence is None:
217
+ sequence = ordered[0][0]
218
+ self._clear_unanchored()
219
+ self.next_sequence = sequence
220
+ for segment_sequence, segment in ordered:
221
+ self._add_anchored_segment(
222
+ segment_sequence,
223
+ segment.data,
224
+ segment.context,
225
+ scan_retransmission=False,
226
+ )
227
+
228
+ def _clear_unanchored(self) -> None:
229
+ self.unanchored.clear()
230
+ self.unanchored_started_at = None
231
+
232
+ def _add_anchored_segment(
233
+ self,
234
+ sequence: int,
235
+ payload: bytes,
236
+ context: PacketContext,
237
+ *,
238
+ scan_retransmission: bool = True,
239
+ ) -> None:
240
+ assert self.next_sequence is not None
241
+ sequence = _unwrap_tcp_sequence(sequence, self.next_sequence)
242
+
243
+ # Ignore bytes already delivered by an earlier copy/retransmission.
244
+ if sequence < self.next_sequence:
245
+ overlap = self.next_sequence - sequence
246
+ overlap_context = PacketContext(
247
+ timestamp=context.timestamp,
248
+ flow=context.flow,
249
+ stream_start=sequence,
250
+ flow_generation=context.flow_generation,
251
+ )
252
+ if overlap >= len(payload):
253
+ # Local Windows captures can occasionally present a complete
254
+ # earlier segment after a later sequence number. It cannot
255
+ # advance the reassembled stream, but it may still contain a
256
+ # self-contained target frame worth scanning.
257
+ if scan_retransmission:
258
+ self.scanner.scan_standalone(payload, overlap_context)
259
+ return
260
+ if scan_retransmission:
261
+ self.scanner.scan_standalone(payload[:overlap], overlap_context)
262
+ payload = payload[overlap:]
263
+ sequence = self.next_sequence
264
+
265
+ if sequence > self.next_sequence:
266
+ previous = self.pending.get(sequence)
267
+ if previous is None or len(payload) > len(previous.data):
268
+ self.pending[sequence] = PendingSegment(payload, context)
269
+ if self.gap_started_at is None:
270
+ self.gap_started_at = context.timestamp
271
+
272
+ # A local capture should rarely lose TCP segments. If it does, do
273
+ # not remain blocked forever: after a short gap, restart at the
274
+ # earliest available segment and let the target scanner resync.
275
+ if self._pending_capacity_exceeded(self.pending):
276
+ # One restart may expose another later gap whose retained
277
+ # segment is itself larger than the configured byte ceiling.
278
+ # Keep making bounded progress until the retained map is back
279
+ # within both limits. Every restart remains observable as
280
+ # capture loss through the ordinary gap-reset accounting.
281
+ while self._pending_capacity_exceeded(self.pending):
282
+ self._resume_after_gap()
283
+ else:
284
+ self.service_gaps(context.timestamp)
285
+ return
286
+
287
+ self._deliver(payload, context)
288
+ self._flush_pending()
289
+
290
+ def _deliver(self, payload: bytes, context: PacketContext) -> None:
291
+ assert self.next_sequence is not None
292
+ delivery_context = PacketContext(
293
+ timestamp=context.timestamp,
294
+ flow=context.flow,
295
+ stream_start=self.next_sequence,
296
+ flow_generation=context.flow_generation,
297
+ )
298
+ self.scanner.feed(payload, delivery_context)
299
+ self.next_sequence += len(payload)
300
+ self.gap_started_at = None
301
+
302
+ def _flush_pending(self) -> None:
303
+ assert self.next_sequence is not None
304
+
305
+ while self.pending:
306
+ sequence = min(self.pending)
307
+ segment = self.pending[sequence]
308
+
309
+ if sequence > self.next_sequence:
310
+ if self.gap_started_at is None:
311
+ self.gap_started_at = min(
312
+ pending.context.timestamp
313
+ for pending in self.pending.values()
314
+ )
315
+ return
316
+
317
+ del self.pending[sequence]
318
+ payload = segment.data
319
+
320
+ if sequence < self.next_sequence:
321
+ overlap = self.next_sequence - sequence
322
+ if overlap >= len(payload):
323
+ continue
324
+ payload = payload[overlap:]
325
+
326
+ self._deliver(payload, segment.context)
327
+
328
+ def service_gaps(self, now: float) -> int:
329
+ """Release gaps whose timeout elapsed, even without a new packet.
330
+
331
+ Returns the number of scanner resets performed. More than one reset
332
+ is possible when several independently missing ranges are already old
333
+ enough at the supplied clock value.
334
+ """
335
+ resets = 0
336
+ if (
337
+ self.unanchored
338
+ and self.unanchored_started_at is not None
339
+ and now - self.unanchored_started_at
340
+ >= _INITIAL_REORDER_GRACE_SECONDS
341
+ ):
342
+ self._commit_unanchored()
343
+ while (
344
+ self.pending
345
+ and self.gap_started_at is not None
346
+ and now - self.gap_started_at >= GAP_RESET_SECONDS
347
+ ):
348
+ self._resume_after_gap()
349
+ resets += 1
350
+ self._start_fin_gap_timer_if_needed()
351
+ if (
352
+ not self.pending
353
+ and not self.unanchored
354
+ and self.fin_sequence is not None
355
+ and self.next_sequence is not None
356
+ and self.gap_started_at is not None
357
+ and now - self.gap_started_at >= GAP_RESET_SECONDS
358
+ ):
359
+ fin_sequence = _unwrap_tcp_sequence(
360
+ self.fin_sequence,
361
+ self.next_sequence,
362
+ )
363
+ if self.next_sequence < fin_sequence:
364
+ self.scanner.reset()
365
+ if self.on_gap_reset is not None:
366
+ self.on_gap_reset()
367
+ self.next_sequence = fin_sequence
368
+ self.gap_started_at = None
369
+ resets += 1
370
+ return resets
371
+
372
+ def _resume_after_gap(self) -> None:
373
+ if not self.pending:
374
+ return
375
+
376
+ sequence = min(self.pending)
377
+ segment = self.pending.pop(sequence)
378
+ self.scanner.reset()
379
+ if self.on_gap_reset is not None:
380
+ self.on_gap_reset()
381
+ self.next_sequence = sequence
382
+ self.gap_started_at = None
383
+ self._deliver(segment.data, segment.context)
384
+ self._flush_pending()
385
+
386
+ def finish(self) -> None:
387
+ """Drain segments still pending at end of capture.
388
+
389
+ Without this, a capture that ends during a sequence gap would strand
390
+ complete, decodable frames in ``pending`` forever; the gap timer only
391
+ fires when another packet arrives on the flow.
392
+ """
393
+ self._commit_unanchored()
394
+ while self.pending:
395
+ self._resume_after_gap()
396
+
397
+
398
+ class FlowManager:
399
+ """Route server-to-client TCP segments to per-flow reassembly state."""
400
+
401
+ def __init__(
402
+ self,
403
+ *,
404
+ server_ports,
405
+ scanner_factory: Callable[[], StreamScanner],
406
+ max_flows: Optional[int] = None,
407
+ on_flow_eviction: Optional[Callable[[], None]] = None,
408
+ on_flow_close: Optional[Callable[[FlowKey], None]] = None,
409
+ idle_timeout: Optional[float] = None,
410
+ track_flow_generations: bool = False,
411
+ max_pending_segments: int = MAX_PENDING_SEGMENTS,
412
+ max_pending_bytes: Optional[int] = None,
413
+ ) -> None:
414
+ if max_flows is not None and max_flows <= 0:
415
+ raise ValueError("max_flows must be positive or None")
416
+ if idle_timeout is not None and idle_timeout <= 0:
417
+ raise ValueError("idle_timeout must be positive or None")
418
+ if (
419
+ isinstance(max_pending_segments, bool)
420
+ or not isinstance(max_pending_segments, int)
421
+ ):
422
+ raise TypeError("max_pending_segments must be an integer")
423
+ if max_pending_segments <= 0:
424
+ raise ValueError("max_pending_segments must be positive")
425
+ if max_pending_bytes is not None and (
426
+ isinstance(max_pending_bytes, bool)
427
+ or not isinstance(max_pending_bytes, int)
428
+ ):
429
+ raise TypeError("max_pending_bytes must be an integer or None")
430
+ if max_pending_bytes is not None and max_pending_bytes <= 0:
431
+ raise ValueError("max_pending_bytes must be positive or None")
432
+ self.server_ports = frozenset(server_ports)
433
+ self._scanner_factory = scanner_factory
434
+ self._max_flows = max_flows
435
+ self._on_flow_eviction = on_flow_eviction
436
+ self._on_flow_close = on_flow_close
437
+ self._idle_timeout = idle_timeout
438
+ self._track_flow_generations = track_flow_generations
439
+ self._max_pending_segments = max_pending_segments
440
+ self._max_pending_bytes = max_pending_bytes
441
+ self._next_flow_generation = 0
442
+ self._flows: OrderedDict[FlowKey, TCPFlowState] = OrderedDict()
443
+ self._tcp_gap_resets = 0
444
+ # Counter-only: never acquire _lock or protect callbacks/delivery here.
445
+ self._diagnostics_lock = Lock()
446
+ # Packet delivery and an eventual wall-clock service hook may run on
447
+ # different threads. Serialize both paths around the same flow state.
448
+ self._lock = RLock()
449
+
450
+ def _new_flow_state(self) -> TCPFlowState:
451
+ generation = 0
452
+ if self._track_flow_generations:
453
+ self._next_flow_generation += 1
454
+ generation = self._next_flow_generation
455
+ return TCPFlowState(
456
+ scanner=self._scanner_factory(),
457
+ max_pending_segments=self._max_pending_segments,
458
+ max_pending_bytes=self._max_pending_bytes,
459
+ generation=generation,
460
+ on_gap_reset=self._record_gap_reset,
461
+ )
462
+
463
+ def _record_gap_reset(self) -> None:
464
+ with self._diagnostics_lock:
465
+ self._tcp_gap_resets += 1
466
+
467
+ @property
468
+ def tcp_gap_resets(self) -> int:
469
+ """Total capture-gap recoveries, including flows already closed."""
470
+ with self._diagnostics_lock:
471
+ return self._tcp_gap_resets
472
+
473
+ def service_gaps(self, now: float) -> int:
474
+ """Advance pending-gap timers using a caller-supplied wall clock.
475
+
476
+ ``FlowManager`` does not own a timer thread. Live capture roots that
477
+ require wall-clock release should call this during their normal
478
+ poll/tick even when no packets arrived. The return value is the number
479
+ of new resets performed during this call; ``tcp_gap_resets`` is
480
+ cumulative.
481
+ """
482
+ with self._lock:
483
+ before = self.tcp_gap_resets
484
+ completed_fin_flows: list[FlowKey] = []
485
+ for flow, state in self._flows.items():
486
+ state.service_gaps(now)
487
+ if state.fin_is_reassembled():
488
+ completed_fin_flows.append(flow)
489
+ for flow in completed_fin_flows:
490
+ state = self._flows.pop(flow)
491
+ state.finish()
492
+ self._notify_flow_close(flow)
493
+ if self._idle_timeout is not None:
494
+ expired = [
495
+ flow
496
+ for flow, state in self._flows.items()
497
+ if state.last_activity_at is not None
498
+ and now - state.last_activity_at >= self._idle_timeout
499
+ ]
500
+ for flow in expired:
501
+ state = self._flows.pop(flow)
502
+ state.finish()
503
+ self._notify_flow_close(flow)
504
+ return self.tcp_gap_resets - before
505
+
506
+ def _notify_flow_close(self, flow: FlowKey) -> None:
507
+ if self._on_flow_close is not None:
508
+ self._on_flow_close(flow)
509
+
510
+ def process_tcp_segment(
511
+ self,
512
+ *,
513
+ source_ip: str,
514
+ source_port: int,
515
+ destination_ip: str,
516
+ destination_port: int,
517
+ sequence: int,
518
+ payload: bytes,
519
+ timestamp: float,
520
+ syn: bool = False,
521
+ rst: bool = False,
522
+ fin: bool = False,
523
+ ) -> None:
524
+ with self._lock:
525
+ self._process_tcp_segment(
526
+ source_ip=source_ip,
527
+ source_port=source_port,
528
+ destination_ip=destination_ip,
529
+ destination_port=destination_port,
530
+ sequence=sequence,
531
+ payload=payload,
532
+ timestamp=timestamp,
533
+ syn=syn,
534
+ rst=rst,
535
+ fin=fin,
536
+ )
537
+
538
+ def _process_tcp_segment(
539
+ self,
540
+ *,
541
+ source_ip: str,
542
+ source_port: int,
543
+ destination_ip: str,
544
+ destination_port: int,
545
+ sequence: int,
546
+ payload: bytes,
547
+ timestamp: float,
548
+ syn: bool = False,
549
+ rst: bool = False,
550
+ fin: bool = False,
551
+ ) -> None:
552
+ # The observed item events are server-to-client and use a game-server
553
+ # source port. Ignore all other traffic.
554
+ if source_port not in self.server_ports:
555
+ return
556
+
557
+ flow = FlowKey(
558
+ source_ip=source_ip,
559
+ source_port=source_port,
560
+ destination_ip=destination_ip,
561
+ destination_port=destination_port,
562
+ )
563
+
564
+ known_flow = flow in self._flows
565
+ # A capture may contain an ACK or a late FIN/RST for a connection that
566
+ # began before capture or whose state was already closed/evicted. Such
567
+ # a packet cannot contribute stream bytes, so it must not consume a
568
+ # bounded flow slot (or evict an active flow to make room).
569
+ if not known_flow and not payload and not syn:
570
+ return
571
+
572
+ if not known_flow and (
573
+ self._max_flows is not None
574
+ and len(self._flows) >= self._max_flows
575
+ ):
576
+ # OrderedDict order is updated on every observed packet, so the
577
+ # first entry is the least recently active connection.
578
+ oldest, evicted = self._flows.popitem(last=False)
579
+ if self._on_flow_eviction is not None:
580
+ self._on_flow_eviction()
581
+ evicted.finish()
582
+ self._notify_flow_close(oldest)
583
+
584
+ if syn and flow in self._flows:
585
+ previous = self._flows.pop(flow)
586
+ previous.finish()
587
+ self._notify_flow_close(flow)
588
+
589
+ if flow not in self._flows:
590
+ self._flows[flow] = self._new_flow_state()
591
+
592
+ state = self._flows[flow]
593
+ state.last_activity_at = timestamp
594
+ self._flows.move_to_end(flow)
595
+ if syn:
596
+ # SYN itself consumes one sequence number, including when it has
597
+ # no payload. This establishes the first credible stream anchor.
598
+ state.anchor_sequence((sequence + 1) & 0xFFFFFFFF)
599
+ if payload:
600
+ # SYN consumes one sequence number before any TCP Fast Open data.
601
+ payload_sequence = (sequence + (1 if syn else 0)) & 0xFFFFFFFF
602
+ state.add_segment(
603
+ sequence=payload_sequence,
604
+ payload=payload,
605
+ context=PacketContext(
606
+ timestamp=timestamp,
607
+ flow=flow,
608
+ flow_generation=state.generation,
609
+ ),
610
+ )
611
+
612
+ if not rst and state.fin_sequence is not None and state.fin_is_reassembled():
613
+ state.finish()
614
+ self._flows.pop(flow, None)
615
+ self._notify_flow_close(flow)
616
+ return
617
+
618
+ if rst:
619
+ # RST is an abortive close; drain what is already available, then
620
+ # discard the connection state immediately.
621
+ state.finish()
622
+ self._flows.pop(flow, None)
623
+ self._notify_flow_close(flow)
624
+ elif fin:
625
+ # FIN can race ahead of earlier payload callbacks. Keep bounded
626
+ # provisional/gap state until all observed bytes through FIN are
627
+ # contiguous, rather than splitting one frame across two states.
628
+ fin_sequence = (
629
+ sequence + (1 if syn else 0) + len(payload)
630
+ ) & 0xFFFFFFFF
631
+ state.mark_fin(fin_sequence, timestamp)
632
+ if state.fin_is_reassembled():
633
+ state.finish()
634
+ self._flows.pop(flow, None)
635
+ self._notify_flow_close(flow)
636
+
637
+ def finish(self) -> None:
638
+ with self._lock:
639
+ flows = tuple(self._flows.items())
640
+ self._flows.clear()
641
+ for flow, state in flows:
642
+ state.finish()
643
+ self._notify_flow_close(flow)
644
+
645
+
646
+ def _unwrap_tcp_sequence(sequence: int, reference: int) -> int:
647
+ """Map a 32-bit TCP sequence to the nearest absolute value to reference."""
648
+ raw = sequence & 0xFFFFFFFF
649
+ candidate = (reference & ~(TCP_SEQUENCE_MODULUS - 1)) | raw
650
+ if candidate - reference > TCP_SEQUENCE_HALF_RANGE:
651
+ candidate -= TCP_SEQUENCE_MODULUS
652
+ elif reference - candidate > TCP_SEQUENCE_HALF_RANGE:
653
+ candidate += TCP_SEQUENCE_MODULUS
654
+ return candidate