diff --git a/macfleet/pool/topology.py b/macfleet/pool/topology.py index 9952ecc..2eae8ec 100644 --- a/macfleet/pool/topology.py +++ b/macfleet/pool/topology.py @@ -16,6 +16,7 @@ from macfleet.pool.network import LinkType, NetworkLink _MAX_SERIALIZED_LINKS = 8 +_MAX_MTU = 65_535 _LINK_PRIORITY = { LinkType.LOOPBACK: 50, @@ -44,6 +45,47 @@ def _is_dialable_address(value: str) -> bool: return not (parsed.version == 6 and parsed.is_link_local) +def _telemetry_number( + item: dict[object, object], + short_name: str, + long_name: str, + *, + maximum: float | None = None, +) -> float: + raw = item[short_name] if short_name in item else item.get(long_name, 0.0) + if not _number_in_range(raw, maximum=maximum): + raise ValueError(f"{long_name} must be a number") + assert isinstance(raw, (int, float)) + value = float(raw) + return value + + +def _number_in_range(value: object, *, maximum: float | None = None) -> bool: + return ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and math.isfinite(float(value)) + and value >= 0 + and (maximum is None or value <= maximum) + ) + + +def _valid_mtu(value: object) -> bool: + return ( + not isinstance(value, bool) + and isinstance(value, int) + and 1 <= value <= _MAX_MTU + ) + + +def _telemetry_mtu(item: dict[object, object]) -> int: + raw = item["m"] if "m" in item else item.get("mtu", 1500) + if not _valid_mtu(raw): + raise ValueError("mtu must be an integer between 1 and 65535") + assert isinstance(raw, int) + return raw + + def _same_lan_score(left: str, right: str) -> int: left_ip = _parsed_address(left) right_ip = _parsed_address(right) @@ -179,18 +221,18 @@ def serialize_network_links(links: Sequence[NetworkLink]) -> str: "t": link.link_type.value, "ip": link.ip_address, } - if link.bandwidth_mbps: + if link.bandwidth_mbps and _number_in_range(link.bandwidth_mbps): item["b"] = link.bandwidth_mbps - if link.latency_ms: + if link.latency_ms and _number_in_range(link.latency_ms): item["l"] = link.latency_ms - if link.loss_rate: + if link.loss_rate and _number_in_range(link.loss_rate, maximum=1.0): item["r"] = link.loss_rate - if link.mtu != 1500: + if link.mtu != 1500 and _valid_mtu(link.mtu): item["m"] = link.mtu payload.append(item) if len(payload) >= _MAX_SERIALIZED_LINKS: break - return json.dumps(payload, separators=(",", ":")) + return json.dumps(payload, separators=(",", ":"), allow_nan=False) def deserialize_network_links(payload: str) -> tuple[NetworkLink, ...]: @@ -201,6 +243,8 @@ def deserialize_network_links(payload: str) -> tuple[NetworkLink, ...]: return () if not isinstance(raw, list): return () + if len(raw) > _MAX_SERIALIZED_LINKS: + return () links: list[NetworkLink] = [] for item in raw: @@ -218,19 +262,10 @@ def deserialize_network_links(payload: str) -> tuple[NetworkLink, ...]: or link_type == LinkType.LOOPBACK ): continue - bandwidth_mbps = float(item.get("b") or item.get("bandwidth_mbps") or 0.0) - latency_ms = float(item.get("l") or item.get("latency_ms") or 0.0) - loss_rate = float(item.get("r") or item.get("loss_rate") or 0.0) - mtu = int(item.get("m") or item.get("mtu") or 1500) - if ( - not all(math.isfinite(value) and value >= 0 for value in ( - bandwidth_mbps, - latency_ms, - loss_rate, - )) - or mtu < 1 - ): - continue + bandwidth_mbps = _telemetry_number(item, "b", "bandwidth_mbps") + latency_ms = _telemetry_number(item, "l", "latency_ms") + loss_rate = _telemetry_number(item, "r", "loss_rate", maximum=1.0) + mtu = _telemetry_mtu(item) links.append( NetworkLink( interface=interface, diff --git a/tests/test_pool/test_topology.py b/tests/test_pool/test_topology.py index 8b19bbf..b235fc4 100644 --- a/tests/test_pool/test_topology.py +++ b/tests/test_pool/test_topology.py @@ -153,3 +153,78 @@ def test_deserialize_network_links_rejects_non_finite_telemetry(): payload = '[{"i":"en0","t":"ethernet","ip":"192.168.1.2","b":NaN}]' assert deserialize_network_links(payload) == () + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("b", True), + ("b", "100"), + ("b", -1), + ("l", True), + ("l", "5"), + ("l", -1), + ("r", True), + ("r", "0.1"), + ("r", -0.1), + ("r", 1.01), + ("m", True), + ("m", 1500.5), + ("m", "1500"), + ("m", 0), + ("m", 65536), + ], +) +def test_deserialize_network_links_rejects_invalid_telemetry_types_and_bounds( + field, value +): + item = {"i": "en0", "t": "ethernet", "ip": "192.168.1.2", field: value} + + assert deserialize_network_links(json.dumps([item])) == () + + +def test_deserialize_network_links_accepts_loss_and_mtu_boundaries(): + payload = json.dumps( + [ + { + "i": "en0", + "t": "ethernet", + "ip": "192.168.1.2", + "r": 1, + "m": 65535, + } + ] + ) + + restored = deserialize_network_links(payload) + + assert len(restored) == 1 + assert restored[0].loss_rate == 1.0 + assert restored[0].mtu == 65535 + + +def test_serialize_network_links_omits_invalid_local_telemetry(): + link = NetworkLink( + "en0", + LinkType.ETHERNET, + "192.168.1.2", + bandwidth_mbps=float("nan"), + latency_ms=-1, + loss_rate=2, + mtu=1500.5, # type: ignore[arg-type] + ) + + payload = json.loads(serialize_network_links([link])) + + assert payload == [{"i": "en0", "t": "ethernet", "ip": "192.168.1.2"}] + + +def test_deserialize_network_links_rejects_too_many_advertisements(): + payload = json.dumps( + [ + {"i": f"en{index}", "t": "ethernet", "ip": f"192.168.1.{index + 1}"} + for index in range(9) + ] + ) + + assert deserialize_network_links(payload) == ()