@@ -1136,6 +1136,194 @@ def test_make_builds_report_from_dict_records(self) -> None:
11361136 self .assertEqual (schema .body .records [1 ].num_sources , 1 )
11371137
11381138
1139+ #: A Report of two group records with source lists: the first record's header
1140+ #: is octets 8-15 and its sources 16-23, the second's header 24-31 and its
1141+ #: sources 32-43.
1142+ IGMPV3_TWO_RECORD_REPORT = igmpv3_report ((
1143+ igmpv3_group_record (1 , '239.1.1.1' , ('10.0.0.1' , '10.0.0.2' )),
1144+ igmpv3_group_record (2 , '239.2.2.2' , ('10.0.0.3' , '10.0.0.4' , '10.0.0.5' )),
1145+ ))
1146+
1147+
1148+ class IGMPv3TruncatedReportTests (unittest .TestCase ):
1149+ """A Report the capture cuts inside its record list parses as far as its
1150+ octets go and records the cut, as a cut Query does, rather than falling
1151+ back to ``Raw`` (#2650). Each datagram's IPv4 header declares the whole
1152+ Report, so the IGMP layer is handed fewer octets than its length."""
1153+
1154+ def setUp (self ) -> None :
1155+ reimport_once_per_class (self )
1156+
1157+ def parse_cut (self , packet : 'bytes' , keep : 'int' ):
1158+ """Parse ``packet`` inside an IPv4 datagram cut to ``keep`` octets of it."""
1159+ from pcapkit .protocols .internet .ipv4 import IPv4
1160+
1161+ with warnings .catch_warnings ():
1162+ warnings .simplefilter ('ignore' )
1163+ outer = IPv4 (protocol = 2 , src = '10.0.0.1' , dst = '224.0.0.22' , payload = packet ).data
1164+ cut = outer [:len (outer ) - len (packet ) + keep ]
1165+ parsed = IPv4 (cut )
1166+ return cut , parsed
1167+
1168+ def assert_rebuilds (self , cut : 'bytes' , parsed ) -> None :
1169+ """Both layers rebuild the octets captured, from ``info`` and its dict."""
1170+ from pcapkit .protocols .internet .igmpv3 import IGMPv3
1171+ from pcapkit .protocols .internet .ipv4 import IPv4
1172+
1173+ igmp = parsed .payload
1174+ with warnings .catch_warnings ():
1175+ # a cut layer warns that its length runs past the data, which is the point
1176+ warnings .simplefilter ('ignore' )
1177+ self .assertEqual (IGMPv3 .from_data (igmp .info ).data , igmp .data )
1178+ self .assertEqual (IGMPv3 .from_data (igmp .info .to_dict ()).data , igmp .data )
1179+ self .assertEqual (IPv4 .from_data (parsed .info ).data , cut )
1180+
1181+ def test_whole_report_is_not_marked (self ) -> None :
1182+ from pcapkit .protocols .internet .igmpv3 import IGMPv3
1183+ from pcapkit .protocols .misc .no_payload import NoPayload
1184+
1185+ packet = IGMPV3_TWO_RECORD_REPORT
1186+ cut , parsed = self .parse_cut (packet , len (packet ))
1187+ igmp = parsed .payload
1188+
1189+ self .assertIsInstance (igmp , IGMPv3 )
1190+ self .assertNotIn ('__short_read__' , igmp .info )
1191+ self .assertEqual (igmp .info .length , len (packet ))
1192+ self .assertEqual (
1193+ [(str (record .multicast_address ), tuple (str (src ) for src in record .sources ))
1194+ for record in igmp .info .records ],
1195+ [('239.1.1.1' , ('10.0.0.1' , '10.0.0.2' )),
1196+ ('239.2.2.2' , ('10.0.0.3' , '10.0.0.4' , '10.0.0.5' ))],
1197+ )
1198+ self .assertIsInstance (igmp .payload , NoPayload )
1199+ self .assert_rebuilds (cut , parsed )
1200+
1201+ def test_report_cut_inside_a_record_header (self ) -> None :
1202+ """Cut after the second record's Number of Sources, before its
1203+ Multicast Address: the first record is whole, the second is kept as
1204+ far as it was read."""
1205+ from pcapkit .protocols .internet .igmpv3 import IGMPv3
1206+ from pcapkit .protocols .misc .no_payload import NoPayload
1207+
1208+ packet = IGMPV3_TWO_RECORD_REPORT
1209+ cut , parsed = self .parse_cut (packet , 28 )
1210+ igmp = parsed .payload
1211+
1212+ self .assertIsInstance (igmp , IGMPv3 )
1213+ self .assertEqual (igmp .info ['__short_read__' ], ('body' , 27 ))
1214+ self .assertEqual (igmp .info .length , 28 )
1215+ self .assertEqual (igmp .data , packet [:28 ])
1216+ self .assertEqual (igmp .info .num_group_records , 2 )
1217+ self .assertEqual (len (igmp .info .records ), 2 )
1218+ first , second = igmp .info .records
1219+ self .assertEqual (str (first .multicast_address ), '239.1.1.1' )
1220+ self .assertEqual (tuple (str (src ) for src in first .sources ), ('10.0.0.1' , '10.0.0.2' ))
1221+ self .assertEqual (second .record_type , 2 )
1222+ self .assertEqual (second .num_sources , 3 )
1223+ self .assertIsInstance (igmp .payload , NoPayload )
1224+ self .assert_rebuilds (cut , parsed )
1225+
1226+ def test_report_cut_inside_a_source_list (self ) -> None :
1227+ """Cut inside the first record's second source: one record of the two
1228+ the count declares, which the rebuild still writes as two."""
1229+ from pcapkit .protocols .internet .igmpv3 import IGMPv3
1230+ from pcapkit .protocols .misc .no_payload import NoPayload
1231+
1232+ packet = IGMPV3_TWO_RECORD_REPORT
1233+ cut , parsed = self .parse_cut (packet , 22 )
1234+ igmp = parsed .payload
1235+
1236+ self .assertIsInstance (igmp , IGMPv3 )
1237+ self .assertEqual (igmp .info ['__short_read__' ], ('body' , 21 ))
1238+ self .assertEqual (igmp .info .length , 22 )
1239+ self .assertEqual (igmp .info .num_group_records , 2 )
1240+ self .assertEqual (len (igmp .info .records ), 1 )
1241+ record = igmp .info .records [0 ]
1242+ self .assertEqual (str (record .multicast_address ), '239.1.1.1' )
1243+ self .assertEqual (record .num_sources , 2 )
1244+ self .assertEqual (str (record .sources [0 ]), '10.0.0.1' )
1245+ self .assertIsInstance (igmp .payload , NoPayload )
1246+ self .assert_rebuilds (cut , parsed )
1247+
1248+ def test_cut_additional_data_goes_to_the_next_layer (self ) -> None :
1249+ """Octets past the counted records still go to the payload when the
1250+ capture ends inside them (:rfc:`3376#section-4.2.11`)."""
1251+ from pcapkit .protocols .internet .igmpv3 import IGMPv3
1252+ from pcapkit .protocols .misc .raw import Raw
1253+
1254+ packet = igmpv3_report ((igmpv3_group_record (1 , '224.0.1.1' , ('10.0.0.1' ,)),)) + b'\xca \xfe \xba \xbe '
1255+ cut , parsed = self .parse_cut (packet , len (packet ) - 2 )
1256+ igmp = parsed .payload
1257+
1258+ self .assertIsInstance (igmp , IGMPv3 )
1259+ self .assertEqual (len (igmp .info .records ), 1 )
1260+ self .assertIsInstance (igmp .payload , Raw )
1261+ self .assertEqual (igmp .payload .info .packet , b'\xca \xfe ' )
1262+ self .assertEqual (igmp .info .length , len (packet ) - 4 )
1263+ self .assert_rebuilds (cut , parsed )
1264+
1265+ def test_record_list_reads_bytes_and_a_live_stream_alike (self ) -> None :
1266+ """The record list is sized by the octets present, from a buffer or a
1267+ stream part-way through, and keeps its declared length afterwards."""
1268+ from pcapkit .protocols .schema .internet .igmpv3 import Report
1269+
1270+ records = IGMPV3_TWO_RECORD_REPORT [8 :28 ]
1271+ field = Report .__fields__ ['records' ]({'__length__' : 36 })
1272+ stream = io .BytesIO (b'\xde \xad ' + records )
1273+ stream .seek (2 )
1274+
1275+ for name , buffer in (('bytes' , records ), ('stream' , stream )):
1276+ with self .subTest (buffer = name ), warnings .catch_warnings ():
1277+ # the record cut short warns that its length runs past the data
1278+ warnings .simplefilter ('ignore' )
1279+ parsed = field .unpack (buffer , {})
1280+ self .assertEqual ([str (record .multicast_address ) for record in parsed ],
1281+ ['239.1.1.1' , '0.0.0.0' ])
1282+ self .assertEqual (parsed [1 ].num_sources , 3 )
1283+ self .assertEqual (field .length , 36 )
1284+
1285+
1286+ class IGMPv3RecordCountTests (unittest .TestCase ):
1287+ """``IGMPv3.make`` writes the ``num_group_records`` it is given only as an
1288+ :obj:`int` from the length of ``records`` to 65535; any other is refused
1289+ rather than written altered or losing records or payload (#2617, #2650)."""
1290+
1291+ RECORDS = (
1292+ {'record_type' : 1 , 'multicast_address' : '239.1.1.1' },
1293+ {'record_type' : 2 , 'multicast_address' : '239.2.2.2' },
1294+ )
1295+
1296+ def setUp (self ) -> None :
1297+ reimport_once_per_class (self )
1298+
1299+ def test_a_count_the_records_or_the_field_cannot_carry_is_refused (self ) -> None :
1300+ import re
1301+
1302+ from pcapkit .const .igmp .type import Type
1303+ from pcapkit .protocols .internet .igmpv3 import IGMPv3
1304+ from pcapkit .utilities .exceptions import ProtocolError
1305+
1306+ proto = object .__new__ (IGMPv3 )
1307+ for count , payload in ((65536 , b'' ), (- 1 , b'' ), (1 , b'XYZW' ), (0 , b'XYZW' ),
1308+ (True , b'' ), (2.0 , b'' ), ('2' , b'' )):
1309+ with self .subTest (count = count , payload = payload ), self .assertRaisesRegex (
1310+ ProtocolError , rf'^IGMPv3: invalid num_group_records: { re .escape (repr (count ))} for 2 record' ):
1311+ proto .make (type = Type .IGMPv3_Membership_Report , records = self .RECORDS ,
1312+ num_group_records = count , payload = payload )
1313+
1314+ def test_a_count_from_the_records_to_the_field_maximum_is_written (self ) -> None :
1315+ from pcapkit .const .igmp .type import Type
1316+ from pcapkit .protocols .internet .igmpv3 import IGMPv3
1317+
1318+ records = igmpv3_group_record (1 , '239.1.1.1' ) + igmpv3_group_record (2 , '239.2.2.2' )
1319+ for count , wire in ((None , 2 ), (2 , 2 ), (3 , 3 ), (0xFFFF , 0xFFFF )):
1320+ with self .subTest (count = count ):
1321+ built = IGMPv3 (type = Type .IGMPv3_Membership_Report , records = self .RECORDS ,
1322+ num_group_records = count , payload = b'XYZW' ).data
1323+ self .assertEqual (built [6 :8 ], wire .to_bytes (2 , 'big' ))
1324+ self .assertEqual (built [8 :], records + b'XYZW' )
1325+
1326+
11391327class IGMPDispatchTests (unittest .TestCase ):
11401328 """The IP protocol 2 registry entry is the abstract base, which picks the
11411329 version class per message (#2439, #2448)."""
0 commit comments