summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile3
-rw-r--r--debian/.editorconfig36
-rw-r--r--debian/.gitlab-ci.yml5
-rw-r--r--debian/changelog53
-rw-r--r--debian/clean4
-rw-r--r--debian/contrib/httptaild23
-rw-r--r--debian/control13
-rw-r--r--debian/patches/0005-build-verbose.patch26
-rw-r--r--debian/patches/series1
-rwxr-xr-xdebian/rules27
-rw-r--r--debian/tests/config/ruff/all.toml5
-rw-r--r--debian/tests/config/ruff/base.toml28
-rw-r--r--debian/tests/control11
-rw-r--r--debian/tests/pyproject.toml44
-rw-r--r--debian/tests/python/ucspi_tcp_test/__init__.py1
-rw-r--r--debian/tests/python/ucspi_tcp_test/__main__.py322
-rw-r--r--debian/tests/python/ucspi_tcp_test/py.typed0
-rw-r--r--debian/tests/python/ucspi_test/__init__.py514
-rw-r--r--debian/tests/python/ucspi_test/py.typed0
-rw-r--r--debian/tests/requirements/install.txt2
-rw-r--r--debian/tests/requirements/ruff.txt1
-rw-r--r--debian/tests/stubs/netifaces.pyi15
-rw-r--r--debian/tests/tox.ini67
-rw-r--r--debian/ucspi-tcp-ipv6.install1
-rw-r--r--debian/ucspi-tcp-man/rblsmtpd.14
-rw-r--r--debian/ucspi-tcp.install1
26 files changed, 1195 insertions, 12 deletions
diff --git a/Makefile b/Makefile
index e6528b4..1fe44fb 100644
--- a/Makefile
+++ b/Makefile
@@ -158,6 +158,7 @@ commands.h
compile: \
warn-auto.sh conf-cc
( cat warn-auto.sh; \
+ [ '$V' != '1' ] || echo echo "`head -1 conf-cc`" '-c $${1+"$$@"}'; \
echo exec "`head -1 conf-cc`" '-c $${1+"$$@"}' \
) > compile
chmod 755 compile
@@ -384,6 +385,8 @@ load: \
warn-auto.sh conf-ld
( cat warn-auto.sh; \
echo 'main="$$1"; shift'; \
+ [ '$V' != '1' ] || echo echo "`head -1 conf-ld`" \
+ '-o "$$main" "$$main".o $${1+"$$@"}'; \
echo exec "`head -1 conf-ld`" \
'-o "$$main" "$$main".o $${1+"$$@"}' \
) > load
diff --git a/debian/.editorconfig b/debian/.editorconfig
new file mode 100644
index 0000000..9969178
--- /dev/null
+++ b/debian/.editorconfig
@@ -0,0 +1,36 @@
+# https://editorconfig.org/
+
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+
+[*.1]
+indent_style = space
+indent_size = 4
+
+[*.py]
+indent_style = space
+indent_size = 4
+
+[*.pyi]
+indent_style = space
+indent_size = 4
+
+[{changelog,control,copyright,watch}]
+indent_style = space
+indent_size = 4
+
+[rules]
+indent_style = tab
+tab_size = 8
+
+[setup.cfg]
+indent_style = space
+indent_size = 4
+
+[tox.ini]
+indent_style = space
+indent_size = 2
diff --git a/debian/.gitlab-ci.yml b/debian/.gitlab-ci.yml
new file mode 100644
index 0000000..cfb8c84
--- /dev/null
+++ b/debian/.gitlab-ci.yml
@@ -0,0 +1,5 @@
+include:
+ - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml
+ - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml
+variables:
+ RELEASE: experimental
diff --git a/debian/changelog b/debian/changelog
index e1567a7..f0693d4 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,56 @@
+ucspi-tcp (1:0.88-8) unstable; urgency=medium
+
+ * Declare dpkg-build-api v1, drop the implied Rules-Requires-Root: no
+ declaration.
+ * Use dh-package-notes to record ELF package metadata.
+ * autopkgtest:
+ - fix a server/client mismatch in a log message
+ - only pass "-R" if supported, do not pass "--"
+ - support verbose logs to the standard output stream
+ - run {proto}server with line-buffered stdout and stderr
+ - dynamic version, bump it to 0.2.0
+ - read all the data from the socket
+ * autopkgtest internal test suite:
+ - tox.ini: add tags for running tox-stages
+ - do not pass the python_version option to mypy, let it check against
+ the Python interpreter that it is being run with
+ - use hatchling for the PEP517 build
+ - convert tox.ini to the Tox 4.x format
+ - use Ruff instead of black, flake8, and pylint
+ - reformat and refactor the source code according to Ruff's suggestions
+
+ -- Peter Pentchev <roam@debian.org> Tue, 27 Feb 2024 11:57:42 +0200
+
+ucspi-tcp (1:0.88-7) unstable; urgency=medium
+
+ [ Dmitry Bogatov ]
+ * Add Gitlab CI config file
+ * Do not run tests under Gitlab CI
+
+ [ Peter Pentchev ]
+ * New maintainer. Closes: #983804
+ * Declare compliance with Policy 4.6.2 with no changes.
+ * Set the debhelper compat level to 13 with no changes.
+ * Add an EditorConfig definitions file.
+ * Run a custom test suite at build-time and as an autopkgtest.
+ * Clean up after a build.
+ * Show the compiler and linker invocations unless "terse" is requested.
+ * Do not use "tee" to generate the config files.
+ * Add Rules-Requires-Root: no to the source control stanza.
+ * Remove leading slashes from debian/*.install.
+ * No longer mention ucspi-tcp-doc, no such package for a long time.
+
+ -- Peter Pentchev <roam@debian.org> Thu, 05 Jan 2023 04:05:08 +0200
+
+ucspi-tcp (1:0.88-6) unstable; urgency=medium
+
+ * Remove references to patched-out `rbl.maps.vix.com' host from manpage
+ (Closes: #681739)
+ * Install example http server, serving log file with chunked transfer
+ encoding (Closes: #772209)
+
+ -- Dmitry Bogatov <KAction@debian.org> Thu, 17 Jan 2019 06:30:43 +0000
+
ucspi-tcp (1:0.88-5) unstable; urgency=medium
* Do not include build path into resulting scripts (Closes: #915511)
diff --git a/debian/clean b/debian/clean
new file mode 100644
index 0000000..af8fd4a
--- /dev/null
+++ b/debian/clean
@@ -0,0 +1,4 @@
+conf-cc
+conf-home
+conf-ld
+ipv6/
diff --git a/debian/contrib/httptaild b/debian/contrib/httptaild
new file mode 100644
index 0000000..ffc7762
--- /dev/null
+++ b/debian/contrib/httptaild
@@ -0,0 +1,23 @@
+#!/bin/sh
+# outputs a file over HTTP 1.1 and pushes updates when new lines are appended
+# © 2014 Nils Dagsson Moskopp (erlehmann) – license: GPLv3+
+# httptaild is intended for usage with tcpserver(1) from ucspi-tcp:
+# "tcpserver 0 8008 httptaild log" serves log on http://localhost:8008
+
+status () { printf '%s\r\n' "HTTP/1.1 200 Ok"; }
+header () { printf '%s\r\n' "$1"; }
+divide () { printf '%s\r\n' "$1"; }
+length () { printf '%x\r\n' "$1"; }
+body_n () { printf '%s\n\r\n' "$1"; }
+
+status
+header "Content-type: $(file --brief --mime "$1")"
+header "Transfer-Encoding: chunked"
+divide
+length "$(wc -c <"$1")"
+body_n "$(cat "$1")"
+
+tail -n0 -f "$1" | while IFS=$(printf '\n') read -r line; do
+ length "$(printf '%s\n' "$line" | wc -c)"
+ body_n "$line"
+done
diff --git a/debian/control b/debian/control
index 663661b..4935615 100644
--- a/debian/control
+++ b/debian/control
@@ -1,10 +1,15 @@
Source: ucspi-tcp
Section: net
Priority: optional
-Maintainer: Dmitry Bogatov <KAction@debian.org>
+Maintainer: Peter Pentchev <roam@debian.org>
Build-Depends:
- debhelper-compat (= 11),
-Standards-Version: 4.2.1
+ debhelper-compat (= 13),
+ dh-package-notes,
+ dpkg-build-api (= 1),
+ python3 <!nocheck>,
+ python3-netifaces <!nocheck>,
+ python3-utf8-locale <!nocheck>,
+Standards-Version: 4.6.2
Homepage: http://cr.yp.to/ucspi-tcp
Vcs-Git: https://salsa.debian.org/debian/ucspi-tcp.git
Vcs-Browser: https://salsa.debian.org/debian/ucspi-tcp
@@ -12,7 +17,6 @@ Vcs-Browser: https://salsa.debian.org/debian/ucspi-tcp
Package: ucspi-tcp
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
-Replaces: ucspi-tcp-doc
Description: command-line tools for building TCP client-server applications
tcpserver waits for incoming connections and, for each connection, runs a
program of your choice. Your program receives environment variables showing
@@ -43,7 +47,6 @@ Description: command-line tools for building TCP client-server applications
Package: ucspi-tcp-ipv6
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
-Replaces: ucspi-tcp-doc
Conflicts: ucspi-tcp
Provides: ucspi-tcp
Description: command-line tools for building TCP client-server applications (IPv6)
diff --git a/debian/patches/0005-build-verbose.patch b/debian/patches/0005-build-verbose.patch
new file mode 100644
index 0000000..c30efb0
--- /dev/null
+++ b/debian/patches/0005-build-verbose.patch
@@ -0,0 +1,26 @@
+Description: Show the compiler and linker invocations during the build
+ If the "V" environment variable is set to the value "1", show
+ the compiler and linker commands before running them.
+Forwarded: not-needed
+Author: Peter Pentchev <roam@ringlet.net>
+Last-Update: 2023-01-05
+
+--- a/Makefile
++++ b/Makefile
+@@ -158,6 +158,7 @@
+ compile: \
+ warn-auto.sh conf-cc
+ ( cat warn-auto.sh; \
++ [ '$V' != '1' ] || echo echo "`head -1 conf-cc`" '-c $${1+"$$@"}'; \
+ echo exec "`head -1 conf-cc`" '-c $${1+"$$@"}' \
+ ) > compile
+ chmod 755 compile
+@@ -384,6 +385,8 @@
+ warn-auto.sh conf-ld
+ ( cat warn-auto.sh; \
+ echo 'main="$$1"; shift'; \
++ [ '$V' != '1' ] || echo echo "`head -1 conf-ld`" \
++ '-o "$$main" "$$main".o $${1+"$$@"}'; \
+ echo exec "`head -1 conf-ld`" \
+ '-o "$$main" "$$main".o $${1+"$$@"}' \
+ ) > load
diff --git a/debian/patches/series b/debian/patches/series
index e8da398..84c9cf3 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -2,3 +2,4 @@
0002-rblsmtpd.c-don-t-use-a-the-default-rbl.maps.vix.com.diff
0003-Makefile-target-choose-do-not-depend-on-conf-home.diff
0004-respect-DESTDIR-variable.patch
+0005-build-verbose.patch
diff --git a/debian/rules b/debian/rules
index d257207..32d5543 100755
--- a/debian/rules
+++ b/debian/rules
@@ -5,19 +5,32 @@ include /usr/share/dpkg/default.mk
export CFLAGS += $(shell getconf LFS_CFLAGS)
export LDFLAGS += $(shell getconf LFS_LDFLAGS)
+include /usr/share/debhelper/dh_package_notes/package-notes.mk
+
PREFIX_IPV4 = $(CURDIR)/debian/ucspi-tcp
PREFIX_IPV6 = $(CURDIR)/debian/ucspi-tcp-ipv6
+ifeq (,$(filter terse,$(DEB_BUILD_OPTIONS)))
+export V=1
+endif
+
%:
dh $@
+# The upstream Makefile does not have a 'clean' target, so simulate one.
+override_dh_auto_clean:
+ sed -nre '/^[a-z0-9@_.-]+:/ { s/:.*//; p; }' Makefile | grep -Fxve it -e default | xargs -r rm -f
+
override_dh_auto_configure:
+ echo '/usr' > conf-home
+ echo '$(CC) $(CFLAGS) $(CPPFLAGS)' > conf-cc
+ echo '$(CC) $(LDFLAGS)' > conf-ld
+ifeq ($(DEBEMAIL),<salsa-pipeline@debian.org>)
+ echo 'int main () { return 0; }' > chkshsgr.c
+endif
mkdir ipv6
xargs install -t ipv6 < FILES
cd ipv6 && patch -p1 < ../debian/ipv6-support.patch
- echo '/usr' | tee conf-home ipv6/conf-home
- echo '$(CC) $(CFLAGS) $(CPPFLAGS)' | tee ipv6/conf-cc conf-cc
- echo '$(CC) $(LDFLAGS)' | tee ipv6/conf-ld conf-ld
override_dh_auto_build:
$(MAKE) DESTDIR=$(PREFIX_IPV4)
@@ -37,5 +50,11 @@ override_dh_auto_install:
# and `install'.
#
# So automatic invocation of tests is inhibited, and they are invoked
-# manuall at `install' stage.
+# manually at `install' stage.
+#
+# We run our own test suite instead, one that will also be used in
+# the autopkgtest later.
override_dh_auto_test:
+ env PYTHONPATH='$(CURDIR)/debian/tests/python' python3 -B -u -m ucspi_tcp_test -d '$(CURDIR)' -p tcp
+ env PYTHONPATH='$(CURDIR)/debian/tests/python' python3 -B -u -m ucspi_tcp_test -d '$(CURDIR)/ipv6' -p tcp -i 4
+ env PYTHONPATH='$(CURDIR)/debian/tests/python' python3 -B -u -m ucspi_tcp_test -d '$(CURDIR)/ipv6' -p tcp -i 6
diff --git a/debian/tests/config/ruff/all.toml b/debian/tests/config/ruff/all.toml
new file mode 100644
index 0000000..d4b8f53
--- /dev/null
+++ b/debian/tests/config/ruff/all.toml
@@ -0,0 +1,5 @@
+extend = "base.toml"
+preview = true
+
+[lint]
+select = ["ALL"]
diff --git a/debian/tests/config/ruff/base.toml b/debian/tests/config/ruff/base.toml
new file mode 100644
index 0000000..4d29037
--- /dev/null
+++ b/debian/tests/config/ruff/base.toml
@@ -0,0 +1,28 @@
+target-version = "py310"
+line-length = 100
+
+[lint]
+select = []
+ignore = [
+ # In Debian packages, the copyright information is stored elsewhere
+ "CPY001",
+
+ # No blank lines before the class docstring, TYVM
+ "D203",
+
+ # The multi-line docstring summary starts on the same line
+ "D213",
+]
+
+[lint.isort]
+force-single-line = true
+known-first-party = ["ucspi_test", "ucspi_unix_test"]
+lines-after-imports = 2
+single-line-exclusions = ["typing"]
+
+[lint.per-file-ignores]
+# This is a command-line tool, console output is part of its job
+"python/ucspi_tcp_test/__main__.py" = ["T201"]
+
+# This is a command-line tool, console output is part of its job
+"python/ucspi_test/__init__.py" = ["T201"]
diff --git a/debian/tests/control b/debian/tests/control
new file mode 100644
index 0000000..c4c2723
--- /dev/null
+++ b/debian/tests/control
@@ -0,0 +1,11 @@
+Test-Command: set -e; for py in $(py3versions -i); do printf -- '\n\n====== %s\n\n' "$py"; env PYTHONPATH="$(pwd)/debian/tests/python" "$py" -B -u -m ucspi_tcp_test -d /usr/bin -p tcp; done
+Depends: ucspi-tcp, python3-all, python3-netifaces, python3-utf8-locale
+Features: test-name=debian-python-v4
+
+Test-Command: set -e; for py in $(py3versions -i); do printf -- '\n\n====== %s\n\n' "$py"; env PYTHONPATH="$(pwd)/debian/tests/python" "$py" -B -u -m ucspi_tcp_test -d /usr/bin -p tcp -i 4; done
+Depends: ucspi-tcp-ipv6, python3-all, python3-netifaces, python3-utf8-locale
+Features: test-name=debian-python-v6-4
+
+Test-Command: set -e; for py in $(py3versions -i); do printf -- '\n\n====== %s\n\n' "$py"; env PYTHONPATH="$(pwd)/debian/tests/python" "$py" -B -u -m ucspi_tcp_test -d /usr/bin -p tcp -i 6; done
+Depends: ucspi-tcp-ipv6, python3-all, python3-netifaces, python3-utf8-locale
+Features: test-name=debian-python-v6
diff --git a/debian/tests/pyproject.toml b/debian/tests/pyproject.toml
new file mode 100644
index 0000000..6f8c7f9
--- /dev/null
+++ b/debian/tests/pyproject.toml
@@ -0,0 +1,44 @@
+[build-system]
+requires = [
+ "hatchling >= 1.8, < 2",
+ "hatch-requirements-txt >= 0.3, < 0.5",
+]
+build-backend = "hatchling.build"
+
+[project]
+name = "uctest"
+description = "Run some tests for the UCSPI client and server tools"
+requires-python = ">= 3.10"
+license = {"text" = "public domain"}
+urls = {"Source" = "https://salsa.debian.org/debian/ucspi-tcp"}
+dynamic = ["dependencies", "version"]
+
+[[project.authors]]
+name = "Peter Pentchev"
+email = "roam@debian.org"
+
+[project.scripts]
+uctest_tcp = "ucspi_tcp_test.__main__:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["python/ucspi_test", "python/ucspi_tcp_test"]
+
+[tool.hatch.metadata.hooks.requirements_txt]
+files = ["requirements/install.txt"]
+
+[tool.setuptools.dynamic]
+version = {attr = "ucspi_test.__init__.VERSION"}
+
+[tool.hatch.version]
+path = "python/ucspi_test/__init__.py"
+pattern = '(?x) ^ VERSION \s* (?: : \s* Final \s* )? = \s* " (?P<version> [^\s"]+ ) " \s* $'
+
+[tool.mypy]
+strict = true
+
+[tool.test-stages]
+stages = [
+ "@check and @quick and not @manual",
+ "@check and not @manual",
+ "@tests and not @manual",
+]
diff --git a/debian/tests/python/ucspi_tcp_test/__init__.py b/debian/tests/python/ucspi_tcp_test/__init__.py
new file mode 100644
index 0000000..ea9ead4
--- /dev/null
+++ b/debian/tests/python/ucspi_tcp_test/__init__.py
@@ -0,0 +1 @@
+"""Run a couple of tests for the `ucspi-tcp` tools."""
diff --git a/debian/tests/python/ucspi_tcp_test/__main__.py b/debian/tests/python/ucspi_tcp_test/__main__.py
new file mode 100644
index 0000000..5bddf1e
--- /dev/null
+++ b/debian/tests/python/ucspi_tcp_test/__main__.py
@@ -0,0 +1,322 @@
+"""Test the TCP implementation of UCSPI."""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import enum
+import pathlib
+import socket
+import typing
+
+import netifaces
+import utf8_locale
+
+import ucspi_test
+
+
+if typing.TYPE_CHECKING:
+ from typing import Any, Final
+
+
+@dataclasses.dataclass
+class InvalidPortNumberError(ucspi_test.RunnerError):
+ """Could not connect to a TCP socket."""
+
+ proto: str
+ portstr: str
+ err: Exception
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"{self.proto}: could not convert {self.portstr!r} to a number: {self.err}"
+
+
+@dataclasses.dataclass
+class NoAvailablePortsError(ucspi_test.RunnerError):
+ """Could not find an available port number."""
+
+ addr: str
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"Could not find a suitable port on {self.addr}"
+
+
+@dataclasses.dataclass
+class SocketCreateError(ucspi_test.RunnerError):
+ """Could not create a TCP socket."""
+
+ err: Exception
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"Could not create a TCP socket: {self.err}"
+
+
+@dataclasses.dataclass
+class SocketReuseAddressError(ucspi_test.RunnerError):
+ """Could not create a TCP socket."""
+
+ err: Exception
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"Could not set the 'reuse address' option on a TCP socket: {self.err}"
+
+
+@dataclasses.dataclass
+class SocketBindError(ucspi_test.RunnerError):
+ """Could not bind a TCP socket."""
+
+ addr: str
+ port: int
+ err: Exception
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"Could not bind to {self.addr}:{self.port}: {self.err}"
+
+
+@dataclasses.dataclass
+class SocketListenError(ucspi_test.RunnerError):
+ """Could not listen on a TCP socket."""
+
+ addr: str
+ port: int
+ err: Exception
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"Could not listen on {self.addr}:{self.port}: {self.err}"
+
+
+@dataclasses.dataclass
+class SocketConnectError(ucspi_test.RunnerError):
+ """Could not connect to a TCP socket."""
+
+ addr: str
+ port: int
+ err: Exception
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"Could not connect a TCP socket to {self.addr}:{self.port}: {self.err}"
+
+
+@dataclasses.dataclass(frozen=True)
+class Config(ucspi_test.Config):
+ """Runtime configuration for the TCP test runner."""
+
+ listen_addr: str
+ listen_addr_len: set[int]
+ listen_family: socket.AddressFamily
+
+
+class TcpRunner(ucspi_test.Runner):
+ """Run ucspi-tcp tests."""
+
+ def find_listening_address(self) -> list[str]:
+ """Find a local address/port combination."""
+ print(f"{self.proto}.find_listening_address() starting")
+ for port in range(6502, 8086):
+ if not isinstance(self.cfg, Config):
+ raise TypeError(repr(self.cfg))
+ addr = self.cfg.listen_addr
+ sock = socket.socket(self.cfg.listen_family, socket.SOCK_STREAM, socket.IPPROTO_TCP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind((addr, port))
+ print(f"- got {port}")
+ sock.close()
+ return [addr, str(port)]
+ except OSError:
+ pass
+
+ raise NoAvailablePortsError(addr)
+
+ def get_listening_socket(self, addr: list[str]) -> socket.socket:
+ """Start listening on the specified address."""
+ if not isinstance(self.cfg, Config):
+ raise TypeError(repr(self.cfg))
+ if len(addr) not in self.cfg.listen_addr_len:
+ raise ucspi_test.SocketAddressLengthError(self.proto, addr)
+ laddr: Final = addr[0]
+ try:
+ lport: Final = int(addr[1])
+ except ValueError as err:
+ raise InvalidPortNumberError(self.proto, addr[1], err) from err
+
+ try:
+ sock: Final = socket.socket(
+ self.cfg.listen_family,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ )
+ except OSError as err:
+ raise SocketCreateError(err) from err
+ try:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ except OSError as err:
+ raise SocketReuseAddressError(err) from err
+ try:
+ sock.bind((laddr, lport))
+ except OSError as err:
+ raise SocketBindError(laddr, lport, err) from err
+ try:
+ sock.listen(5)
+ except OSError as err:
+ raise SocketListenError(laddr, lport, err) from err
+
+ return sock
+
+ def get_connected_socket(self, addr: list[str]) -> socket.socket:
+ """Connect to the specified address."""
+ if not isinstance(self.cfg, Config):
+ raise TypeError(repr(self.cfg))
+ if len(addr) not in self.cfg.listen_addr_len:
+ raise ucspi_test.SocketAddressLengthError(self.proto, addr)
+ laddr: Final = addr[0]
+ try:
+ lport: Final = int(addr[1])
+ except ValueError as err:
+ raise InvalidPortNumberError(self.proto, addr[1], err) from err
+
+ try:
+ sock: Final = socket.socket(
+ self.cfg.listen_family,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ )
+ except OSError as err:
+ raise SocketCreateError(err) from err
+ try:
+ sock.connect((laddr, lport))
+ except OSError as err:
+ raise SocketConnectError(laddr, lport, err) from err
+
+ return sock
+
+ def format_local_addr(self, addr: list[str]) -> str:
+ """Format an address returned by accept(), etc."""
+ if not isinstance(self.cfg, Config):
+ raise TypeError(repr(self.cfg))
+ if len(addr) not in self.cfg.listen_addr_len:
+ raise TypeError(repr(addr))
+ return f"{addr[0]}:{addr[1]}"
+
+ def format_remote_addr(self, addr: Any) -> str: # noqa: ANN401
+ """Format an address returned by accept(), etc."""
+ if not isinstance(self.cfg, Config):
+ raise TypeError(repr(self.cfg))
+ if (
+ not isinstance(addr, tuple)
+ or len(addr) not in self.cfg.listen_addr_len
+ or not isinstance(addr[0], str)
+ or not isinstance(addr[1], int)
+ ):
+ raise TypeError(repr(addr))
+ return f"{addr[0]}:{addr[1]}"
+
+
+class IPVersion(str, enum.Enum):
+ """The IP address family for the listening socket."""
+
+ IPV4: Final = "4"
+ IPV6: Final = "6"
+
+ def __str__(self) -> str:
+ """Return the string value itself."""
+ return self.value
+
+ def addr_len(self) -> set[int]:
+ """Obtain the expected length of an address/port tuple."""
+ match self:
+ case IPVersion.IPV4:
+ return {2}
+
+ case IPVersion.IPV6:
+ return {2, 4}
+
+ def family(self) -> socket.AddressFamily:
+ """Obtain the address family corresponding to this value."""
+ match self:
+ case IPVersion.IPV4:
+ return socket.AF_INET
+
+ case IPVersion.IPV6:
+ return socket.AF_INET6
+
+
+def get_listen_address(ip_version: IPVersion) -> tuple[str, set[int], socket.AddressFamily] | None:
+ """Get a loopback address for the specified address family, if any are configured."""
+ ifaces: Final = netifaces.interfaces()
+ if "lo" not in ifaces:
+ print("No 'lo' interface at all?!")
+ return None
+
+ family: Final = ip_version.family()
+ addrs: Final = netifaces.ifaddresses("lo")
+ candidates: Final = addrs.get(family)
+ if not candidates:
+ print("No addresses for the specified family on the 'lo' interface")
+ return None
+
+ return candidates[0]["addr"], ip_version.addr_len(), family
+
+
+def parse_args() -> Config | None:
+ """Parse the command-line arguments."""
+ parser: Final = argparse.ArgumentParser(prog="uctest")
+
+ parser.add_argument(
+ "-d",
+ "--bindir",
+ type=pathlib.Path,
+ required=True,
+ help="the path to the UCSPI utilities",
+ )
+ parser.add_argument(
+ "-i",
+ "--ip-version",
+ type=IPVersion,
+ default=IPVersion.IPV4,
+ help="the address family to listen on ('4' for IPv4, '6' for IPv6)",
+ choices=["4", "6"],
+ )
+ parser.add_argument(
+ "-p",
+ "--proto",
+ type=str,
+ required=True,
+ help="the UCSPI protocol ('tcp', 'unix', etc)",
+ )
+ args: Final = parser.parse_args()
+
+ listen_data: Final = get_listen_address(args.ip_version)
+ if listen_data is None:
+ return None
+
+ return Config(
+ bindir=args.bindir.absolute(),
+ listen_addr=listen_data[0],
+ listen_addr_len=listen_data[1],
+ listen_family=listen_data[2],
+ proto=args.proto,
+ utf8_env=utf8_locale.UTF8Detect().detect().env,
+ )
+
+
+def main() -> None:
+ """Parse command-line arguments, run the tests."""
+ cfg: Final = parse_args()
+ if cfg is None:
+ print("No loopback interface addresses for the requested family")
+ return
+
+ ucspi_test.add_handler("tcp", TcpRunner)
+ ucspi_test.run_test_handler(cfg)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/debian/tests/python/ucspi_tcp_test/py.typed b/debian/tests/python/ucspi_tcp_test/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/debian/tests/python/ucspi_tcp_test/py.typed
diff --git a/debian/tests/python/ucspi_test/__init__.py b/debian/tests/python/ucspi_test/__init__.py
new file mode 100644
index 0000000..6aac8a2
--- /dev/null
+++ b/debian/tests/python/ucspi_test/__init__.py
@@ -0,0 +1,514 @@
+"""Run a couple of UCSPI client and server tests."""
+
+from __future__ import annotations
+
+import abc
+import dataclasses
+import pathlib
+import shlex
+import subprocess # noqa: S404
+import sys
+import typing
+
+
+if typing.TYPE_CHECKING:
+ import socket
+ from collections.abc import Callable
+ from typing import Any, Final
+
+
+VERSION: Final = "0.2.0"
+
+MSG_RESP_HELLO: Final = "a01 hello\n"
+MSG_RESP_BYE: Final = "a02 bye\n"
+
+
+@dataclasses.dataclass(frozen=True)
+class Config:
+ """Runtime configuration for the UCSPI test runner."""
+
+ bindir: pathlib.Path
+ proto: str
+ utf8_env: dict[str, str]
+
+
+@dataclasses.dataclass
+class RunnerError(Exception):
+ """An error that occurred while preparing for or running the tests."""
+
+
+@dataclasses.dataclass
+class HandlerMismatchError(RunnerError):
+ """The test framework attempted to add a different handler."""
+
+ proto: str
+ current: type[Runner]
+ runner: type[Runner]
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return (
+ f"Handler mismatch for the {self.proto!r} protocol: "
+ "had {self.current!r}, now {self.runner!r}"
+ )
+
+
+@dataclasses.dataclass
+class SocketAddressLengthError(RunnerError):
+ """An address with an unexpected length was specified."""
+
+ proto: str
+ addr: Any
+
+ def __str__(self) -> str:
+ """Provide a human-readable error message."""
+ return f"{self.proto}.get_connected_socket(): unexpected address length for {self.addr!r}"
+
+
+class Runner(abc.ABC):
+ """A helper class for running tests for a single UCSPI protocol."""
+
+ _cfg: Config
+ _proto: str
+
+ def __init__(self, cfg: Config, proto: str) -> None:
+ """Store the configuration object."""
+ self._cfg = cfg
+ self._proto = proto
+
+ @property
+ def cfg(self) -> Config:
+ """Get the configuration for this runner."""
+ return self._cfg
+
+ @property
+ def proto(self) -> str:
+ """Get the name of the UCSPI protocol to test."""
+ return self._proto
+
+ @property
+ def supports_remote_info(self) -> bool:
+ """Whether the client and server support the -R command-line option."""
+ return True
+
+ @property
+ def logs_to_stdout(self) -> bool:
+ """Whether verbose output goes to the standard output stream (argh)."""
+ return False
+
+ @abc.abstractmethod
+ def find_listening_address(self) -> list[str]:
+ """Find an available protocol-specific address to listen on."""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def get_listening_socket(self, addr: list[str]) -> socket.socket:
+ """Start listening on the specified address."""
+ raise NotImplementedError(repr(addr))
+
+ @abc.abstractmethod
+ def get_connected_socket(self, addr: list[str]) -> socket.socket:
+ """Connect to the specified address."""
+ raise NotImplementedError(repr(addr))
+
+ @abc.abstractmethod
+ def format_local_addr(self, addr: list[str]) -> str:
+ """Format an address returned by accept(), etc."""
+ raise NotImplementedError(repr(addr))
+
+ @abc.abstractmethod
+ def format_remote_addr(self, addr: Any) -> str: # noqa: ANN401
+ """Format an address returned by accept(), etc."""
+ raise NotImplementedError(repr(addr))
+
+ def __repr__(self) -> str:
+ """Provide a Python-esque representation of the helper."""
+ return f"{type(self).__name__}(cfg={self._cfg!r}, proto={self._proto!r})"
+
+
+HANDLERS: dict[str, type[Runner]] = {}
+
+
+def with_listener(
+ runner: Runner,
+ addr: list[str],
+ tag: str,
+ callback: Callable[[socket.socket], None],
+) -> None:
+ """Get a listener socket from the runner, invoke the callback."""
+ proto: Final = runner.proto
+ saddr: Final = runner.format_local_addr(addr)
+ print(f"- {tag}: setting up a listening socket at {saddr}")
+ try:
+ listener: Final = runner.get_listening_socket(addr)
+ except RunnerError as err:
+ sys.exit(f"{tag}: could not get a {proto} listening socket: {err}")
+
+ try:
+ callback(listener)
+ finally:
+ try:
+ listener.close()
+ except OSError as err:
+ print(f"{tag}: could not close the listening socket at {saddr}: {err}", file=sys.stderr)
+
+
+def with_connect(
+ runner: Runner,
+ addr: list[str],
+ tag: str,
+ callback: Callable[[socket.socket], None],
+) -> None:
+ """Ask the runner to connect to the specified address, invoke the callback."""
+ proto: Final = runner.proto
+ saddr: Final = runner.format_local_addr(addr)
+ print(f"- {tag}: setting up a socket connected to {saddr}")
+ try:
+ conn: Final = runner.get_connected_socket(addr)
+ except RunnerError as err:
+ sys.exit(f"{tag}: could not get a {proto} connected socket: {err}")
+
+ try:
+ callback(conn)
+ finally:
+ try:
+ conn.close()
+ except OSError as err:
+ print(f"{tag}: could not close the socket connected to {saddr}: {err}", file=sys.stderr)
+
+
+def with_child(
+ runner: Runner,
+ tag: str,
+ cmd: list[str],
+ callback: Callable[[subprocess.Popen[str]], None],
+ stderr: int | None = None,
+) -> None:
+ """Spawn a child process, invoke the callback."""
+ print(f"- {tag}: starting {shlex.join(cmd)}")
+ try:
+ with subprocess.Popen(
+ cmd, # noqa: S603
+ bufsize=0,
+ encoding="UTF-8",
+ env=runner.cfg.utf8_env,
+ stdout=subprocess.PIPE,
+ stderr=stderr,
+ ) as child:
+ try:
+ callback(child)
+ finally:
+ if child.poll() is None:
+ ptag: Final = f"the {child.pid} ({cmd[0]}) process"
+ print(f"- {tag}: {ptag} is still active, killing it")
+ try:
+ child.kill()
+ except OSError as err:
+ print(
+ f"{tag}: could not send a kill signal to {ptag}: {err}",
+ file=sys.stderr,
+ )
+ print(f"- {tag}: waiting for {ptag} to go away")
+ try:
+ child.wait()
+ except OSError as err:
+ print(f"{tag}: could not wait for {ptag} to end: {err}", file=sys.stderr)
+ except (OSError, subprocess.CalledProcessError) as err:
+ sys.exit(f"Could not spawn {shlex.join(cmd)}: {err}")
+
+
+def test_local_spew(runner: Runner, addr: list[str], tag: str, cmd: list[str]) -> None:
+ """Test a client program against our listening socket, unidirectional transfer."""
+
+ def run(listener: socket.socket, catproc: subprocess.Popen[str]) -> None: # noqa: PLR0912
+ """Run the spew test itself."""
+ saddr: Final = runner.format_local_addr(addr)
+ print(f"- started the client as pid {catproc.pid}")
+ print(f"- waiting for a connection at {saddr}")
+ try:
+ conn, rem_addr = listener.accept()
+ except OSError as err:
+ sys.exit(f"Could not accept an incoming connection at {saddr}: {err}")
+ print(
+ f"- accepted a connection at fd {conn.fileno()} from "
+ f"{runner.format_remote_addr(rem_addr)}",
+ )
+
+ msg: Final = MSG_RESP_HELLO + MSG_RESP_BYE
+ try:
+ conn.sendall(msg.encode("UTF-8"))
+ except OSError as err:
+ sys.exit(f"Could not send a message on the {conn} connection at {saddr}: {err}")
+
+ print("- closing the incoming connection")
+ try:
+ conn.close()
+ except OSError as err:
+ sys.exit(f"Could not close the {conn} connection at {saddr}: {err}")
+
+ try:
+ output, _ = catproc.communicate()
+ except OSError as err:
+ sys.exit(f"Could not read the output of the {cmd[0]} process: {err}")
+ if not isinstance(output, str):
+ raise TypeError(repr(output))
+
+ try:
+ res: Final = catproc.wait()
+ except OSError as err:
+ sys.exit(f"Could not wait for the {cmd[0]} process to end: {err}")
+ print(f"- client exit code: {res}; output: {output!r}")
+ if res != 0:
+ sys.exit(f"The {cmd[0]} program exited with non-zero code {res}")
+
+ if output != msg:
+ sys.exit(f"Expected {msg!r} as {cmd[0]} output, got {catproc.stdout!r}")
+
+ with_listener(
+ runner,
+ addr,
+ tag,
+ lambda listener: with_child(runner, tag, cmd, lambda catproc: run(listener, catproc)),
+ )
+ print(f"- {cmd[0]} seems fine")
+
+
+def test_local_cat(runner: Runner, addr: list[str]) -> None:
+ """Test the {proto}cat program."""
+ proto: Final = runner.proto
+ print(f"\n=== Testing {proto}cat")
+ if runner.cfg.bindir != pathlib.Path("/usr/bin"):
+ print(
+ f"- test skipped, {proto}cat will probably not find {runner.cfg.bindir}/{proto}client",
+ )
+ return
+
+ catpath: Final = runner.cfg.bindir / f"{proto}cat"
+ print(f"- will spawn {proto}cat at {catpath} in a while")
+ test_local_spew(runner, addr, "test_local_cat", [str(catpath), *addr])
+
+
+def remote_opt(runner: Runner) -> list[str]:
+ """Add the -R command-line option if the client and server support it."""
+ return ["-R"] if runner.supports_remote_info else []
+
+
+def test_local_client_spew(runner: Runner, addr: list[str]) -> None:
+ """Test {proto}client against our own listening socket."""
+ proto: Final = runner.proto
+ print(f"\n=== Testing {proto}client against our own listening socket")
+
+ clipath: Final = runner.cfg.bindir / f"{proto}client"
+ print(f"- will spawn {proto}client at {clipath} in a while")
+ test_local_spew(
+ runner,
+ addr,
+ "test_local_client_spew",
+ [str(clipath), *remote_opt(runner), *addr, "sh", "-c", "set -e; exec <&6; exec cat"],
+ )
+
+
+def test_server_local(runner: Runner, addr: list[str]) -> None: # noqa: C901,PLR0915
+ """Test {proto}server against our own client socket."""
+
+ def run(srvproc: subprocess.Popen[str], conn: socket.socket) -> None: # noqa: PLR0912
+ """Run the test itself."""
+ try:
+ rem_addr: Final = conn.getpeername()
+ except OSError as err:
+ sys.exit(f"getpeername() failed for {conn!r}: {err}")
+ raddr: Final = runner.format_remote_addr(rem_addr)
+ print(f"- got a connection at fd {conn.fileno()} to {raddr}")
+
+ print("- reading all the data we can")
+ data = b""
+ while True:
+ try:
+ chunk = conn.recv(4096)
+ except OSError as err:
+ sys.exit(f"Could not read from the {conn!r} socket: {err}")
+ print(f"- read {len(chunk)} bytes from the socket")
+ if not chunk:
+ break
+ data += chunk
+ print(f"- read a total of {len(data)} bytes from the socket")
+
+ try:
+ output: Final = data.decode("UTF-8")
+ except ValueError as err:
+ sys.exit(f"Could not decode {data!r} as valid UTF-8: {err}")
+ if output != message:
+ sys.exit(f"Expected {message!r}, got {output!r}")
+
+ if srvproc.poll() is not None:
+ sys.exit(
+ f"Did not expect the {proto}server process to have exited: "
+ f"code {srvproc.returncode}",
+ )
+ print(f"- sending a SIGTERM signal to the {proto}server process")
+ try:
+ srvproc.terminate()
+ except OSError as err:
+ sys.exit(
+ f"Could not send a SIGTERM signal to the {proto}server process at "
+ f"{srvproc.pid}: {err}",
+ )
+ print(f"- waiting for the {proto}server process to exit")
+ res: Final = srvproc.wait()
+ if res != 0:
+ sys.exit(f"The {proto}server process exited with a non-zero code {res}")
+
+ def wait_and_connect(srvproc: subprocess.Popen[str]) -> None:
+ """Wait for the "ready" message from tcpserver."""
+ stream: Final = srvproc.stdout if runner.logs_to_stdout else srvproc.stderr
+ assert stream is not None, repr(srvproc) # noqa: S101 # mypy needs this
+ print(f"- awaiting the first 'status' line from {proto}server")
+ line: Final = stream.readline()
+ if "server: status: 0/" not in line:
+ sys.exit(
+ f"Unexpected first line from {proto}server: expected 'status: 0/N', got {line!r}",
+ )
+ with_connect(runner, addr, "test_server_local", lambda conn: run(srvproc, conn))
+
+ proto: Final = runner.proto
+ print(f"\n=== Testing {proto}server against our own listening socket")
+
+ srvpath: Final = runner.cfg.bindir / f"{proto}server"
+ print(f"- will spawn {proto}server at {srvpath} in a while")
+ message: Final = MSG_RESP_HELLO + MSG_RESP_BYE
+ with_child(
+ runner,
+ "test_server_local",
+ [
+ "stdbuf",
+ "-oL",
+ "-eL",
+ "--",
+ str(srvpath),
+ "-v",
+ *remote_opt(runner),
+ *addr,
+ "printf",
+ "--",
+ message.replace("\n", "\\n"),
+ ],
+ wait_and_connect,
+ stderr=None if runner.logs_to_stdout else subprocess.PIPE,
+ )
+
+ print(f"- test_server_local: {srvpath} seems fine")
+
+
+def test_server_client_spew(runner: Runner, addr: list[str]) -> None: # noqa: C901
+ """Test {proto}server against {proto}client, unidirectional data transfer."""
+
+ def run(srvproc: subprocess.Popen[str], cliproc: subprocess.Popen[str]) -> None:
+ """Read the data received by the client."""
+ try:
+ output, _ = cliproc.communicate()
+ except OSError as err:
+ sys.exit(f"Could not read the output of the {proto}client process: {err}")
+ res_cli: Final = cliproc.poll()
+ print(f"- client exit code {res_cli}; output {output!r}")
+ if res_cli is None:
+ sys.exit(f"Expected the {proto}client process to be done by now")
+ if res_cli != 0:
+ sys.exit(f"The {proto}client process exited with a non-zero code {res_cli}")
+ if output != message:
+ sys.exit(f"Expected {message!r}, got {output!r}")
+
+ if srvproc.poll() is not None:
+ sys.exit(
+ f"Did not expect the {proto}server process to have exited: "
+ f"code {srvproc.returncode}",
+ )
+ print(f"- sending a SIGTERM signal to the {proto}server process")
+ try:
+ srvproc.terminate()
+ except OSError as err:
+ sys.exit(
+ f"Could not send a SIGTERM signal to the {proto}server process at "
+ f"{srvproc.pid}: {err}",
+ )
+ print(f"- waiting for the {proto}server process to exit")
+ res_srv: Final = srvproc.wait()
+ if res_srv != 0:
+ sys.exit(f"The {proto}server process exited with a non-zero code {res_srv}")
+
+ def wait_and_connect(srvproc: subprocess.Popen[str]) -> None:
+ """Wait for the "ready" message from tcpserver."""
+ stream: Final = srvproc.stdout if runner.logs_to_stdout else srvproc.stderr
+ assert stream is not None, repr(srvproc) # noqa: S101 # mypy needs this
+ print(f"- awaiting the first 'status' line from {proto}server")
+ line: Final = stream.readline()
+ if "server: status: 0/" not in line:
+ sys.exit(
+ f"Unexpected first line from {proto}server: expected 'status: 0/N', got {line!r}",
+ )
+ with_child(
+ runner,
+ "test_server_client_spew",
+ [str(clipath), *remote_opt(runner), *addr, "sh", "-c", "set -e; exec <&6; exec cat"],
+ lambda cliproc: run(srvproc, cliproc),
+ )
+
+ proto: Final = runner.proto
+ print(f"\n=== Testing {proto}server against {proto}client")
+
+ srvpath: Final = runner.cfg.bindir / f"{proto}server"
+ print(f"- will spawn {proto}server at {srvpath} in a while")
+ clipath: Final = runner.cfg.bindir / f"{proto}client"
+ print(f"- will spawn {proto}client at {clipath} in a while")
+ message: Final = MSG_RESP_HELLO + MSG_RESP_BYE
+ with_child(
+ runner,
+ "test_server_client_spew",
+ [
+ "stdbuf",
+ "-oL",
+ "-eL",
+ "--",
+ str(srvpath),
+ "-v",
+ *remote_opt(runner),
+ *addr,
+ "printf",
+ "--",
+ message.replace("\n", "\\n"),
+ ],
+ wait_and_connect,
+ stderr=None if runner.logs_to_stdout else subprocess.PIPE,
+ )
+
+ print(f"- {srvpath} seems fine")
+
+
+def run_test(runner: Runner) -> None:
+ """Run a couple of UCSPI tests."""
+ addr: Final = runner.find_listening_address()
+
+ test_local_cat(runner, addr)
+ test_local_client_spew(runner, addr)
+
+ test_server_local(runner, addr)
+ test_server_client_spew(runner, addr)
+
+ print(f"\n=== The tests for {runner.cfg.proto} passed")
+
+
+def add_handler(proto: str, runner: type[Runner]) -> None:
+ """Add a UCSPI protocol test runner."""
+ current: Final = HANDLERS.get(proto)
+ if current is None:
+ HANDLERS[proto] = runner
+ elif current != runner:
+ raise HandlerMismatchError(proto, current, runner)
+
+
+def run_test_handler(cfg: Config) -> None:
+ """Parse command-line arguments, run the tests."""
+ hprot: Final = HANDLERS.get(cfg.proto)
+ if hprot is None:
+ sys.exit(f"Don't know how to test the {cfg.proto!r} UCSPI protocol")
+
+ run_test(hprot(cfg, cfg.proto))
diff --git a/debian/tests/python/ucspi_test/py.typed b/debian/tests/python/ucspi_test/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/debian/tests/python/ucspi_test/py.typed
diff --git a/debian/tests/requirements/install.txt b/debian/tests/requirements/install.txt
new file mode 100644
index 0000000..620bac7
--- /dev/null
+++ b/debian/tests/requirements/install.txt
@@ -0,0 +1,2 @@
+netifaces >= 0.10, < 1
+utf8-locale >= 1, < 2
diff --git a/debian/tests/requirements/ruff.txt b/debian/tests/requirements/ruff.txt
new file mode 100644
index 0000000..ba884d9
--- /dev/null
+++ b/debian/tests/requirements/ruff.txt
@@ -0,0 +1 @@
+ruff == 0.2.2
diff --git a/debian/tests/stubs/netifaces.pyi b/debian/tests/stubs/netifaces.pyi
new file mode 100644
index 0000000..6d8afcd
--- /dev/null
+++ b/debian/tests/stubs/netifaces.pyi
@@ -0,0 +1,15 @@
+import socket
+
+from typing import TypedDict
+
+# Ah well, we can't use NotRequired with Python 3.10, can we now...
+class AddressDict(TypedDict):
+ addr: str
+ broadcast: str
+ netmask: str
+ peer: str
+
+
+def interfaces() -> list[str]: ...
+
+def ifaddresses(iface: str) -> dict[socket.AddressFamily, list[AddressDict]]: ...
diff --git a/debian/tests/tox.ini b/debian/tests/tox.ini
new file mode 100644
index 0000000..44143ac
--- /dev/null
+++ b/debian/tests/tox.ini
@@ -0,0 +1,67 @@
+[tox]
+envlist =
+ format
+ ruff
+ mypy
+ functional
+isolated_build = True
+
+[defs]
+pyfiles =
+ python/ucspi_test \
+ python/ucspi_tcp_test
+
+[testenv:format]
+skip_install = True
+tags =
+ check
+ quick
+deps =
+ -r {toxinidir}/requirements/ruff.txt
+commands =
+ ruff check --config config/ruff/base.toml --select=D,I --diff -- {[defs]pyfiles}
+ ruff format --check --config config/ruff/base.toml --diff -- {[defs]pyfiles}
+
+[testenv:reformat]
+skip_install = True
+tags =
+ format
+ manual
+deps =
+ -r {toxinidir}/requirements/ruff.txt
+commands =
+ ruff check --config config/ruff/base.toml --select=D,I --fix -- {[defs]pyfiles}
+ ruff format --config config/ruff/base.toml -- {[defs]pyfiles}
+
+[testenv:ruff]
+skip_install = True
+tags =
+ check
+ ruff
+ quick
+deps =
+ -r requirements/ruff.txt
+commands =
+ ruff check --config config/ruff/all.toml --output-format=concise -- {[defs]pyfiles}
+
+[testenv:mypy]
+skip_install = True
+tags =
+ check
+deps =
+ -r {toxinidir}/requirements/install.txt
+ mypy >= 1, < 2
+setenv =
+ MYPYPATH = {toxinidir}/stubs
+commands =
+ mypy {[defs]pyfiles}
+
+[testenv:functional]
+tags =
+ tests
+deps =
+ -r {toxinidir}/requirements/install.txt
+setenv =
+ UCBINDIR = {env:UCBINDIR:{toxinidir}/../..}
+commands =
+ uctest_tcp -d '{env:UCBINDIR}' -p tcp
diff --git a/debian/ucspi-tcp-ipv6.install b/debian/ucspi-tcp-ipv6.install
new file mode 100644
index 0000000..2461dc6
--- /dev/null
+++ b/debian/ucspi-tcp-ipv6.install
@@ -0,0 +1 @@
+debian/contrib/httptaild usr/share/doc/ucspi-tcp-ipv6/contrib
diff --git a/debian/ucspi-tcp-man/rblsmtpd.1 b/debian/ucspi-tcp-man/rblsmtpd.1
index 9d9ca08..ca73a44 100644
--- a/debian/ucspi-tcp-man/rblsmtpd.1
+++ b/debian/ucspi-tcp-man/rblsmtpd.1
@@ -84,9 +84,7 @@ and
.B \-a
options.
.B rblsmtpd
-tries each source in turn until it finds one that lists or anti-lists $TCPREMOTEIP. It also tries an RBL source of rbl.maps.vix.com if you do not supply any
-.B -r
-options. See http://maps.vix.com/rbl/ for more information about rbl.maps.vix.com.
+tries each source in turn until it finds one that lists or anti-lists $TCPREMOTEIP.
If you want to run your own RBL source or anti-RBL source for
.BR rblsmtpd ,
diff --git a/debian/ucspi-tcp.install b/debian/ucspi-tcp.install
new file mode 100644
index 0000000..4a66742
--- /dev/null
+++ b/debian/ucspi-tcp.install
@@ -0,0 +1 @@
+debian/contrib/httptaild usr/share/doc/ucspi-tcp/contrib