sltmodel 0.1.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.
sltmodel/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """GUI-independent data models and operations for StructLayoutToolkit."""
2
+ from importlib import import_module
3
+
4
+ _EXPORTS = {
5
+ "format_infosize": (".infosize", "format_infosize"),
6
+ "infosize_to_json_data": (".infosize", "infosize_to_json_data"),
7
+ "infosize_value_to_string": (".infosize", "infosize_value_to_string"),
8
+ "parse_infosize_input": (".infosize", "parse_infosize_input"),
9
+ "CaptureDocument": (".packet_data", "CaptureDocument"),
10
+ "PacketFragment": (".packet_data", "PacketFragment"),
11
+ "ReassembledPacket": (".packet_data", "ReassembledPacket"),
12
+ "internet_checksum": (".packet_data", "internet_checksum"),
13
+ "PayloadStructDef": (".payload_struct_defs", "PayloadStructDef"),
14
+ "load_payload_struct_defs": (".payload_struct_defs",
15
+ "load_payload_struct_defs"),
16
+ "matching_struct_layout": (".payload_struct_defs",
17
+ "matching_struct_layout"),
18
+ "packet_eval_env": (".payload_struct_defs", "packet_eval_env"),
19
+ "save_payload_struct_defs": (".payload_struct_defs",
20
+ "save_payload_struct_defs"),
21
+ "field_instance_at_path": (".struct_instance", "field_instance_at_path"),
22
+ "format_field_value": (".struct_instance", "format_field_value"),
23
+ "format_type": (".struct_instance", "format_type"),
24
+ "format_value": (".struct_instance", "format_value"),
25
+ "minimum_struct_size": (".struct_instance", "minimum_struct_size"),
26
+ "parse_field_value": (".struct_instance", "parse_field_value"),
27
+ "parse_value": (".struct_instance", "parse_value"),
28
+ "replace_instance_field_value": (".struct_instance",
29
+ "replace_instance_field_value"),
30
+ "replace_instance_value": (".struct_instance", "replace_instance_value"),
31
+ }
32
+
33
+ __all__ = list(_EXPORTS)
34
+
35
+
36
+ def __getattr__(name: str):
37
+ """Load model APIs only when requested."""
38
+ try:
39
+ module_name, attribute_name = _EXPORTS[name]
40
+ except KeyError as exc:
41
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}"
42
+ ) from exc
43
+ return getattr(import_module(module_name, __name__), attribute_name)
sltmodel/infosize.py ADDED
@@ -0,0 +1,93 @@
1
+ """Shared InfoSize parsing/formatting helpers used by the editor windows.
2
+
3
+ These take the caller's InfoSize class as a parameter instead of importing
4
+ one directly, so callers (and their tests) can supply their own InfoSize
5
+ implementation without this module caching a stale class reference.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+
12
+ def format_infosize(value: object) -> str:
13
+ """Format an InfoSize-like value as its ``byte,bit`` string form."""
14
+ return f"{value.byte},{value.bit}"
15
+
16
+
17
+ def parse_infosize_input(value: object, info_size_cls: type) -> object:
18
+ """Parse ``byte,bit`` input, preserving non-static expressions."""
19
+ if isinstance(value, info_size_cls):
20
+ return value
21
+ if isinstance(value, dict) and value.get("__type__") == "InfoSize":
22
+ return info_size_cls(int(value.get("byte", 0)), int(value.get("bit",
23
+ 0)))
24
+ text = str(value).strip()
25
+ if not text:
26
+ text = "0,0"
27
+ try:
28
+ parsed = json.loads(text)
29
+ except json.JSONDecodeError:
30
+ parsed = None
31
+ if isinstance(parsed, dict) and parsed.get("__type__") == "InfoSize":
32
+ return info_size_cls(int(parsed.get("byte", 0)),
33
+ int(parsed.get("bit", 0)))
34
+ parts = [part.strip() for part in text.split(",")]
35
+ if len(parts) == 1:
36
+ try:
37
+ return info_size_cls(int(parts[0]), 0)
38
+ except ValueError:
39
+ return text
40
+ if len(parts) == 2:
41
+ try:
42
+ byte, bit = (int(part) for part in parts)
43
+ except ValueError:
44
+ return text
45
+ return info_size_cls(byte, bit)
46
+ return text
47
+
48
+
49
+ def infosize_value_to_string(value: object, info_size_cls: type) -> str:
50
+ """Convert an InfoSize-like value to one editable cell string."""
51
+
52
+ def from_dict(item: dict) -> str:
53
+ info_size = info_size_cls(int(item.get("byte", 0)),
54
+ int(item.get("bit", 0)))
55
+ return format_infosize(info_size)
56
+
57
+ def from_string(item: str) -> str:
58
+ stripped = item.strip()
59
+ if not stripped:
60
+ return "0,0"
61
+ try:
62
+ parsed = json.loads(stripped)
63
+ except json.JSONDecodeError:
64
+ try:
65
+ info_size = info_size_cls(int(stripped), 0)
66
+ except ValueError:
67
+ return stripped
68
+ return format_infosize(info_size)
69
+ if isinstance(parsed, dict) and parsed.get("__type__") == "InfoSize":
70
+ return from_dict(parsed)
71
+ return stripped
72
+
73
+ if isinstance(value, info_size_cls):
74
+ return format_infosize(value)
75
+
76
+ handlers = {
77
+ dict:
78
+ lambda item: from_dict(item)
79
+ if item.get("__type__") == "InfoSize" else "0,0",
80
+ str:
81
+ from_string,
82
+ int:
83
+ lambda item: from_dict({
84
+ "byte": item,
85
+ "bit": 0
86
+ }),
87
+ }
88
+ return handlers.get(type(value), lambda _: "0,0")(value)
89
+
90
+
91
+ def infosize_to_json_data(value: object) -> dict:
92
+ """Convert an InfoSize-like instance to its typed JSON dictionary."""
93
+ return json.loads(value.serialize())
@@ -0,0 +1,518 @@
1
+ """Capture packet extraction and IP fragment reassembly."""
2
+ from __future__ import annotations
3
+
4
+ import ipaddress
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from sltcodec import decode
10
+ from sltcore import virtual_bytearray
11
+
12
+ from .resources import load_pcap_layout, load_pcapng_layout
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PacketFragment:
17
+ """Location of one IP payload fragment in the capture file."""
18
+
19
+ sequence: int
20
+ timestamps: dict[str, object]
21
+ capture_offset: int
22
+ payload_offset: int
23
+ length: int
24
+ more_fragments: bool
25
+
26
+
27
+ @dataclass
28
+ class ReassembledPacket:
29
+ """One complete or partial reassembled IP payload."""
30
+
31
+ sequence: int
32
+ timestamps: dict[str, object]
33
+ key: tuple
34
+ ip_version: int
35
+ source_bytes: bytes
36
+ destination_bytes: bytes
37
+ protocol: int
38
+ identification: int | None
39
+ fragments: list[PacketFragment]
40
+ data: virtual_bytearray
41
+ complete: bool
42
+
43
+ @property
44
+ def source(self) -> str:
45
+ """Return the source IP address as a string."""
46
+ return str(ipaddress.ip_address(self.source_bytes))
47
+
48
+ @property
49
+ def destination(self) -> str:
50
+ """Return the destination IP address as a string."""
51
+ return str(ipaddress.ip_address(self.destination_bytes))
52
+
53
+ def replace_data(
54
+ self,
55
+ capture_data: bytearray,
56
+ data: bytes | bytearray | virtual_bytearray,
57
+ ) -> None:
58
+ """Write same-length reassembled data back to all source fragments."""
59
+ if not self.complete:
60
+ raise ValueError("Incomplete fragmented packets cannot be edited.")
61
+ if len(data) != len(self.data):
62
+ raise ValueError("Encoded data must keep the reassembled length.")
63
+
64
+ del capture_data
65
+ if data is not self.data:
66
+ self.data.write_slice(0, data)
67
+ self._update_transport_checksum(self.data)
68
+
69
+ def _update_transport_checksum(self, data: virtual_bytearray) -> None:
70
+ """Update the transport layer checksum for the given data."""
71
+ checksum_offset = {6: 16, 17: 6, 1: 2, 58: 2}.get(self.protocol)
72
+ if checksum_offset is None or len(data) < checksum_offset + 2:
73
+ return
74
+ data.write_slice(checksum_offset, b"\x00\x00")
75
+ if self.protocol == 1:
76
+ checksum_data = data
77
+ elif self.ip_version == 4:
78
+ checksum_data = virtual_bytearray((
79
+ self.source_bytes,
80
+ self.destination_bytes,
81
+ bytes((0, self.protocol)),
82
+ len(data).to_bytes(2, "big"),
83
+ data,
84
+ ))
85
+ else:
86
+ checksum_data = virtual_bytearray((
87
+ self.source_bytes,
88
+ self.destination_bytes,
89
+ len(data).to_bytes(4, "big"),
90
+ b"\x00\x00\x00",
91
+ bytes((self.protocol, )),
92
+ data,
93
+ ))
94
+ checksum = internet_checksum(checksum_data)
95
+ data.write_slice(checksum_offset, checksum.to_bytes(2, "big"))
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class _RawFragment:
100
+ """Representation of a raw IP fragment."""
101
+ sequence: int
102
+ timestamps: dict[str, object]
103
+ key: tuple
104
+ ip_version: int
105
+ source: bytes
106
+ destination: bytes
107
+ protocol: int
108
+ identification: int | None
109
+ offset: int
110
+ more_fragments: bool
111
+ capture_offset: int
112
+ data: virtual_bytearray
113
+
114
+
115
+ _Frame = tuple[int, int, int, dict[str, object], object]
116
+
117
+
118
+ class CaptureDocument:
119
+ """Decoded capture data with reassembled IP payloads."""
120
+
121
+ def __init__(
122
+ self,
123
+ data: bytes | bytearray,
124
+ capture_format: str,
125
+ decode_transform: Callable[[object], object] | None = None,
126
+ *,
127
+ progress_callback: Callable[[float], None] | None = None,
128
+ ) -> None:
129
+ self.data = data if isinstance(data, bytearray) else bytearray(data)
130
+ self.capture_format = capture_format
131
+ layout = (load_pcap_layout()
132
+ if capture_format == "pcap" else load_pcapng_layout())
133
+ report_progress = progress_callback or (lambda _progress: None)
134
+ self.struct_instance = decode(
135
+ layout,
136
+ self.data,
137
+ progress_callback=lambda progress: report_progress(progress * 0.65),
138
+ )
139
+ if decode_transform is not None:
140
+ self.struct_instance = decode_transform(self.struct_instance)
141
+ report_progress(0.65)
142
+ frames = _frames(self.struct_instance, capture_format)
143
+ fragments = []
144
+ for sequence, frame in enumerate(frames):
145
+ offset, length, link_type, timestamps, packet_data = frame
146
+ fragment = _extract_ip_fragment(
147
+ self.data,
148
+ offset,
149
+ length,
150
+ link_type,
151
+ sequence,
152
+ timestamps,
153
+ packet_data,
154
+ )
155
+ if fragment is not None:
156
+ fragments.append(fragment)
157
+ report_progress(0.65 + 0.25 * (sequence + 1) / len(frames))
158
+ report_progress(0.9)
159
+ self.packets = _reassemble(
160
+ fragments,
161
+ lambda progress: report_progress(0.9 + progress * 0.1),
162
+ )
163
+ report_progress(1.0)
164
+
165
+ @classmethod
166
+ def from_bytes(
167
+ cls,
168
+ data: bytes | bytearray,
169
+ decode_transform: Callable[[object], object] | None = None,
170
+ *,
171
+ progress_callback: Callable[[float], None] | None = None,
172
+ ) -> "CaptureDocument":
173
+ """Detect and decode a PCAP or PCAPNG byte stream."""
174
+ magic = bytes(data[:4])
175
+ if magic == b"\x0a\x0d\x0d\x0a":
176
+ return cls(data,
177
+ "pcapng",
178
+ decode_transform,
179
+ progress_callback=progress_callback)
180
+ if magic in {
181
+ b"\xd4\xc3\xb2\xa1",
182
+ b"\xa1\xb2\xc3\xd4",
183
+ b"\x4d\x3c\xb2\xa1",
184
+ b"\xa1\xb2\x3c\x4d",
185
+ }:
186
+ return cls(data,
187
+ "pcap",
188
+ decode_transform,
189
+ progress_callback=progress_callback)
190
+ raise ValueError("The file is not a supported PCAP or PCAPNG capture.")
191
+
192
+ @classmethod
193
+ def open(
194
+ cls,
195
+ path: str | Path,
196
+ decode_transform: Callable[[object], object] | None = None,
197
+ *,
198
+ progress_callback: Callable[[float], None] | None = None,
199
+ ) -> "CaptureDocument":
200
+ """Open a capture document from the specified file path."""
201
+ return cls.from_bytes(
202
+ bytearray(Path(path).read_bytes()),
203
+ decode_transform,
204
+ progress_callback=progress_callback,
205
+ )
206
+
207
+ def save(self, path: str | Path) -> None:
208
+ """Save the capture document to the specified file path."""
209
+ Path(path).write_bytes(self.data)
210
+
211
+
212
+ def _frames(instance: object, capture_format: str) -> list[_Frame]:
213
+ """Extract frame metadata from decoded PCAP or PCAPNG fields."""
214
+ if capture_format == "pcap":
215
+ return _pcap_frames(instance)
216
+ return _pcapng_frames(instance)
217
+
218
+
219
+ def _field(instance: object, name: str) -> object | None:
220
+ """Return a named direct child field instance, if present."""
221
+ return next((field for field in getattr(instance, "field_instances", ())
222
+ if field.field_def.name == name), None)
223
+
224
+
225
+ def _field_offset(*fields: object) -> int:
226
+ """Return the absolute byte offset represented by nested fields."""
227
+ return sum(field.field_def.offset.bytes for field in fields)
228
+
229
+
230
+ def _timestamp_values(fields: tuple[object, ...]) -> dict[str, object]:
231
+ """Extract timestamp values from the direct fields of a packet record."""
232
+ values = {}
233
+ for field in fields:
234
+ name = field.field_def.name
235
+ value = field.value
236
+ child_values = {
237
+ child.field_def.name: child.value
238
+ for child in getattr(value, "field_instances", ())
239
+ }
240
+ if name == "pcap_timestamp":
241
+ display_value = getattr(value, "display_value", None)
242
+ if display_value is not None:
243
+ values["timestamp"] = display_value
244
+ else:
245
+ seconds = child_values["timestamp_seconds"]
246
+ nanoseconds = child_values["timestamp_nanoseconds"]
247
+ values["timestamp"] = (seconds if isinstance(seconds, str) else
248
+ f"{seconds}.{nanoseconds:09d}")
249
+ elif name == "timestamp":
250
+ display_value = getattr(value, "display_value", None)
251
+ if display_value is not None:
252
+ values[name] = display_value
253
+ else:
254
+ high = child_values["high"]
255
+ low = child_values["low"]
256
+ values[name] = (high if isinstance(high, str) else high << 32
257
+ | low)
258
+ elif name.startswith("timestamp_"):
259
+ values[name] = value
260
+ return values
261
+
262
+
263
+ def internet_checksum(data: bytes | bytearray | virtual_bytearray, ) -> int:
264
+ """Compute the RFC 1071 one's-complement checksum for the given data."""
265
+ total = 0
266
+ high_byte = None
267
+ for value in data:
268
+ if high_byte is None:
269
+ high_byte = value
270
+ else:
271
+ total += high_byte << 8 | value
272
+ high_byte = None
273
+ if high_byte is not None:
274
+ total += high_byte << 8
275
+ while total >> 16:
276
+ total = (total & 0xFFFF) + (total >> 16)
277
+ return (~total) & 0xFFFF
278
+
279
+
280
+ def _pcap_frames(instance: object) -> list[_Frame]:
281
+ """Extract PCAP frame metadata from decoded fields."""
282
+ header = _field(instance, "header")
283
+ if header is None:
284
+ return []
285
+ link_type = _field(header.value, "link_type").value & 0xFFFF
286
+ frames = []
287
+ for record in instance.field_instances:
288
+ if not record.field_def.name.startswith("record["):
289
+ continue
290
+ packet_data = _field(record.value, "packet_data")
291
+ if packet_data is not None:
292
+ frames.append(
293
+ (_field_offset(record,
294
+ packet_data), packet_data.field_def.size.bytes,
295
+ link_type, _timestamp_values(record.value.field_instances),
296
+ packet_data.value))
297
+ return frames
298
+
299
+
300
+ def _pcapng_frames(instance: object) -> list[_Frame]:
301
+ """Extract PCAPNG frame metadata from decoded fields."""
302
+ frames = []
303
+ interfaces: list[tuple[int, int]] = []
304
+ for block in instance.field_instances:
305
+ if _field(block.value, "byte_order_magic") is not None:
306
+ interfaces = []
307
+ body = _field(block.value, "body")
308
+ if body is None:
309
+ continue
310
+ link_type = _field(body.value, "link_type")
311
+ snap_length = _field(body.value, "snap_len")
312
+ if link_type is not None and snap_length is not None:
313
+ interfaces.append((link_type.value, snap_length.value))
314
+ continue
315
+ packet_data = _field(body.value, "packet_data")
316
+ interface_id = _field(body.value, "interface_id")
317
+ if packet_data is not None and interface_id is not None:
318
+ if interface_id.value < len(interfaces):
319
+ frame_link_type, _ = interfaces[interface_id.value]
320
+ frames.append(
321
+ (_field_offset(block, body, packet_data),
322
+ packet_data.field_def.size.bytes, frame_link_type,
323
+ _timestamp_values(body.value.field_instances),
324
+ packet_data.value))
325
+ continue
326
+ packet_data = _field(body.value, "packet_data_with_padding")
327
+ original_length = _field(body.value, "original_packet_length")
328
+ if packet_data is None or original_length is None:
329
+ continue
330
+ if not interfaces:
331
+ continue
332
+ frame_link_type, snap_length = interfaces[0]
333
+ frames.append((_field_offset(block, body, packet_data),
334
+ min(original_length.value, snap_length,
335
+ packet_data.field_def.size.bytes), frame_link_type,
336
+ {}, packet_data.value))
337
+ return frames
338
+
339
+
340
+ def _extract_ip_fragment(
341
+ capture: bytearray,
342
+ frame_offset: int,
343
+ frame_length: int,
344
+ link_type: int,
345
+ sequence: int,
346
+ timestamps: dict[str, object],
347
+ packet_data: object,
348
+ ) -> _RawFragment | None:
349
+ """Extract an IP fragment from decoded Ethernet packet data."""
350
+ del frame_length
351
+ if link_type != 1:
352
+ return None
353
+ network_packet, network_offset = _network_packet(packet_data)
354
+ if network_packet is None:
355
+ return None
356
+ if _field(network_packet, "version_ihl") is not None:
357
+ return _extract_ipv4(capture, frame_offset + network_offset, sequence,
358
+ timestamps, network_packet)
359
+ if _field(network_packet, "version_traffic_flow") is not None:
360
+ return _extract_ipv6(capture, frame_offset + network_offset, sequence,
361
+ timestamps, network_packet)
362
+ return None
363
+
364
+
365
+ def _network_packet(packet_data: object) -> tuple[object | None, int]:
366
+ """Return the decoded IP packet and its offset within Ethernet data."""
367
+ offset = 0
368
+ current = packet_data
369
+ while True:
370
+ network_packet = _field(current, "network_packet")
371
+ if network_packet is None:
372
+ return None, offset
373
+ offset += network_packet.field_def.offset.bytes
374
+ current = network_packet.value
375
+ if (_field(current, "version_ihl") is not None
376
+ or _field(current, "version_traffic_flow") is not None):
377
+ return current, offset
378
+
379
+
380
+ def _extract_ipv4(capture: bytearray, offset: int, sequence: int,
381
+ timestamps: dict[str, object],
382
+ packet: object) -> _RawFragment | None:
383
+ """Extract an IPv4 fragment from decoded packet fields."""
384
+ transport_segment = _field(packet, "transport_segment")
385
+ if transport_segment is None:
386
+ return None
387
+ fragment_offset = _field(packet, "fragment_offset").value * 8
388
+ more_fragments = bool(_field(packet, "more_fragments").value)
389
+ protocol = _field(packet, "protocol").value
390
+ identification = _field(packet, "identification").value
391
+ payload_start = offset + transport_segment.field_def.offset.bytes
392
+ return _raw_fragment(capture, sequence, timestamps, packet, 4, protocol,
393
+ identification, fragment_offset, more_fragments,
394
+ payload_start, transport_segment.field_def.size.bytes,
395
+ fragment_offset > 0 or more_fragments)
396
+
397
+
398
+ def _extract_ipv6(capture: bytearray, offset: int, sequence: int,
399
+ timestamps: dict[str, object],
400
+ packet: object) -> _RawFragment | None:
401
+ """Extract an IPv6 fragment from decoded packet fields."""
402
+ transport_segment = _field(packet, "transport_segment")
403
+ if transport_segment is None:
404
+ return None
405
+ if _field(packet, "next_header").value == 44:
406
+ fragment_header = transport_segment.value
407
+ payload_field = _field(fragment_header, "fragment_payload")
408
+ if payload_field is None:
409
+ return None
410
+ protocol = _field(fragment_header, "next_header").value
411
+ fragment_offset = _field(fragment_header, "fragment_offset").value * 8
412
+ more_field = _field(fragment_header, "more_fragments")
413
+ more_fragments = bool(more_field.value)
414
+ identification = _field(fragment_header, "identification").value
415
+ payload_start = (offset + transport_segment.field_def.offset.bytes +
416
+ payload_field.field_def.offset.bytes)
417
+ else:
418
+ protocol = _field(packet, "next_header").value
419
+ fragment_offset = 0
420
+ more_fragments = False
421
+ identification = None
422
+ payload_field = transport_segment
423
+ payload_start = offset + transport_segment.field_def.offset.bytes
424
+ return _raw_fragment(capture, sequence, timestamps, packet, 6, protocol,
425
+ identification, fragment_offset, more_fragments,
426
+ payload_start, payload_field.field_def.size.bytes,
427
+ identification is not None)
428
+
429
+
430
+ def _raw_fragment(
431
+ capture: bytearray,
432
+ sequence: int,
433
+ timestamps: dict[str, object],
434
+ packet: object,
435
+ ip_version: int,
436
+ protocol: int,
437
+ identification: int | None,
438
+ fragment_offset: int,
439
+ more_fragments: bool,
440
+ payload_start: int,
441
+ payload_length: int,
442
+ is_fragmented: bool,
443
+ ) -> _RawFragment:
444
+ """Build a raw fragment backed by the original capture data."""
445
+ source = bytes(_field(packet, "source_address").value)
446
+ destination = bytes(_field(packet, "destination_address").value)
447
+ if is_fragmented:
448
+ key = (ip_version, source, destination, protocol, identification)
449
+ else:
450
+ key = (ip_version, sequence)
451
+ payload = virtual_bytearray(
452
+ (capture, ))[payload_start:payload_start + payload_length]
453
+ return _RawFragment(sequence, timestamps, key, ip_version, source,
454
+ destination, protocol, identification, fragment_offset,
455
+ more_fragments, payload_start, payload)
456
+
457
+
458
+ def _reassemble(
459
+ fragments: list[_RawFragment],
460
+ progress_callback: Callable[[float], None] | None = None,
461
+ ) -> list[ReassembledPacket]:
462
+ """Reassemble raw IP fragments into complete packets."""
463
+ groups: dict[tuple, list[_RawFragment]] = {}
464
+ for fragment in fragments:
465
+ groups.setdefault(fragment.key, []).append(fragment)
466
+
467
+ packets = []
468
+ for group_index, group in enumerate(groups.values()):
469
+ group.sort(key=lambda fragment: (fragment.offset, fragment.sequence))
470
+ expected_length = max(
471
+ (fragment.offset + len(fragment.data)
472
+ for fragment in group if not fragment.more_fragments),
473
+ default=None)
474
+ data_length = expected_length or max(
475
+ fragment.offset + len(fragment.data) for fragment in group)
476
+ chunks = []
477
+ cursor = 0
478
+ has_gap = False
479
+ locations = []
480
+ for fragment in group:
481
+ fragment_end = min(data_length,
482
+ fragment.offset + len(fragment.data))
483
+ length = fragment_end - fragment.offset
484
+ locations.append(
485
+ PacketFragment(fragment.sequence, fragment.timestamps,
486
+ fragment.capture_offset, fragment.offset, length,
487
+ fragment.more_fragments))
488
+ if fragment.offset > cursor:
489
+ chunks.append(bytes(fragment.offset - cursor))
490
+ has_gap = True
491
+ cursor = fragment.offset
492
+ if fragment_end > cursor:
493
+ chunk_start = cursor - fragment.offset
494
+ chunks.append(fragment.data[chunk_start:length])
495
+ cursor = fragment_end
496
+ if cursor < data_length:
497
+ chunks.append(bytes(data_length - cursor))
498
+ has_gap = True
499
+ data = virtual_bytearray(chunks)
500
+ first = group[0]
501
+ packets.append(
502
+ ReassembledPacket(
503
+ sequence=min(fragment.sequence for fragment in group),
504
+ timestamps=first.timestamps,
505
+ key=first.key,
506
+ ip_version=first.ip_version,
507
+ source_bytes=first.source,
508
+ destination_bytes=first.destination,
509
+ protocol=first.protocol,
510
+ identification=first.identification,
511
+ fragments=locations,
512
+ data=data,
513
+ complete=expected_length is not None and not has_gap,
514
+ ))
515
+ if progress_callback is not None:
516
+ progress_callback((group_index + 1) / len(groups))
517
+ packets.sort(key=lambda packet: packet.sequence)
518
+ return packets