bind9/bin/tests/system/isctest/name.py
Petr Špaček 6ae224fc9c Fix minimal dnspython version test
Wrong version number was uncovered by Ubuntu 22.04 Jammy which actually
has dnspython 2.1.0.
2025-07-30 14:35:32 +00:00

204 lines
7.6 KiB
Python

# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
from typing import Container, Iterable, FrozenSet
import pytest
pytest.importorskip("dns", minversion="2.3.0") # NameRelation
from dns.name import Name, NameRelation
import dns.zone
import dns.rdatatype
def prepend_label(label: str, name: Name) -> Name:
return Name((label,) + name.labels)
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
- reachable_wildcard_parents - reachable_wildcards with leftmost '*' stripped
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)
self.reachable_wildcard_parents = {
Name(wname[1:]) for wname in self.reachable_wildcards
}
# (except for wildcard expansions) all names in zone which result in NOERROR answers
self.all_existing_names = (
self.reachable.union(self.ents)
.union(self.reachable_delegations)
.union(self.reachable_dnames)
)
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 closest_encloser(self, qname: Name):
"""
Get (closest encloser, next closer name) for given qname.
"""
ce = None # Closest encloser, RFC 4592
nce = None # Next closer name, RFC 5155
for zname in self.all_existing_names:
relation, _, common_labels = qname.fullcompare(zname)
if relation == NameRelation.SUBDOMAIN:
if not ce or common_labels > len(ce):
# longest match so far
ce = zname
_, nce = qname.split(len(ce) + 1)
assert ce is not None
assert nce is not None
return ce, nce
def source_of_synthesis(self, qname: Name) -> Name:
"""
Return source of synthesis according to RFC 4592 section 3.3.1.
Name is not guaranteed to exist or be reachable.
"""
ce, _ = self.closest_encloser(qname)
return Name("*") + ce
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