]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add reproducer for #5759
authorAlessio Podda <alessio@isc.org>
Mon, 23 Feb 2026 10:04:47 +0000 (11:04 +0100)
committerAlessio Podda <alessio@isc.org>
Tue, 24 Feb 2026 12:04:19 +0000 (13:04 +0100)
Adds a test case that runs IXFR while leaving an rdataset unchanged.

bin/tests/system/ixfr-nonminimal/ans2/ans.py [new file with mode: 0644]
bin/tests/system/ixfr-nonminimal/ans4/ans.py [new file with mode: 0644]
bin/tests/system/ixfr-nonminimal/ns1/named.conf.j2 [new file with mode: 0644]
bin/tests/system/ixfr-nonminimal/ns3/named.conf.j2 [new file with mode: 0644]
bin/tests/system/ixfr-nonminimal/prereq.sh [new file with mode: 0644]
bin/tests/system/ixfr-nonminimal/setup.sh [new file with mode: 0644]
bin/tests/system/ixfr-nonminimal/tests.sh [new file with mode: 0644]
bin/tests/system/ixfr-nonminimal/tests_sh_ixfr_nonminimal.py [new file with mode: 0644]

diff --git a/bin/tests/system/ixfr-nonminimal/ans2/ans.py b/bin/tests/system/ixfr-nonminimal/ans2/ans.py
new file mode 100644 (file)
index 0000000..207b5e4
--- /dev/null
@@ -0,0 +1,200 @@
+"""
+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.
+"""
+
+import abc
+
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.rrset
+
+from typing import AsyncGenerator, Collection, Iterable
+
+from isctest.asyncserver import (
+    ControllableAsyncDnsServer,
+    DnsResponseSend,
+    QueryContext,
+    ResponseHandler,
+    SwitchControlCommand,
+)
+
+
+def rrset(owner: str, rdtype: dns.rdatatype.RdataType, rdata: str) -> dns.rrset.RRset:
+    return dns.rrset.from_text(
+        owner,
+        300,
+        dns.rdataclass.IN,
+        rdtype,
+        rdata,
+    )
+
+
+def soa(serial: int, *, owner: str = "nil.") -> dns.rrset.RRset:
+    return rrset(
+        owner,
+        dns.rdatatype.SOA,
+        f"ns.nil. root.nil. {serial} 300 300 604800 300",
+    )
+
+
+def ns() -> dns.rrset.RRset:
+    return rrset(
+        "nil.",
+        dns.rdatatype.NS,
+        "ns.nil.",
+    )
+
+
+def a(address: str, *, owner: str) -> dns.rrset.RRset:
+    return rrset(
+        owner,
+        dns.rdatatype.A,
+        address,
+    )
+
+
+def txt(data: str, *, owner: str = "nil.") -> dns.rrset.RRset:
+    return rrset(
+        owner,
+        dns.rdatatype.TXT,
+        f'"{data}"',
+    )
+
+
+class SoaHandler(ResponseHandler):
+    def __init__(self, serial: int):
+        self._serial = serial
+
+    def match(self, qctx: QueryContext) -> bool:
+        return qctx.qtype == dns.rdatatype.SOA
+
+    async def get_responses(
+        self, qctx: QueryContext
+    ) -> AsyncGenerator[DnsResponseSend, None]:
+        qctx.response.answer.append(soa(self._serial))
+        yield DnsResponseSend(qctx.response)
+
+
+class AxfrHandler(ResponseHandler):
+    @property
+    @abc.abstractmethod
+    def answers(self) -> Iterable[Collection[dns.rrset.RRset]]:
+        """
+        Answer sections of response packets sent in response to
+        AXFR queries.
+        """
+        raise NotImplementedError
+
+    def match(self, qctx: QueryContext) -> bool:
+        return qctx.qtype == dns.rdatatype.AXFR
+
+    async def get_responses(
+        self, qctx: QueryContext
+    ) -> AsyncGenerator[DnsResponseSend, None]:
+        for answer in self.answers:
+            response = qctx.prepare_new_response()
+            for rrset_ in answer:
+                response.answer.append(rrset_)
+            yield DnsResponseSend(response)
+
+
+class IxfrHandler(ResponseHandler):
+    @property
+    @abc.abstractmethod
+    def answer(self) -> Collection[dns.rrset.RRset]:
+        """
+        Answer section of a response packet sent in response to
+        IXFR queries.
+        """
+        raise NotImplementedError
+
+    def match(self, qctx: QueryContext) -> bool:
+        return qctx.qtype == dns.rdatatype.IXFR
+
+    async def get_responses(
+        self, qctx: QueryContext
+    ) -> AsyncGenerator[DnsResponseSend, None]:
+        for rrset_ in self.answer:
+            qctx.response.answer.append(rrset_)
+        yield DnsResponseSend(qctx.response)
+
+
+class InitialAxfrHandler(AxfrHandler):
+    answers = (
+        (soa(1),),
+        (
+            ns(),
+            txt("initial AXFR"),
+            a("10.0.0.61", owner="a.nil."),
+            a("10.0.0.62", owner="b.nil."),
+        ),
+        (soa(1),),
+    )
+
+
+class InitialIxfrHandler(IxfrHandler):
+    answer = (
+        soa(1),
+        ns(),
+        txt("initial AXFR"),
+        a("10.0.0.61", owner="a.nil."),
+        a("10.0.0.62", owner="b.nil."),
+        soa(1),
+    )
+
+
+class UnchangedIxfrHandler(IxfrHandler):
+    """
+    IXFR from serial 1 -> 2.
+
+    The diff deletes nothing for the A rrset at a.nil., but re-adds
+    "a.nil. A 10.0.0.61" which already exists. This causes the merge
+    to find no new records, triggering DNS_R_UNCHANGED.
+
+    We also add a new TXT record so the IXFR has at least one real
+    change (the SOA serial bump + TXT addition).
+    """
+
+    answer = (
+        soa(2),
+        soa(1),
+        soa(2),
+        txt("unchanged ixfr test"),
+        a("10.0.0.61", owner="a.nil."),  # already exists -> DNS_R_UNCHANGED
+        soa(2),
+    )
+
+
+def main() -> None:
+    server = ControllableAsyncDnsServer(
+        default_aa=True, default_rcode=dns.rcode.NOERROR
+    )
+    switch_command = SwitchControlCommand(
+        {
+            "initial_axfr": (
+                SoaHandler(1),
+                InitialIxfrHandler(),
+                InitialAxfrHandler(),
+            ),
+            "unchanged_ixfr": (
+                SoaHandler(2),
+                UnchangedIxfrHandler(),
+            ),
+        }
+    )
+    server.install_control_command(switch_command)
+    server.run()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/bin/tests/system/ixfr-nonminimal/ans4/ans.py b/bin/tests/system/ixfr-nonminimal/ans4/ans.py
new file mode 100644 (file)
index 0000000..769600e
--- /dev/null
@@ -0,0 +1,199 @@
+"""
+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.
+"""
+
+import abc
+
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.rrset
+
+from typing import AsyncGenerator, Collection, Iterable
+
+from isctest.asyncserver import (
+    ControllableAsyncDnsServer,
+    DnsResponseSend,
+    QueryContext,
+    ResponseHandler,
+    SwitchControlCommand,
+)
+
+
+def rrset(owner: str, rdtype: dns.rdatatype.RdataType, rdata: str) -> dns.rrset.RRset:
+    return dns.rrset.from_text(
+        owner,
+        300,
+        dns.rdataclass.IN,
+        rdtype,
+        rdata,
+    )
+
+
+def soa(serial: int, *, owner: str = "nil.") -> dns.rrset.RRset:
+    return rrset(
+        owner,
+        dns.rdatatype.SOA,
+        f"ns.nil. root.nil. {serial} 300 300 604800 300",
+    )
+
+
+def ns() -> dns.rrset.RRset:
+    return rrset(
+        "nil.",
+        dns.rdatatype.NS,
+        "ns.nil.",
+    )
+
+
+def a(address: str, *, owner: str) -> dns.rrset.RRset:
+    return rrset(
+        owner,
+        dns.rdatatype.A,
+        address,
+    )
+
+
+def txt(data: str, *, owner: str = "nil.") -> dns.rrset.RRset:
+    return rrset(
+        owner,
+        dns.rdatatype.TXT,
+        f'"{data}"',
+    )
+
+
+class SoaHandler(ResponseHandler):
+    def __init__(self, serial: int):
+        self._serial = serial
+
+    def match(self, qctx: QueryContext) -> bool:
+        return qctx.qtype == dns.rdatatype.SOA
+
+    async def get_responses(
+        self, qctx: QueryContext
+    ) -> AsyncGenerator[DnsResponseSend, None]:
+        qctx.response.answer.append(soa(self._serial))
+        yield DnsResponseSend(qctx.response)
+
+
+class AxfrHandler(ResponseHandler):
+    @property
+    @abc.abstractmethod
+    def answers(self) -> Iterable[Collection[dns.rrset.RRset]]:
+        """
+        Answer sections of response packets sent in response to
+        AXFR queries.
+        """
+        raise NotImplementedError
+
+    def match(self, qctx: QueryContext) -> bool:
+        return qctx.qtype == dns.rdatatype.AXFR
+
+    async def get_responses(
+        self, qctx: QueryContext
+    ) -> AsyncGenerator[DnsResponseSend, None]:
+        for answer in self.answers:
+            response = qctx.prepare_new_response()
+            for rrset_ in answer:
+                response.answer.append(rrset_)
+            yield DnsResponseSend(response)
+
+
+class IxfrHandler(ResponseHandler):
+    @property
+    @abc.abstractmethod
+    def answer(self) -> Collection[dns.rrset.RRset]:
+        """
+        Answer section of a response packet sent in response to
+        IXFR queries.
+        """
+        raise NotImplementedError
+
+    def match(self, qctx: QueryContext) -> bool:
+        return qctx.qtype == dns.rdatatype.IXFR
+
+    async def get_responses(
+        self, qctx: QueryContext
+    ) -> AsyncGenerator[DnsResponseSend, None]:
+        for rrset_ in self.answer:
+            qctx.response.answer.append(rrset_)
+        yield DnsResponseSend(qctx.response)
+
+
+class InitialAxfrHandler(AxfrHandler):
+    answers = (
+        (soa(1),),
+        (
+            ns(),
+            txt("initial AXFR"),
+            a("10.0.0.62", owner="b.nil."),
+        ),
+        (soa(1),),
+    )
+
+
+class InitialIxfrHandler(IxfrHandler):
+    answer = (
+        soa(1),
+        ns(),
+        txt("initial AXFR"),
+        a("10.0.0.62", owner="b.nil."),
+        soa(1),
+    )
+
+
+class NxrrsetIxfrHandler(IxfrHandler):
+    """
+    IXFR from serial 1 -> 2.
+
+    Deletes the only A record at b.nil. (10.0.0.62).  Since this is
+    the last record in that rdataset, subtractrdataset() returns
+    DNS_R_NXRRSET.
+
+    Also adds a TXT record so the zone has a real change beyond the
+    SOA serial bump.
+    """
+
+    answer = (
+        soa(2),
+        soa(1),
+        a("10.0.0.62", owner="b.nil."),
+        txt("initial AXFR"),
+        soa(2),
+        txt("nxrrset ixfr test"),
+        soa(2),
+    )
+
+
+def main() -> None:
+    server = ControllableAsyncDnsServer(
+        default_aa=True, default_rcode=dns.rcode.NOERROR
+    )
+    switch_command = SwitchControlCommand(
+        {
+            "initial_axfr": (
+                SoaHandler(1),
+                InitialIxfrHandler(),
+                InitialAxfrHandler(),
+            ),
+            "nxrrset_ixfr": (
+                SoaHandler(2),
+                NxrrsetIxfrHandler(),
+            ),
+        }
+    )
+    server.install_control_command(switch_command)
+    server.run()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/bin/tests/system/ixfr-nonminimal/ns1/named.conf.j2 b/bin/tests/system/ixfr-nonminimal/ns1/named.conf.j2
new file mode 100644 (file)
index 0000000..7618147
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+options {
+       query-source address 10.53.0.1;
+       notify-source 10.53.0.1;
+       transfer-source 10.53.0.1;
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.1; };
+       listen-on-v6 { none; };
+       allow-transfer { any; };
+       recursion no;
+       notify yes;
+       dnssec-validation no;
+};
+
+key rndc_key {
+       secret "1234abcd8765";
+       algorithm @DEFAULT_HMAC@;
+};
+
+controls {
+       inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
+};
+
+zone "nil" {
+       type secondary;
+       file "nil.db";
+       primaries { 10.53.0.2; };
+};
diff --git a/bin/tests/system/ixfr-nonminimal/ns3/named.conf.j2 b/bin/tests/system/ixfr-nonminimal/ns3/named.conf.j2
new file mode 100644 (file)
index 0000000..500cc81
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+options {
+       query-source address 10.53.0.3;
+       notify-source 10.53.0.3;
+       transfer-source 10.53.0.3;
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.3; };
+       listen-on-v6 { none; };
+       allow-transfer { any; };
+       recursion no;
+       notify yes;
+       dnssec-validation no;
+};
+
+key rndc_key {
+       secret "1234abcd8765";
+       algorithm @DEFAULT_HMAC@;
+};
+
+controls {
+       inet 10.53.0.3 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
+};
+
+zone "nil" {
+       type secondary;
+       file "nil.db";
+       primaries { 10.53.0.4; };
+};
diff --git a/bin/tests/system/ixfr-nonminimal/prereq.sh b/bin/tests/system/ixfr-nonminimal/prereq.sh
new file mode 100644 (file)
index 0000000..4190753
--- /dev/null
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+# 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.
+
+. ../conf.sh
+
+exit 0
diff --git a/bin/tests/system/ixfr-nonminimal/setup.sh b/bin/tests/system/ixfr-nonminimal/setup.sh
new file mode 100644 (file)
index 0000000..fd733ef
--- /dev/null
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+# 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.
+
+. ../conf.sh
diff --git a/bin/tests/system/ixfr-nonminimal/tests.sh b/bin/tests/system/ixfr-nonminimal/tests.sh
new file mode 100644 (file)
index 0000000..bd8d77a
--- /dev/null
@@ -0,0 +1,86 @@
+#!/bin/sh
+
+# 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.
+
+set -e
+
+. ../conf.sh
+
+wait_for_serial() (
+  $DIG $DIGOPTS "@$1" "$2" SOA >"$4"
+  serial=$(awk '$4 == "SOA" { print $7 }' "$4")
+  [ "$3" -eq "${serial:--1}" ]
+)
+
+status=0
+n=0
+
+DIGOPTS="+tcp +noadd +nosea +nostat +noquest +nocomm +nocmd -p ${PORT}"
+RNDCCMD="$RNDC -p ${CONTROLPORT} -c ../_common/rndc.conf -s"
+
+switch_responses() {
+  $DIG $DIGOPTS "@$1" "${2}.switch._control." TXT +time=5 +tries=1 +tcp >/dev/null 2>&1
+}
+
+# Set up initial_axfr handlers and trigger transfers
+switch_responses 10.53.0.2 "initial_axfr"
+switch_responses 10.53.0.4 "initial_axfr"
+$RNDCCMD 10.53.0.1 refresh nil | sed 's/^/ns1 /' | cat_i
+$RNDCCMD 10.53.0.3 refresh nil | sed 's/^/ns3 /' | cat_i
+
+# Wait for initial AXFRs to complete
+retry_quiet 10 wait_for_serial 10.53.0.1 nil. 1 dig.out.ns1.axfr || {
+  echo_i "ns1 initial AXFR failed"
+  exit 1
+}
+retry_quiet 10 wait_for_serial 10.53.0.3 nil. 1 dig.out.ns3.axfr || {
+  echo_i "ns3 initial AXFR failed"
+  exit 1
+}
+
+# Test 1: IXFR that re-adds an existing record -> DNS_R_UNCHANGED
+n=$((n + 1))
+echo_i "testing IXFR with unchanged rdataset ($n)"
+ret=0
+
+switch_responses 10.53.0.2 "unchanged_ixfr"
+sleep 1
+
+$RNDCCMD 10.53.0.1 refresh nil | sed 's/^/ns1 /' | cat_i
+sleep 2
+
+$DIG $DIGOPTS @10.53.0.1 nil. TXT | grep 'unchanged ixfr test' >/dev/null || ret=1
+$DIG $DIGOPTS @10.53.0.1 a.nil. A | grep '10.0.0.61' >/dev/null || ret=1
+grep "dns_diff_apply: update with no effect" ns1/named.run >/dev/null || ret=1
+
+if [ $ret != 0 ]; then echo_i "failed"; fi
+status=$((status + ret))
+
+# Test 2: IXFR that deletes last record in rdataset -> DNS_R_NXRRSET
+n=$((n + 1))
+echo_i "testing IXFR with nxrrset ($n)"
+ret=0
+
+switch_responses 10.53.0.4 "nxrrset_ixfr"
+sleep 1
+
+$RNDCCMD 10.53.0.3 refresh nil | sed 's/^/ns3 /' | cat_i
+sleep 2
+
+$DIG $DIGOPTS @10.53.0.3 nil. TXT | grep 'nxrrset ixfr test' >/dev/null || ret=1
+$DIG $DIGOPTS @10.53.0.3 b.nil. A | grep '10.0.0.62' >/dev/null && ret=1
+
+if [ $ret != 0 ]; then echo_i "failed"; fi
+status=$((status + ret))
+
+echo_i "exit status: $status"
+[ $status -eq 0 ] || exit 1
diff --git a/bin/tests/system/ixfr-nonminimal/tests_sh_ixfr_nonminimal.py b/bin/tests/system/ixfr-nonminimal/tests_sh_ixfr_nonminimal.py
new file mode 100644 (file)
index 0000000..a4b7495
--- /dev/null
@@ -0,0 +1,27 @@
+# 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.
+
+import pytest
+
+pytestmark = pytest.mark.extra_artifacts(
+    [
+        "dig.out*",
+        "ans*/ans.run",
+        "ns1/nil.db",
+        "ns1/*.jnl",
+        "ns3/nil.db",
+        "ns3/*.jnl",
+    ]
+)
+
+
+def test_my_ixfr_nonminimal(run_tests_sh):
+    run_tests_sh()