Commit 9bd3b8bdb0 for bind

commit 9bd3b8bdb0ec2a8318b9b30e366e1a949d86a5da
Author: Alessio Podda <alessio@isc.org>
Date:   Fri Oct 16 10:58:38 2026 +0200

    Support hypothesis in unit tests

    Wire up the unit test suite so that unit tests can be built with
    protobuf support and can use hypothesis.

    Also, add a Python helper to run a C server that communicates with
    Python via protobuf, and an example unit test using hypothesis.

diff --git a/tests/hypothesis/accumulator.proto b/tests/hypothesis/accumulator.proto
new file mode 100644
index 0000000000..b81f5e5a3b
--- /dev/null
+++ b/tests/hypothesis/accumulator.proto
@@ -0,0 +1,16 @@
+// Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+// SPDX-License-Identifier: MPL-2.0
+syntax = "proto2";
+package accumulator;
+
+message Request {
+  oneof command {
+    uint64 reset = 1;
+    uint32 add = 2;
+    uint32 subtract = 3;
+  }
+}
+
+message Response {
+  required uint64 value = 1;
+}
diff --git a/tests/hypothesis/accumulator.py b/tests/hypothesis/accumulator.py
new file mode 100644
index 0000000000..4d935e62d2
--- /dev/null
+++ b/tests/hypothesis/accumulator.py
@@ -0,0 +1,62 @@
+# 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.
+
+"""
+A minimal model test for a stateful uint64 accumulator.
+"""
+
+import os
+import sys
+
+from isc_hypothesis import RestartingProtobufServer
+
+try:
+    from hypothesis import example, given, settings
+    from hypothesis import strategies as st
+
+    import accumulator_pb2 as proto  # pylint: disable=import-error
+except ModuleNotFoundError as error:
+    print(f"skipping accumulator hypothesis tests: {error}", file=sys.stderr)
+    sys.exit(77)
+
+UINT64_MAX = 2**64 - 1
+server = None
+
+
+@settings(max_examples=300, deadline=None, derandomize=True)
+@given(
+    initial=st.integers(0, UINT64_MAX),
+    changes=st.lists(st.integers(-127, 127), max_size=100),
+)
+@example(initial=0, changes=[-1, 1])
+@example(initial=UINT64_MAX, changes=[1, -1])
+@example(initial=42, changes=[0, 10, -3, -7])
+def test_accumulator(initial, changes):
+    # Reset between examples, while retaining state between commands.
+    expected = initial
+    assert server.command(proto.Request(reset=initial)).value == expected
+    for change in changes:
+        request = (
+            proto.Request(add=change)
+            if change >= 0
+            else proto.Request(subtract=-change)
+        )
+        # Unsigned C arithmetic wraps modulo 2**64.
+        expected = (expected + change) & UINT64_MAX
+        assert server.command(request).value == expected
+
+
+if __name__ == "__main__":
+    with RestartingProtobufServer(
+        os.environ["ACCUMULATOR_SERVER"], proto.Response
+    ) as server:
+        test_accumulator()  # pylint: disable=no-value-for-parameter
+        print("test_accumulator: passed", flush=True)
diff --git a/tests/hypothesis/accumulator_server.c b/tests/hypothesis/accumulator_server.c
new file mode 100644
index 0000000000..020d97a73a
--- /dev/null
+++ b/tests/hypothesis/accumulator_server.c
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+#include <arpa/inet.h>
+#include <stdint.h>
+#include <stdio.h>
+
+#include <isc/util.h>
+
+#include "accumulator.pb-c.h"
+
+int
+main(void) {
+	uint64_t value = 0;
+	uint32_t wire_length;
+	unsigned char data[64];
+
+	while (true) {
+		size_t n = fread(&wire_length, 1, sizeof(wire_length), stdin);
+		if (n == 0 && feof(stdin)) {
+			break;
+		}
+		RUNTIME_CHECK(n == sizeof(wire_length));
+		size_t length = ntohl(wire_length);
+		RUNTIME_CHECK(length > 0 && length <= sizeof(data));
+		RUNTIME_CHECK(fread(data, 1, length, stdin) == length);
+		Accumulator__Request *request =
+			accumulator__request__unpack(NULL, length, data);
+		RUNTIME_CHECK(request != NULL);
+		switch (request->command_case) {
+		case ACCUMULATOR__REQUEST__COMMAND_RESET:
+			value = request->reset;
+			break;
+		case ACCUMULATOR__REQUEST__COMMAND_ADD:
+			value += request->add;
+			break;
+		case ACCUMULATOR__REQUEST__COMMAND_SUBTRACT:
+			value -= request->subtract;
+			break;
+		default:
+			UNREACHABLE();
+		}
+		accumulator__request__free_unpacked(request, NULL);
+
+		Accumulator__Response response = {
+			.base = PROTOBUF_C_MESSAGE_INIT(
+				&accumulator__response__descriptor),
+			.value = value,
+		};
+		length = accumulator__response__get_packed_size(&response);
+		RUNTIME_CHECK(length <= sizeof(data));
+		accumulator__response__pack(&response, data);
+		wire_length = htonl(length);
+		RUNTIME_CHECK(fwrite(&wire_length, 1, sizeof(wire_length),
+				     stdout) == sizeof(wire_length));
+		RUNTIME_CHECK(fwrite(data, 1, length, stdout) == length);
+		RUNTIME_CHECK(fflush(stdout) == 0);
+	}
+	return 0;
+}
diff --git a/tests/hypothesis/isc_hypothesis/__init__.py b/tests/hypothesis/isc_hypothesis/__init__.py
new file mode 100644
index 0000000000..2ef1609302
--- /dev/null
+++ b/tests/hypothesis/isc_hypothesis/__init__.py
@@ -0,0 +1,14 @@
+# 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.
+
+from .server import RestartingProtobufServer
+
+__all__ = ["RestartingProtobufServer"]
diff --git a/tests/hypothesis/isc_hypothesis/server.py b/tests/hypothesis/isc_hypothesis/server.py
new file mode 100644
index 0000000000..0c2089d572
--- /dev/null
+++ b/tests/hypothesis/isc_hypothesis/server.py
@@ -0,0 +1,142 @@
+# 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 contextlib
+import os
+import select
+import struct
+import subprocess
+
+TIMEOUT = 10
+MAX_RESPONSE_SIZE = 4 * 1024 * 1024
+LENGTH_PREFIX = struct.Struct("!I")
+
+
+class _ProtobufServer:
+    """
+    Drive a helper binary that reads length-prefixed protobuf requests on
+    stdin and writes length-prefixed protobuf responses on stdout.
+
+    The process starts on construction and must be torn down with
+    shutdown() or close().
+    """
+
+    def __init__(self, path, response_type):
+        self._path = path
+        self._response_type = response_type
+        # pylint: disable=consider-using-with
+        self._proc = subprocess.Popen(
+            [path],
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+        )
+
+    @property
+    def running(self):
+        return self._proc.poll() is None
+
+    def command(self, request):
+        """
+        Send one request and return the decoded response.
+        """
+        if not self.running:
+            self._fail("exited")
+
+        data = request.SerializeToString()
+        try:
+            self._proc.stdin.write(LENGTH_PREFIX.pack(len(data)) + data)
+            self._proc.stdin.flush()
+        except BrokenPipeError:
+            self._fail("broken pipe")
+
+        (length,) = LENGTH_PREFIX.unpack(self._read(LENGTH_PREFIX.size))
+        if length > MAX_RESPONSE_SIZE:
+            self._fail(f"response too large ({length} bytes)")
+        return self._response_type.FromString(self._read(length))
+
+    def shutdown(self):
+        """
+        Ask the server to exit and check that it did so cleanly.
+        """
+        try:
+            self._proc.stdin.close()
+            code = self._proc.wait(timeout=TIMEOUT)
+            if code != 0:
+                self._fail(f"exit status {code}")
+        finally:
+            self.close()
+
+    def close(self):
+        """
+        Tear down the process without checking its exit status.
+        """
+        if self.running:
+            self._proc.kill()
+            self._proc.wait()
+        # A failed write leaves the request buffered; closing flushes it.
+        with contextlib.suppress(BrokenPipeError):
+            self._proc.stdin.close()
+        self._proc.stdout.close()
+
+    def _fail(self, reason):
+        # Reap (or kill) the process, so that it is no longer running.
+        try:
+            self._proc.wait(timeout=TIMEOUT)
+        except subprocess.TimeoutExpired:
+            self._proc.kill()
+            self._proc.wait()
+        raise AssertionError(f"{self._path}: {reason}")
+
+    def _read(self, size):
+        # Bypass the buffered reader so that select() sees all pending data.
+        fd = self._proc.stdout.fileno()
+        data = bytearray()
+        while len(data) < size:
+            ready, _, _ = select.select([fd], [], [], TIMEOUT)
+            if not ready:
+                self._proc.kill()
+                self._fail("timed out")
+            chunk = os.read(fd, size - len(data))
+            if not chunk:
+                self._fail("EOF")
+            data.extend(chunk)
+        return bytes(data)
+
+
+class RestartingProtobufServer:
+    """
+    A _ProtobufServer that is replaced with a fresh one whenever it dies, so
+    that a single crash fails only the example that caused it.
+    """
+
+    def __init__(self, path, response_type):
+        self._args = (path, response_type)
+        self._server = None
+
+    def __enter__(self):
+        self._server = _ProtobufServer(*self._args)
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        _ = exc_value, traceback  # Silence vulture spurious warnings
+        if exc_type is None:
+            self._server.shutdown()
+        else:
+            self._server.close()
+
+    def command(self, request):
+        """
+        Send one request, restarting the server first if it died.
+        """
+        if not self._server.running:
+            # Already reaped; dropping it closes its pipes.
+            self._server = _ProtobufServer(*self._args)
+        return self._server.command(request)
diff --git a/tests/hypothesis/meson.build b/tests/hypothesis/meson.build
new file mode 100644
index 0000000000..d37982152e
--- /dev/null
+++ b/tests/hypothesis/meson.build
@@ -0,0 +1,57 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+# SPDX-License-Identifier: MPL-2.0
+
+# Keep these dependencies independent of dnstap and cmocka.
+hypothesis_protoc = find_program('protoc', required: false)
+hypothesis_protoc_c = find_program('protoc-c', required: false)
+hypothesis_proto_dep = dependency('libprotobuf-c', required: false)
+if not python.found() or not hypothesis_protoc.found() or not hypothesis_protoc_c.found() or not hypothesis_proto_dep.found()
+    subdir_done()
+endif
+
+accumulator_proto_c = custom_target(
+    'accumulator-protobuf-c',
+    input: 'accumulator.proto',
+    output: ['accumulator.pb-c.c', 'accumulator.pb-c.h'],
+    command: [
+        hypothesis_protoc_c,
+        '--proto_path=@CURRENT_SOURCE_DIR@',
+        '--c_out=@OUTDIR@',
+        '@INPUT@',
+    ],
+)
+accumulator_proto_py = custom_target(
+    'accumulator-protobuf-python',
+    input: 'accumulator.proto',
+    output: 'accumulator_pb2.py',
+    command: [
+        hypothesis_protoc,
+        '--proto_path=@CURRENT_SOURCE_DIR@',
+        '--python_out=@OUTDIR@',
+        '@INPUT@',
+    ],
+)
+accumulator_server = executable(
+    'accumulator_server',
+    'accumulator_server.c',
+    accumulator_proto_c,
+    default_sanitize_options,
+    include_directories: test_inc,
+    dependencies: [libisc_dep, hypothesis_proto_dep],
+    build_by_default: false,
+    install: false,
+)
+accumulator_env = environment(test_env_vars)
+accumulator_env.set('ACCUMULATOR_SERVER', accumulator_server.full_path())
+accumulator_env.set('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', 'python')
+accumulator_env.prepend('PYTHONPATH', meson.current_build_dir())
+test(
+    'accumulator-hypothesis',
+    python,
+    args: files('accumulator.py'),
+    depends: [accumulator_server, accumulator_proto_py],
+    env: accumulator_env,
+    suite: ['hypothesis'],
+    timeout: 300,
+    workdir: meson.current_build_dir(),
+)
diff --git a/tests/meson.build b/tests/meson.build
index 7b7cb9f9c8..01321b3188 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -48,6 +48,8 @@ libtest_qp_dep = declare_dependency(
     include_directories: test_inc,
 )

+subdir('hypothesis')
+
 if not cmocka_dep.found()
     subdir_done()
 endif