diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index 83e63a7059..b06a3772fa 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -31,76 +31,15 @@ from hypothesis import assume, given from isctest.hypothesis.strategies import dns_names import isctest +import isctest.name SUFFIX = dns.name.from_text("nsec3.example.") AUTH = "10.53.0.3" RESOLVER = "10.53.0.4" TIMEOUT = 5 - - -def get_known_names_and_delegations(): - - # Read zone file - system_test_root = Path(os.environ["srcdir"]) - with open( - f"{system_test_root}/dnssec/ns3/nsec3.example.db.in", encoding="utf-8" - ) as zf: - content = dns.zone.from_file(zf, origin=SUFFIX, relativize=False) - all_names = set(content) - known_names = sorted(all_names) - - # Remove out of zone, obscured and glue names - for known_name in known_names: - relation, _, _ = known_name.fullcompare(SUFFIX) - if relation == dns.name.NameRelation.EQUAL: - continue - if relation in (dns.name.NameRelation.NONE, dns.name.NameRelation.SUPERDOMAIN): - known_names.remove(known_name) - continue - nsset = content.get_rdataset(known_name, rdtype=dns.rdatatype.NS) - dname = content.get_rdataset(known_name, rdtype=dns.rdatatype.DNAME) - if nsset is not None or dname is not None: - for glue in known_names: - relation, _, _ = glue.fullcompare(known_name) - if relation == dns.name.NameRelation.SUBDOMAIN: - known_names.remove(glue) - - # Add in possible ENT names - for known_name in known_names: - _, super_name = known_name.split(len(known_name.labels) - 1) - while len(super_name.labels) > len(SUFFIX.labels): - known_names.append(super_name) - _, super_name = super_name.split(len(super_name.labels) - 1) - known_names = set(known_names) - - # Build list of delegation points and DNAMES - delegations = [] - for known_name in known_names: - relation, _, _ = known_name.fullcompare(SUFFIX) - if relation == dns.name.NameRelation.EQUAL: - continue - nsset = content.get_rdataset(known_name, rdtype=dns.rdatatype.NS) - dname = content.get_rdataset(known_name, rdtype=dns.rdatatype.DNAME) - if nsset is not None or dname is not None: - delegations.append(known_name) - - # build list of WILDCARD named - wildcards = [] - for known_name in known_names: - if known_name.is_wild(): - wildcards.append(known_name) - return known_names, delegations, wildcards - - -KNOWN_NAMES, DELEGATIONS, WILDCARDS = get_known_names_and_delegations() - - -def is_delegated(name, delegations): - for delegation in delegations: - relation, _, _ = name.fullcompare(delegation) - if relation in (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN): - return True - return False +ZONE = isctest.name.ZoneAnalyzer.read_path( + Path(os.environ["builddir"]) / "dnssec/ns3/nsec3.example.db.in", origin=SUFFIX +) def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool: @@ -164,7 +103,7 @@ def test_dnssec_nsec3_nxdomain(server, name: dns.name.Name, named_port: int) -> @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] ) -@given(name=dns_names(suffix=KNOWN_NAMES)) +@given(name=dns_names(suffix=ZONE.reachable.union(ZONE.ents))) def test_dnssec_nsec3_subdomain_nxdomain( server, name: dns.name.Name, named_port: int ) -> None: @@ -173,10 +112,19 @@ def test_dnssec_nsec3_subdomain_nxdomain( def noqname_test(server, name: dns.name.Name, named_port: int) -> None: # Name must not exist. - assume(name not in KNOWN_NAMES) + all_existing_names = ( + ZONE.reachable.union(ZONE.ents).union(ZONE.delegations).union(ZONE.dnames) + ) + assume(name not in (all_existing_names)) # Name must not be below a delegation or DNAME. - assume(not is_delegated(name, DELEGATIONS)) + assume( + not isctest.name.is_related_to_any( + name, + (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN), + ZONE.reachable_delegations.union(ZONE.reachable_dnames), + ) + ) query = dns.message.make_query( name, dns.rdatatype.A, use_edns=True, want_dnssec=True @@ -208,7 +156,7 @@ def noqname_test(server, name: dns.name.Name, named_port: int) -> None: _, nce = name.split(ce_labels + 1) else: ce_labels = 0 - for zname in KNOWN_NAMES: + for zname in all_existing_names: relation, _, nlabels = name.fullcompare(zname) if relation == dns.name.NameRelation.SUBDOMAIN: if nlabels > ce_labels: @@ -241,7 +189,7 @@ def noqname_test(server, name: dns.name.Name, named_port: int) -> None: if response.rcode() is dns.rcode.NOERROR: # only NOERRORs should be from wildcards found_wc = False - for wildcard in WILDCARDS: + for wildcard in ZONE.reachable_wildcards: if wildcard == wc: found_wc = True assert found_wc diff --git a/bin/tests/system/isctest/__init__.py b/bin/tests/system/isctest/__init__.py index c3e65d8866..fb04a2a1e8 100644 --- a/bin/tests/system/isctest/__init__.py +++ b/bin/tests/system/isctest/__init__.py @@ -13,7 +13,6 @@ from . import check from . import instance from . import query from . import kasp -from . import name from . import rndc from . import run from . import template diff --git a/bin/tests/system/isctest/name.py b/bin/tests/system/isctest/name.py index 867380a8f2..75c22720f6 100644 --- a/bin/tests/system/isctest/name.py +++ b/bin/tests/system/isctest/name.py @@ -9,7 +9,14 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -from dns.name import Name +from typing import Container, Iterable, FrozenSet + +import pytest + +pytest.importorskip("dns", minversion="2.1.0") # NameRelation +from dns.name import Name, NameRelation +import dns.zone +import dns.rdatatype def prepend_label(label: str, name: Name) -> Name: @@ -18,3 +25,144 @@ def prepend_label(label: str, name: Name) -> Name: def len_wire_uncompressed(name: Name) -> int: return len(name) + sum(map(len, name.labels)) + + +def get_wildcard_names(names: Iterable[Name]) -> FrozenSet[Name]: + return frozenset(name for name in names if name.is_wild()) + + +class ZoneAnalyzer: + """ + Categorize names in zone and provide list of ENTs: + + - delegations - names with NS RR + - dnames - names with DNAME RR + - wildcards - names with leftmost label '*' + - reachable - non-empty authoritative nodes in zone + - have at least one auth RR set and are not occluded + - ents - reachable empty non-terminals + - occluded - names under a parent node which has DNAME or a non-apex NS + - reachable_delegations + - have NS RR on it, are not zone's apex, and are not occluded + - reachable_dnames - have DNAME RR on it and are not occluded + - reachable_wildcards - have leftmost label '*' and are not occluded + + Warnings: + - Quadratic complexity ahead! Use only on small test zones. + - Zone must be constant. + """ + + @classmethod + def read_path(cls, zpath, origin): + with open(zpath, encoding="ascii") as zf: + zonedb = dns.zone.from_file(zf, origin, relativize=False) + return cls(zonedb) + + def __init__(self, zone: dns.zone.Zone): + self.zone = zone + assert self.zone.origin # mypy hack + # based on individual nodes but not relationship between nodes + self.delegations = self.get_names_with_type(dns.rdatatype.NS) - { + self.zone.origin + } + self.dnames = self.get_names_with_type(dns.rdatatype.DNAME) + self.wildcards = get_wildcard_names(self.zone) + + # takes relationship between nodes into account + self._categorize_names() + self.ents = self.generate_ents() + self.reachable_dnames = self.dnames.intersection(self.reachable) + self.reachable_wildcards = self.wildcards.intersection(self.reachable) + + def get_names_with_type(self, rdtype) -> FrozenSet[Name]: + return frozenset( + name for name in self.zone if self.zone.get_rdataset(name, rdtype) + ) + + def _categorize_names( + self, + ) -> None: + """ + Split names defined in a zone into three sets: + Generally reachable, reachable delegations, and occluded. + + Delegations are set aside because they are a weird hybrid with different + rules for different RR types (NS, DS, NSEC, everything else). + """ + assert self.zone.origin # mypy workaround + reachable = set(self.zone) + # assume everything is reachable until proven otherwise + reachable_delegations = set(self.delegations) + occluded = set() + + def _mark_occluded(name: Name) -> None: + occluded.add(name) + if name in reachable: + reachable.remove(name) + if name in reachable_delegations: + reachable_delegations.remove(name) + + # sanity check, should be impossible with dnspython 2.7.0 zone reader + for name in reachable: + relation, _, _ = name.fullcompare(self.zone.origin) + if relation in ( + NameRelation.NONE, # out of zone? + NameRelation.SUPERDOMAIN, # parent of apex?! + ): + raise NotImplementedError + + for maybe_occluded in reachable.copy(): + for cut in self.delegations: + rel, _, _ = maybe_occluded.fullcompare(cut) + if rel == NameRelation.EQUAL: + # data _on_ a parent-side of a zone cut are in limbo: + # - are not really authoritative (except for DS) + # - but NS is not really 'occluded' + # We remove them from 'reachable' but do not add them to 'occluded' set, + # i.e. leave them in 'reachable_delegations'. + if maybe_occluded in reachable: + reachable.remove(maybe_occluded) + + if rel == NameRelation.SUBDOMAIN: + _mark_occluded(maybe_occluded) + # do not break cycle - handle also nested NS and DNAME + + # DNAME itself is authoritative but nothing under it is reachable + for dname in self.dnames: + rel, _, _ = maybe_occluded.fullcompare(dname) + if rel == NameRelation.SUBDOMAIN: + _mark_occluded(maybe_occluded) + # do not break cycle - handle also nested NS and DNAME + + self.reachable = frozenset(reachable) + self.reachable_delegations = frozenset(reachable_delegations) + self.occluded = frozenset(occluded) + + def generate_ents(self) -> FrozenSet[Name]: + """ + Generate reachable names of empty nodes "between" all reachable + names with a RR and the origin. + """ + assert self.zone.origin + all_reachable_names = self.reachable.union(self.reachable_delegations) + ents = set() + for name in all_reachable_names: + _, super_name = name.split(len(name) - 1) + while len(super_name) > len(self.zone.origin): + if super_name not in all_reachable_names: + ents.add(super_name) + _, super_name = super_name.split(len(super_name) - 1) + + return frozenset(ents) + + +def is_related_to_any( + test_name: Name, + acceptable_relations: Container[NameRelation], + candidates: Iterable[Name], +) -> bool: + for maybe_parent in candidates: + relation, _, _ = test_name.fullcompare(maybe_parent) + if relation in acceptable_relations: + return True + return False