Commit 1e67e0833b for bind

commit 1e67e0833bad3b3d53eb8e56232cdc0452a5a694
Author: Nicki Křížek <nicki@isc.org>
Date:   Tue Aug 25 13:15:28 2026 +0000

    Improve server-less zone handling in isctest.zone.Zone

    The NO_NS expressed "this zone has no nameserver" as Nameserver(".", 0,
    "", ""), relying on "." doubling as the current directory and as a magic
    value for templates to compare against. The empty ip/ip6 strings would
    render silently into a zone file if a template ever dereferenced them.

    Instead, make the nameserver optional for Zone. This ensures consistent
    and robust handling across all templates and use-cases.

    Assisted-by: Claude:claude-fable-5

diff --git a/bin/tests/system/_common/zones.conf.j2 b/bin/tests/system/_common/zones.conf.j2
index edfe0713fc..5b5df244cf 100644
--- a/bin/tests/system/_common/zones.conf.j2
+++ b/bin/tests/system/_common/zones.conf.j2
@@ -1,6 +1,6 @@
 {% if zones is defined and zones %}
 {% for zone in zones.values() %}
-{% if zone.ns.name == ns.name %}
+{% if zone.ns and zone.ns.name == ns.name %}
 zone "@zone.name@" {
 	type @zone.type@;
 {% if zone.type == "static-stub" %}
diff --git a/bin/tests/system/_common/zones/ns.partial.db.j2 b/bin/tests/system/_common/zones/ns.partial.db.j2
index 5c0198b036..30144ccdc9 100644
--- a/bin/tests/system/_common/zones/ns.partial.db.j2
+++ b/bin/tests/system/_common/zones/ns.partial.db.j2
@@ -1,4 +1,4 @@
-{% if ns.name != "." %}
+{% if zone.ns %}
 @zone.name@.			NS	@zone.ns.name@.@zone.name@.
 @zone.ns.name@.@zone.name@.			A	@zone.ns.ip@
 {% else %}
diff --git a/bin/tests/system/_common/zones/soa.partial.db.j2 b/bin/tests/system/_common/zones/soa.partial.db.j2
index 7a084c4b0d..acf97ec9d1 100644
--- a/bin/tests/system/_common/zones/soa.partial.db.j2
+++ b/bin/tests/system/_common/zones/soa.partial.db.j2
@@ -1,11 +1,11 @@
-{% if not ns.name.startswith("ans") %}
+{% if not (ns and ns.name.startswith("ans")) %}
 $ORIGIN @zone.name@.
 {% endif %}
 $TTL 300
-{% if zone.ns.name == "." %}
-{% raw %}@{% endraw %}			IN SOA	@zone.name@. . (
-{% else %}
+{% if zone.ns %}
 {% raw %}@{% endraw %}			IN SOA	@zone.ns.name@.@zone.name@. . (
+{% else %}
+{% raw %}@{% endraw %}			IN SOA	@zone.name@. . (
 {% endif %}
 				1		; serial
 				20		; refresh (20 seconds)
diff --git a/bin/tests/system/isctest/template.py b/bin/tests/system/isctest/template.py
index 522887c0af..75661b88bc 100644
--- a/bin/tests/system/isctest/template.py
+++ b/bin/tests/system/isctest/template.py
@@ -200,7 +200,6 @@ NS8 = Nameserver("ns8")
 NS9 = Nameserver("ns9")
 NS10 = Nameserver("ns10")
 NS11 = Nameserver("ns11")
-NO_NS = Nameserver(".", 0, "", "")

 ANS1 = Nameserver("ans1")
 ANS2 = Nameserver("ans2")
@@ -219,7 +218,7 @@ ANS11 = Nameserver("ans11")
 class Zone:

     name: str
-    ns: Nameserver
+    ns: Nameserver | None = None
     type: str = "primary"
     filepath: Path | None = field(default=None)

diff --git a/bin/tests/system/isctest/zone.py b/bin/tests/system/isctest/zone.py
index b92f0d2591..4060279767 100644
--- a/bin/tests/system/isctest/zone.py
+++ b/bin/tests/system/isctest/zone.py
@@ -221,7 +221,7 @@ class FileZoneKey(ZoneKey):
         Zone.copy_dssets enforces this.
         """
         assert self.zone is not None, "write_dsset requires a zone-attached key"
-        src = Path(self.zone.ns.name) / f"dsset-{self.zone.name}."
+        src = self.zone.directory / f"dsset-{self.zone.name}."
         dst = Path(target_dir) / src.name
         if src.resolve() == dst.resolve():
             debug(f"{self.zone.name}: dsset already in {target_dir}")
@@ -238,19 +238,19 @@ class FileZoneKey(ZoneKey):
         """
         Generate a DNSSEC key via dnssec-keygen for zone and return it.

-        Runs dnssec-keygen in zone.ns.name/keys/, stores the key there, and
+        Runs dnssec-keygen in zone.directory/keys/, stores the key there, and
         returns the resulting FileZoneKey.  Pass params="-f KSK" to generate a
         Key Signing Key; omit it (or pass "") for a Zone Signing Key.
         """
         debug(f"{zone.name}: generating key using dnssec-keygen")
-        keydir = Path(zone.ns.name) / KEYDIR
+        keydir = zone.directory / KEYDIR
         keydir.mkdir(exist_ok=True)
         if alg is None:
             alg = Algorithm.default()
         keygen = EnvCmd(
             "KEYGEN", f"-q -a {alg.number} -b {alg.bits} -K {KEYDIR} -L {DNSKEY_TTL}"
         )
-        key_name = keygen(f"{params} {zone.name}", cwd=zone.ns.name).out.strip()
+        key_name = keygen(f"{params} {zone.name}", cwd=zone.directory).out.strip()
         return FileZoneKey(key_name, keydir=keydir, zone=zone)


@@ -401,7 +401,7 @@ class Zone:
     def __init__(
         self,
         name: str | dns.name.Name,
-        ns: Nameserver,
+        ns: Nameserver | None = None,
         signed: bool = False,
         subdir: str | None = "zones",
         filepath_unsigned: Path | str | None = None,
@@ -438,6 +438,27 @@ class Zone:
         """
         return self.filepath_signed if self.signed else self.filepath_unsigned

+    @property
+    def directory(self) -> Path:
+        """
+        Directory the zone's files are rooted in, relative to the test directory.
+        """
+        return Path(self.ns.name) if self.ns else Path(".")
+
+    @property
+    def path_unsigned(self) -> Path:
+        """
+        Path of the unsigned zone file, relative to the test directory.
+        """
+        return self.directory / self.filepath_unsigned
+
+    @property
+    def path_signed(self) -> Path:
+        """
+        Path of the signed zone file, relative to the test directory.
+        """
+        return self.directory / self.filepath_signed
+
     def add_keys(self, ksk: bool = True, zsk: bool = True) -> None:
         """
         Generate KSK and/or ZSK via dnssec-keygen and append to self.keys.
@@ -449,7 +470,7 @@ class Zone:

     def copy_dssets(self) -> None:
         """
-        Write dsset-* files for each signed delegation into self.ns dir.
+        Write dsset-* files for each signed delegation into self.directory.
         """
         for zone in self.delegations:
             ksks = [k for k in zone.keys if k.is_ksk()]
@@ -463,7 +484,7 @@ class Zone:
                 )
             if ksks:
                 for key in ksks:
-                    key.write_dsset(Path(self.ns.name))
+                    key.write_dsset(self.directory)
             else:
                 debug(f"{zone.name}: delegation is insecure (no KSK)")

@@ -473,7 +494,7 @@ class Zone:
         """
         debug(f"{self.name}: rendering zone file")
         templates = TemplateEngine(".")
-        output = Path(self.ns.name) / self.filepath_unsigned
+        output = self.path_unsigned
         output.parent.mkdir(exist_ok=True)

         if template is None:
@@ -508,7 +529,7 @@ class Zone:
         signer(
             f"-P -x -O full -o {self.name}"
             f" -f {self.filepath_signed} {self.filepath_unsigned}",
-            cwd=self.ns.name,
+            cwd=self.directory,
         )

     def configure(
diff --git a/bin/tests/system/verify/tests_verify.py b/bin/tests/system/verify/tests_verify.py
index ca82f81f68..4ea2bee643 100644
--- a/bin/tests/system/verify/tests_verify.py
+++ b/bin/tests/system/verify/tests_verify.py
@@ -9,7 +9,6 @@
 # See the COPYRIGHT file distributed with this work for additional
 # information regarding copyright ownership.

-from pathlib import Path
 from re import compile as Re

 import os
@@ -28,7 +27,7 @@ import dns.rdatatype
 import dns.zone
 import pytest

-from isctest.template import NO_NS, zones
+from isctest.template import zones
 from isctest.zone import Zone

 import isctest
@@ -74,15 +73,13 @@ def bootstrap():
         return ksk_private_key, ksk_dnskey

     def gen_and_sign(name, nsec3=False):
-        zone = Zone(name, NO_NS, signed=True)
+        zone = Zone(name, signed=True)
         ksk_private_key, ksk_dnskey = generate_keys()
         keys = [(ksk_private_key, ksk_dnskey)]
         zone.render()

         # read the rendered zone
-        unsigned_path = str(Path(zone.ns.name) / zone.filepath_unsigned)
-        signed_path = str(Path(zone.ns.name) / zone.filepath_signed)
-        zoneobj = dns.zone.from_file(unsigned_path, origin=f"{name}.")
+        zoneobj = dns.zone.from_file(str(zone.path_unsigned), origin=f"{name}.")
         lifetime = 30 * 86400

         # sign the zone
@@ -134,7 +131,7 @@ def bootstrap():
             nsec3_rrsig.ttl = 300
             nsec3_rrsig.add(nsec3_sigdata)

-        zoneobj.to_file(signed_path)
+        zoneobj.to_file(str(zone.path_signed))

         return zone