|
45 | 45 |
|
46 | 46 | from net_addr import Ip6Address, MacAddress |
47 | 47 | from net_proto import Icmp6Mld2MulticastAddressRecordType as RecordType |
| 48 | +from net_proto import Icmp6Type |
48 | 49 | from net_proto.lib.inet_cksum import inet_cksum |
49 | 50 | from pytcp import stack |
| 51 | +from pytcp.lib.ip6_multicast_filter import ( |
| 52 | + Ip6MulticastFilter, |
| 53 | + Ip6MulticastFilterMode, |
| 54 | +) |
50 | 55 | from pytcp.runtime.socket import ( |
51 | 56 | IPPROTO_IPV6, |
52 | 57 | IPV6_JOIN_GROUP, |
|
63 | 68 | _GROUP = Ip6Address("ff15::1234") |
64 | 69 | _S1 = Ip6Address("2001:db8::a") |
65 | 70 | _S2 = Ip6Address("2001:db8::b") |
| 71 | +_S3 = Ip6Address("2001:db8::c") |
66 | 72 | _AF_INET6 = 10 |
67 | 73 |
|
68 | 74 | # Ethernet(14) + IPv6(40) + HBH(8) — the ICMPv6 message starts here. |
@@ -289,6 +295,103 @@ def test__any_source_join_emits_change_to_exclude(self) -> None: |
289 | 295 | msg="A fresh any-source join must report CHANGE_TO_EXCLUDE.", |
290 | 296 | ) |
291 | 297 |
|
| 298 | + def test__within_mode_change__emits_both_allow_and_block(self) -> None: |
| 299 | + """ |
| 300 | + Ensure a single within-mode interface-filter change that both |
| 301 | + adds and drops sources (INCLUDE{S1,S2} → INCLUDE{S2,S3}) emits one |
| 302 | + Report carrying both an ALLOW_NEW_SOURCES record for the added |
| 303 | + source and a BLOCK_OLD_SOURCES record for the dropped source. |
| 304 | +
|
| 305 | + Reference: RFC 3810 §6.1 (INCLUDE(A)→INCLUDE(B) sends ALLOW (B-A) and BLOCK (A-B)). |
| 306 | + """ |
| 307 | + |
| 308 | + # Drive the interface filter directly through the handler so a |
| 309 | + # single recompute changes two sources at once (the socket options |
| 310 | + # only mutate one source per call). |
| 311 | + self._packet_handler.mc6_set_socket_filter( |
| 312 | + _GROUP, |
| 313 | + token=1, |
| 314 | + source_filter=Ip6MulticastFilter(Ip6MulticastFilterMode.INCLUDE, frozenset({_S1, _S2})), |
| 315 | + ) |
| 316 | + |
| 317 | + before = len(self._frames_tx) |
| 318 | + self._packet_handler.mc6_set_socket_filter( |
| 319 | + _GROUP, |
| 320 | + token=1, |
| 321 | + source_filter=Ip6MulticastFilter(Ip6MulticastFilterMode.INCLUDE, frozenset({_S2, _S3})), |
| 322 | + ) |
| 323 | + frames = self._frames_tx[before:] |
| 324 | + |
| 325 | + self.assertEqual(len(frames), 1, msg="The within-mode change must emit exactly one Report.") |
| 326 | + self.assertEqual( |
| 327 | + set(_records(frames[0])), |
| 328 | + { |
| 329 | + (int(RecordType.ALLOW_NEW_SOURCES), frozenset({_S3})), |
| 330 | + (int(RecordType.BLOCK_OLD_SOURCES), frozenset({_S1})), |
| 331 | + }, |
| 332 | + msg="The Report must carry ALLOW for the added source and BLOCK for the dropped source.", |
| 333 | + ) |
| 334 | + |
| 335 | + def test__emit_mld2_report__empty_records_emits_nothing(self) -> None: |
| 336 | + """ |
| 337 | + Ensure emitting an MLDv2 Report with no Multicast Address Records |
| 338 | + sends no frame — an empty state-change is not put on the wire. |
| 339 | +
|
| 340 | + Reference: RFC 3810 §5.2 (a Report with zero records carries no information). |
| 341 | + """ |
| 342 | + |
| 343 | + before = len(self._frames_tx) |
| 344 | + self._packet_handler._icmp6_tx._emit_mld2_report([]) |
| 345 | + |
| 346 | + self.assertEqual( |
| 347 | + len(self._frames_tx[before:]), |
| 348 | + 0, |
| 349 | + msg="An MLDv2 Report with no records must emit nothing.", |
| 350 | + ) |
| 351 | + |
| 352 | + def test__all_nodes__state_change_emits_nothing(self) -> None: |
| 353 | + """ |
| 354 | + Ensure a state-change for the permanent all-nodes group ff02::1 |
| 355 | + emits nothing — the host never reports its all-nodes membership. |
| 356 | +
|
| 357 | + Reference: RFC 3810 §6 (the all-nodes group ff02::1 is never reported). |
| 358 | + """ |
| 359 | + |
| 360 | + before = len(self._frames_tx) |
| 361 | + self._packet_handler._send_mld_state_change( |
| 362 | + Ip6Address("ff02::1"), |
| 363 | + old=Ip6MulticastFilter(Ip6MulticastFilterMode.INCLUDE), |
| 364 | + new=Ip6MulticastFilter(Ip6MulticastFilterMode.EXCLUDE), |
| 365 | + ) |
| 366 | + |
| 367 | + self.assertEqual( |
| 368 | + len(self._frames_tx[before:]), |
| 369 | + 0, |
| 370 | + msg="A state-change for ff02::1 must emit nothing.", |
| 371 | + ) |
| 372 | + |
| 373 | + def test__mldv1_mode__source_only_change_emits_nothing(self) -> None: |
| 374 | + """ |
| 375 | + Ensure that while the interface is in MLDv1 Host Compatibility |
| 376 | + Mode a source-only change within a still-joined membership |
| 377 | + (blocking a source on an any-source join) emits nothing — MLDv1 |
| 378 | + has no source concept, so only reception-edge changes are visible. |
| 379 | +
|
| 380 | + Reference: RFC 3810 §8.3.1 (MLDv1 Reports carry no source list). |
| 381 | + """ |
| 382 | + |
| 383 | + with sysctl.override("mld.version", 1): |
| 384 | + self._socket.setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, _ipv6_mreq(_GROUP)) |
| 385 | + |
| 386 | + before = len(self._frames_tx) |
| 387 | + self._socket.setsockopt(IPPROTO_IPV6, MCAST_BLOCK_SOURCE, _group_source_req(_GROUP, _S1)) |
| 388 | + |
| 389 | + self.assertEqual( |
| 390 | + len(self._frames_tx[before:]), |
| 391 | + 0, |
| 392 | + msg="A source-only change in MLDv1 mode must emit no Report.", |
| 393 | + ) |
| 394 | + |
292 | 395 |
|
293 | 396 | class TestIcmp6MldStateChangeRetransmit(IcmpTestCase): |
294 | 397 | """ |
@@ -387,3 +490,85 @@ def test__compat_mode_change_cancels_retransmits(self) -> None: |
387 | 490 | {}, |
388 | 491 | msg="The compat-mode change must clear the pending retransmit train.", |
389 | 492 | ) |
| 493 | + |
| 494 | + def test__retransmit__fires_in_mldv1_mode(self) -> None: |
| 495 | + """ |
| 496 | + Ensure a state-change whose retransmit train runs while the |
| 497 | + interface is in MLDv1 Host Compatibility Mode retransmits the |
| 498 | + coarse MLDv1 Report form (type 131), not an MLDv2 Report. |
| 499 | +
|
| 500 | + Reference: RFC 3810 §8.3.1 (state-change retransmits take the MLDv1 form in v1 mode). |
| 501 | + """ |
| 502 | + |
| 503 | + with sysctl.override("mld.version", 1), sysctl.override("mld.robustness", 2): |
| 504 | + self.enterContext( |
| 505 | + patch( |
| 506 | + "pytcp.runtime.packet_handler.packet_handler__icmp6__tx.random.randint", |
| 507 | + return_value=200, |
| 508 | + ) |
| 509 | + ) |
| 510 | + before = len(self._frames_tx) |
| 511 | + self._socket.setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, _ipv6_mreq(_GROUP)) |
| 512 | + immediate = self._frames_tx[before:] |
| 513 | + self.assertEqual(len(immediate), 1, msg="The join must emit one immediate Report.") |
| 514 | + self.assertEqual( |
| 515 | + immediate[0][_OFFSET_ICMP6], |
| 516 | + int(Icmp6Type.MULTICAST_LISTENER_REPORT), |
| 517 | + msg="The immediate join Report must be an MLDv1 Report (type 131) in v1 mode.", |
| 518 | + ) |
| 519 | + |
| 520 | + tx = self._advance(ms=200) |
| 521 | + self.assertEqual(len(tx), 1, msg="One robustness retransmit must fire.") |
| 522 | + self.assertEqual( |
| 523 | + tx[0][_OFFSET_ICMP6], |
| 524 | + int(Icmp6Type.MULTICAST_LISTENER_REPORT), |
| 525 | + msg="The retransmit must also take the MLDv1 Report form in v1 mode.", |
| 526 | + ) |
| 527 | + |
| 528 | + def test__robustness_one__schedules_no_retransmit(self) -> None: |
| 529 | + """ |
| 530 | + Ensure a Robustness Variable of 1 emits the state-change Report |
| 531 | + once and schedules no retransmit train (RV-1 = 0). |
| 532 | +
|
| 533 | + Reference: RFC 3810 §9.1 (RV total transmissions — RV=1 means a single Report). |
| 534 | + """ |
| 535 | + |
| 536 | + with sysctl.override("mld.robustness", 1): |
| 537 | + self.enterContext( |
| 538 | + patch( |
| 539 | + "pytcp.runtime.packet_handler.packet_handler__icmp6__tx.random.randint", |
| 540 | + return_value=200, |
| 541 | + ) |
| 542 | + ) |
| 543 | + before = len(self._frames_tx) |
| 544 | + self._socket.setsockopt(IPPROTO_IPV6, MCAST_JOIN_SOURCE_GROUP, _group_source_req(_GROUP, _S1)) |
| 545 | + |
| 546 | + self.assertEqual(len(self._frames_tx[before:]), 1, msg="RV=1 must emit exactly one Report.") |
| 547 | + self.assertEqual( |
| 548 | + self._packet_handler._icmp6_tx._mld_state_change__pending, |
| 549 | + {}, |
| 550 | + msg="RV=1 must schedule no retransmit train.", |
| 551 | + ) |
| 552 | + self.assertEqual(len(self._advance(ms=200)), 0, msg="No retransmit must fire when RV=1.") |
| 553 | + |
| 554 | + def test__retransmit__multiple_rounds_then_exhausts(self) -> None: |
| 555 | + """ |
| 556 | + Ensure a Robustness Variable of 3 retransmits the state-change |
| 557 | + Report twice (RV-1 = 2) — the train decrements and re-arms across |
| 558 | + rounds before exhausting. |
| 559 | +
|
| 560 | + Reference: RFC 3810 §6.1 (state-change Report retransmitted RV-1 times). |
| 561 | + """ |
| 562 | + |
| 563 | + with sysctl.override("mld.robustness", 3): |
| 564 | + self.enterContext( |
| 565 | + patch( |
| 566 | + "pytcp.runtime.packet_handler.packet_handler__icmp6__tx.random.randint", |
| 567 | + return_value=200, |
| 568 | + ) |
| 569 | + ) |
| 570 | + self._socket.setsockopt(IPPROTO_IPV6, MCAST_JOIN_SOURCE_GROUP, _group_source_req(_GROUP, _S1)) |
| 571 | + |
| 572 | + self.assertEqual(len(self._advance(ms=200)), 1, msg="The first retransmit round must fire.") |
| 573 | + self.assertEqual(len(self._advance(ms=200)), 1, msg="The second retransmit round must fire.") |
| 574 | + self.assertEqual(len(self._advance(ms=200)), 0, msg="No third round fires after RV-1 = 2 rounds.") |
0 commit comments