]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Reimplement the FORMERR system test in Python
authorŠtěpán Balážik <stepan@isc.org>
Wed, 29 Apr 2026 07:52:23 +0000 (09:52 +0200)
committerŠtěpán Balážik <stepan@isc.org>
Wed, 17 Jun 2026 18:08:48 +0000 (20:08 +0200)
Replace the shell and Perl based FORMERR system test with a Python
test that constructs the malformed DNS packets directly and checks
the responses.

Remove the legacy shell and Perl test script and the intermediate
packet files in hex, leaving the packet construction inline in
tests_formerr.py.

Preserve the same wire for all packets sent to the server, but
construct them in a more explicit and readable way.

23 files changed:
bin/tests/system/formerr/badnsec3owner [deleted file]
bin/tests/system/formerr/badrecordname [deleted file]
bin/tests/system/formerr/dupans [deleted file]
bin/tests/system/formerr/dupquestion [deleted file]
bin/tests/system/formerr/formerr.pl [deleted file]
bin/tests/system/formerr/keyclass [deleted file]
bin/tests/system/formerr/malformeddeltype [deleted file]
bin/tests/system/formerr/malformedrrsig [deleted file]
bin/tests/system/formerr/nametoolong [deleted file]
bin/tests/system/formerr/noquestions [deleted file]
bin/tests/system/formerr/optwrongname [deleted file]
bin/tests/system/formerr/qtypeasanswer [deleted file]
bin/tests/system/formerr/questionclass [deleted file]
bin/tests/system/formerr/shortquestion [deleted file]
bin/tests/system/formerr/shortrecord [deleted file]
bin/tests/system/formerr/tests.sh [deleted file]
bin/tests/system/formerr/tests_formerr.py [new file with mode: 0644]
bin/tests/system/formerr/tests_sh_formerr.py [deleted file]
bin/tests/system/formerr/tsignotlast [deleted file]
bin/tests/system/formerr/tsigwrongclass [deleted file]
bin/tests/system/formerr/twoquestionnames [deleted file]
bin/tests/system/formerr/twoquestiontypes [deleted file]
bin/tests/system/formerr/wrongclass [deleted file]

