Skip to content

Commit ce7a09f

Browse files
ccie18643claude
andcommitted
test(mld): close the R4 IPv6-SSM coverage gaps
A coverage audit of the R4 IPv6 source-specific-multicast work surfaced a handful of untested branches; this fills them (test-only, no production change). The new state-change methods in the ICMPv6 TX handler and the 'Membership6Api' now have full line + branch coverage. New tests in 'test__icmp6__mld__source_state_change.py': - a single within-mode filter change that both adds and drops a source emits one Report carrying BOTH an ALLOW_NEW_SOURCES and a BLOCK_OLD_SOURCES record (driven through 'mc6_set_socket_filter' so one recompute changes two sources — the socket options only mutate one); - a source-only change while in MLDv1 Host Compatibility Mode emits nothing (MLDv1 has no source concept — the 'coarse_join=None' path); - a retransmit train that runs while in MLDv1 mode retransmits the coarse MLDv1 Report form (the '_fire' v1 branch); - a Robustness Variable of 1 schedules no retransmit train (the RV-1 = 0 short-circuit); - a Robustness Variable of 3 retransmits twice then exhausts (the train's decrement + re-arm across rounds); - a state-change for the permanent all-nodes group ff02::1 emits nothing; - an MLDv2 Report with no records emits nothing (the empty-records guard). New test in 'test__icmp6__mld__membership6_api.py': - 'Membership6Api.leave' rejects a non-multicast address (previously only covered on 'join'). Lint clean, 13545 passing, 0 skipped. Reference: RFC 3810 §6.1 (source-bearing state-change difference records and retransmission). Reference: RFC 3810 §8.3.1 (MLDv1 Reports carry no source list). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa3979b commit ce7a09f

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

packages/pytcp/pytcp/tests/integration/protocols/icmp6/test__icmp6__mld__membership6_api.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ def test__membership6__leave_rejects_all_nodes(self) -> None:
121121
with self.assertRaises(ValueError):
122122
stack.membership6.leave(group=_ALL_NODES)
123123

124+
def test__membership6__leave_rejects_non_multicast(self) -> None:
125+
"""
126+
Ensure leaving a non-multicast address is rejected.
127+
128+
Reference: RFC 4291 §2.7 (membership is for multicast groups).
129+
"""
130+
131+
with self.assertRaises(ValueError):
132+
stack.membership6.leave(group=Ip6Address("2001:db8::1"))
133+
124134
def test__membership6__set_socket_filter_joins_include(self) -> None:
125135
"""
126136
Ensure 'set_socket_filter' with a non-empty INCLUDE filter joins

packages/pytcp/pytcp/tests/integration/protocols/icmp6/test__icmp6__mld__source_state_change.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,13 @@
4545

4646
from net_addr import Ip6Address, MacAddress
4747
from net_proto import Icmp6Mld2MulticastAddressRecordType as RecordType
48+
from net_proto import Icmp6Type
4849
from net_proto.lib.inet_cksum import inet_cksum
4950
from pytcp import stack
51+
from pytcp.lib.ip6_multicast_filter import (
52+
Ip6MulticastFilter,
53+
Ip6MulticastFilterMode,
54+
)
5055
from pytcp.runtime.socket import (
5156
IPPROTO_IPV6,
5257
IPV6_JOIN_GROUP,
@@ -63,6 +68,7 @@
6368
_GROUP = Ip6Address("ff15::1234")
6469
_S1 = Ip6Address("2001:db8::a")
6570
_S2 = Ip6Address("2001:db8::b")
71+
_S3 = Ip6Address("2001:db8::c")
6672
_AF_INET6 = 10
6773

6874
# 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:
289295
msg="A fresh any-source join must report CHANGE_TO_EXCLUDE.",
290296
)
291297

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+
292395

293396
class TestIcmp6MldStateChangeRetransmit(IcmpTestCase):
294397
"""
@@ -387,3 +490,85 @@ def test__compat_mode_change_cancels_retransmits(self) -> None:
387490
{},
388491
msg="The compat-mode change must clear the pending retransmit train.",
389492
)
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

Comments
 (0)