Commit 22cd58920a for bind

commit 22cd58920ac61cc974eaece4aef00e0b0977f222
Author: Štěpán Balážik <stepan@isc.org>
Date:   Wed Sep 16 18:40:34 2026 +0200

    Answer a CNAME loop with SERVFAIL

    A looping CNAME chain in zone data used to be followed until Python ran
    out of stack.  Stop as soon as the chain returns to a name it has been
    through and answer as named does when it hits max-query-restarts: the
    chain collected so far and SERVFAIL.

    Assisted-by: Claude:claude-fable-5-1

diff --git a/bin/tests/system/isctest/asyncserver/__init__.py b/bin/tests/system/isctest/asyncserver/__init__.py
index b954c717a5..219ab550d4 100644
--- a/bin/tests/system/isctest/asyncserver/__init__.py
+++ b/bin/tests/system/isctest/asyncserver/__init__.py
@@ -977,6 +977,12 @@ class AsyncDnsServer(_AsyncServer):
         if not cname:
             return False

+        if qctx.current_qname in qctx.aliases:
+            # CNAME loop
+            qctx.response.set_rcode(dns.rcode.SERVFAIL)
+            return True
+        qctx.aliases.add(qctx.current_qname)
+
         qctx.response.set_rcode(dns.rcode.NOERROR)
         cname_rrset = dns.rrset.RRset(qctx.current_qname, qctx.qclass, cname.rdtype)
         cname_rrset.update(cname)
diff --git a/bin/tests/system/isctest/asyncserver/context.py b/bin/tests/system/isctest/asyncserver/context.py
index 2910add2b4..df8daf4475 100644
--- a/bin/tests/system/isctest/asyncserver/context.py
+++ b/bin/tests/system/isctest/asyncserver/context.py
@@ -68,6 +68,7 @@ class QueryContext:
     node: dns.node.Node | None = field(default=None, init=False)
     answer: dns.rdataset.Rdataset | None = field(default=None, init=False)
     alias: dns.name.Name | None = field(default=None, init=False)
+    aliases: set[dns.name.Name] = field(default_factory=set, init=False)
     _initialized_response: dns.message.Message | None = field(default=None, init=False)
     _initialized_response_with_zone_data: dns.message.Message | None = field(
         default=None, init=False
diff --git a/bin/tests/system/isctest/asyncserver/tests/zone_data/ans1/zones/example.db b/bin/tests/system/isctest/asyncserver/tests/zone_data/ans1/zones/example.db
index 9256f78d37..7650367735 100644
--- a/bin/tests/system/isctest/asyncserver/tests/zone_data/ans1/zones/example.db
+++ b/bin/tests/system/isctest/asyncserver/tests/zone_data/ans1/zones/example.db
@@ -3,3 +3,5 @@ example.       300 IN NS    ns.example.
 ns.example.    300 IN A     10.53.0.1
 foo.example.   300 IN CNAME bar.example.
 bar.example.   300 IN A     192.0.2.2
+loop1.example. 300 IN CNAME loop2.example.
+loop2.example. 300 IN CNAME loop1.example.
diff --git a/bin/tests/system/isctest/asyncserver/tests/zone_data/tests_zone_data.py b/bin/tests/system/isctest/asyncserver/tests/zone_data/tests_zone_data.py
index 3f0ea842ea..9aed0714e3 100644
--- a/bin/tests/system/isctest/asyncserver/tests/zone_data/tests_zone_data.py
+++ b/bin/tests/system/isctest/asyncserver/tests/zone_data/tests_zone_data.py
@@ -30,3 +30,13 @@ def test_cname_chain_is_followed():
         dns.rdatatype.A,
     ]
     assert res.answer[-1][0].to_text() == "192.0.2.2"
+
+
+def test_looping_cname_chain_is_cut_with_servfail():
+    res = query("loop1.example.", "A")
+    isctest.check.servfail(res)
+    assert [rrset.name.to_text() for rrset in res.answer] == [
+        "loop1.example.",
+        "loop2.example.",
+    ]
+    assert all(rrset.rdtype == dns.rdatatype.CNAME for rrset in res.answer)