diff --git a/bin/tests/system/formerr/badnsec3owner b/bin/tests/system/formerr/badnsec3owner
deleted file mode 100644 (file)
index de6e692..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-# bad NSEC3 owner (:) not in base32hex valid characters
-# header: id=0008 opcode=query questions=1 authority=1
-00 08 00 00 00 01 00 00 00 01 00 00
-# question: ./A/IN
-00 00 01 00 01
-# :./NSEC3/IN/1 length=7 hashtype=240 flags=0 interations=0 salt=- hashlen=1 hash=ff
-01 58 00 00 32 00 01 00 00 00 01 00 07 f0 00 00 00 00 01 ff
diff --git a/bin/tests/system/formerr/badrecordname b/bin/tests/system/formerr/badrecordname
deleted file mode 100644 (file)
index f79df49..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-# oversized owner name
-# header: additional=1
-00 00 00 00 00 00 00 00 00 00 00 01
-# owner name too big (256 octets) A/IN/1 0.0.0.0
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-00 01 00 01 00 00 00 01 00 04 00 00 00 00
diff --git a/bin/tests/system/formerr/dupans b/bin/tests/system/formerr/dupans
deleted file mode 100644 (file)
index 142022a..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# multiple singletons (SOA)
-# header questions=1 answers=2
-00 00 00 00 00 01 00 02 00 00 00 00
-# question SOA/IN
-00 00 06 00 01
-# 2 SOA records that differ in expire
-00 00 06 00 01 00 00 00 01 00 16 00 00 00 00 00 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 05
-00 00 06 00 01 00 00 00 01 00 16 00 00 00 00 00 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 06
diff --git a/bin/tests/system/formerr/dupquestion b/bin/tests/system/formerr/dupquestion
deleted file mode 100644 (file)
index 327b8ca..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-# header: 2 questions
-00 00 00 00 00 02 00 00 00 00 00 00
-# question: AAAAAAAAAAAAAA./A/IN
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-00 01
-00 01
-# question: AAAAAAAAAAAAAA./A/IN
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-00 01
-00 01
diff --git a/bin/tests/system/formerr/formerr.pl b/bin/tests/system/formerr/formerr.pl
deleted file mode 100644 (file)
index fd7d298..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/usr/bin/perl
-
-# 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.
-
-# This is a tool for sending an arbitrary packet via UDP or TCP to an
-# arbitrary address and port.  The packet is specified in a file or on
-# the standard input, in the form of a series of bytes in hexadecimal.
-# Whitespace is ignored, as is anything following a '#' symbol.
-#
-# For example, the following input would generate normal query for
-# isc.org/NS/IN":
-#
-#     # QID:
-#     0c d8
-#     # header:
-#     01 00 00 01 00 00 00 00 00 00
-#     # qname isc.org:
-#     03 69 73 63 03 6f 72 67 00
-#     # qtype NS:
-#     00 02
-#     # qclass IN:
-#     00 01
-#
-# Note that we do not wait for a response for the server.  This is simply
-# a way of injecting arbitrary packets to test server resposnes.
-#
-# Usage: packet.pl [-a <address>] [-p <port>] [-t (udp|tcp)] [filename]
-#
-# If not specified, address defaults to 127.0.0.1, port to 53, protocol
-# to udp, and file to stdin.
-#
-# XXX: Doesn't support IPv6 yet
-
-require 5.006_001;
-
-use strict;
-use Getopt::Std;
-use IO::File;
-use IO::Socket;
-
-sub usage {
-    print ("Usage: packet.pl [-a address] [-p port] [file]\n");
-    exit 1;
-}
-
-my %options={};
-getopts("a:p:", \%options);
-
-my $addr = "127.0.0.1";
-$addr = $options{a} if defined $options{a};
-
-my $port = 53;
-$port = $options{p} if defined $options{p};
-
-my $file = "STDIN";
-if (@ARGV >= 1) {
-    my $filename = shift @ARGV;
-    open FH, "<$filename" or die "$filename: $!";
-    $file = "FH";
-}
-
-my $input = "";
-while (defined(my $line = <$file>) ) {
-    chomp $line;
-    $line =~ s/#.*$//;
-    $input .= $line;
-}
-
-$input =~ s/\s+//g;
-my $data = pack("H*", $input);
-my $len = length $data;
-
-my $output = unpack("H*", $data);
-print ("sending: $output\n");
-
-my $sock = IO::Socket::INET->new(PeerAddr => $addr, PeerPort => $port,
-                                Proto => "tcp") or die "$!";
-
-my $bytes;
-$bytes = $sock->syswrite(pack("n", $len), 2);
-$bytes = $sock->syswrite($data, $len);
-$bytes = $sock->sysread($data, 2);
-$len = unpack("n", $data);
-$bytes = $sock->sysread($data, $len);
-print "got: ", unpack("H*", $data). "\n";
-
-$sock->close;
-close $file;
diff --git a/bin/tests/system/formerr/keyclass b/bin/tests/system/formerr/keyclass
deleted file mode 100644 (file)
index c84568a..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-# mismatched key class
-# header: questions=1 additional=1
-00 00 00 00 00 01 00 00 00 00 00 01
-# question: ./A/IN
-00 00 01 00 01
-# additional: ./KEY/CLASS2 flags=0 protocol=0 algorithm=248 keydata=00
-00 00 19 00 02 00 00 00 01 00 05 00 00 00 f8 00
diff --git a/bin/tests/system/formerr/malformeddeltype b/bin/tests/system/formerr/malformeddeltype
deleted file mode 100644 (file)
index 2d14ae4..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# UPDATE malformed 'delete type' update change (non empty data)
-# header: UPDATE authority=1
-00 00 28 00 00 00 00 00 00 01 00 00
-# ./A/ANY TTL=0 length=1 data=00
-00 00 01 00 ff 00 00 00 00 00 01 00
diff --git a/bin/tests/system/formerr/malformedrrsig b/bin/tests/system/formerr/malformedrrsig
deleted file mode 100644 (file)
index 07d2af0..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# malformed RRRSIG
-# header: QUERY, additional=1
-00 00 00 00 00 00 00 00 00 00 00 01
-# ./RRSIG/IN TTL=1 covers=0 algorithm=240 labels=0 ttl=1 expire=2 signed=3 id=0
-00 00 2e 00 01 00 00 00 01 00 14 00 00 f0 00 00 00 00 01 00 00 00 02 00 00 00 03 00 00 00 00
diff --git a/bin/tests/system/formerr/nametoolong b/bin/tests/system/formerr/nametoolong
deleted file mode 100644 (file)
index b81545f..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-00 00 00 00 00 01 00 00 00 00 00 00
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0f 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-00 01
-00 01
diff --git a/bin/tests/system/formerr/noquestions b/bin/tests/system/formerr/noquestions
deleted file mode 100644 (file)
index f087bcd..0000000
+++ /dev/null
@@ -1 +0,0 @@
-00 00 00 00 00 00 00 00 00 00 00 00
diff --git a/bin/tests/system/formerr/optwrongname b/bin/tests/system/formerr/optwrongname
deleted file mode 100644 (file)
index 7b486d9..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# OPT record with wrong name (not .)
-# header: QUERY, additional=1
-00 00 00 00 00 00 00 00 00 00 00 01
-# OPT record (owner A.)
-01 41 00 00 29 00 01 00 00 00 00 00 00
diff --git a/bin/tests/system/formerr/qtypeasanswer b/bin/tests/system/formerr/qtypeasanswer
deleted file mode 100644 (file)
index d254d1d..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# QTYPE-only type as answer
-# header, answers=1
-00 00 00 00 00 00 00 01 00 00 00 00
-# ./MAILB/IN
-00 00 fd 00 01 00 00 00 01 00 00
diff --git a/bin/tests/system/formerr/questionclass b/bin/tests/system/formerr/questionclass
deleted file mode 100644 (file)
index b34d1e3..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-# two questions of different classes
-# header: QUERY, questions=2
-00 00 00 00 00 02 00 00 00 00 00 00
-# ./A/IN
-00 00 01 00 01
-# ./A/CLASS2
-00 00 01 00 02
diff --git a/bin/tests/system/formerr/shortquestion b/bin/tests/system/formerr/shortquestion
deleted file mode 100644 (file)
index 168ed90..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# truncated question section
-# header: QUERY, questions=1
-00 00 00 00 00 01 00 00 00 00 00 00
-# truncated question (no class)
-00 00 01
diff --git a/bin/tests/system/formerr/shortrecord b/bin/tests/system/formerr/shortrecord
deleted file mode 100644 (file)
index d9a2ab7..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# truncated record
-# header: QUERY, additional=1
-00 09 00 00 00 00 00 00 00 00 00 01
-# truncated A record (no ttl, length or data)
-00 00 01 00 01
diff --git a/bin/tests/system/formerr/tests.sh b/bin/tests/system/formerr/tests.sh
deleted file mode 100644 (file)
index a30198d..0000000
+++ /dev/null
@@ -1,177 +0,0 @@
-#!/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
-
-status=0
-
-echo_i "test name too long"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} nametoolong >nametoolong.out
-ans=$(grep got: nametoolong.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$((status + 1))
-fi
-
-echo_i "two question names"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} twoquestionnames >twoquestionnames.out
-ans=$(grep got: twoquestionnames.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$((status + 1))
-fi
-
-echo_i "two question types"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} twoquestiontypes >twoquestiontypes.out
-ans=$(grep got: twoquestiontypes.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$((status + 1))
-fi
-
-echo_i "duplicate questions"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} dupquestion >dupquestion.out
-ans=$(grep got: dupquestion.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "duplicate answer"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} dupans >dupans.out
-ans=$(grep got: dupans.out)
-if [ "${ans}" != "got: 0000800100010000000000000000060001" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "question only type in answer"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} qtypeasanswer >qtypeasanswer.out
-ans=$(grep got: qtypeasanswer.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-# this would be NOERROR if it included a COOKIE option,
-# but is a FORMERR without one.
-echo_i "empty question section (and no COOKIE option)"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} noquestions >noquestions.out
-ans=$(grep got: noquestions.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$((status + 1))
-fi
-
-echo_i "bad nsec3 owner"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} badnsec3owner >badnsec3owner.out
-ans=$(grep got: badnsec3owner.out)
-# SERVFAIL (2) rather than FORMERR (1)
-if [ "${ans}" != "got: 0008800200010000000000000000010001" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "short record before rdata "
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} shortrecord >shortrecord.out
-ans=$(grep got: shortrecord.out)
-if [ "${ans}" != "got: 000980010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "short question"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} shortquestion >shortquestion.out
-ans=$(grep got: shortquestion.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "mismatch classes in question section"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} questionclass >questionclass.out
-ans=$(grep got: questionclass.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "bad record owner name"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} badrecordname >badrecordname.out
-ans=$(grep got: badrecordname.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "mismatched class in record"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} wrongclass >wrongclass.out
-ans=$(grep got: wrongclass.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "mismatched KEY class"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} keyclass >keyclass.out
-ans=$(grep got: keyclass.out)
-if [ "${ans}" != "got: 0000800100010000000000000000010001" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "OPT wrong owner name"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} optwrongname >optwrongname.out
-ans=$(grep got: optwrongname.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "RRSIG invalid covers"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} malformedrrsig >malformedrrsig.out
-ans=$(grep got: malformedrrsig.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "UPDATE malformed delete type"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} malformeddeltype >malformeddeltype.out
-ans=$(grep got: malformeddeltype.out)
-if [ "${ans}" != "got: 0000a8010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "TSIG wrong class"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} tsigwrongclass >tsigwrongclass.out
-ans=$(grep got: tsigwrongclass.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "TSIG not last"
-$PERL formerr.pl -a 10.53.0.1 -p ${PORT} tsignotlast >tsignotlast.out
-ans=$(grep got: tsignotlast.out)
-if [ "${ans}" != "got: 000080010000000000000000" ]; then
-  echo_i "failed"
-  status=$(expr $status + 1)
-fi
-
-echo_i "exit status: $status"
-
-[ $status -eq 0 ] || exit 1
diff --git a/bin/tests/system/formerr/tests_formerr.py b/bin/tests/system/formerr/tests_formerr.py
new file mode 100644 (file)
index 0000000..7d6dfa0
--- /dev/null
@@ -0,0 +1,517 @@
+# 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 typing import SupportsInt
+
+import socket
+
+import dns.flags
+import dns.name
+import dns.opcode
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdtypes.ANY.NSEC3
+import dns.rdtypes.ANY.OPT
+import dns.rdtypes.ANY.RRSIG
+import dns.rdtypes.ANY.SOA
+import dns.rdtypes.ANY.TSIG
+import dns.rdtypes.IN.A
+import pytest
+
+
+def wire(*parts: bytes) -> bytes:
+    return b"".join(parts)
+
+
+def u16(value: SupportsInt) -> bytes:
+    return int(value).to_bytes(2, byteorder="big")
+
+
+def u32(value: SupportsInt) -> bytes:
+    return int(value).to_bytes(4, byteorder="big")
+
+
+def name(text: str) -> bytes:
+    return dns.name.from_text(text).to_wire()
+
+
+def root() -> bytes:
+    return dns.name.root.to_wire()
+
+
+def header(
+    message_id: int = 0,
+    flags: int = 0,
+    opcode: int = dns.opcode.QUERY,
+    rcode: dns.rcode.Rcode = dns.rcode.NOERROR,
+    qdcount: int = 0,
+    ancount: int = 0,
+    nscount: int = 0,
+    arcount: int = 0,
+) -> bytes:
+    return wire(
+        u16(message_id),
+        u16(flags | dns.opcode.to_flags(opcode) | int(rcode)),
+        u16(qdcount),
+        u16(ancount),
+        u16(nscount),
+        u16(arcount),
+    )
+
+
+def formerr_response_header(
+    message_id: int = 0,
+    opcode: int = dns.opcode.QUERY,
+    rcode: dns.rcode.Rcode = dns.rcode.FORMERR,
+    qdcount: int = 0,
+    ancount: int = 0,
+    nscount: int = 0,
+    arcount: int = 0,
+) -> bytes:
+    return header(
+        message_id=message_id,
+        flags=dns.flags.QR,
+        opcode=opcode,
+        rcode=rcode,
+        qdcount=qdcount,
+        ancount=ancount,
+        nscount=nscount,
+        arcount=arcount,
+    )
+
+
+def question(
+    qname: bytes,
+    qtype: dns.rdatatype.RdataType = dns.rdatatype.RdataType.A,
+    qclass: dns.rdataclass.RdataClass = dns.rdataclass.RdataClass.IN,
+) -> bytes:
+    return wire(
+        qname,
+        u16(qtype),
+        u16(qclass),
+    )
+
+
+def rr(
+    owner: bytes,
+    rrtype: SupportsInt,
+    rrclass: SupportsInt,
+    *,
+    ttl: int,
+    rdata: bytes = b"",
+) -> bytes:
+    return wire(
+        question(
+            owner, dns.rdatatype.RdataType(rrtype), dns.rdataclass.RdataClass(rrclass)
+        ),
+        u32(ttl),
+        u16(len(rdata)),
+        rdata,
+    )
+
+
+def oversized_name() -> bytes:
+    labels = [bytes([15]) + b"A" * 15 for _ in range(15)]
+    labels.append(bytes([14]) + b"A" * 14 + root())
+    return wire(*labels)
+
+
+def soa_rr(
+    *,
+    minimum: int,
+) -> bytes:
+    return rr(
+        root(),
+        dns.rdatatype.RdataType.SOA,
+        dns.rdataclass.RdataClass.IN,
+        ttl=1,
+        rdata=dns.rdtypes.ANY.SOA.SOA(
+            dns.rdataclass.RdataClass.IN,
+            dns.rdatatype.RdataType.SOA,
+            dns.name.root,
+            dns.name.root,
+            1,
+            2,
+            3,
+            4,
+            minimum,
+        ).to_wire(),
+    )
+
+
+def nsec3_rr(
+    *,
+    owner: bytes,
+) -> bytes:
+    return rr(
+        owner,
+        dns.rdatatype.RdataType.NSEC3,
+        dns.rdataclass.RdataClass.IN,
+        ttl=1,
+        rdata=dns.rdtypes.ANY.NSEC3.NSEC3(
+            dns.rdataclass.RdataClass.IN,
+            dns.rdatatype.RdataType.NSEC3,
+            algorithm=240,
+            flags=0,
+            iterations=0,
+            salt=b"",
+            next=b"\xff",
+            windows=[],
+        ).to_wire(),
+    )
+
+
+def key_rdata(
+    *,
+    flags: int,
+    protocol: int,
+    algorithm: int,
+    keydata: bytes,
+) -> bytes:
+    # No dns.rdtypes.ANY.KEY.KEY class, so construct the rdata manually
+    return wire(u16(flags), bytes([protocol, algorithm]), keydata)
+
+
+def key_rr(*, rdclass: dns.rdataclass.RdataClass) -> bytes:
+    return rr(
+        root(),
+        dns.rdatatype.RdataType.KEY,
+        rdclass,
+        ttl=1,
+        rdata=key_rdata(
+            flags=0,
+            protocol=0,
+            algorithm=248,
+            keydata=b"\x00",
+        ),
+    )
+
+
+def malformed_rrsig_rr() -> bytes:
+    return rr(
+        root(),
+        dns.rdatatype.RdataType.RRSIG,
+        dns.rdataclass.RdataClass.IN,
+        ttl=1,
+        rdata=dns.rdtypes.ANY.RRSIG.RRSIG(
+            dns.rdataclass.RdataClass.IN,
+            dns.rdatatype.RdataType.RRSIG,
+            0,
+            240,
+            0,
+            1,
+            2,
+            3,
+            0,
+            dns.name.root,
+            b"\x00",
+        ).to_wire(),
+    )
+
+
+def tsig_rr(
+    *,
+    owner: bytes = root(),
+    rdclass: dns.rdataclass.RdataClass = dns.rdataclass.RdataClass.ANY,
+    algorithm: dns.name.Name = dns.name.root,
+    time_signed: int = 0x010203040506,
+    fudge: int = 0x0102,
+    mac: bytes = b"\x00",
+    original_id: int = 0,
+    error: int = 0,
+    other: bytes = b"",
+) -> bytes:
+    return rr(
+        owner,
+        dns.rdatatype.RdataType.TSIG,
+        rdclass,
+        ttl=1,
+        rdata=dns.rdtypes.ANY.TSIG.TSIG(
+            rdclass,
+            dns.rdatatype.RdataType.TSIG,
+            algorithm,
+            time_signed,
+            fudge,
+            mac,
+            original_id,
+            error,
+            other,
+        ).to_wire(),
+    )
+
+
+def opt_rr(*, owner: bytes) -> bytes:
+    return rr(
+        owner,
+        dns.rdatatype.RdataType.OPT,
+        dns.rdataclass.RdataClass.IN,
+        ttl=0,
+        rdata=dns.rdtypes.ANY.OPT.OPT(
+            dns.rdataclass.RdataClass.IN,
+            dns.rdatatype.RdataType.OPT,
+            [],
+        ).to_wire(),
+    )
+
+
+def a_rdata(ipv4_bytes: bytes = b"\x00\x00\x00\x00") -> bytes:
+    return dns.rdtypes.IN.A.A(
+        dns.rdataclass.RdataClass.IN,
+        dns.rdatatype.RdataType.A,
+        ipv4_bytes,
+    ).to_wire()
+
+
+def a_rr(owner: bytes = root()) -> bytes:
+    return rr(
+        owner,
+        dns.rdatatype.RdataType.A,
+        dns.rdataclass.RdataClass.IN,
+        ttl=1,
+        rdata=a_rdata(),
+    )
+
+
+def query_raw_tcp(host: str, port: int, packet_wire: bytes) -> bytes:
+    with (
+        socket.create_connection((host, port), timeout=10) as sock,
+        sock.makefile("rwb") as f,
+    ):
+        f.write(u16(len(packet_wire)))
+        f.write(packet_wire)
+        f.flush()
+        length = int.from_bytes(f.read(2), byteorder="big")
+        return f.read(length)
+
+
+@pytest.mark.parametrize(
+    "query_wire,expected_wire",
+    [
+        pytest.param(
+            wire(
+                header(qdcount=1),
+                question(oversized_name()),
+            ),
+            formerr_response_header(),
+            id="nametoolong",
+        ),
+        pytest.param(
+            wire(
+                header(qdcount=2),
+                question(name("AAAAAAAAAAAAAA."), dns.rdatatype.RdataType.A),
+                # Two names concatenated in the QNAME field
+                question(
+                    wire(name("AAAAAAAAAAAAAA."), name("AAAAAAAAAAAAAB.")),
+                    dns.rdatatype.RdataType.A,
+                ),
+            ),
+            formerr_response_header(),
+            id="twoquestionnames",
+        ),
+        pytest.param(
+            wire(
+                header(qdcount=2),
+                question(name("AAAAAAAAAAAAAA."), dns.rdatatype.RdataType.A),
+                question(name("AAAAAAAAAAAAAA."), dns.rdatatype.RdataType.NS),
+            ),
+            formerr_response_header(),
+            id="twoquestiontypes",
+        ),
+        pytest.param(
+            wire(
+                header(qdcount=2),
+                question(name("AAAAAAAAAAAAAA."), dns.rdatatype.RdataType.A),
+                question(name("AAAAAAAAAAAAAA."), dns.rdatatype.RdataType.A),
+            ),
+            formerr_response_header(),
+            id="dupquestion",
+        ),
+        pytest.param(
+            wire(
+                header(qdcount=1, ancount=2),
+                question(root(), dns.rdatatype.RdataType.SOA),
+                soa_rr(minimum=5),
+                soa_rr(minimum=6),
+            ),
+            wire(
+                formerr_response_header(qdcount=1),
+                question(root(), dns.rdatatype.RdataType.SOA),
+            ),
+            id="dupans",
+        ),
+        pytest.param(
+            wire(
+                header(ancount=1),
+                rr(
+                    root(),
+                    dns.rdatatype.RdataType.MAILB,
+                    dns.rdataclass.RdataClass.IN,
+                    ttl=1,
+                ),
+            ),
+            formerr_response_header(),
+            id="qtypeasanswer",
+        ),
+        pytest.param(
+            header(),
+            # This would be NOERROR if it included a COOKIE option,
+            # but is a FORMERR without one.
+            formerr_response_header(),
+            id="noquestions",
+        ),
+        pytest.param(
+            wire(
+                header(message_id=8, qdcount=1, nscount=1),
+                question(root(), dns.rdatatype.RdataType.A),
+                # Bad NSEC3 owner: X. is not in the base32hex alphabet.
+                nsec3_rr(owner=name("X.")),
+            ),
+            wire(
+                formerr_response_header(
+                    message_id=8, rcode=dns.rcode.SERVFAIL, qdcount=1
+                ),
+                question(root(), dns.rdatatype.RdataType.A),
+            ),
+            id="badnsec3owner",
+        ),
+        pytest.param(
+            wire(
+                header(message_id=9, arcount=1),
+                # Truncated A record (no ttl, length or data)
+                question(root(), dns.rdatatype.RdataType.A),
+            ),
+            formerr_response_header(message_id=9),
+            id="shortrecord",
+        ),
+        pytest.param(
+            wire(
+                header(qdcount=1),
+                # Truncated question (no class)
+                root(),
+                u16(dns.rdatatype.RdataType.A),
+            ),
+            formerr_response_header(),
+            id="shortquestion",
+        ),
+        pytest.param(
+            wire(
+                header(qdcount=2),
+                question(
+                    root(),
+                    dns.rdatatype.RdataType.A,
+                    dns.rdataclass.RdataClass.IN,
+                ),
+                question(
+                    root(),
+                    dns.rdatatype.RdataType.A,
+                    dns.rdataclass.RdataClass(2),
+                ),
+            ),
+            formerr_response_header(),
+            id="questionclass",
+        ),
+        pytest.param(
+            wire(
+                header(arcount=1),
+                a_rr(owner=oversized_name()),
+            ),
+            formerr_response_header(),
+            id="badrecordname",
+        ),
+        pytest.param(
+            wire(
+                header(arcount=2),
+                a_rr(),
+                rr(
+                    root(),
+                    dns.rdatatype.RdataType(65280),
+                    dns.rdataclass.RdataClass(256),
+                    ttl=33554433,
+                    rdata=a_rdata(),
+                ),
+            ),
+            formerr_response_header(),
+            id="wrongclass",
+        ),
+        pytest.param(
+            wire(
+                header(qdcount=1, arcount=1),
+                question(root(), dns.rdatatype.RdataType.A),
+                key_rr(rdclass=dns.rdataclass.RdataClass(2)),
+            ),
+            wire(
+                formerr_response_header(qdcount=1),
+                question(root(), dns.rdatatype.RdataType.A),
+            ),
+            id="keyclass",
+        ),
+        pytest.param(
+            wire(
+                header(arcount=1),
+                # OPT owner should be root
+                opt_rr(owner=name("A.")),
+            ),
+            formerr_response_header(),
+            id="optwrongname",
+        ),
+        pytest.param(
+            wire(
+                header(arcount=1),
+                malformed_rrsig_rr(),
+            ),
+            formerr_response_header(),
+            id="malformedrrsig",
+        ),
+        pytest.param(
+            wire(
+                header(opcode=dns.opcode.UPDATE, nscount=1),
+                rr(
+                    root(),
+                    dns.rdatatype.RdataType.A,
+                    dns.rdataclass.RdataClass.ANY,
+                    ttl=0,
+                    # Non-empty rdata for DELETE type
+                    rdata=b"\x00",
+                ),
+            ),
+            formerr_response_header(opcode=dns.opcode.UPDATE),
+            id="malformeddeltype",
+        ),
+        pytest.param(
+            wire(
+                header(arcount=1),
+                # Class should be ANY not IN
+                tsig_rr(rdclass=dns.rdataclass.RdataClass.IN),
+            ),
+            formerr_response_header(),
+            id="tsigwrongclass",
+        ),
+        pytest.param(
+            wire(
+                header(arcount=2),
+                tsig_rr(),
+                # TSIG should be the last record
+                a_rr(),
+            ),
+            formerr_response_header(),
+            id="tsignotlast",
+        ),
+    ],
+)
+def test_formerr(
+    query_wire: bytes,
+    expected_wire: bytes,
+    named_port: int,
+    ns1,
+) -> None:
+    response_wire = query_raw_tcp(ns1.ip, named_port, query_wire)
+    assert response_wire == expected_wire
diff --git a/bin/tests/system/formerr/tests_sh_formerr.py b/bin/tests/system/formerr/tests_sh_formerr.py
deleted file mode 100644 (file)
index a091546..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-# 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(
-    [
-        "badnsec3owner.out",
-        "badrecordname.out",
-        "dupans.out",
-        "dupquestion.out",
-        "keyclass.out",
-        "malformeddeltype.out",
-        "malformedrrsig.out",
-        "nametoolong.out",
-        "noquestions.out",
-        "optwrongname.out",
-        "qtypeasanswer.out",
-        "questionclass.out",
-        "shortquestion.out",
-        "shortrecord.out",
-        "tsignotlast.out",
-        "tsigwrongclass.out",
-        "twoquestionnames.out",
-        "twoquestiontypes.out",
-        "wrongclass.out",
-    ]
-)
-
-
-def test_formerr(run_tests_sh):
-    run_tests_sh()
diff --git a/bin/tests/system/formerr/tsignotlast b/bin/tests/system/formerr/tsignotlast
deleted file mode 100644 (file)
index 108936c..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-# SIG not last in additional
-# header: QUERY additional=2
-00 00 00 00 00 00 00 00 00 00 00 02
-# Additional records:
-# SIG record (class ANY)
-00 00 fa 00 ff 00 00 00 01 00 12 00 01 02 03 04 05 06 01 02 00 01 00 00 00 00 00 00 00
-# A record
-00 00 01 00 01 00 00 00 01 00 04 00 00 00 00
diff --git a/bin/tests/system/formerr/tsigwrongclass b/bin/tests/system/formerr/tsigwrongclass
deleted file mode 100644 (file)
index 29aea08..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# TSIG wrong class
-# header: QUERY, additional=1
-00 00 00 00 00 00 00 00 00 00 00 01
-# class should be ANY (00 ff) not IN (00 01)
-00 00 fa 00 01 00 00 00 01 00 12 00 01 02 03 04 05 06 01 02 00 01 00 00 00 00 00 00 00
diff --git a/bin/tests/system/formerr/twoquestionnames b/bin/tests/system/formerr/twoquestionnames
deleted file mode 100644 (file)
index f363a23..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-# two questions with different names
-00 00 00 00 00 02 00 00 00 00 00 00
-# AAAAAAAAAAAAAA./A/IN
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-00 01
-00 01
-# AAAAAAAAAAAAAB./A/IN
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 42 00
-00 01
-00 01
diff --git a/bin/tests/system/formerr/twoquestiontypes b/bin/tests/system/formerr/twoquestiontypes
deleted file mode 100644 (file)
index 451f8f6..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-# two questions that differ by type
-00 00 00 00 00 02 00 00 00 00 00 00
-# AAAAAAAAAAAAAAA./A/IN
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-00 01
-00 01
-# AAAAAAAAAAAAAAA./NS/IN
-0e 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00
-00 02
-00 01
diff --git a/bin/tests/system/formerr/wrongclass b/bin/tests/system/formerr/wrongclass
deleted file mode 100644 (file)
index 96211e0..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-# class mismatch
-# header: QUERY, additional=2
-00 00 00 00 00 00 00 00 00 00 00 02
-# ./A/IN
-00 00 01 00 01 00 00 00 01 00 04 00 00 00 00
-# ./TYPE65280/CLASS256
-00 ff 00 01 00 02 00 00 01 00 04 00 00 00 00