Home | History | Annotate | Line # | Download | only in isctest
      1 # Copyright (C) Internet Systems Consortium, Inc. ("ISC")
      2 #
      3 # SPDX-License-Identifier: MPL-2.0
      4 #
      5 # This Source Code Form is subject to the terms of the Mozilla Public
      6 # License, v. 2.0.  If a copy of the MPL was not distributed with this
      7 # file, you can obtain one at https://mozilla.org/MPL/2.0/.
      8 #
      9 # See the COPYRIGHT file distributed with this work for additional
     10 # information regarding copyright ownership.
     11 
     12 from typing import Iterable, FrozenSet
     13 
     14 import dns.name
     15 import dns.zone
     16 import dns.rdatatype
     17 
     18 from dns.name import Name
     19 
     20 
     21 def prepend_label(label: str, name: Name) -> Name:
     22     return Name((label,) + name.labels)
     23 
     24 
     25 def len_wire_uncompressed(name: Name) -> int:
     26     return len(name) + sum(map(len, name.labels))
     27 
     28 
     29 def get_wildcard_names(names: Iterable[Name]) -> FrozenSet[Name]:
     30     return frozenset(name for name in names if name.is_wild())
     31 
     32 
     33 class ZoneAnalyzer:
     34     """
     35     Categorize names in zone and provide list of ENTs:
     36 
     37     - delegations - names with NS RR
     38     - dnames - names with DNAME RR
     39     - wildcards - names with leftmost label '*'
     40     - reachable - non-empty authoritative nodes in zone
     41       - have at least one auth RR set and are not occluded
     42     - ents - reachable empty non-terminals
     43     - occluded - names under a parent node which has DNAME or a non-apex NS
     44     - reachable_delegations
     45       - have NS RR on it, are not zone's apex, and are not occluded
     46     - reachable_dnames - have DNAME RR on it and are not occluded
     47     - reachable_wildcards - have leftmost label '*' and are not occluded
     48     - reachable_wildcard_parents - reachable_wildcards with leftmost '*' stripped
     49 
     50     Warnings:
     51     - Quadratic complexity ahead! Use only on small test zones.
     52     - Zone must be constant.
     53     """
     54 
     55     @classmethod
     56     def read_path(cls, zpath, origin):
     57         with open(zpath, encoding="ascii") as zf:
     58             zonedb = dns.zone.from_file(zf, origin, relativize=False)
     59         return cls(zonedb)
     60 
     61     def __init__(self, zone: dns.zone.Zone):
     62         self._abort_on_old_dnspython()
     63         self.zone = zone
     64         assert self.zone.origin  # mypy hack
     65         # based on individual nodes but not relationship between nodes
     66         self.delegations = self.get_names_with_type(dns.rdatatype.NS) - {
     67             self.zone.origin
     68         }
     69         self.dnames = self.get_names_with_type(dns.rdatatype.DNAME)
     70         self.wildcards = get_wildcard_names(self.zone)
     71 
     72         # takes relationship between nodes into account
     73         self._categorize_names()
     74         self.ents = self.generate_ents()
     75         self.reachable_dnames = self.dnames.intersection(self.reachable)
     76         self.reachable_wildcards = self.wildcards.intersection(self.reachable)
     77         self.reachable_wildcard_parents = {
     78             Name(wname[1:]) for wname in self.reachable_wildcards
     79         }
     80 
     81         # (except for wildcard expansions) all names in zone which result in NOERROR answers
     82         self.all_existing_names = (
     83             self.reachable.union(self.ents)
     84             .union(self.reachable_delegations)
     85             .union(self.reachable_dnames)
     86         )
     87 
     88     def _abort_on_old_dnspython(self):
     89         if not hasattr(dns.name, "NameRelation"):
     90             raise RuntimeError(
     91                 "ZoneAnalyzer requires dnspython>=2.3.0 for dns.name.NameRelation support. "
     92                 "Use pytest.importorskip('dns', minversion='2.3.0') to the test module to "
     93                 "skip this test."
     94             )
     95 
     96     def get_names_with_type(self, rdtype) -> FrozenSet[Name]:
     97         return frozenset(
     98             name for name in self.zone if self.zone.get_rdataset(name, rdtype)
     99         )
    100 
    101     def _categorize_names(
    102         self,
    103     ) -> None:
    104         """
    105         Split names defined in a zone into three sets:
    106         Generally reachable, reachable delegations, and occluded.
    107 
    108         Delegations are set aside because they are a weird hybrid with different
    109         rules for different RR types (NS, DS, NSEC, everything else).
    110         """
    111         assert self.zone.origin  # mypy workaround
    112         reachable = set(self.zone)
    113         # assume everything is reachable until proven otherwise
    114         reachable_delegations = set(self.delegations)
    115         occluded = set()
    116 
    117         def _mark_occluded(name: Name) -> None:
    118             occluded.add(name)
    119             if name in reachable:
    120                 reachable.remove(name)
    121             if name in reachable_delegations:
    122                 reachable_delegations.remove(name)
    123 
    124         # sanity check, should be impossible with dnspython 2.7.0 zone reader
    125         for name in reachable:
    126             relation, _, _ = name.fullcompare(self.zone.origin)
    127             if relation in (
    128                 dns.name.NameRelation.NONE,  # out of zone?
    129                 dns.name.NameRelation.SUPERDOMAIN,  # parent of apex?!
    130             ):
    131                 raise NotImplementedError
    132 
    133         for maybe_occluded in reachable.copy():
    134             for cut in self.delegations:
    135                 rel, _, _ = maybe_occluded.fullcompare(cut)
    136                 if rel == dns.name.NameRelation.EQUAL:
    137                     # data _on_ a parent-side of a zone cut are in limbo:
    138                     # - are not really authoritative (except for DS)
    139                     # - but NS is not really 'occluded'
    140                     # We remove them from 'reachable' but do not add them to 'occluded' set,
    141                     # i.e. leave them in 'reachable_delegations'.
    142                     if maybe_occluded in reachable:
    143                         reachable.remove(maybe_occluded)
    144 
    145                 if rel == dns.name.NameRelation.SUBDOMAIN:
    146                     _mark_occluded(maybe_occluded)
    147                 # do not break cycle - handle also nested NS and DNAME
    148 
    149             # DNAME itself is authoritative but nothing under it is reachable
    150             for dname in self.dnames:
    151                 rel, _, _ = maybe_occluded.fullcompare(dname)
    152                 if rel == dns.name.NameRelation.SUBDOMAIN:
    153                     _mark_occluded(maybe_occluded)
    154                 # do not break cycle - handle also nested NS and DNAME
    155 
    156         self.reachable = frozenset(reachable)
    157         self.reachable_delegations = frozenset(reachable_delegations)
    158         self.occluded = frozenset(occluded)
    159 
    160     def generate_ents(self) -> FrozenSet[Name]:
    161         """
    162         Generate reachable names of empty nodes "between" all reachable
    163         names with a RR and the origin.
    164         """
    165         assert self.zone.origin
    166         all_reachable_names = self.reachable.union(self.reachable_delegations)
    167         ents = set()
    168         for name in all_reachable_names:
    169             _, super_name = name.split(len(name) - 1)
    170             while len(super_name) > len(self.zone.origin):
    171                 if super_name not in all_reachable_names:
    172                     ents.add(super_name)
    173                 _, super_name = super_name.split(len(super_name) - 1)
    174 
    175         return frozenset(ents)
    176 
    177     def closest_encloser(self, qname: Name):
    178         """
    179         Get (closest encloser, next closer name) for given qname.
    180         """
    181         ce = None  # Closest encloser, RFC 4592
    182         nce = None  # Next closer name, RFC 5155
    183         for zname in self.all_existing_names:
    184             relation, _, common_labels = qname.fullcompare(zname)
    185             if relation == dns.name.NameRelation.SUBDOMAIN:
    186                 if not ce or common_labels > len(ce):
    187                     # longest match so far
    188                     ce = zname
    189                     _, nce = qname.split(len(ce) + 1)
    190         assert ce is not None
    191         assert nce is not None
    192         return ce, nce
    193 
    194     def source_of_synthesis(self, qname: Name) -> Name:
    195         """
    196         Return source of synthesis according to RFC 4592 section 3.3.1.
    197         Name is not guaranteed to exist or be reachable.
    198         """
    199         ce, _ = self.closest_encloser(qname)
    200         return Name("*") + ce
    201