diff --git a/src/netcheck/traceparse.py b/src/netcheck/traceparse.py index 5df9f76..3b27059 100644 --- a/src/netcheck/traceparse.py +++ b/src/netcheck/traceparse.py @@ -110,3 +110,40 @@ def parse_windows(text: str) -> list[TraceHop]: rdns = None hops.append(finalize_hop(TraceHop(ttl=ttl, ip=ip, reverse_dns=rdns, probes=probes))) return hops + + +_BSD_CONTINUATION_RE = re.compile(r"^\s+\S") + + +def parse_darwin(text: str) -> list[TraceHop]: + hops: list[TraceHop] = [] + for line in text.splitlines(): + match = _HOP_LINE_RE.match(line) + if match: + ttl, body = int(match.group(1)), match.group(2) + rdns, ip = _unix_host_and_ip(body) + hops.append( + TraceHop( + ttl=ttl, + ip=ip, + reverse_dns=rdns, + probes=_unix_probes(body), + annotations=_unix_annotations(body), + ) + ) + continue + if not hops or not _BSD_CONTINUATION_RE.match(line): + continue + # BSD prints one continuation line per probe that came back from a + # different router than the hop's first probe. + hop = hops[-1] + _, extra_ip = _unix_host_and_ip(line) + hop.probes.extend(_unix_probes(line)) + if extra_ip and extra_ip != hop.ip: + marker = f"alt:{extra_ip}" + if marker not in hop.annotations: + hop.annotations.append(marker) + for token in _unix_annotations(line): + if token not in hop.annotations: + hop.annotations.append(token) + return [finalize_hop(hop) for hop in hops] diff --git a/tests/fixtures/traceroute/darwin/bsd_basic.txt b/tests/fixtures/traceroute/darwin/bsd_basic.txt new file mode 100644 index 0000000..1f4f420 --- /dev/null +++ b/tests/fixtures/traceroute/darwin/bsd_basic.txt @@ -0,0 +1,6 @@ +traceroute to one.one.one.one (1.1.1.1), 64 hops max, 52 byte packets + 1 192.168.1.1 (192.168.1.1) 2.145 ms 1.902 ms 1.870 ms + 2 10.64.0.1 (10.64.0.1) 9.001 ms 8.940 ms 8.870 ms + 3 * * * + 4 ae-1.r01.ams.example.net (77.88.1.5) 12.400 ms 12.100 ms 12.220 ms + 5 one.one.one.one (1.1.1.1) 13.900 ms 13.700 ms 13.610 ms diff --git a/tests/fixtures/traceroute/darwin/bsd_multipath.txt b/tests/fixtures/traceroute/darwin/bsd_multipath.txt new file mode 100644 index 0000000..8477b49 --- /dev/null +++ b/tests/fixtures/traceroute/darwin/bsd_multipath.txt @@ -0,0 +1,8 @@ +traceroute to 203.0.113.9 (203.0.113.9), 64 hops max, 52 byte packets + 1 192.168.1.1 (192.168.1.1) 2.100 ms 1.900 ms 1.850 ms + 2 10.64.0.1 (10.64.0.1) 9.001 ms + 10.64.0.5 (10.64.0.5) 9.220 ms + 10.64.0.1 (10.64.0.1) 8.870 ms + 3 * 11.200 ms * + 4 border.example.net (198.51.100.7) 15.100 ms !H 15.200 ms 15.050 ms + 5 filtered.example.net (198.51.100.9) 16.000 ms !X 16.100 ms !X 16.200 ms !X diff --git a/tests/test_traceparse.py b/tests/test_traceparse.py index 39908f4..bcb9e61 100644 --- a/tests/test_traceparse.py +++ b/tests/test_traceparse.py @@ -141,3 +141,45 @@ def test_windows_destination_unreachable_hop_keeps_its_ip(trace_fixture): def test_windows_parser_tolerates_empty_input(): assert parse_windows("") == [] + + +from netcheck.traceparse import parse_darwin + + +def test_darwin_basic_hops_match_the_gnu_shape(trace_fixture): + hops = parse_darwin(trace_fixture("darwin", "bsd_basic.txt")) + assert [h.ttl for h in hops] == [1, 2, 3, 4, 5] + assert hops[0].ip == "192.168.1.1" + assert hops[0].reverse_dns is None + assert hops[3].reverse_dns == "ae-1.r01.ams.example.net" + assert hops[2].probes == [None, None, None] + + +def test_darwin_merges_continuation_lines_into_one_hop(trace_fixture): + hops = parse_darwin(trace_fixture("darwin", "bsd_multipath.txt")) + assert [h.ttl for h in hops] == [1, 2, 3, 4, 5] + hop = hops[1] + assert hop.probes == [9.001, 9.22, 8.87] + assert hop.ip == "10.64.0.1" + + +def test_darwin_records_alternate_addresses_for_a_multipath_hop(trace_fixture): + hops = parse_darwin(trace_fixture("darwin", "bsd_multipath.txt")) + assert "alt:10.64.0.5" in hops[1].annotations + assert "alt:10.64.0.1" not in hops[1].annotations + + +def test_darwin_keeps_bsd_only_annotations(trace_fixture): + hops = parse_darwin(trace_fixture("darwin", "bsd_multipath.txt")) + assert hops[3].annotations == ["!H"] + assert hops[4].annotations == ["!X"] + + +def test_darwin_partial_timeout_hop_keeps_positions(trace_fixture): + hops = parse_darwin(trace_fixture("darwin", "bsd_multipath.txt")) + assert hops[2].probes == [None, 11.2, None] + assert hops[2].loss_pct == pytest.approx(66.667) + + +def test_darwin_parser_tolerates_empty_input(): + assert parse_darwin("") == []