Commit 4d3c475fb9 for bind

commit 4d3c475fb9e00a6441482d915b0f0ecf20424b00
Author: Štěpán Balážik <stepan@isc.org>
Date:   Wed Sep 16 17:31:18 2026 +0200

    Refuse two zone files defining the same zone

    Only the root zone may name its own origin, so only the root zone could
    be defined twice in one server directory, and which file won depended
    on the order the filesystem listed them in.  Zones and keys now load
    once the server is up, so that the refusal shows in its log.

    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 219ab550d4..ab5c8b68c9 100644
--- a/bin/tests/system/isctest/asyncserver/__init__.py
+++ b/bin/tests/system/isctest/asyncserver/__init__.py
@@ -106,6 +106,7 @@ class _AsyncServer:

     def __init__(
         self,
+        init_handler: Callable[[], None] | None,
         udp_handler: _UdpHandler | None,
         tcp_handler: _TcpHandler | None,
         pidfile: str | None = None,
@@ -132,6 +133,7 @@ class _AsyncServer:

         self._ip_addresses: tuple[str, str] = (ipv4_address, ipv6_address)
         self._port: int = port
+        self._init_handler: Callable[[], None] | None = init_handler
         self._udp_handler: _UdpHandler | None = udp_handler
         self._tcp_handler: _TcpHandler | None = tcp_handler
         self._pidfile: str | None = pidfile
@@ -158,6 +160,8 @@ class _AsyncServer:
         await self._listen_udp()
         await self._listen_tcp()
         self._write_pidfile()
+        if self._init_handler:
+            self._init_handler()
         await self._work_done
         self._cleanup_pidfile()

@@ -318,6 +322,8 @@ class _ZoneTree:
         Add a zone to the tree and rearrange sub-zones if necessary.
         """
         best_match = self._find_best_match(origin, self._root)
+        if best_match.zone is not None and best_match.zone.origin == origin:
+            raise ValueError(f'zone "{origin}" is defined by more than one zone file')
         added_node = _ZoneTreeNode(zone)
         self._move_children(best_match, added_node)
         best_match.children.append(added_node)
@@ -407,7 +413,9 @@ class AsyncDnsServer(_AsyncServer):
         keyring: dict[dns.name.Name, dns.tsig.Key] | Literal[False] | None = None,
         acknowledge_manual_dname_handling: bool = False,
     ) -> None:
-        super().__init__(self._handle_udp, self._handle_tcp, "ans.pid")
+        super().__init__(
+            self._handle_init, self._handle_udp, self._handle_tcp, "ans.pid"
+        )

         self._zone_tree: _ZoneTree = _ZoneTree()
         self._zones: dict[dns.name.Name, dns.zone.Zone] = {}
@@ -421,9 +429,6 @@ class AsyncDnsServer(_AsyncServer):
         self._keyring = keyring
         self._acknowledge_manual_dname_handling = acknowledge_manual_dname_handling

-        self._load_zones()
-        self._load_keys()
-
     def install_response_handler(
         self, handler: ResponseHandler, prepend: bool = False
     ) -> None:
@@ -471,6 +476,10 @@ class AsyncDnsServer(_AsyncServer):
             raise RuntimeError("Only one connection handler can be installed")
         self._connection_handler = handler

+    def _handle_init(self) -> None:
+        self._load_zones()
+        self._load_keys()
+
     def _scan_directory(self, directory: str) -> Iterator[os.DirEntry]:
         directory_path = pathlib.Path(directory)
         if directory_path.exists():
diff --git a/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/ans.py b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/ans.py
new file mode 120000
index 0000000000..0855b69d50
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/ans.py
@@ -0,0 +1 @@
+../../../../../ans.py
\ No newline at end of file
diff --git a/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/zones/one.db b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/zones/one.db
new file mode 100644
index 0000000000..b17aae8b89
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/zones/one.db
@@ -0,0 +1,4 @@
+$ORIGIN .
+$TTL 300
+@ SOA ns.example. hostmaster.example. 1 2 3 4 5
+@ NS ns.example.
diff --git a/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/zones/two.db b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/zones/two.db
new file mode 100644
index 0000000000..b17aae8b89
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/ans1/zones/two.db
@@ -0,0 +1,4 @@
+$ORIGIN .
+$TTL 300
+@ SOA ns.example. hostmaster.example. 1 2 3 4 5
+@ NS ns.example.
diff --git a/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/tests_duplicate_root_zone.py b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/tests_duplicate_root_zone.py
new file mode 100644
index 0000000000..304245b021
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/duplicate_root_zone/tests_duplicate_root_zone.py
@@ -0,0 +1,17 @@
+# 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.
+
+
+def test_two_zone_files_with_the_same_origin_are_refused(ans1):
+    with ans1.watch_log_from_start() as watcher:
+        watcher.wait_for_line('zone "." is defined by more than one zone file')
+    # The server died as intended; keep stop.pl from reporting it at teardown.
+    (ans1.directory / "ans.pid").unlink()