Commit 3c32ad224f for bind

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

    Test AsyncDnsServer with malformed input and misbehaving clients

    Guard the fixes for invalid queries (fd0290c919), TCP clients that
    disconnect or reset the connection before sending a whole message
    (e4c3186a7c, 748ed4259b), several queries on one TCP connection
    (575a874582) and ignored TCP connections outliving a garbage collection
    (1acde358ea).  The ans2 server ignores every TCP connection and collects
    garbage a second after accepting one.

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

diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/ans1/ans.py b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/ans.py
new file mode 120000
index 0000000000..0855b69d50
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/ans.py
@@ -0,0 +1 @@
+../../../../../ans.py
\ No newline at end of file
diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/ans1/zones/example.db b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/zones/example.db
new file mode 100644
index 0000000000..9eb19da167
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/zones/example.db
@@ -0,0 +1,4 @@
+example.       300 IN SOA   ns.example. hostmaster.example. 1 3600 600 86400 300
+example.       300 IN NS    ns.example.
+ns.example.    300 IN A     10.53.0.1
+bar.example.   300 IN A     192.0.2.2
diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/ans2/ans.py b/bin/tests/system/isctest/asyncserver/tests/transport/ans2/ans.py
new file mode 100644
index 0000000000..92e539283c
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/ans2/ans.py
@@ -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.
+
+import asyncio
+import gc
+
+from isctest.asyncserver import AsyncDnsServer
+from isctest.asyncserver.context import Peer
+from isctest.asyncserver.handlers import IgnoreAllConnections
+
+
+class IgnoreAllConnectionsThenCollectGarbage(IgnoreAllConnections):
+    """
+    Ignore every TCP connection and collect garbage a second after accepting
+    it: unless the objects behind an ignored connection are kept alive on
+    purpose, the collection destroys the task handling it.
+    """
+
+    async def handle(
+        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, peer: Peer
+    ) -> None:
+        await super().handle(reader, writer, peer)
+        asyncio.get_running_loop().call_later(1, gc.collect)
+
+
+def main() -> None:
+    server = AsyncDnsServer()
+    server.install_connection_handler(IgnoreAllConnectionsThenCollectGarbage())
+    server.run()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/tests_transport.py b/bin/tests/system/isctest/asyncserver/tests/transport/tests_transport.py
new file mode 100644
index 0000000000..8a098aaaa6
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/tests_transport.py
@@ -0,0 +1,112 @@
+# 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 os
+import socket
+import struct
+import time
+
+import dns.exception
+import dns.message
+import dns.query
+import pytest
+
+from isctest.template import ANS1, ANS2, Nameserver
+
+import isctest
+
+TIMEOUT = 3
+GARBAGE = b"\x00\x03\x00\x01\x02"  # three bytes of nonsense, framed for TCP
+
+
+def port() -> int:
+    return int(os.environ["PORT"])
+
+
+def query() -> dns.message.Message:
+    return isctest.query.create("bar.example.", "A", dnssec=False, rd=False)
+
+
+def tcp_exchange(
+    server: Nameserver,
+    *items: dns.message.Message | bytes,
+    reset: bool = False,
+    wait: float = TIMEOUT,
+) -> tuple[list[dns.message.Message], bool]:
+    """
+    Send the items over a new TCP connection, messages framed and bytes as
+    they are, and return the responses that arrive within `wait` seconds,
+    plus whether the server closed the connection.  With `reset`, tear the
+    connection down with an RST segment instead of a FIN.
+    """
+    with socket.create_connection((server.ip, port()), timeout=TIMEOUT) as sock:
+        if reset:
+            sock.setsockopt(
+                socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)
+            )
+        for item in items:
+            if isinstance(item, bytes):
+                sock.sendall(item)
+            else:
+                dns.query.send_tcp(sock, item)
+        sock.setblocking(False)  # as dns.query.receive_tcp() expects
+        responses = []
+        try:
+            while True:
+                responses.append(dns.query.receive_tcp(sock, time.time() + wait)[0])
+        except dns.exception.Timeout:
+            return responses, False
+        except EOFError:
+            return responses, True
+
+
+def check_still_answering() -> None:
+    for transport in (isctest.query.udp, isctest.query.tcp):
+        res = transport(query(), ANS1.ip, timeout=TIMEOUT, attempts=2)
+        isctest.check.noerror(res)
+        isctest.check.rr_count_eq(res.answer, 1)
+
+
+def test_server_survives_invalid_udp_query():
+    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+        sock.sendto(GARBAGE, (ANS1.ip, port()))
+    check_still_answering()
+
+
+def test_invalid_tcp_query_is_dropped_but_the_connection_kept():
+    msg = query()
+    responses, closed = tcp_exchange(ANS1, GARBAGE, msg)
+    assert [res.id for res in responses] == [msg.id]
+    assert not closed
+    check_still_answering()
+
+
+@pytest.mark.parametrize("reset", [False, True], ids=["fin", "rst"])
+@pytest.mark.parametrize(
+    "partial",
+    [b"", b"\x00", query().to_wire(prepend_length=True)[:-3]],
+    ids=["nothing", "length", "message"],
+)
+def test_server_survives_early_disconnect(partial, reset):
+    tcp_exchange(ANS1, partial, reset=reset, wait=0)
+    check_still_answering()
+
+
+def test_pipelined_tcp_queries_are_all_answered():
+    msgs = [query() for _ in range(3)]
+    responses, _ = tcp_exchange(ANS1, *msgs)
+    assert [res.id for res in responses] == [msg.id for msg in msgs]
+
+
+def test_ignored_tcp_connection_survives_garbage_collection():
+    responses, closed = tcp_exchange(ANS2, query())
+    assert not responses
+    assert not closed