import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:sm_mobile/services/net/icmp_packet.dart'; import 'package:sm_mobile/services/net/icmp_probe.dart'; import 'package:sm_mobile/services/net/posix.dart'; /// IPv4 header (20 bytes, protocol ICMP) in front of [icmp]. Uint8List ipv4(List icmp, {int protocol = 1}) => Uint8List.fromList([ 0x45, 0, 0, 0, 0, 0, 0, 0, 64, protocol, 0, 0, // 192, 0, 2, 1, 192, 0, 2, 2, ...icmp, ]); /// IPv6 header (40 bytes, next header ICMPv6) in front of [icmp]. Uint8List ipv6(List icmp, {int next = 58}) => Uint8List.fromList([ 0x60, 0, 0, 0, 0, 8, next, 64, ...List.filled(32, 0), ...icmp, ]); Future> collect(IcmpRun run) => run.events.toList().timeout(const Duration(seconds: 60)); void main() { group('ICMP packets', () { test('echo request layout and RFC 1071 checksum', () { final p = buildEchoRequest(v6: false, id: 0x1234, seq: 7, size: 4); expect(p.sublist(0, 2), [8, 0]); expect(p.sublist(4, 8), [0x12, 0x34, 0, 7]); expect(icmpChecksum(p), 0, reason: 'a valid packet sums to zero'); final v6 = buildEchoRequest(v6: true, id: 1, seq: 2, size: 0); expect(v6[0], 128); expect(v6.length, 8); // RFC 1071 example data. expect( icmpChecksum( Uint8List.fromList([0, 1, 0xf2, 3, 0xf4, 0xf5, 0xf6, 0xf7]), ), ~0xddf2 & 0xffff, ); expect(icmpChecksum(Uint8List.fromList([1])), ~0x0100 & 0xffff); }); test('echo replies with and without the IPv4 header', () { final reply = [0, 0, 0, 0, 0xab, 0xcd, 0, 9]; final darwin = parseIcmp(ipv4(reply), v6: false, ipHeader: true)!; expect( (darwin.kind, darwin.id, darwin.seq), (IcmpKind.echoReply, 0xabcd, 9), ); final linux = parseIcmp(Uint8List.fromList(reply), v6: false)!; expect((linux.kind, linux.seq), (IcmpKind.echoReply, 9)); final six = parseIcmp( Uint8List.fromList([129, 0, 0, 0, 0, 1, 0, 2]), v6: true, )!; expect((six.kind, six.id, six.seq), (IcmpKind.echoReply, 1, 2)); }); test('errors are matched by the quoted echo request', () { final quoted = ipv4([8, 0, 0, 0, 0x12, 0x34, 0, 5]); final exceeded = parseIcmp( ipv4([11, 0, 0, 0, 0, 0, 0, 0, ...quoted]), v6: false, ipHeader: true, )!; expect( (exceeded.kind, exceeded.id, exceeded.seq), (IcmpKind.timeExceeded, 0x1234, 5), ); final unreachable = parseIcmp( Uint8List.fromList([3, 3, 0, 0, 0, 0, 0, 0, ...quoted]), v6: false, )!; expect((unreachable.kind, unreachable.code), (IcmpKind.unreachable, 3)); expect(unreachable.seq, 5); final quoted6 = ipv6([128, 0, 0, 0, 0, 7, 0, 8]); final six = parseIcmp( Uint8List.fromList([3, 0, 0, 0, 0, 0, 0, 0, ...quoted6]), v6: true, )!; expect((six.kind, six.id, six.seq), (IcmpKind.timeExceeded, 7, 8)); final unreachable6 = parseIcmp( Uint8List.fromList([1, 4, 0, 0, 0, 0, 0, 0, ...quoted6]), v6: true, )!; expect((unreachable6.kind, unreachable6.code), (IcmpKind.unreachable, 4)); }); test('foreign, truncated and malformed messages are not matched', () { expect(parseIcmp(Uint8List(3), v6: false), isNull); expect( parseIcmp(Uint8List.fromList([0x60, 0, 0]), v6: false, ipHeader: true), isNull, ); // Time exceeded quoting a UDP packet (someone else's traceroute). final udp = parseIcmp( ipv4([ 11, 0, 0, 0, 0, 0, 0, 0, ...ipv4(List.filled(8, 0), protocol: 17), ]), v6: false, ipHeader: true, )!; expect((udp.kind, udp.seq), (IcmpKind.timeExceeded, null)); // Quoted packet cut short. expect( parseIcmp( Uint8List.fromList([11, 0, 0, 0, 0, 0, 0, 0, 0x45]), v6: false, )!.seq, isNull, ); // ICMPv6 error quoting a non-ICMPv6 packet. expect( parseIcmp( Uint8List.fromList([ 3, 0, 0, 0, 0, 0, 0, 0, ...ipv6(List.filled(8, 0), next: 17), ]), v6: true, )!.seq, isNull, ); // Router advertisements and others are ignored. expect( parseIcmp( Uint8List.fromList([134, 0, 0, 0, 0, 0, 0, 0]), v6: true, )!.kind, IcmpKind.other, ); expect( sentEcho(Uint8List.fromList([8, 0, 0, 0, 0, 1, 0, 2]), v6: false), (id: 1, seq: 2), ); expect( sentEcho(Uint8List.fromList([0, 0, 0, 0, 0, 1, 0, 2]), v6: false), isNull, ); }); test('statistics: min, average, maximum and loss', () { final stats = PingStats.of([ const ProbeEvent(1, ProbeStatus.reply, rttMs: 10), const ProbeEvent(2, ProbeStatus.timeout), const ProbeEvent(3, ProbeStatus.reply, rttMs: 30), const ProbeEvent(4, ProbeStatus.unreachable), ]); expect((stats.sent, stats.received), (4, 2)); expect((stats.min, stats.avg, stats.max), (10.0, 20.0, 30.0)); expect(stats.lossPercent, 50); final none = PingStats.of(const []); expect((none.lossPercent, none.avg), (0.0, null)); }); test('socket addresses for both platform layouts', () { final p = Posix.current!; for (final a in [ InternetAddress('192.0.2.7'), InternetAddress('2001:db8::7'), ]) { final raw = p.sockaddr(a); expect(p.address(raw, raw.length), a); } }); }); // Real sockets on this host. On macOS this is the same Darwin socket API // that iOS uses; Android uses the Linux implementation (device test). group('real ICMP on this host (loopback)', () { test('ping 127.0.0.1 and ::1', () async { for (final target in [ InternetAddress.loopbackIPv4, InternetAddress.loopbackIPv6, ]) { final events = await collect( IcmpProbe.ping( target, const PingSettings(count: 3, interval: Duration(milliseconds: 200)), ), ); expect(events.map((e) => e.seq), [1, 2, 3], reason: '$target'); expect( events.every((e) => e.status == ProbeStatus.reply), true, reason: '$target', ); expect(events.every((e) => e.from == target), true); expect(events.every((e) => e.rttMs! >= 0 && e.rttMs! < 1000), true); } }); test('payload sizes from 0 to 1400 bytes', () async { for (final size in [0, 8, 1400]) { final events = await collect( IcmpProbe.ping( InternetAddress.loopbackIPv4, PingSettings(count: 1, size: size), ), ); expect(events.single.status, ProbeStatus.reply, reason: '$size B'); } }); test('traceroute to loopback reaches the target at hop 1', () async { final events = await collect( IcmpProbe.traceroute( InternetAddress.loopbackIPv4, const TraceSettings(), ), ); expect(events.length, 3); expect( events.every((e) => e.ttl == 1 && e.status == ProbeStatus.reply), true, ); }); test('cancel stops at once and sends nothing more', () async { final run = IcmpProbe.ping( InternetAddress.loopbackIPv4, const PingSettings(count: 100, interval: Duration(seconds: 1)), ); final events = []; final first = Completer(); final done = Completer(); run.events.listen((e) { events.add(e); if (!first.isCompleted) first.complete(); }, onDone: done.complete); await first.future.timeout(const Duration(seconds: 10)); final watch = Stopwatch()..start(); await run.cancel(); await done.future.timeout(const Duration(seconds: 2)); expect(watch.elapsedMilliseconds, lessThan(1000)); final count = events.length; await Future.delayed(const Duration(seconds: 2)); expect(events.length, count, reason: 'no events after cancel'); expect(count, lessThan(5)); }); test('an unanswered target times out instead of hanging', () async { // 192.0.2.0/24 is TEST-NET-1 (RFC 5737): never answered. final events = await collect( IcmpProbe.ping( InternetAddress('192.0.2.1'), const PingSettings( count: 2, interval: Duration(milliseconds: 200), timeout: Duration(milliseconds: 500), ), ), ); expect(events.length, 2); expect( events.every( (e) => e.status == ProbeStatus.timeout || e.status == ProbeStatus.unreachable || e.status == ProbeStatus.sendFailed, ), true, ); expect(PingStats.of(events).lossPercent, 100); }); }); group('extended probes on this host (loopback)', () { test('UDP traceroute: the destination answers port unreachable', () async { for (final target in [ InternetAddress.loopbackIPv4, InternetAddress.loopbackIPv6, ]) { final events = await collect( IcmpProbe.traceroute( target, const TraceSettings(method: TraceMethod.udp, probes: 2), ), ); expect(events, hasLength(2), reason: '$target'); expect( events.every( (e) => e.ttl == 1 && e.status == ProbeStatus.reply && e.note == 'portUnreachable' && e.from == target, ), true, reason: '$target', ); } }); test( 'TCP traceroute: open port (SYN-ACK) and closed port (reset)', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final port = server.port; final open = await collect( IcmpProbe.traceroute( InternetAddress.loopbackIPv4, TraceSettings(method: TraceMethod.tcp, port: port, probes: 2), ), ); expect(open.map((e) => e.note), ['tcpOpen', 'tcpOpen']); await server.close(); final closed = await collect( IcmpProbe.traceroute( InternetAddress.loopbackIPv4, TraceSettings(method: TraceMethod.tcp, port: port, probes: 1), ), ); expect(closed.single.status, ProbeStatus.reply); expect(closed.single.note, 'tcpRefused'); }, skip: Posix.current?.darwin == true ? false : 'TCP traceroute is Darwin only', ); test('first TTL and probe interval are honoured', () async { final watch = Stopwatch()..start(); final events = await collect( IcmpProbe.traceroute( InternetAddress.loopbackIPv4, const TraceSettings( firstTtl: 5, probes: 3, interval: Duration(milliseconds: 150), ), ), ); expect(events.every((e) => e.ttl == 5), true); expect(watch.elapsedMilliseconds, greaterThanOrEqualTo(290)); }); test('ping with TTL, Don\'t Fragment and continuous mode', () async { final ttl = await collect( IcmpProbe.ping( InternetAddress.loopbackIPv4, const PingSettings(count: 2, ttl: 3, dontFragment: true), ), ); expect(ttl.every((e) => e.status == ProbeStatus.reply), true); expect(ttl.every((e) => e.time != null), true); // A payload above the loopback MTU cannot leave unfragmented. final big = await collect( IcmpProbe.ping( InternetAddress.loopbackIPv4, const PingSettings(count: 1, size: 20000, dontFragment: true), ), ); expect(big.single.status, ProbeStatus.sendFailed); expect(big.single.note, 'messageTooLong'); final run = IcmpProbe.ping( InternetAddress.loopbackIPv4, const PingSettings(count: 0, interval: Duration(milliseconds: 200)), ); final got = []; final done = Completer(); run.events.listen(got.add, onDone: done.complete); await Future.delayed(const Duration(milliseconds: 1500)); await run.cancel(); await done.future.timeout(const Duration(seconds: 2)); expect(got.length, greaterThanOrEqualTo(4)); expect(got.map((e) => e.seq).take(4), [1, 2, 3, 4]); }, skip: Posix.current?.darwin == true ? false : 'checked on device'); }); // Real Internet measurements; opt-in because they need network access. final internet = Platform.environment['SM_NET_TEST'] == '1'; group('real ICMP over the Internet', () { for (final target in ['1.1.1.1', '2606:4700:4700::1111']) { test('ping and traceroute $target', () async { final address = InternetAddress(target); final ping = await collect( IcmpProbe.ping(address, const PingSettings(count: 3)), ); expect(PingStats.of(ping).received, greaterThan(0)); final trace = await collect( IcmpProbe.traceroute(address, const TraceSettings(maxHops: 30)), ); final hops = trace.map((e) => e.ttl).toSet(); expect(hops.length, greaterThan(1), reason: 'intermediate routers'); expect( trace.any((e) => e.status == ProbeStatus.timeExceeded), true, reason: 'routers answered with Time Exceeded', ); expect( trace .where((e) => e.status == ProbeStatus.reply) .every((e) => e.from == address), true, ); for (final method in [TraceMethod.udp, TraceMethod.tcp]) { if (IcmpProbe.unsupported(method) != null) continue; final other = await collect( IcmpProbe.traceroute( address, TraceSettings( method: method, port: method == TraceMethod.tcp ? 443 : null, ), ), ); expect( other.any((e) => e.status == ProbeStatus.timeExceeded), true, reason: '$method: routers answered', ); expect( other.any((e) => e.status == ProbeStatus.reply), true, reason: '$method: destination reached', ); } // ignore: avoid_print print( [ for (final e in trace) '${e.ttl}.${e.seq} ${e.status.name} ${e.from?.address ?? '*'} ' '${e.rttMs?.toStringAsFixed(1) ?? ''}', ].join('\n'), ); }, timeout: const Timeout(Duration(minutes: 5))); } }, skip: !internet); }