]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Separate zone analyzer from NSEC3 test
authorPetr Špaček <pspacek@isc.org>
Wed, 21 May 2025 14:40:01 +0000 (16:40 +0200)
committerPetr Špaček <pspacek@isc.org>
Tue, 29 Jul 2025 08:00:37 +0000 (10:00 +0200)
Code to generate ENTs, detect wildcards, occlusion etc. is generic
enough to be in an utility module.

bin/tests/system/dnssec/tests_nsec3.py
bin/tests/system/isctest/__init__.py
bin/tests/system/isctest/name.py

index 83e63a7059ed0b7c4319874308f2f608959ae3d5..b06a3772fa5ecac50839eb84e87e49fe77563a82 100755 (executable)
@@ -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
index c3e65d8866a75a9311f995e6011e729d7df5a564..fb04a2a1e85841136663e122a352cd9ed4993238 100644 (file)
@@ -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
index 867380a8f24fde79b9495799dcf29ff153c20439..75c22720f680d7dd72d043fe5d7293271ee57f8f 100644 (file)
@@ -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