From ecd9436427879b4ef798d55d36b62ac7163cbff3 Mon Sep 17 00:00:00 2001 From: Simon Lin Date: Wed, 12 Aug 2020 00:26:28 +0800 Subject: [PATCH] [scripts] add packet verification framework (#4428) This commit introduces the packet verification (PV) framework for certification tests. - Add packet verification framework code - Implement packet verification for cert 5.1.7 as a minimal example. There will be more 1.1/1.2 tests with PV submitted in the future. - Download pre-built thread-wireshark binaries from openthread/wireshark/releases for packet dissecting (used by pyshark) - Added a Github Action job for Packet Verification --- .github/workflows/posix.yml | 4 +- .github/workflows/simulation-1.2.yml | 2 +- .github/workflows/simulation.yml | 38 +- .../thread-cert/Cert_5_1_07_MaxChildCount.py | 55 ++ tests/scripts/thread-cert/Makefile.am | 20 + tests/scripts/thread-cert/node.py | 3 +- .../scripts/thread-cert/pktverify/__init__.py | 33 + tests/scripts/thread-cert/pktverify/addrs.py | 189 +++++ tests/scripts/thread-cert/pktverify/bytes.py | 159 +++++ tests/scripts/thread-cert/pktverify/coap.py | 185 +++++ tests/scripts/thread-cert/pktverify/consts.py | 146 ++++ .../thread-cert/pktverify/decorators.py | 62 ++ tests/scripts/thread-cert/pktverify/errors.py | 64 ++ .../thread-cert/pktverify/layer_fields.py | 658 ++++++++++++++++++ .../pktverify/layer_fields_container.py | 84 +++ tests/scripts/thread-cert/pktverify/layers.py | 181 +++++ .../thread-cert/pktverify/null_field.py | 121 ++++ tests/scripts/thread-cert/pktverify/packet.py | 148 ++++ .../thread-cert/pktverify/packet_filter.py | 466 +++++++++++++ .../thread-cert/pktverify/packet_verifier.py | 449 ++++++++++++ .../thread-cert/pktverify/pcap_reader.py | 76 ++ .../scripts/thread-cert/pktverify/summary.py | 188 +++++ .../thread-cert/pktverify/test_info.py | 87 +++ .../pktverify/test_layer_fields.py | 354 ++++++++++ tests/scripts/thread-cert/pktverify/utils.py | 152 ++++ .../thread-cert/pktverify/verify_result.py | 85 +++ tests/scripts/thread-cert/requirements.txt | 1 + tests/scripts/thread-cert/simulator.py | 17 +- tests/scripts/thread-cert/thread_cert.py | 121 +++- 29 files changed, 4132 insertions(+), 16 deletions(-) create mode 100644 tests/scripts/thread-cert/pktverify/__init__.py create mode 100644 tests/scripts/thread-cert/pktverify/addrs.py create mode 100644 tests/scripts/thread-cert/pktverify/bytes.py create mode 100644 tests/scripts/thread-cert/pktverify/coap.py create mode 100644 tests/scripts/thread-cert/pktverify/consts.py create mode 100644 tests/scripts/thread-cert/pktverify/decorators.py create mode 100644 tests/scripts/thread-cert/pktverify/errors.py create mode 100644 tests/scripts/thread-cert/pktverify/layer_fields.py create mode 100644 tests/scripts/thread-cert/pktverify/layer_fields_container.py create mode 100644 tests/scripts/thread-cert/pktverify/layers.py create mode 100644 tests/scripts/thread-cert/pktverify/null_field.py create mode 100644 tests/scripts/thread-cert/pktverify/packet.py create mode 100644 tests/scripts/thread-cert/pktverify/packet_filter.py create mode 100644 tests/scripts/thread-cert/pktverify/packet_verifier.py create mode 100644 tests/scripts/thread-cert/pktverify/pcap_reader.py create mode 100644 tests/scripts/thread-cert/pktverify/summary.py create mode 100644 tests/scripts/thread-cert/pktverify/test_info.py create mode 100644 tests/scripts/thread-cert/pktverify/test_layer_fields.py create mode 100644 tests/scripts/thread-cert/pktverify/utils.py create mode 100644 tests/scripts/thread-cert/pktverify/verify_result.py diff --git a/.github/workflows/posix.yml b/.github/workflows/posix.yml index 600ddf4a2..d157c813f 100644 --- a/.github/workflows/posix.yml +++ b/.github/workflows/posix.yml @@ -55,7 +55,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y libreadline6-dev + sudo apt-get --no-install-recommends install -y libreadline6-dev python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt - name: Build run: | @@ -83,7 +83,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y libreadline6-dev python3-setuptools + sudo apt-get --no-install-recommends install -y libreadline6-dev python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt sudo python3 -m pip install git+https://github.com/openthread/pyspinel - name: Build diff --git a/.github/workflows/simulation-1.2.yml b/.github/workflows/simulation-1.2.yml index fab1c864c..f212ec6af 100644 --- a/.github/workflows/simulation-1.2.yml +++ b/.github/workflows/simulation-1.2.yml @@ -61,7 +61,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y g++-multilib ninja-build + sudo apt-get --no-install-recommends install -y g++-multilib ninja-build python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt - name: Build run: | diff --git a/.github/workflows/simulation.yml b/.github/workflows/simulation.yml index 68a5a3ae4..fd5823c2f 100644 --- a/.github/workflows/simulation.yml +++ b/.github/workflows/simulation.yml @@ -52,7 +52,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y llvm-runtime + sudo apt-get --no-install-recommends install -y llvm-runtime python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt - name: Run run: | @@ -90,6 +90,31 @@ jobs: - name: Codecov uses: codecov/codecov-action@v1 + + packet-verification: + runs-on: ubuntu-18.04 + env: + REFERENCE_DEVICE: 1 + VIRTUAL_TIME: 1 + PACKET_VERIFICATION: 1 + steps: + - uses: actions/checkout@v2 + - name: Bootstrap + run: | + sudo rm /etc/apt/sources.list.d/* && sudo apt-get update + sudo apt-get --no-install-recommends install -y g++-multilib python3-setuptools python3-wheel + python3 -m pip install -r tests/scripts/thread-cert/requirements.txt + - name: Build + run: | + ./bootstrap + make -f examples/Makefile-simulation + - name: Run + run: | + VERBOSE=1 make -f examples/Makefile-simulation check + - name: Codecov + uses: codecov/codecov-action@v1 + + cli-ftd: runs-on: ubuntu-18.04 env: @@ -104,7 +129,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y g++-multilib + sudo apt-get --no-install-recommends install -y g++-multilib python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt - name: Build run: | @@ -131,7 +156,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y g++-multilib + sudo apt-get --no-install-recommends install -y g++-multilib python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt - name: Build run: | @@ -158,7 +183,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y g++-multilib + sudo apt-get --no-install-recommends install -y g++-multilib python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt - name: Build run: | @@ -246,6 +271,7 @@ jobs: - uses: actions/checkout@v2 - name: Bootstrap run: | + sudo apt-get --no-install-recommends install -y python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt - name: Build run: | @@ -273,7 +299,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y g++-multilib python3-setuptools + sudo apt-get --no-install-recommends install -y g++-multilib python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt sudo python3 -m pip install git+https://github.com/openthread/pyspinel - name: Build @@ -301,7 +327,7 @@ jobs: - name: Bootstrap run: | sudo rm /etc/apt/sources.list.d/* && sudo apt-get update - sudo apt-get --no-install-recommends install -y python3-setuptools + sudo apt-get --no-install-recommends install -y python3-setuptools python3-wheel python3 -m pip install -r tests/scripts/thread-cert/requirements.txt sudo python3 -m pip install git+https://github.com/openthread/pyspinel - name: Build diff --git a/tests/scripts/thread-cert/Cert_5_1_07_MaxChildCount.py b/tests/scripts/thread-cert/Cert_5_1_07_MaxChildCount.py index ebb8b68fa..dd1dcbdf1 100755 --- a/tests/scripts/thread-cert/Cert_5_1_07_MaxChildCount.py +++ b/tests/scripts/thread-cert/Cert_5_1_07_MaxChildCount.py @@ -31,6 +31,8 @@ import unittest import config import thread_cert +from pktverify.consts import MLE_PARENT_RESPONSE, MLE_CHILD_ID_RESPONSE +from pktverify.packet_verifier import PacketVerifier LEADER = 1 ROUTER = 2 @@ -40,11 +42,13 @@ SED1 = 7 class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): TOPOLOGY = { LEADER: { + 'name': 'LEADER', 'mode': 'rsdn', 'panid': 0xface, 'whitelist': [ROUTER] }, ROUTER: { + 'name': 'ROUTER', 'max_children': 10, 'mode': 'rsdn', 'panid': 0xface, @@ -52,6 +56,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [LEADER, 3, 4, 5, 6, SED1, 8, 9, 10, 11, 12] }, 3: { + 'name': 'MED1', 'is_mtd': True, 'mode': 'rsn', 'panid': 0xface, @@ -59,6 +64,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 4: { + 'name': 'MED2', 'is_mtd': True, 'mode': 'rsn', 'panid': 0xface, @@ -66,6 +72,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 5: { + 'name': 'MED3', 'is_mtd': True, 'mode': 'rsn', 'panid': 0xface, @@ -73,6 +80,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 6: { + 'name': 'MED4', 'is_mtd': True, 'mode': 'rsn', 'panid': 0xface, @@ -80,6 +88,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, SED1: { + 'name': 'SED1', 'is_mtd': True, 'mode': 's', 'panid': 0xface, @@ -87,6 +96,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 8: { + 'name': 'SED2', 'is_mtd': True, 'mode': 's', 'panid': 0xface, @@ -94,6 +104,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 9: { + 'name': 'SED3', 'is_mtd': True, 'mode': 's', 'panid': 0xface, @@ -101,6 +112,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 10: { + 'name': 'SED4', 'is_mtd': True, 'mode': 's', 'panid': 0xface, @@ -108,6 +120,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 11: { + 'name': 'SED5', 'is_mtd': True, 'mode': 's', 'panid': 0xface, @@ -115,6 +128,7 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): 'whitelist': [ROUTER] }, 12: { + 'name': 'SED6', 'is_mtd': True, 'mode': 's', 'panid': 0xface, @@ -137,6 +151,9 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): self.simulator.go(7) self.assertEqual(self.nodes[i].get_state(), 'child') + self.collect_rloc16s() + self.collect_ipaddrs() + ipaddrs = self.nodes[SED1].get_addrs() for addr in ipaddrs: if addr[0:4] != 'fe80' and 'ff:fe00' not in addr: @@ -150,6 +167,44 @@ class Cert_5_1_07_MaxChildCount(thread_cert.TestCase): self.assertTrue(self.nodes[LEADER].ping(addr, size=106)) break + def verify(self, pv: PacketVerifier): + pkts = pv.pkts + pv.summary.show() + + ROUTER = pv.vars['ROUTER'] + router_pkts = pkts.filter_wpan_src64(ROUTER) + + # Step 1: The DUT MUST send properly formatted MLE Parent Response + # and MLE Child ID Response to each child. + for i in range(1, 7): + _pkts = router_pkts.copy().filter_wpan_dst64(pv.vars['SED%d' % i]) + _pkts.filter_mle_cmd(MLE_PARENT_RESPONSE).must_next() + _pkts.filter_mle_cmd(MLE_CHILD_ID_RESPONSE).must_next() + + for i in range(1, 5): + _pkts = router_pkts.copy().filter_wpan_dst64(pv.vars['MED%d' % i]) + _pkts.filter_mle_cmd(MLE_PARENT_RESPONSE).must_next() + _pkts.filter_mle_cmd(MLE_CHILD_ID_RESPONSE).must_next() + + # Step 2:The DUT MUST properly forward ICMPv6 Echo Requests to all MED children + # The DUT MUST properly forward ICMPv6 Echo Replies to the Leader + leader_rloc16 = pv.vars['LEADER_RLOC16'] + for i in range(1, 5): + rloc16 = pv.vars['MED%d_RLOC16' % i] + _pkts = router_pkts.copy() + p = _pkts.filter('wpan.dst16 == {rloc16}', rloc16=rloc16).filter_ping_request().must_next() + _pkts.filter('wpan.dst16 == {rloc16}', + rloc16=leader_rloc16).filter_ping_reply(identifier=p.icmpv6.echo.identifier).must_next() + + # Step 3: The DUT MUST properly forward ICMPv6 Echo Requests to all SED children + # The DUT MUST properly forward ICMPv6 Echo Replies to the Leader + for i in range(1, 7): + rloc16 = pv.vars['SED%d_RLOC16' % i] + _pkts = router_pkts.copy() + p = _pkts.filter('wpan.dst16 == {rloc16}', rloc16=rloc16).filter_ping_request().must_next() + _pkts.filter('wpan.dst16 == {rloc16}', + rloc16=leader_rloc16).filter_ping_reply(identifier=p.icmpv6.echo.identifier).must_next() + if __name__ == '__main__': unittest.main() diff --git a/tests/scripts/thread-cert/Makefile.am b/tests/scripts/thread-cert/Makefile.am index bf923fd34..150f069e5 100644 --- a/tests/scripts/thread-cert/Makefile.am +++ b/tests/scripts/thread-cert/Makefile.am @@ -161,6 +161,26 @@ EXTRA_DIST = \ test_service.py \ thread_cert.py \ tlvs_parsing.py \ + thread_cert.py \ + pktverify/__init__.py \ + pktverify/addrs.py \ + pktverify/bytes.py \ + pktverify/coap.py \ + pktverify/consts.py \ + pktverify/decorators.py \ + pktverify/errors.py \ + pktverify/layer_fields.py \ + pktverify/layer_fields_container.py \ + pktverify/layers.py \ + pktverify/null_field.py \ + pktverify/packet.py \ + pktverify/packet_filter.py \ + pktverify/packet_verifier.py \ + pktverify/pcap_reader.py \ + pktverify/summary.py \ + pktverify/test_info.py \ + pktverify/utils.py \ + pktverify/verify_result.py \ $(NULL) check_PROGRAMS = \ diff --git a/tests/scripts/thread-cert/node.py b/tests/scripts/thread-cert/node.py index f79b4151e..d80941e88 100755 --- a/tests/scripts/thread-cert/node.py +++ b/tests/scripts/thread-cert/node.py @@ -43,8 +43,9 @@ import binascii class Node: - def __init__(self, nodeid, is_mtd=False, simulator=None, version=None, is_bbr=False): + def __init__(self, nodeid, is_mtd=False, simulator=None, name=None, version=None, is_bbr=False): self.nodeid = nodeid + self.name = name or ('Node%d' % nodeid) self.verbose = int(float(os.getenv('VERBOSE', 0))) self.node_type = os.getenv('NODE_TYPE', 'sim') self.env_version = os.getenv('THREAD_VERSION', '1.1') diff --git a/tests/scripts/thread-cert/pktverify/__init__.py b/tests/scripts/thread-cert/pktverify/__init__.py new file mode 100644 index 000000000..988bf8275 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/__init__.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +import sys + +# make sure we are using Python3.6+ +assert sys.version_info.major == 3 and sys.version_info.minor >= 6 diff --git a/tests/scripts/thread-cert/pktverify/addrs.py b/tests/scripts/thread-cert/pktverify/addrs.py new file mode 100644 index 000000000..61173fd5a --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/addrs.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import ipaddress +from typing import Union + +from pktverify.bytes import Bytes + + +class EthAddr(Bytes): + """ + Represents an Ethernet address. + """ + + def __init__(self, addr: Union[str, bytearray, 'Bytes']): + super().__init__(addr) + if len(self) not in (6, 8): + raise ValueError((addr, self)) + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.format_octets()) + + def __str__(self): + return self.format_octets() + + +class ExtAddr(Bytes): + """ + Represents an WPAN Extended address. + """ + + def __init__(self, addr: Union[str, bytearray, 'Bytes']): + super().__init__(addr) + if len(self) != 8: + raise ValueError((addr, self)) + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.format_octets()) + + def __str__(self): + return self.format_octets() + + +class Ipv6Addr(Bytes): + """ + Represents an Ip6 address. + """ + + def __init__(self, addr: Union[str, bytearray, 'Bytes']): + if isinstance(addr, str): + # try to parse compacted ipv6 address + try: + addr = Ipv6Addr._expand(addr) + except ipaddress.AddressValueError: + pass + + super().__init__(addr) + if len(self) != 16: + raise ValueError((addr, self)) + self._addr = ipaddress.IPv6Address(self) + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.format_hextets()) + + def __str__(self): + return self.format_hextets() + + @staticmethod + def _expand(addr) -> str: + assert isinstance(addr, str) + a = ipaddress.IPv6Address(addr) + return a.exploded + + @property + def is_global(self) -> bool: + """ + Returns if the Ip6 address is global. + """ + if self._addr.is_global: + return True + + if self._addr.is_link_local or self._addr.is_multicast or self._addr.is_loopback: + return False + + return True + + @property + def is_dua(self) -> bool: + """ + Returns if the Ip6 address is Domain Unicast Address. + """ + from pktverify import consts + return self.startswith(consts.DOMAIN_PREFIX) + + @property + def is_backbone(self) -> bool: + """ + Returns if the Ip6 address is Backbone address. + """ + from pktverify import consts + return self.startswith(consts.BACKBONE_IPV6_PREFIX) + + @property + def is_link_local(self) -> bool: + """ + Returns if the Ip6 address is link local. + """ + return self._addr.is_link_local + + @property + def is_multicast(self) -> bool: + """ + Returns if the Ip6 address is multicast. + """ + return self._addr.is_multicast + + @property + def is_mleid(self) -> bool: + """ + Returns if the Ip6 address is ML-EID. + """ + from pktverify.consts import DEFAULT_MESH_LOCAL_PREFIX + return self.startswith(DEFAULT_MESH_LOCAL_PREFIX) + + +if __name__ == '__main__': + a = EthAddr("010203040506") + assert a == EthAddr("01:02:03:04:05:06") + assert a == EthAddr("0102:0304:0506") + assert eval(repr(a)) == a + assert a == str(a) + assert str(a) == a + + print(a, repr(a)) + assert isinstance(a[:], Bytes) + assert a[:3] == "010203" + assert a[3:] == "040506" + + a = ExtAddr("0102030405060708") + assert a == ExtAddr("01:02:03:04:05:06:07:08") + assert a == ExtAddr("0102:0304:0506:0708") + assert eval(repr(a)) == a + assert a == str(a) + assert str(a) == a + + print(a, repr(a)) + assert isinstance(a[:], Bytes) + assert a[:4] == "01020304" + assert a[4:] == "05060708" + + a = Ipv6Addr("00112233445566778899aabbccddeeff") + assert a == Ipv6Addr("00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff") + assert a == Ipv6Addr("0011:2233:4455:6677:8899:aabb:ccdd:eeff") + assert eval(repr(a)) == a + assert a == str(a) + assert str(a) == a + + print(a, repr(a)) + assert isinstance(a[:], Bytes) + assert a[:4] == "00112233" + assert a[-4:] == "ccddeeff" + + assert Ipv6Addr("fd00:db8::ff:fe00:8001") == Ipv6Addr("fd00:0db8:0000:0000:0000:00ff:fe00:8001") + print(Ipv6Addr("fdde:ad00:beef:0:9d87:85f0:3358:3fff")) diff --git a/tests/scripts/thread-cert/pktverify/bytes.py b/tests/scripts/thread-cert/pktverify/bytes.py new file mode 100644 index 000000000..191f2cf3f --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/bytes.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import sys +from typing import Union + + +class Bytes(bytearray): + """Bytes represents a byte array which is able to handle strings of flexible formats""" + + def __init__(self, s: Union[str, bytearray, 'Bytes']): + if isinstance(s, str): + try: + s = Bytes._parse_compact(s) + except ValueError: + try: + s = Bytes._parse_octets(s) + except ValueError: + s = Bytes._parse_hextets(s) + + super().__init__(s) + + def __hash__(self): + return hash(bytes(self)) + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.format_compact()) + + def format_compact(self) -> str: + """ + Converts the Bytes to a compact string (without ":"). + """ + return ''.join('%02x' % b for b in self) + + def format_octets(self) -> str: + """ + Converts the Bytes to a string of octets separated by ":". + """ + return ':'.join('%02x' % b for b in self) + + def format_hextets(self) -> str: + """ + Converts the Bytes to a string of hextets separated by ":" + """ + assert len(self) % 2 == 0, self.format_octets() + return ':'.join('%04x' % (self[i] * 256 + self[i + 1]) for i in range(0, len(self), 2)) + + __str__ = format_octets + + @staticmethod + def _parse_compact(s: str) -> bytearray: + try: + assert len(s) % 2 == 0 + return bytearray(int(s[i:i + 2], 16) for i in range(0, len(s), 2)) + except Exception: + raise ValueError(s) + + @staticmethod + def _parse_octets(s: str) -> bytearray: + try: + assert len(s) % 3 == 2 or not s + if not s: + return bytearray(b"") + + return bytearray(int(x, 16) for x in s.split(':')) + except Exception: + raise ValueError(s) + + @staticmethod + def _parse_hextets(s) -> bytearray: + try: + assert len(s) % 5 == 4 or not s + if not s: + return bytearray(b"") + + return bytearray(int(x[i:i + 2], 16) for x in s.split(':') for i in (0, 2)) + except Exception: + raise ValueError(s) + + def __getitem__(self, item) -> Union['Bytes', int]: + """ + Get self[item]. + + :param item: index or slice to retrieve + :return: the byte value at specified index or sub `Bytes` if item is slice + """ + x = super().__getitem__(item) + if isinstance(x, bytearray): + return Bytes(x) + else: + return x + + def __eq__(self, other: Union[str, 'Bytes']): + """ + Check if bytes is equal to other. + """ + if other is None: + return False + elif not isinstance(other, Bytes): + other = self.__class__(other) + + eq = super().__eq__(other) + print("[%r %s %r]" % (self, "==" if eq else "!=", other), file=sys.stderr) + return eq + + +if __name__ == '__main__': + # some simple tests + x = Bytes(b"\x01\x02\x03\x04") + assert eval(repr(x)) == x, repr(x) # representation of Bytes should be able to be evaluated back + assert x == str(x), (x, str(x)) + + assert x.format_compact() == "01020304", x.format_compact() + assert x.format_octets() == "01:02:03:04", x.format_octets() + assert x.format_hextets() == "0102:0304", x.format_hextets() + + assert Bytes._parse_compact("") == Bytes(b"") + assert Bytes._parse_compact('01020304') == x + + assert Bytes._parse_octets("") == Bytes(b"") + assert Bytes._parse_octets('01:02:03:04') == x + + assert Bytes._parse_hextets("") == Bytes(b"") + assert Bytes._parse_hextets('0102:0304') == x + + assert isinstance(x[:2], Bytes) + assert isinstance(x[-2:], Bytes) + assert x[:2] == Bytes(b'\x01\x02') + assert x[-2:] == Bytes(b'\x03\x04') + + # should also parse string formats + assert Bytes("01020304") == Bytes(b"\x01\x02\x03\x04") + assert Bytes("01:02:03:04") == Bytes(b"\x01\x02\x03\x04") + assert Bytes("0102:0304") == Bytes(b"\x01\x02\x03\x04") diff --git a/tests/scripts/thread-cert/pktverify/coap.py b/tests/scripts/thread-cert/pktverify/coap.py new file mode 100644 index 000000000..17aa7d50d --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/coap.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import struct +from typing import Tuple, List + +from pktverify.addrs import ExtAddr, Ipv6Addr +from pktverify.consts import COAP_CODE_POST, COAP_CODE_ACK +from pktverify.layers import Layer + + +class CoapTlvParser(object): + + @staticmethod + def _parse_0(v: bytearray) -> List[Tuple[str, str]]: + """parse Target EID TLV""" + return [('target_eid', CoapTlvParser._parse_ipv6_address(v))] + + @staticmethod + def _parse_1(v: bytearray) -> List[Tuple[str, str]]: + """parse MAC Extended Address TLV""" + return [('ext_mac_addr', CoapTlvParser._parse_ext_mac_addr(v))] + + @staticmethod + def _parse_2(v: bytearray) -> List[Tuple[str, str]]: + """parse RLOC16 TLV""" + return [('rloc16', CoapTlvParser._parse_uint16(v))] + + @staticmethod + def _parse_3(v: bytearray) -> List[Tuple[str, str]]: + """parse ML-EID TLV""" + return [('ml_eid', CoapTlvParser._parse_ext_mac_addr(v))] + + @staticmethod + def _parse_4(v: bytearray) -> List[Tuple[str, str]]: + """parse Status TLV""" + assert len(v) == 1 + return [('status', hex(v[0]))] + + @staticmethod + def _parse_6(v: bytearray) -> List[Tuple[str, str]]: + """parse Time Since Last Transaction TLV""" + return [('last_transaction_time', CoapTlvParser._parse_uint32(v))] + + @staticmethod + def _parse_7(v: bytearray) -> List[Tuple[str, str]]: + """parse Router Mask TLV""" + assert len(v) == 9 + return [ + ('router_mask_id_seq', hex(v[0])), + ('router_mask_assigned', CoapTlvParser._parse_uint64(v[1:])), + ] + + @staticmethod + def _parse_10(v: bytearray) -> List[Tuple[str, str]]: + """parse Thread Network Data TLV""" + # TODO: Thread Network Data can not be parsed by COAP TLVs yet + return [] + + @staticmethod + def _parse_12(v: bytearray) -> List[Tuple[str, str]]: + """parse Network Name TLV""" + return [('net_name', CoapTlvParser._parse_utf8_str(v))] + + @staticmethod + def _parse_uint16(v: bytearray) -> str: + assert len(v) == 2 + return hex(v[0] * 256 + v[1]) + + @staticmethod + def _parse_uint32(v: bytearray) -> str: + assert len(v) == 4 + return hex(struct.unpack(">I", v)[0]) + + @staticmethod + def _parse_uint64(v: bytearray) -> str: + assert len(v) == 8 + return hex(struct.unpack(">Q", v)[0]) + + @staticmethod + def _parse_ipv6_address(s: bytearray): + assert len(s) == 16 + a = Ipv6Addr(s) + return a.format_hextets() + + @staticmethod + def _parse_utf8_str(v: bytearray) -> str: + return v.decode('utf-8') + + @staticmethod + def parse(t, v: bytearray) -> str: + assert isinstance(v, bytearray) + try: + parse_func = getattr(CoapTlvParser, f'_parse_{t}') + except AttributeError: + raise NotImplementedError(f"Please implement _parse_{t} for COAP TLV: type={t}") + + return parse_func(v) + + @staticmethod + def _parse_ext_mac_addr(v: bytearray) -> str: + assert len(v) == 8 + return ExtAddr(v).format_octets() + + +class CoapLayer(Layer): + """ + Represents a COAP layer. + """ + + def __init__(self, packet, layer_name): + super().__init__(packet, layer_name) + + @property + def is_post(self) -> bool: + """ + Returns if the COAP layer is using code POST. + """ + return self.code == COAP_CODE_POST + + @property + def is_ack(self) -> bool: + """ + Returns if the COAP layer is using code ACK. + """ + return self.code == COAP_CODE_ACK + + def __getattr__(self, name): + super_attr = super().__getattr__(name) + if name == 'tlv': + self._parse_coap_payload() + + return super_attr + + def _parse_coap_payload(self): + payload = self.payload + + r = 0 + while True: + t, tvs, r = self._parse_next_tlv(payload, r) + if t is None: + break + + self._add_field('coap.tlv.type', hex(t)) + for k, v in tvs: + assert isinstance(k, str), (t, k, v) + assert isinstance(v, str), (t, k, v) + self._add_field('coap.tlv.' + k, v) + + @staticmethod + def _parse_next_tlv(payload, read_pos) -> tuple: + assert read_pos <= len(payload) + if read_pos == len(payload): + return None, None, read_pos + + t = payload[read_pos] + len_ = payload[read_pos + 1] + assert (len(payload) - read_pos - 2 >= len_) + kvs = CoapTlvParser.parse(t, payload[read_pos + 2:read_pos + 2 + len_]) + return t, kvs, read_pos + len_ + 2 diff --git a/tests/scripts/thread-cert/pktverify/consts.py b/tests/scripts/thread-cert/pktverify/consts.py new file mode 100644 index 000000000..c05a811f1 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/consts.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +from pktverify.addrs import Ipv6Addr +from pktverify.bytes import Bytes + +DOMAIN_PREFIX = Bytes('fd00:7d03:7d03:7d03') +BACKBONE_IPV6_PREFIX = Bytes('2001:0db8:0001:0000') + +LINK_LOCAL_All_THREAD_NODES_MULTICAST_ADDRESS = Ipv6Addr('ff32:40:fdde:ad00:beef:0:0:1') +REALM_LOCAL_All_THREAD_NODES_MULTICAST_ADDRESS = Ipv6Addr('ff33:40:fdde:ad00:beef:0:0:1') +REALM_LOCAL_ALL_ROUTERS_ADDRESS = Ipv6Addr('ff03::2') +LINK_LOCAL_ALL_NODES_MULTICAST_ADDRESS = Ipv6Addr('ff02::1') +LINK_LOCAL_ALL_ROUTERS_MULTICAST_ADDRESS = Ipv6Addr('ff02::2') +LINK_LOCAL_ALL_BBRS_MULTICAST_ADDRESS = Ipv6Addr('ff32:40:fd00:7d03:7d03:7d03:0:3') + +# MA in Test Plan, make sure these are same as ../config.py +MA1 = Ipv6Addr('ff04::1234:777a:1') +MA1g = Ipv6Addr('ff0e::1234:777a:1') +MA2 = Ipv6Addr('ff05::1234:777a:1') +MA3 = Ipv6Addr('ff0e::1234:777a:3') +MA4 = Ipv6Addr('ff05::1234:777a:4') +MA5 = Ipv6Addr('ff03::1234:777a:5') +MA6 = Ipv6Addr('ff02::1') +MAe1 = Ipv6Addr('fd0e::1234:777a:1') +MAe2 = Ipv6Addr('::') +MAe3 = Ipv6Addr('cafe::e0ff') +ALL_MPL_FORWARDERS_MA = Ipv6Addr('ff03::fc') + +LINK_LOCAL_PREFIX = Bytes("fe80") +DEFAULT_MESH_LOCAL_PREFIX = Bytes("fd00:0db8:0000:0000") + +# COAP methods +COAP_CODE_POST = 2 +COAP_CODE_ACK = 68 + +MLE_LINK_REQUEST = 0 +MLE_LINK_ACCEPT = 1 +MLE_LINK_ACCEPT_AND_REQUEST = 2 +MLE_ADVERTISEMENT = 4 +MLE_DATA_RESPONSE = 8 +MLE_PARENT_REQUEST = 9 +MLE_PARENT_RESPONSE = 10 +MLE_CHILD_ID_REQUEST = 11 +MLE_CHILD_ID_RESPONSE = 12 + +# DUA related constants + +ADDRESS_QUERY_INITIAL_RETRY_DELAY = 15 +ADDRESS_QUERY_MAX_RETRY_DELAY = 8 +ADDRESS_QUERY_TIMEOUT = 3 +ADVERTISEMENT_I_MAX = 32 +ADVERTISEMENT_I_MIN = 1 + +CONTEXT_ID_REUSE_DELAY = 48 + +DATA_RESUBMIT_DELAY = 300 + +DUA_DAD_PERIOD = 100 +DUA_DAD_QUERY_TIMEOUT = 1.0 +DUA_DAD_REPEATS = 2 +DUA_RECENT_TIME = 20 +FAILED_ROUTER_TRANSMISSIONS = 4 +ID_REUSE_DELAY = 100 +ID_SEQUENCE_PERIOD = 10 +INFINITE_COST_TIMEOUT = 90 + +REAL_LAYER_NAMES = { + 'mle', + 'coap', + 'wpan', + 'eth', + 'tcp', + 'udp', + 'ip', + 'ipv6', + 'icmpv6', + '6lowpan', + 'arp', + 'thread_bl', + 'thread_address', + 'thread_nm', + 'ssdp', + 'dns', + 'igmp', + 'mdns', +} + +FAKE_LAYER_NAMES = {'thread_nwd', 'thread_meshcop'} + +VALID_LAYER_NAMES = REAL_LAYER_NAMES | FAKE_LAYER_NAMES + +AUTO_SEEK_BACK_MAX_DURATION = 0.01 + +# Wireshark configs +WIRESHARK_OVERRIDE_PREFS = { + '6lowpan.context0': 'fd00:db8::/64', + '6lowpan.context1': 'fd00:7d03:7d03:7d03::/64', + 'wpan.802154_fcs_ok': 'FALSE', + 'wpan.802154_sec_suite': 'AES-128 Encryption, 32-bit Integrity Protection', + 'thread.thr_seq_ctr': '00000000', + 'uat:ieee802154_keys': '"00112233445566778899aabbccddeeff","1","Thread hash"', +} + +WIRESHARK_DECODE_AS_ENTRIES = { + 'udp.port==61631': 'coap', +} + +TIMEOUT_JOIN_NETWORK = 10 +TIMEOUT_DUA_REGISTRATION = 10 +TIMEOUT_DUA_DAD = 15 +TIMEOUT_HOST_READY = 10 +TIMEOUT_CHILD_DETACH = 120 +TIMEOUT_REGISTER_MA = 5 + +if __name__ == '__main__': + from pktverify.addrs import Ipv6Addr + + assert Ipv6Addr("fe80:0000:0000:0000:0200:0000:0000:0004").startswith(LINK_LOCAL_PREFIX) + assert Ipv6Addr("fd00:0db8:0000:0000:0000:00ff:fe00:8001").startswith(DEFAULT_MESH_LOCAL_PREFIX) diff --git a/tests/scripts/thread-cert/pktverify/decorators.py b/tests/scripts/thread-cert/pktverify/decorators.py new file mode 100644 index 000000000..0be089bee --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/decorators.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +from functools import wraps + + +def cached(f): + """ + Decorator to convert a function to cache its return value when it's called by the first time. + + :param f: The function to decorate. + :return: The caching function. + """ + cache_key = '_once_' + f.__name__ + + @wraps(f) + def once_f(self): + try: + v = object.__getattribute__(self, cache_key) # can not use getattr, will trigger __getattr__ wrongly + except AttributeError: + v = f(self) + setattr(self, cache_key, v) + return v + + return once_f + + +def cached_property(f): + """ + Decorator for declaring a property that caches its value. + + :param f: The property getter function. + + :return: The caching property. + """ + return property(cached(f)) diff --git a/tests/scripts/thread-cert/pktverify/errors.py b/tests/scripts/thread-cert/pktverify/errors.py new file mode 100644 index 000000000..27379df3a --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/errors.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + + +class Error(Exception): + """ + Base error class for all Packet Verification errors. + """ + pass + + +class PacketNotFound(Error): + """ + Represents an error that the packet was not found. + """ + + def __init__(self, start_index, stop_index): + self._start_index = start_index + self._stop_index = stop_index + + +class UnexpectedPacketFound(Error): + """ + Represents an error that the packet was found unexpectedly. + """ + + def __init__(self, idx, pkt): + self._idx = idx + self._pkt = pkt + + +class VerifyFailed(Error): + """ + Represents an error that the packet failed to pass verification criteria. + """ + + def __init__(self, pkt): + self._pkt = pkt diff --git a/tests/scripts/thread-cert/pktverify/layer_fields.py b/tests/scripts/thread-cert/pktverify/layer_fields.py new file mode 100644 index 000000000..35b426916 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/layer_fields.py @@ -0,0 +1,658 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import sys +from typing import Any, Union + +from pyshark.packet.fields import LayerFieldsContainer, LayerField +from pyshark.packet.packet import Packet as RawPacket + +from pktverify.addrs import EthAddr, ExtAddr, Ipv6Addr +from pktverify.bytes import Bytes +from pktverify.consts import VALID_LAYER_NAMES +from pktverify.null_field import nullField + + +def _auto(v: Union[LayerFieldsContainer, LayerField]): + """parse the layer field automatically according to its format""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1, v.fields + dv = v.get_default_value() + rv = v.raw_value + + if dv.startswith('0x'): + return int(dv, 16) + + try: + if dv == rv: + return int(dv) + elif int(dv) == int(rv, 16): + return int(dv) + except (ValueError, TypeError): + pass + + if rv is None: + try: + return int(dv) + except (ValueError, TypeError): + pass + + if ':' in dv and '::' not in dv and dv.replace(':', '') == rv: # '88:00', '8800' + return int(rv, 16) + + if dv.endswith(' CST'): + # e.x. 'Jan 1, 1970 08:00:00.000000000 CST', '0000000000000000' + # todo: check if the time is valid + return int(rv) + + try: + int(rv, 16) + return int(dv) + except Exception: + pass + + raise ValueError((v, v.get_default_value(), v.raw_value)) + + +def _payload(v: Union[LayerFieldsContainer, LayerField]) -> bytearray: + """parse the layer field as a bytearray""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + hex_value = v.raw_value + assert len(hex_value) % 2 == 0 + s = bytearray() + for i in range(0, len(hex_value), 2): + s.append(int(hex_value[i:i + 2], 16)) + + return s + + +def _hex(v: Union[LayerFieldsContainer, LayerField]) -> int: + """parse the layer field as a hex string""" + # split v into octets and reverse the order + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + return int(v.get_default_value(), 16) + + +def _raw_hex(v: Union[LayerFieldsContainer, LayerField]) -> int: + """parse the layer field as a raw hex string""" + # split v into octets and reverse the order + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + iv = v.hex_value + + try: + int(v.get_default_value()) + assert int(v.get_default_value()) == iv, (v.get_default_value(), v.raw_value) + except ValueError: + pass + + try: + int(v.get_default_value(), 16) + assert int(v.get_default_value(), 16) == iv, (v.get_default_value(), v.raw_value) + except ValueError: + pass + + return iv + + +def _raw_hex_rev(v: Union[LayerFieldsContainer, LayerField]) -> int: + """parse the layer field as a reversed raw hex string""" + # split v into octets and reverse the order + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + rv = v.raw_value + octets = [rv[i:i + 2] for i in range(0, len(rv), 2)] + + iv = int(''.join(reversed(octets)), 16) + + try: + int(v.get_default_value()) + assert int(v.get_default_value()) == iv, (v.get_default_value(), v.raw_value) + except ValueError: + pass + + try: + int(v.get_default_value(), 16) + assert int(v.get_default_value(), 16) == iv, (v.get_default_value(), v.raw_value) + except ValueError: + pass + + return iv + + +def _dec(v: Union[LayerFieldsContainer, LayerField]) -> int: + """parse the layer field as a decimal""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + return int(v.get_default_value()) + + +def _float(v: Union[LayerFieldsContainer, LayerField]) -> float: + """parse the layer field as a float""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + return float(v.get_default_value()) + + +def _str(v: Union[LayerFieldsContainer, LayerField]) -> str: + """parse the layer field as a string""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + return str(v.get_default_value()) + + +def _bytes(v: Union[LayerFieldsContainer, LayerField]) -> Bytes: + """parse the layer field as raw bytes""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + return Bytes(v.raw_value) + + +def _ext_addr(v: Union[LayerFieldsContainer, LayerField]) -> ExtAddr: + """parse the layer field as an extended address""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + return ExtAddr(v.get_default_value()) + + +def _ipv6_addr(v: Union[LayerFieldsContainer, LayerField]) -> Ipv6Addr: + """parse the layer field as an IPv6 address""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1 + return Ipv6Addr(v.get_default_value()) + + +def _eth_addr(v: Union[LayerFieldsContainer, LayerField]) -> EthAddr: + """parse the layer field as an Ethernet MAC address""" + assert not isinstance(v, LayerFieldsContainer) or len(v.fields) == 1, v.fields + return EthAddr(v.get_default_value()) + + +class _first(object): + """parse the first layer field""" + + def __init__(self, sub_parse): + self._sub_parse = sub_parse + + def __call__(self, v: Union[LayerFieldsContainer, LayerField]): + return self._sub_parse(v.fields[0]) + + +class _list(object): + """parse all layer fields into a list""" + + def __init__(self, sub_parse): + self._sub_parse = sub_parse + + def __call__(self, v: Union[LayerFieldsContainer, LayerField]): + return [self._sub_parse(f) for f in v.fields] + + +_LAYER_FIELDS = { + # WPAN + 'wpan.fcf': _raw_hex_rev, + 'wpan.security': _auto, + 'wpan.frame_type': _auto, + 'wpan.pending': _auto, + 'wpan.ack_request': _auto, + 'wpan.pan_id_compression': _auto, + 'wpan.seqno_suppression': _auto, + 'wpan.ie_present': _auto, + 'wpan.dst_addr_mode': _auto, + 'wpan.version': _auto, + 'wpan.src_addr_mode': _auto, + 'wpan.dst_pan': _auto, + 'wpan.seq_no': _auto, + 'wpan.src16': _auto, + 'wpan.dst16': _auto, + 'wpan.src64': _ext_addr, + 'wpan.dst64': _ext_addr, + 'wpan.fcs': _raw_hex_rev, + 'wpan.fcs_ok': _auto, + 'wpan.frame_length': _dec, + 'wpan.key_number': _auto, + 'wpan.aux_sec.sec_suite': _auto, + 'wpan.aux_sec.security_control_field': _auto, + 'wpan.aux_sec.sec_level': _auto, + 'wpan.aux_sec.key_id_mode': _auto, + 'wpan.aux_sec.frame_counter_suppression': _auto, + 'wpan.aux_sec.asn_in_nonce': _auto, + 'wpan.aux_sec.reserved': _auto, + 'wpan.aux_sec.frame_counter': _auto, + 'wpan.aux_sec.key_source': _auto, + 'wpan.aux_sec.key_index': _auto, + 'wpan.aux_sec.hdr': _str, + 'wpan.mic': _auto, + 'wpan.channel': _auto, + + # MLE + 'mle.cmd': _auto, + 'mle.tlv.type': _list(_dec), + 'mle.tlv.len': _list(_dec), + 'mle.tlv.mode.receiver_on_idle': _auto, + 'mle.tlv.mode.reserved1': _auto, + 'mle.tlv.mode.reserved2': _auto, + 'mle.tlv.mode.device_type_bit': _auto, + 'mle.tlv.mode.network_data': _auto, + 'mle.tlv.challenge': _bytes, + 'mle.tlv.scan_mask.r': _auto, + 'mle.tlv.scan_mask.e': _auto, + 'mle.tlv.version': _auto, + 'mle.tlv.source_addr': _auto, + 'mle.tlv.active_tstamp': _auto, + 'mle.tlv.leader_data.partition_id': _auto, + 'mle.tlv.leader_data.weighting': _auto, + 'mle.tlv.leader_data.data_version': _auto, + 'mle.tlv.leader_data.stable_data_version': _auto, + 'mle.tlv.leader_data.router_id': _auto, + 'mle.tlv.route64.nbr_out': _list(_auto), + 'mle.tlv.route64.nbr_in': _list(_auto), + 'mle.tlv.route64.id_seq': _auto, + 'mle.tlv.route64.id_mask': _auto, + 'mle.tlv.route64.cost': _list(_auto), + 'mle.tlv.response': _bytes, + 'mle.tlv.mle_frm_cntr': _auto, + 'mle.tlv.ll_frm_cntr': _auto, + 'mle.tlv.link_margin': _auto, + 'mle.tlv.conn.sed_dgram_cnt': _auto, + 'mle.tlv.conn.sed_buf_size': _auto, + 'mle.tlv.conn.lq3': _auto, + 'mle.tlv.conn.lq2': _auto, + 'mle.tlv.conn.lq1': _auto, + 'mle.tlv.conn.leader_cost': _auto, + 'mle.tlv.conn.id_seq': _auto, + 'mle.tlv.conn.flags.pp': _auto, + 'mle.tlv.conn.active_rtrs': _auto, + 'mle.tlv.timeout': _auto, + 'mle.tlv.addr16': _auto, + + # IP + 'ip.version': _auto, + 'ip.src': _str, + 'ip.src_host': _str, + 'ip.dst': _str, + 'ip.dst_host': _str, + 'ip.ttl': _auto, + 'ip.proto': _auto, + 'ip.len': _auto, + 'ip.id': _auto, + 'ip.host': _list(_str), + 'ip.hdr_len': _dec, + 'ip.frag_offset': _auto, + 'ip.flags.rb': _auto, + 'ip.flags.mf': _auto, + 'ip.flags.df': _auto, + 'ip.dsfield.ecn': _auto, + 'ip.dsfield.dscp': _auto, + 'ip.checksum.status': _auto, + 'ip.addr': _list(_str), + 'ip.options.routeralert': _bytes, + 'ip.opt.type.number': _auto, + 'ip.opt.type.copy': _auto, + 'ip.opt.type.class': _auto, + 'ip.opt.ra': _auto, + 'ip.opt.len': _auto, + # UDP + 'udp.stream': _auto, + 'udp.srcport': _auto, + 'udp.dstport': _auto, + 'udp.length': _auto, + 'udp.port': _list(_dec), + 'udp.checksum.status': _auto, + + # IPv6 + 'ipv6.version': _auto, + 'ipv6.src': _ipv6_addr, + 'ipv6.src_host': _ipv6_addr, + 'ipv6.dst': _ipv6_addr, + 'ipv6.dst_host': _ipv6_addr, + 'ipv6.addr': _list(_ipv6_addr), + 'ipv6.tclass.dscp': _auto, + 'ipv6.tclass.ecn': _auto, + 'ipv6.flow': _auto, + 'ipv6.hlim': _auto, + 'ipv6.nxt': _auto, + 'ipv6.hopopts.len': _auto, + 'ipv6.hopopts.nxt': _auto, + 'ipv6.hopopts.len_oct': _dec, + 'ipv6.host': _list(_ipv6_addr), + 'ipv6.plen': _auto, + 'ipv6.opt.type.rest': _list(_auto), + 'ipv6.opt.type.change': _list(_auto), + 'ipv6.opt.type.action': _list(_auto), + 'ipv6.opt.router_alert': _auto, + 'ipv6.opt.padn': _str, + 'ipv6.opt.length': _list(_auto), + 'ipv6.opt.mpl.sequence': _auto, + 'ipv6.opt.mpl.flag.v': _auto, + 'ipv6.opt.mpl.flag.s': _auto, + 'ipv6.opt.mpl.flag.rsv': _auto, + 'ipv6.opt.mpl.flag.m': _auto, + + # Eth + 'eth.src': _eth_addr, + 'eth.src_resolved': _eth_addr, + 'eth.dst': _eth_addr, + 'eth.dst_resolved': _eth_addr, + 'eth.type': _auto, + 'eth.addr': _list(_eth_addr), + 'eth.addr_resolved': _list(_eth_addr), + 'eth.ig': _list(_auto), + 'eth.lg': _list(_auto), + # 6LOWPAN + '6lowpan.src': _ipv6_addr, + '6lowpan.dst': _ipv6_addr, + '6lowpan.udp.src': _auto, + '6lowpan.udp.dst': _auto, + '6lowpan.udp.checksum': _auto, + '6lowpan.frag.offset': _auto, + '6lowpan.frag.tag': _auto, + '6lowpan.frag.size': _auto, + '6lowpan.pattern': _list(_auto), + '6lowpan.hops': _auto, + '6lowpan.padding': _auto, + '6lowpan.next': _auto, + '6lowpan.flow': _auto, + '6lowpan.ecn': _auto, + '6lowpan.iphc.tf': _auto, + '6lowpan.iphc.m': _auto, + '6lowpan.iphc.nh': _auto, + '6lowpan.iphc.hlim': _auto, + '6lowpan.iphc.cid': _auto, + '6lowpan.iphc.sac': _auto, + '6lowpan.iphc.sam': _auto, + '6lowpan.iphc.dac': _auto, + '6lowpan.iphc.dam': _auto, + '6lowpan.iphc.sci': _auto, + '6lowpan.iphc.dci': _auto, + '6lowpan.iphc.sctx.prefix': _ipv6_addr, + '6lowpan.iphc.dctx.prefix': _ipv6_addr, + '6lowpan.mesh.v': _auto, + '6lowpan.nhc.pattern': _list(_auto), + '6lowpan.nhc.udp.checksum': _auto, + '6lowpan.nhc.udp.ports': _auto, + '6lowpan.nhc.ext.nh': _auto, + '6lowpan.nhc.ext.length': _auto, + '6lowpan.nhc.ext.eid': _auto, + '6lowpan.reassembled.length': _auto, + '6lowpan.fragments': _str, + '6lowpan.fragment.count': _auto, + '6lowpan.mesh.orig16': _auto, + '6lowpan.mesh.hops8': _auto, + '6lowpan.mesh.hops': _auto, + '6lowpan.mesh.f': _auto, + '6lowpan.mesh.dest16': _auto, + + # ICMPv6 + 'icmpv6.type': _first(_auto), + 'icmpv6.code': _first(_auto), + 'icmpv6.checksum': _first(_auto), + 'icmpv6.reserved': _raw_hex, + 'icmpv6.resptime': _float, + 'icmpv6.resp_to': _auto, + 'icmpv6.mldr.nb_mcast_records': _auto, + 'icmpv6.nd.ra.cur_hop_limit': _auto, + 'icmpv6.nd.ns.target_address': _ipv6_addr, + 'icmpv6.nd.na.target_address': _ipv6_addr, + 'icmpv6.nd.na.flag.s': _auto, + 'icmpv6.nd.na.flag.o': _auto, + 'icmpv6.nd.na.flag.r': _auto, + 'icmpv6.nd.na.flag.rsv': _auto, + 'icmpv6.mldr.mar.record_type': _list(_auto), + 'icmpv6.mldr.mar.aux_data_len': _list(_auto), + 'icmpv6.mldr.mar.nb_sources': _list(_auto), + 'icmpv6.mldr.mar.multicast_address': _list(_ipv6_addr), + 'icmpv6.opt.type': _list(_auto), + 'icmpv6.opt.nonce': _bytes, + 'icmpv6.opt.linkaddr': _eth_addr, + 'icmpv6.opt.src_linkaddr': _eth_addr, + 'icmpv6.opt.target_linkaddr': _eth_addr, + 'icmpv6.opt.route_lifetime': _auto, + 'icmpv6.opt.route_info.flag.route_preference': _auto, + 'icmpv6.opt.route_info.flag.reserved': _auto, + 'icmpv6.opt.prefix.valid_lifetime': _auto, + 'icmpv6.opt.prefix.preferred_lifetime': _auto, + 'icmpv6.opt.prefix.length': _list(_auto), + 'icmpv6.opt.prefix.flag.reserved': _auto, + 'icmpv6.opt.prefix.flag.r': _auto, + 'icmpv6.opt.prefix.flag.l': _auto, + 'icmpv6.opt.prefix.flag.a': _auto, + 'icmpv6.opt.length': _list(_auto), + 'icmpv6.opt.reserved': _str, + 'icmpv6.nd.ra.router_lifetime': _auto, + 'icmpv6.nd.ra.retrans_timer': _auto, + 'icmpv6.nd.ra.reachable_time': _auto, + 'icmpv6.nd.ra.flag.rsv': _auto, + 'icmpv6.nd.ra.flag.prf': _auto, + 'icmpv6.nd.ra.flag.p': _auto, + 'icmpv6.nd.ra.flag.o': _auto, + 'icmpv6.nd.ra.flag.m': _auto, + 'icmpv6.nd.ra.flag.h': _auto, + 'icmpv6.echo.sequence_number': _auto, + 'icmpv6.echo.identifier': _auto, + + # COAP + 'coap.code': _auto, + 'coap.version': _auto, + 'coap.type': _auto, + 'coap.mid': _auto, + 'coap.token_len': _auto, + 'coap.token': _auto, + 'coap.opt.uri_path': _list(_str), + 'coap.opt.name': _list(_str), + 'coap.opt.length': _list(_auto), + 'coap.opt.uri_path_recon': _str, + 'coap.payload': _payload, + 'coap.payload_length': _auto, + 'coap.payload_desc': _str, + 'coap.opt.end_marker': _auto, + 'coap.opt.desc': _list(_str), + 'coap.opt.delta': _list(_auto), + 'coap.response_to': _auto, + 'coap.response_time': _float, + # COAP TLVS + 'coap.tlv.type': _list(_auto), + 'coap.tlv.status': _auto, + 'coap.tlv.target_eid': _ipv6_addr, + 'coap.tlv.ml_eid': _ext_addr, + 'coap.tlv.last_transaction_time': _auto, + 'coap.tlv.rloc16': _auto, + 'coap.tlv.net_name': _str, + 'coap.tlv.ext_mac_addr': _ext_addr, + 'coap.tlv.router_mask_assigned': _auto, + 'coap.tlv.router_mask_id_seq': _auto, + + # thread_address + 'thread_address.tlv.type': _list(_auto), + 'thread_address.tlv.status': _auto, + + # thread bl + 'thread_bl.tlv.type': _list(_auto), + 'thread_bl.tlv.len': _list(_auto), + 'thread_bl.tlv.target_eid': _ipv6_addr, + 'thread_bl.tlv.ml_eid': _ext_addr, + 'thread_bl.tlv.last_transaction_time': _auto, + # THEAD NM + 'thread_nm.tlv.type': _list(_auto), + 'thread_nm.tlv.ml_eid': _ext_addr, + 'thread_nm.tlv.target_eid': _ipv6_addr, + # thread_meshcop is not a real layer + 'thread_meshcop.len_size_mismatch': _str, + 'thread_meshcop.tlv.type': _list(_auto), + 'thread_meshcop.tlv.len8': _list(_auto), + 'thread_meshcop.tlv.net_name': _str, # from thread_bl + 'thread_meshcop.tlv.commissioner_sess_id': _auto, # from mle + "thread_meshcop.tlv.channel_page": _auto, # from ble + "thread_meshcop.tlv.channel": _auto, # from ble + "thread_meshcop.tlv.chan_mask": _str, # from ble + 'thread_meshcop.tlv.chan_mask_page': _auto, + 'thread_meshcop.tlv.chan_mask_len': _auto, + 'thread_meshcop.tlv.chan_mask_mask': _auto, + 'thread_meshcop.tlv.pan_id': _auto, + 'thread_meshcop.tlv.xpan_id': _bytes, + 'thread_meshcop.tlv.ml_prefix': _bytes, + 'thread_meshcop.tlv.master_key': _bytes, + 'thread_meshcop.tlv.pskc': _bytes, + 'thread_meshcop.tlv.sec_policy_rot': _auto, + 'thread_meshcop.tlv.sec_policy_o': _auto, + 'thread_meshcop.tlv.sec_policy_n': _auto, + 'thread_meshcop.tlv.sec_policy_r': _auto, + 'thread_meshcop.tlv.sec_policy_c': _auto, + 'thread_meshcop.tlv.sec_policy_b': _auto, + 'thread_meshcop.tlv.unknown': _bytes, + + # THREAD NWD + 'thread_nwd.tlv.type': _list(_auto), + 'thread_nwd.tlv.len': _list(_auto), + 'thread_nwd.tlv.stable': _list(_auto), + 'thread_nwd.tlv.service.t': _auto, + 'thread_nwd.tlv.service.s_id': _auto, + 'thread_nwd.tlv.service.s_data_len': _auto, + 'thread_nwd.tlv.service.s_data.seqno': _auto, + 'thread_nwd.tlv.service.s_data.rrdelay': _auto, + 'thread_nwd.tlv.service.s_data.mlrtimeout': _auto, + 'thread_nwd.tlv.server.16': _auto, + 'thread_nwd.tlv.border_router.16': _auto, + 'thread_nwd.tlv.sub_tlvs': _list(_str), + 'thread_nwd.tlv.prefix.length': _auto, + 'thread_nwd.tlv.prefix.domain_id': _auto, + 'thread_nwd.tlv.border_router.pref': _auto, + 'thread_nwd.tlv.border_router.flag.s': _auto, + 'thread_nwd.tlv.border_router.flag.r': _auto, + 'thread_nwd.tlv.border_router.flag.p': _auto, + 'thread_nwd.tlv.border_router.flag.o': _auto, + 'thread_nwd.tlv.border_router.flag.n': _auto, + 'thread_nwd.tlv.border_router.flag.dp': _auto, + 'thread_nwd.tlv.border_router.flag.d': _auto, + 'thread_nwd.tlv.border_router.flag.c': _auto, + 'thread_nwd.tlv.6co.flag.reserved': _auto, + 'thread_nwd.tlv.6co.flag.cid': _auto, + 'thread_nwd.tlv.6co.flag.c': _auto, + 'thread_nwd.tlv.6co.context_length': _auto, +} + +_layer_containers = set() + +for key in _LAYER_FIELDS: + assert key.strip() == key and ' ' not in key, key + secs = key.split('.') + assert len(secs) >= 2 + assert secs[0] in VALID_LAYER_NAMES, secs[0] + for i in range(len(secs) - 2): + path = secs[0] + '.' + '.'.join(secs[1:i + 2]) + assert path not in _LAYER_FIELDS, '%s can not be both field and path' % path + _layer_containers.add(path) + + +def is_layer_field(uri: str) -> bool: + """ + Returns if the URI is a valid layer field. + + :param uri: The layer field URI. + """ + return uri in _LAYER_FIELDS + + +def is_layer_field_container(uri: str) -> bool: + """ + Returns if the URI is a valid layer field container. + + :param uri: The layer field container URI. + """ + return uri in _layer_containers + + +def get_layer_field(packet: RawPacket, field_uri: str) -> Any: + """ + Get a given layer field from the packet. + + :param packet: The packet. + :param field_uri: The layer field URI. + + :return: The specified layer field. + """ + assert isinstance(packet, RawPacket) + secs = field_uri.split('.') + layer_name = secs[0] + + if is_layer_field(field_uri): + candidate_layers = _get_candidate_layers(packet, layer_name) + for layer in candidate_layers: + v = layer.get_field(field_uri) + if v is not None: + try: + v = _LAYER_FIELDS[field_uri](v) + print("[%s = %r] " % (field_uri, v), file=sys.stderr) + return v + except Exception as ex: + raise ValueError('can not parse field %s = %r' % (field_uri, + (v.get_default_value(), v.raw_value))) from ex + + print("[%s = %s] " % (field_uri, "null"), file=sys.stderr) + return nullField + + elif is_layer_field_container(field_uri): + from pktverify.layer_fields_container import LayerFieldsContainer + return LayerFieldsContainer(packet, field_uri) + else: + raise NotImplementedError('Field %s is not valid, please add it to `_LAYER_FIELDS`' % field_uri) + + +def check_layer_field_exists(packet, field_uri): + """ + Check if a given layer field URI exists in the packet. + + :param packet: The packet to check. + :param field_uri: The layer field URI. + :return: Whether the layer field URI exists in the packet. + """ + assert isinstance(packet, RawPacket) + secs = field_uri.split('.') + layer_name = secs[0] + + if not is_layer_field(field_uri) and not is_layer_field_container(field_uri): + raise NotImplementedError('%s is neither a field or field container' % field_uri) + + candidate_layers = _get_candidate_layers(packet, layer_name) + for layer in candidate_layers: + for k, v in layer._all_fields.items(): + if k == field_uri or k.startswith(field_uri + '.'): + return True + + return False + + +def _get_candidate_layers(packet, layer_name): + if layer_name == 'thread_meshcop': + candidate_layer_names = ['mle', 'coap', 'thread_bl'] + elif layer_name == 'thread_nwd': + candidate_layer_names = ['mle', 'thread_address'] + elif layer_name == 'wpan': + candidate_layer_names = ['wpan', 'mle'] + elif layer_name == 'ip': + candidate_layer_names = ['ip', 'ipv6'] + else: + candidate_layer_names = [layer_name] + + layers = [] + for ln in candidate_layer_names: + if hasattr(packet, ln): + layers.append(getattr(packet, ln)) + + return layers diff --git a/tests/scripts/thread-cert/pktverify/layer_fields_container.py b/tests/scripts/thread-cert/pktverify/layer_fields_container.py new file mode 100644 index 000000000..2c0af6764 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/layer_fields_container.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +from pyshark.packet.packet import Packet as RawPacket + +from pktverify.layer_fields import get_layer_field, check_layer_field_exists + + +class LayerFieldsContainer(object): + """ + Represents a layer field container. + """ + + def __init__(self, packet: RawPacket, path: str): + assert isinstance(packet, RawPacket) + assert isinstance(path, str) + + self._packet = packet + self._path = path + + def __getattr__(self, name): + subpath = self._path + '.' + name + v = get_layer_field(self._packet, subpath) + setattr(self, name, v) + return v + + def has(self, subpath): + """ + Returns if the layer field container has a sub layer field or container. + + :param subpath: The sub path to the layer field or container. + """ + subpath = self._path + '.' + subpath + return check_layer_field_exists(self._packet, subpath) is not None + + def __getitem__(self, item): + return getattr(self, item) + + @property + def full_path(self): + """ + Returns the full path to this layer field container. + """ + return self._path + + @property + def field_path(self): + """ + Returns the field that references to this layer field container. + """ + secs = self._path.split('.') + assert len(secs) >= 2 + return '.'.join(secs[1:]) + + def __bool__(self): + """ + Returns if this layer field container exists in the packet. + """ + return check_layer_field_exists(self._packet, self._path) diff --git a/tests/scripts/thread-cert/pktverify/layers.py b/tests/scripts/thread-cert/pktverify/layers.py new file mode 100644 index 000000000..cd35f6809 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/layers.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import logging +from typing import Optional + +from pyshark.packet.fields import LayerField, LayerFieldsContainer +from pyshark.packet.layer import Layer as RawLayer +from pyshark.packet.packet import Packet as RawPacket + +from pktverify.layer_fields import get_layer_field, check_layer_field_exists + + +class Layer(object): + """ + Represents a layer of a packet. + """ + + def __init__(self, packet: RawPacket, layer_name: str): + assert isinstance(packet, RawPacket) + assert isinstance(layer_name, str) + self._packet = packet + self._layer_name = layer_name + + @property + def _layer(self) -> Optional[RawLayer]: + try: + return getattr(self._packet, self._layer_name) + except AttributeError: + return None + + @property + def layer_name(self) -> str: + """ + Returns the layer name. + """ + return self._layer_name + + def show(self): + """ + Print the layer information. + """ + print(self._layer) + + def has(self, name) -> bool: + """ + Returns if the layer has a given field. + + :param name: The field name. + """ + path = '%s.%s' % (self.layer_name, name) + return check_layer_field_exists(self._packet, path) + + def __bool__(self): + """ + Returns if this layer exists in the packet. + """ + layer_exists = hasattr(self._packet, self._layer_name) + return layer_exists + + def __getattr__(self, name): + """ + Returns the layer field or container of a given field name. + + :param name: The name of layer field or container. + """ + path = '%s.%s' % (self.layer_name, name) + v = get_layer_field(self._packet, path) + assert not isinstance(v, (LayerField, LayerFieldsContainer)), '%s = %s(%r)' % (path, v.__class__.__name__, v) + setattr(self, name, v) + return v + + def _add_field(self, key: str, val: str): + logging.debug("layer %s add field: %s = %s", self.layer_name, key, val) + field = LayerField(name=key, value=val) + all_fields = self._layer._all_fields + if key not in all_fields: + all_fields[key] = LayerFieldsContainer(main_field=field) + else: + all_fields[key].fields.append(field) + + +class ThreadMeshcopLayer(Layer): + """ + Represents the Thread MeshCop layer of a packet. + """ + + def __bool__(self): + raise NotImplementedError("thread_meshcop is not a real layer, please do not check as bool") + + +class ThreadNetworkDataLayer(Layer): + """ + Represents the Thread NetworkData layer of a packet. + """ + + def __bool__(self): + raise NotImplementedError("thread_nwd is not a real layer, please do not check as bool") + + +class Icmpv6Layer(Layer): + """ + Represents the ICMPv6 layer of a packet. + """ + + @property + def is_ping(self) -> bool: + """ + Returns if the ICMPv6 layer is a Ping Request or Reply. + """ + return self.type in (128, 129) + + @property + def is_ping_request(self) -> bool: + """ + Returns if the ICMPv6 layer is a Ping Request. + """ + return self.type == 128 + + @property + def is_ping_reply(self) -> bool: + """ + Returns if the ICMPv6 layer is a Ping Reply. + """ + return self.type == 129 + + @property + def is_neighbor_advertisement(self) -> bool: + """ + Returns if the ICMPv6 layer is a Neighbor Advertisement. + """ + return self.type == 136 + + @property + def is_neighbor_solicitation(self) -> bool: + """ + Returns if the ICMPv6 layer is a Neighbor Solicitation. + """ + return self.type == 135 + + @property + def is_router_advertisement(self) -> bool: + """ + Returns if the ICMPv6 layer is a Router Advertisement. + """ + return self.type == 134 + + +class WpanLayer(Layer): + """ + Represents the WPAN layer of a packet. + """ + + @property + def is_ack(self) -> bool: + return self.frame_type == 0x2 diff --git a/tests/scripts/thread-cert/pktverify/null_field.py b/tests/scripts/thread-cert/pktverify/null_field.py new file mode 100644 index 000000000..19103300b --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/null_field.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +nullField = None + + +class NullField(object): + """ + Represents a null field that does not exists. + """ + + def __new__(cls, *args, **kwargs): + global nullField + + if nullField is None: + nullField = object.__new__(cls, *args, **kwargs) + + return nullField + + def __init__(self): + assert self is nullField + + def __bool__(self): + """ + NullField is always treated as False. + """ + return False + + def __getattr__(self, item): + """ + Any sub field of the NullField is NullField itself. + """ + return self + + def __setattr__(self, key, value): + pass + + def __len__(self) -> 0: + return 0 + + def __eq__(self, other): + """ + NullField is always not equal to any other value. + """ + return False + + def __ne__(self, other): + return True + + def __lt__(self, other): + """ + Comparing NullField to any other value gets False. + """ + return False + + def __le__(self, other): + """ + Comparing NullField to any other value gets False. + """ + return False + + def __gt__(self, other): + """ + Comparing NullField to any other value gets False. + """ + return False + + def __ge__(self, other): + """ + Comparing NullField to any other value gets False. + """ + return False + + def __str__(self): + return "nullField" + + def __repr__(self): + return 'nullField' + + +NullField() + +if __name__ == '__main__': + assert nullField is NullField() + assert not nullField, repr(nullField) + assert nullField != nullField, repr(nullField) + assert nullField != 0 + assert not (nullField > 1) + assert not (nullField < 1) + assert not (nullField < nullField) + assert not (nullField > nullField) + assert bool(nullField) is False + assert nullField != "" + assert nullField != None # noqa + assert nullField is not None diff --git a/tests/scripts/thread-cert/pktverify/packet.py b/tests/scripts/thread-cert/pktverify/packet.py new file mode 100644 index 000000000..20f7a9dd3 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/packet.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import logging +import sys +from typing import Iterable, List, Union, Callable + +from pyshark.packet.layer import Layer as RawLayer +from pyshark.packet.packet import Packet as RawPacket + +from pktverify import errors +from pktverify.addrs import EthAddr +from pktverify.coap import CoapLayer +from pktverify.consts import VALID_LAYER_NAMES +from pktverify.decorators import cached_property +from pktverify.layers import Layer, ThreadMeshcopLayer, Icmpv6Layer, WpanLayer, ThreadNetworkDataLayer +from pktverify.utils import make_filter_func + + +class Packet(object): + + def __init__(self, packet: RawPacket): + self._packet = packet + self._strip_wpan_eth_wrapper(packet) + + def __str__(self) -> str: + return str(self._packet) + + def __repr__(self) -> str: + return repr(self._packet) + + def __dir__(self) -> Iterable[str]: + return dir(self._packet) + + def _strip_wpan_eth_wrapper(self, packet: RawPacket): + if not hasattr(packet, 'wpan'): + return + + for layer in packet.layers: + if layer.layer_name == 'eth': + packet.layers.remove(layer) + eth_src = EthAddr(layer.get_field('eth.src')) + eth_dst = EthAddr(layer.get_field('eth.dst')) + logging.debug("stripping eth: src=%s, dst=%s", eth_src, eth_dst) + channel = eth_src[5] + self.wpan._add_field('wpan.channel', hex(channel)) + + return + + @property + def layers(self) -> Iterable[RawLayer]: + for l in self._packet.layers: + if l.layer_name != 'data': + yield getattr(self, l.layer_name) + + @property + def layer_names(self) -> List[str]: + return [l.layer_name for l in self._packet.layers if l.layer_name != 'data'] + + @cached_property + def wpan(self) -> WpanLayer: + return WpanLayer(self._packet, 'wpan') + + @cached_property + def coap(self) -> CoapLayer: + return CoapLayer(self._packet, 'coap') + + @cached_property + def icmpv6(self) -> Icmpv6Layer: + return Icmpv6Layer(self._packet, 'icmpv6') + + @cached_property + def thread_meshcop(self) -> ThreadMeshcopLayer: + return ThreadMeshcopLayer(self._packet, 'thread_meshcop') + + @cached_property + def thread_nwd(self) -> ThreadNetworkDataLayer: + return ThreadNetworkDataLayer(self._packet, 'thread_nwd') + + def __getattr__(self, layer_name: str) -> Layer: + + real_layer_name = layer_name + if layer_name == 'lowpan': + real_layer_name = '6lowpan' + + assert real_layer_name in VALID_LAYER_NAMES, '%s is not a valid layer name' % real_layer_name + _layer = getattr(self._packet, real_layer_name, None) + assert _layer is None or isinstance(_layer, RawLayer) + + layer = Layer(self._packet, real_layer_name) + setattr(self, layer_name, layer) + if real_layer_name != layer_name: + setattr(self, real_layer_name, layer) + + return layer + + def verify(self, func: Union[str, Callable], **vars) -> bool: + print("\n>>> verifying packet:", file=sys.stderr, flush=False) + func = make_filter_func(func, **vars) + ok = func(self) + print("\t=> %s" % ok, file=sys.stderr) + return ok + + def must_verify(self, func: Union[str, Callable], **vars): + if not self.verify(func, **vars): + raise errors.VerifyFailed(self) + + def must_not_verify(self, func: Union[str, Callable], **vars): + if self.verify(func, **vars): + raise errors.VerifyFailed(self) + + @property + def sniff_timestamp(self) -> float: + return float(self._packet.sniff_timestamp) + + def show(self): + self._packet.show() + + def debug_fields(self): + for layer in self._packet.layers: + print("### Layer %s ###" % layer.layer_name) + for k, v in layer._all_fields.items(): + print("\t\t%r = %r" % (k, v)) diff --git a/tests/scripts/thread-cert/pktverify/packet_filter.py b/tests/scripts/thread-cert/pktverify/packet_filter.py new file mode 100644 index 000000000..c4135eb55 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/packet_filter.py @@ -0,0 +1,466 @@ +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +import sys +from operator import attrgetter +from typing import Optional, Callable, Tuple + +from pktverify import consts, errors +from pktverify.addrs import EthAddr, ExtAddr, Ipv6Addr +from pktverify.packet import Packet +from pktverify.utils import make_filter_func + +WPAN, ETH = 0, 1 + + +class _SavedIndex(object): + __slots__ = ('_pkts', '_saved_index') + + def __init__(self, pkts): + self._pkts = pkts + self._saved_index = pkts.index + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_val, exc_tb): + self._pkts.index = self._saved_index + + +def _always_true(p): + return True + + +class PacketFilter(object): + """ + Represents a range of packets that are filtered by given filter + """ + + def __init__(self, + pkts, + start=(0, 0), + stop=None, + *, + index=None, + filter_func: Optional[Callable] = None, + parent: Optional['PacketFilter'] = None): + if stop is None: + stop = (len(pkts), len(pkts)) + + self._pkts = pkts + self._start_index = start + self._stop_index = stop + self._index = index if index is not None else self._start_index + self._last_index = -1 + self._filter_func = filter_func or _always_true + self._parent = parent + self._check_type_ok() + + def _check_type_ok(self): + assert self._last_index == -1 or 0 <= self._last_index < len(self._pkts) + + assert isinstance(self._start_index, tuple) and len(self._start_index) == 2, self._start_index + assert isinstance(self._stop_index, tuple) and len(self._stop_index) == 2, self._stop_index + assert isinstance(self._index, tuple) and len(self._index) == 2, self._index + self._check_idx_range_ok((0, 0), self._start_index) + self._check_idx_range_ok(self._start_index, self._index) + self._check_idx_range_ok(self._index, self._stop_index) + self._check_idx_range_ok(self._stop_index, (len(self._pkts), len(self._pkts))) + + def _check_idx_range_ok(self, start, stop): + assert start[0] <= stop[0], (start, stop) + assert start[1] <= stop[1], (start, stop) + + @property + def index(self) -> Tuple[int, int]: + """ + :return: the current index (which is a tuple) + """ + return self._index + + @index.setter + def index(self, index: Tuple[int, int]): + """ + Set the current index + :param index: the index tuple to set + """ + assert isinstance(index, tuple) and len(index) == 2, index + self._check_type_ok() + self._index = index + self._check_type_ok() + + def __len__(self): + """ + :return: length of packets + """ + return len(self._pkts) + + def save_index(self): + """ + Save the current index to be restored. + + :return: a context that saves the current index when entering, and restores when exiting + """ + return _SavedIndex(self) + + @property + def start_index(self) -> Tuple[int, int]: + """ + :return: the start index tuple + """ + return self._start_index + + @property + def stop_index(self) -> Tuple[int, int]: + """ + :return: the stop index tuple + """ + return self._stop_index + + def filter(self, func, cascade=True, **vars) -> 'PacketFilter': + """ + Create a new PacketFilter based on this packet filter with given filter func + + :param func: a callable that returns a bool (e.x. lambda p: xxx) or a filter string + :param cascade: True if calling next in the new filter will also set index for this filter, False otherwise + :param vars: variables for filter string + :return: a new PacketFilter + """ + print('\n>>> filtering in range %s~%s%s:' % + (self._index, self._stop_index, "" if self._stop_index == len(self._pkts) else ""), + file=sys.stderr) + + func = make_filter_func(func, **vars) + self._check_type_ok() + return PacketFilter(self._pkts, + self._index, + self._stop_index, + filter_func=lambda p: self._filter_func(p) and func(p), + parent=self if cascade else None) + + def filter_if(self, cond: bool, *args, **kwargs) -> 'PacketFilter': + """ + Create a filter using given arguments if `cond` is true. + + :param cond: the condition to be checked + :param args: arguments for filter func + :param kwargs: arguments for filter func + :return: a sub filter using given arguments if cond is true, or self otherwise + """ + if cond: + return self.filter(*args, **kwargs) + else: + return self + + @property + def last_index(self) -> Tuple[int, int]: + return self._last_index + + def last(self) -> Packet: + """ + :return: the last packet found + """ + if self._last_index >= 0: + return self._pkts[self._last_index] + else: + raise errors.PacketNotFound(self.index, self._stop_index) + + def next(self) -> Optional[Packet]: + """ + Find the next packet starting from the current index to the stop index that matches the current filter. + + :return: the next matching packet, or None if packet not found + """ + self._check_type_ok() + idx = min(self._index) + stop_idx = max(self._stop_index) + + while idx < stop_idx: + p = self._pkts[idx] + + sys.stderr.write('#%d %s' % (idx + 1, '\n' if idx % 40 == 39 else '')) + if self._filter_func(p): + if p.wpan and not (self._index[0] <= idx < self._stop_index[0]): # wpan matched but not in range + pass + elif p.eth and not (self._index[1] <= idx < self._stop_index[1]): # eth matched but not in range + pass + else: + self._on_found_next(idx, p) + print("\n>>> found packet at #%d!" % (idx + 1,), file=sys.stderr) + return p + + idx += 1 + + return None + + def must_next(self) -> Packet: + """ + Call .next(), raise error if packet is not found. + + :return: the next matching packet + """ + p = self.next() + if p is not None: + return p + else: + raise errors.PacketNotFound(self.index, self._stop_index) + + def must_not_next(self) -> None: + """ + Call .next(), raise error if packet is found + """ + p = self.next() + if p is None: + return + else: + raise errors.UnexpectedPacketFound(self.index, p) + + def _on_found_next(self, idx: int, p: Packet): + assert self._pkts[idx] is p + assert idx >= min(self._index) + assert not p.wpan or idx >= self._index[0] + assert not p.eth or idx >= self._index[1], (self._index, idx) + + if p.wpan: + wpan_idx = idx + 1 + eth_idx = max(self._index[1], + self._find_prev_packet(idx + 1, p.sniff_timestamp - consts.AUTO_SEEK_BACK_MAX_DURATION, ETH)) + else: + eth_idx = idx + 1 + wpan_idx = max( + self._index[0], + self._find_prev_packet(idx + 1, p.sniff_timestamp - consts.AUTO_SEEK_BACK_MAX_DURATION, WPAN)) + + # make sure index never go back + assert wpan_idx >= self._index[0] + assert eth_idx >= self._index[1] + + print('\n>>>_on_found_next %d %s => %s' % (idx, self._index, (wpan_idx, eth_idx)), file=sys.stderr) + self._set_found_index(idx, (wpan_idx, eth_idx)) + + def _find_prev_packet(self, idx, min_sniff_timestamp, pkttype): + assert pkttype in (WPAN, ETH) + + prev_idx = idx + while idx > 0 and self._pkts[idx - 1].sniff_timestamp >= min_sniff_timestamp: + idx -= 1 + if pkttype == WPAN and self._pkts[idx].wpan: + prev_idx = idx + elif pkttype == ETH and self._pkts[idx].eth: + prev_idx = idx + + return prev_idx + + def __iter__(self): + for pkt in self._pkts: + yield pkt + + def range(self, start, stop=None, cascade=True) -> 'PacketFilter': + """ + Create a new PacketFilter using the specified start and stop index tuples + + :param start: the new start index tuple + :param stop: the new stop index tuple + :param cascade: True if calling next in the new filter will also set index for this filter, False otherwise + :return: a new PacketFilter with new start and stop range + """ + + if stop is None: + stop = self._stop_index + + assert self._start_index <= start <= self._stop_index + assert self._start_index <= stop <= self._stop_index + return PacketFilter(self._pkts, start, stop, filter_func=self._filter_func, parent=self if cascade else None) + + def copy(self) -> 'PacketFilter': + """ + :return: a copy of the current PacketFilter + """ + return PacketFilter(self._pkts, self._index, self._stop_index, filter_func=self._filter_func, parent=None) + + def __getitem__(self, index: int) -> Packet: + """ + :param index: the packet index (not tuple!) + :return: the packet at the specified index + """ + assert isinstance(index, int), index + return self._pkts[index] + + def seek_back(self, max_duration: float, *, eth=False, wpan=False) -> 'PacketFilter': + """ + Move the current index back in time within the specified max duration. Either eth or wpan must be True. + + :param max_duration: the max duration to move back + :param eth: True if eth index can be moved back + :param wpan: True if wpan index can be moved back + :return: self + """ + assert eth or wpan, "must have eth or wpan" + + wpan_idx = self._index[0] + if wpan and wpan_idx < len(self._pkts): + wpan_idx = self._find_prev_packet(wpan_idx, self._pkts[wpan_idx].sniff_timestamp - max_duration, WPAN) + wpan_idx = max(self._start_index[0], wpan_idx) + + eth_idx = self._index[1] + if eth and eth_idx < len(self._pkts): + eth_idx = self._find_prev_packet(eth_idx, self._pkts[eth_idx].sniff_timestamp - max_duration, ETH) + eth_idx = max(self._start_index[1], eth_idx) + + print("\n>>> back %s wpan=%s, eth=%s: index %s => %s" % (max_duration, wpan, eth, self._index, + (wpan_idx, eth_idx)), + file=sys.stderr) + self._index = (wpan_idx, eth_idx) + self._check_type_ok() + return self + + def _set_found_index(self, last_index: Tuple[int, int], index: Tuple[int, int]): + self._last_index = last_index + self._index = index + self._check_type_ok() + + if self._parent is not None: + self._parent._set_found_index(last_index, index) + + def filter_coap(self, **kwargs): + """ + Create a new PacketFilter to filter COAP packets. + + :param kwargs: Extra arguments for `filter`. + :return: The new PacketFilter to filter COAP packets. + """ + return self.filter(attrgetter('coap'), **kwargs) + + def filter_coap_request(self, uri_path, port=None, **kwargs): + """ + Create a new PacketFilter to filter COAP Request packets. + + :param uri_path: The COAP URI path to filter. + :param port: The UDP port to filter if specified. + :param kwargs: Extra arguments for `filter`. + :return: The new PacketFilter to filter COAP Request packets. + """ + assert isinstance(uri_path, str), uri_path + assert port is None or isinstance(port, int), port + return self.filter( + lambda p: (p.coap.is_post and p.coap.opt.uri_path_recon == uri_path and + (port is None or p.udp.dstport == port)), **kwargs) + + def filter_coap_ack(self, uri_path, port=None, **kwargs): + """ + Create a new PacketFilter for filter COAP ACK packets. + + :param uri_path: The COAP URI path to filter. + :param port: The UDP port to filter if specified. + :param kwargs: Extra arguments for `filter`. + :return: The new PacketFilter to filter COAP ACK packets. + """ + assert isinstance(uri_path, str), uri_path + assert port is None or isinstance(port, int), port + return self.filter( + lambda p: (p.coap.is_ack and p.coap.opt.uri_path_recon == uri_path and + (port is None or p.udp.dstport == port)), **kwargs) + + def filter_wpan(self, **kwargs): + """ + Create a new PacketFilter for filter WPAN packets. + + :param kwargs: Extra arguments for `filter`. + :return: The new PacketFilter to filter WPAN packets. + """ + return self.filter(attrgetter('wpan'), **kwargs) + + def filter_wpan_channel(self, channel: int, **kwargs): + """ + Create a new PacketFilter for filter WPAN packets of a given channel. + + :param channel: The channel to filter. + :param kwargs: Extra arguments for `filter`. + :return: The new PacketFilter to filter WPAN packets. + """ + return self.filter(lambda p: p.wpan.channel == channel, **kwargs) + + def filter_wpan_src64(self, addr, **kwargs): + assert isinstance(addr, (str, ExtAddr)), addr + return self.filter(lambda p: p.wpan.src64 == addr, **kwargs) + + def filter_wpan_dst64(self, addr, **kwargs): + assert isinstance(addr, (str, ExtAddr)), addr + return self.filter(lambda p: p.wpan.dst64 == addr, **kwargs) + + def filter_ping_request(self, **kwargs): + return self.filter(lambda p: p.icmpv6.is_ping_request, **kwargs) + + def filter_ping_reply(self, **kwargs): + identifier = kwargs.pop('identifier', None) + return self.filter( + lambda p: (p.icmpv6.is_ping_reply and (identifier is None or p.icmpv6.echo.identifier == identifier)), + **kwargs) + + def filter_eth(self, **kwargs): + return self.filter(attrgetter('eth'), **kwargs) + + def filter_eth_src(self, addr, **kwargs): + assert isinstance(addr, (str, EthAddr)) + return self.filter(lambda p: p.eth.src == addr, **kwargs) + + def filter_ipv6_dst(self, addr, **kwargs): + assert isinstance(addr, (str, Ipv6Addr)) + return self.filter(lambda p: p.ipv6.dst == addr, **kwargs) + + def filter_LLANMA(self, **kwargs): + return self.filter(lambda p: p.ipv6.dst == consts.LINK_LOCAL_ALL_NODES_MULTICAST_ADDRESS, **kwargs) + + def filter_LLABMA(self, **kwargs): + return self.filter(lambda p: p.ipv6.dst == consts.LINK_LOCAL_ALL_BBRS_MULTICAST_ADDRESS, **kwargs) + + def filter_mle(self, **kwargs): + return self.filter(attrgetter('mle'), **kwargs) + + def filter_mle_cmd(self, cmd, **kwargs): + assert isinstance(cmd, int), cmd + return self.filter(lambda p: p.mle.cmd == cmd, **kwargs) + + def filter_icmpv6(self, **kwargs): + return self.filter(attrgetter('icmpv6'), **kwargs) + + def filter_icmpv6_nd_ns(self, target_address: Ipv6Addr): + return self.filter(lambda p: + (p.icmpv6.is_neighbor_solicitation and p.icmpv6.nd.ns.target_address == target_address)) + + def filter_icmpv6_nd_na(self, target_address: Ipv6Addr): + return self.filter(lambda p: + (p.icmpv6.is_neighbor_advertisement and p.icmpv6.nd.na.target_address == target_address)) + + def filter_has_bbr_dataset(self): + return self.filter(""" + thread_nwd.tlv.server.has('16') + and thread_nwd.tlv.service.s_data.seqno is not null + and thread_nwd.tlv.service.s_data.rrdelay is not null + and thread_nwd.tlv.service.s_data.mlrtimeout is not null + """) diff --git a/tests/scripts/thread-cert/pktverify/packet_verifier.py b/tests/scripts/thread-cert/pktverify/packet_verifier.py new file mode 100644 index 000000000..692b6ca69 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/packet_verifier.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import logging +from typing import Tuple + +from pktverify import consts +from pktverify.consts import DUA_RECENT_TIME, MLE_CHILD_ID_REQUEST, MLE_ADVERTISEMENT, MLE_CHILD_ID_RESPONSE +from pktverify.pcap_reader import PcapReader +from pktverify.summary import Summary +from pktverify.test_info import TestInfo +from pktverify.verify_result import VerifyResult + + +class PacketVerifier(object): + """ + Base class for packet verifiers that runs the packet verification process + """ + NET_NAME = "OpenThread" + MC_PORT = 49191 + MM_PORT = 61631 + LLANMA = 'ff02::1' # Link-Local All Nodes multicast address + LLARMA = 'ff02::2' # Link-Local All Routers multicast address + RLANMA = 'ff03::1' # realm-local all-nodes multicast address + RLARMA = 'ff03::2' # realm-local all-routers multicast address + RLAMFMA = 'ff03::fc' # realm-local ALL_MPL_FORWARDERS address + LLABMA = 'ff32:40:fd00:7d03:7d03:7d03:0:3' # Link-Local All BBRs multicast address + + def __init__(self, test_info_path): + logging.basicConfig(level=logging.INFO, + format='File "%(pathname)s", line %(lineno)d, in %(funcName)s\n' + '%(asctime)s - %(levelname)s - %(message)s') + + ti = TestInfo(test_info_path) + pkts = PcapReader.read(ti.pcap_path) + print('loaded %d packets from %s' % (len(pkts), ti.pcap_path)) + self.pkts = pkts + self.test_info = ti + + self.summary = Summary(pkts, ti) + self._vars = {} + self._add_initial_vars() + + def add_vars(self, **vars): + """ + Add new variables. + + :param vars: The new variables. + """ + self._vars.update(vars) + + @property + def vars(self): + """ + :return: the dict of all variables + """ + return self._vars + + def add_common_vars(self): + """ + Add common variables that is needed by many test cases. + """ + self.add_vars( + NET_NAME=PacketVerifier.NET_NAME, + MM_PORT=PacketVerifier.MM_PORT, + MC_PORT=PacketVerifier.MC_PORT, + LLANMA=PacketVerifier.LLANMA, # Link-Local All Nodes multicast address + LLARMA=PacketVerifier.LLARMA, # Link-Local All Routers multicast address + RLANMA=PacketVerifier.RLANMA, # realm-local all-nodes multicast address + RLARMA=PacketVerifier.RLARMA, # realm-local all-routers multicast address + RLAMFMA=PacketVerifier.RLAMFMA, # realm-local ALL_MPL_FORWARDERS address + LLABMA=PacketVerifier.LLABMA, # Link-Local All BBRs multicast address + MA1=consts.MA1, + MA2=consts.MA2, + MA3=consts.MA3, + MA4=consts.MA4, + MA5=consts.MA5, + MA6=consts.MA6, + MA1g=consts.MA1g, + MAe1=consts.MAe1, + MAe2=consts.MAe2, + MAe3=consts.MAe3, + ) + + def _add_initial_vars(self): + for i, addr in self.test_info.extaddrs.items(): + name = self.test_info.get_node_name(i) + self._vars[name] = addr + + for i, addr in self.test_info.ethaddrs.items(): + name = self.test_info.get_node_name(i) + '_ETH' + self._vars[name] = addr + + for i, addrs in self.test_info.ipaddrs.items(): + name = self.test_info.get_node_name(i) + for addr in addrs: + if addr.is_dua: + key = name + '_DUA' + elif addr.is_backbone: + key = name + '_BBA' + elif addr.is_link_local: + key = name + '_LLA' + else: + logging.warning("IPv6 address ignored: name=%s, addr=%s, is_global=%s, is_link_local=%s", name, + addr, addr.is_global, addr.is_link_local) + continue + + if key in self._vars: + logging.warning("duplicate IPv6 address type: name=%s, addr=%s,%s", name, addr, self._vars[key]) + continue + + self._vars[key] = addr + + for i, addr in self.test_info.mleids.items(): + name = self.test_info.get_node_name(i) + self._vars[name + '_MLEID'] = addr + + for i, rloc16 in self.test_info.rloc16s.items(): + key = self.test_info.get_node_name(i) + '_RLOC16' + self._vars[key] = rloc16 + + for k, v in self.test_info.extra_vars.items(): + assert k not in self._vars, k + logging.info("add extra var: %s = %s", k, v) + self._vars[k] = v + + def verify_dua_registration(self, + td: str, + bbr: str, + pkts=None, + dua_deadline=None, + DAD=False, + sbbr=None) -> VerifyResult: + """ + Run the packet verification for the while DAD registration, including optional steps + This is commonly used in many test cases. + + :param pkts: The packet filter to verify, or self.pkts if None + :param td: TB's name. + :param bbr: BBR's name. + """ + assert self.is_wpan_device(td) + assert self.is_wpan_device(bbr) and self.is_eth_device(bbr), bbr + + if pkts is None: + pkts = self.pkts + + logging.info("verifying DUA registration from %s to %s ...", td, bbr) + result = VerifyResult() + + TD = self.vars[td] + BBR = self.vars[bbr] + BBR_ETH = self.vars[bbr + '_ETH'] + if sbbr: + SBBR_ETH = self.vars[sbbr + '_ETH'] + # BBR_DUA = self.vars[bbr + '_DUA'] + p = pkts.filter_wpan_src64(TD) \ + .filter_coap_request("/n/dr", port=self.MM_PORT) \ + .must_next() + + result.record_last("/n/dr", pkts) + + if dua_deadline is not None: + p.must_verify(lambda p: p.sniff_timestamp <= dua_deadline) + + idx_after_n_dr = pkts.index + + p.must_verify(lambda p: p.coap.tlv.target_eid and p.coap.tlv.ml_eid) + DUA = p.coap.tlv.target_eid + MLEID = p.coap.tlv.ml_eid + self.add_vars(**{td + "_DUA": DUA, td + "_MLEID": MLEID}) + logging.info(f"DUA={DUA}, MLEID={MLEID}") + + if DAD: + # DAD (DUA_DATA_REPEAT+1) TIMES + before_dad_index = pkts.index + after_dad_index = before_dad_index + with pkts.save_index(): + for i in range(consts.DUA_DAD_REPEATS + 1): + # Step 3: PBBR - Performs DAD on the backbone link - Multicasts a BB.qry CoAP request + filter = pkts.filter_eth_src(BBR_ETH) \ + .filter_LLABMA() \ + .filter_coap_request("/b/bq") \ + .filter(lambda p: p.coap.tlv.target_eid == DUA) + p = filter.must_next() + after_dad_index = self.max_index(after_dad_index, pkts.index) + + with pkts.save_index(): + # try to find the next multicast + # start_index = pkts.index + if filter.next(): + pe = pkts.last() + time_gap = pe.sniff_timestamp - p.sniff_timestamp + # PBBR Waits for DUA_DAD_QUERY_TIMEOUT: Verify that DUA_DAD_QUERY_TIMEOUT time passes + assert time_gap >= consts.DUA_DAD_QUERY_TIMEOUT - 0.01, time_gap + + # SBBR: Does not respond: SBBR does not respond to the BB.qry message. + if sbbr is not None: + dad_pkts_range = pkts.range(before_dad_index, after_dad_index, cascade=False) + dad_pkts_range.filter_eth_src(SBBR_ETH) \ + .filter_LLABMA() \ + .filter_coap_ack("/b/bq") \ + .must_not_next() + + # BBR updates the corresponding entry in its DUA device table. + # No pass criteria + + # Step 7: BBR informs other BBRs on the network of the DUA registration. + # FIXME: Test plan requires that last_transaction_time <= 3, + # however real OT implementation can have last_transaction_time == 4 + expected_last_transaction_time = 0 if not DAD else 4 + pkts.filter_eth_src(BBR_ETH) \ + .filter_LLABMA() \ + .filter_coap_request("/b/ba") \ + .filter("coap.tlv.target_eid == {DUA}", DUA=DUA) \ + .must_next() \ + .must_verify(""" + coap.tlv.ml_eid == {MLEID} + and coap.tlv.last_transaction_time <= {expected_last_transaction_time} + and coap.tlv.net_name == {NET_NAME} + """, MLEID=MLEID, NET_NAME=self.NET_NAME, + expected_last_transaction_time=expected_last_transaction_time) + + idx1 = pkts.index + + # SBBR receives PRO_BB.ntf and optionally updates the corresponding entry + # in its Backup DUA Devices Table. No pass criteria. + + # BBR announces itself as the new ND proxy for the roaming device + pkts.seek_back(0.2, eth=True) \ + .filter_eth_src(BBR_ETH) \ + .filter_LLANMA() \ + .filter_icmpv6_nd_na(DUA) \ + .must_next() \ + .must_verify(""" + icmpv6.nd.na.flag.s == 0 + and icmpv6.nd.na.flag.o == 1 + and icmpv6.nd.na.flag.r == 1 + and icmpv6.opt.target_linkaddr == {BBR_ETH} + """, BBR_ETH=BBR_ETH) + + idx2 = pkts.index + # BBR responds to the DUA registration + pkts.index = idx_after_n_dr # reset index to just after /n/dr request + pkts.filter_wpan_src64(BBR) \ + .filter_coap_ack("/n/dr") \ + .filter("coap.tlv.target_eid == {DUA}", DUA=DUA) \ + .must_next() \ + .must_verify(""" + coap.tlv.target_eid == {DUA} + and coap.tlv.status == 0 + """, DUA=DUA) + + pkts.index = self.max_index(idx1, idx2, pkts.index) + # BBR optionally repeats the unsolicited neighbor advertisement. + # Optional 1 or 2 times + with pkts.save_index(): + filter = pkts.filter_eth_src(BBR_ETH) \ + .filter_LLANMA() \ + .filter_icmpv6_nd_na(DUA) + + for i in range(2): + if not filter.next(): + break + + filter.last().must_verify(""" + icmpv6.nd.na.flag.s == 0 + and icmpv6.nd.na.flag.o == 1 + and icmpv6.nd.na.flag.r == 1 + and icmpv6.opt.target_linkaddr == {BBR_ETH} + """, + BBR_ETH=BBR_ETH) + + # BBR Optionally repeats the DUA registration notification + # Optional + with pkts.save_index(): + filter = pkts.filter_eth_src(BBR_ETH) \ + .filter_LLABMA() \ + .filter_coap_request("/b/ba") \ + .filter("coap.tlv.target_eid == {DUA}", DUA=DUA) + + if filter.next(): + p = filter.last() + p.must_verify(""" + coap.tlv.ml_eid == {MLEID} + and coap.tlv.net_name == {NET_NAME} + and 3 < coap.tlv.last_transaction_time < DUA_RECENT_TIME + """, + DUA_RECENT_TIME=DUA_RECENT_TIME, + MLEID=MLEID, + NET_NAME=self.NET_NAME) + + if sbbr is not None: + # SBBR: Does not respond to the ND Neighbor Solicitation message. + SBBR_ETH = self.vars[sbbr + '_ETH'] + pkts_in_range = pkts.range(idx_after_n_dr, pkts.index, cascade=False) + pkts_in_range.filter_eth_src(SBBR_ETH).filter_LLANMA().filter_icmpv6_nd_na(DUA).must_not_next() + + return result + + def verify_attached(self, name: str, pkts=None) -> VerifyResult: + """ + Verify that the device attaches to the Thread network. + + :param name: The device name. + """ + result = VerifyResult() + assert self.is_wpan_device(name), name + pkts = pkts or self.pkts + extaddr = self.vars[name] + + src_pkts = pkts.filter_wpan_src64(extaddr) + src_pkts.filter_mle_cmd(MLE_CHILD_ID_REQUEST).must_next() # Child Id Request + result.record_last('child_id_request', pkts) + + dst_pkts = pkts.filter_wpan_dst64(extaddr) + dst_pkts.filter_mle_cmd(MLE_CHILD_ID_RESPONSE).must_next() # Child Id Response + result.record_last('child_id_response', pkts) + + with pkts.save_index(): + src_pkts.filter_mle_cmd(MLE_ADVERTISEMENT).must_next() # MLE Advertisement + result.record_last('mle_advertisement', pkts) + logging.info(f"verify attached: d={name}, result={result}") + + return result + + def verify_ping(self, src: str, dst: str, bbr: str = None, pkts: 'PacketVerifier' = None) -> VerifyResult: + """ + Verify the ping process. + + :param src: The source device name. + :param dst: The destination device name. + :param bbr: The Backbone Router name. + If specified, this method also verifies that the ping request and reply be forwarded by the Backbone Router. + :param pkts: The PacketFilter to search. + + :return: The verification result. + """ + if bbr: + assert not (self.is_wpan_device(src) and self.is_wpan_device(dst)), \ + f"both {src} and {dst} are WPAN devices" + assert not (self.is_eth_device(src) and self.is_eth_device(dst)), \ + f"both {src} and {dst} are ETH devices" + + if pkts is None: + pkts = self.pkts + + src_dua = self.vars[src + '_DUA'] + dst_dua = self.vars[dst + '_DUA'] + if bbr: + bbr_ext = self.vars[bbr] + bbr_eth = self.vars[bbr + '_ETH'] + + result = VerifyResult() + ping_req = pkts.filter_ping_request().filter_ipv6_dst(dst_dua) + if self.is_eth_device(src): + p = ping_req.filter_eth_src(self.vars[src + '_ETH']).must_next() + else: + p = ping_req.filter_wpan_src64(self.vars[src]).must_next() + + # pkts.last().show() + ping_id = p.icmpv6.echo.identifier + logging.info("verify_ping: ping_id=%x", ping_id) + result.record_last('ping_request', pkts) + ping_req = ping_req.filter(lambda p: p.icmpv6.echo.identifier == ping_id) + + # BBR unicasts the ping packet to TD. + if bbr: + if self.is_eth_device(src): + ping_req.filter_wpan_src64(bbr_ext).must_next() + else: + ping_req.filter_eth_src(bbr_eth).must_next() + + ping_reply = pkts.filter_ping_reply().filter_ipv6_dst(src_dua).filter( + lambda p: p.icmpv6.echo.identifier == ping_id) + # TD receives ping packet and responds back to Host via SBBR. + if self.is_wpan_device(dst): + ping_reply.filter_wpan_src64(self.vars[dst]).must_next() + else: + ping_reply.filter_eth_src(self.vars[dst + '_ETH']).must_next() + + result.record_last('ping_reply', pkts) + + if bbr: + # SBBR forwards the ping response packet to Host. + if self.is_wpan_device(dst): + ping_reply.filter_eth_src(bbr_eth).must_next() + else: + ping_reply.filter_wpan_src64(bbr_ext).must_next() + + return result + + def is_wpan_device(self, name: str) -> bool: + """ + Returns if the device is an WPAN device. + + :param name: The device name. + + Note that device can be both a WPAN device and an Ethernet device. + """ + assert isinstance(name, str), name + + return name in self.test_info.extaddrs + + def is_eth_device(self, name: str) -> bool: + """ + Returns if the device s an Ethernet device. + + :param name: The device name. + + Note that device can be both a WPAN device and an Ethernet device. + """ + assert isinstance(name, str), name + + return name in self.test_info.ethaddrs + + def max_index(self, *indexes: Tuple[int, int]) -> Tuple[int, int]: + wpan_idx = 0 + eth_idx = 0 + for wi, ei in indexes: + wpan_idx = max(wpan_idx, wi) + eth_idx = max(eth_idx, ei) + + return wpan_idx, eth_idx diff --git a/tests/scripts/thread-cert/pktverify/pcap_reader.py b/tests/scripts/thread-cert/pktverify/pcap_reader.py new file mode 100644 index 000000000..bcad5a567 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/pcap_reader.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS' +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import logging +import os +import subprocess +from typing import Optional + +import pyshark + +from pktverify import consts, utils +from pktverify.packet import Packet +from pktverify.packet_filter import PacketFilter + + +class PcapReader(object): + """ + Implements Pcap reading utilities. + """ + + @classmethod + def read(cls, filename: str, tshark_path: Optional[str] = None) -> PacketFilter: + """ + Read packets from a given Pcap file. + + :param filename: The Pcap file. + :param tshark_path: The optional path to the `tshark`. + :return: A PacketFilter containing all packets of the Pcap file. + """ + if tshark_path is None: + tshark_path = utils.which_tshark() + + logging.info("Using tshark path: %s", tshark_path) + subprocess.check_call(f"{tshark_path} -v", shell=True) + os.system(f"ls -l {filename}") + filecap = pyshark.FileCapture(filename, + tshark_path=tshark_path, + override_prefs=consts.WIRESHARK_OVERRIDE_PREFS, + decode_as=consts.WIRESHARK_DECODE_AS_ENTRIES) + filecap.load_packets() + return PacketFilter(tuple(map(Packet, filecap._packets))) + + +if __name__ == '__main__': + pkts = PcapReader.read("../../../../merged.pcap") + print(len(pkts), 'packets loaded') + for p in pkts: + if not p.mle: + break + + print("mle.cmd=%r" % p.mle.cmd) diff --git a/tests/scripts/thread-cert/pktverify/summary.py b/tests/scripts/thread-cert/pktverify/summary.py new file mode 100644 index 000000000..60cffa8b5 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/summary.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +import logging +import operator +import sys + +from pktverify import consts +from pktverify.test_info import TestInfo + + +class NodeSummary(object): + """ + Represents a summary of a node. + """ + + def __init__(self, role, extaddr): + self._role = role + self._extaddr = extaddr + self._ipaddrs = {} + + @property + def role(self): + return self._role + + @property + def extaddr(self): + return self._extaddr + + @property + def ipaddr_link_local(self): + for a, _ in self._iter_ipaddrs_rev(): + if a.is_link_local: + return a + + return None + + @property + def ipaddr_mleid(self): + for a, _ in self._iter_ipaddrs_rev(): + if a.is_mleid: + return a + + return None + + def _iter_ipaddrs_rev(self): + return sorted(self._ipaddrs.items(), key=operator.itemgetter(1), reverse=True) + + def add_ipaddr(self, ipaddr, index): + if ipaddr not in self._ipaddrs: + self._ipaddrs[ipaddr] = index + + def __str__(self): + return "[node {role} extaddr {extaddr} ipaddrs {ipaddrs}]".format( + role=self._role, + extaddr=self.extaddr, + ipaddrs=", ".join(map(str, sorted(self._ipaddrs))), + ) + + __repr__ = __str__ + + +class Summary(object): + """ + Represents a summary of the test. + """ + + def __init__(self, pkts, test_info: TestInfo): + self._pkts = pkts + self._test_info = test_info + self._leader_id = None + self._analyze() + + def iterroles(self): + return self._role_to_node.items() + + def _analyze(self): + self._analyze_test_info() + + with self._pkts.save_index(): + for f in [ + self._analyze_leader, + self._analyze_packets, + ]: + self._pkts.index = (0, 0) + f() + + def _analyze_test_info(self): + self._role_to_node = {} + self._extaddr_to_node = {} + + for role, extaddr in self._test_info.extaddrs.items(): + assert role not in self._role_to_node + assert extaddr not in self._extaddr_to_node + + node = NodeSummary(role, extaddr) + self._role_to_node[role] = node + self._extaddr_to_node[extaddr] = node + + def _analyze_leader(self): + for p in self._pkts: + + if p.mle.cmd in [consts.MLE_DATA_RESPONSE, consts.MLE_ADVERTISEMENT]: + + p.mle.__getattr__('tlv') + p.mle.__getattr__('tlv.leader_data') + p.mle.__getattr__('tlv.leader_data.router_id') + + tlv = p.mle.tlv + if tlv.leader_data: + self._leader_id = tlv.leader_data.router_id + logging.info("leader found in pcap: %d", self._leader_id) + break + else: + logging.warning("leader not found in pcap") + + def _analyze_packets(self): + for i, p in enumerate(self._pkts): + extaddr, src = None, None + # each packet should be either wpan or eth + assert (p.wpan and not p.eth) or (p.eth and not p.wpan) + if p.wpan: + # it is a 802.15.4 packet + extaddr = p.wpan.src64 + + if p.ipv6: + # it is a IPv6 packet + src = p.ipv6.src + + if extaddr and src: + if extaddr in self._extaddr_to_node: + role_sum = self._extaddr_to_node[extaddr] + role_sum.add_ipaddr(src, i) + else: + logging.warn("Extaddr %s is not in the testbed", extaddr) + + def show(self): + show_roles = "\n\t\t".join(map(str, self._role_to_node.values())) + sys.stderr.write("""{header} + Pcap Summary: + packets = {num_packets} + roles = {num_roles} + {show_roles} + {tailer} + """.format( + header='>' * 120, + num_packets=len(self._pkts), + num_roles=len(self._role_to_node), + show_roles=show_roles, + tailer='<' * 120, + )) + + def ipaddr_mleid_by_role(self, role): + node = self._role_to_node[role] + return node.ipaddr_mleid + + def ipaddr_link_local_by_role(self, role): + node = self._role_to_node[role] + return node.ipaddr_link_local + + def role(self, r): + return self._role_to_node[r] diff --git a/tests/scripts/thread-cert/pktverify/test_info.py b/tests/scripts/thread-cert/pktverify/test_info.py new file mode 100644 index 000000000..9198d2be8 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/test_info.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +import json +import os +from typing import Dict + +from pktverify.addrs import EthAddr, ExtAddr, Ipv6Addr + + +class TestInfo(object): + """ + Represents the test information. + """ + + def __init__(self, filename): + self.filename = filename + with open(filename, 'rt') as fd: + test_info = json.loads(fd.read()) + + self.testcase = test_info.get('testcase', '') + self._pcap = test_info.get('pcap', 'current.pcap') + self.topology = self._convert_keys_to_ints(test_info['topology']) + self.extaddrs = {int(k): ExtAddr(v) for k, v in test_info.get('extaddrs', {}).items()} + self.ethaddrs = {int(k): EthAddr(v) for k, v in test_info.get('ethaddrs', {}).items()} + self.ipaddrs = {int(k): [Ipv6Addr(x) for x in l] for k, l in test_info.get('ipaddrs', {}).items()} + self.mleids = {int(k): Ipv6Addr(v) for k, v in test_info.get('mleids', {}).items()} + self.rloc16s = self._convert_hex_values(self._convert_keys_to_ints(test_info.get('rloc16s', {}))) + self.extra_vars = test_info.get('extra_vars', {}) + + def __str__(self): + macs = dict(self.extaddrs) + macs.update({k + '_ETH': v for k, v in self.ethaddrs.items()}) + macs = ",\n\t".join("%s=%s" % (k, v) for k, v in macs.items()) + + return "TestInfo<{case}|{pcap}|\n\t{macs}>".format(case=self.testcase, pcap=self._pcap, macs=macs) + + @property + def pcap_path(self) -> str: + """ + :return: The path to the Pcap file. + """ + dir = os.path.dirname(self.filename) + return os.path.join(dir, self._pcap) + + @staticmethod + def _convert_keys_to_ints(d: Dict[str, any]) -> Dict[int, any]: + return {int(k): v for k, v in d.items()} + + @staticmethod + def _convert_hex_values(d: Dict[any, str]) -> Dict[any, int]: + return {k: int(v, 16) for k, v in d.items()} + + def get_node_name(self, node_id: int) -> str: + """ + Gets the name of the device. + + :param node_id: The device ID. + :return: The name of the device. + """ + return self.topology[node_id].get('name', 'Node%d' % node_id) diff --git a/tests/scripts/thread-cert/pktverify/test_layer_fields.py b/tests/scripts/thread-cert/pktverify/test_layer_fields.py new file mode 100644 index 000000000..d7ea80ec9 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/test_layer_fields.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# This is a test script for checking layer fields against a given test.pcap. +# + +import logging +import sys +import unittest + +from pktverify import layer_fields +from pktverify.addrs import EthAddr, ExtAddr, Ipv6Addr +from pktverify.bytes import Bytes +from pktverify.consts import REAL_LAYER_NAMES, VALID_LAYER_NAMES +from pktverify.layer_fields_container import LayerFieldsContainer +from pktverify.null_field import nullField +from pktverify.packet import Packet +from pktverify.pcap_reader import PcapReader + + +class TestLayerFields(unittest.TestCase): + + def test(self): + logging.basicConfig(level=logging.DEBUG) + pkts = PcapReader.read("test.pcap") + + for p in pkts: + + for layer_name in VALID_LAYER_NAMES: + if layer_name == 'lowpan': # we already checked 6lowpan + continue + + layer = getattr(p, layer_name) + if hasattr(p._packet, layer_name): + if layer_name in REAL_LAYER_NAMES: + self.assertTrue(layer) + checker = getattr(self, '_test_' + layer_name, None) + if checker is None: + continue + + try: + checker(p) + except Exception: + layer.show() + raise + + self._check_missing_fields(p, layer_name, getattr(p._packet, layer_name)) + else: + if layer_name in REAL_LAYER_NAMES: + self.assertFalse(layer) + + def _test_coap(self, p): + coap = p.coap + self.assertIsInstance(coap.version, int) + self.assertIsInstance(coap.type, int) + self.assertIsInstance(coap.token_len, int) + self.assertIsInstance(coap.code, int) + self.assertIsInstance(coap.mid, int) + self.assertIsInstanceOrNull(coap.token, int) + self.assertIsInstanceOrNull(coap.opt.uri_path_recon, str) + self.assertIsInstanceOrNull(coap.payload, bytearray) + + print(p.coap.tlv.type, p.coap.tlv) + assert isinstance(coap.tlv, LayerFieldsContainer), repr(coap.tlv) + self.assertIsInstanceOrNull(coap.tlv.type, list) + + def _test_mle(self, p): + mle = p.mle + + self._must_have_wpan_aux_sec(p) + + self.assertIsInstance(mle.cmd, int) + self.assertIsInstanceOrNull(mle.tlv.mode.receiver_on_idle, int) + self.assertIsInstanceOrNull(mle.tlv.mode.reserved1, int) + self.assertIsInstanceOrNull(mle.tlv.mode.reserved2, int) + self.assertIsInstanceOrNull(mle.tlv.mode.device_type_bit, int) + self.assertIsInstanceOrNull(mle.tlv.mode.network_data, int) + self.assertIsInstanceOrNull(mle.tlv.challenge, Bytes) + self.assertIsInstanceOrNull(mle.tlv.scan_mask.r, int) + self.assertIsInstanceOrNull(mle.tlv.scan_mask.e, int) + self.assertIsInstanceOrNull(mle.tlv.version, int) + self.assertIsInstanceOrNull(mle.tlv.source_addr, int) + self.assertIsInstanceOrNull(mle.tlv.active_tstamp, int) + self.assertIsInstanceOrNull(mle.tlv.leader_data.partition_id, int) + self.assertIsInstanceOrNull(mle.tlv.leader_data.weighting, int) + self.assertIsInstanceOrNull(mle.tlv.leader_data.data_version, int) + self.assertIsInstanceOrNull(mle.tlv.leader_data.stable_data_version, int) + self.assertIsInstanceOrNull(mle.tlv.leader_data.router_id, int) + + def _test_wpan(self, p): + wpan = p.wpan + self.assertIsInstance(wpan.fcf, int) + self.assertIsInstance(wpan.fcs, int) + self.assertIsInstance(wpan.security, int) + self.assertIsInstance(wpan.pending, int) + self.assertIsInstance(wpan.ack_request, int) + self.assertIsInstance(wpan.pan_id_compression, int) + self.assertIsInstance(wpan.seqno_suppression, int) + self.assertIsInstance(wpan.ie_present, int) + self.assertIsInstance(wpan.dst_addr_mode, int) + self.assertIsInstance(wpan.version, int) + self.assertIsInstance(wpan.src_addr_mode, int) + + self.assertIsInstance(wpan.seq_no, int) + + if not wpan.is_ack: + self.assertIsInstanceOrNull(wpan.dst_pan, int) + self.assertIsInstanceOrNull(wpan.dst16, int) + self.assertIsInstanceOrNull(wpan.src16, int) + self.assertIsInstanceOrNull(wpan.src64, ExtAddr) + self.assertIsInstanceOrNull(wpan.dst64, ExtAddr) + + if wpan.aux_sec: + self._must_have_wpan_aux_sec(p) + + def _must_have_wpan_aux_sec(self, p): + wpan = p.wpan + self.assertIsInstanceOrNull(wpan.aux_sec.sec_suite, int) + self.assertIsInstanceOrNull(wpan.aux_sec.security_control_field, int) + self.assertIsInstanceOrNull(wpan.aux_sec.sec_level, int) + self.assertIsInstanceOrNull(wpan.aux_sec.key_id_mode, int) + self.assertIsInstanceOrNull(wpan.aux_sec.frame_counter_suppression, int) + self.assertIsInstanceOrNull(wpan.aux_sec.asn_in_nonce, int) + self.assertIsInstanceOrNull(wpan.aux_sec.reserved, int) + self.assertIsInstanceOrNull(wpan.aux_sec.frame_counter, int) + self.assertIsInstanceOrNull(wpan.aux_sec.key_source, int) + self.assertIsInstanceOrNull(wpan.aux_sec.key_index, int) + + def assertIsInstanceOrNull(self, field, type): + if field is not nullField: + self.assertIsInstance(field, type) + + def _test_thread_bl(self, p): + thread_bl = p.thread_bl + self.assertTrue(thread_bl) + + self.assertIsInstanceOrNull(thread_bl.tlv.target_eid, Ipv6Addr) + self.assertIsInstanceOrNull(thread_bl.tlv.ml_eid, ExtAddr) + self.assertIsInstanceOrNull(thread_bl.tlv.last_transaction_time, int) + self.assertIsInstanceOrNull(p.thread_meshcop.tlv.net_name, str) + + def _test_thread_meshcop(self, p: Packet): + thread_meshcop = p.thread_meshcop + + for layer in sorted(p.layers, key=lambda l: l.layer_name): + if 'thread_meshcop.tlv.commissioner_sess_id' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.commissioner_sess_id, int) + + if 'thread_meshcop.tlv.net_name' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.net_name, str) + + if 'thread_meshcop.tlv.channel_page' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.channel_page, int) + + if 'thread_meshcop.tlv.channel' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.channel, int) + + if 'thread_meshcop.tlv.chan_mask_page' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.chan_mask_page, int) + if 'thread_meshcop.tlv.chan_mask_len' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.chan_mask_len, int) + if 'thread_meshcop.tlv.chan_mask_mask' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.chan_mask_mask, int) + + if 'thread_meshcop.tlv.panid' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.panid, int) + + if 'thread_meshcop.tlv.ml_prefix' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.ml_prefix, Bytes) + + if 'thread_meshcop.tlv.master_key' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.master_key, Bytes) + + if 'thread_meshcop.tlv.pskc' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.pskc, Bytes) + + if 'thread_meshcop.tlv.sec_policy_rot' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.sec_policy_rot, int) + + if 'thread_meshcop.tlv.sec_policy_o' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.sec_policy_o, int) + + if 'thread_meshcop.tlv.sec_policy_n' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.sec_policy_n, int) + + if 'thread_meshcop.tlv.sec_policy_r' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.sec_policy_r, int) + + if 'thread_meshcop.tlv.sec_policy_c' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.sec_policy_c, int) + + if 'thread_meshcop.tlv.sec_policy_b' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.sec_policy_b, int) + + if 'thread_meshcop.tlv.pan_id' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.pan_id, int) + + if 'thread_meshcop.tlv.xpan_id' in layer._layer._all_fields: + self.assertIsInstance(thread_meshcop.tlv.xpan_id, Bytes) + + for field in layer._layer._all_fields: + if field.startswith('thread_meshcop') and not layer_fields.is_layer_field(field) and field not in ( + 'thread_meshcop.tlv', 'thread_meshcop.tlv.type', 'thread_meshcop.tlv.len8'): + print('found %s = %s in layer %s' % ( + field, + layer._layer.get_field(field), + layer.layer_name, + )) + + def _test_icmpv6(self, p): + icmpv6 = p.icmpv6 + self.assertTrue(p.icmpv6) + + self.assertIsInstance(icmpv6.type, int) + self.assertIsInstance(icmpv6.code, int) + self.assertIsInstance(icmpv6.checksum, int) + self.assertIsInstanceOrNull(icmpv6.reserved, int) + self.assertIsInstanceOrNull(icmpv6.nd.na.flag.s, int) + self.assertIsInstanceOrNull(icmpv6.nd.na.flag.o, int) + self.assertIsInstanceOrNull(icmpv6.nd.na.flag.r, int) + self.assertIsInstanceOrNull(icmpv6.nd.na.flag.rsv, int) + self.assertIsInstanceOrNull(icmpv6.nd.ra.cur_hop_limit, int) + self.assertIsInstanceOrNull(icmpv6.mldr.nb_mcast_records, int) + self.assertIsInstanceOrNull(icmpv6.nd.ns.target_address, Ipv6Addr) + self.assertIsInstanceOrNull(icmpv6.mldr.mar.multicast_address, list) + + def get_field(self, p: Packet, f): + secs = f.split('.') + assert len(secs) >= 2 + v = p + for sec in secs: + v = getattr(v, sec) + + return v + + def _test_6lowpan(self, p): + lowpan = p.lowpan + assert lowpan is getattr(p, '6lowpan') + self.assertIsInstanceOrNull(lowpan.src, Ipv6Addr) + self.assertIsInstanceOrNull(lowpan.dst, Ipv6Addr) + self.assertIsInstanceOrNull(lowpan.udp.src, int) + self.assertIsInstanceOrNull(lowpan.udp.dst, int) + self.assertIsInstanceOrNull(lowpan.udp.checksum, int) + self.assertIsInstanceOrNull(lowpan.frag.size, int) + self.assertIsInstanceOrNull(lowpan.frag.tag, int) + self.assertIsInstanceOrNull(lowpan.frag.offset, int) + self.assertIsInstanceOrNull(lowpan.nhc.pattern, list) + self.assertIsInstanceOrNull(lowpan.nhc.udp.checksum, int) + self.assertIsInstanceOrNull(lowpan.nhc.udp.ports, int) + + self.assertIsInstanceOrNull(lowpan.pattern, list) + self.assertIsInstanceOrNull(lowpan.iphc.tf, int) + self.assertIsInstanceOrNull(lowpan.iphc.nh, int) + self.assertIsInstanceOrNull(lowpan.iphc.hlim, int) + self.assertIsInstanceOrNull(lowpan.iphc.cid, int) + self.assertIsInstanceOrNull(lowpan.iphc.sac, int) + self.assertIsInstanceOrNull(lowpan.iphc.sam, int) + self.assertIsInstanceOrNull(lowpan.iphc.m, int) + self.assertIsInstanceOrNull(lowpan.iphc.dac, int) + self.assertIsInstanceOrNull(lowpan.iphc.dam, int) + self.assertIsInstanceOrNull(lowpan.iphc.sctx.prefix, Bytes) + self.assertIsInstanceOrNull(lowpan.iphc.dctx.prefix, Bytes) + + def _test_ip(self, p): + pass + + def _test_ipv6(self, p): + pass + + def _test_udp(self, p): + pass + + def _test_eth(self, p): + eth = p.eth + self.assertIsInstance(eth.src, EthAddr) + self.assertIsInstance(eth.dst, EthAddr) + self.assertIsInstance(eth.type, int) + + def _check_missing_fields(self, p, layer_name, _layer): + for f in sorted(_layer._all_fields.keys(), reverse=True): + if f.startswith('_ws') or f.startswith('data'): + continue + + logging.info('_check_missing_fields: %s = %r' % (f, _layer._all_fields[f])) + if f in { + '', 'icmpv6.checksum.status', 'ip.ttl.lncb', 'wpan.aux_sec.key_source.bytes', 'wpan.src64.origin' + }: + # TODO: handle these fields + continue + + v = _layer._all_fields[f] + + if layer_fields.is_layer_field_container(f): + continue + + try: + rv = self.get_field(p, f) + self.assertIsNot(rv, nullField) + + parser = layer_fields._LAYER_FIELDS[f] + if isinstance(parser, layer_fields._first): + parser = parser._sub_parse + + if parser in (layer_fields._raw_hex, layer_fields._hex, layer_fields._raw_hex_rev, layer_fields._dec, + layer_fields._auto): + self.assertIsInstance(rv, int) + elif isinstance(parser, layer_fields._list): + self.assertIsInstance(rv, list) + elif parser is layer_fields._ipv6_addr: + self.assertIsInstance(rv, Ipv6Addr) + elif parser is layer_fields._eth_addr: + self.assertIsInstance(rv, EthAddr) + elif parser is layer_fields._ext_addr: + self.assertIsInstance(rv, ExtAddr) + elif parser is layer_fields._str: + self.assertIsInstance(rv, str) + elif parser is layer_fields._bytes: + self.assertIsInstance(rv, Bytes) + elif parser is layer_fields._payload: + self.assertIsInstance(rv, bytearray) + elif parser is layer_fields._float: + self.assertIsInstance(rv, float) + else: + raise NotImplementedError(parser) + except Exception: + logging.info('checking [%s] %s=%r, %r, %r (%d)' % + (layer_name, f, v, v.get_default_value(), v.raw_value, len(v.fields)), + file=sys.stderr) + raise diff --git a/tests/scripts/thread-cert/pktverify/utils.py b/tests/scripts/thread-cert/pktverify/utils.py new file mode 100644 index 000000000..aa85d8a12 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/utils.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +import logging +import os +import subprocess +import sys +from typing import Callable, Union + +from pktverify.addrs import EthAddr, ExtAddr, Ipv6Addr +from pktverify.bytes import Bytes +from pktverify.null_field import nullField + + +def make_filter_func(func: Union[str, Callable], **vars) -> Callable: + """ + Convert the filter to a callable function if it's a string. + + :param func: The filter string or callable. + :param vars: The variables. + :return: The filter callable. + """ + if isinstance(func, str): + # if func is a string, compile it to a function + func = func.format_map({k: repr(v) for k, v in vars.items()}).strip() + print("\t%s" % func, file=sys.stderr) + code = compile('(\n' + func + '\n)', func, "eval") + + def func(p): + return eval( + code, None, { + 'p': p, + 'coap': p.coap, + 'wpan': p.wpan, + 'mle': p.mle, + 'ipv6': p.ipv6, + 'lowpan': p.lowpan, + 'eth': p.eth, + 'icmpv6': p.icmpv6, + 'udp': p.udp, + 'thread_bl': p.thread_bl, + 'thread_meshcop': p.thread_meshcop, + 'Bytes': Bytes, + 'ExtAddr': ExtAddr, + 'Ipv6Addr': Ipv6Addr, + 'EthAddr': EthAddr, + 'thread_nm': p.thread_nm, + 'thread_nwd': p.thread_nwd, + 'null': nullField, + }) + else: + assert not vars, 'can not provide vars for non-str filter: %r %r' % (func, vars) + + assert callable(func) + return func + + +def _install_travis_thread_wireshark(): + logging.info("downloading thread-wireshark from https://github.com/openthread/wireshark/releases ...") + + download_url = 'https://github.com/openthread/wireshark/releases/download/ot-pktverify-20200727/thread-wireshark.tar.gz' + save_file = '/tmp/thread-wireshark.tar.gz' + + subprocess.check_call(f'curl -L {download_url} -o {save_file}', shell=True) + subprocess.check_call(f'tar -C /tmp -xvzf {save_file}', shell=True) + assert os.path.isdir('/tmp/thread-wireshark') + + +def _setup_wireshark_disabled_protos(): + home = os.environ['HOME'] + wireshark_config_dir = os.path.join(home, '.config', 'wireshark') + os.makedirs(wireshark_config_dir, exist_ok=True) + disabled_protos_path = os.path.join(wireshark_config_dir, 'disabled_protos') + # read current disabled protos + try: + with open(disabled_protos_path, 'rt') as fd: + disabled_protos = set(l.strip() for l in fd if l.strip() != '') + except FileNotFoundError: + disabled_protos = set() + + old_disabled_protos_num = len(disabled_protos) + disabled_protos.add('lwm') + disabled_protos.add('prp') + disabled_protos.add('stcsig') + disabled_protos.add('transum') + disabled_protos.add('zbee_nwk') + disabled_protos.add('zbee_nwk_gp') + + if len(disabled_protos) > old_disabled_protos_num: + logging.info(f"set disabled_protos = {' '.join(disabled_protos)}") + with open(disabled_protos_path, 'wt') as fd: + fd.write('\n'.join(sorted(disabled_protos))) + fd.write('\n') + + +def get_wireshark_dir() -> str: + """ + :return: The path to wireshark directory. + """ + dir = '/tmp/thread-wireshark' + if not os.path.exists(dir): + _install_travis_thread_wireshark() + + _setup_wireshark_disabled_protos() + + return dir + + +def which_tshark() -> str: + """ + :return: The path to `tshark` executable. + """ + return os.path.join(get_wireshark_dir(), 'tshark') + + +def which_dumpcap() -> str: + """ + :return: The path to `dumpcap` executable. + """ + return os.path.join(get_wireshark_dir(), 'dumpcap') + + +def which_mergecap() -> str: + """ + :return: The path to `mergecap` executable. + """ + return os.path.join(get_wireshark_dir(), 'mergecap') diff --git a/tests/scripts/thread-cert/pktverify/verify_result.py b/tests/scripts/thread-cert/pktverify/verify_result.py new file mode 100644 index 000000000..012cce407 --- /dev/null +++ b/tests/scripts/thread-cert/pktverify/verify_result.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +from pktverify.packet import Packet +from pktverify.packet_filter import PacketFilter + + +class VerifyResult(object): + """ + Represents a verification result of a packet. + """ + __slots__ = ('_packet_found', '_packet_indexes', '_seek_indexes') + + def __init__(self): + self._packet_found = {} + self._packet_indexes = {} + self._seek_indexes = {} + + def record_last(self, name: str, pkts: PacketFilter) -> None: + """ + Record the information of the last found packet. + + :param name: The record name. + :param pkts: The packet filter. + """ + assert name not in self._packet_found, f'duplicate name: {name}' + self._packet_found[name] = pkts.last() + self._packet_indexes[name] = pkts.last_index + self._seek_indexes[name] = pkts.index + + def packet_index(self, name: str) -> int: + """ + Returns a recorded packet index. + + :param name: The record name. + :return: The packet index. + """ + return self._packet_indexes[name] + + def packet(self, name: str) -> Packet: + """ + Returns the recorded packet. + + :param name: The record name. + :return: The packet. + """ + return self._packet_found[name] + + def seek_index(self, name: str) -> tuple: + """ + Returns the recorded seek index. + + :param name: The record name. + :return: The seek index. + """ + return self._seek_indexes[name] + + def __str__(self): + return "VerifyResult%s" % self._packet_found diff --git a/tests/scripts/thread-cert/requirements.txt b/tests/scripts/thread-cert/requirements.txt index 1d8ca8c95..584eae7c7 100644 --- a/tests/scripts/thread-cert/requirements.txt +++ b/tests/scripts/thread-cert/requirements.txt @@ -1,3 +1,4 @@ ipaddress pexpect pycryptodome +pyshark==0.4.2.11 diff --git a/tests/scripts/thread-cert/simulator.py b/tests/scripts/thread-cert/simulator.py index 002946cf6..632028c8f 100644 --- a/tests/scripts/thread-cert/simulator.py +++ b/tests/scripts/thread-cert/simulator.py @@ -112,7 +112,13 @@ class RealTime(BaseSimulator): time.sleep(duration) def stop(self): - pass + if self.is_running: + # self._sniffer.stop() # FIXME: seems it blocks forever + self._sniffer = None + + @property + def is_running(self): + return self._sniffer is not None class VirtualTime(BaseSimulator): @@ -170,8 +176,13 @@ class VirtualTime(BaseSimulator): self.stop() def stop(self): - self.sock.close() - self.sock = None + if self.sock: + self.sock.close() + self.sock = None + + @property + def is_running(self): + return self.sock is not None def _add_message(self, nodeid, message_obj): addr = ('127.0.0.1', self.port + nodeid) diff --git a/tests/scripts/thread-cert/thread_cert.py b/tests/scripts/thread-cert/thread_cert.py index 1f73e9693..4c64de299 100644 --- a/tests/scripts/thread-cert/thread_cert.py +++ b/tests/scripts/thread-cert/thread_cert.py @@ -27,14 +27,23 @@ # POSSIBILITY OF SUCH DAMAGE. # +import json import os +import subprocess import sys +import time import unittest import config import debug from node import Node +PACKET_VERIFICATION = int(os.getenv('PACKET_VERIFICATION', 0)) + +if PACKET_VERIFICATION: + from pktverify.addrs import ExtAddr + from pktverify.packet_verifier import PacketVerifier + PORT_OFFSET = int(os.getenv('PORT_OFFSET', "0")) DEFAULT_PARAMS = { @@ -73,6 +82,11 @@ class TestCase(NcpSupportMixin, unittest.TestCase): TOPOLOGY = None + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._start_time = None + self._do_packet_verification = PACKET_VERIFICATION and hasattr(self, 'verify') + def setUp(self): """Create simulator, nodes and apply configurations. """ @@ -81,18 +95,21 @@ class TestCase(NcpSupportMixin, unittest.TestCase): self.simulator = config.create_default_simulator() self.nodes = {} - initial_topology = {} + self._initial_topology = initial_topology = {} + for i, params in self.TOPOLOGY.items(): if params: params = dict(DEFAULT_PARAMS, **params) else: params = DEFAULT_PARAMS.copy() + initial_topology[i] = params self.nodes[i] = Node( i, params['is_mtd'], simulator=self.simulator, + name=params.get('name'), version=params['version'], is_bbr=params['is_bbr'], ) @@ -159,6 +176,7 @@ class TestCase(NcpSupportMixin, unittest.TestCase): self.nodes[i].enable_whitelist() self._inspector = debug.Inspector(self) + self._collect_test_info_after_setup() def inspect(self): self._inspector.inspect() @@ -166,13 +184,25 @@ class TestCase(NcpSupportMixin, unittest.TestCase): def tearDown(self): """Destroy nodes and simulator. """ + if self._do_packet_verification and os.uname().sysname != "Linux": + raise NotImplementedError( + f'{self.testcase_name}: Packet Verification not available on {os.uname().sysname} (Linux only).') + + if self._do_packet_verification: + time.sleep(3) + for node in list(self.nodes.values()): node.stop() node.destroy() self.simulator.stop() - del self.nodes - del self.simulator + + if self._do_packet_verification: + self._test_info['pcap'] = self._get_pcap_filename() + + test_info_path = self._output_test_info() + os.environ['LD_LIBRARY_PATH'] = '/tmp/thread-wireshark' + self._verify_packets(test_info_path) def flush_all(self): """Flush away all captured messages of all nodes. @@ -196,3 +226,88 @@ class TestCase(NcpSupportMixin, unittest.TestCase): Clean up node files in tmp directory """ os.system(f"rm -f tmp/{PORT_OFFSET}_*.flash tmp/{PORT_OFFSET}_*.data tmp/{PORT_OFFSET}_*.swap") + + def _verify_packets(self, test_info_path: str): + pv = PacketVerifier(test_info_path) + pv.add_common_vars() + self.verify(pv) + print("Packet verification passed: %s" % test_info_path, file=sys.stderr) + + @property + def testcase_name(self): + return os.path.splitext(os.path.basename(sys.argv[0]))[0] + + def collect_ipaddrs(self): + if not self._do_packet_verification: + return + + test_info = self._test_info + + for i, node in self.nodes.items(): + ipaddrs = node.get_addrs() + test_info['ipaddrs'][i] = ipaddrs + mleid = node.get_mleid() + test_info['mleids'][i] = mleid + + def collect_rloc16s(self): + if not self._do_packet_verification: + return + + test_info = self._test_info + test_info['rloc16s'] = {} + + for i, node in self.nodes.items(): + test_info['rloc16s'][i] = '0x%04x' % node.get_addr16() + + def collect_extra_vars(self, **vars): + if not self._do_packet_verification: + return + + for k in vars.keys(): + assert isinstance(k, str), k + + test_vars = self._test_info.setdefault("extra_vars", {}) + test_vars.update(vars) + + def _collect_test_info_after_setup(self): + """ + Collect test info after setUp + """ + if not self._do_packet_verification: + return + + test_info = self._test_info = { + 'testcase': self.testcase_name, + 'start_time': time.ctime(self._start_time), + 'pcap': '', + 'extaddrs': {}, + 'ethaddrs': {}, + 'ipaddrs': {}, + 'mleids': {}, + 'topology': self._initial_topology, + } + + for i, node in self.nodes.items(): + extaddr = node.get_addr64() + test_info['extaddrs'][i] = ExtAddr(extaddr).format_octets() + + def _output_test_info(self): + """ + Output test info to json file after tearDown + """ + filename = f'{self.testcase_name}.json' + with open(filename, 'wt') as ofd: + ofd.write(json.dumps(self._test_info, indent=1, sort_keys=True)) + + return filename + + def _get_pcap_filename(self): + current_pcap = os.getenv('TEST_NAME', 'current') + '.pcap' + return os.path.abspath(current_pcap) + + def assure_run_ok(self, cmd, shell=False): + if not shell and isinstance(cmd, str): + cmd = cmd.split() + proc = subprocess.run(cmd, stdout=sys.stdout, stderr=sys.stderr, shell=shell) + print(">>> %s => %d" % (cmd, proc.returncode), file=sys.stderr) + proc.check_returncode()