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 collections.abc import Iterable
     13 
     14 from dns.name import Name
     15 
     16 import dns.name
     17 import dns.rdatatype
     18 import dns.zone
     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.zone = zone
     63         assert self.zone.origin  # mypy hack
     64         # based on individual nodes but not relationship between nodes
     65         self.delegations = self.get_names_with_type(dns.rdatatype.NS) - {
     66             self.zone.origin
     67         }
     68         self.dnames = self.get_names_with_type(dns.rdatatype.DNAME)
     69         self.wildcards = get_wildcard_names(self.zone)
     70 
     71         # takes relationship between nodes into account
     72         self._categorize_names()
     73         self.ents = self.generate_ents()
     74         self.reachable_dnames = self.dnames.intersection(self.reachable)
     75         self.reachable_wildcards = self.wildcards.intersection(self.reachable)
     76         self.reachable_wildcard_parents = {
     77             Name(wname[1:]) for wname in self.reachable_wildcards
     78         }
     79 
     80         # (except for wildcard expansions) all names in zone which result in NOERROR answers
     81         self.all_existing_names = (
     82             self.reachable.union(self.ents)
     83             .union(self.reachable_delegations)
     84             .union(self.reachable_dnames)
     85         )
     86 
     87     def get_names_with_type(self, rdtype) -> frozenset[Name]:
     88         return frozenset(
     89             name for name in self.zone if self.zone.get_rdataset(name, rdtype)
     90         )
     91 
     92     def _categorize_names(
     93         self,
     94     ) -> None:
     95         """
     96         Split names defined in a zone into three sets:
     97         Generally reachable, reachable delegations, and occluded.
     98 
     99         Delegations are set aside because they are a weird hybrid with different
    100         rules for different RR types (NS, DS, NSEC, everything else).
    101         """
    102         assert self.zone.origin  # mypy workaround
    103         reachable = set(self.zone)
    104         # assume everything is reachable until proven otherwise
    105         reachable_delegations = set(self.delegations)
    106         occluded = set()
    107 
    108         def _mark_occluded(name: Name) -> None:
    109             occluded.add(name)
    110             if name in reachable:
    111                 reachable.remove(name)
    112             if name in reachable_delegations:
    113                 reachable_delegations.remove(name)
    114 
    115         # sanity check, should be impossible with dnspython 2.7.0 zone reader
    116         for name in reachable:
    117             relation, _, _ = name.fullcompare(self.zone.origin)
    118             if relation in (
    119                 dns.name.NameRelation.NONE,  # out of zone?
    120                 dns.name.NameRelation.SUPERDOMAIN,  # parent of apex?!
    121             ):
    122                 raise NotImplementedError
    123 
    124         for maybe_occluded in reachable.copy():
    125             for cut in self.delegations:
    126                 rel, _, _ = maybe_occluded.fullcompare(cut)
    127                 if rel == dns.name.NameRelation.EQUAL:
    128                     # data _on_ a parent-side of a zone cut are in limbo:
    129                     # - are not really authoritative (except for DS)
    130                     # - but NS is not really 'occluded'
    131                     # We remove them from 'reachable' but do not add them to 'occluded' set,
    132                     # i.e. leave them in 'reachable_delegations'.
    133                     if maybe_occluded in reachable:
    134                         reachable.remove(maybe_occluded)
    135 
    136                 if rel == dns.name.NameRelation.SUBDOMAIN:
    137                     _mark_occluded(maybe_occluded)
    138                 # do not break cycle - handle also nested NS and DNAME
    139 
    140             # DNAME itself is authoritative but nothing under it is reachable
    141             for dname in self.dnames:
    142                 rel, _, _ = maybe_occluded.fullcompare(dname)
    143                 if rel == dns.name.NameRelation.SUBDOMAIN:
    144                     _mark_occluded(maybe_occluded)
    145                 # do not break cycle - handle also nested NS and DNAME
    146 
    147         self.reachable = frozenset(reachable)
    148         self.reachable_delegations = frozenset(reachable_delegations)
    149         self.occluded = frozenset(occluded)
    150 
    151     def generate_ents(self) -> frozenset[Name]:
    152         """
    153         Generate reachable names of empty nodes "between" all reachable
    154         names with a RR and the origin.
    155         """
    156         assert self.zone.origin
    157         all_reachable_names = self.reachable.union(self.reachable_delegations)
    158         ents = set()
    159         for name in all_reachable_names:
    160             _, super_name = name.split(len(name) - 1)
    161             while len(super_name) > len(self.zone.origin):
    162                 if super_name not in all_reachable_names:
    163                     ents.add(super_name)
    164                 _, super_name = super_name.split(len(super_name) - 1)
    165 
    166         return frozenset(ents)
    167 
    168     def closest_encloser(self, qname: Name):
    169         """
    170         Get (closest encloser, next closer name) for given qname.
    171         """
    172         ce = None  # Closest encloser, RFC 4592
    173         nce = None  # Next closer name, RFC 5155
    174         for zname in self.all_existing_names:
    175             relation, _, common_labels = qname.fullcompare(zname)
    176             if relation == dns.name.NameRelation.SUBDOMAIN:
    177                 if not ce or common_labels > len(ce):
    178                     # longest match so far
    179                     ce = zname
    180                     _, nce = qname.split(len(ce) + 1)
    181         assert ce is not None
    182         assert nce is not None
    183         return ce, nce
    184 
    185     def source_of_synthesis(self, qname: Name) -> Name:
    186         """
    187         Return source of synthesis according to RFC 4592 section 3.3.1.
    188         Name is not guaranteed to exist or be reachable.
    189         """
    190         ce, _ = self.closest_encloser(qname)
    191         return Name("*") + ce
    192