#!/usr/bin/env perl # Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) # SPDX-License-Identifier: MIT # Checks for network-diag.pl: the address arithmetic, the address classification, the # ping parsing, the MTU family fallback and the small helpers. # # Run from anywhere: perl tests/network-diag.pl use strict; use warnings; my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..'; $root = "./$root" if $root !~ m{^/}; require "$root/network-diag.pl"; my ($passed, $failed) = (0, 0); sub is { my ($got, $want, $label) = @_; $got = 'undef' unless defined $got; $want = 'undef' unless defined $want; if ($got eq $want) { $passed++; print "ok $label\n"; return 1; } $failed++; print "FAIL $label\n got: $got\n want: $want\n"; return 0; } sub check { my ($cond, $label) = @_; return is($cond ? 1 : 0, 1, $label); } sub hex_of { my ($bytes) = @_; return join(' ', map { sprintf('%02x', $_) } unpack('C*', $bytes)); } # --------------------------------------------------------------------------- # IPv4 and IPv6 parsing # --------------------------------------------------------------------------- is(hex_of(ip4_bytes('127.0.0.1')), '7f 00 00 01', 'ip4: 127.0.0.1'); is(hex_of(ip4_bytes('0.0.0.0')), '00 00 00 00', 'ip4: the unspecified address'); is(hex_of(ip4_bytes('255.255.255.255')), 'ff ff ff ff', 'ip4: the broadcast address'); is(ip4_bytes('256.0.0.1'), undef, 'ip4: an octet above 255 is refused'); is(ip4_bytes('1.2.3'), undef, 'ip4: three octets are refused'); is(ip4_bytes('1.2.3.4.5'), undef, 'ip4: five octets are refused'); is(ip4_bytes('::1'), undef, 'ip4: an IPv6 address is refused'); is(hex_of(ip6_bytes('::')), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00', 'ip6: the unspecified address'); is(hex_of(ip6_bytes('::1')), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01', 'ip6: loopback in shorthand'); is(hex_of(ip6_bytes('2001:db8::1')), '20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01', 'ip6: shorthand in the middle'); is(hex_of(ip6_bytes('::ffff:127.0.0.1')), '00 00 00 00 00 00 00 00 00 00 ff ff 7f 00 00 01', 'ip6: an embedded IPv4 address'); is(hex_of(ip6_bytes('fe80::1%eth0')), 'fe 80 00 00 00 00 00 00 00 00 00 00 00 00 00 01', 'ip6: a scope suffix is dropped'); is(hex_of(ip6_bytes('2001:0db8:0000:0000:0000:0000:0000:0001')), '20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01', 'ip6: the full form'); is(ip6_bytes('127.0.0.1'), undef, 'ip6: an IPv4 address is refused'); is(ip6_bytes('2001:db8:::1'), undef, 'ip6: a malformed address is refused'); is(ip6_bytes('2001:db8::1::2'), undef, 'ip6: two shorthands are refused'); is(is_mapped6('::ffff:10.0.0.1'), 1, 'mapped: an IPv4-mapped address'); is(is_mapped6('::1'), 0, 'mapped: loopback is not mapped'); is(is_mapped6('2001:db8::1'), 0, 'mapped: a global address is not mapped'); # --------------------------------------------------------------------------- # sockaddr packing # --------------------------------------------------------------------------- my $sa = sa_in('127.0.0.1', 80); is(length($sa), 16, 'sa_in: a sockaddr_in is 16 bytes'); is(unpack('S', substr($sa, 0, 2)), 2, 'sa_in: the family is AF_INET'); is(unpack('n', substr($sa, 2, 2)), 80, 'sa_in: the port is network order'); is(hex_of(substr($sa, 4, 4)), '7f 00 00 01', 'sa_in: the address follows'); is(hex_of(substr($sa, 8)), '00 00 00 00 00 00 00 00', 'sa_in: the padding is zero'); is(sa_in('::1', 80), undef, 'sa_in: an IPv6 address is refused'); my $sa6 = sa_in6('::1', 443); is(length($sa6), 28, 'sa_in6: a sockaddr_in6 is 28 bytes'); is(unpack('n', substr($sa6, 2, 2)), 443, 'sa_in6: the port is network order'); is(hex_of(substr($sa6, 8, 16)), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01', 'sa_in6: the address follows the flow information'); is(sa_in6('127.0.0.1', 443), undef, 'sa_in6: an IPv4 address is refused'); # --------------------------------------------------------------------------- # Address classification # --------------------------------------------------------------------------- is(class4(ip4_bytes('127.0.0.1')), 'loopback', 'class4: loopback'); is(class4(ip4_bytes('169.254.1.1')), 'linklocal', 'class4: link-local'); is(class4(ip4_bytes('0.0.0.0')), 'unspecified', 'class4: unspecified'); is(class4(ip4_bytes('224.0.0.1')), 'multicast', 'class4: multicast'); is(class4(ip4_bytes('10.0.0.1')), 'private', 'class4: the ten network'); is(class4(ip4_bytes('192.168.1.1')), 'private', 'class4: the 192.168 network'); is(class4(ip4_bytes('172.16.0.1')), 'private', 'class4: the 172.16 network'); is(class4(ip4_bytes('100.64.0.1')), 'private', 'class4: carrier-grade NAT'); is(class4(ip4_bytes('8.8.8.8')), 'global', 'class4: a global address'); is(class6(ip6_bytes('::')), 'unspecified', 'class6: unspecified'); is(class6(ip6_bytes('::1')), 'loopback', 'class6: loopback'); is(class6(ip6_bytes('::ffff:1.2.3.4')), 'mapped', 'class6: IPv4-mapped'); is(class6(ip6_bytes('fe80::1')), 'linklocal', 'class6: link-local'); is(class6(ip6_bytes('ff02::1')), 'multicast', 'class6: multicast'); is(class6(ip6_bytes('fd00::1')), 'private', 'class6: unique local'); is(class6(ip6_bytes('2002::1')), 'private', 'class6: 6to4'); is(class6(ip6_bytes('2001:db8::1')), 'global', 'class6: a documentation prefix'); is(address_kind('v4', '127.0.0.1'), 'loopback', 'address_kind: an IPv4 class'); is(address_kind('v6', '::1'), 'loopback', 'address_kind: an IPv6 class'); is(address_kind('v4', 'not-an-address'), '', 'address_kind: unparsable input is empty'); # --------------------------------------------------------------------------- # Ping output # --------------------------------------------------------------------------- my $iputils = <<'PING'; PING cloudflare.com (104.16.132.229) 56(84) bytes of data. 64 bytes from 104.16.132.229: icmp_seq=1 ttl=57 time=11.2 ms 64 bytes from 104.16.132.229: icmp_seq=2 ttl=57 time=12.4 ms 64 bytes from 104.16.132.229: icmp_seq=3 ttl=57 time=10.1 ms --- cloudflare.com ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 2003ms rtt min/avg/max/mdev = 10.109/11.233/12.401/0.938 ms PING my ($latencies, $received) = parse_ping_output($iputils); is(scalar(@$latencies), 3, 'ping: three replies are parsed'); is($latencies->[0], '11.2', 'ping: the first latency'); is($latencies->[2], '10.1', 'ping: the last latency'); is($received, 3, 'ping: the received counter'); my $lossy = <<'PING'; 2 packets transmitted, 1 received, 50% packet loss, time 1001ms PING my ($none, $one) = parse_ping_output($lossy); is(scalar(@$none), 0, 'ping: a transcript without replies has no latencies'); is($one, 1, 'ping: a partial loss is counted'); my ($min, $max, $sum) = min_max_sum([3, 1, 2]); is($min, 1, 'latency: the minimum'); is($max, 3, 'latency: the maximum'); is($sum, 6, 'latency: the sum'); # --------------------------------------------------------------------------- # Listening sockets # --------------------------------------------------------------------------- my @ss_ports = parse_ss_output( "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\n" . "LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd daemon\",pid=977,fd=3))\n" . "LISTEN 0 128 [::]:22 [::]:*\n" ); is($ss_ports[0]{process}, 'users:(("sshd daemon",pid=977,fd=3))', 'ss: a process name with spaces is kept whole'); is($ss_ports[0]{bucket}, 'tcp4', 'ss: a plain local address is IPv4'); is($ss_ports[1]{process}, '', 'ss: a socket without permission has no process'); is($ss_ports[1]{bucket}, 'tcp6', 'ss: a bracketed local address is IPv6'); # --------------------------------------------------------------------------- # Grading # --------------------------------------------------------------------------- is(grade_letter(100), 'A', 'grade: a perfect score'); is(grade_letter(90), 'A', 'grade: the A boundary'); is(grade_letter(89.9), 'B', 'grade: just under the A boundary'); is(grade_letter(80), 'B', 'grade: the B boundary'); is(grade_letter(70), 'C', 'grade: the C boundary'); is(grade_letter(60), 'D', 'grade: the D boundary'); is(grade_letter(59.9), 'F', 'grade: just under the D boundary'); is(grade_letter(0), 'F', 'grade: zero'); my $graded = compute_grade({}); is(ref($graded), 'HASH', 'grade: the verdict is a hash'); is($graded->{score}, 0, 'grade: no data scores zero'); is($graded->{grade}, 'F', 'grade: no data fails'); check($graded->{max_score} > 0, 'grade: the maximum is reported'); check(ref($graded->{breakdown}) eq 'HASH', 'grade: the breakdown is a hash'); my $full = compute_grade({ ping => { loss_pct => 0, avg_ms => 10 }, dns => { v4 => { 'example.org' => [{ ms => 5 }] } }, }); check($full->{score} >= 0 && $full->{score} <= $full->{max_score}, 'grade: a measured run stays within the maximum'); is(ms_colour(undef), "\033[2m", 'colour: an unmeasured latency is dim'); is(ms_colour(0), "\033[2m", 'colour: a zero latency is dim'); check(ms_colour(5) ne ms_colour(5000), 'colour: the bands differ'); # --------------------------------------------------------------------------- # Serialisation and arguments # --------------------------------------------------------------------------- is(perl_literal(5), '5', 'literal: an integer stays bare'); is(perl_literal(1.5), '1.5', 'literal: a float stays bare'); is(perl_literal('abc'), "'abc'", 'literal: a word is quoted'); is(perl_literal('42x'), "'42x'", 'literal: a number-like word is quoted'); is(perl_literal(undef), 'undef', 'literal: undef'); is(perl_literal("it's"), "'it\\'s'", 'literal: a quote is escaped'); is(perl_literal([1, 2]), '[1, 2]', 'literal: an array'); is(perl_literal({ b => 2, a => 1 }), "{ 'a' => 1, 'b' => 2 }", 'literal: a hash in key order'); eval { write_literal('/nonexistent/network-diag-check', 1) }; check(scalar($@ =~ /cannot write /), 'result write: a failed write names the operation'); { local $ENV{TMPDIR} = ''; my $scratch = eval { scratch_dir() }; check(defined($scratch) && index($scratch // '', '/tmp/') == 0, 'scratch: an empty TMPDIR falls back to /tmp'); remove_scratch() if defined $scratch; } my $report_out = ''; open(my $report_cap, '>', \$report_out) or die "cannot capture the report: $!\n"; { local *STDOUT = $report_cap; print_report({ hostname => 'testhost', os => 'linux', dns => { error => 'probe boom' } }); } close($report_cap); check(index($report_out, 'unavailable: probe boom') >= 0, 'report: a failed DNS check names its error'); my @saved = @ARGV; @ARGV = ('--target', 'example.org', '--protocol', 'v6'); my %args = parse_args(); is($args{target}, 'example.org', 'args: --target takes a value'); is($args{protocol}, 'v6', 'args: --protocol takes a value'); @ARGV = @saved; @ARGV = (); my %defaults = parse_args(); is($defaults{protocol}, 'auto', 'args: the family defaults to auto'); is($defaults{target}, 'cloudflare.com', 'args: the target has a default'); @ARGV = @saved; # --------------------------------------------------------------------------- # The MTU probe, through fixture binaries on a private PATH # --------------------------------------------------------------------------- # A fake getent answers the resolver probe and one fixed name, and a fake ping # models a host whose IPv6 path is dead while IPv4 passes a 1500 B DF probe. # Together they exercise the family fallback of check_mtu end to end without # touching the network. sub install_fake_bin { my ($dir, $name, $body) = @_; open(my $fh, '>', "$dir/$name") or die "cannot write $dir/$name: $!\n"; print {$fh} "#!/usr/bin/env perl\n", $body; close($fh); chmod(0755, "$dir/$name") or die "cannot chmod $dir/$name: $!\n"; return; } my $bindir = "/tmp/network-diag-test-bin.$$"; mkdir($bindir, 0700) or die "cannot create $bindir: $!\n"; { local $ENV{PATH} = "$bindir:$ENV{PATH}"; install_fake_bin($bindir, 'getent', <<'FIXTURE'); my ($verb, $host) = @ARGV; if ($verb eq 'ahostsv4' && $host eq 'localhost') { print "127.0.0.1 STREAM\n"; print "# the localhost resolver probe\n"; exit 0; } if ($host eq 'mtu-fallback.test') { print $verb eq 'ahostsv6' ? "2001:db8::1 STREAM\n" : "192.0.2.10 STREAM\n"; exit 0; } exit 2; FIXTURE install_fake_bin($bindir, 'ping', <<'FIXTURE'); my ($family, $payload) = ('', 0); for (my $i = 0; $i < @ARGV; $i++) { $family = $ARGV[$i] if $ARGV[$i] eq '-4' || $ARGV[$i] eq '-6'; $payload = $ARGV[$i + 1] if $ARGV[$i] eq '-s'; } if ($family eq '-6' || $payload > 1472) { print "1 packets transmitted, 0 received, 100% packet loss, time 0ms\n"; exit 1; } print "1 packets transmitted, 1 received, 0% packet loss, time 0ms\n"; exit 0; FIXTURE my $auto = check_mtu('mtu-fallback.test', 'auto', 'linux'); is($auto->{path_mtu}, 1500, 'mtu: a dead preferred family falls back to the other'); check(index($auto->{method} // '', 'IPv4 fallback') >= 0, 'mtu: the fallback names the family that answered'); my $v6 = check_mtu('mtu-fallback.test', 'v6', 'linux'); is($v6->{path_mtu}, 0, 'mtu: an explicit protocol choice does not fall back'); is($v6->{method}, 'ping DF probe (no probe size succeeded)', 'mtu: an explicit protocol choice reports the failure'); my $v4 = check_mtu('mtu-fallback.test', 'v4', 'linux'); is($v4->{path_mtu}, 1500, 'mtu: the preferred family answering needs no fallback'); is($v4->{method}, 'ping DF probe (1500B OK)', 'mtu: a direct hit carries no fallback note'); } unlink(glob("$bindir/*")); rmdir($bindir) or die "cannot remove $bindir: $!\n"; print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n"; exit($failed ? 1 : 0);