* sig = \x0D \x0A \x0D \x0A \x00 \x0D \x0A \x51 \x55 \x49 \x54 \x0A
@@ -948,9 +981,21 @@
apr_status_t ret;
switch (hdr->v2.ver_cmd & 0xF) {
+ case 0x00: /* LOCAL command */
+ /* keep local connection address for LOCAL */
+ conn_conf->client_addr = c->client_addr;
+ conn_conf->client_ip = c->client_ip;
+ return HDR_DONE;
case 0x01: /* PROXY command */
switch (hdr->v2.fam) {
case 0x11: /* TCPv4 */
+ if (remoteip_get_v2_len(hdr) < sizeof(hdr->v2.addr.ip4)) {
+ ap_log_cerror(APLOG_MARK, APLOG_ERR, 0, c, APLOGNO(10597)
+ "RemoteIPProxyProtocol: address length "
+ "%" APR_SIZE_T_FMT " too short for TCPv4",
+ remoteip_get_v2_len(hdr));
+ return HDR_ERROR;
+ }
ret = apr_sockaddr_info_get(&conn_conf->client_addr, NULL,
APR_INET,
ntohs(hdr->v2.addr.ip4.src_port),
@@ -968,6 +1013,13 @@
case 0x21: /* TCPv6 */
#if APR_HAVE_IPV6
+ if (remoteip_get_v2_len(hdr) < sizeof(hdr->v2.addr.ip6)) {
+ ap_log_cerror(APLOG_MARK, APLOG_ERR, 0, c, APLOGNO(10598)
+ "RemoteIPProxyProtocol: address length "
+ "%" APR_SIZE_T_FMT " too short for TCPv6",
+ remoteip_get_v2_len(hdr));
+ return HDR_ERROR;
+ }
ret = apr_sockaddr_info_get(&conn_conf->client_addr, NULL,
APR_INET6,
ntohs(hdr->v2.addr.ip6.src_port),
@@ -1014,12 +1066,6 @@
return HDR_DONE;
}
-/** Return length for a v2 protocol header. */
-static apr_size_t remoteip_get_v2_len(proxy_header *hdr)
-{
- return ntohs(hdr->v2.len);
-}
-
/** Determine if this is a v1 or v2 PROXY header.
*/
static int remoteip_determine_version(conn_rec *c, const char *ptr)
@@ -1088,6 +1134,14 @@
apr_off_t got, want = ctx->need - ctx->rcvd;
ret = ap_get_brigade(f->next, ctx->bb, ctx->mode, block, want);
+ if (APR_STATUS_IS_EOF(ret)) {
+ /* The peer went away before sending a whole header, as a
+ * health check may. */
+ ap_log_cerror(APLOG_MARK, APLOG_INFO, ret, f->c, APLOGNO(10634)
+ "RemoteIPProxyProtocol: connection closed "
+ "before a complete header was received");
+ return ret;
+ }
if (ret != APR_SUCCESS) {
ap_log_cerror(APLOG_MARK, APLOG_ERR, ret, f->c, APLOGNO(10184)
"failed reading input");
@@ -1111,6 +1165,13 @@
while (!ctx->done && !APR_BRIGADE_EMPTY(ctx->bb)) {
b = APR_BRIGADE_FIRST(ctx->bb);
+ if (APR_BUCKET_IS_METADATA(b)) {
+ /* Nothing to copy from EOS or FLUSH; EOF is reported by
+ * the next read. */
+ apr_bucket_delete(b);
+ continue;
+ }
+
ret = apr_bucket_read(b, &ptr, &len, block);
if (APR_STATUS_IS_EAGAIN(ret) && block == APR_NONBLOCK_READ) {
return APR_SUCCESS;
@@ -1228,11 +1289,11 @@
"Specifies one or more internal (transparent) proxies "
"which are trusted to present IP headers"),
AP_INIT_TAKE1("RemoteIPTrustedProxyList", proxylist_read, 0,
- RSRC_CONF | EXEC_ON_READ,
+ RSRC_CONF,
"The filename to read the list of trusted proxies, "
"see the RemoteIPTrustedProxy directive"),
AP_INIT_TAKE1("RemoteIPInternalProxyList", proxylist_read, (void*)1,
- RSRC_CONF | EXEC_ON_READ,
+ RSRC_CONF,
"The filename to read the list of internal proxies, "
"see the RemoteIPInternalProxy directive"),
AP_INIT_FLAG("RemoteIPProxyProtocol", remoteip_enable_proxy_protocol, NULL,
diff -Nru apache2-2.4.69/support/ab.c apache2-2.4.71/support/ab.c
--- apache2-2.4.69/support/ab.c 2026-06-04 08:50:44.000000000 +0000
+++ apache2-2.4.71/support/ab.c 2026-11-09 03:07:05.000000000 +0000
@@ -2128,14 +2128,14 @@
static void copyright(void)
{
if (!use_html) {
- printf("This is ApacheBench, Version %s\n", AP_AB_BASEREVISION " <$Revision: 1934973 $>");
+ printf("This is ApacheBench, Version %s\n", AP_AB_BASEREVISION " <$Revision$>");
printf("Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/\n");
printf("Licensed to The Apache Software Foundation, http://www.apache.org/\n");
printf("\n");
}
else {
printf("\n");
- printf(" This is ApacheBench, Version %s <%s>
\n", AP_AB_BASEREVISION, "$Revision: 1934973 $");
+ printf(" This is ApacheBench, Version %s <%s>
\n", AP_AB_BASEREVISION, "$Revision$");
printf(" Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
\n");
printf(" Licensed to The Apache Software Foundation, http://www.apache.org/
\n");
printf("
\n\n");
diff -Nru apache2-2.4.69/test/.gitignore apache2-2.4.71/test/.gitignore
--- apache2-2.4.69/test/.gitignore 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/.gitignore 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,2 @@
+config.ini
+gen
diff -Nru apache2-2.4.69/test/GitHub-CI-Emu.md apache2-2.4.71/test/GitHub-CI-Emu.md
--- apache2-2.4.69/test/GitHub-CI-Emu.md 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/GitHub-CI-Emu.md 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,296 @@
+# GitHub CI emulation for Apache httpd (act + Docker)
+
+Run the Linux jobs of httpd's GitHub Actions CI workflow
+(`.github/workflows/linux.yml`) on your own machine, for any commit of trunk or
+2.4.x. Use it to check changes to the workflow, `test/travis_run_linux.sh`,
+`Makefile.in` or `test/` before pushing, to reproduce a CI failure with a
+shell in the same environment, or when the hosted Linux queue is long.
+
+You need `act`, Docker and a git checkout of httpd. It was written and tested
+against an arm64 Linux Docker engine. The scripts pick the container
+architecture from `uname -m`, but nothing has been run on x86_64, Windows or
+WSL. On Windows, work from a WSL shell.
+
+## How it works
+
+* [act](https://github.com/nektos/act) reads the workflow and runs one job in a
+ Docker container built from an image that imitates GitHub's `ubuntu-24.04`
+ runner.
+* `prepare.sh` exports a committed ref of your checkout (`git archive`) into a
+ scratch tree, `$CI_EMU/src`, and patches that copy in four small ways so the
+ workflow runs under act (see "Deviations"). Your checkout is not touched.
+ Uncommitted changes are not tested; commit them first, on a throw-away branch
+ if you like.
+* `run-job.sh ""` runs one entry of the `build` job's matrix with
+ the packages, `configure` flags, environment and `test/travis_run_linux.sh`
+ from the workflow.
+
+Not covered: the other workflows (`windows.yml`, `sanity.yml`) and the GitHub
+caches (APR and httpd are built from scratch each run). `upload-artifact` fails
+under act with "Unable to get the ACTIONS_RUNTIME_TOKEN env variable"; ignore
+it.
+
+## Differences from the hosted runner
+
+| | GitHub runner | Here |
+|---|---|---|
+| CPU | x86_64; arm64 only for the "64-bit ARM" entries | the architecture of your Docker engine |
+| User | `runner`, with sudo | the same (created in the image) |
+| PID 1 | init | `docker --init` |
+| `$GITHUB_ENV` in step `if:` | honoured | not honoured (deviation 2) |
+| `go`, for the pebble step | present | absent unless you add it (see Dockerfile) |
+
+Matrix entries with `os: ubuntu-24.04-arm` are not mapped by
+`-P ubuntu-latest=...`, so run the entry of the same name without `, 64-bit
+ARM`; the container runs on your engine's architecture anyway.
+
+## Prerequisites
+
+* Docker (Engine or Desktop) with the daemon running; `docker info` must work
+ for your user.
+* `act`: from your package manager, a release binary from
+ , or `gh extension install
+ https://github.com/nektos/gh-act`. Tested with 0.2.90.
+* `git`, `tar`, `perl`, a POSIX shell. `gh` is optional (comparing with real
+ runs).
+* Network access from the container (apt, GitHub, Go modules).
+* About 2 GB for the image, plus the container and the scratch tree.
+
+## Setting it up
+
+Pick a directory for the harness. The scripts read both paths from the
+environment:
+
+ export CI_EMU=$HOME/ci-emul # scripts, scratch tree, logs
+ export REPO=$HOME/src/httpd # the httpd git checkout
+ mkdir -p "$CI_EMU/out" && cd "$CI_EMU"
+
+### 1. The runner image
+
+ docker pull ghcr.io/catthehacker/ubuntu:act-24.04
+
+The image is on ghcr.io (not Docker Hub) and has amd64 and arm64 variants. If
+`DOCKER_DEFAULT_PLATFORM` is set in your environment to another architecture,
+unset it or add `--platform` to the pull.
+
+`Dockerfile.act` adds what the hosted image has and the act one lacks:
+
+```dockerfile
+FROM ghcr.io/catthehacker/ubuntu:act-24.04
+# The hosted image has apt assume "yes"; this one does not.
+RUN echo 'APT::Get::Assume-Yes "true";' > /etc/apt/apt.conf.d/90assumeyes
+# linux.yml picks /usr/sbin/php-fpm*, which the hosted image ships.
+RUN apt-get update && apt-get install -y php8.3-fpm golang-go && rm -rf /var/lib/apt/lists/*
+# httpd's default "User #-1" fails as root, and the hosted runner is not root.
+RUN useradd -m -u 1001 -s /bin/bash runner \
+ && echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner
+```
+
+`golang-go` is for the "Install pebble" step of the `TEST_MD` jobs. Without
+`go` that step only warns ("pebble install failed; modules/md tests will be
+skipped") and every `modules/md` test skips. The distribution's Go is older
+than the Go 1.24 that pebble v2 needs; `go install` fetches the toolchain
+itself. Installing `golang-go` by hand in a running container worked. The
+Dockerfile line has not been built.
+
+ docker build -f Dockerfile.act -t act-ubuntu-24.04-ci:local .
+
+### 2. `prepare.sh`
+
+```sh
+#!/bin/sh
+# usage: prepare.sh [ref] (default HEAD of $REPO; a branch, tag or sha)
+set -e
+REPO=${REPO:?set REPO to the httpd git checkout}
+CI_EMU=${CI_EMU:-$(cd "$(dirname "$0")" && pwd)}
+ref=${1:-HEAD}
+src=$CI_EMU/src
+git -C "$REPO" rev-parse --verify -q "$ref^{commit}" >/dev/null \
+ || { echo "prepare.sh: no such commit in $REPO: $ref" >&2; exit 2; }
+/bin/rm -rf "$src"; mkdir -p "$src"
+git -C "$REPO" archive "$ref" | tar -x -C "$src"
+cd "$src"
+
+# 1: /etc/hosts is a bind mount in Docker; "sed -i" cannot rename over it
+perl -pi -e 's|^(\s*)sudo sed -i "/ip6-/d" /etc/hosts|$1sudo sh -c "sed /ip6-/d /etc/hosts > /tmp/hosts.new \&\& cat /tmp/hosts.new > /etc/hosts"|' \
+ test/travis_before_linux.sh
+grep -q 'hosts.new' test/travis_before_linux.sh || { echo "fix 1 did not apply" >&2; exit 1; }
+
+# 2: act does not feed $GITHUB_ENV into step "if:" expressions
+perl -pi -e 's/if: \$\{\{ ! \( env\.SKIP_TESTING \|\| env\.NO_TEST_FRAMEWORK \) \}\}/if: \${{ ! ( contains(matrix.env, \x27SKIP_TESTING\x27) || contains(matrix.env, \x27NO_TEST_FRAMEWORK\x27) ) }}/' \
+ .github/workflows/linux.yml
+grep -q "contains(matrix.env, 'NO_TEST_FRAMEWORK')" .github/workflows/linux.yml \
+ || { echo "fix 2 did not apply" >&2; exit 1; }
+
+# 3: act's $GITHUB_ENV parser rejects the trailing blank line of a multi-line matrix.env
+perl -pi -e 's/echo "\$\{\{ matrix\.env \}\}" >> \$GITHUB_ENV/echo "\${{ matrix.env }}" | sed "\/^\$\/d" >> \$GITHUB_ENV/' \
+ .github/workflows/linux.yml
+grep -q 'sed "/^\$/d" >> \$GITHUB_ENV' .github/workflows/linux.yml \
+ || { echo "fix 3 did not apply" >&2; exit 1; }
+
+# 4: -f matters; .gitignore lists /build/PrintPath but it is tracked upstream
+git init -q; git add -Af >/dev/null
+git -c user.name=ci -c user.email=ci@example.com commit -qm "emulation snapshot of $ref"
+echo "prepared $ref in $src"
+```
+
+### 3. `run-job.sh`
+
+```sh
+#!/bin/sh
+# usage: run-job.sh 'Python pytest test suites'
+CI_EMU=${CI_EMU:-$(cd "$(dirname "$0")" && pwd)}
+cd "$CI_EMU/src" || exit 1
+case $(uname -m) in
+ x86_64|amd64) arch=linux/amd64 ;;
+ *) arch=linux/arm64 ;;
+esac
+# Keep the previous run's container (as -prev) rather than deleting it.
+if docker inspect httpd-github-ci >/dev/null 2>&1; then
+ docker rm -f httpd-github-ci-prev >/dev/null 2>&1
+ docker rename httpd-github-ci httpd-github-ci-prev
+fi
+act push -W .github/workflows/linux.yml -j build \
+ --matrix "name:${1:-Python pytest test suites}" \
+ -P ubuntu-latest=act-ubuntu-24.04-ci:local --pull=false \
+ --container-architecture "$arch" \
+ --container-options "--init --user runner" --reuse
+rc=$?
+# act cannot name the container, so rename it for later inspection.
+c=$(docker ps -a --format '{{.Names}}' | grep '^act-' | head -1)
+[ -n "$c" ] && docker rename "$c" httpd-github-ci && echo "container renamed: $c -> httpd-github-ci"
+exit $rc
+```
+
+* `-P ubuntu-latest=...` maps the runner label to the local image;
+ `--pull=false` stops act looking for it on a registry.
+* `--init` gives the container a real PID 1, so orphaned CGI processes are
+ reaped; `--user runner` runs the job as the non-root user from the image.
+* `--reuse` keeps the container after a successful run (a failed job's
+ container is kept as well, unless `--rm` is given).
+
+`chmod +x prepare.sh run-job.sh`.
+
+## Running a job
+
+ cd "$CI_EMU"
+ ./prepare.sh origin/2.4.x # or trunk, a branch, a tag, a sha
+ ./run-job.sh 'Python pytest test suites' > out/job.log 2>&1
+
+* The argument is the `name:` of a matrix entry of the `build` job; list them:
+ `grep -n '^ - name:' src/.github/workflows/linux.yml`.
+* Re-run `prepare.sh` after every commit you want tested; the job sees the
+ last snapshot only.
+* A full pytest job takes roughly 15 to 30 minutes (dependencies, APR, httpd,
+ the suites). `tail -f out/job.log` shows progress; a log that stops growing
+ while no `act` process is running means the job died.
+
+### Narrowing a run
+
+`travis_run_linux.sh` passes `PYTEST_ARGS` to `make check-all-pytest`: test
+paths relative to `test/` and any pytest options (see `test/README.ci`). To use
+it, after `prepare.sh` add a line to the `env:` block of the matrix entry in
+`$CI_EMU/src/.github/workflows/linux.yml`, for example after `TEST_MD=1`:
+
+ PYTEST_ARGS=modules/md/test_310_conf_store.py -v
+
+The value may contain spaces. `prepare.sh` regenerates the file, so this is
+not permanent. The build still runs in full; for fast repeat runs of the same
+build use the kept container, below.
+
+### Reading the result
+
+ grep -n -E '�|�|Job succeeded|Job failed|[0-9]+ passed|[0-9]+ failed|error in' out/job.log
+
+The `Build and test` step's pytest summary (`N passed, M skipped, ...`) is the
+line to compare with GitHub. Check that suites you care about did not skip: a
+`::warning::pebble install failed` line means `modules/md` did not run.
+
+## Inspecting and debugging in the kept container
+
+The container is kept as `httpd-github-ci`, the run before it as
+`httpd-github-ci-prev`. Its workspace is a Docker volume that act filled with a
+copy of `$CI_EMU/src`, mounted at the same absolute path. The build (`check/`)
+is in it, and editing files on the host does not change it. If Docker was
+restarted, run `docker start httpd-github-ci` first.
+
+ docker exec -it -u runner -w "$CI_EMU/src" httpd-github-ci bash
+
+Run the pytest suites again against the existing build, without rebuilding:
+
+ docker exec -u runner -w "$CI_EMU/src" httpd-github-ci bash -c '
+ export PATH=$HOME/go/bin:$HOME/.local/bin:$PATH \
+ PYHTTPD_CONFIG='"$CI_EMU"'/src/check/config.ini
+ test/runtests.sh --apxs='"$CI_EMU"'/src/check/bin/apxs \
+ --php-fpm=/usr/sbin/php-fpm8.3 modules/md/test_310_conf_store.py -q'
+
+Tools missing from the image can be installed in the container with `sudo
+apt-get install` (for example `gdb`, to take `thread apply all bt` of a hung
+httpd child); they are lost when the container is removed. `docker cp` puts a
+changed file into the container.
+
+## Deviations, and why
+
+`prepare.sh` applies these to the scratch copy only. It stops if one does not
+apply, which is how a workflow change that breaks one gets noticed.
+
+1. `/etc/hosts` is a bind mount in Docker, so `sed -i`, which renames a new
+ file over it, fails. It is rewritten in place instead.
+2. act does not feed `$GITHUB_ENV` into step `if:` conditions, so a step that
+ GitHub skips for `NO_TEST_FRAMEWORK` jobs would run. The condition tests
+ `matrix.env` instead.
+3. act's `$GITHUB_ENV` parser rejects the blank line a multi-line `matrix.env`
+ ends with and drops all of the job's variables, `TEST_*` included. Blank
+ lines are filtered out of the write.
+4. act copies the workspace honouring `.gitignore`, which lists
+ `/build/PrintPath` although the file is tracked. Without it `configure`
+ finds no perl and `apxs` gets a placeholder shebang. `git add -Af` in the
+ snapshot commit includes it.
+
+The image, not `prepare.sh`, supplies php-fpm, the non-root `runner` user and
+(optionally) Go.
+
+## Pitfalls
+
+* `Cannot connect to the Docker daemon` / `failed to connect to the docker
+ API`: the daemon is not running or your user may not use it.
+* `No such image` for `catthehacker/ubuntu`: pull from `ghcr.io/...`, not Docker
+ Hub. An architecture mismatch error from act: see `DOCKER_DEFAULT_PLATFORM`
+ above, and `--container-architecture` in `run-job.sh`.
+* Every test fails with connection refused, or httpd exits at start: the job ran
+ as root. Keep `--user runner`.
+* `modules/md` tests all skip: no `go`, so no pebble.
+* Jobs that do not use `NO_TEST_FRAMEWORK` check out the Perl test framework
+ from GitHub and need network access.
+* A pytest run that was killed can leave httpd, php-fpm or CGI processes behind
+ in the kept container; the next run then fails on busy ports. Kill them or
+ start from a fresh container.
+
+## Comparing with real CI
+
+ gh run list --repo apache/httpd --workflow Linux --branch trunk --limit 5
+ gh run view --repo apache/httpd --json jobs \
+ --jq '.jobs[] | select(.conclusion=="failure") | [.databaseId,.name] | @tsv'
+ gh run view --repo apache/httpd --job --log-failed
+
+On a failed job the workflow uploads an `error_log-` artifact (httpd error
+logs and `config.log`):
+
+ gh api repos/apache/httpd/actions/runs//artifacts --jq '.artifacts[]|[.id,.name]|@tsv'
+ gh api repos/apache/httpd/actions/artifacts//zip > a.zip
+
+Unzip it into an empty directory of its own. The `modules/` suites reuse one
+log file that is truncated at every server start, so it holds only the last
+server instance; run the test in the kept container to see the others.
+
+The hosted result is the reference. A difference that the CPU architecture does
+not explain is worth chasing.
+
+## Files
+
+ $CI_EMU/
+ Dockerfile.act runner image additions
+ prepare.sh export a ref + deviations
+ run-job.sh act invocation, container rename
+ src/ the scratch tree act runs (regenerated)
+ out/ logs
diff -Nru apache2-2.4.69/test/README apache2-2.4.71/test/README
--- apache2-2.4.69/test/README 2026-07-18 15:32:22.000000000 +0000
+++ apache2-2.4.71/test/README 2026-11-09 03:07:05.000000000 +0000
@@ -6,91 +6,89 @@
Python / pytest test suites
===========================
-There are two complementary Python (pytest) test suites in this directory.
-Both run against an already-built httpd; they are independent of each other.
+There are two complementary Python (pytest) test suites in this directory,
+run as one pytest project against an already-built httpd.
pyhttpd / modules/ httpd's own test framework. Covers HTTP/2, mod_md
(ACME), HTTP/1, proxy and core behaviour. Drives the
- server with external clients (curl, nghttp, h2load) and
- is configured via pyhttpd/config.ini (generated by
- httpd's configure). Framework code lives in pyhttpd/;
- tests live in modules/{core,http1,http2,md,proxy}/.
+ server with external clients (curl, nghttp, h2load);
+ each test package writes the configuration it needs and
+ restarts the server. Framework code lives in pyhttpd/;
+ tests live in modules/{core,http1,http2,md,proxy,...}/.
pytest_suite/ A self-contained port of the classic Perl Apache::Test
suite (core HTTP, per-module tests, security/CVE
- regressions, SSL/TLS, PHP). Drives the server with an
- in-process Python HTTP client (httpx) and raw sockets.
- It is fully self-contained: it ships its own conf
- templates, document root, C test modules and helper
- framework under pytest_suite/, and reads nothing outside
- that directory. See pytest_suite/README.md for details,
+ regressions, SSL/TLS, PHP). One server with a static
+ configuration, driven by an in-process Python HTTP
+ client (httpx) and raw sockets. It ships its own conf
+ templates, document root and C test modules under
+ pytest_suite/. See pytest_suite/README.md for details,
including how to add new tests.
-The two suites coexist: pytest_suite/ has its own conftest.py and
-pyproject.toml, so pytest treats it as its own rootdir and does NOT pick up
-this directory's pyhttpd conftest.py. Run pytest_suite from inside
-pytest_suite/ (or via the unified runner below) -- do not point pytest at the
-top-level test/ directory expecting to run both at once.
-
-
-Unified runner: run-all-tests.sh
----------------------------------
-
-run-all-tests.sh runs BOTH suites against the same built httpd and reports a
-combined result. pytest_suite runs first, then the pyhttpd modules/ tests.
-
- ./run-all-tests.sh # run both suites (pytest_suite first)
- ./run-all-tests.sh --only=pysuite # only the pytest_suite tests
- ./run-all-tests.sh --only=pyhttpd # only the pyhttpd modules/ tests
- ./run-all-tests.sh --apxs=/path/to/apxs # point at a specific httpd build
- ./run-all-tests.sh -k status -v # flags pass through to BOTH suites
-
-How it finds the httpd build:
- * pytest_suite is located via apxs -- from --apxs, the $APXS environment
- variable, the "prefix" in pyhttpd/config.ini, or apxs on $PATH.
- * the pyhttpd tests use pyhttpd/config.ini (built by httpd's configure), via
- the system "pytest".
-
-Selecting tests:
- * Flag arguments (-v, -k NAME, -x, ...) are passed to both suites.
- * Positional test paths are passed only to pytest_suite (they are
- pytest_suite paths). Select pyhttpd tests with the PYHTTPD_TARGETS
- environment variable, e.g.
- PYHTTPD_TARGETS="modules/http2" ./run-all-tests.sh --only=pyhttpd
- By default the pyhttpd side runs every modules/* directory whose conftest
- imports successfully in your environment; a directory whose optional
- dependency is missing (e.g. modules/md needs pyOpenSSL) is skipped with a
- note rather than aborting the run.
-
-Environment overrides:
- APXS path to apxs (default: pyhttpd/config.ini prefix, else PATH)
- PHP_FPM path to a php-fpm binary; enables pytest_suite's PHP tests
- PYHTTPD_TARGETS pyhttpd test path(s) to run (default: auto-detected modules/*)
-
-The runner exits non-zero if either suite has failures.
-
-
-Running a suite directly
-------------------------
-
-Both runtests.sh scripts create their own virtualenv on first run and can be
-invoked from any directory -- the paths below are just the convenient way to
-type them. The venv is (re)built automatically whenever it is missing or its
-pyproject.toml has changed, using `uv sync` (uv reads pyproject.toml + uv.lock).
-uv (https://docs.astral.sh/uv/) is required. To force a clean rebuild yourself,
-`rm -rf /.venv`.
-
-pytest_suite (self-contained; the venv holds only pytest + httpx):
-
- ./pytest_suite/runtests.sh --apxs=/path/to/apxs # all tests
- ./pytest_suite/runtests.sh --php-fpm=/path/to/php-fpm tests/t/php # PHP tests
- ./pytest_suite/runtests.sh -k rewrite -v # any pytest args pass through
-
-pyhttpd tests (need pyhttpd/config.ini from httpd's configure, plus curl,
-nghttp2/h2load, and -- for modules/md -- pyOpenSSL and an ACME test server):
-
- ./pyhttpd/runtests.sh modules/http2 # all HTTP/2 tests
- ./pyhttpd/runtests.sh modules/core -k test_001 # a subset
+Both use the same framework underneath (pyhttpd/: finding and probing the
+build, marker-based skipping, starting and stopping httpd, ports, test
+certificates, C test modules). The framework in pytest_suite/apache_pytest/
+is a thin layer over it, so that the classic test files stay identical to
+their upstream copy in the httpd-tests repository.
+
+One project, one environment: test/pyproject.toml holds the dependencies and
+the pytest configuration, test/uv.lock pins the versions, and test/.venv is
+the virtualenv.
+
+
+Running the tests: runtests.sh
+-------------------------------
+
+runtests.sh (runtests.bat on Windows) creates or updates the venv, finds apxs
+and php-fpm, and runs pytest from this directory. It can be invoked from any
+directory. Without a path it runs both suites.
+
+ ./runtests.sh # both suites
+ ./runtests.sh modules # only the modules/ suites
+ ./runtests.sh pytest_suite/tests # only the classic suite
+ ./runtests.sh modules/http2 -k 003 -v # a subset; any pytest option passes through
+ ./runtests.sh --apxs=/path/to/apxs # a specific httpd build
+ ./runtests.sh tests/t/php --php-fpm=/path/to/php-fpm
+
+Paths are relative to this directory; a path relative to pytest_suite/
+(tests/t/php) is accepted as well. ./run-one-test.sh PATH[::nodeid] re-runs a
+single test from a path as printed in a test run, and lists all tests when
+called without arguments.
+
+The venv is (re)built automatically when it is missing or pyproject.toml or
+uv.lock has changed, with `uv sync`; uv (https://docs.astral.sh/uv/) is required. To force
+a clean rebuild, `rm -rf test/.venv`. You can also run
+`.venv/bin/pytest --apxs=/path/to/apxs` yourself from test/.
+
+How the httpd build is found: from --apxs (or $APXS); else from --httpd with
+--conf (or $HTTPD); else from the config.ini that httpd's configure generates
+in pyhttpd/ (or $PYHTTPD_CONFIG); runtests.sh only falls back to an apxs on
+$PATH without that. It is found once, before the tests are collected, and
+both suites, and every skip and expected failure, use that one build; a
+build named with --apxs or --httpd that is not usable stops the run. The
+modules/ suites
+need curl and, for HTTP/2, nghttp2 and h2load; modules/md needs an ACME test
+server (pebble) and skips without it. A suite whose optional dependency is
+missing skips (and fails under --strict-optional).
+
+Ports: the modules/ suites listen on free ports picked for each test
+environment. --fixed-ports uses the ports of config.ini (5001 ...) instead,
+--base-port N uses N to N+4.
+
+Error log: the modules/ suites fail a test that makes httpd log an unexpected
+error or warning. The classic suite does the same; what it expects is listed
+in pyhttpd/classic_errorlog.py (--classic-elog-report=FILE lists what each
+test caused instead of failing).
+
+The C modules the tests load (mod_aptest, mod_h1test, mod_h2test, the
+c-modules/ of pytest_suite) are built with apxs, once: next to each .so a
+.stamp records the sources, defines and the httpd it was built for, and a
+module is rebuilt when one of them changes, e.g. when you point --apxs at
+another build. --clean-modules removes all of them first.
+
+From a build tree, `make check-all-pytest` builds the check/ tree and runs the
+same thing; pass paths and options as PYTEST_ARGS, for example
+`make check-all-pytest PYTEST_ARGS=modules/http2`.
Other contents
--------------
diff -Nru apache2-2.4.69/test/README.ci apache2-2.4.71/test/README.ci
--- apache2-2.4.69/test/README.ci 2023-03-02 09:58:30.000000000 +0000
+++ apache2-2.4.71/test/README.ci 2026-11-09 03:07:05.000000000 +0000
@@ -1,4 +1,3 @@
-
Variables
---------
@@ -23,61 +22,172 @@
* BUILDCONFIG - arguments to pass when running httpd's ./buildconf script
-* MFLAGS - arguments to pass when running "make" for httpd.
+* MFLAGS - arguments to pass when running "make", both for httpd and
+ for any dependency built from source. The Linux workflow defaults
+ this to "-j"; a matrix entry can override it, e.g.
+ setting it empty to force a serial build.
+
+* SKIP_TESTING - if set, no testing is done at all
-* SKIP_TESTING - if set, the Perl test framework is not run for the
- build.
+* NO_TEST_FRAMEWORK - if set, the Perl-based test framework and
+ libcheck-based unit tests are skipped
+
+* TEST_LOGNO - if set, checks for APLOGNO() macro errors (duplicates
+ or empty arguments) and exits; no build or testing is done.
* TEST_UBSAN - set for job using UBSan ("Undefined Behaviour Sanitizer")
-* TEST_MALLOC - set for job using enhanced malloc debugging.
+* TEST_ASAN - set for job using ASan ("Address Sanitizer")
+
+* TEST_MALLOC - set for job using enhanced malloc debugging
+ (sets MALLOC_PERTURB_, MALLOC_CHECK_ and LIBC_FATAL_STDERR_).
* TEST_INSTALL - set for job testing "make install"
-* TEST_VPATH - set for job testing srcdir!=builddir
+* TEST_VPATH - set for job testing srcdir!=builddir (VPATH build)
* TEST_LDAP - set for job with slapd, running LDAP tests
-* TEST_SSL - set for job with SSL/TLS testing variants
+* TEST_SSL - set for job with SSL/TLS testing variants (encrypted
+ private keys, session cache backends via redis/memcache/shmcb, etc.)
+
+* TEST_OPENSSL3 - if set to a version string (e.g. "3.4.7"), that
+ version of OpenSSL is built from source and used for mod_ssl.
+ Requires non-system APR/APR-util (APR_VERSION must also be set).
+
+* TEST_OPENSSL3_BRANCH - if set together with TEST_OPENSSL3, the
+ OpenSSL source is cloned from the named git branch instead of
+ downloading a release tarball.
+
+* OPENSSL_CONFIG - extra arguments passed to OpenSSL's ./Configure
+ when building from source (e.g. "no-engine").
+
+* TEST_PYTEST - if set, runs all available pytest-based test suites
+ via "make check-all-pytest" (pytest_suite/ and test/modules/*/
+ suites, in one pytest session). Replaces the old per-suite TEST_CORE
+ / TEST_PROXY / TEST_H2 / TEST_MD flags. A suite whose dependencies
+ are missing skips itself: modules/md without `pebble` on PATH,
+ modules/http2 when mod_http2 was not built.
+
+* PYTEST_ARGS - with TEST_PYTEST, test paths (relative to test/) and
+ options passed through to pytest, to narrow the run: "modules/http2",
+ "modules" (skip pytest_suite/), "pytest_suite/tests" (skip modules/),
+ "-k expr", "-m expr".
+
+* TEST_NGHTTP2 - if set to a version string (e.g. "1.68.0"), that
+ release of nghttp2 is built from its GitHub tag (library only) and
+ used for mod_http2/mod_proxy_http2 via --with-nghttp2. Used by the
+ HTTP/2 workflow to test against nghttp2 versions other than the one
+ packaged for the runner image.
+
+* TEST_MD - set together with TEST_PYTEST; installs pebble first so
+ that modules/md runs instead of skipping. See the
+ "Python pytest test suites" job in .github/workflows/linux.yml.
+
+* TEST_APR_TARBALL - if set, APR/APR-util source is fetched as a
+ release tarball from archive.apache.org rather than cloned from git.
+ (This is necessary for old releases where running ./buildconf from
+ a tag may now be difficult.)
* TESTS - a list of Perl framework tests to run
* TEST_ARGS - arguments to pass to ./t/TEST in the Perl test framework
-* CLEAR_CACHE - if set, the cached $HOME/root is removed before each build
+* LITMUS - if set, runs the litmus WebDAV test suite against mod_dav.
+
+* PHP_FPM - path to the php-fpm binary. If set, PHP tests are
+ included in the pytest test suites. If not set by the matrix
+ entry, the workflow sets it to the newest /usr/sbin/php-fpm* found
+ in the runner image (e.g. "/usr/sbin/php-fpm8.3"), failing the job
+ if there is none.
-Caching -- NOTE, BROKEN IN GITHUB ACTIONS --
+Caching
-------
-Perl modules installed in $HOME/perl5 are cached.
+Two directories are cached.
-Anything installed into the $HOME/root directory is cached - notably,
-versions of APR/APR-util are installed here and cached across httpd
-build jobs without needing to be rebuilt every time.
-
-The cached installs of APR/APR-util are refreshed if the
-last-changed-revision of the build is stale.
-
-If APR_VERSION and APU_VERSION are both set to 1.x versions, then
-CLEAR_CACHE should also be set to disable APR* caching. APR-util can
-only be rebuilt if an APR checkout is present, so a APR-util cannot be
-built from source alone. (i.e. the scripts do not handle the case of
-cached, fresh APR plus a cached but stale APR-util)
+~/root (key "cache-libs----$ROOTID") holds the
+APR, APR-util and OpenSSL installs. gha-resolve-deps.sh computes
+$ROOTID before the cache is restored, hashing only what determines
+those installs: the versions, their configure arguments and $CC. The
+job's own configuration is deliberately not included, so jobs building
+the same dependencies share one entry however differently they
+configure or test httpd - the five pool-debug jobs, for instance. A job
+which builds no dependencies gets no $ROOTID and does not use the cache
+at all.
+
+For anything built from a branch rather than a tag - APR_VERSION=trunk
+or 1.7.x, and TEST_OPENSSL3_BRANCH - the version alone does not identify
+what gets built, so the branch is resolved to a commit and that goes
+into $ROOTID too. The commit is exported as
+$APR_COMMIT/$APU_COMMIT/$OPENSSL_COMMIT so that the build uses exactly
+what the key names rather than resolving the branch a second time,
+minutes later, by which point it may have moved.
+
+It is restored before the dependencies are built and saved afterwards,
+only if they built successfully - a failed job would otherwise write a
+half-built tree to a key which can never be updated.
+
+~/perl5 (key "cache-cpan--$PERLID--") holds the
+CPAN modules. Nothing in it varies per job: the module list is fixed
+and the modules are always built with CC=gcc, so the content depends
+only on the perl ABI and on the module list itself, hashed from
+travis_before_linux.sh. One cache is therefore shared by every job.
+
+$PERLID is a hash of the "perl -V" output, computed by
+gha-resolve-deps.sh, which also logs that output so a change to it can
+be seen. The image name is deliberately not used: the runner image is
+rebuilt regularly and its name does not change when perl does, so the
+key outlived a perl upgrade which the modules did not. The key has no
+restore-keys fallback, so the modules restored always match the perl
+running the job.
+
+The cached CPAN modules still become gradually stale as versions in
+CPAN move on from whatever was installed for the first build. Bump
+the serial in the key ("cache-cpan-1-" -> "cache-cpan-2-") to discard
+them and rebuild against current CPAN.
+
+APR and APR-util must stay in a single cache entry. APR-util's
+buildconf needs APR's *source* tree, which lives in $HOME/build and is
+not cached, so APR-util can only be built in a job which also builds
+APR. $ROOTID covers both, and the entry is saved only once both have
+built, so a restored entry never holds one without the other.
+
+Artifacts
+---------
+
+A job which fails uploads its config.log, and the error_log from any
+test run, as artifacts named "config.log-$JOBID" and "error_log-$JOBID".
+
+$JOBID is the matrix index of the job followed by its name, with every
+character upload-artifact rejects - "/" among them - replaced, so job
+zero uploads "error_log-0-Shared-MPMs-event-pool-debug-SSL-TLS-variants".
+
+The index is what makes the name unique. It cannot collide however the
+matrix changes, including for an entry which sets no name at all,
+whereas hashing a hand-picked subset of the job configuration - as this
+used to do - collides silently as soon as two entries differ only in a
+field which the hash omits. Two entries already differ only in "os",
+which was never hashed: they stayed apart only because $ImageOS, which
+was, happens to differ on an ARM runner. The job name is appended
+purely so that an artifact can be matched to the job it came from
+without counting matrix entries; it is not needed for uniqueness, and
+an entry which sets none is still named unambiguously.
+
+The name reaches the shell as $MATRIX_NAME through the step's env
+rather than being interpolated into the script, so that no job name can
+be written which breaks the shell.
+
+The value computed for a job can be read from its log without waiting
+for a failure: it is listed in the environment which the runner prints
+under the "Run" group of every step after the one which sets it.
Travis to Github Actions Migration TODO
---------------------------------------
-* better path filtering so e.g. CHANGES changes don't trigger CI
-* support branch conditionals again (some tests are 2.4.x only, some trunk only)
-* make caching work properly for APR + CPAN modules
- - this is using the wrong model at the moment
- - the cache key needs to be based off (source code, job configuration)
- - rather than done on the fly in test/travis_before_linux.sh
- - pebble + Rustls builds should also be cached
-* turn on failure notifications?
* test across different Ubuntu versions again
- and test against OpenSSL 1.x since we're now ONLY building against 3.x
-* update the docs below for testing from PRs/feature branches
+* container testing for non-Ubuntu hosts
* introduce some job ordering rather than having a flat/concurrent
set, if the default "./configure && make && test" works *then* start
jobs doing 200 different variations on ./configure --enable-XXX
@@ -86,33 +196,23 @@
TODO list
---------
-* non-x86 builds
* MacOS build
-* Windows build
* clang-on-Linux build
* Use containers for non-Ubuntu-based Linux testing
Known Failures
--------------
-Some known failures:
+- A few mod_http2 Python tests sometimes fail spuriously, e.g.
+ FAILED modules/http2/test_106_shutdown.py::TestShutdown::test_h2_106_02 - AssertionError: failed on 2. request: * Added test1.tests.httpd.apache.org:5001:127.0.0.1 to DNS cache
-* prefork, and more rarely non-prefork testing sometimes catches child
- segfaults under pool-debug from assert()s in (e.g.)
- __pthread_tpp_change_priority where one child is destroying threads
- which another is waiting for, or iterating through via
- apr_pool_walk_tree().
-
- See dev@httpd threads:
- msg <5f4abde1b5789_13fde2ecacb40795a1@travis-tasks-5b566d48fc-drkb9.mail>
- msg <73060f7b-df7f-ad3c-a9fa-dd666a59b31e@kippdata.de> and
- https://bz.apache.org/bugzilla/show_bug.cgi?id=63098
- https://bz.apache.org/bugzilla/show_bug.cgi?id=46185
+- mod_md tests can deadlock pebble v2.10.1 which hangs the test suite,
+ hopefully fixed for a future release. See e.g.
+ https://github.com/letsencrypt/pebble/pull/555
- Not clear if there is a real bug here which can be reproduced
- outside of pool-debug.
+- ERROR at teardown of TestConf.test_md_300_022 - some race condition?
-Testing from a Feature Branch [*** N/A FOR GITHUB ACTIONS ***]
+Testing from a Feature Branch
-----------------------------
An SVN branch off trunk should be mirrored to github, and will be
@@ -120,11 +220,7 @@
available for those familiar with using Subversion and the standard
ASF/httpd repository layout.
-Tested branches are listed at: https://travis-ci.com/github/apache/httpd/branches
-
-Travis will also run the tests for a PR filed against the httpd Github
-repository at https://github.com/apache/httpd or from a fork of this
-repository if enabled for the Travis user.
+Test runs are listed at: https://github.com/apache/httpd/actions
A workflow to enable testing would be as follows, substituting
$USERNAME for your github username:
@@ -137,18 +233,12 @@
$ git commit ...
$ git push -u $USERNAME my-feature:my-feature
-To enable testing for a fork, visit the settings page at
-https://travis-ci.com/$USERNAME/httpd/settings - you may need to sync
-your account via https://travis-ci.com/account/repositories for a
-freshly created fork.
-
To create a Pull Request, go to the URL produced in the "git push"
command output when pushing to your fork, which is something like:
https://github.com/apache/httpd/compare/trunk...$USERNAME:trunk
-Once a PR has been created, travis will run the tests and link the
-results from a PR comment. All tested PRs are listed here:
-https://travis-ci.com/github/apache/httpd/pull_requests
+Once a PR has been created, the tests will run and test results
+displayed directly in the PR.
To merge from github back to SVN trunk, create a patch from e.g.:
@@ -161,3 +251,47 @@
$ git rebase -i origin/trunk
and follow the standard rebase steps.
+
+An alternative way to apply a PR into an SVN checkout is to use the
+apply_trunk_pr.sh script:
+https://svn.apache.org/repos/asf/httpd/dev-tools/github/apply_trunk_pr.sh
+
+If you use keywords in the SVN commit message, the PR will be closed
+when the commit is mirrored to Github, e.g. a commit message like:
+
+ mod_foo: Fix the baz feature.
+
+ PR: 12345
+ Submitted by: Jane Doe
+ GitHub: closes #1
+
+will close PR #1 when it's merged.
+
+2.4.x
+-----
+
+The 2.4.x workflow runs the pytest jobs of trunk's, and TEST_PYTEST and
+"make check-all-pytest" work the same on both branches, so a test
+backported from trunk needs no change in how it is run. A job that
+configures an MPM which the branch does not have yet (MotorZ, for now)
+succeeds without doing anything. travis_before_linux.sh and
+travis_run_linux.sh are the same on both branches and do what the
+workflow leaves to them:
+
+* CLEAR_CACHE - if set, the cached $HOME/root and $HOME/perl5 are
+ removed before the build.
+
+* MFLAGS - the 2.4.x workflow does not set it; the build script then
+ uses "-j2".
+
+* TEST_MOD_TLS - with RUSTLS_VERSION, builds that tag of rustls-ffi
+ into $HOME/root/rustls and configures httpd with --with-tls and
+ --with-rustls.
+
+When the workflow does not set $ROOTID, its cache key does not say what
+was built, so the scripts check for themselves. APR and APR-util are
+rebuilt unless $HOME/root/.key-apr (.key-apr-util) names the same
+version, commit, configuration and $CC; OpenSSL is rebuilt unless
+$HOME/root/openssl-is-$TEST_OPENSSL3 exists and, for a branch, names its
+current commit; the CPAN modules are rebuilt when the output of
+"perl -V" differs from the one saved in $HOME/perl5/.perlver.
diff -Nru apache2-2.4.69/test/README.pytest apache2-2.4.71/test/README.pytest
--- apache2-2.4.69/test/README.pytest 2026-07-18 15:32:22.000000000 +0000
+++ apache2-2.4.71/test/README.pytest 2026-11-09 03:07:05.000000000 +0000
@@ -6,9 +6,10 @@
Install
-------
The Python dependencies (pytest, pyOpenSSL, etc.) are managed with uv
-(https://docs.astral.sh/uv/), which reads pyproject.toml + uv.lock and creates
-a local .venv. Install uv, then let pyhttpd/runtests.sh build the venv on first
-run (it invokes `uv sync` for you); or create it yourself:
+(https://docs.astral.sh/uv/), which reads test/pyproject.toml + test/uv.lock and
+creates test/.venv, shared with pytest_suite. Install uv, then let
+runtests.sh build the venv on first run (it invokes `uv sync` for you);
+or create it yourself, from test/:
> uv sync
And for 'h2load':
@@ -35,6 +36,50 @@
> pytest -vvv -k test_h2_004_01
run the specific test with mod_http2 at log level TRACE2.
+There is an option to archive the results across different
+modules and httpd versions. The archiving will preserve
+error_log and access_log (cumulative for all tests in the module),
+and save a separate config file for each test case.
+> pytest test/modules/core --archive=/path/to/archive
+
+If you don't provide any specific module, pytest will execute all of them
+and add a new folder per module to the existing structure.
+> pytest --archive=/path/to/archive
+
+Always use --archive= (with =, not a space) to avoid pytest rootdir issues.
+
+The archive folder is named after the httpd version and the git commit hash
+so results from different code versions are kept separate.
+Running the same module again with the same commit updates its folder in place.
+
+What gets archived:
+- error_log and access_log for the module run
+- per-test configs: test.conf snapshot after each test
+- module config: modules.conf
+- shared configs and infrastructure in _shared/ to avoid duplication
+
+Example archive structure with 2 httpd versions:
+ ├── /path/to/archive
+ │ ├── 2.4.65-a3f8c1d
+ │ │ ├── _shared
+ │ │ │ ├── conf
+ │ │ │ │ ├── httpd.conf
+ │ │ │ │ ├── mime.types
+ │ │ │ │ └── stop.conf
+ │ │ │ ├── ca
+ │ │ ├── core
+ │ │ │ ├── conf
+ │ │ │ │ ├── modules.conf
+ │ │ │ │ ├── test_core_001_01.conf
+ │ │ │ └── logs
+ │ │ │ ├── error_log
+ │ │ │ └── access_log
+ │ │ ├── http1
+ │ │ └── http2
+ │ └── 2.4.66-b7d2e4f
+ │ ├── _shared
+ │ └── core
+
By default, test cases will configure httpd with mpm_event. You
can change that with the invocation:
> MPM=worker pytest test/modules/http2
@@ -49,6 +94,52 @@
of 'pebble', an ACME test server, and look for it in $PATH.
+Strict optional dependencies
+----------------------------
+A skip is convenient locally and dangerous in CI: a run that was meant
+to cover SSL, HTTP/2 and mod_md stays green while quietly covering none
+of them, because the machine was misconfigured. Strict mode turns that
+silence into a failure.
+
+> pytest --strict-optional
+> PYHTTPD_STRICT_OPTIONAL=1 pytest
+
+Both are off by default, so nothing changes unless you ask for it. The
+env var is the one to set in CI; the flag is the one to use by hand.
+
+Not every skip becomes a failure. Skips come in two kinds:
+
+ - "does not apply here" -- wrong MPM, httpd too old, STRESS_TEST not
+ set, $SSL selects a different TLS module. These always skip, in
+ strict mode too, and a test gated on both kinds skips rather than
+ fails.
+
+ - "something we expected to be installed is missing" -- curl, nghttp,
+ h2load, the 'websockets' and 'multipart' python packages, mod_ssl,
+ mod_tls, mod_md, a2md, the ACME test server, an OCSP responder.
+ These fail under strict mode.
+
+The OCSP responder is the one judgement call in that split. pebble
+serves no OCSP, boulder does, so with ACME=pebble the stapling tests
+have nothing to talk to. That is a property of the environment rather
+than of this machine's packages, and a pipeline that is meant to cover
+OCSP stapling and runs pebble has lost that coverage silently -- which
+is the thing strict mode exists to catch. So it fails, and the message
+names ACME=pebble as what was detected. Run ACME=boulder to cover
+stapling, or leave strict mode off.
+
+A strict failure is raised during test setup, so pytest reports it as
+ERROR rather than FAILED; the exit code is non-zero either way. The
+message is prefixed '[strict-optional]' so it greps cleanly out of a CI
+log, and names the dependency, what was expected, what was found
+instead, and how to switch strict mode back off.
+
+curl is the one dependency gated for the suite as a whole rather than
+test by test: apache_restart() proves the server came up by making a
+curl request, so without curl nothing under test/modules/ can run. That
+gate lives in the root conftest.py.
+
+
Workings
--------
All tests start httpd on their own and try hard to shut it down
@@ -124,11 +215,44 @@
other modules. 'test/modules/md' checks and starts external
processes (an ACME test server).
+If your test needs something that may not be installed, say
+so with the decorator from 'pyhttpd.depends'. It takes the
+condition itself, so it works on a method, a class or a
+module-level 'pytestmark':
+
+ from pyhttpd.depends import needs_dependency
+
+ @needs_dependency("a2md", MDTestEnv.has_a2md(),
+ reason="no a2md available")
+ class TestStore:
+
+For the tools the suite gates on most often there are ready
+made versions of that decorator, which probe the tool once
+and cache the answer:
+
+ @H2TestEnv.needs_nghttp() # and (assets=True)
+ @H2TestEnv.needs_h2load("1.41.0")
+ @HttpdTestEnv.needs_multipart()
+
+For a check that can only be made once the test is running,
+'env.require()' does the same job from inside the body:
+
+ env.require(env.curl_is_at_least('8.0.0'),
+ 'need at least curl v8.0.0 for this',
+ dep='curl',
+ detected=f"curl {env.curl_version_str()}")
+
+Both skip normally and fail under strict mode; see "Strict
+optional dependencies" above. When the reason is instead
+"this test does not apply here", keep using a plain
+'pytest.mark.skipif' or 'pytest.skip()' -- those never
+become failures.
+
Infrastructure
--------------
The test cases rely on the classes provided in `test/pyhttpd/` (where
-`test/pyhttpd/runtests.sh` runs the suite) for common code in managing
+`test/runtests.sh` runs the suite) for common code in managing
a httpd test instance and do provide some base setups:
- a small set of virtual hosts with some files
- access to paths and definitions (env fixture)
@@ -137,3 +261,38 @@
- create certificates for your hosts and make curl use
the root certs (so no --insecure calls by curl).
+
+Tests of changes missing from 2.4.x
+-----------------------------------
+This directory is the same on trunk and on 2.4.x: a test is written on trunk,
+then `test/` is copied to 2.4.x as it is, and `check-test-sync.sh` fails if
+the two branches differ. A test covering a trunk change that 2.4.x does not
+have yet carries a mark from `pyhttpd/backport.py` (or, in `pytest_suite/`,
+`apache_pytest.not_backported`) naming the trunk revision:
+
+ from pyhttpd.backport import not_backported, skip_not_backported
+
+ @not_backported("r1939033", "nonce-count validation")
+ def test_digest_001(self, env): ...
+
+`not_backported` makes the test an expected failure on a server older than
+2.5.0, `skip_not_backported` skips it where failing would hang or crash the
+server or leave errors in the error log. Neither does anything on trunk.
+Only a failure of the test function itself is expected: an error in the
+setup or teardown of a fixture (a server that does not start, the error log
+check) stays an error. Mark only the tests, or the parameters (with
+`pytest.param(..., marks=not_backported(...))`), that fail without the
+change; on 2.4.x a mark on a test that passes there would turn a regression
+into an expected failure.
+Both marks are applied when the tests are collected, by `pyhttpd/gating.py`,
+against the httpd that `pyhttpd/build.py` found (`--apxs`, `--httpd`, or the
+build's `config.ini`); the `need_*` markers of `pytest_suite/` are evaluated
+there too, and a missing module or PHP/SSL support is a missing dependency:
+skipped, or failed under `--strict-optional`.
+
+When the change is backported, add the 2.4.x release it first appears in:
+
+ @not_backported("r1939033", "nonce-count validation", backported_in="2.4.71")
+
+on trunk, then copy `test/` to 2.4.x again. Older 2.4.x servers keep the
+expectation. `platform="win32"` limits it to one `sys.platform`.
diff -Nru apache2-2.4.69/test/check-test-sync.sh apache2-2.4.71/test/check-test-sync.sh
--- apache2-2.4.69/test/check-test-sync.sh 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/check-test-sync.sh 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Fail if test/ differs between trunk and 2.4.x.
+#
+# usage: check-test-sync.sh [trunk-ref [2.4.x-ref]]
+#
+# Run from anywhere in a git checkout of apache/httpd that has both refs;
+# they default to origin/trunk and origin/2.4.x. The comparison is of the
+# committed trees, so uncommitted changes do not count.
+set -e
+
+a=${1:-origin/trunk}
+b=${2:-origin/2.4.x}
+
+for ref in "$a" "$b"; do
+ if ! git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
+ echo "check-test-sync: no such ref: $ref" >&2
+ exit 2
+ fi
+done
+
+top=$(git rev-parse --show-toplevel)
+if git -C "$top" diff --quiet "$a" "$b" -- test; then
+ echo "test/ is identical on $a and $b"
+else
+ echo "test/ differs between $a and $b:" >&2
+ git -C "$top" diff --stat "$a" "$b" -- test >&2
+ exit 1
+fi
diff -Nru apache2-2.4.69/test/clients/h2ws.c apache2-2.4.71/test/clients/h2ws.c
--- apache2-2.4.69/test/clients/h2ws.c 2026-03-09 15:27:35.000000000 +0000
+++ apache2-2.4.71/test/clients/h2ws.c 2026-11-09 03:07:05.000000000 +0000
@@ -141,14 +141,13 @@
int *pipv6, size_t *pconsumed,
const char *s, size_t len, uint16_t def_port)
{
- size_t i, offset;
+ size_t i, offset=0;
char *host = NULL;
int port = 0;
int rv = 1, ipv6 = 0;
if (!len)
goto leave;
- offset = 0;
if (s[offset] == '[') {
ipv6 = 1;
for (i = offset++; i < len; ++i) {
@@ -490,7 +489,7 @@
const nghttp2_frame *frame,
void *user_data)
{
- struct h2_session *session = user_data;
+ (void)ngh2;
switch (frame->hd.type) {
case NGHTTP2_HEADERS:
@@ -511,8 +510,19 @@
break;
case NGHTTP2_GOAWAY:
log_infof("frame recv", "FRAME[GOAWAY]");
- fprintf(stdout, "[%d] GOAWAY\n", frame->hd.stream_id);
- session->aborted = 1;
+ {
+ struct h2_session *session = user_data;
+ struct h2_stream *s;
+ while ((s = session->streams) != NULL) {
+ s->error_code = frame->goaway.error_code;
+ s->closed = 1;
+ s->reset = 1;
+ fprintf(stdout, "[%d] RST\n", s->id);
+ h2_session_stream_remove(session, s);
+ if (s->on_close)
+ s->on_close(s);
+ }
+ }
break;
}
return 0;
@@ -746,9 +756,6 @@
int want_read, want_write;
struct h2_stream *stream;
- if(session->aborted)
- return 0;
-
want_read = (nghttp2_session_want_read(session->ngh2) ||
session->want_io == IO_WANT_READ);
want_write = (nghttp2_session_want_write(session->ngh2) ||
@@ -807,6 +814,14 @@
static void h2_session_close(struct h2_session *session)
{
+ if (session->ngh2) {
+ nghttp2_session_del(session->ngh2);
+ session->ngh2 = NULL;
+ }
+ if (session->fd >= 0) {
+ close(session->fd);
+ session->fd = -1;
+ }
log_infof(cmd, "closed session to %s:%u",
session->connect_host, session->connect_port);
}
@@ -958,7 +973,7 @@
struct uri uri;
struct h2_session session;
struct ws_stream *stream;
- char ch;
+ int ch;
cmd = argv[0];
while((ch = getopt(argc, argv, "c:vh")) != -1) {
@@ -1111,5 +1126,11 @@
h2_session_run(&session);
h2_session_close(&session);
+ free(stream);
+ free((void *)uri.host);
+ if (uri.authority != uri.host)
+ free((void *)uri.authority);
+ free((void *)uri.path);
+ free((void *)host);
return 0;
}
diff -Nru apache2-2.4.69/test/conftest.py apache2-2.4.71/test/conftest.py
--- apache2-2.4.69/test/conftest.py 2026-07-17 16:04:01.000000000 +0000
+++ apache2-2.4.71/test/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,5 +1,7 @@
import sys
import os
+import re
+import subprocess
import pytest
@@ -7,14 +9,178 @@
from pyhttpd.env import HttpdTestEnv
+# Imported here before pytest_plugins loads it, so register it for
+# assertion rewriting first, to avoid PytestAssertRewriteWarning.
+pytest.register_assert_rewrite("pyhttpd.depends")
+from pyhttpd.depends import apply_gate, strict_optional_enabled
+
+# pyhttpd.plugin registers the command-line options of both suites;
+# pyhttpd.gating evaluates the need_* and backport markers of both;
+# pyhttpd.depends provides --strict-optional and the needs_dependency marker.
+# pytest_plugins is only honoured in the rootdir conftest, and there is no
+# pytest.ini in this tree, so this is the only place it can be declared.
+pytest_plugins = ["pyhttpd.plugin", "pyhttpd.gating", "pyhttpd.depends",
+ "pyhttpd.classic_errorlog",
+ # pytester: the self-tests of these plugins
+ "pytester"]
+
+# Everything below test/modules/ drives a real httpd.
+MODULES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'modules')
+
+
def pytest_report_header(config, start_path):
+ from pyhttpd.build import config_ini_path, resolve_build
+ info = resolve_build(config)
+ if info is None:
+ # no httpd found (neither --apxs/--httpd nor a build generated
+ # config.ini); nothing to report
+ return None
+ line = f"[apache httpd: {info.version_str}, mpm: {info.mpm}, {info.prefix}, via {info.source}]"
+ if info.source != "config.ini" and config_ini_path().is_file():
+ # the modules/ suite tests the same httpd, but still takes its
+ # ports and client tools from here
+ line += f"\n[modules/ suite: ports and clients from {config_ini_path()}]"
+ return line
+
+
+def _generate_config_ini(config):
+ """Generate a config.ini from --httpd/--conf when no config.ini exists."""
+ httpd_opt = config.getoption("--httpd")
+ conf_opt = config.getoption("--conf")
+ if not httpd_opt:
+ return
+
+ httpd_path = os.path.abspath(httpd_opt)
+ prefix = os.path.dirname(os.path.dirname(httpd_path))
+ bindir = os.path.join(prefix, "bin")
+ libexecdir = os.path.join(prefix, "modules")
+ is_win = sys.platform == "win32"
+
+ # version from httpd -v
+ version = "2.5.0"
+ try:
+ p = subprocess.run([httpd_path, "-v"], capture_output=True, text=True)
+ m = re.search(r"Apache/(\S+)", p.stdout)
+ if m:
+ version = m.group(1)
+ except Exception:
+ pass
+
+ # modules and mpm from conf file
+ dso_modules = []
+ mpm_modules = []
+ if conf_opt:
+ conf_path = os.path.abspath(conf_opt)
+ try:
+ with open(conf_path) as f:
+ for line in f:
+ line = line.strip()
+ if line.startswith("#"):
+ continue
+ m = re.match(r"LoadModule\s+(\S+)_module", line)
+ if m:
+ name = m.group(1)
+ if name.startswith("mpm_"):
+ mpm_modules.append(name)
+ else:
+ dso_modules.append(name)
+ except Exception:
+ pass
+
+ if not mpm_modules:
+ mpm_modules = ["mpm_winnt"] if is_win else ["mpm_event"]
+ if is_win and "cgid" in dso_modules and "cgi" not in dso_modules:
+ dso_modules = ["cgi" if m == "cgid" else m for m in dso_modules]
+
+ apachectl = httpd_path if is_win else os.path.join(bindir, "apachectl")
+ if is_win:
+ prefix_curl = os.path.join(prefix, "bin", "curl.exe")
+ curl_bin = prefix_curl if os.path.isfile(prefix_curl) else "curl.exe"
+ else:
+ curl_bin = "curl"
+ test_dir = os.path.dirname(os.path.abspath(__file__))
+ gen_dir = os.path.join(test_dir, "gen")
+
+ ini_content = f"""[global]
+prefix = {prefix}
+exec_prefix = ${{prefix}}
+bindir = ${{prefix}}/bin
+sbindir = ${{exec_prefix}}/sbin
+libdir = ${{exec_prefix}}/lib
+libexecdir = ${{exec_prefix}}/modules
+
+apr_bindir = ${{prefix}}
+apxs = ${{prefix}}/bin/apxs
+apachectl = {apachectl}
+
+curl_bin = {curl_bin}
+nghttp = nghttp
+h2load = h2load
+
+pre_built_test_binaries_dir = ${{libexecdir}}
+
+[httpd]
+version = {version}
+name = httpd
+dso_modules = {' '.join(dso_modules)}
+mpm_modules = {' '.join(mpm_modules)}
+
+[test]
+gen_dir = {gen_dir}
+http_port = 5002
+https_port = 5001
+proxy_port = 5003
+http_port2 = 5004
+ws_port = 5100
+http_tld = tests.httpd.apache.org
+test_dir = {test_dir}
+test_src_dir = {test_dir}
+"""
+ ini_path = os.path.join(test_dir, "pyhttpd", "config.ini")
+ with open(ini_path, "w") as f:
+ f.write(ini_content)
+
+
+def pytest_configure(config):
+ if config.getoption("--clean-modules", default=False):
+ # the c-modules/ of pytest_suite are cleaned by its own fixture
+ from pyhttpd.cmodules import clean_test_modules
+ clean_test_modules(os.path.dirname(os.path.abspath(__file__)))
+ httpd_opt = config.getoption("--httpd", default=None)
+ if not httpd_opt:
+ return
+ ini_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "pyhttpd", "config.ini")
+ if not os.path.isfile(ini_path):
+ # No build-generated config.ini (typical on Windows without
+ # autoconf) — generate one from --httpd/--conf so the framework
+ # has something to read.
+ _generate_config_ini(config)
+
+
+def pytest_collection_modifyitems(config, items):
+ """Gate the integration suite on the clients the framework itself needs.
+
+ curl is not an optional dependency of the individual test cases that
+ call curl_get(): apache_restart() proves the server came up with
+ is_live(), and is_live() is a curl request. Without curl every test
+ that starts an httpd dies in fixture setup, which is neither a skip nor
+ a useful failure. So it is gated once here for the whole of
+ test/modules/ rather than test by test.
+
+ Tests outside that directory - the strict-mode self-test - run no httpd
+ and are left alone.
+ """
+ if not any(str(item.path).startswith(MODULES_DIR) for item in items):
+ return
env = HttpdTestEnv()
- return f"[apache httpd: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
-
-def pytest_addoption(parser):
- parser.addoption("--repeat", action="store", type=int, default=1,
- help='Number of times to repeat each test')
- parser.addoption("--all", action="store_true")
+ if env.has_curl():
+ return
+ missing = [("curl", "no curl available", f"tried to run {env.curl}")]
+ strict = strict_optional_enabled(config)
+ for item in items:
+ if str(item.path).startswith(MODULES_DIR):
+ apply_gate(item, missing, strict)
def pytest_generate_tests(metafunc):
@@ -22,26 +188,3 @@
count = int(metafunc.config.getoption("repeat"))
metafunc.fixturenames.append('tmp_ct')
metafunc.parametrize('repeat', range(count))
-
-
-@pytest.fixture(autouse=True, scope="function")
-def _function_scope(env, request):
- env.set_current_test_name(request.node.name)
- yield
- env.check_error_log()
- env.set_current_test_name(None)
-
-
-@pytest.fixture(autouse=True, scope="module")
-def _module_scope(env):
- yield
- env.check_error_log()
-
-
-@pytest.fixture(autouse=True, scope="package")
-def _package_scope(env):
- env.httpd_error_log.clear_ignored_matches()
- env.httpd_error_log.clear_ignored_lognos()
- yield
- assert env.apache_stop() == 0
- env.check_error_log()
diff -Nru apache2-2.4.69/test/gha-docs-check.sh apache2-2.4.71/test/gha-docs-check.sh
--- apache2-2.4.69/test/gha-docs-check.sh 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/gha-docs-check.sh 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,46 @@
+#!/bin/sh
+#
+# Check the XML sources of the manual: every file must be well-formed,
+# and every file with a DOCTYPE must be valid against its DTD. Pass
+# file names to check only those, otherwise all of docs/ is checked.
+#
+# The DTDs pull in the XHTML entity sets by public identifier, so these
+# must be resolvable from an XML catalog (on Debian/Ubuntu, install
+# w3c-sgml-lib; on Fedora, xhtml1-dtds, or point XML_CATALOG_FILES at
+# a catalog); nothing is fetched from the network.
+
+cd "`dirname "$0"`/.." || exit 1
+
+if ! command -v xmllint >/dev/null; then
+ echo "FAIL: xmllint not found"
+ exit 1
+fi
+
+if [ $# -eq 0 ]; then
+ set -- `find docs -name '*.xml' -o -name '*.xml.*' | sort`
+fi
+
+rv=0
+count=0
+
+for f in "$@"; do
+ count=$((count + 1))
+ # The language files use lang.dtd only to define entities, and
+ # declare no elements, so can only be checked for well-formedness.
+ if grep -q '&1` \
+ || echo "$out" | grep -q 'failed to load external entity'; then
+ echo "$out"
+ echo "FAIL: $f"
+ rv=1
+ fi
+done
+
+[ $rv -eq 0 ] && echo "PASS: $count files checked"
+
+exit $rv
diff -Nru apache2-2.4.69/test/gha-resolve-deps.sh apache2-2.4.71/test/gha-resolve-deps.sh
--- apache2-2.4.69/test/gha-resolve-deps.sh 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/gha-resolve-deps.sh 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,93 @@
+#!/bin/bash -e
+#
+# Work out what identifies the contents of the cached directories and
+# write it to $GITHUB_ENV for the cache keys to use: $ROOTID for ~/root,
+# $PERLID for ~/perl5.
+#
+# ~/root holds only the dependencies built from source, so the key
+# covers those alone - not the job's own configuration. Jobs which
+# build the same dependencies then share one cache entry even where they
+# configure or test httpd differently. $ROOTID is left unset for a job
+# which builds no dependencies at all, and the cache is skipped.
+#
+# Anything built from a branch is resolved to a commit here and the
+# commit exported, so that the build uses exactly what the key names;
+# resolving the branch again at build time would race with it moving.
+# Tags and release tarballs are immutable, so the version is enough.
+
+root=
+
+# $1 = name, $2 = version, $3 = configure arguments, $4 = variable to
+# export the resolved commit into.
+function add_apx() {
+ local name=$1
+ local version=$2
+ local config=$3
+ local var=$4
+ local sha
+
+ if test -z "$version"; then
+ return 0
+ fi
+
+ case "$version" in
+ trunk|*.x)
+ sha=`git ls-remote https://github.com/apache/${name}.git refs/heads/${version} | cut -f1`
+ if test -z "$sha"; then
+ : Could not resolve ${name} branch ${version} - check branch is valid?
+ exit 1
+ fi
+ echo "${var}=${sha}" >> $GITHUB_ENV ;;
+ *) sha=${version} ;;
+ esac
+
+ root="${root} ${name}-${version}-${sha}-${config}"
+}
+
+add_apx apr "${APR_VERSION-}" "${APR_CONFIG-}" APR_COMMIT
+add_apx apr-util "${APU_VERSION-}" "${APU_CONFIG-}" APU_COMMIT
+
+if test -n "${TEST_OPENSSL3-}"; then
+ if test -n "${TEST_OPENSSL3_BRANCH-}"; then
+ sha=`git ls-remote https://github.com/openssl/openssl refs/heads/${TEST_OPENSSL3_BRANCH} | cut -f1`
+ if test -z "$sha"; then
+ : Could not resolve openssl branch ${TEST_OPENSSL3_BRANCH} - check branch is valid?
+ exit 1
+ fi
+ echo "OPENSSL_COMMIT=${sha}" >> $GITHUB_ENV
+ root="${root} openssl-${TEST_OPENSSL3_BRANCH}-${sha}-${OPENSSL_CONFIG-}"
+ else
+ root="${root} openssl-${TEST_OPENSSL3}-${OPENSSL_CONFIG-}"
+ fi
+fi
+
+if test -n "${TEST_NGHTTP2-}"; then
+ root="${root} nghttp2-${TEST_NGHTTP2}"
+fi
+
+if test -n "$root"; then
+ # The compiler and every flag which reaches the build are baked into
+ # the libraries, NOTEST_* included: APR adds those to the build too,
+ # it just does not use them for ./configure.
+ root="${root} CC=${CC-} CFLAGS=${CFLAGS-} CPPFLAGS=${CPPFLAGS-}"
+ root="${root} LDFLAGS=${LDFLAGS-} LIBS=${LIBS-}"
+ root="${root} NOTEST_CFLAGS=${NOTEST_CFLAGS-} NOTEST_CPPFLAGS=${NOTEST_CPPFLAGS-}"
+ root="${root} NOTEST_LDFLAGS=${NOTEST_LDFLAGS-} NOTEST_LIBS=${NOTEST_LIBS-}"
+ root="${root}${TEST_APR_TARBALL+ tarball}"
+ : Dependencies:${root}
+ echo ROOTID=`echo "$root" | md5sum - | sed 's/ .*//'` >> $GITHUB_ENV
+else
+ : No dependencies are built from source, so ~/root is not cached.
+fi
+
+# The CPAN modules in ~/perl5 are XS builds tied to the system perl, so
+# key that cache on the "perl -V" output rather than on the image name.
+# The image is rebuilt regularly and its name does not change when perl
+# does, so the key stayed valid across a perl upgrade and restored
+# modules built for a different perl. The output is logged because it
+# is otherwise impossible to see what changed.
+perlv=`perl -V`
+echo "::group::perl -V"
+echo "$perlv"
+echo "::endgroup::"
+echo PERLID=`echo "$perlv" | md5sum - | sed 's/ .*//'` >> $GITHUB_ENV
diff -Nru apache2-2.4.69/test/gha-sanity-check.sh apache2-2.4.71/test/gha-sanity-check.sh
--- apache2-2.4.69/test/gha-sanity-check.sh 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/gha-sanity-check.sh 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,34 @@
+#!/bin/sh
+#
+# Sanity-check the CI inputs: the GitHub Actions workflow files must
+# parse as YAML, and the scripts those workflows run must pass a shell
+# syntax check. Neither is exercised by the build, so run this before
+# pushing a change to either.
+
+cd "`dirname "$0"`/.." || exit 1
+
+rv=0
+
+if python3 -c 'import yaml' 2>/dev/null; then
+ for f in .github/workflows/*.yml; do
+ if python3 -c 'import sys, yaml; yaml.safe_load(open(sys.argv[1]))' "$f"; then
+ echo "PASS: $f"
+ else
+ echo "FAIL: $f"
+ rv=1
+ fi
+ done
+else
+ echo "SKIP: .github/workflows/*.yml, python3 has no yaml module"
+fi
+
+for f in test/travis*.sh test/gha*.sh; do
+ if bash -n "$f"; then
+ echo "PASS: $f"
+ else
+ echo "FAIL: $f"
+ rv=1
+ fi
+done
+
+exit $rv
diff -Nru apache2-2.4.69/test/httpdunit.c apache2-2.4.71/test/httpdunit.c
--- apache2-2.4.69/test/httpdunit.c 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/httpdunit.c 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,53 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "httpdunit.h"
+
+#include "apr_general.h"
+
+static Suite *main_test_suite(void)
+{
+ Suite *suite = suite_create("main");
+
+ /* The list of test cases is automatically generated from the test/unit
+ * directory by the build system. */
+# include "test/httpdunit.cases"
+
+ return suite;
+}
+
+int main(int argc, const char * const argv[])
+{
+ SRunner *runner;
+ int failed;
+
+ /* Initialize APR and create our test runner. */
+ apr_app_initialize(&argc, &argv, NULL);
+ runner = srunner_create(main_test_suite());
+
+ /* Log TAP to stdout. */
+ srunner_set_tap(runner, "-");
+
+ /* Run the tests and collect failures. */
+ srunner_run_all(runner, CK_SILENT /* output only TAP */);
+ failed = srunner_ntests_failed(runner);
+
+ /* Clean up. */
+ srunner_free(runner);
+ apr_terminate();
+
+ return failed ? 1 : 0;
+}
diff -Nru apache2-2.4.69/test/httpdunit.h apache2-2.4.71/test/httpdunit.h
--- apache2-2.4.69/test/httpdunit.h 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/httpdunit.h 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,120 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * httpdunit.h: a collection of test helper macros designed to reduce test
+ * boilerplate and help the build system autogenerate test case definitions.
+ *
+ * Here's how the magic works:
+ *
+ * Every test case under test/unit declares test functions using Check's
+ * START_TEST or the HTTPD_START_LOOP_TEST macro. The build system searches each
+ * source file for those macros and generates an appropriate stub, called
+ * test/unit/.tests, that will add each test function to the test
+ * case. This stub is then pulled in by three lines of boilerplate at the end of
+ * every test case's source file (see the HTTPD_BEGIN_TEST_CASE documentation,
+ * below).
+ *
+ * The build system uses that same three-line boilerplate to determine the names
+ * of all the test cases, and adds them automatically to the main test suite
+ * using a similar generate-stub-and-include process.
+ */
+
+#include "apr.h" /* for pid_t on Windows, needed by Check */
+
+#if defined(__GNUC__)
+#if __GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 6)
+#pragma GCC diagnostic push
+#endif
+#pragma GCC diagnostic ignored "-Wstrict-prototypes"
+#elif defined(__clang__)
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wstrict-prototypes"
+#endif
+
+#include "check.h"
+
+#if defined(__GNUC__)
+#if __GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 6)
+#pragma GCC diagnostic pop
+#endif
+#elif defined(__clang__)
+#pragma clang diagnostic pop
+#endif
+
+/*
+ * Boilerplate Macros
+ */
+
+/**
+ * Declares a test case. The build system uses this macro to generate a stub,
+ * which will automatically pull the new test case into the main suite.
+ *
+ * After beginning a test case, you must #include the autogenerated testcase
+ * stub ("test/unit/.tests") and then end the test case with
+ * HTTPD_END_TEST_CASE. For example, for a test case called foobar.c:
+ *
+ * HTTPD_BEGIN_TEST_CASE(foobar)
+ * #include "test/unit/foobar.tests"
+ * HTTPD_END_TEST_CASE
+ *
+ * The NAME identifying the test case must be globally unique within the test
+ * suite; if in doubt, just use the filename (minus extension).
+ */
+#define HTTPD_BEGIN_TEST_CASE(NAME) \
+TCase * NAME##_test_case(void); \
+TCase * NAME##_test_case(void) \
+{ \
+ TCase *testcase = tcase_create(#NAME);
+
+/**
+ * Like HTTPD_BEGIN_TEST_CASE, but additionally declares a pair of setup and
+ * teardown functions for the test case. The setup function is run once before
+ * every test function, and the teardown function is run directly after. Keep in
+ * mind that expensive fixtures will heavily impact test runtime.
+ *
+ * Both setup and teardown take no parameters and return void.
+ *
+ * These functions are passed to Check's tcase_add_checked_fixture(). See the
+ * Check documentation for details; the gist is that checked fixures run after
+ * Check forks the test process, and therefore they cannot influence each other
+ * even if something smashes the heap.
+ */
+#define HTTPD_BEGIN_TEST_CASE_WITH_FIXTURE(NAME, SETUP, TEARDOWN) \
+HTTPD_BEGIN_TEST_CASE(NAME) \
+ tcase_add_checked_fixture(testcase, (SETUP), (TEARDOWN));
+
+/**
+ * Bookend for HTTPD_BEGIN_TEST_CASE[_WITH_FIXTURE], which finishes the test
+ * case function.
+ */
+#define HTTPD_END_TEST_CASE \
+ return testcase; \
+}
+
+/*
+ * Test Declaration Macros
+ */
+
+/**
+ * Use this macro instead of Check's START_TEST when you want to run a test
+ * function multiple times. The build system will add the test function to the
+ * test case using Check's tcase_add_loop_test().
+ *
+ * The loop index is available to the test function as the special variable _i.
+ * _i will start at 0 and increment to a maximum of (NUM_ITERATIONS - 1).
+ */
+#define HTTPD_START_LOOP_TEST(NAME, NUM_ITERATIONS) START_TEST(NAME)
diff -Nru apache2-2.4.69/test/modules/aaa/__init__.py apache2-2.4.71/test/modules/aaa/__init__.py
--- apache2-2.4.69/test/modules/aaa/__init__.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/__init__.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/aaa/conftest.py apache2-2.4.71/test/modules/aaa/conftest.py
--- apache2-2.4.69/test/modules/aaa/conftest.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,128 @@
+import logging
+import os
+import sys
+
+import pytest
+
+from .env import AAATestEnv
+from pyhttpd.conf import HttpdConf
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+
+def pytest_report_header(config, start_path):
+ env = AAATestEnv()
+ return f"mod_auth_digest [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
+
+
+def _digest_dir(docs, path, extra_lines):
+ lines = [
+ f'',
+ ' AuthType Digest',
+ f' AuthName "{AAATestEnv.REALM}"',
+ ]
+ lines.extend(f" {l}" for l in extra_lines)
+ lines.append(' Require valid-user')
+ lines.append('')
+ return lines
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> AAATestEnv:
+ level = logging.INFO
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+ logging.getLogger('').addHandler(console)
+ logging.getLogger('').setLevel(level=level)
+ env = AAATestEnv(pytestconfig=pytestconfig)
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+
+ docs = env.server_docs_dir
+ pwfile = env.digest_pwfile
+ conf = HttpdConf(env)
+ # Pin the client table to its historical size, ~12 entries, rather than
+ # the current default of ~140: the tests which need an entry to be
+ # garbage collected (085) drive that by filling the table with bare
+ # requests, and a table an order of magnitude larger makes them an order
+ # of magnitude slower for nothing.
+ conf.add('AuthDigestShmemSize 1000')
+ conf.add(_digest_dir(docs, "default", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ ]))
+ conf.add(_digest_dir(docs, "nccheck", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNcCheck On',
+ ]))
+ conf.add(_digest_dir(docs, "nccheck-shortlife", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNcCheck On',
+ 'AuthDigestNonceLifetime 2',
+ ]))
+ conf.add(_digest_dir(docs, "shortlife", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 2',
+ ]))
+ conf.add(_digest_dir(docs, "neverexpire", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime -1',
+ ]))
+ conf.add(_digest_dir(docs, "onetime", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 0',
+ ]))
+ conf.add(_digest_dir(docs, "onetime-nccheck", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 0',
+ 'AuthDigestNcCheck On',
+ ]))
+ conf.add(_digest_dir(docs, "domain", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestDomain "/digest/domain/" "https://mirror.example.org/other/"',
+ ]))
+ conf.add(_digest_dir(docs, "noprovider", [
+ # AuthDigestProvider intentionally omitted: falls back to "file".
+ f'AuthUserFile "{pwfile}"',
+ ]))
+ # A request which fails header validation (e.g. no Host on HTTP/1.1)
+ # is answered before the Digest module's post_read_request runs, so it
+ # has no per-request Digest record; an ErrorDocument redirecting it into
+ # a Digest-protected location must not then dereference that record.
+ # Only configured where that is fixed (r1939032): earlier code does
+ # dereference the missing record.
+ if env.httpd_is_at_least("2.5.0"):
+ conf.add('ErrorDocument 400 /digest/default/secret.txt')
+ # A reverse-proxied location which authenticates with Digest at the
+ # proxy (so the backend is never reached and need not be reachable).
+ # The challenge is an origin-style WWW-Authenticate, so AuthDigestDomain
+ # applies to it just as for a non-proxied location.
+ conf.add([
+ 'ProxyPass "/pxdomain/" "http://127.0.0.1:1/unused/"',
+ '',
+ ' AuthType Digest',
+ f' AuthName "{AAATestEnv.REALM}"',
+ ' AuthDigestProvider file',
+ f' AuthUserFile "{pwfile}"',
+ ' AuthDigestDomain "/pxdomain/"',
+ ' Require valid-user',
+ '',
+ ])
+ conf.install()
+ assert env.apache_restart() == 0
+ return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
diff -Nru apache2-2.4.69/test/modules/aaa/digest_client.py apache2-2.4.71/test/modules/aaa/digest_client.py
--- apache2-2.4.69/test/modules/aaa/digest_client.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/digest_client.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,134 @@
+"""Minimal hand-rolled RFC 2617 Digest auth client.
+
+curl's own `--digest` handles the challenge/response handshake transparently,
+which is no good for testing edge cases (tampered nonces, replayed
+nonce-counts, wrong realms, bad algorithm tokens, ...). This module lets
+tests parse a WWW-Authenticate challenge, compute the expected response by
+hand, and build a (possibly deliberately broken) Authorization header.
+
+mod_auth_digest here only implements qop="auth" (see modules/aaa/mod_auth_digest.c
+Open Issues: "MD5-sess and auth-int are not yet implemented"), so this client
+only implements the qop=auth request-digest/response-auth formulas from
+RFC 2617 section 3.2.2.
+"""
+
+import hashlib
+import re
+from dataclasses import dataclass
+from typing import Dict, List, Optional
+
+_PARAM_RE = re.compile(r'(\w+)=(?:"([^"]*)"|([^\s,]+))\s*,?\s*')
+
+
+def _md5hex(s: str) -> str:
+ return hashlib.md5(s.encode('utf-8')).hexdigest()
+
+
+def parse_params(value: str) -> Dict[str, str]:
+ """Parse a comma-separated key=value / key="value" list, as used by
+ both WWW-Authenticate and Authentication-Info header values."""
+ params = {}
+ for m in _PARAM_RE.finditer(value):
+ key = m.group(1)
+ val = m.group(2) if m.group(2) is not None else m.group(3)
+ params[key.lower()] = val
+ return params
+
+
+@dataclass
+class DigestChallenge:
+ realm: Optional[str]
+ nonce: Optional[str]
+ algorithm: Optional[str] = None
+ opaque: Optional[str] = None
+ domain: Optional[str] = None
+ qop: Optional[str] = None
+ stale: bool = False
+ raw: str = ""
+
+ @staticmethod
+ def parse(www_authenticate: str) -> 'DigestChallenge':
+ assert www_authenticate.startswith("Digest "), \
+ f"not a Digest challenge: {www_authenticate}"
+ params = parse_params(www_authenticate[len("Digest "):])
+ return DigestChallenge(
+ realm=params.get('realm'),
+ nonce=params.get('nonce'),
+ algorithm=params.get('algorithm'),
+ opaque=params.get('opaque'),
+ domain=params.get('domain'),
+ qop=params.get('qop'),
+ stale=params.get('stale', '').lower() == 'true',
+ raw=www_authenticate,
+ )
+
+ def domain_list(self) -> List[str]:
+ return self.domain.split() if self.domain else []
+
+
+def ha1(username: str, realm: str, password: str) -> str:
+ return _md5hex(f"{username}:{realm}:{password}")
+
+
+def ha2(method: str, uri: str) -> str:
+ return _md5hex(f"{method}:{uri}")
+
+
+def request_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
+ qop: str, ha2_hex: str) -> str:
+ return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
+
+
+def rspauth_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
+ qop: str, uri: str) -> str:
+ """Authentication-Info's rspauth uses A2 = ':' + uri (no method)."""
+ ha2_hex = _md5hex(f":{uri}")
+ return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
+
+
+def build_authorization(username: str, challenge: DigestChallenge, password: str,
+ method: str, uri: str, nc: str = "00000001",
+ cnonce: str = "0a4f113b3c2e7a1d", qop: Optional[str] = "auth",
+ realm: Optional[str] = None, nonce_val: Optional[str] = None,
+ algorithm: Optional[str] = None, response: Optional[str] = None,
+ opaque: Optional[str] = None, include_opaque: bool = True,
+ include_qop_fields: bool = True, extra: Optional[List[str]] = None
+ ) -> str:
+ """Build a Digest Authorization header value.
+
+ By default this builds a *correct* response for the given challenge and
+ credentials. Any of realm=/nonce_val=/algorithm=/response=/opaque= can be
+ overridden to construct deliberately invalid headers, and qop=None with
+ include_qop_fields=False builds a legacy RFC 2069-style header (no qop,
+ cnonce, or nc) to prove that path is rejected.
+ """
+ eff_realm = challenge.realm if realm is None else realm
+ eff_nonce = challenge.nonce if nonce_val is None else nonce_val
+ if response is None:
+ h1 = ha1(username, eff_realm, password)
+ h2 = ha2(method, uri)
+ if qop:
+ response = request_digest(h1, eff_nonce, nc, cnonce, qop, h2)
+ else:
+ # legacy RFC 2069: MD5(HA1:nonce:HA2), no qop/cnonce/nc
+ response = _md5hex(f"{h1}:{eff_nonce}:{h2}")
+
+ parts = [
+ f'username="{username}"',
+ f'realm="{eff_realm}"',
+ f'nonce="{eff_nonce}"',
+ f'uri="{uri}"',
+ f'response="{response}"',
+ ]
+ if algorithm is not None:
+ parts.append(f'algorithm={algorithm}')
+ if qop and include_qop_fields:
+ parts.append(f'qop={qop}')
+ parts.append(f'nc={nc}')
+ parts.append(f'cnonce="{cnonce}"')
+ eff_opaque = challenge.opaque if (opaque is None and include_opaque) else opaque
+ if eff_opaque:
+ parts.append(f'opaque="{eff_opaque}"')
+ if extra:
+ parts.extend(extra)
+ return "Digest " + ", ".join(parts)
diff -Nru apache2-2.4.69/test/modules/aaa/env.py apache2-2.4.71/test/modules/aaa/env.py
--- apache2-2.4.69/test/modules/aaa/env.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,79 @@
+import hashlib
+import inspect
+import logging
+import os
+from typing import List, Optional
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+from pyhttpd.result import ExecResult
+
+log = logging.getLogger(__name__)
+
+
+class AAATestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(AAATestSetup)))
+ self.add_modules(["auth_digest", "authn_file", "authn_core",
+ "authz_core", "authz_user", "proxy", "proxy_http"])
+
+
+class AAATestEnv(HttpdTestEnv):
+
+ REALM = "AAA Digest Realm"
+ DIGEST_USER = "digestuser"
+ DIGEST_PASSWORD = "digestpass2617"
+ DIGEST_USER2 = "otheruser"
+ DIGEST_PASSWORD2 = "otherpass2617"
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ self.add_httpd_log_modules(["auth_digest", "authn_file", "authz_core"])
+ self._digest_pwfile = os.path.join(self.server_dir, "digest.passwd")
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=AAATestSetup(env=self))
+ self._write_digest_pwfile()
+
+ def _write_digest_pwfile(self):
+ def ha1(user, password):
+ return hashlib.md5(
+ f"{user}:{self.REALM}:{password}".encode()).hexdigest()
+
+ with open(self._digest_pwfile, 'w') as fd:
+ fd.write(f"{self.DIGEST_USER}:{self.REALM}:"
+ f"{ha1(self.DIGEST_USER, self.DIGEST_PASSWORD)}\n")
+ fd.write(f"{self.DIGEST_USER2}:{self.REALM}:"
+ f"{ha1(self.DIGEST_USER2, self.DIGEST_PASSWORD2)}\n")
+
+ @property
+ def digest_pwfile(self) -> str:
+ return self._digest_pwfile
+
+ def configtest(self, directory_lines: List[str], extra_top_lines: Optional[List[str]] = None
+ ) -> ExecResult:
+ """Run `httpd -t` against a minimal, standalone config built from the
+ already-generated modules.conf plus `directory_lines` wrapped in a
+ block over the shared docroot. Used to test directives
+ that are rejected at config-check time (e.g. AuthDigestQop values
+ other than 'auth') without touching the package's running server.
+ """
+ conf_path = os.path.join(self.gen_dir, "digest-configtest.conf")
+ modules_conf = os.path.join(self.server_conf_dir, "modules.conf")
+ lines = [
+ f'ServerRoot "{self.server_dir}"',
+ f'Include "{modules_conf}"',
+ f'DocumentRoot "{self.server_docs_dir}"',
+ f'Listen {self.http_port2}',
+ ]
+ if extra_top_lines:
+ lines.extend(extra_top_lines)
+ lines.append(f'')
+ lines.extend(f" {l}" for l in directory_lines)
+ lines.append('')
+ with open(conf_path, 'w') as fd:
+ fd.write('\n'.join(lines))
+ fd.write('\n')
+ httpd_bin = os.path.join(self.bin_dir, 'httpd')
+ return self.run([httpd_bin, '-t', '-f', conf_path])
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/default/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/default/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/default/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/default/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-default-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/domain/nested/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/domain/nested/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/domain/nested/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/domain/nested/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-domain-nested-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/domain/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/domain/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/domain/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/domain/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-domain-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/nccheck/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/nccheck/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/nccheck/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/nccheck/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-nccheck-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-nccheck-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/neverexpire/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/neverexpire/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/neverexpire/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/neverexpire/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-neverexpire-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/noprovider/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/noprovider/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/noprovider/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/noprovider/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-noprovider-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/onetime/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/onetime/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/onetime/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/onetime/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-onetime-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-onetime-secret
diff -Nru apache2-2.4.69/test/modules/aaa/htdocs/digest/shortlife/secret.txt apache2-2.4.71/test/modules/aaa/htdocs/digest/shortlife/secret.txt
--- apache2-2.4.69/test/modules/aaa/htdocs/digest/shortlife/secret.txt 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/htdocs/digest/shortlife/secret.txt 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+digest-shortlife-secret
diff -Nru apache2-2.4.69/test/modules/aaa/test_001_challenge_response.py apache2-2.4.71/test/modules/aaa/test_001_challenge_response.py
--- apache2-2.4.69/test/modules/aaa/test_001_challenge_response.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_001_challenge_response.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,199 @@
+"""RFC 2617 Digest challenge/response scenarios against mod_auth_digest's
+default configuration (AuthDigestProvider file, AuthDigestQop auth (the only
+supported value), AuthDigestNonceLifetime 300, no AuthDigestDomain).
+"""
+
+from . import digest_client as dc
+from pyhttpd.backport import not_backported
+from .env import AAATestEnv
+
+
+class TestDigestChallengeResponse:
+
+ def url(self, env, path="secret.txt", location="default"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location="default"):
+ r = env.curl_get(self.url(env, location=location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def test_digest_001_no_credentials(self, env):
+ # No Authorization header at all -> 401 with a well-formed challenge.
+ r = env.curl_get(self.url(env))
+ assert r.response["status"] == 401
+ auth = r.response["header"]["www-authenticate"]
+ challenge = dc.DigestChallenge.parse(auth)
+ assert challenge.realm == AAATestEnv.REALM
+ assert challenge.algorithm == "MD5"
+ assert challenge.qop == "auth"
+ assert challenge.stale is False
+ # no AuthDigestDomain configured for this Location -> no domain=
+ assert challenge.domain is None
+ # nonce-count checking is off and lifetime isn't 0 here, so the
+ # server has no reason to track this client -> no opaque=
+ assert challenge.opaque is None
+
+ def test_digest_002_success(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-default-secret\n"
+
+ def test_digest_003_rspauth(self, env):
+ # Authentication-Info's rspauth= must match what we independently
+ # compute from the same HA1 -- proves the server round-trips the
+ # session parameters (nonce/nc/cnonce/qop) correctly.
+ challenge = self.challenge(env)
+ nc = "00000001"
+ cnonce = "test-cnonce-rspauth"
+ uri = "/digest/default/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
+ expected = dc.rspauth_digest(h1, challenge.nonce, nc, cnonce, "auth", uri)
+ assert ai["rspauth"] == expected
+ assert ai["qop"] == "auth"
+ assert ai["nc"] == nc
+ assert ai["cnonce"] == cnonce
+
+ def test_digest_004_wrong_password(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, "not-the-password",
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01794"])
+
+ def test_digest_005_unknown_user(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ "no-such-user", challenge, "whatever",
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01790"])
+
+ def test_digest_006_second_user(self, env):
+ # a distinct user in the same password file also works
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER2, challenge, AAATestEnv.DIGEST_PASSWORD2,
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+
+ def test_digest_007_wrong_realm(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ realm="Some Other Realm")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01788"])
+
+ def test_digest_008_bad_algorithm_token(self, env):
+ # a client claiming an algorithm other than MD5 is rejected outright,
+ # even though the response hash below is computed correctly for MD5.
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ algorithm="MD5-sess")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01789"])
+
+ def test_digest_009_legacy_no_qop_rejected(self, env):
+ # RFC 2069-style digest (no qop/cnonce/nc) is syntactically valid but
+ # explicitly no longer supported by this module.
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ qop=None, include_qop_fields=False)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH10560"])
+
+ def test_digest_010_malformed_header_missing_field(self, env):
+ # missing "uri" entirely -> header is syntactically INVALID, so the
+ # server issues a fresh (non-stale) challenge rather than evaluating
+ # the (nonexistent) response hash.
+ challenge = self.challenge(env)
+ h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
+ auth = ('Digest username="digestuser", '
+ f'realm="{challenge.realm}", nonce="{challenge.nonce}", '
+ f'response="{h1}", qop=auth, nc=00000001, cnonce="x"')
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is False
+ env.httpd_error_log.ignore_recent(lognos=["AH01782"])
+
+ def test_digest_011_wrong_scheme(self, env):
+ r = env.curl_get(self.url(env), options=[
+ "-H", "Authorization: Basic ZGlnZXN0dXNlcjpkaWdlc3RwYXNz"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01781"])
+
+ def test_digest_012_uri_mismatch(self, env):
+ # The Authorization uri= must match the actual request-target; a
+ # self-consistent response computed for a *different* uri than the
+ # one actually requested is rejected as a bad request, before the
+ # hash is even checked.
+ challenge = self.challenge(env)
+ other_uri = "/digest/default/other-secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=other_uri)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 400
+ env.httpd_error_log.ignore_recent(lognos=["AH01786"])
+
+ def test_digest_013_invalid_opaque(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ opaque="not-a-hex-number")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01782", "AH01787"])
+
+ def test_digest_014_tampered_response_hash(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ response="0" * 32)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01794"])
+
+ @not_backported("r1939034", "rejecting an Authorization uri which fails to unescape")
+ def test_digest_015_uri_bad_escaping_rejected(self, env):
+ # The Authorization uri= is compared against the request-target after
+ # %-decoding both. A uri= that fails to decode -- an encoded NUL or
+ # slash -- must be rejected, not silently truncated at the bad octet
+ # so that its prefix matches the request-target.
+ challenge = self.challenge(env)
+ for bad_uri in ["/digest/default/secret.txt%00ignored",
+ "/digest/default%2fsecret.txt"]:
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=bad_uri)
+ r = env.curl_get(self.url(env),
+ options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 400, \
+ f"uri={bad_uri!r} was not rejected"
+ env.httpd_error_log.ignore_recent(lognos=["AH01783"])
diff -Nru apache2-2.4.69/test/modules/aaa/test_002_nonce.py apache2-2.4.71/test/modules/aaa/test_002_nonce.py
--- apache2-2.4.69/test/modules/aaa/test_002_nonce.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_002_nonce.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,134 @@
+"""Nonce lifecycle scenarios: tampered nonces, AuthDigestNonceLifetime
+expiry/reissue, a never-expiring nonce, and the one-time-nonce
+(AuthDigestNonceLifetime 0) case.
+"""
+
+import time
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestNonce:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def authenticate(self, env, location, challenge, nc="00000001",
+ cnonce="nonce-test-cnonce", uri=None):
+ uri = uri or f"/digest/{location}/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce)
+ return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_020_tampered_nonce_is_stale(self, env):
+ challenge = self.challenge(env, "default")
+ # flip a character in the middle of the opaque nonce blob: it stays
+ # the right length but its embedded hash no longer verifies.
+ bad = list(challenge.nonce)
+ mid = len(bad) // 2
+ bad[mid] = 'x' if bad[mid] != 'x' else 'y'
+ challenge.nonce = ''.join(bad)
+ r = self.authenticate(env, "default", challenge)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_021_garbage_nonce_hash_is_stale(self, env):
+ # A nonce must still look like "b64(time)+hex(hash)" (VALID_NONCE in
+ # mod_auth_digest.c checks length and the '=' padding boundary) to
+ # even be considered for a hash check; something that doesn't match
+ # that shape is instead rejected as a malformed header (see
+ # test_digest_010). Here we keep the genuine time-prefix (so the
+ # shape is valid) but replace the whole hash suffix with garbage, to
+ # hit check_nonce()'s "hash is not %s" path distinctly from
+ # test_digest_020's single-flipped-character tamper.
+ #
+ # The hash length depends on which keyed hash the module was built
+ # with, so take it from the nonce rather than assuming: the time is
+ # base64 of 8 bytes and so ends with the only '=' in the string.
+ challenge = self.challenge(env, "default")
+ time_prefix = challenge.nonce[:challenge.nonce.index('=') + 1]
+ hash_len = len(challenge.nonce) - len(time_prefix)
+ challenge.nonce = time_prefix + ("f" * hash_len)
+ r = self.authenticate(env, "default", challenge)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_022_short_lifetime_expires(self, env):
+ # AuthDigestNonceLifetime 2 for this location.
+ challenge = self.challenge(env, "shortlife")
+ r = self.authenticate(env, "shortlife", challenge)
+ assert r.response["status"] == 200
+
+ time.sleep(3)
+ # same nonce, now past its lifetime -> 401 stale=true
+ r = self.authenticate(env, "shortlife", challenge)
+ assert r.response["status"] == 401
+ stale_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert stale_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ # the fresh nonce from the stale challenge works again
+ r = self.authenticate(env, "shortlife", stale_challenge)
+ assert r.response["status"] == 200
+
+ def test_digest_023_never_expiring_nonce(self, env):
+ # AuthDigestNonceLifetime -1 for this location: no NcCheck is
+ # configured, so the identical Authorization line can simply be
+ # replayed after a delay and must still succeed both times.
+ challenge = self.challenge(env, "neverexpire")
+ r1 = self.authenticate(env, "neverexpire", challenge)
+ assert r1.response["status"] == 200
+
+ time.sleep(3)
+ r2 = self.authenticate(env, "neverexpire", challenge)
+ assert r2.response["status"] == 200
+
+ def test_digest_024_one_time_nonce_rejects_reuse(self, env):
+ # AuthDigestNonceLifetime 0: a successful request immediately
+ # supersedes its nonce (the tracked "last_nonce" moves on to the
+ # nextnonce from Authentication-Info), so replaying the very same
+ # nonce right afterwards must fail as stale. Each request against
+ # this client (success OR failure) advances the tracked nonce again,
+ # so this test does exactly one success followed by exactly one
+ # reuse -- no longer chain that would need to account for that.
+ challenge = self.challenge(env, "onetime")
+ assert challenge.opaque is not None, \
+ "one-time-nonce tracking requires an opaque to identify the client"
+
+ r1 = self.authenticate(env, "onetime", challenge)
+ assert r1.response["status"] == 200
+ ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
+ assert "nextnonce" in ai1
+ assert ai1["nextnonce"] != challenge.nonce
+
+ # reusing the exact same (now superseded) nonce fails as stale
+ r2 = self.authenticate(env, "onetime", challenge)
+ assert r2.response["status"] == 401
+ stale_challenge = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"])
+ assert stale_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_025_one_time_nonce_chain_continues(self, env):
+ # Following the nextnonce handed out on a successful response lets
+ # the client keep authenticating, one hop at a time.
+ challenge = self.challenge(env, "onetime")
+ r1 = self.authenticate(env, "onetime", challenge)
+ assert r1.response["status"] == 200
+ ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
+
+ challenge.nonce = ai1["nextnonce"]
+ r2 = self.authenticate(env, "onetime", challenge)
+ assert r2.response["status"] == 200
+ ai2 = dc.parse_params(r2.response["header"]["authentication-info"])
+ assert ai2["nextnonce"] != ai1["nextnonce"]
diff -Nru apache2-2.4.69/test/modules/aaa/test_003_nccheck.py apache2-2.4.71/test/modules/aaa/test_003_nccheck.py
--- apache2-2.4.69/test/modules/aaa/test_003_nccheck.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_003_nccheck.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,180 @@
+"""AuthDigestNcCheck replay-detection scenarios.
+
+The semantics are those of RFC 7616 3.4.3: the nonce-count is counted by
+the client per-nonce, so the server tracks a count per (client, nonce) pair
+and requires it to strictly increase. Within one nonce, an nc which has
+already been seen is a replay and is rejected; a *higher* nc than expected
+is not, since the client also counts the requests it sends to URIs in the
+protection space which turn out not to need authentication, and the server
+never sees those. Moving to a newer nonce starts a fresh count, and a nonce
+the client has already moved on from is rejected.
+
+The tracked count is only ever updated for a fully verified request, so a
+failed request cannot disturb the count of the client whose opaque it
+quotes; test_007_replay.py covers that property directly.
+"""
+
+from . import digest_client as dc
+from pyhttpd.backport import not_backported
+from .env import AAATestEnv
+
+
+class TestDigestNcCheck:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def authenticate(self, env, location, challenge, nc, cnonce="ncc-test-cnonce",
+ include_opaque=True):
+ uri = f"/digest/{location}/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce,
+ include_opaque=include_opaque)
+ return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_030_nccheck_requires_opaque(self, env):
+ # with AuthDigestNcCheck on, the server cannot verify nc without
+ # having tracked this client via its opaque -- omitting the opaque
+ # therefore fails, even with nc=00000001. It is rejected before the
+ # nc check is even reached: the nonce hash is computed over the
+ # opaque (gen_nonce_hash()), so a nonce quoted without the opaque it
+ # was issued with does not verify, and that is reported as stale.
+ challenge = self.challenge(env, "nccheck")
+ assert challenge.opaque is not None
+ r = self.authenticate(env, "nccheck", challenge, nc="00000001", include_opaque=False)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_031_nccheck_sequential_ok(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
+ assert r2.response["status"] == 200
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000003")
+ assert r3.response["status"] == 200
+
+ def test_digest_032_nccheck_replay_rejected(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
+ assert r2.response["status"] == 200
+
+ # replay an already-used nc -> rejected, and NOT reported as stale
+ # (this is a distinct failure mode from an invalid/expired nonce).
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r3.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r3.response["header"]["www-authenticate"])
+ assert new_challenge.stale is False
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ # recovery: the rejected attempt handed out a fresh challenge for
+ # this client, and following it -- new nonce, so the count starts
+ # over at 00000001 -- authenticates again.
+ r4 = self.authenticate(env, "nccheck", new_challenge, nc="00000001")
+ assert r4.response["status"] == 200
+
+ # the superseded nonce is not usable any more, at any nc.
+ r5 = self.authenticate(env, "nccheck", challenge, nc="00000003")
+ assert r5.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ def test_digest_033_nccheck_skip_ahead_allowed(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+
+ # skipping ahead is allowed: nc only has to be higher than the
+ # highest already seen for this nonce, not exactly one more. A
+ # client legitimately produces gaps by sending counted requests to
+ # URIs in the protection space which don't need authentication, and
+ # a higher nc is not a replay in any case.
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000009")
+ assert r2.response["status"] == 200
+
+ # ...and the skipped-over counts are spent: they are no longer
+ # accepted afterwards.
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000005")
+ assert r3.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ def test_digest_034_no_nccheck_allows_replay(self, env):
+ # the "default" location has no AuthDigestNcCheck (Off by default),
+ # so replaying the exact same nc is not detected or rejected.
+ challenge = self.challenge(env, "default")
+ r1 = self.authenticate(env, "default", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "default", challenge, nc="00000001")
+ assert r2.response["status"] == 200
+
+ def test_digest_035_out_of_range_opaque_is_not_truncated(self, env):
+ # The opaque is a 32- or 64-bit client id. A value which would
+ # truncate onto a live id must not select that client. This is
+ # observable in the challenge which comes back: a client the server
+ # still knows is re-challenged with its own opaque, whereas an unknown
+ # one is given a freshly minted opaque and stale=true.
+ challenge = self.challenge(env, "nccheck")
+ assert self.authenticate(env, "nccheck", challenge,
+ nc="00000001").response["status"] == 200
+
+ # 2^bits + the live id, which truncates to the live id in that many
+ # bits; one of the two is out of range whichever width is in use
+ for bits in (32, 64):
+ crafted = "%x" % ((1 << bits) + int(challenge.opaque, 16))
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/nccheck/secret.txt", nc="00000002",
+ cnonce="trunc-cnonce", opaque=crafted)
+ r = env.curl_get(env.mkurl("http", "aaa", "/digest/nccheck/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ # AH01782 (AH01787 before 64-bit ids) for an opaque out of range,
+ # AH01776 (nonce hash) for one in range but not the opaque the
+ # nonce was issued with
+ env.httpd_error_log.ignore_recent(lognos=["AH01782", "AH01787",
+ "AH01776"])
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"])
+ assert new_challenge.opaque != crafted, \
+ f"an opaque 2^{bits} above a live client id was truncated onto it"
+
+ @not_backported("r1939033", "nonce-count validation")
+ def test_digest_036_malformed_nc_rejected(self, env):
+ # The nonce-count is 8 hex digits. A value which is not -- a negative
+ # number, non-hex, or the wrong length -- must be rejected rather
+ # than parsed leniently into a wrapped count.
+ challenge = self.challenge(env, "nccheck")
+ for bad in ["-0000001", "zzzzzzzz", "000000001", "1", "0000 001"]:
+ r = self.authenticate(env, "nccheck", challenge, nc=bad,
+ cnonce=f"nc-{len(bad)}")
+ assert r.response["status"] == 401, f"nc={bad!r} was accepted"
+ env.httpd_error_log.ignore_recent(lognos=["AH01782"])
+
+ @not_backported("r1939033", "nonce-count validation")
+ def test_digest_037_malformed_nc_does_not_poison_client(self, env):
+ # A correctly-signed request carrying nc="-0000001" must not be
+ # accepted: before the count was validated it was read as a huge
+ # unsigned value and stored, locking the client out of every later
+ # (smaller) in-sequence count.
+ challenge = self.challenge(env, "nccheck")
+ assert self.authenticate(env, "nccheck", challenge,
+ nc="00000001").response["status"] == 200
+
+ r = self.authenticate(env, "nccheck", challenge, nc="-0000001",
+ cnonce="poison-cnonce")
+ assert r.response["status"] == 401, "a negative nc was accepted"
+
+ # the legitimate client carries on with its next in-sequence count
+ assert self.authenticate(env, "nccheck", challenge,
+ nc="00000002").response["status"] == 200, \
+ "a malformed nc locked the client out"
+ env.httpd_error_log.ignore_recent(lognos=["AH01782"])
diff -Nru apache2-2.4.69/test/modules/aaa/test_004_domain.py apache2-2.4.71/test/modules/aaa/test_004_domain.py
--- apache2-2.4.69/test/modules/aaa/test_004_domain.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_004_domain.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,56 @@
+"""AuthDigestDomain: presence, format, and inheritance of the domain=
+attribute in the WWW-Authenticate challenge.
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestDomain:
+
+ def url(self, env, path):
+ return env.mkurl("http", "aaa", path)
+
+ def test_digest_040_domain_attribute_present(self, env):
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ # set_uri_list() (mod_auth_digest.c) builds a single quoted,
+ # space-separated list from the configured AuthDigestDomain URIs.
+ assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
+ assert challenge.domain_list() == [
+ "/digest/domain/", "https://mirror.example.org/other/"]
+
+ def test_digest_041_no_domain_configured_omits_attribute(self, env):
+ r = env.curl_get(self.url(env, "/digest/default/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert challenge.domain is None
+
+ def test_digest_042_domain_location_still_authenticates(self, env):
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/domain/secret.txt")
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-domain-secret\n"
+
+ def test_digest_043_domain_inherited_by_nested_path(self, env):
+ # AuthDigestDomain is set on /digest/domain/; a path nested below it
+ # inherits the same directory config (same realm/credentials/domain).
+ r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert challenge.realm == AAATestEnv.REALM
+ assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
+
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/domain/nested/secret.txt")
+ r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-domain-nested-secret\n"
diff -Nru apache2-2.4.69/test/modules/aaa/test_005_provider.py apache2-2.4.71/test/modules/aaa/test_005_provider.py
--- apache2-2.4.69/test/modules/aaa/test_005_provider.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_005_provider.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,37 @@
+"""AuthDigestProvider scenarios."""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestProvider:
+
+ def url(self, env, path):
+ return env.mkurl("http", "aaa", path)
+
+ def test_digest_050_omitted_provider_defaults_to_file(self, env):
+ # /digest/noprovider/ has no AuthDigestProvider directive at all;
+ # mod_auth_digest falls back to the "file" provider (mod_authn_file)
+ # by default (see get_hash() / AUTHN_DEFAULT_PROVIDER in mod_auth.h).
+ path = "/digest/noprovider/secret.txt"
+ r = env.curl_get(self.url(env, path))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=path)
+ r = env.curl_get(self.url(env, path), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-noprovider-secret\n"
+
+ def test_digest_051_unknown_provider_rejected_at_config_time(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider no-such-provider',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "Unknown Authn provider" in r.stderr
diff -Nru apache2-2.4.69/test/modules/aaa/test_006_config_errors.py apache2-2.4.71/test/modules/aaa/test_006_config_errors.py
--- apache2-2.4.69/test/modules/aaa/test_006_config_errors.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_006_config_errors.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,107 @@
+"""Config-time validation for directives whose *documented* syntax (see
+docs/manual/mod/mod_auth_digest.xml) is broader than what this build's
+mod_auth_digest.c actually implements: AuthDigestQop only accepts "auth"
+(qop=none/auth-int are rejected -- the "Open Issues" comment in the source
+notes MD5-sess and auth-int were removed as incomplete), AuthDigestAlgorithm
+only accepts "MD5", and AuthDigestShmemSize enforces a minimum size. These
+are all checked with `httpd -t` against a throwaway config so the shared
+package server is never disturbed.
+"""
+
+from pyhttpd.backport import not_backported
+from .env import AAATestEnv
+
+
+class TestDigestConfigErrors:
+
+ def test_digest_060_qop_none_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop none',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "AuthDigestQop" in r.stderr
+
+ def test_digest_061_qop_auth_int_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop auth-int',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "AuthDigestQop" in r.stderr
+
+ def test_digest_062_qop_auth_accepted(self, env):
+ # the only value actually supported must still work.
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop auth',
+ 'Require valid-user',
+ ])
+ assert r.exit_code == 0
+
+ def test_digest_063_algorithm_md5_sess_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestAlgorithm MD5-sess',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "Unsupported algorithm" in r.stderr
+
+ def test_digest_064_algorithm_md5_accepted(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestAlgorithm MD5',
+ 'Require valid-user',
+ ])
+ assert r.exit_code == 0
+
+ def test_digest_065_shmemsize_too_small_rejected(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 10"])
+ assert r.exit_code != 0
+ assert "AuthDigestShmemSize" in r.stderr
+
+ def test_digest_066_shmemsize_valid_accepted(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 1000"])
+ assert r.exit_code == 0
+
+ def test_digest_067_shmemsize_units_accepted(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64K"])
+ assert r.exit_code == 0
+
+ @not_backported("r1939036", "AuthDigestShmemSize validation")
+ def test_digest_068_shmemsize_trailing_junk_rejected(self, env):
+ # Junk after the unit character must be rejected, not silently
+ # ignored (which read "64Kfoo" as 64K).
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64Kfoo"])
+ assert r.exit_code != 0
+ assert "AuthDigestShmemSize" in r.stderr
+
+ @not_backported("r1939036", "AuthDigestShmemSize validation")
+ def test_digest_069_shmemsize_no_room_for_entry_rejected(self, env):
+ # A size which would hold the table and an entry by their bare
+ # struct sizes, but not once the per-allocation rmm overhead is
+ # counted, must be rejected: otherwise every request needing an
+ # entry gets a 503 the config check did not warn about. 120 bytes is
+ # above those struct sizes but below the overhead-aware minimum on
+ # both 32- and 64-bit.
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 120"])
+ assert r.exit_code != 0
+ assert "AuthDigestShmemSize" in r.stderr
diff -Nru apache2-2.4.69/test/modules/aaa/test_007_replay.py apache2-2.4.71/test/modules/aaa/test_007_replay.py
--- apache2-2.4.69/test/modules/aaa/test_007_replay.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_007_replay.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,237 @@
+"""Replay-attack scenarios against AuthDigestNcCheck.
+
+AuthDigestNcCheck exists to detect replayed requests: the server tracks the
+highest nonce-count it has accepted from a client (identified by its opaque)
+for the nonce that client is using, and requires each request to raise it.
+
+The security property under test here is not just "the replayed request is
+rejected", but that rejecting it must not damage the legitimate client:
+
+ With nonce-count checking enabled, a replay attack MUST NOT affect the
+ original (legitimate) client by resetting its nonce count.
+
+It used to. On a failed authentication mod_auth_digest issues a fresh
+challenge via note_digest_auth_failure(), and for an already-known
+(opaque-identified) client that path reset client->nonce_count to 0, while
+the post_read_request hook re-incremented the count from 0 on the next
+request carrying that opaque. An attacker who could make *any* request fail
+for the victim's opaque therefore rewound the victim's counter, with two
+consequences:
+
+ * the legitimate client's next in-sequence nc no longer matched, so it
+ was locked out (denial of service against the victim), and
+ * the attacker's replayed request lined up with the rewound counter and
+ was accepted -- 200, 401, 200, 401, ... for one captured header, or
+ every time if the attacker rewound the counter deliberately first.
+
+The count is now tracked per (client, nonce) and updated only for a request
+which has been fully verified, so a request which fails to authenticate
+leaves the victim's state untouched.
+"""
+
+import time
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+# See the note in test_003_nccheck.py: a failed nc check is not reported as
+# stale, since it is a distinct failure mode from an invalid/expired nonce.
+NC_FAILED = "AH01774"
+NONCE_HASH_INVALID = "AH01776"
+PASSWORD_MISMATCH = "AH01794"
+
+
+class TestDigestReplay:
+
+ LOCATION = "nccheck"
+
+ def url(self, env, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/{path}")
+
+ @property
+ def uri(self):
+ return f"/digest/{self.LOCATION}/secret.txt"
+
+ def challenge(self, env):
+ r = env.curl_get(self.url(env))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def victim_header(self, challenge, nc, cnonce="victim-cnonce"):
+ """A correct Authorization header from the legitimate client."""
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri, nc=nc, cnonce=cnonce)
+
+ def attacker_header(self, challenge, nc="00000001", cnonce="attacker-cnonce"):
+ """A well-formed Digest header carrying the victim's opaque and nonce
+ but a bogus response digest. An attacker who has merely *seen* one of
+ the victim's requests can build this; no credentials are needed."""
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, "not-the-password",
+ method="GET", uri=self.uri, nc=nc, cnonce=cnonce,
+ response="0" * 32)
+
+ def send(self, env, auth):
+ return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_070_replay_does_not_lock_out_legit_client(self, env):
+ # The legitimate client authenticates a few times, in sequence.
+ challenge = self.challenge(env)
+ for nc in ["00000001", "00000002", "00000003"]:
+ assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200
+
+ # An attacker replays a request captured earlier in that sequence.
+ # Rejecting it is correct...
+ replayed = self.victim_header(challenge, "00000002")
+ replay_status = self.send(env, replayed).response["status"]
+
+ # ...but it must not disturb the legitimate client, which knows
+ # nothing of the replay and simply carries on with its next nc.
+ r = self.send(env, self.victim_header(challenge, "00000004"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert replay_status == 401
+ assert r.response["status"] == 200, \
+ "the replay reset the victim's nonce-count and locked it out"
+
+ def test_digest_071_bogus_request_does_not_lock_out_legit_client(self, env):
+ # Same property, but the attacker does not even need to have captured
+ # a complete valid request: any well-formed Digest header quoting the
+ # victim's opaque is enough to rewind the victim's counter.
+ challenge = self.challenge(env)
+ for nc in ["00000001", "00000002"]:
+ assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200
+
+ bogus_status = self.send(env, self.attacker_header(challenge)).response["status"]
+
+ r = self.send(env, self.victim_header(challenge, "00000003"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH])
+ assert bogus_status == 401
+ assert r.response["status"] == 200, \
+ "a bogus request reset the victim's nonce-count and locked it out"
+
+ def test_digest_072_captured_request_is_never_accepted_twice(self, env):
+ # The flip side of the same defect. One captured Authorization header
+ # is replayed verbatim; the first send is the genuine request, so it
+ # succeeds, and every later send must be rejected. Before the fix the
+ # rejection rewound the counter, so the replay after it lined up
+ # again: the observed pattern was 200, 401, 200, 401, ...
+ challenge = self.challenge(env)
+ captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce")
+
+ assert self.send(env, captured).response["status"] == 200
+ statuses = [self.send(env, captured).response["status"] for _ in range(4)]
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert statuses == [401, 401, 401, 401], \
+ f"replayed request was accepted again: {statuses}"
+
+ def test_digest_073_attacker_cannot_force_replay_to_succeed(self, env):
+ # Severity check: the attacker must not be able to line the counter
+ # up on demand. Before the fix, sending a bogus request first rewound
+ # the counter to 0, so the replay that followed succeeded every
+ # single time.
+ challenge = self.challenge(env)
+ captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce")
+ assert self.send(env, captured).response["status"] == 200
+
+ statuses = []
+ for _ in range(3):
+ self.send(env, self.attacker_header(challenge))
+ statuses.append(self.send(env, captured).response["status"])
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH])
+ assert statuses == [401, 401, 401], \
+ f"attacker replayed at will by forcing a counter reset: {statuses}"
+
+ def test_digest_074_legit_client_recovers_via_fresh_challenge(self, env):
+ # Invariant: a client whose nc is rejected is handed a fresh
+ # challenge, and following that challenge -- new nonce, so the count
+ # starts over at 1 -- gets it working again. Simply never resetting
+ # the count, without tying it to the nonce it was counted for, would
+ # break this.
+ challenge = self.challenge(env)
+ assert self.send(env, self.victim_header(challenge, "00000001")).response["status"] == 200
+
+ # provoke the rejection with a replay of that first request
+ r = self.send(env, self.victim_header(challenge, "00000001"))
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert fresh.stale is False
+ assert fresh.opaque == challenge.opaque, \
+ "the client keeps its identity across a re-challenge"
+ assert fresh.nonce != challenge.nonce
+
+ r = self.send(env, self.victim_header(fresh, "00000001"))
+ assert r.response["status"] == 200
+
+ def test_digest_075_nonce_is_bound_to_opaque(self, env):
+ # A captured header cannot be re-pointed at a *different* client
+ # session to dodge that session's nonce-count: the nonce hash is
+ # computed over the opaque (gen_nonce_hash()), so quoting one
+ # client's nonce under another client's opaque fails the hash check
+ # outright, and is reported as stale.
+ victim = self.challenge(env)
+ captured = self.victim_header(victim, "00000001", cnonce="captured-cnonce")
+ assert self.send(env, captured).response["status"] == 200
+
+ attacker = self.challenge(env)
+ assert attacker.opaque != victim.opaque
+ spliced = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, victim, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri, nc="00000001", cnonce="captured-cnonce",
+ opaque=attacker.opaque)
+ r = self.send(env, spliced)
+ env.httpd_error_log.ignore_recent(lognos=[NONCE_HASH_INVALID])
+ assert r.response["status"] == 401
+ assert dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"]).stale is True
+
+
+class TestDigestNcCheckExpiry:
+ """AuthDigestNcCheck combined with an expiring nonce.
+
+ The nonce is checked before the nonce-count, so that an expired nonce
+ still produces a "stale=true" challenge rather than being reported as a
+ replay -- the client then retries silently against the fresh nonce, with
+ its count restarted at 1.
+ """
+
+ LOCATION = "nccheck-shortlife" # AuthDigestNcCheck On, lifetime 2s
+
+ def url(self, env):
+ return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/secret.txt")
+
+ def challenge(self, env):
+ r = env.curl_get(self.url(env))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def send(self, env, challenge, nc):
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=f"/digest/{self.LOCATION}/secret.txt", nc=nc,
+ cnonce="expiry-cnonce")
+ return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_076_expired_nonce_restarts_the_count(self, env):
+ challenge = self.challenge(env)
+ assert self.send(env, challenge, "00000001").response["status"] == 200
+ assert self.send(env, challenge, "00000002").response["status"] == 200
+
+ time.sleep(3)
+
+ # past its lifetime: reported as stale, not as a nonce-count failure
+ r = self.send(env, challenge, "00000003")
+ assert r.response["status"] == 401
+ fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert fresh.stale is True
+
+ # the client restarts its count for the fresh nonce, which must not
+ # collide with the count already tracked for the expired one
+ assert self.send(env, fresh, "00000001").response["status"] == 200
+ assert self.send(env, fresh, "00000002").response["status"] == 200
+
+ # and the expired nonce stays unusable
+ r = self.send(env, challenge, "00000004")
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01776", NC_FAILED])
diff -Nru apache2-2.4.69/test/modules/aaa/test_008_onetime_nccheck.py apache2-2.4.71/test/modules/aaa/test_008_onetime_nccheck.py
--- apache2-2.4.69/test/modules/aaa/test_008_onetime_nccheck.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_008_onetime_nccheck.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,194 @@
+"""One-time nonces (AuthDigestNonceLifetime 0), alone and with AuthDigestNcCheck.
+
+With a lifetime of 0 the server hands the client a nextnonce on every
+successful response, and a nonce may be used once: it is accepted only if
+it is newer than the last nonce that client used. The client counts from 1
+again for each new nonce, so with AuthDigestNcCheck also on, every request
+legitimately carries nc=00000001.
+
+The security property here is the one from test_007_replay.py, applied to
+the other piece of per-client state:
+
+ A request which fails to authenticate MUST NOT invalidate the nonce
+ which the legitimate client is holding.
+
+It did, when the client's state was the last nonce *issued* to it:
+note_digest_auth_failure() generates a fresh nonce and recorded it there,
+and any request quoting the client's opaque can provoke a challenge. So an
+eavesdropper who had captured one Authorization header could replay it at
+will -- the replay itself was correctly rejected, but it moved the stored
+nonce on, and the victim's next request was then refused. The opaque is in
+the clear in every challenge and every request, and such a captured header
+never goes stale for this purpose, since it works by failing.
+
+This needed no credentials and, despite where it was first noticed, no
+AuthDigestNcCheck: the tests below run against both locations to pin that
+the defect was in the one-time-nonce path, not in the combination.
+
+The state is now the last nonce the client actually *used*, which nothing
+unauthenticated can move.
+"""
+
+import pytest
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+BOTH = ["onetime", "onetime-nccheck"]
+
+NC_FAILED = "AH01774"
+NONCE_HASH_INVALID = "AH01776"
+PASSWORD_MISMATCH = "AH01794"
+CLIENT_UNKNOWN = "AH10618"
+
+
+class TestOneTimeNonce:
+
+ def url(self, env, location):
+ return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"])
+ assert challenge.opaque is not None, \
+ "one-time nonces are tracked per client, so an opaque is required"
+ return challenge
+
+ def header(self, location, challenge, nc="00000001", cnonce="onetime-cnonce",
+ response=None):
+ """A correct Authorization header, unless response= overrides the
+ digest -- an attacker can build that from an observed request
+ without knowing the password."""
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=f"/digest/{location}/secret.txt", nc=nc,
+ cnonce=cnonce, response=response)
+
+ def send(self, env, location, auth):
+ return env.curl_get(self.url(env, location),
+ options=["-H", f"Authorization: {auth}"])
+
+ def follow_nextnonce(self, r, challenge):
+ """Advance the client to the nextnonce it was just handed."""
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ assert "nextnonce" in ai
+ assert ai["nextnonce"] != challenge.nonce
+ challenge.nonce = ai["nextnonce"]
+
+ def test_digest_080_nccheck_does_not_break_the_onetime_chain(self, env):
+ # Each nonce is new, so the client's count restarts at 1 every time
+ # and the nonce-count check must not object. (Before the nonce-count
+ # was tracked per-nonce this alternated 200, 401, 200, 401, ...)
+ challenge = self.challenge(env, "onetime-nccheck")
+ for _ in range(4):
+ r = self.send(env, "onetime-nccheck", self.header(
+ "onetime-nccheck", challenge, nc="00000001"))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_081_onetime_nonce_rejects_immediate_replay(self, env, location):
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ assert self.send(env, location, captured).response["status"] == 200
+
+ r = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert r.response["status"] == 401
+ assert dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"]).stale is True
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_082_onetime_nonce_rejects_replay_after_rotation(self, env, location):
+ # The captured header stays rejected once the client has moved on
+ # through the nextnonce chain.
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ r = self.send(env, location, captured)
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ r = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert r.response["status"] == 401
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_083_replay_does_not_invalidate_the_clients_nonce(self, env, location):
+ # The eavesdropper's version: no credentials, no forgery, just one
+ # captured Authorization header replayed after the client has moved
+ # on. Rejecting it is correct; denying the client's next request is
+ # not.
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ r = self.send(env, location, captured)
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ replay_status = self.send(env, location, captured).response["status"]
+
+ r = self.send(env, location, self.header(location, challenge))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert replay_status == 401
+ assert r.response["status"] == 200, \
+ "the replay moved the client's one-time nonce on and locked it out"
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_084_bogus_request_does_not_invalidate_the_clients_nonce(
+ self, env, location):
+ # Same property with a forged digest rather than a captured one, so
+ # it holds however the attacker's request comes to fail.
+ challenge = self.challenge(env, location)
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ bogus = self.header(location, challenge, cnonce="bogus",
+ response="0" * 32)
+ bogus_status = self.send(env, location, bogus).response["status"]
+
+ r = self.send(env, location, self.header(location, challenge))
+ env.httpd_error_log.ignore_recent(
+ lognos=[NC_FAILED, NONCE_HASH_INVALID, PASSWORD_MISMATCH])
+ assert bogus_status == 401
+ assert r.response["status"] == 200, \
+ "the bogus request moved the client's one-time nonce on and locked it out"
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_085_replay_rejected_when_the_client_entry_is_gone(self, env,
+ location):
+ # The client table is small here -- conftest pins AuthDigestShmemSize
+ # to 1000 bytes, "~ 12 entries" -- so filling it makes gc() discard
+ # entries. The pressure has to come from clients which have
+ # authenticated: gc() discards the entries of clients which never
+ # did first, so bare requests no longer evict anybody who matters.
+ #
+ # A captured request must still not be replayable once the client's
+ # entry has gone. It used to be: check_nonce() skipped the one-time
+ # comparison entirely when the client was unknown, so the nonce was
+ # taken on trust and the replay served the protected resource.
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ assert self.send(env, location, captured).response["status"] == 200
+ assert self.send(env, location, captured).response["status"] == 401
+
+ # Note that the victim is left alone while the table fills: looking
+ # its entry up would move it to the front of its bucket, which is
+ # exactly what saves an entry from gc().
+ for _ in range(30):
+ other = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, other)).response["status"] == 200
+
+ r = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
+ assert r.response["status"] == 401, \
+ "captured request replayed once the client entry was evicted"
+ gone = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert gone.opaque != challenge.opaque, \
+ "the entry was not evicted, so the gc'd-client path is untested"
diff -Nru apache2-2.4.69/test/modules/aaa/test_009_restart.py apache2-2.4.71/test/modules/aaa/test_009_restart.py
--- apache2-2.4.69/test/modules/aaa/test_009_restart.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_009_restart.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,230 @@
+"""Per-client state across a server restart.
+
+The client table lives in a shared memory segment created by post_config,
+and that segment does not survive a restart: the restart loop in
+server/main.c clears pconf, which destroys the segment, and post_config
+then builds a new one, empty.
+
+The nonce does survive, because the secret it is hashed with is kept in
+retained data across restarts. So a client returning after a restart
+presents a nonce which still verifies, naming per-client state which no
+longer exists.
+
+The right answer for such a client is a stale=true challenge: its
+credentials were never in doubt, only the server-side state backing its
+nonce is gone, so it should retry silently against the fresh nonce rather
+than being told its authentication failed. mod_auth_digest does exactly
+that when it finds the client unknown (AH10618).
+
+It stopped doing it once the id had been handed to somebody else, which the
+ids restarting from 1 made routine rather than exotic: the first clients
+seen after a restart took exactly the ids the clients from before it were
+still quoting. A returning client was then checked against a *different*
+client's entry and reported as a nonce-count failure -- stale=false, which
+a browser shows as a failed password.
+
+With one-time nonces it was a replay hole rather than a wrong answer to the
+user. Single use is enforced by remembering the last nonce each client
+used, and that memory dies with the segment while the nonce does not; the
+one-time counter restarts at 0, so a nonce from before the restart outranks
+anything the new holder of its id has reached, and was accepted. An
+eavesdropper who had captured one used request only had to wait for a
+restart.
+
+The ids are now seeded randomly for each segment, so a returning client's
+opaque no longer names anybody, and it takes the unknown-client path above.
+"""
+
+import sys
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from . import digest_client as dc
+from .env import AAATestEnv
+
+NC_FAILED = "AH01774"
+NONCE_INVALID = "AH01776"
+CLIENT_UNKNOWN = "AH10618"
+ONETIME_REUSED = "AH01779"
+
+_WIN32 = sys.platform == "win32"
+
+# on the tests that fail without it (the others pass there as well)
+NOT_BACKPORTED = not_backported("r1937711", "random client ids after a restart")
+
+
+class TestDigestRestart:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def uri(self, location):
+ return f"/digest/{location}/secret.txt"
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def header(self, location, challenge, nc="00000001", cnonce="restart-cnonce"):
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce)
+
+ def send(self, env, location, auth):
+ return env.curl_get(self.url(env, location),
+ options=["-H", f"Authorization: {auth}"])
+
+ def reload(self, env):
+ assert env.apache_reload() == 0, "graceful restart failed"
+
+ def challenge_of(self, r):
+ """The challenge carried by a 401 response."""
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def follow_nextnonce(self, r, challenge):
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ assert "nextnonce" in ai
+ challenge.nonce = ai["nextnonce"]
+
+ @pytest.mark.xfail(sys.platform == "win32", reason=
+ "mpm_winnt child is a separate process, "
+ "ap_retained_data does not survive restart")
+ def test_digest_090_returning_client_is_challenged_as_stale(self, env):
+ # A client which authenticated before a restart comes back afterwards
+ # with the nonce it was holding. The state naming its opaque is gone,
+ # so the request cannot be accepted -- but the client did nothing
+ # wrong, and must be told to retry rather than that it failed.
+ location = "nccheck"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, challenge)).response["status"] == 200
+
+ self.reload(env)
+
+ r = self.send(env, location, self.header(location, challenge, "00000002"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]
+ + ([NONCE_INVALID] if _WIN32 else []))
+ assert r.response["status"] == 401
+ fresh = self.challenge_of(r)
+ assert fresh.stale, \
+ "a client returning after a restart was told its authentication " \
+ "failed, rather than being asked to retry with a fresh nonce"
+
+ # ...and the retry against that fresh challenge succeeds.
+ assert self.send(env, location,
+ self.header(location, fresh)).response["status"] == 200
+
+ @pytest.mark.xfail(sys.platform == "win32", reason=
+ "mpm_winnt child is a separate process, "
+ "ap_retained_data does not survive restart")
+ @NOT_BACKPORTED
+ def test_digest_091_returning_client_is_stale_even_if_its_id_was_reused(self, env):
+ # Same property, but now the id space has caught up: after the restart
+ # a new client is handed the id the returning client still quotes.
+ # That is not an unlikely coincidence -- the counter restarts from 1,
+ # so the first clients seen after a restart take exactly the ids the
+ # clients from before it are holding.
+ location = "nccheck"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, challenge)).response["status"] == 200
+
+ self.reload(env)
+
+ # A different client arrives first and is given the recycled id.
+ # Today that newcomer is handed the returning client's id, because
+ # the counter restarts at 1. The assertions below deliberately do not
+ # require it: a server which stops recycling ids satisfies this
+ # property by making the returning client simply unknown, which is
+ # the outcome under test either way.
+ newcomer = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, newcomer)).response["status"] == 200
+
+ r = self.send(env, location, self.header(location, challenge, "00000002"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]
+ + ([NONCE_INVALID] if _WIN32 else []))
+ assert r.response["status"] == 401
+ assert self.challenge_of(r).stale, \
+ "a returning client was reported as a possible replay attack " \
+ "because its id had been given to somebody else"
+
+ @pytest.mark.xfail(sys.platform == "win32", reason=
+ "mpm_winnt child is a separate process, "
+ "ap_retained_data does not survive restart")
+ @NOT_BACKPORTED
+ def test_digest_092_onetime_nonce_from_before_a_restart_is_not_accepted(self, env):
+ # One-time nonces are ordered by a counter which restarts at 0 with
+ # the segment, while the nonce itself stays verifiable. A client
+ # holding an unused nonce from before the restart therefore presents
+ # a counter value the new server has not reached yet.
+ location = "onetime"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ for _ in range(5):
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+ # challenge.nonce is now an unused nonce, well ahead of the counter
+ # that a restart will reset to 0.
+
+ self.reload(env)
+
+ newcomer = self.challenge(env, location)
+ r = self.send(env, location, self.header(location, newcomer))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, newcomer)
+
+ # The nonce from the previous server generation must not be usable.
+ stale_use = self.send(env, location, self.header(location, challenge))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN,
+ ONETIME_REUSED]
+ + ([NONCE_INVALID] if _WIN32 else []))
+ assert stale_use.response["status"] == 401, \
+ "a one-time nonce issued before the restart was accepted after it"
+
+ # ...and the client which legitimately owns that entry still works.
+ assert self.send(env, location,
+ self.header(location, newcomer)).response["status"] == 200, \
+ "the stale nonce locked out the client holding that id"
+
+ @pytest.mark.xfail(sys.platform == "win32", reason=
+ "mpm_winnt child is a separate process, "
+ "ap_retained_data does not survive restart")
+ @NOT_BACKPORTED
+ def test_digest_093_onetime_request_cannot_be_replayed_across_a_restart(self, env):
+ # The severity case. A one-time nonce is single-use because the server
+ # remembers the last nonce each client used -- and that memory does
+ # not survive a restart, while the nonce does. So an eavesdropper who
+ # captured one *successfully used* request needs only to wait for a
+ # restart: the counter it outranks has gone back to zero, and the id
+ # it names is handed straight back out.
+ location = "onetime"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ for _ in range(5):
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ captured = self.header(location, challenge) # a used request
+ self.follow_nextnonce(r, challenge)
+
+ assert self.send(env, location, captured).response["status"] == 401, \
+ "the captured request was not single-use before the restart"
+
+ self.reload(env)
+
+ # Somebody authenticates, so the recycled id names a live entry.
+ newcomer = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, newcomer)).response["status"] == 200
+
+ replay = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN,
+ ONETIME_REUSED]
+ + ([NONCE_INVALID] if _WIN32 else []))
+ assert replay.response["status"] == 401, \
+ "a captured one-time request was replayed successfully after a restart"
diff -Nru apache2-2.4.69/test/modules/aaa/test_010_eviction.py apache2-2.4.71/test/modules/aaa/test_010_eviction.py
--- apache2-2.4.69/test/modules/aaa/test_010_eviction.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_010_eviction.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,134 @@
+"""Which client entries are discarded when the table is full.
+
+The client table is a fixed-size shared memory segment (AuthDigestShmemSize,
+pinned small by conftest here), and gc() makes room by discarding one entry
+from each bucket. An entry is allocated for any request which needs a
+challenge, including one carrying no credentials at all, since the challenge
+has to carry the identifier the client will be tracked by. An attacker can
+therefore fill the table with bare requests as fast as it can send them.
+
+What that costs the clients already using the server depends on which entry
+gc() picks. Discarding a client which has authenticated costs it a request:
+its nonce-count and last-used nonce go with the entry, so its next request
+is answered with a fresh challenge (stale=true) and has to be retried.
+Discarding a client which has never authenticated costs nothing at all --
+the entry records nothing yet, and the client simply gets a new identifier
+with its next challenge.
+
+So gc() prefers the entries which have never been used to authenticate,
+which are exactly the ones a flood of unauthenticated requests creates.
+
+"Never used to authenticate" is last_nonce_time == 0, which is exact in both
+of the configurations that track clients: an accepted request stores the
+time its nonce was generated at, which is a wall-clock time for a nonce with
+a lifetime and a counter which starts at 1 for a one-time nonce. Neither is
+ever 0. The tests below run against both to pin that.
+"""
+
+import sys
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from . import digest_client as dc
+from .env import AAATestEnv
+
+NC_FAILED = "AH01774"
+NONCE_INVALID = "AH01776"
+CLIENT_UNKNOWN = "AH10618"
+_WIN32 = sys.platform == "win32"
+
+# A location tracking clients for the nonce-count, and one tracking them for
+# one-time nonces: the two put different kinds of value in last_nonce_time.
+BOTH = ["nccheck", "onetime"]
+
+# on the tests that fail without it (the others pass there as well)
+NOT_BACKPORTED = not_backported("r1937713", "evicting never-authenticated clients first")
+
+
+class TestDigestEviction:
+
+ def url(self, env, location):
+ return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt")
+
+ def uri(self, location):
+ return f"/digest/{location}/secret.txt"
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def header(self, location, challenge, nc="00000001", cnonce="eviction-cnonce"):
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce)
+
+ def send(self, env, location, auth):
+ return env.curl_get(self.url(env, location),
+ options=["-H", f"Authorization: {auth}"])
+
+ def advance(self, r, challenge):
+ """Prepare the client's next request, and return the nc to send with
+ it. A one-time nonce is replaced by the nextnonce just handed out,
+ and the count restarts at 1 for it; otherwise the nonce stays and the
+ count goes up."""
+ ai = r.response["header"].get("authentication-info")
+ if ai:
+ params = dc.parse_params(ai)
+ if "nextnonce" in params:
+ challenge.nonce = params["nextnonce"]
+ return "00000001"
+ return "00000002"
+
+ def flood(self, env, location, count):
+ """Bare requests, each of which allocates a client entry."""
+ for _ in range(count):
+ env.curl_get(self.url(env, location))
+
+ @pytest.mark.xfail(sys.platform == "win32", reason=
+ "mpm_winnt child is a separate process, "
+ "ap_retained_data does not survive restart")
+ @pytest.mark.parametrize("location", BOTH)
+ @NOT_BACKPORTED
+ def test_digest_100_authenticated_client_survives_a_flood(self, env, location):
+ # The legitimate client authenticates, so its entry now records the
+ # nonce it used.
+ challenge = self.challenge(env, location)
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ nc = self.advance(r, challenge)
+
+ # An attacker fills the table several times over with requests which
+ # carry no credentials at all.
+ self.flood(env, location, 60)
+
+ # The victim carries on. Its entry must still be there: it is the
+ # only one in the table which is worth keeping.
+ r = self.send(env, location, self.header(location, challenge, nc))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]
+ + ([NONCE_INVALID] if _WIN32 else []))
+ assert r.response["status"] == 200, \
+ "a flood of unauthenticated requests evicted an authenticated client"
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_101_unused_entries_are_the_ones_discarded(self, env, location):
+ # The counterpart: the entries a flood creates are themselves the
+ # ones discarded, so a flood cannot fill the table permanently. The
+ # opaque handed out at the start of one no longer names an entry by
+ # the end, which the server reports by minting a new one rather than
+ # echoing it back -- and as a stale challenge, since nothing was
+ # wrong with the client's credentials.
+ first = self.challenge(env, location)
+ self.flood(env, location, 60)
+
+ r = self.send(env, location, self.header(location, first))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]
+ + ([NONCE_INVALID] if _WIN32 else []))
+ assert r.response["status"] == 401
+ again = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert again.opaque != first.opaque, \
+ "the unused entry should have been discarded by the flood"
+ assert again.stale, \
+ "a client whose unused entry was discarded should be re-challenged " \
+ "as stale, not told its authentication failed"
diff -Nru apache2-2.4.69/test/modules/aaa/test_011_authinfo_errors.py apache2-2.4.71/test/modules/aaa/test_011_authinfo_errors.py
--- apache2-2.4.69/test/modules/aaa/test_011_authinfo_errors.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_011_authinfo_errors.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,93 @@
+"""Authentication-Info survives an error response.
+
+The header is added at fixups, so unless it goes into err_headers_out it
+is dropped when the response turns into an error, costing a one-time-nonce
+client the nextnonce and forcing a stale re-challenge. A 404 for a missing
+file inside the protected area reaches the error path while fully
+authenticated, so it exercises this without a handler like mod_dav.
+"""
+
+from pyhttpd.backport import not_backported
+from . import digest_client as dc
+from .env import AAATestEnv
+
+# on the tests that fail without it (the others pass there as well)
+NOT_BACKPORTED = not_backported("r1937888", "Authentication-Info on error responses")
+
+
+class TestAuthInfoOnError:
+
+ def url(self, env, location, path):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def uri(self, location, path):
+ return f"/digest/{location}/{path}"
+
+ def challenge(self, env, location, path="secret.txt"):
+ r = env.curl_get(self.url(env, location, path))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def auth(self, env, location, path, challenge, nc="00000001"):
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri(location, path), nc=nc,
+ cnonce="authinfo-cnonce")
+
+ def get(self, env, location, path, challenge, nc="00000001"):
+ return env.curl_get(self.url(env, location, path), options=[
+ "-H", f"Authorization: {self.auth(env, location, path, challenge, nc)}"])
+
+ @NOT_BACKPORTED
+ def test_digest_110_error_response_carries_authentication_info(self, env):
+ # A 404 for an authenticated request still confirms the response to
+ # the client: Authentication-Info with a valid rspauth.
+ location = "default"
+ challenge = self.challenge(env, location)
+ r = self.get(env, location, "no-such-file.txt", challenge)
+ assert r.response["status"] == 404
+ assert "authentication-info" in r.response["header"], \
+ "the error response dropped Authentication-Info"
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ expect = dc.rspauth_digest(
+ dc.ha1(AAATestEnv.DIGEST_USER, AAATestEnv.REALM,
+ AAATestEnv.DIGEST_PASSWORD),
+ challenge.nonce, "00000001", "authinfo-cnonce", "auth",
+ self.uri(location, "no-such-file.txt"))
+ assert ai.get("rspauth") == expect, "rspauth wrong on the error response"
+
+ @NOT_BACKPORTED
+ def test_digest_111_onetime_client_continues_after_an_error(self, env):
+ # Under one-time nonces the error must still hand back a nextnonce,
+ # or the client's next request is stale-challenged. Prove the client
+ # can carry straight on to a real request with what the 404 gave it.
+ location = "onetime"
+ challenge = self.challenge(env, location)
+ r = self.get(env, location, "no-such-file.txt", challenge)
+ assert r.response["status"] == 404
+ assert "authentication-info" in r.response["header"], \
+ "the one-time-nonce error response dropped Authentication-Info"
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ assert "nextnonce" in ai, "no nextnonce to continue with after the error"
+
+ challenge.nonce = ai["nextnonce"]
+ ok = self.get(env, location, "secret.txt", challenge)
+ assert ok.response["status"] == 200, \
+ "the nextnonce from the error response was not usable"
+
+ def test_digest_112_challenge_has_no_authentication_info(self, env):
+ # When the error is itself a 401, the challenge stands alone: the
+ # client is not handed a nextnonce it could not use beside a fresh
+ # nonce. A wrong password reaches note_digest_auth_failure.
+ location = "onetime"
+ challenge = self.challenge(env, location)
+ bad = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, "wrong-password",
+ method="GET", uri=self.uri(location, "secret.txt"),
+ cnonce="authinfo-cnonce")
+ r = env.curl_get(self.url(env, location, "secret.txt"),
+ options=["-H", f"Authorization: {bad}"])
+ env.httpd_error_log.ignore_recent(lognos=["AH01794"]) # password mismatch
+ assert r.response["status"] == 401
+ assert "authentication-info" not in r.response["header"], \
+ "a 401 challenge carried an Authentication-Info header"
diff -Nru apache2-2.4.69/test/modules/aaa/test_012_errordoc.py apache2-2.4.71/test/modules/aaa/test_012_errordoc.py
--- apache2-2.4.69/test/modules/aaa/test_012_errordoc.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_012_errordoc.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,40 @@
+"""An ErrorDocument redirecting into a Digest-protected location.
+
+A request which fails header validation -- e.g. an HTTP/1.1 request with no
+Host header -- is rejected in ap_check_request_header, before
+ap_post_read_request runs. The Digest module sets up its per-request record
+in a post_read_request hook, so that record was never created for such a
+request.
+
+If ErrorDocument then redirects the failure into a Digest-protected
+location, the auth hook runs for the redirect and walks back along ->prev
+to the original request to find the shared record -- which is NULL here.
+Dereferencing it crashes the child, so an unauthenticated client can take
+down a worker with a single malformed request.
+"""
+
+from pyhttpd.backport import skip_not_backported
+from .env import AAATestEnv
+
+pytestmark = skip_not_backported("r1939032", "no Digest record for a request failing header validation")
+
+
+class TestDigestErrorDocument:
+
+ def test_digest_125_errordoc_into_digest_without_prr(self, env):
+ # Send an HTTP/1.1 request with the Host header removed; the server
+ # answers 400 before the Digest post_read_request hook runs, and the
+ # ErrorDocument points into a Digest-protected location.
+ url = env.mkurl("http", "aaa", "/anything")
+ r = env.curl_get(url, options=["--http1.1", "-H", "Host:"])
+
+ # The request must get an HTTP response, not a reset connection from
+ # a crashed child.
+ assert r.exit_code == 0, \
+ f"no HTTP response (curl exit {r.exit_code}): the worker likely " \
+ f"crashed handling the ErrorDocument"
+ assert r.response["status"] in (400, 401)
+
+ # The server is still serving afterwards.
+ r = env.curl_get(env.mkurl("http", "aaa", "/digest/default/secret.txt"))
+ assert r.response["status"] == 401
diff -Nru apache2-2.4.69/test/modules/aaa/test_012_truncated_nonce.py apache2-2.4.71/test/modules/aaa/test_012_truncated_nonce.py
--- apache2-2.4.69/test/modules/aaa/test_012_truncated_nonce.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_012_truncated_nonce.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,29 @@
+import re
+
+import pytest
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+class TestDigestTruncatedNonce:
+
+ def test_digest_012_01(self, env):
+ url = env.mkurl("http", "aaa", "/digest/default/secret.txt")
+ r1 = env.curl_get(url)
+ assert r1.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(
+ r1.response["header"]["www-authenticate"])
+ # Truncate: take from the '=' onward, removing most of the base64 time prefix.
+ eq_pos = challenge.nonce.index("=")
+ truncated_nonce = challenge.nonce[eq_pos:]
+
+ auth_header = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ nonce_val=truncated_nonce)
+
+ start_pos = env.httpd_error_log.current_pos()
+ r2 = env.curl_get(url, options=['-H', f'Authorization: {auth_header}'])
+ assert r2.response["status"] == 401
+
+ env.httpd_error_log.ignore_recent(lognos=["AH01782"])
diff -Nru apache2-2.4.69/test/modules/aaa/test_013_proxy_domain.py apache2-2.4.71/test/modules/aaa/test_013_proxy_domain.py
--- apache2-2.4.69/test/modules/aaa/test_013_proxy_domain.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/aaa/test_013_proxy_domain.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,28 @@
+"""AuthDigestDomain on a reverse-proxied location.
+
+Digest auth can run at a reverse proxy, for a location mapped by ProxyPass.
+Such a request carries PROXYREQ_REVERSE, but its 401 is an ordinary
+origin-style WWW-Authenticate (the client authenticates to this server, not
+through it), so the configured AuthDigestDomain belongs in the challenge
+just as for a non-proxied location.
+
+The suppression test treated any proxy request alike and dropped domain=
+for the reverse-proxied case, so clients re-authenticated per URI prefix
+and sent the Authorization header outside the intended protection space.
+"""
+
+from pyhttpd.backport import not_backported
+from . import digest_client as dc
+
+pytestmark = not_backported("r1939035", "domain= for reverse-proxied requests")
+
+
+class TestDigestProxyDomain:
+
+ def test_digest_130_domain_sent_for_reverse_proxy(self, env):
+ r = env.curl_get(env.mkurl("http", "aaa", "/pxdomain/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"])
+ assert "/pxdomain/" in challenge.domain_list(), \
+ "AuthDigestDomain was dropped from a reverse-proxied challenge"
diff -Nru apache2-2.4.69/test/modules/arch/linux/README apache2-2.4.71/test/modules/arch/linux/README
--- apache2-2.4.69/test/modules/arch/linux/README 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/README 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,109 @@
+mod_systemd tests
+=================
+
+What is different about this module
+-----------------------------------
+mod_systemd has no directives. Everything it does is driven by the
+environment httpd was started with, and almost everything it produces goes
+to the service manager rather than to a client:
+
+ - sd_notify(3) datagrams sent to $NOTIFY_SOCKET at four points: reading
+ the configuration (pre_config), configuration loaded (post_config),
+ the MPM starting (pre_mpm, the only notification carrying MAINPID),
+ and a status line refreshed by the monitor hook.
+ - listening sockets inherited from the service manager, found through
+ $LISTEN_FDS. mod_systemd registers the two optional functions
+ server/listen.c calls, so loading the module is what enables socket
+ activation and not loading it is what disables it.
+ - ap_extended_status forced on in pre_config, so that the monitor hook
+ has request counts to report.
+ - the watchdog keep-alive, WATCHDOG=1, sent while the service manager
+ asks for one. Which it does through the environment as well:
+ WatchdogSec= in the unit becomes $WATCHDOG_USEC, and $WATCHDOG_PID
+ names the process expected to report.
+
+None of that is observable over HTTP, so the tests observe it directly.
+
+How the tests run without systemd, and without privileges
+---------------------------------------------------------
+There is no need for a service manager to exercise the protocol. Only
+test_005, the last test of test_006, and test_007 involve systemd at all;
+the rest run anywhere, and need nothing from the systemd package beyond the
+libsystemd httpd itself is linked against.
+
+ test_001_notify.py $NOTIFY_SOCKET is an ordinary AF_UNIX datagram
+ socket the test binds itself (env.NotifyListener).
+ sd_notify writes to whatever that variable names, so
+ the test reads httpd's notifications straight off the
+ socket. HttpdTestEnv.set_httpd_env() puts the
+ variable into the environment apachectl passes on.
+
+ test_002_monitor.py The periodic status line. ap_run_monitor() is
+ called once every ten turns of the parent's ~1s
+ loop, so these tests wait up to 25 seconds.
+
+ test_003_extended_status.py
+ The ExtendedStatus side effect, observed through
+ mod_status.
+
+ test_004_socket_activation.py
+ The test opens the listening socket itself and
+ hands it to httpd as descriptor 3 with LISTEN_FDS
+ and LISTEN_PID set, which is the whole protocol.
+ systemd-socket-activate would do the same, but its
+ --now option is newer than the systemd on some
+ distributions, and doing it directly needs no
+ systemd tooling at all.
+
+ test_006_watchdog.py The keep-alive. Mostly the same stand-in socket,
+ with $WATCHDOG_USEC set in the environment httpd is
+ started with; env.ForegroundServer runs httpd in the
+ foreground where $WATCHDOG_PID can name it, which
+ apachectl cannot do for a parent it has not forked
+ yet. The last test uses a real unit with
+ WatchdogSec=, where systemd's own
+ WatchdogTimestampMonotonic is the evidence that the
+ pings arrived.
+
+ The keep-alive is sent from the monitor hook, so it
+ is only as frequent as that hook is: about every ten
+ seconds. A WatchdogSec shorter than twice that
+ cannot be met, and mod_systemd says so (AH10621)
+ rather than letting a healthy server be killed.
+
+ test_005_service.py The real thing: a transient Type=notify unit run
+ with "systemd-run --user". This is what checks that
+ systemd holds the unit in "activating" until READY=1
+ arrives, tracks the right MainPID, and shows the
+ reported STATUS= as the unit's status text.
+ Skipped when the user has no systemd manager.
+
+ test_007_notify_reload.py
+ The reload protocol, as a transient
+ Type=notify-reload unit: that "systemctl reload"
+ waits for the new configuration to be in use, that a
+ configuration which does not parse fails the reload
+ without disturbing the running server, and that a
+ reload restarts it gracefully and only once. Also
+ skipped where systemd is older than 253, which is
+ where Type=notify-reload arrived.
+
+The whole package is skipped unless mod_systemd was built, which needs
+configure --enable-systemd. A static module is enough for everything
+except the test which has to leave mod_systemd out of the configuration;
+that one needs --enable-systemd=shared.
+
+Running the systemd suites where there is no user session
+---------------------------------------------------------
+"systemctl --user" needs a per-user manager, which a login session has but
+a CI container does not; enabling lingering needs privileges. Where that
+is a problem, run the tests inside a container with systemd as pid 1,
+which rootless podman supports on a cgroup v2 host:
+
+ podman run --rm -it --systemd=always \
+ -v $PWD:/src:z -w /src registry.fedoraproject.org/fedora:latest \
+ /usr/sbin/init
+
+then, in another terminal, "podman exec" into it and run pytest as a
+non-root user with XDG_RUNTIME_DIR set. The other four suites need none of
+this and run anywhere.
diff -Nru apache2-2.4.69/test/modules/arch/linux/conftest.py apache2-2.4.71/test/modules/arch/linux/conftest.py
--- apache2-2.4.69/test/modules/arch/linux/conftest.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,41 @@
+import logging
+import os
+import sys
+
+import pytest
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../../..'))
+
+from .env import SystemdTestEnv
+
+
+def pytest_report_header(config, start_path):
+ env = SystemdTestEnv()
+ return f"mod_systemd [apache: {env.get_httpd_version()}, " \
+ f"mpm: {env.mpm_module}, {env.prefix}]"
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> SystemdTestEnv:
+ level = logging.INFO
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+ logging.getLogger('').addHandler(console)
+ logging.getLogger('').setLevel(level=level)
+ env = SystemdTestEnv(pytestconfig=pytestconfig)
+ env.require(env.has_systemd_module, "mod_systemd is not built, configure with --enable-systemd",
+ dep="mod_systemd",
+ detected=f"not found in {env.libexec_dir}")
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+ env.start_notify_listener()
+ yield env
+ env.stop_notify_listener()
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
diff -Nru apache2-2.4.69/test/modules/arch/linux/env.py apache2-2.4.71/test/modules/arch/linux/env.py
--- apache2-2.4.69/test/modules/arch/linux/env.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,638 @@
+import inspect
+import logging
+import os
+import re
+import shutil
+import signal
+import socket
+import subprocess
+import threading
+import time
+from typing import Callable, Dict, List, Optional
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+
+log = logging.getLogger(__name__)
+
+
+class SystemdTestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(SystemdTestSetup)))
+ # mod_systemd is only built with --enable-systemd, so it must not be
+ # a hard requirement; the tests skip when it is absent.
+ self.add_optional_modules(["systemd"])
+
+
+class SystemdTestEnv(HttpdTestEnv):
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ self.add_httpd_log_modules(["core"])
+ self._notify = None
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=SystemdTestSetup(env=self))
+
+ @property
+ def systemd_is_dso(self) -> bool:
+ return os.path.isfile(os.path.join(self.libexec_dir, 'mod_systemd.so'))
+
+ @property
+ def has_systemd_module(self) -> bool:
+ """Whether mod_systemd is available, however it was built:
+ --enable-systemd links it statically, --enable-systemd=shared
+ builds the DSO."""
+ if self.systemd_is_dso:
+ return True
+ p = subprocess.run([self.httpd_bin, '-l'], capture_output=True,
+ text=True)
+ return re.search(r'^\s+mod_systemd\.c$', p.stdout, re.M) is not None
+
+ @property
+ def notify(self) -> 'NotifyListener':
+ """The stand-in notification socket httpd reports to."""
+ return self._notify
+
+ def start_notify_listener(self) -> 'NotifyListener':
+ """Bind the notification socket and point httpd's $NOTIFY_SOCKET at it.
+
+ This must happen before the server is first started, since libsystemd
+ reads $NOTIFY_SOCKET from the environment of the httpd process.
+ """
+ assert self._notify is None
+ self._notify = NotifyListener(
+ os.path.join(self.server_dir, 'systemd-notify.sock'))
+ self.set_httpd_env('NOTIFY_SOCKET', self._notify.path)
+ return self._notify
+
+ def stop_notify_listener(self):
+ if self._notify is not None:
+ self._notify.close()
+ self._notify = None
+
+ def server_env(self) -> Dict[str, str]:
+ """The environment httpd is started with, as apachectl gets it."""
+ return self._clean_path_env()
+
+ @property
+ def httpd_bin(self) -> str:
+ return os.path.join(self.bin_dir, 'httpd')
+
+
+class NotifyListener:
+ """A stand-in for the systemd notification socket.
+
+ sd_notify(3) does nothing more than send a datagram to the AF_UNIX
+ socket named by $NOTIFY_SOCKET, so an unconnected datagram socket is
+ enough to observe everything mod_systemd reports, with no systemd
+ instance and no privileges involved.
+
+ Datagrams are drained by a background thread so that the periodic
+ notifications from the monitor hook cannot fill the socket buffer
+ while a test is doing something else.
+ """
+
+ def __init__(self, path: str):
+ # sockaddr_un is limited to 108 bytes; well within reach for a
+ # source tree in a home directory, but check rather than fail
+ # obscurely inside bind().
+ assert len(path) < 100, f"notification socket path too long: {path}"
+ if os.path.exists(path):
+ os.unlink(path)
+ self.path = path
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
+ self._sock.bind(path)
+ self._sock.settimeout(0.1)
+ self._lock = threading.Lock()
+ self._messages: List[Dict[str, str]] = []
+ self._stop = threading.Event()
+ self._thread = threading.Thread(target=self._drain, daemon=True)
+ self._thread.start()
+
+ def _drain(self):
+ while not self._stop.is_set():
+ try:
+ data = self._sock.recv(8192)
+ except socket.timeout:
+ continue
+ except OSError:
+ break
+ try:
+ msg = self.parse(data)
+ except Exception as ex:
+ # Never let one unreadable datagram stop the listener: the
+ # tests would then see silence rather than a failure.
+ log.warning(f"undecodable notification {data!r}: {ex}")
+ continue
+ log.debug(f"notify: {msg}")
+ with self._lock:
+ self._messages.append(msg)
+
+ def close(self):
+ self._stop.set()
+ self._thread.join(timeout=2)
+ self._sock.close()
+ if os.path.exists(self.path):
+ os.unlink(self.path)
+
+ @staticmethod
+ def parse(data: bytes) -> Dict[str, str]:
+ """Split one notification datagram into its NAME=VALUE assignments.
+
+ The last line carries no trailing newline in the MAINPID
+ notification, and a value may itself contain '='.
+ """
+ msg = {}
+ for line in data.decode(errors='replace').split('\n'):
+ name, sep, value = line.partition('=')
+ if sep:
+ msg[name] = value
+ return msg
+
+ @property
+ def messages(self) -> List[Dict[str, str]]:
+ with self._lock:
+ return list(self._messages)
+
+ def clear(self):
+ with self._lock:
+ self._messages.clear()
+
+ def wait_for(self, match: Callable[[Dict[str, str]], bool],
+ timeout: float = 5.0) -> Optional[Dict[str, str]]:
+ """Return the first message satisfying `match`, waiting for it to
+ arrive if it has not already. Returns None on timeout."""
+ end = time.time() + timeout
+ seen = 0
+ while True:
+ with self._lock:
+ pending = self._messages[seen:]
+ seen = len(self._messages)
+ for msg in pending:
+ if match(msg):
+ return msg
+ if time.time() >= end:
+ return None
+ time.sleep(0.05)
+
+ def wait_for_key(self, key: str, timeout: float = 5.0) \
+ -> Optional[Dict[str, str]]:
+ return self.wait_for(lambda m: key in m, timeout=timeout)
+
+ def wait_for_status(self, pattern: str, timeout: float = 5.0) \
+ -> Optional[Dict[str, str]]:
+ rx = re.compile(pattern)
+ return self.wait_for(lambda m: 'STATUS' in m and rx.search(m['STATUS']),
+ timeout=timeout)
+
+ def statuses(self) -> List[str]:
+ return [m['STATUS'] for m in self.messages if 'STATUS' in m]
+
+
+# The STATUS= line the monitor hook reports, from systemd_monitor() in
+# modules/arch/unix/mod_systemd.c. Idle and busy are the percentages
+# ap_get_sload() computes, and are -1 when there are no workers at all.
+MONITOR_STATUS = re.compile(
+ r'^Total requests: (?P\d+);\s*'
+ r'Idle/Busy workers (?P-?\d+)%/(?P-?\d+)%;\s*'
+ r'Requests/sec: (?P\S+);\s*'
+ r'Bytes served/sec: (?P.*)B/sec$')
+
+# ap_run_monitor() is called every INTERVAL_OF_WRITABLE_PROBES (10) turns of
+# the ~1s parent loop in ap_wait_or_timeout(), so a status update is up to
+# roughly 10 seconds away. Waiting for one costs that; waiting to be sure
+# none is coming costs the whole timeout, so keep it to a small multiple.
+MONITOR_TIMEOUT = 25.0
+
+# Showing that no report is coming costs the whole wait, so it only has to
+# comfortably outlast one turn of that cycle.
+NO_MONITOR_TIMEOUT = 15.0
+
+
+# The keep-alive notification is sent from the same monitor hook, so waiting
+# for one costs the same as waiting for a status report.
+WATCHDOG_TIMEOUT = MONITOR_TIMEOUT
+
+# The shortest WatchdogSec mod_systemd will accept without complaining, which
+# is twice the monitor interval: the recommended keep-alive period is half the
+# watchdog timeout, and half of anything shorter than this is out of reach of a
+# hook which runs every ten seconds. Keep in step with mod_systemd.c.
+SYSTEMD_MONITOR_INTERVAL = 10
+MIN_WATCHDOG_SEC = 2 * SYSTEMD_MONITOR_INTERVAL
+
+
+def is_watchdog_ping(msg: Dict[str, str]) -> bool:
+ """Whether a notification carries the keep-alive ping.
+
+ mod_systemd is free to send WATCHDOG=1 in a datagram of its own or
+ alongside whatever else it is reporting, so match on the assignment
+ rather than on the message being only that.
+ """
+ return msg.get('WATCHDOG') == '1'
+
+
+# A configuration for a server run directly rather than through apachectl,
+# sharing the server root, module list and error log with the rest of the
+# suite but with its own pid file and port.
+STANDALONE_CONF = """
+ServerRoot "${server_dir}"
+DefaultRuntimeDir logs
+PidFile "${pidfile}"
+Include "conf/${modules_conf}"
+ServerName standalone.test
+ErrorLog "logs/error_log"
+LogLevel ${loglevel}
+DocumentRoot "${server_dir}/htdocs"
+
+ Require all granted
+
+
+ SSLSessionCache "shmcb:ssl_gcache_data(32000)"
+
+${extra}
+Listen ${port}
+"""
+
+
+def write_server_conf(env: SystemdTestEnv, name: str, port: int,
+ modules_conf: str = 'modules.conf',
+ extra: str = '') -> str:
+ """Write a standalone configuration and return its path."""
+ path = os.path.join(env.server_conf_dir, f'{name}.conf')
+ with open(path, 'w') as fd:
+ fd.write(STANDALONE_CONF
+ .replace('${server_dir}', env.server_dir)
+ .replace('${pidfile}',
+ os.path.join(env.server_logs_dir, f'{name}.pid'))
+ .replace('${loglevel}', 'debug' if env.verbosity else 'warn')
+ .replace('${modules_conf}', modules_conf)
+ .replace('${extra}', extra)
+ .replace('${port}', str(port)))
+ return path
+
+
+def http_responds(port: int, timeout: float = 2.0) -> bool:
+ """One HTTP request, without curl, so that a listening socket which
+ nothing is serving cannot be mistaken for a running server: under socket
+ activation the listener exists before httpd does."""
+ try:
+ with socket.create_connection(('127.0.0.1', port), 1.0) as c:
+ c.settimeout(timeout)
+ c.sendall(b'GET / HTTP/1.0\r\nHost: standalone.test\r\n\r\n')
+ return c.recv(64).startswith(b'HTTP/1.')
+ except OSError:
+ return False
+
+
+def error_log_size(env: SystemdTestEnv) -> int:
+ """Where the shared error log ends now, so that a later read can take
+ only what one operation wrote."""
+ try:
+ return os.path.getsize(env.httpd_error_log.path)
+ except OSError:
+ return 0
+
+
+def error_log_since(env: SystemdTestEnv, pos: int) -> str:
+ """The error log written since error_log_size() returned pos."""
+ try:
+ with open(env.httpd_error_log.path, errors='replace') as fd:
+ fd.seek(pos)
+ return fd.read()
+ except OSError:
+ return ''
+
+
+class ActivatedServer:
+ """An httpd handed a listening socket the way a service manager does.
+
+ The protocol is only $LISTEN_FDS descriptors starting at 3, and
+ $LISTEN_PID naming the process they were meant for, so the test opens
+ the socket and speaks it directly. systemd-socket-activate would do
+ the same, but its --now option is too recent to rely on, and this
+ needs no systemd tooling at all.
+
+ apachectl cannot pass descriptors, so httpd is run directly, in the
+ foreground: LISTEN_PID has to be the process which calls
+ sd_listen_fds(), and a daemonised parent would not be it.
+ """
+
+ def __init__(self, env: SystemdTestEnv, port: int, name: str = 'activate',
+ extra: str = '', listen_port: int = None,
+ modules_conf: str = 'modules.conf'):
+ self.env = env
+ self.port = port
+ # The port systemd-socket-activate binds, which is the same as the
+ # configured one unless a test wants them to disagree.
+ self.listen_port = port if listen_port is None else listen_port
+ self.name = name
+ self.pid_file = os.path.join(env.server_logs_dir, f'{name}.pid')
+ self.proc = None
+ self.stdout = None
+ self.stderr = None
+ self.conf_file = write_server_conf(env, name, port,
+ modules_conf=modules_conf,
+ extra=extra)
+
+ @staticmethod
+ def modules_conf_without(env: SystemdTestEnv, module: str) -> str:
+ """Write a copy of the generated module list with one module left
+ out, to check what happens when it is not loaded."""
+ name = f'modules-no-{module}.conf'
+ src = os.path.join(env.server_conf_dir, 'modules.conf')
+ rx = re.compile(rf'^\s*LoadModule\s+{module}_module\b')
+ with open(src) as fd:
+ lines = [l for l in fd if not rx.match(l)]
+ with open(os.path.join(env.server_conf_dir, name), 'w') as fd:
+ fd.writelines(lines)
+ return name
+
+ def args(self, fd: int) -> List[str]:
+ # The shell moves the inherited socket to descriptor 3 and names
+ # itself in LISTEN_PID before exec'ing httpd in its place, which is
+ # the one thing this cannot do from the parent: the pid has to be
+ # the one which will call sd_listen_fds(). bash rather than sh
+ # because dash parses only one digit in a redirection, and the
+ # socket lands well above descriptor 9.
+ return [
+ 'bash', '-c',
+ f'exec 3<&{fd}; export LISTEN_FDS=1 LISTEN_FDNAMES=activate '
+ 'LISTEN_PID=$$; exec "$0" "$@"',
+ self.env.httpd_bin, '-DFOREGROUND',
+ '-d', self.env.server_dir, '-f', self.conf_file,
+ ]
+
+ def start(self) -> 'ActivatedServer':
+ lsock = socket.create_server(('', self.listen_port),
+ family=socket.AF_INET6,
+ dualstack_ipv6=True, backlog=128)
+ try:
+ # A new session so that the whole group can be signalled on the
+ # way out.
+ self.proc = subprocess.Popen(
+ self.args(lsock.fileno()), env=self.env.server_env(),
+ start_new_session=True, pass_fds=(lsock.fileno(),),
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ finally:
+ # The child holds it now; keeping a copy here would leave the
+ # port bound after the server is gone.
+ lsock.close()
+ return self
+
+ def _reap(self):
+ """Collect the output of a server which has exited."""
+ if self.stderr is None and self.proc.poll() is not None:
+ self.stdout, self.stderr = self.proc.communicate()
+
+ def wait_exit(self, timeout: float = 10.0) -> int:
+ """Wait for a server which is expected to fail to start."""
+ self.stdout, self.stderr = self.proc.communicate(timeout=timeout)
+ return self.proc.returncode
+
+ def is_live(self, timeout: float = 10.0) -> bool:
+ end = time.time() + timeout
+ while True:
+ if self.proc.poll() is not None:
+ # Collect its diagnostics, so that a test reporting the
+ # server did not come up can say why.
+ self._reap()
+ return False
+ if http_responds(self.port):
+ return True
+ if time.time() >= end:
+ return False
+ time.sleep(0.2)
+
+ def is_running(self, settle: float = 2.0) -> bool:
+ """Whether the server is still up once it has had time to fail.
+
+ A restart which cannot find its sockets takes a few milliseconds to
+ bring the parent down, and its old children go on serving after it,
+ so neither an immediate check nor a request proves anything.
+ """
+ end = time.time() + settle
+ while time.time() < end:
+ if self.proc.poll() is not None:
+ return False
+ time.sleep(0.1)
+ return True
+
+ def reload(self) -> int:
+ """Ask the running server to restart gracefully."""
+ r = self.env.run([self.env.httpd_bin, '-d', self.env.server_dir,
+ '-f', self.conf_file, '-k', 'graceful'],
+ env=self.env.server_env())
+ return r.exit_code
+
+ def _signal_group(self, sig: int) -> bool:
+ """Signal every process still in the server's group, reporting
+ whether any remained. start_new_session() made the process we
+ launched the group leader, so its pid is the group id whether or
+ not it is still alive."""
+ try:
+ os.killpg(self.proc.pid, sig)
+ return True
+ except OSError:
+ return False
+
+ def stop(self):
+ if self.proc is None:
+ return
+ self._signal_group(signal.SIGTERM)
+ if self.proc.poll() is None:
+ try:
+ self.stdout, self.stderr = self.proc.communicate(timeout=10)
+ except subprocess.TimeoutExpired:
+ self._signal_group(signal.SIGKILL)
+ self.stdout, self.stderr = self.proc.communicate()
+ # A parent which died during a failed restart leaves its children
+ # behind, still holding the listening socket and still answering.
+ # They have to go too, or the next test finds the port taken.
+ end = time.time() + 5
+ while self._signal_group(0):
+ if time.time() >= end:
+ self._signal_group(signal.SIGKILL)
+ break
+ time.sleep(0.1)
+
+ def __enter__(self) -> 'ActivatedServer':
+ return self.start()
+
+ def __exit__(self, *args):
+ self.stop()
+
+
+# Type=notify-reload and ReloadSignal= both arrived in systemd 253. An
+# unrecognised Type= does not degrade to anything, it stops the unit loading
+# at all, so there is nothing to fall back to and the tests are skipped.
+NOTIFY_RELOAD_VERSION = 253
+
+
+def systemd_version() -> int:
+ """The version of the systemd on this host, or 0 if it cannot be asked.
+ "systemctl --version" opens with "systemd 259 (259.8-1.fc44)"."""
+ try:
+ p = subprocess.run(['systemctl', '--version'], capture_output=True,
+ text=True, timeout=15)
+ except (OSError, subprocess.TimeoutExpired):
+ return 0
+ m = re.match(r'systemd (\d+)', p.stdout)
+ return int(m.group(1)) if m else 0
+
+
+class TransientService:
+ """httpd run as a real transient systemd unit, with systemd-run.
+
+ This is the only harness here which exercises the notification protocol
+ against systemd itself rather than a stand-in socket: systemd provides
+ NOTIFY_SOCKET, holds the service in "activating" until READY=1 arrives,
+ tracks MAINPID, and shows the reported STATUS= as the unit's status
+ text. It needs a per-user service manager, which a login session has
+ but a bare CI container does not.
+
+ service_type selects what is exercised: "notify" for startup and
+ shutdown, "notify-reload" for the reload protocol on top of them.
+ """
+
+ def __init__(self, env: SystemdTestEnv, port: int,
+ name: str = None, extra: str = '',
+ service_type: str = 'notify', exec_reload: bool = True,
+ properties: List[str] = None):
+ self.env = env
+ self.port = port
+ self.unit = name or f'httpd-test-{os.getpid()}'
+ self.service_type = service_type
+ self.exec_reload = exec_reload
+ self.conf_file = write_server_conf(env, 'transient', port, extra=extra)
+ self.pid_file = os.path.join(env.server_logs_dir, 'transient.pid')
+ # Extra --property arguments for the unit, such as WatchdogSec= or
+ # ReloadSignal=.
+ self.properties = list(properties or [])
+
+ def read_pid(self) -> Optional[int]:
+ try:
+ with open(self.pid_file) as fd:
+ return int(fd.read().strip())
+ except (OSError, ValueError):
+ return None
+
+ @staticmethod
+ def is_available() -> bool:
+ """Whether this user has a systemd manager to run services under."""
+ if shutil.which('systemd-run') is None:
+ return False
+ if not os.environ.get('XDG_RUNTIME_DIR'):
+ return False
+ # Talking to the manager at all is the test: without a login
+ # session, or with lingering off, there is none to talk to.
+ try:
+ return subprocess.run(['systemctl', '--user', 'show', '-p',
+ 'Version'], capture_output=True,
+ timeout=15).returncode == 0
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+
+ def systemctl(self, *args,
+ timeout: float = 60.0) -> subprocess.CompletedProcess:
+ return subprocess.run(['systemctl', '--user', *args],
+ capture_output=True, text=True, timeout=timeout)
+
+ def show(self, prop: str) -> str:
+ r = self.systemctl('show', '-p', prop, '--value', f'{self.unit}.service')
+ return r.stdout.strip()
+
+ def start(self, timeout: float = 20.0) -> subprocess.CompletedProcess:
+ httpd = self.env.httpd_bin
+ props = [
+ f'--service-type={self.service_type}',
+ '--property=KillMode=mixed',
+ # A reload which is never reported finished holds the job open
+ # until this elapses, so keep it to the same bound as the start.
+ f'--property=TimeoutStartSec={int(timeout)}',
+ ]
+ if self.exec_reload:
+ props.append(f'--property=ExecReload={httpd} '
+ f'-d {self.env.server_dir} '
+ f'-f {self.conf_file} -k graceful')
+ props += [f'--property={p}' for p in self.properties]
+ r = subprocess.run([
+ 'systemd-run', '--user', '--collect', '--quiet',
+ '--unit', self.unit, *props,
+ httpd, '-DFOREGROUND',
+ '-d', self.env.server_dir, '-f', self.conf_file,
+ ], capture_output=True, text=True, timeout=timeout)
+ return r
+
+ def reload(self, timeout: float = 60.0) -> subprocess.CompletedProcess:
+ """Ask the manager to reload the unit. Under Type=notify-reload
+ this returns once the server has reported the reload finished;
+ under Type=notify, as soon as ExecReload= has exited."""
+ return self.systemctl('reload', f'{self.unit}.service',
+ timeout=timeout)
+
+ def rewrite_conf(self, extra: str = ''):
+ """Replace the configuration the unit reads on its next reload.
+ The path does not change, so ExecStart= and ExecReload= still name
+ it."""
+ self.conf_file = write_server_conf(self.env, 'transient', self.port,
+ extra=extra)
+
+ def wait_active(self, timeout: float = 20.0) -> bool:
+ end = time.time() + timeout
+ while time.time() < end:
+ if self.show('ActiveState') == 'active':
+ return True
+ time.sleep(0.2)
+ return False
+
+ def stop(self):
+ self.systemctl('stop', f'{self.unit}.service')
+ self.systemctl('reset-failed', f'{self.unit}.service')
+
+ def __enter__(self) -> 'TransientService':
+ return self
+
+ def __exit__(self, *args):
+ self.stop()
+
+
+class ForegroundServer(ActivatedServer):
+ """httpd run directly in the foreground, with extra environment.
+
+ The watchdog protocol is keyed to a process id: sd_watchdog_enabled(3)
+ ignores $WATCHDOG_USEC unless $WATCHDOG_PID is unset or names the
+ process reading it. apachectl cannot be used to set that, since the
+ variable has to name the parent httpd and the pid is not known until it
+ exists, so the value is assigned in a shell which then exec's httpd in
+ its own place -- the same trick ActivatedServer uses for $LISTEN_PID.
+
+ Assignments are shell words, so "$$" in a value is the pid httpd will
+ have.
+ """
+
+ def __init__(self, env: SystemdTestEnv, port: int,
+ name: str = 'foreground', extra: str = '',
+ setenv: Dict[str, str] = None,
+ modules_conf: str = 'modules.conf'):
+ super().__init__(env, port, name=name, extra=extra,
+ modules_conf=modules_conf)
+ self.setenv = dict(setenv or {})
+
+ def args(self, fd: int = None) -> List[str]:
+ assigns = ' '.join(f'{k}={v}' for k, v in self.setenv.items())
+ return [
+ 'bash', '-c',
+ f'export {assigns}; exec "$0" "$@"' if assigns else 'exec "$0" "$@"',
+ self.env.httpd_bin, '-DFOREGROUND',
+ '-d', self.env.server_dir, '-f', self.conf_file,
+ ]
+
+ def start(self) -> 'ForegroundServer':
+ # A new session, so the whole group can be signalled on the way out
+ # even when a failed restart leaves children behind.
+ self.proc = subprocess.Popen(
+ self.args(), env=self.env.server_env(), start_new_session=True,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ return self
diff -Nru apache2-2.4.69/test/modules/arch/linux/test_001_notify.py apache2-2.4.71/test/modules/arch/linux/test_001_notify.py
--- apache2-2.4.69/test/modules/arch/linux/test_001_notify.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/test_001_notify.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,155 @@
+import time
+
+import pytest
+
+from pyhttpd.backport import not_backported
+
+from pyhttpd.conf import HttpdConf
+
+
+@not_backported("r1937596-r1937647", "mod_systemd watchdog, notify and socket activation")
+class TestSystemdNotify:
+ """The service notifications mod_systemd sends over $NOTIFY_SOCKET
+ across the server lifecycle."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = HttpdConf(env)
+ conf.add_vhost_test1()
+ conf.install()
+ # Each test drives startup itself, so leave the server down.
+ assert env.apache_stop() == 0
+
+ @staticmethod
+ def index_of(messages, match):
+ for i, msg in enumerate(messages):
+ if match(msg):
+ return i
+ return -1
+
+ def test_systemd_001_01_startup(self, env):
+ """Startup reports configuration reading, then readiness, then that
+ the MPM is serving."""
+ env.notify.clear()
+ assert env.apache_restart() == 0
+ assert env.notify.wait_for_status(r'^Reading configuration\.\.\.$'), \
+ f"no reload notification, got {env.notify.statuses()}"
+ assert env.notify.wait_for_status(r'^Configuration loaded\.$'), \
+ f"no ready notification, got {env.notify.statuses()}"
+ assert env.notify.wait_for_status(r'^Processing requests\.\.\.$'), \
+ f"no pre_mpm notification, got {env.notify.statuses()}"
+
+ def test_systemd_001_02_reloading_before_ready(self, env):
+ """RELOADING=1 is sent while the configuration is read, and READY=1
+ only once it has been."""
+ env.notify.clear()
+ assert env.apache_restart() == 0
+ assert env.notify.wait_for_status(r'^Configuration loaded\.$')
+ msgs = env.notify.messages
+ reloading = self.index_of(
+ msgs, lambda m: m.get('RELOADING') == '1'
+ and m.get('STATUS') == 'Reading configuration...')
+ ready = self.index_of(
+ msgs, lambda m: m.get('READY') == '1'
+ and m.get('STATUS') == 'Configuration loaded.')
+ assert reloading >= 0 and ready >= 0
+ assert reloading < ready, \
+ "READY=1 was reported before the configuration was read"
+
+ def test_systemd_001_03_mainpid(self, env):
+ """MAINPID is the pid of the parent process, the one httpd records
+ in its pid file."""
+ env.notify.clear()
+ assert env.apache_restart() == 0
+ msg = env.notify.wait_for_key('MAINPID')
+ assert msg, f"no MAINPID reported, got {env.notify.messages}"
+ assert msg.get('READY') == '1'
+ assert msg.get('STATUS') == 'Processing requests...'
+ assert int(msg['MAINPID']) == env.read_pid_file()
+
+ def test_systemd_001_04_reload(self, env):
+ """A graceful restart reports reading the configuration and then
+ being ready again."""
+ assert env.apache_restart() == 0
+ pid = env.read_pid_file()
+ env.notify.clear()
+ assert env.apache_reload() == 0
+ assert env.notify.wait_for_status(r'^Reading configuration\.\.\.$'), \
+ f"no reload notification, got {env.notify.statuses()}"
+ assert env.notify.wait_for_status(r'^Configuration loaded\.$'), \
+ f"no ready notification, got {env.notify.statuses()}"
+ # The parent survives a graceful restart, and the MPM is not started
+ # over, so the pid systemd tracks neither changes nor is re-reported.
+ assert env.read_pid_file() == pid
+ for msg in env.notify.messages:
+ assert 'MAINPID' not in msg or int(msg['MAINPID']) == pid
+
+ def test_systemd_001_05_hard_restart(self, env):
+ """An ungraceful restart starts the MPM over, and re-reports the
+ main pid, which is still that of the surviving parent."""
+ assert env.apache_restart() == 0
+ pid = env.read_pid_file()
+ env.notify.clear()
+ assert env.apache_hard_restart() == 0
+ assert env.notify.wait_for_status(r'^Configuration loaded\.$'), \
+ f"no ready notification, got {env.notify.statuses()}"
+ msg = env.notify.wait_for_key('MAINPID')
+ assert msg, f"no MAINPID after restart, got {env.notify.messages}"
+ assert int(msg['MAINPID']) == pid
+ assert env.read_pid_file() == pid
+
+ def test_systemd_001_06_notify_socket_kept(self, env):
+ """mod_systemd leaves NOTIFY_SOCKET in the environment, so a second
+ start after a stop is reported just like the first."""
+ assert env.apache_restart() == 0
+ assert env.apache_stop() == 0
+ env.notify.clear()
+ assert env.apache_restart() == 0
+ assert env.notify.wait_for_status(r'^Configuration loaded\.$')
+
+ def test_systemd_001_07_stopping(self, env):
+ """Shutdown is announced before the process goes away."""
+ assert env.apache_restart() == 0
+ env.notify.clear()
+ assert env.apache_stop() == 0
+ # The server is already gone, so anything it sent has arrived.
+ msg = env.notify.wait_for_key('STOPPING', timeout=1)
+ assert msg, f"no STOPPING=1 on shutdown, got {env.notify.messages}"
+ assert msg['STOPPING'] == '1'
+ assert msg.get('STATUS') == 'Shutting down.'
+
+ def test_systemd_001_08_reloading_monotonic(self, env):
+ assert env.apache_restart() == 0
+ env.notify.clear()
+ assert env.apache_reload() == 0
+ msg = env.notify.wait_for(lambda m: m.get('RELOADING') == '1')
+ assert msg, "no RELOADING=1 on graceful restart"
+ assert 'MONOTONIC_USEC' in msg, \
+ f"RELOADING=1 sent without MONOTONIC_USEC: {msg}"
+ # systemd compares this against its own reading of the same clock.
+ assert 0 < int(msg['MONOTONIC_USEC']) <= time.clock_gettime_ns(
+ time.CLOCK_MONOTONIC) // 1000
+
+ def test_systemd_001_09_watchdog(self, env):
+ """A server started with $WATCHDOG_USEC reports that it is alive.
+
+ $WATCHDOG_PID is left unset, which sd_watchdog_enabled(3) takes to
+ mean any process may report: it has to be, since apachectl starts a
+ parent whose pid nothing knew in advance. The keep-alive arrives
+ with the startup notifications rather than only from the monitor
+ hook ten seconds later, which is what makes this cheap to check.
+ test_006_watchdog.py covers the rest of the protocol.
+ """
+ assert env.apache_stop() == 0
+ # Long enough that mod_systemd does not object to it (AH10622).
+ env.set_httpd_env('WATCHDOG_USEC', str(60 * 1000000))
+ try:
+ env.notify.clear()
+ assert env.apache_restart() == 0
+ msg = env.notify.wait_for_key('WATCHDOG', timeout=4)
+ assert msg, f"no watchdog keepalive was sent, " \
+ f"got {env.notify.messages}"
+ assert msg['WATCHDOG'] == '1'
+ finally:
+ env.set_httpd_env('WATCHDOG_USEC', None)
+ assert env.apache_restart() == 0
diff -Nru apache2-2.4.69/test/modules/arch/linux/test_002_monitor.py apache2-2.4.71/test/modules/arch/linux/test_002_monitor.py
--- apache2-2.4.69/test/modules/arch/linux/test_002_monitor.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/test_002_monitor.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,110 @@
+import re
+
+import pytest
+
+from pyhttpd.backport import not_backported
+
+from pyhttpd.conf import HttpdConf
+
+from .env import MONITOR_STATUS, MONITOR_TIMEOUT
+
+
+@not_backported("r1937596-r1937647", "mod_systemd watchdog, notify and socket activation")
+class TestSystemdMonitor:
+ """The periodic STATUS= line the monitor hook reports, which is what
+ "systemctl status httpd" shows below the unit description."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = HttpdConf(env, extras={
+ 'base': """
+
+ SetHandler server-status
+
+ """
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ @staticmethod
+ def wait_report(env, above=-1):
+ """Wait for a report from the monitor hook accounting for more than
+ `above` requests, and return its fields."""
+ msg = env.notify.wait_for(
+ lambda m: 'STATUS' in m
+ and (g := MONITOR_STATUS.match(m['STATUS'])) is not None
+ and int(g.group('requests')) > above,
+ timeout=MONITOR_TIMEOUT)
+ assert msg, f"no monitor report within {MONITOR_TIMEOUT}s, " \
+ f"server {'up' if env.is_live() else 'down'}, " \
+ f"got {env.notify.messages}"
+ return MONITOR_STATUS.match(msg['STATUS'])
+
+ @pytest.fixture(scope='class')
+ def reports(self, env, _class_scope):
+ """Two consecutive reports with requests served between them, and
+ the mod_status report as of the second.
+
+ The hook runs once every ten turns of the parent's one second
+ loop, so waiting for a report costs up to ten seconds. The tests
+ below share one pair rather than each waiting for its own.
+ """
+ env.notify.clear()
+ first = self.wait_report(env)
+ for _ in range(10):
+ r = env.curl_get(env.mkurl("http", "test1", "/"))
+ assert r.response['status'] == 200
+ second = self.wait_report(env, above=int(first.group('requests')))
+ r = env.curl_get(env.mkurl("http", "test1", "/server-status?auto"))
+ assert r.response['status'] == 200
+ auto = {}
+ for line in r.response['body'].decode().splitlines():
+ key, sep, value = line.partition(':')
+ if sep and key not in auto:
+ auto[key] = value.strip()
+ return {'first': first, 'second': second, 'auto': auto}
+
+ def test_systemd_002_01_report_format(self, reports):
+ m = reports['first']
+ assert int(m.group('requests')) >= 0
+ assert m.group('bps')
+
+ def test_systemd_002_02_requests_counted(self, reports):
+ """The request count reported to systemd tracks requests served."""
+ before = int(reports['first'].group('requests'))
+ after = int(reports['second'].group('requests'))
+ assert after >= before + 10, \
+ f"{after - before} requests reported, at least 10 were served"
+
+ def test_systemd_002_03_rates_are_finite(self, reports):
+ """Neither rate is inf or nan.
+
+ systemd_monitor() divides by an uptime in whole seconds, which is
+ zero for a report arriving in the first second after the scoreboard
+ records a restart.
+ """
+ for which in ('first', 'second'):
+ m = reports[which]
+ rate = m.group('rate')
+ assert re.match(r'^-?\d', rate), f"{which} request rate is {rate!r}"
+ assert float(rate) >= 0
+ assert 'inf' not in m.group('bps') and 'nan' not in m.group('bps'), \
+ f"{which} byte rate is {m.group('bps')!r}"
+
+ def test_systemd_002_04_report_repeats(self, env, reports):
+ """The status line keeps being refreshed while the server runs."""
+ assert reports['first'].group(0) != reports['second'].group(0)
+
+ def test_systemd_002_05_worker_percentages(self, reports):
+ """Idle and busy are percentages of the workers available, and are
+ reported as such."""
+ m, auto = reports['second'], reports['auto']
+ idle, busy = int(m.group('idle')), int(m.group('busy'))
+ assert 0 <= idle <= 100 and 0 <= busy <= 100
+ # Integer division loses at most one point between the two.
+ assert 99 <= idle + busy <= 100
+ # Busy workers are a minority of a mostly idle test server, which
+ # is what mod_status reports over the same scoreboard.
+ assert int(auto['IdleWorkers']) > int(auto['BusyWorkers'])
+ assert idle > busy
diff -Nru apache2-2.4.69/test/modules/arch/linux/test_003_extended_status.py apache2-2.4.71/test/modules/arch/linux/test_003_extended_status.py
--- apache2-2.4.69/test/modules/arch/linux/test_003_extended_status.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/test_003_extended_status.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,69 @@
+import pytest
+
+from pyhttpd.backport import not_backported
+
+from pyhttpd.conf import HttpdConf
+
+from .env import NO_MONITOR_TIMEOUT
+
+
+@not_backported("r1937596-r1937647", "mod_systemd watchdog, notify and socket activation")
+class TestSystemdExtendedStatus:
+ """mod_systemd turns ExtendedStatus on so that it has request counts to
+ report, which changes what the rest of the server records too."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ yield
+ conf = HttpdConf(env)
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def auto_report(self, env):
+ r = env.curl_get(env.mkurl("http", "test1", "/server-status?auto"))
+ assert r.response['status'] == 200
+ return r.response['body'].decode()
+
+ def install(self, env, extra=''):
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+ {extra}
+
+ SetHandler server-status
+
+ """
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_systemd_003_01_enabled_by_default(self, env):
+ """Loading mod_systemd is enough to get extended status; no
+ ExtendedStatus directive is present in this configuration."""
+ self.install(env)
+ body = self.auto_report(env)
+ assert 'Total Accesses:' in body, \
+ "ExtendedStatus was not enabled by mod_systemd"
+ assert 'Total kBytes:' in body
+
+ def test_systemd_003_02_directive_wins(self, env):
+ """An explicit ExtendedStatus off still takes effect: mod_systemd
+ sets the default in pre_config, before the configuration is read."""
+ self.install(env, extra='ExtendedStatus off')
+ body = self.auto_report(env)
+ assert 'Total Accesses:' not in body, \
+ "ExtendedStatus off was overridden by mod_systemd"
+
+ def test_systemd_003_03_no_report_without_extended_status(self, env):
+ """With extended status off the monitor hook declines, so no status
+ line is reported to systemd."""
+ env.notify.clear()
+ self.install(env, extra='ExtendedStatus off')
+ # Startup notifications are still sent...
+ assert env.notify.wait_for_status(r'^Processing requests\.\.\.$',
+ timeout=10) is not None
+ # ...but the periodic status line is not.
+ assert env.notify.wait_for_status(r'^Total requests: ',
+ timeout=NO_MONITOR_TIMEOUT) is None, \
+ "a monitor report was sent with ExtendedStatus off"
diff -Nru apache2-2.4.69/test/modules/arch/linux/test_004_socket_activation.py apache2-2.4.71/test/modules/arch/linux/test_004_socket_activation.py
--- apache2-2.4.69/test/modules/arch/linux/test_004_socket_activation.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/test_004_socket_activation.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,92 @@
+import pytest
+
+from pyhttpd.backport import not_backported
+
+from .env import ActivatedServer
+
+
+@not_backported("r1937596-r1937647", "mod_systemd watchdog, notify and socket activation")
+class TestSystemdSocketActivation:
+ """Listening sockets passed in by the service manager.
+
+ mod_systemd exports the two optional functions server/listen.c uses to
+ find them, so socket activation is enabled by loading the module and
+ disabled by not loading it, whatever the environment says.
+
+ apachectl cannot pass file descriptors, so these tests open the
+ listening socket themselves and run httpd directly, in the foreground,
+ with the descriptor and the environment a service manager would give
+ it. No systemd process is involved.
+ """
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ # The activated servers use the same server root; keep the one
+ # started by other tests out of the way.
+ assert env.apache_stop() == 0
+ yield
+ assert env.apache_stop() == 0
+
+ def test_systemd_004_01_activated_listener(self, env):
+ """httpd serves on a socket it never opened itself."""
+ env.notify.clear()
+ with ActivatedServer(env, port=env.http_port2) as server:
+ assert server.is_live(), \
+ f"server did not come up: {server.stderr}"
+ r = env.curl_get(f"http://{env.http_addr}:{env.http_port2}/")
+ assert r.response['status'] == 200
+ # The notification handshake works the same way as when httpd opens
+ # its own sockets.
+ assert env.notify.wait_for_status(r'^Configuration loaded\.$', timeout=0)
+ msg = env.notify.wait_for_key('MAINPID', timeout=0)
+ assert msg, f"httpd sent no MAINPID, got {env.notify.messages}"
+ assert msg['STATUS'] == 'Processing requests...'
+
+ def test_systemd_004_02_no_socket_for_port(self, env):
+ """A Listen port the service manager did not pass is an error, not
+ a port httpd quietly opens for itself."""
+ server = ActivatedServer(env, port=env.http_port2,
+ listen_port=env.proxy_port,
+ name='activate-wrongport')
+ with server:
+ assert server.wait_exit() != 0, "httpd started without a socket"
+ assert b'not configured in systemd' in server.stderr, \
+ f"unexpected startup diagnostic: {server.stderr}"
+
+ def test_systemd_004_03_disabled_without_module(self, env):
+ """Without mod_systemd the passed sockets are ignored and httpd
+ opens the configured port itself, the same arrangement that fails
+ in the test above."""
+ if not env.systemd_is_dso:
+ pytest.skip("mod_systemd is linked statically and cannot be "
+ "left out of the configuration")
+ modules_conf = ActivatedServer.modules_conf_without(env, 'systemd')
+ server = ActivatedServer(env, port=env.http_port2,
+ listen_port=env.proxy_port,
+ name='activate-nomodule',
+ modules_conf=modules_conf)
+ with server:
+ assert server.is_live(), \
+ f"server did not come up: {server.stderr}"
+ r = env.curl_get(f"http://{env.http_addr}:{env.http_port2}/")
+ assert r.response['status'] == 200
+
+ def test_systemd_004_04_graceful_restart(self, env):
+ """An activated server survives a graceful restart, which means
+ finding the passed sockets again after the environment naming them
+ has been cleared."""
+ with ActivatedServer(env, port=env.http_port2,
+ name='activate-reload') as server:
+ assert server.is_live(), \
+ f"server did not come up: {server.stderr}"
+ server.reload()
+ env.httpd_error_log.ignore_recent(lognos=['AH02487'])
+ # Check the parent first: when it dies here its children carry
+ # on holding the listening socket and answering, so a request
+ # succeeding proves nothing on its own.
+ assert server.is_running(), \
+ "the parent exited on graceful restart"
+ assert server.is_live(timeout=5), \
+ "the server did not survive a graceful restart"
+ r = env.curl_get(f"http://{env.http_addr}:{env.http_port2}/")
+ assert r.response['status'] == 200
diff -Nru apache2-2.4.69/test/modules/arch/linux/test_005_service.py apache2-2.4.71/test/modules/arch/linux/test_005_service.py
--- apache2-2.4.69/test/modules/arch/linux/test_005_service.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/test_005_service.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,95 @@
+import time
+
+import pytest
+
+from pyhttpd.backport import not_backported
+
+from pyhttpd.depends import needs_dependency
+
+from .env import MONITOR_STATUS, MONITOR_TIMEOUT, TransientService, http_responds
+
+pytestmark = needs_dependency(
+ "systemd --user manager", TransientService.is_available(),
+ reason="no per-user systemd manager to run a transient service under",
+ detected="systemd-run --user is not usable here")
+
+
+@not_backported("r1937596-r1937647", "mod_systemd watchdog, notify and socket activation")
+class TestSystemdService:
+ """httpd as a real systemd service, end to end.
+
+ Everything else here checks what mod_systemd sends. These check what
+ systemd does with it: hold the unit in "activating" until httpd is
+ ready, track the right process, and show the reported status text.
+ """
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ assert env.apache_stop() == 0
+ yield
+ assert env.apache_stop() == 0
+
+ @pytest.fixture
+ def service(self, env) -> TransientService:
+ svc = TransientService(env, port=env.http_port2)
+ yield svc
+ svc.stop()
+
+ def test_systemd_005_01_type_notify(self, env, service):
+ """systemd-run returns once the unit is active, which for a
+ Type=notify service means READY=1 has been received, which
+ mod_systemd sends only after the configuration is loaded."""
+ r = service.start()
+ assert r.returncode == 0, f"systemd-run failed: {r.stderr}"
+ assert service.show('ActiveState') == 'active'
+ assert http_responds(env.http_port2), \
+ "the unit was active before the server would answer"
+
+ def test_systemd_005_02_main_pid(self, env, service):
+ """The process systemd tracks is the httpd parent."""
+ assert service.start().returncode == 0
+ assert service.wait_active()
+ main_pid = int(service.show('MainPID'))
+ assert main_pid > 0
+ assert main_pid == service.read_pid()
+
+ def test_systemd_005_03_status_text(self, env, service):
+ """The status line "systemctl status" shows comes from the monitor
+ hook, and is refreshed while the server runs."""
+ assert service.start().returncode == 0
+ assert service.wait_active()
+ # First the post_config report, then the periodic one.
+ assert service.show('StatusText') in ('Configuration loaded.',
+ 'Processing requests...')
+ end = time.time() + MONITOR_TIMEOUT
+ text = None
+ while time.time() < end:
+ text = service.show('StatusText')
+ if MONITOR_STATUS.match(text):
+ break
+ time.sleep(0.5)
+ assert MONITOR_STATUS.match(text), \
+ f"status text was never refreshed by the monitor hook: {text!r}"
+
+ def test_systemd_005_04_reload(self, env, service):
+ """systemctl reload runs httpd -k graceful and the unit stays
+ active throughout."""
+ assert service.start().returncode == 0
+ assert service.wait_active()
+ pid = int(service.show('MainPID'))
+ r = service.systemctl('reload', f'{service.unit}.service')
+ assert r.returncode == 0, f"reload failed: {r.stderr}"
+ assert service.show('ActiveState') == 'active'
+ assert int(service.show('MainPID')) == pid
+ assert http_responds(env.http_port2)
+
+ def test_systemd_005_05_stop(self, env, service):
+ """The unit stops cleanly, without systemd having to time out and
+ kill it."""
+ assert service.start().returncode == 0
+ assert service.wait_active()
+ r = service.systemctl('stop', f'{service.unit}.service')
+ assert r.returncode == 0, f"stop failed: {r.stderr}"
+ assert service.show('ActiveState') == 'inactive'
+ assert service.show('Result') == 'success'
+ assert not http_responds(env.http_port2)
diff -Nru apache2-2.4.69/test/modules/arch/linux/test_006_watchdog.py apache2-2.4.71/test/modules/arch/linux/test_006_watchdog.py
--- apache2-2.4.69/test/modules/arch/linux/test_006_watchdog.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/test_006_watchdog.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,217 @@
+import os
+import re
+import time
+
+import pytest
+
+from pyhttpd.backport import not_backported
+
+from pyhttpd.depends import needs_dependency
+
+from .env import (ForegroundServer, TransientService, MIN_WATCHDOG_SEC,
+ NO_MONITOR_TIMEOUT, WATCHDOG_TIMEOUT, is_watchdog_ping)
+
+
+def watchdog_env(usec: int, pid: str = '$$') -> dict:
+ """The environment a service manager sets for a watched service.
+
+ $WATCHDOG_PID names the process expected to report; "$$" is the shell
+ which exec's httpd, so it is the pid httpd will run as.
+ """
+ env = {'WATCHDOG_USEC': str(usec)}
+ if pid is not None:
+ env['WATCHDOG_PID'] = pid
+ return env
+
+
+@not_backported("r1937596-r1937647", "mod_systemd watchdog, notify and socket activation")
+class TestSystemdWatchdog:
+ """The keep-alive ping of the systemd watchdog protocol.
+
+ A service whose unit sets WatchdogSec= is started with $WATCHDOG_USEC
+ holding that timeout in microseconds, and $WATCHDOG_PID holding the pid
+ expected to report. The service must then send "WATCHDOG=1" to the
+ notification socket more often than the timeout, or systemd puts the
+ unit into a failed state with Result=watchdog. The timeout is armed
+ once start-up completes and, as test_006_06 relies on, stays armed
+ across a reload.
+
+ Nothing here needs systemd: the ping goes to $NOTIFY_SOCKET like every
+ other notification, so the same stand-in socket observes it. Only the
+ last test involves a service manager.
+ """
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ # These run their own servers on the second port; the shared one
+ # would only add its notifications to the same socket.
+ assert env.apache_stop() == 0
+ yield
+ assert env.apache_stop() == 0
+
+ @pytest.fixture
+ def server_factory(self, env):
+ started = []
+
+ def make(usec=None, pid='$$', extra='', name='watchdog'):
+ setenv = watchdog_env(usec, pid) if usec is not None else {}
+ srv = ForegroundServer(env, port=env.http_port2, name=name,
+ extra=extra, setenv=setenv)
+ started.append(srv)
+ env.notify.clear()
+ srv.start()
+ assert srv.is_live(), \
+ f"server did not come up: {srv.stderr!r}"
+ return srv
+
+ yield make
+ for srv in started:
+ srv.stop()
+
+ def test_systemd_006_01_no_ping_when_unwatched(self, env, server_factory):
+ """A server the service manager is not watching sends no keep-alive.
+
+ The monitor hook still runs -- its status report is the evidence of
+ that -- so the absence of a ping is a decision and not silence.
+ """
+ server_factory()
+ assert env.notify.wait_for_status(r'^Total requests: ',
+ timeout=WATCHDOG_TIMEOUT), \
+ "the monitor hook never ran, so this proves nothing"
+ assert not [m for m in env.notify.messages if is_watchdog_ping(m)], \
+ "a keep-alive was sent with $WATCHDOG_USEC unset"
+
+ def test_systemd_006_02_ping_when_watched(self, env, server_factory):
+ """With the watchdog enabled the keep-alive is sent."""
+ server_factory(usec=60 * 1000000)
+ assert env.notify.wait_for(is_watchdog_ping,
+ timeout=WATCHDOG_TIMEOUT), \
+ f"no keep-alive within {WATCHDOG_TIMEOUT}s, " \
+ f"got {env.notify.messages}"
+
+ def test_systemd_006_03_ping_repeats(self, env, server_factory):
+ """The keep-alive is periodic, which is the whole point of it: one
+ ping would satisfy a test but not systemd."""
+ server_factory(usec=60 * 1000000)
+ for n in range(2):
+ assert env.notify.wait_for(is_watchdog_ping,
+ timeout=WATCHDOG_TIMEOUT), \
+ f"only {n} keep-alives arrived, got {env.notify.messages}"
+ env.notify.clear()
+
+ def test_systemd_006_04_ping_without_extended_status(self, env,
+ server_factory):
+ """The keep-alive does not depend on ExtendedStatus.
+
+ The monitor hook declines early when it has no request counts to
+ report (test_003_03), and a server whose status line is switched
+ off must still be reported as alive.
+ """
+ server_factory(usec=60 * 1000000, extra='ExtendedStatus off')
+ assert env.notify.wait_for_status(r'^Total requests: ',
+ timeout=NO_MONITOR_TIMEOUT) is None, \
+ "ExtendedStatus off did not stop the status report"
+ assert env.notify.wait_for(is_watchdog_ping,
+ timeout=WATCHDOG_TIMEOUT), \
+ "no keep-alive with ExtendedStatus off"
+
+ def test_systemd_006_05_no_ping_for_another_pid(self, env, server_factory):
+ """$WATCHDOG_PID naming a different process means the variables were
+ set for something further up the process tree, and must be ignored.
+ """
+ server_factory(usec=60 * 1000000, pid='1')
+ assert env.notify.wait_for_status(r'^Total requests: ',
+ timeout=WATCHDOG_TIMEOUT), \
+ "the monitor hook never ran, so this proves nothing"
+ assert not [m for m in env.notify.messages if is_watchdog_ping(m)], \
+ "a keep-alive was sent although $WATCHDOG_PID was another process"
+
+ def test_systemd_006_06_ping_across_reload(self, env, server_factory):
+ """The keep-alive survives a graceful restart.
+
+ The parent re-reads its configuration in the same process, but
+ mod_systemd is unloaded and loaded again with it, so anything it
+ remembered about the watchdog is gone by the time the monitor hook
+ runs again. systemd keeps the timeout armed throughout.
+ """
+ srv = server_factory(usec=60 * 1000000)
+ assert env.notify.wait_for(is_watchdog_ping, timeout=WATCHDOG_TIMEOUT)
+ env.notify.clear()
+ assert srv.reload() == 0
+ assert srv.is_live()
+ assert env.notify.wait_for(is_watchdog_ping,
+ timeout=WATCHDOG_TIMEOUT), \
+ f"no keep-alive after a reload, got {env.notify.messages}"
+
+ def test_systemd_006_07_ping_covers_the_reload_window(self, env,
+ server_factory):
+ """A keep-alive is sent as the configuration is read, and again once
+ it is loaded.
+
+ Reading the configuration happens outside the parent's monitor loop,
+ so nothing else reports during it. The watchdog stays armed while
+ the unit reloads, and a configuration which takes longer to parse
+ than the timeout would otherwise be killed halfway through.
+ """
+ srv = server_factory(usec=60 * 1000000)
+ assert env.notify.wait_for(is_watchdog_ping, timeout=WATCHDOG_TIMEOUT)
+ env.notify.clear()
+ assert srv.reload() == 0
+ assert env.notify.wait_for(
+ lambda m: 'RELOADING' in m and is_watchdog_ping(m),
+ timeout=WATCHDOG_TIMEOUT), \
+ f"no keep-alive as the configuration was read, " \
+ f"got {env.notify.messages}"
+ assert env.notify.wait_for(
+ lambda m: m.get('READY') == '1' and is_watchdog_ping(m),
+ timeout=WATCHDOG_TIMEOUT), \
+ f"no keep-alive once the configuration was loaded, " \
+ f"got {env.notify.messages}"
+
+ def test_systemd_006_08_short_timeout_warned(self, env, server_factory):
+ """A WatchdogSec the parent cannot meet is reported.
+
+ The keep-alive is sent from the monitor hook, which runs once every
+ ten turns of the parent's one second loop. A timeout of a few
+ seconds cannot be met however the module is written, and failing
+ silently would leave the server being killed and restarted with
+ nothing in the log to say why.
+ """
+ server_factory(usec=2 * 1000000)
+ assert env.httpd_error_log.scan_recent(
+ re.compile(r'.*AH10621: .*[Ww]atchdog.*'), timeout=10), \
+ "no warning about a watchdog timeout that cannot be met"
+ env.httpd_error_log.ignore_recent(lognos=['AH10621'])
+
+ def test_systemd_006_09_workable_timeout_not_warned(self, env,
+ server_factory):
+ """A timeout the parent can meet is not complained about."""
+ server_factory(usec=MIN_WATCHDOG_SEC * 1000000)
+ assert env.notify.wait_for(is_watchdog_ping, timeout=WATCHDOG_TIMEOUT)
+ with pytest.raises(TimeoutError):
+ env.httpd_error_log.scan_recent(
+ re.compile(r'.*AH10621: .*'), timeout=1)
+
+ @needs_dependency("systemd --user manager", TransientService.is_available(),
+ reason="no per-user systemd manager",
+ detected="systemd-run --user is not usable here")
+ def test_systemd_006_10_service_keeps_watchdog_alive(self, env):
+ """The real thing: systemd records each keep-alive it receives, and
+ the unit stays active rather than failing with Result=watchdog."""
+ with TransientService(env, port=env.http_port2,
+ properties=[f'WatchdogSec={MIN_WATCHDOG_SEC}s'],
+ name=f'httpd-wd-{os.getpid()}') as svc:
+ assert svc.start().returncode == 0
+ assert svc.wait_active()
+ first = svc.show('WatchdogTimestampMonotonic')
+ assert first and int(first) > 0, \
+ "systemd recorded no keep-alive at all"
+ end = time.time() + WATCHDOG_TIMEOUT
+ while time.time() < end:
+ if svc.show('WatchdogTimestampMonotonic') != first:
+ break
+ time.sleep(0.5)
+ assert svc.show('WatchdogTimestampMonotonic') != first, \
+ "systemd received no further keep-alive"
+ assert svc.show('ActiveState') == 'active'
+ assert svc.show('Result') == 'success'
diff -Nru apache2-2.4.69/test/modules/arch/linux/test_007_notify_reload.py apache2-2.4.71/test/modules/arch/linux/test_007_notify_reload.py
--- apache2-2.4.69/test/modules/arch/linux/test_007_notify_reload.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/arch/linux/test_007_notify_reload.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,146 @@
+import os
+import time
+
+import pytest
+
+from pyhttpd.backport import not_backported
+
+from pyhttpd.depends import needs_dependency
+
+from .env import (NOTIFY_RELOAD_VERSION, TransientService, error_log_since,
+ error_log_size, http_responds, systemd_version)
+
+pytestmark = [
+ needs_dependency(
+ "systemd --user manager", TransientService.is_available(),
+ reason="no per-user systemd manager to run a transient service under",
+ detected="systemd-run --user is not usable here"),
+ # a version gate, not a missing dependency: keeps skipping when strict
+ pytest.mark.skipif(
+ systemd_version() < NOTIFY_RELOAD_VERSION,
+ reason=f"Type=notify-reload needs systemd {NOTIFY_RELOAD_VERSION}"),
+]
+
+
+@not_backported("r1937596-r1937647", "mod_systemd watchdog, notify and socket activation")
+class TestNotifyReload:
+ """httpd as a Type=notify-reload unit.
+
+ Under Type=notify a reload is only whatever ExecReload= does, and
+ systemctl returns as soon as that command exits. For "httpd -k
+ graceful" that is as soon as the signal has been sent, long before the
+ new configuration is in use, and it reports success however the restart
+ turns out. Type=notify-reload holds the reload job open until the
+ service sends RELOADING=1 and then READY=1, which mod_systemd sends
+ from pre_config and post_config, so the result is reported once it is
+ known.
+
+ The unit here keeps ExecReload= and sets ReloadSignal=SIGCONT, which
+ httpd ignores. The manager runs ExecReload= first and sends
+ ReloadSignal= only once it has exited successfully, so:
+
+ - "httpd -k graceful" stays the thing which restarts the server, and
+ because it parses the new configuration in its own process before
+ signalling, a configuration which does not parse fails the reload
+ without the running server being signalled at all. A bare
+ ReloadSignal= has the running parent read the new configuration and
+ exit if it does not parse.
+
+ - the signal the manager then sends is redundant, so it is pointed at
+ one httpd does not act on. The default is SIGHUP, which httpd
+ takes as an *ungraceful* restart.
+ """
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ assert env.apache_stop() == 0
+ yield
+ assert env.apache_stop() == 0
+
+ @pytest.fixture
+ def service(self, env) -> TransientService:
+ svc = TransientService(env, port=env.http_port2,
+ name=f'httpd-reload-{os.getpid()}',
+ service_type='notify-reload',
+ properties=['ReloadSignal=SIGCONT'])
+ yield svc
+ svc.stop()
+
+ def test_systemd_007_01_reload_waits_for_the_new_config(self, env, service):
+ """systemctl reload returns only once the reloaded server is
+ serving: the port added to the configuration answers without the
+ test waiting for it, because READY=1 is sent from post_config, by
+ which point the listeners are open."""
+ assert service.start().returncode == 0
+ assert service.wait_active()
+ assert http_responds(env.http_port2)
+
+ service.rewrite_conf(extra=f'Listen {env.proxy_port}')
+ r = service.reload()
+ assert r.returncode == 0, f"reload failed: {r.stderr}"
+ assert http_responds(env.proxy_port, timeout=10.0), \
+ "reload returned before the newly configured port was served"
+ assert http_responds(env.http_port2)
+
+ def test_systemd_007_02_broken_config_fails_the_reload(self, env, service):
+ """A configuration which does not parse fails the reload and leaves
+ the running server alone. ExecReload= reads it in a process of its
+ own and exits without signalling, and the manager sends no reload
+ signal of its own once that has failed, so the parent never reads
+ it."""
+ assert service.start().returncode == 0
+ assert service.wait_active()
+ pid = int(service.show('MainPID'))
+
+ service.rewrite_conf(extra='ThisDirectiveDoesNotExist on')
+ r = service.reload()
+ assert r.returncode != 0, \
+ "reload of an unparseable configuration reported success"
+ assert service.show('ActiveState') == 'active', \
+ "a failed reload took the unit out of active"
+ # The parent exits when a restart re-reads a configuration which
+ # does not parse, and its children go on serving for a while
+ # afterwards, so answering a request is not on its own proof that
+ # the server survived: check the process the manager tracks.
+ assert int(service.show('MainPID')) == pid, \
+ "the parent was restarted despite the configuration not parsing"
+ assert http_responds(env.http_port2)
+
+ def test_systemd_007_03_reload_restarts_gracefully_once(self, env, service):
+ """One reload is one graceful restart, and no ungraceful one.
+ Leaving ReloadSignal= at its SIGHUP default is what this catches:
+ httpd restarts ungracefully on SIGHUP, dropping the connections a
+ reload is supposed to keep."""
+ if env.mpm_module not in ('mpm_event', 'mpm_worker'):
+ pytest.skip(f"{env.mpm_module} does not log the graceful restart")
+ assert service.start().returncode == 0
+ assert service.wait_active()
+
+ pos = error_log_size(env)
+ assert service.reload().returncode == 0
+ # The restart is logged before the configuration is re-read, so it
+ # is written by the time READY=1 ends the reload; give the
+ # redundant signal which follows a moment to land as well.
+ time.sleep(2)
+ log = error_log_since(env, pos)
+ assert log.count("Attempting to restart") == 0, \
+ f"the reload restarted the server ungracefully:\n{log}"
+ assert log.count("Doing graceful restart") == 1, \
+ f"expected one graceful restart, log said:\n{log}"
+
+ def test_systemd_007_04_reload_repeats(self, env, service):
+ """Reloading twice works. The manager ignores a RELOADING=1 which
+ is not stamped later than the reload it asked for, so a server
+ which got MONOTONIC_USEC wrong could report the first reload and
+ then leave the second to time out."""
+ assert service.start().returncode == 0
+ assert service.wait_active()
+ pid = int(service.show('MainPID'))
+
+ for i in range(2):
+ r = service.reload()
+ assert r.returncode == 0, f"reload {i + 1} failed: {r.stderr}"
+ assert service.show('ActiveState') == 'active'
+ assert int(service.show('MainPID')) == pid, \
+ "a graceful restart replaced the parent process"
+ assert http_responds(env.http_port2)
diff -Nru apache2-2.4.69/test/modules/conftest.py apache2-2.4.71/test/modules/conftest.py
--- apache2-2.4.69/test/modules/conftest.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,70 @@
+"""Fixtures shared by every test package below modules/.
+
+They act on the package-scoped ``env`` that each package's conftest defines,
+so they live here rather than in the root conftest: tests outside modules/
+(pytest_suite) have no ``env``.
+"""
+import socket
+import sys
+import warnings
+
+import pytest
+
+
+def _has_ipv6():
+ try:
+ s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
+ s.bind(('::1', 0))
+ s.close()
+ return True
+ except (socket.error, OSError):
+ return False
+
+
+@pytest.fixture(autouse=True, scope="function")
+def _function_scope(env, request):
+ env.set_current_test_name(request.node.name)
+ yield
+ env.check_error_log()
+ archive_dir = request.config.getoption("--archive")
+ if archive_dir:
+ fspath = str(request.fspath)
+ if 'modules/' in fspath:
+ package_name = fspath.split('modules/')[1].split('/')[0]
+ env.archive_test_conf(request.node.name, package_name, archive_dir)
+ env.set_current_test_name(None)
+
+
+@pytest.fixture(autouse=True, scope="module")
+def _module_scope(env):
+ yield
+ env.check_error_log()
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _package_scope(env, request):
+ env.httpd_error_log.clear_ignored_matches()
+ env.httpd_error_log.clear_ignored_lognos()
+ if sys.platform == "win32":
+ env.httpd_error_log.add_ignored_lognos([
+ "AH00098",
+ "AH10373",
+ "AH00336",
+ "AH00337",
+ "AH00338",
+ "AH00341",
+ "AH02322",
+ ])
+ if sys.platform == "win32" and _has_ipv6():
+ env.httpd_error_log.add_ignored_lognos(["AH00332"])
+ yield
+ assert env.apache_stop() == 0
+ env.check_error_log()
+ archive_dir = request.config.getoption("--archive")
+ if archive_dir == "":
+ warnings.warn("--archive option was empty, skipping archiving")
+ if archive_dir:
+ fspath = str(request.fspath)
+ if 'modules/' in fspath:
+ package_name = fspath.split('modules/')[1].split('/')[0]
+ env.archive_logs(package_name, archive_dir)
diff -Nru apache2-2.4.69/test/modules/core/env.py apache2-2.4.71/test/modules/core/env.py
--- apache2-2.4.69/test/modules/core/env.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/core/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -12,7 +12,7 @@
def __init__(self, env: 'HttpdTestEnv'):
super().__init__(env=env)
self.add_source_dir(os.path.dirname(inspect.getfile(CoreTestSetup)))
- self.add_modules(["cgid"])
+ self.add_modules(["cgid","include","userdir","suexec","headers"])
class CoreTestEnv(HttpdTestEnv):
diff -Nru apache2-2.4.69/test/modules/core/htdocs/cgi/delay.py apache2-2.4.71/test/modules/core/htdocs/cgi/delay.py
--- apache2-2.4.69/test/modules/core/htdocs/cgi/delay.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/htdocs/cgi/delay.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+import sys, time
+
+content="A delayed response.\n"
+
+print("Status: 200")
+print(f"Request-Length: {len(content)}")
+print("Content-Type: text/plain\n")
+time.sleep(1)
+sys.stdout.write(content)
+
diff -Nru apache2-2.4.69/test/modules/core/htdocs/cgi/env_parameters.py apache2-2.4.71/test/modules/core/htdocs/cgi/env_parameters.py
--- apache2-2.4.69/test/modules/core/htdocs/cgi/env_parameters.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/htdocs/cgi/env_parameters.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+
+import os
+import json
+
+print("Content-Type: application/json")
+print()
+
+data = {
+ "REQUEST_METHOD": os.getenv("REQUEST_METHOD", ""),
+ "QUERY_STRING": os.getenv("QUERY_STRING", ""),
+}
+
+print(json.dumps(data, indent=2))
diff -Nru apache2-2.4.69/test/modules/core/htdocs/cgiredir/redir.cgi apache2-2.4.71/test/modules/core/htdocs/cgiredir/redir.cgi
--- apache2-2.4.69/test/modules/core/htdocs/cgiredir/redir.cgi 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/htdocs/cgiredir/redir.cgi 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# A CGI script which performs an RFC 3875 "local redirect" to the path
+# given in the query string.
+
+import os
+import sys
+
+query = os.environ.get("QUERY_STRING", "")
+if query:
+ sys.stdout.write("Location: {0}\r\n\r\n".format(query))
+else:
+ sys.stdout.write("Content-Type: text/plain\r\n\r\n")
+ sys.stdout.write("usage: redir.cgi?/local/path\n")
diff -Nru apache2-2.4.69/test/modules/core/htdocs/cgiredir/target apache2-2.4.71/test/modules/core/htdocs/cgiredir/target
--- apache2-2.4.69/test/modules/core/htdocs/cgiredir/target 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/htdocs/cgiredir/target 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+# A file with no extension recognized by mod_mime. It must never be
+# run as a CGI script, only served as plain content.
+
+import sys
+
+mark = "EXECUTED"
+sys.stdout.write("Content-Type: text/plain\r\n\r\n")
+sys.stdout.write(mark + "-AS-CGI\n")
diff -Nru apache2-2.4.69/test/modules/core/htdocs/ssi/exec.shtml apache2-2.4.71/test/modules/core/htdocs/ssi/exec.shtml
--- apache2-2.4.69/test/modules/core/htdocs/ssi/exec.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/htdocs/ssi/exec.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
\ No newline at end of file
diff -Nru apache2-2.4.69/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/.htaccess apache2-2.4.71/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/.htaccess
--- apache2-2.4.69/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/.htaccess 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/.htaccess 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+RequestHeader note internal_note "empty_note"
diff -Nru apache2-2.4.69/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/test.cgi apache2-2.4.71/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/test.cgi
--- apache2-2.4.69/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/test.cgi 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/htdocs/userdir/testuser/public_html/cgi-bin/test.cgi 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,4 @@
+#!/bin/sh
+echo "Content-Type: text/plain"
+echo
+echo "OK"
diff -Nru apache2-2.4.69/test/modules/core/test_001_encoding.py apache2-2.4.71/test/modules/core/test_001_encoding.py
--- apache2-2.4.69/test/modules/core/test_001_encoding.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_001_encoding.py 2026-11-09 03:07:05.000000000 +0000
@@ -66,10 +66,10 @@
["/cgi-bin/%%32%65%%32%65/echo.py", 400, ["AH10244"]],
["/nothing/%%32%65%%32%65/%%32%65%%32%65/h2_env.py", 400, ["AH10244"]],
["/cgi-bin/%%32%65%%32%65/%%32%65%%32%65/h2_env.py", 400, ["AH10244"]],
- ["/nothing/%25%32%65%25%32%65/echo.py", 404, ["AH01264"]],
- ["/cgi-bin/%25%32%65%25%32%65/echo.py", 404, ["AH01264"]],
- ["/nothing/%25%32%65%25%32%65/%25%32%65%25%32%65/h2_env.py", 404, ["AH01264"]],
- ["/cgi-bin/%25%32%65%25%32%65/%25%32%65%25%32%65/h2_env.py", 404, ["AH01264"]],
+ ["/nothing/%25%32%65%25%32%65/echo.py", 404, ["AH01264", "AH02811"]],
+ ["/cgi-bin/%25%32%65%25%32%65/echo.py", 404, ["AH01264", "AH02811"]],
+ ["/nothing/%25%32%65%25%32%65/%25%32%65%25%32%65/h2_env.py", 404, ["AH01264", "AH02811"]],
+ ["/cgi-bin/%25%32%65%25%32%65/%25%32%65%25%32%65/h2_env.py", 404, ["AH01264", "AH02811"]],
])
def test_core_001_04(self, env, path, status, lognos: Optional[List[str]]):
url = env.mkurl("https", "test1", path)
diff -Nru apache2-2.4.69/test/modules/core/test_002_restarts.py apache2-2.4.71/test/modules/core/test_002_restarts.py
--- apache2-2.4.69/test/modules/core/test_002_restarts.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_002_restarts.py 2026-11-09 03:07:05.000000000 +0000
@@ -88,8 +88,8 @@
@pytest.mark.skipif(condition='STRESS_TEST' not in os.environ,
reason="STRESS_TEST not set in env")
-@pytest.mark.skipif(condition=not CoreTestEnv().h2load_is_at_least('1.41.0'),
- reason="h2load unavailable or misses --connect-to option")
+@CoreTestEnv.needs_h2load("1.41.0",
+ reason="h2load unavailable or misses --connect-to option")
class TestRestarts:
def test_core_002_01(self, env):
diff -Nru apache2-2.4.69/test/modules/core/test_003_cgi_env_vars.py apache2-2.4.71/test/modules/core/test_003_cgi_env_vars.py
--- apache2-2.4.69/test/modules/core/test_003_cgi_env_vars.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_003_cgi_env_vars.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,33 @@
+import pytest
+
+from pyhttpd.conf import HttpdConf
+
+class TestCGIEnvVars:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+
+ AllowOverride None
+ Options +ExecCGI
+
+ SetEnv REQUEST-METHOD OVERRIDDEN
+ SetEnv QUERY.STRING OVERRIDDEN
+ """,
+ })
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_cgi_003_01(self, env):
+ """
+ CVE-2025-65082:
+ Configuration-defined env vars must not override
+ server-calculated CGI env vars.
+ """
+ url = env.mkurl("http", "cgi", "/env_parameters.py?x=123")
+ r = env.curl_get(url)
+ assert r.response["status"] == 200
+ assert r.response["json"]["REQUEST_METHOD"] == "GET"
+ assert r.response["json"]["QUERY_STRING"] == "x=123"
diff -Nru apache2-2.4.69/test/modules/core/test_004_ssi.py apache2-2.4.71/test/modules/core/test_004_ssi.py
--- apache2-2.4.69/test/modules/core/test_004_ssi.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_004_ssi.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,35 @@
+import sys
+import pytest
+import textwrap
+
+from pyhttpd.conf import HttpdConf
+
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="exec cmd= needs /bin/sh; echo is a cmd.exe builtin, not an executable")
+class TestSSIInjection:
+
+ @pytest.fixture(autouse=True, scope="class")
+ def _class_scope(self, env):
+ conf = HttpdConf(env, extras={
+ "base": textwrap.dedent(f"""
+
+ Options +Includes
+ AddType text/html .shtml
+ AddOutputFilter INCLUDES .shtml
+
+ """)
+ })
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_ssi_004_01(self, env):
+ """
+ CVE-2025-58098:
+ Server Side Includes must not add query string to #exec cmd=...
+ """
+ url = env.mkurl("http", "htdocs", "/ssi/exec.shtml?INJECTED")
+ r = env.curl_get(url)
+
+ body = r.response["body"].decode("utf-8")
+ assert "SSI_OK" in body
+ assert "INJECTED" not in body
\ No newline at end of file
diff -Nru apache2-2.4.69/test/modules/core/test_005_userdir_headers.py apache2-2.4.71/test/modules/core/test_005_userdir_headers.py
--- apache2-2.4.69/test/modules/core/test_005_userdir_headers.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_005_userdir_headers.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,43 @@
+import os
+import re
+import pytest
+
+from pyhttpd.conf import HttpdConf
+
+class TestUserdirHeaders:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+
+ userdir_base = os.path.join(env.server_dir, "htdocs", "userdir")
+
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+ UserDir "{userdir_base}/*/public_html"
+
+
+ AllowOverride FileInfo
+ Options +ExecCGI
+ SetHandler cgi-script
+
+ """
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_core_005_01_requestheader_note_rejected(self, env):
+ url = env.mkurl("http", "test1", "/~testuser/cgi-bin/test.cgi")
+ r = env.curl_get(url)
+
+ # Check error log for the rejection message
+ re_rejection = re.compile(r".*RequestHeader does not support the 'note' action.*")
+
+ directive_rejected = False
+ try:
+ directive_rejected = env.httpd_error_log.scan_recent(re_rejection)
+ except TimeoutError:
+ pass
+
+ assert directive_rejected, \
+ "RequestHeader note directive was not rejected"
diff -Nru apache2-2.4.69/test/modules/core/test_006_merge_lang.py apache2-2.4.71/test/modules/core/test_006_merge_lang.py
--- apache2-2.4.69/test/modules/core/test_006_merge_lang.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_006_merge_lang.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,39 @@
+import os
+import pytest
+
+from pyhttpd.conf import HttpdConf
+
+
+class TestMergeLanguage:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ # Create a file with two language extensions so mod_mime
+ # populates r->content_languages with nelts == nalloc == 2.
+ doc_dir = os.path.join(env.server_dir, "htdocs", "test1")
+ with open(os.path.join(doc_dir, "doc.en.fr.html"), "w") as f:
+ f.write("Hello World\n")
+
+ conf = HttpdConf(env, extras={
+ f"test1.{env.http_tld}": """
+ AddLanguage en .en
+ AddLanguage fr .fr
+ Header set Content-Language "de, es"
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # Requesting a file with two language extensions while mod_headers
+ # adds two non-matching Content-Language tokens should not crash.
+ # The merge loop in merge_response_headers must refresh its pointer
+ # to r->content_languages->elts after apr_array_push reallocates.
+ def test_core_006_01(self, env):
+ url = env.mkurl("http", "test1", "/doc.en.fr.html")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ cl = r.response["header"]["content-language"]
+ for lang in ["en", "fr", "de", "es"]:
+ assert lang in cl, f"expected '{lang}' in Content-Language: {cl}"
diff -Nru apache2-2.4.69/test/modules/core/test_007_cgi_redirect.py apache2-2.4.71/test/modules/core/test_007_cgi_redirect.py
--- apache2-2.4.69/test/modules/core/test_007_cgi_redirect.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_007_cgi_redirect.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,43 @@
+import pytest
+import textwrap
+
+from pyhttpd.conf import HttpdConf
+
+class TestCGIRedirectHandler:
+
+ @pytest.fixture(autouse=True, scope="class")
+ def _class_scope(self, env):
+ conf = HttpdConf(env, extras={
+ "base": textwrap.dedent(f"""
+
+ Options +ExecCGI
+
+ ForceType application/x-httpd-cgi
+
+
+ """)
+ })
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_cgi_007_01(self, env):
+ """A file with no extension known to mod_mime is not a CGI script."""
+ url = env.mkurl("http", "htdocs", "/cgiredir/target")
+ r = env.curl_get(url)
+ assert r.response["status"] == 200
+ body = r.response["body"].decode("utf-8")
+ assert "#!/usr/bin/env python3" in body
+ assert "EXECUTED-AS-CGI" not in body
+
+ def test_cgi_007_02(self, env):
+ """
+ A local redirect from a CGI script must not make the redirect
+ target inherit the trusted content-type of the script, since
+ that would run the target as a CGI script too.
+ """
+ url = env.mkurl("http", "htdocs", "/cgiredir/redir.cgi?/cgiredir/target")
+ r = env.curl_get(url)
+ assert r.response["status"] == 200
+ body = r.response["body"].decode("utf-8")
+ assert "#!/usr/bin/env python3" in body
+ assert "EXECUTED-AS-CGI" not in body
diff -Nru apache2-2.4.69/test/modules/core/test_008_restart.py apache2-2.4.71/test/modules/core/test_008_restart.py
--- apache2-2.4.69/test/modules/core/test_008_restart.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_008_restart.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,59 @@
+import sys
+
+import pytest
+
+from pyhttpd.conf import HttpdConf
+
+
+class TestRestart:
+ """The server goes on serving across a restart, graceful or not.
+
+ Whatever the MPM does with its children, what has to hold afterwards
+ is that a request is answered. A restart which leaves the listening
+ socket open with nothing behind it is the failure this catches, and
+ it is not visible from the exit status of "httpd -k restart".
+ """
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = HttpdConf(env)
+ conf.add_vhost_test1()
+ conf.install()
+ # Restarting is rough on mod_cgid, which is loaded for this
+ # package: the daemon is signalled along with the rest of the
+ # process group (AH01239), and restarting again before it has
+ # unlinked its socket leaves the new one unable to bind
+ # (AH01243). Neither is what these tests are about.
+ env.httpd_error_log.add_ignored_lognos(['AH01239', 'AH01243'])
+ assert env.apache_restart() == 0
+
+ def get(self, env, when):
+ # --max-time, or a server which accepts the connection and then
+ # says nothing hangs here rather than failing.
+ r = env.curl_get(env.mkurl("http", "test1", "/"),
+ options=['--max-time', '10'])
+ assert r.exit_code == 0, f"no answer {when}: {r.stderr}"
+ assert r.response['status'] == 200, f"bad status {when}"
+
+ def test_core_008_01_graceful(self, env):
+ self.get(env, "before the reload")
+ assert env.apache_reload() == 0
+ self.get(env, "after a graceful reload")
+
+ def test_core_008_02_ungraceful(self, env):
+ """"httpd -k restart" keeps the same parent and serves again."""
+ self.get(env, "before the restart")
+ pid = env.read_pid_file()
+ assert pid, "no pid file"
+ assert env.apache_hard_restart() == 0
+ assert env.read_pid_file() == pid, "the restart replaced the parent"
+ self.get(env, "after an ungraceful restart")
+
+ @pytest.mark.xfail(sys.platform == "win32", reason=
+ "mpm_winnt restart does not wait for child exit, "
+ "rapid restarts cause AH02322 socket errors")
+ def test_core_008_03_repeated(self, env):
+ """Restarting twice in a row is no different from once."""
+ for _ in range(2):
+ assert env.apache_hard_restart() == 0
+ self.get(env, "after repeated restarts")
diff -Nru apache2-2.4.69/test/modules/core/test_009_allow_methods.py apache2-2.4.71/test/modules/core/test_009_allow_methods.py
--- apache2-2.4.69/test/modules/core/test_009_allow_methods.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_009_allow_methods.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,49 @@
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+
+class TestAllowMethods:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = HttpdConf(env)
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # mod_aptest passes the methods named in AP-Test-Allow-Methods to
+ # ap_allow_methods() from a fixups hook; the default handler then
+ # merges its own and answers OPTIONS with the Allow header.
+ def options(self, env, methods=None):
+ options = ['-X', 'OPTIONS']
+ if methods is not None:
+ options.extend(['-H', f'AP-Test-Allow-Methods: {methods}'])
+ r = env.curl_get(env.mkurl("http", "test1", "/index.html"),
+ options=options)
+ assert r.response, f"no response: {r.stderr}"
+ assert r.response["status"] == 200
+ return [m.strip() for m in r.response["header"]["allow"].split(',')]
+
+ def test_core_009_01(self, env):
+ allow = self.options(env)
+ for method in ["GET", "POST", "OPTIONS"]:
+ assert method in allow, f"expected {method} in Allow: {allow}"
+
+ def test_core_009_02(self, env):
+ allow = self.options(env, "DELETE")
+ assert "DELETE" in allow, f"expected DELETE in Allow: {allow}"
+
+ # An extension method reaches make_allow() only through method_list,
+ # which is emitted just when the M_INVALID bit is set in method_mask.
+ @not_backported("r1938291", "unregistered methods listed in Allow")
+ def test_core_009_03(self, env):
+ allow = self.options(env, "FROBNICATE")
+ assert "FROBNICATE" in allow, f"expected FROBNICATE in Allow: {allow}"
+
+ @not_backported("r1938291", "unregistered methods listed in Allow")
+ def test_core_009_04(self, env):
+ allow = self.options(env, "DELETE, FROBNICATE")
+ for method in ["DELETE", "FROBNICATE"]:
+ assert method in allow, f"expected {method} in Allow: {allow}"
diff -Nru apache2-2.4.69/test/modules/core/test_010_limit_xml_request_body.py apache2-2.4.71/test/modules/core/test_010_limit_xml_request_body.py
--- apache2-2.4.69/test/modules/core/test_010_limit_xml_request_body.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_010_limit_xml_request_body.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,48 @@
+import os
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+
+class TestLimitXMLRequestBody:
+
+ # have httpd parse the configuration, like "httpd -t" does
+ def check_config(self, env, value):
+ conf = HttpdConf(env)
+ conf.add(f"LimitXMLRequestBody {value}")
+ conf.add_vhost_test1()
+ conf.install()
+ return env.run([os.path.join(env.bin_dir, 'httpd'),
+ "-t", "-d", env.server_dir,
+ "-f", os.path.join(env.server_dir, 'conf', 'httpd.conf')])
+
+ @pytest.mark.parametrize("value", ["0", "1", "123", "1000000"])
+ def test_core_010_001(self, env, value):
+ r = self.check_config(env, value)
+ assert r.exit_code == 0, f"{r.stderr}"
+
+ # only a whole non-negative decimal number is a valid limit
+ # (without the change, only "-1" was rejected)
+ @pytest.mark.parametrize("value", [
+ pytest.param(v, marks=not_backported(
+ "r1939121", "rejecting invalid LimitXMLRequestBody values"))
+ for v in ["abc123", "123abc", "123foo456", "1.5", "0x10", "+", "-"]
+ ] + ["-1"])
+ def test_core_010_002(self, env, value):
+ r = self.check_config(env, value)
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert "LimitXMLRequestBody requires a non-negative integer" in r.stderr
+
+ # more than can be stored or than the directive permits
+ @pytest.mark.parametrize("value", [
+ "9223372036854775807", "9223372036854775808", "9" * 40,
+ ])
+ @not_backported("r1939121", "rejecting LimitXMLRequestBody values beyond a "
+ "32-bit long, which atol() truncates on Windows",
+ platform="win32")
+ def test_core_010_003(self, env, value):
+ r = self.check_config(env, value)
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert "LimitXMLRequestBody" in r.stderr
diff -Nru apache2-2.4.69/test/modules/core/test_012_config_off_t.py apache2-2.4.71/test/modules/core/test_012_config_off_t.py
--- apache2-2.4.69/test/modules/core/test_012_config_off_t.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/core/test_012_config_off_t.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,117 @@
+import os
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+# size and time directives parsed with ap_size_parameter_parse(), which can
+# not be negative
+DIRECTIVES = [
+ "LimitRequestBody", "LimitXMLRequestBody",
+ "ScriptLogLength", "ScriptLogBuffer",
+ "CacheMinFileSize", "CacheMaxFileSize", "CacheReadSize", "CacheReadTime",
+ "CacheSocacheMaxTime", "CacheSocacheMinTime", "CacheSocacheReadSize",
+ "CacheSocacheReadTime",
+]
+
+OFF_T = not_backported("r1939129, r1939163", "strict size parsing for off_t directives")
+OFF_T_WIN = not_backported("r1939129, r1939163", "strict size parsing for off_t directives",
+ platform="win32")
+
+# Without the change (2.4.x), these directives take the number at the start of
+# a value and ignore the rest, and these also take a value without one;
+# LimitRequestBody was strict already, but has no units and other messages.
+UNCHECKED_TAIL = [d for d in DIRECTIVES if d != "LimitRequestBody"]
+NO_DIGITS_OK = ["LimitXMLRequestBody", "ScriptLogLength", "ScriptLogBuffer"]
+# The cache handlers check apr_strtoff()'s status, but not whether it consumed
+# digits, and glibc leaves errno unset when there are none; Windows CI accepted
+# them too. A libc that rejects these already gives a non-strict XPASS, which
+# is fine.
+NO_DIGITS_OK += [d for d in DIRECTIVES if d.startswith("Cache")]
+NO_RANGE_CHECK = ["ScriptLogLength", "ScriptLogBuffer"]
+
+
+def cases(values, lacks, lacks_win=lambda d, v: False):
+ """Every (directive, value), marked where a server without the change
+ gets it wrong: lacks(directive, value), or only on Windows: lacks_win."""
+ return [pytest.param(d, v, marks=OFF_T if lacks(d, v)
+ else OFF_T_WIN if lacks_win(d, v) else ())
+ for v in values for d in DIRECTIVES]
+
+
+class TestConfigOffT:
+
+ # have httpd parse the configuration, like "httpd -t" does
+ def check_config(self, env, directive, value):
+ conf = HttpdConf(env)
+ conf.add(f"{directive} {value}")
+ conf.add_vhost_test1()
+ conf.install()
+ return env.run([os.path.join(env.bin_dir, 'httpd'),
+ "-t", "-d", env.server_dir,
+ "-f", os.path.join(env.server_dir, 'conf', 'httpd.conf')])
+
+ @pytest.mark.parametrize("directive, value", cases(
+ ["0", "1", "123", "1000000", "00123",
+ "512b", "512B", "1k", "1K", "2m", "2M", "1g", "1G"],
+ lambda d, v: d == "LimitRequestBody" and not v.isdigit()))
+ def test_core_012_001(self, env, directive, value):
+ r = self.check_config(env, directive, value)
+ assert r.exit_code == 0, f"{r.stderr}"
+
+ # only a whole decimal number is valid
+ @pytest.mark.parametrize("directive, value", cases(
+ ["abc", "abc123", "123abc", "123foo456", "12.3", "0x10", "1kb", "k",
+ "+", "-"],
+ lambda d, v: (d in UNCHECKED_TAIL and v[0].isdigit()) or d in NO_DIGITS_OK))
+ def test_core_012_002(self, env, directive, value):
+ r = self.check_config(env, directive, value)
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert directive in r.stderr, f"{r.stderr}"
+
+ # a number the directive does not accept, or too large to be stored
+ @pytest.mark.parametrize("directive, value", cases(
+ ["-1", "9223372036854775808", "9" * 40, "8589934592g"],
+ lambda d, v: (d in UNCHECKED_TAIL and v.endswith("g")) or d in NO_RANGE_CHECK,
+ # atol() into a long, which is 32 bits on Windows
+ lambda d, v: d == "LimitXMLRequestBody" and v in ("9223372036854775808", "9" * 40)))
+ def test_core_012_003(self, env, directive, value):
+ r = self.check_config(env, directive, value)
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert directive in r.stderr, f"{r.stderr}"
+
+ # this one has a lower limit as well
+ @pytest.mark.parametrize("value, valid", [
+ ["1024", True], ["102400", True], ["1023", False], ["0", False],
+ pytest.param("1024abc", False, marks=OFF_T), ["abc", False], ["9" * 40, False],
+ ])
+ def test_core_012_004(self, env, value, valid):
+ r = self.check_config(env, "CacheSocacheMaxSize", value)
+ if valid:
+ assert r.exit_code == 0, f"{r.stderr}"
+ else:
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert "CacheSocacheMaxSize" in r.stderr, f"{r.stderr}"
+
+ # LimitRequestBody reports invalid syntax (APR_EINVAL) and a number
+ # which does not fit (APR_ERANGE) differently. A value without digits
+ # is invalid syntax regardless of the status returned by the
+ # underlying strto*() implementation: FreeBSD and musl set EINVAL
+ # there, glibc does not.
+ # (a value without digits gets another message without the change)
+ @pytest.mark.parametrize("value, error", [
+ pytest.param("abc", "requires a non-negative integer", marks=OFF_T),
+ pytest.param("+", "requires a non-negative integer", marks=OFF_T),
+ pytest.param("-", "requires a non-negative integer", marks=OFF_T),
+ pytest.param("abc123", "requires a non-negative integer", marks=OFF_T),
+ ["123abc", "requires a non-negative integer"],
+ ["12.3", "requires a non-negative integer"],
+ ["9223372036854775808", "argument is not parsable"],
+ ["-9223372036854775809", "argument is not parsable"],
+ ["9" * 40, "argument is not parsable"],
+ ])
+ def test_core_012_005(self, env, value, error):
+ r = self.check_config(env, "LimitRequestBody", value)
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert f"LimitRequestBody {error}" in r.stderr, f"{r.stderr}"
diff -Nru apache2-2.4.69/test/modules/dav/test_002_options_trace.py apache2-2.4.71/test/modules/dav/test_002_options_trace.py
--- apache2-2.4.69/test/modules/dav/test_002_options_trace.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/dav/test_002_options_trace.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,77 @@
+import os
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+DAV_METHODS = {"OPTIONS", "GET", "HEAD", "POST", "DELETE", "PROPFIND",
+ "PROPPATCH", "COPY", "MOVE", "LOCK", "UNLOCK"}
+
+
+def _setup(env, trace):
+ dav_dir = os.path.join(env.gen_dir, 'dav')
+ os.makedirs(os.path.join(dav_dir, 'col'), exist_ok=True)
+ with open(os.path.join(dav_dir, 'file.txt'), 'w') as fd:
+ fd.write('hello\n')
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+ DavLockDB "{env.gen_dir}/davlock"
+ TraceEnable {trace}
+ """,
+ f"test1.{env.http_tld}": f"""
+ Alias /dav "{dav_dir}"
+
+ Dav On
+ Require all granted
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+
+def _request(env, method, path):
+ r = env.curl_raw(env.mkurl("https", "test1", path),
+ options=['-X', method])
+ assert r.response, f"no response: {r.stderr}"
+ allow = r.response["header"].get("allow", "")
+ return r.response["status"], {m.strip() for m in allow.split(',') if m.strip()}
+
+
+class TestOptionsTraceDisabled:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ _setup(env, "off")
+
+ # TRACE is not offered while the server refuses it
+ @pytest.mark.parametrize("path", ["/dav/file.txt", "/dav/col/"])
+ @not_backported("r1939146", "DAV OPTIONS Allow honouring TraceEnable")
+ def test_dav_002_001(self, env, path):
+ status, allow = _request(env, "OPTIONS", path)
+ assert status == 200
+ assert "TRACE" not in allow, f"Allow: {allow}"
+ assert DAV_METHODS <= allow, f"Allow: {allow}"
+
+ def test_dav_002_002(self, env):
+ assert _request(env, "TRACE", "/dav/file.txt")[0] == 405
+
+
+class TestOptionsTraceEnabled:
+
+ @pytest.fixture(autouse=True, scope='class',
+ params=["on", "extended"])
+ def _class_scope(self, env, request):
+ _setup(env, request.param)
+
+ # unchanged: TRACE is offered and works
+ @pytest.mark.parametrize("path", ["/dav/file.txt", "/dav/col/"])
+ def test_dav_002_101(self, env, path):
+ status, allow = _request(env, "OPTIONS", path)
+ assert status == 200
+ assert (DAV_METHODS | {"TRACE"}) <= allow, f"Allow: {allow}"
+
+ def test_dav_002_102(self, env):
+ assert _request(env, "TRACE", "/dav/file.txt")[0] == 200
diff -Nru apache2-2.4.69/test/modules/dav/test_003_config_quota.py apache2-2.4.71/test/modules/dav/test_003_config_quota.py
--- apache2-2.4.69/test/modules/dav/test_003_config_quota.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/dav/test_003_config_quota.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,38 @@
+import os
+
+import pytest
+
+from pyhttpd.backport import skip_not_backported
+from pyhttpd.conf import HttpdConf
+
+pytestmark = skip_not_backported("r1939129", "DAVquota")
+
+
+class TestConfigQuota:
+
+ # have httpd parse the configuration, like "httpd -t" does
+ def check_config(self, env, value):
+ conf = HttpdConf(env)
+ conf.add(f"DavQuota {value}")
+ conf.add_vhost_test1()
+ conf.install()
+ return env.run([os.path.join(env.bin_dir, 'httpd'),
+ "-t", "-d", env.server_dir,
+ "-f", os.path.join(env.server_dir, 'conf', 'httpd.conf')])
+
+ @pytest.mark.parametrize("value", [
+ "Off", "None", "0", "1", "123", "1000000", "00123",
+ ])
+ def test_dav_003_01(self, env, value):
+ r = self.check_config(env, value)
+ assert r.exit_code == 0, f"{r.stderr}"
+
+ # not Off, None or a size, in particular no negative number, which would
+ # be taken for one of the first two
+ @pytest.mark.parametrize("value", [
+ "abc", "123abc", "12.3", "0x10", "-1", "-2", "9" * 40,
+ ])
+ def test_dav_003_02(self, env, value):
+ r = self.check_config(env, value)
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert "DAVquota must be Off, None, or a size in bytes" in r.stderr
diff -Nru apache2-2.4.69/test/modules/dav/test_004_lock_timeout_overflow.py apache2-2.4.71/test/modules/dav/test_004_lock_timeout_overflow.py
--- apache2-2.4.69/test/modules/dav/test_004_lock_timeout_overflow.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/dav/test_004_lock_timeout_overflow.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,86 @@
+import os
+import re
+
+import pytest
+
+from pyhttpd.conf import HttpdConf
+
+LOCK_BODY = (
+ ''
+ ''
+ ''
+ ''
+ ''
+)
+
+
+# BZ 65788: dav_get_timeout() in modules/dav/main/util.c parsed a
+# "Timeout: Second-" header with atol(), which has no overflow
+# detection. A large enough digit string overflows it, and the later
+# "now + expires" addition overflows time_t too, so the lock ends up
+# stored with a nonsensical (already-expired) timeout instead of the
+# large-but-finite one the client asked for -- a LOCK request that looks
+# successful (200, a valid Lock-Token) silently grants a lock that is
+# already expired.
+class TestLockTimeoutOverflow:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ dav_dir = os.path.join(env.gen_dir, 'dav-timeout-overflow')
+ os.makedirs(dav_dir, exist_ok=True)
+ for name in ('file1.txt', 'file2.txt', 'file3.txt'):
+ with open(os.path.join(dav_dir, name), 'w') as fd:
+ fd.write('hello\n')
+
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+ DavLockDB "{env.gen_dir}/davlock-timeout-overflow"
+ """,
+ f"test1.{env.http_tld}": f"""
+ Alias /dav-timeout-overflow "{dav_dir}"
+
+ Dav On
+ Require all granted
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def _lock(self, env, path, timeout_hdr):
+ url = env.mkurl("https", "test1", path)
+ return env.curl_raw(url, options=[
+ '-X', 'LOCK', '-H', f'Timeout: {timeout_hdr}',
+ '-H', 'Content-Type: text/xml', '--data-binary', LOCK_BODY])
+
+ # an overflowing Second- value must not silently collapse to an
+ # already-expired (or otherwise nonsensical) timeout; it is clamped
+ # to the RFC 4918 TimeOut maximum of 2^32 - 1 instead
+ def test_dav_004_01(self, env):
+ r = self._lock(env, "/dav-timeout-overflow/file1.txt",
+ "Second-99999999999999999999999999999999")
+ assert r.response["status"] == 200, r.response
+ m = re.search(r'([^<]*)', r.stdout)
+ assert m, f"no in response: {r.stdout}"
+ assert m.group(1) == "Second-4294967295", \
+ f"timeout: {m.group(1)}"
+
+ # an ordinary, in-range timeout request is unaffected
+ def test_dav_004_02(self, env):
+ r = self._lock(env, "/dav-timeout-overflow/file2.txt", "Second-600")
+ assert r.response["status"] == 200, r.response
+ m = re.search(r'([^<]*)', r.stdout)
+ assert m, f"no in response: {r.stdout}"
+ assert m.group(1) == "Second-600", f"timeout: {m.group(1)}"
+
+ # a Second- value with no digits at all parses the same way atol()
+ # already did (0, not an error), same as before this fix: an
+ # immediately-expired lock, not Infinite
+ def test_dav_004_03(self, env):
+ r = self._lock(env, "/dav-timeout-overflow/file3.txt",
+ "Second-notanumber")
+ assert r.response["status"] == 200, r.response
+ m = re.search(r'([^<]*)', r.stdout)
+ assert m, f"no in response: {r.stdout}"
+ assert m.group(1) == "Second-0", f"timeout: {m.group(1)}"
diff -Nru apache2-2.4.69/test/modules/filters/conftest.py apache2-2.4.71/test/modules/filters/conftest.py
--- apache2-2.4.69/test/modules/filters/conftest.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,30 @@
+import logging
+import os
+import sys
+
+import pytest
+
+from .env import FiltersTestEnv
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+
+def pytest_report_header(config, start_path):
+ env = FiltersTestEnv()
+ return f"filters [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> FiltersTestEnv:
+ logging.getLogger('').setLevel(level=logging.INFO)
+ env = FiltersTestEnv(pytestconfig=pytestconfig)
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+ return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
diff -Nru apache2-2.4.69/test/modules/filters/env.py apache2-2.4.71/test/modules/filters/env.py
--- apache2-2.4.69/test/modules/filters/env.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,26 @@
+import inspect
+import logging
+import os
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+
+log = logging.getLogger(__name__)
+
+
+class FiltersTestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(FiltersTestSetup)))
+ self.add_modules(["substitute", "sed", "buffer"])
+ self.add_cgi_module()
+
+
+class FiltersTestEnv(HttpdTestEnv):
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ self.add_httpd_log_modules(["substitute", "sed", "core"])
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=FiltersTestSetup(env=self))
diff -Nru apache2-2.4.69/test/modules/filters/htdocs/test1/cgi/echo.py apache2-2.4.71/test/modules/filters/htdocs/test1/cgi/echo.py
--- apache2-2.4.69/test/modules/filters/htdocs/test1/cgi/echo.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/htdocs/test1/cgi/echo.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+# Echo the request body back verbatim, so an InputSed test can see exactly
+# what the input filter handed to the handler. Read to EOF rather than
+# CONTENT_LENGTH bytes: mod_sed rewrites the body but not the header, so the
+# two need not agree.
+import sys
+
+body = sys.stdin.buffer.read()
+print("Content-Type: text/plain")
+print()
+sys.stdout.flush()
+sys.stdout.buffer.write(body)
diff -Nru apache2-2.4.69/test/modules/filters/htdocs/test1/cgi/nobody.py apache2-2.4.71/test/modules/filters/htdocs/test1/cgi/nobody.py
--- apache2-2.4.69/test/modules/filters/htdocs/test1/cgi/nobody.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/htdocs/test1/cgi/nobody.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+# Headers and nothing else, so the output filters see a response whose body
+# is a lone EOS.
+print("Content-Type: text/html")
+print()
diff -Nru apache2-2.4.69/test/modules/filters/test_001_substitute_maxlen.py apache2-2.4.71/test/modules/filters/test_001_substitute_maxlen.py
--- apache2-2.4.69/test/modules/filters/test_001_substitute_maxlen.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/test_001_substitute_maxlen.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,83 @@
+import os
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+# SubstituteMaxLineLength caps the length of a line after substitution. Each
+# flag combination reaches a different branch of do_pattmatch(): regex or
+# literal ('n'), flattened into one bucket or left as separate ones ('q').
+MODES = {
+ 'regex_flatten': 'f',
+ 'regex_quick': 'q',
+ 'literal_flatten': 'nf',
+ 'literal_quick': 'nq',
+}
+
+MAXLEN = 10
+# "x" expands to "yy", so each line grows by exactly one byte.
+OVER = "x" + "a" * 9 # 10 bytes in, 11 out -- over the limit
+OVER_MID = "aaaa" + "x" + "aaaaa" # same, with the match off the start
+OVER_END = "a" * 9 + "x" # same again, but with no unmatched tail
+EXACT = "x" + "a" * 8 # 9 bytes in, 10 out -- exactly at the limit
+
+
+class TestSubstituteMaxLineLength:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ doc_dir = os.path.join(env.server_dir, "htdocs", "test1")
+ for name, text in [("over.html", OVER), ("over_mid.html", OVER_MID),
+ ("over_end.html", OVER_END), ("exact.html", EXACT)]:
+ with open(os.path.join(doc_dir, name), "w") as f:
+ f.write(text)
+ # APLOGNO(01328) "Line too long" is the expected rejection.
+ env.httpd_error_log.add_ignored_lognos(["AH01328"])
+
+ def configure(self, env, flags):
+ conf = HttpdConf(env, extras={
+ f"test1.{env.http_tld}": f"""
+ SubstituteMaxLineLength {MAXLEN}
+
+ AddOutputFilterByType SUBSTITUTE text/html
+ Substitute "s/x/yy/{flags}"
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def get(self, env, path):
+ return env.curl_get(env.mkurl("http", "test1", path))
+
+ # A line that would grow past the limit must be rejected, not truncated
+ # and not returned over-length.
+ # (without the change, a regex substitution with more of the line after
+ # the last match gets it wrong)
+ @pytest.mark.parametrize("mode, doc", [
+ pytest.param(mode, doc, id=f"{mode}-{doc}", marks=not_backported(
+ "r1938306", "SubstituteMaxLineLength counting the unmatched tail")
+ if mode.startswith("regex") and doc != "over_end.html" else ())
+ for mode in MODES for doc in ["over.html", "over_mid.html", "over_end.html"]
+ ])
+ def test_filters_001_01(self, env, mode, doc):
+ self.configure(env, MODES[mode])
+ r = self.get(env, f"/{doc}")
+ body = r.response["body"].decode() if r.response else None
+ assert not (r.response and r.response["status"] == 200
+ and len(body) > MAXLEN), \
+ f"{mode} {doc}: over-length line returned: " \
+ f"{len(body) if body else None} bytes {body!r}"
+
+ # A line that lands exactly on the limit is still served.
+ @pytest.mark.parametrize("mode", list(MODES), ids=list(MODES))
+ def test_filters_001_02(self, env, mode):
+ self.configure(env, MODES[mode])
+ r = self.get(env, "/exact.html")
+ assert r.response, f"{mode}: no response"
+ assert r.response["status"] == 200, f"{mode}: status {r.response['status']}"
+ body = r.response["body"].decode()
+ assert body == "yy" + "a" * 8, f"{mode}: body {body!r}"
+ assert len(body) == MAXLEN
diff -Nru apache2-2.4.69/test/modules/filters/test_002_sed_ycomp.py apache2-2.4.71/test/modules/filters/test_002_sed_ycomp.py
--- apache2-2.4.69/test/modules/filters/test_002_sed_ycomp.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/test_002_sed_ycomp.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,62 @@
+import sys
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+# ycomp() reports "transform strings not the same size" when the two halves of
+# a y/// command differ in length, but used to carry on afterwards: the loop
+# kept advancing tsp, past the closing delimiter and past the NUL terminating
+# the config line, reading whatever followed until it happened to stop. The
+# error eventually reported was therefore the wrong one, "ending delimiter
+# missing", raised once the walk fell off the end of the string.
+TOO_SHORT = "transform strings not the same size"
+FELL_OFF_THE_END = "ending delimiter missing"
+
+
+# On Windows the "apachectl" pyhttpd drives is httpd.exe itself, where
+# "-k start" means "start the installed service" and fails with AH00436
+# before the configuration is ever read, so there is no compile error to
+# assert on.
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="httpd -k start is service control on Windows")
+class TestSedYComp:
+
+ def install(self, env, expr):
+ conf = HttpdConf(env, extras={
+ f"test1.{env.http_tld}": f"""
+
+ AddOutputFilterByType SED text/html
+ OutputSed "{expr}"
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ return env._run_apachectl("start")
+
+ # Source longer than destination: rejected, naming the real problem.
+ @pytest.mark.parametrize("expr", ["y/abc/de/", "y/abcdef/de/",
+ "y/abcdefgh/x/"])
+ @not_backported("r1938337", "mod_sed y command size mismatch error")
+ def test_filters_002_01(self, env, expr):
+ r = self.install(env, expr)
+ assert r.exit_code != 0, f"httpd started with bad expression {expr}"
+ assert TOO_SHORT in r.stderr, \
+ f"{expr}: expected '{TOO_SHORT}', got: {r.stderr.strip()}"
+ assert FELL_OFF_THE_END not in r.stderr, \
+ f"{expr}: parser walked off the end: {r.stderr.strip()}"
+
+ # Destination longer than source is caught by the trailing check and has
+ # always reported the right error; keep it that way.
+ def test_filters_002_02(self, env):
+ r = self.install(env, "y/ab/abc/")
+ assert r.exit_code != 0
+ assert TOO_SHORT in r.stderr, r.stderr
+
+ # A well-formed y/// still compiles and the server starts.
+ def test_filters_002_03(self, env):
+ r = self.install(env, "y/abc/xyz/")
+ assert r.exit_code == 0, f"valid expression rejected: {r.stderr}"
+ assert env.is_live(), "httpd did not come up"
diff -Nru apache2-2.4.69/test/modules/filters/test_003_sed.py apache2-2.4.71/test/modules/filters/test_003_sed.py
--- apache2-2.4.69/test/modules/filters/test_003_sed.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/test_003_sed.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,565 @@
+import os
+import re
+
+import pytest
+
+from pyhttpd.backport import not_backported, skip_not_backported
+from pyhttpd.conf import HttpdConf
+
+# for the cases a server without the change gets wrong
+COMPILE_CHECKS = not_backported("r1938962", "mod_sed expression compile checks")
+
+# mod_sed implements the Solaris 10 sed language over the response (OutputSed)
+# or the request body (InputSed). Almost none of it was covered before, so
+# these tests walk the commands the module documents plus the paths mod_sed.c
+# itself has: the 8000-byte output buffer, the transient-bucket flush, the
+# empty-body short circuit and the error path.
+
+# The document most tests run against. Three lines, trailing newline.
+DOC = "one monday two\nthree sunday four\nmonday monday monday\n"
+
+# huge.html: enough ordinary lines to push mod_sed past MAX_TRANSIENT_BUCKETS
+# (50 buckets of MODSED_OUTBUF_SIZE, so 400000 bytes) and make it flush what it
+# has to the client, then a single line past the 8 MB (MAX_BUF_SIZE) a line may
+# grow to, which fails the evaluation.
+HUGE_HEAD_LINES = 500
+HUGE_LINE_LEN = 900
+HUGE_HEAD_LEN = HUGE_HEAD_LINES * (HUGE_LINE_LEN + 1)
+HUGE_TAIL_LEN = 9 * 1024 * 1024
+
+# For a test whose failure mode is "never answers" rather than "answers
+# wrongly". pyhttpd only gives curl --connect-timeout.
+BOUNDED = ["--max-time", "30"]
+
+
+class TestSed:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ self.docdir = os.path.join(env.server_dir, "htdocs", "test1")
+ self.write(env, "sed.html", DOC)
+ # Same text with no newline after the last line.
+ self.write(env, "nonl.html", DOC[:-1])
+ self.write(env, "empty.html", "")
+ # One line per 4 KB, well past MODSED_OUTBUF_SIZE and past the 50
+ # transient buckets mod_sed flushes at.
+ self.write(env, "big.html",
+ "".join(f"line {i:06d} monday {'x' * 4000}\n"
+ for i in range(500)))
+ # A byte from each interesting class for the l command: printable,
+ # tab, DEL and three high-bit bytes.
+ self.write_bytes(env, "bytes.html", b"a\tb\x7fc\xffd\x80e\xa9f\n")
+ self.write(env, "insert.txt", "INSERTED\n")
+ # First line has nothing for \(a*\) to capture, second has "aa".
+ self.write(env, "backref.html", "b\naab\n")
+ head = "".join(
+ f"line {i:06d} monday ".ljust(HUGE_LINE_LEN, "y") + "\n"
+ for i in range(HUGE_HEAD_LINES))
+ assert len(head) == HUGE_HEAD_LEN
+ self.write(env, "huge.html", head + "z" * HUGE_TAIL_LEN + "\n")
+
+ @staticmethod
+ def write(env, name, text):
+ TestSed.write_bytes(env, name, text.encode())
+
+ @staticmethod
+ def write_bytes(env, name, data):
+ path = os.path.join(env.server_dir, "htdocs", "test1", name)
+ with open(path, "wb") as f:
+ f.write(data)
+
+ @staticmethod
+ def sed_path(env, name):
+ """The path of a document as an "r" command has to spell it: sed
+ reads a backslash in a filename as an escape and drops it, so a
+ Windows path only survives with forward slashes."""
+ return os.path.join(env.server_dir, "htdocs", "test1",
+ name).replace(os.sep, "/")
+
+ def configure(self, env, exprs, extra="", input_sed=False, fails=False):
+ if isinstance(exprs, str):
+ exprs = [exprs]
+ directive = "InputSed" if input_sed else "OutputSed"
+ lines = [f' {directive} "{e}"'
+ for e in exprs if isinstance(e, str)]
+ # A directive given as bytes may hold bytes which are not UTF-8, so
+ # it goes into a file of its own, written as-is.
+ raw = [e for e in exprs if isinstance(e, bytes)]
+ if raw:
+ path = os.path.join(env.server_dir, "conf", "sed-raw.conf")
+ with open(path, "wb") as f:
+ f.writelines(directive.encode() + b' "' + e + b'"\n'
+ for e in raw)
+ lines.append(f' Include "{path}"')
+ lines = "\n".join(lines)
+ conf = HttpdConf(env, extras={
+ f"test1.{env.http_tld}": f"""
+
+ AddOutputFilterByType SED text/html
+ AddInputFilter SED py
+ AddHandler cgi-script .py
+ Options +ExecCGI
+{lines}
+
+ {extra}
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ if fails:
+ # Check the configuration with httpd -t, which reports the error
+ # on stderr on every platform; the server is not restarted.
+ r = env.run([os.path.join(env.bin_dir, "httpd"),
+ "-d", env.server_dir,
+ "-f", os.path.join(env.server_dir, "conf",
+ "httpd.conf"), "-t"])
+ assert r.exit_code != 0, f"{exprs}: accepted"
+ return r.stderr
+ assert env.apache_restart() == 0
+
+ def get(self, env, path="/sed.html", options=None):
+ return env.curl_get(env.mkurl("http", "test1", path), options=options)
+
+ def body(self, env, exprs, path="/sed.html", **kwargs):
+ self.configure(env, exprs, **kwargs)
+ r = self.get(env, path)
+ assert r.response, f"no response for {exprs}"
+ assert r.response["status"] == 200, \
+ f"{exprs}: status {r.response['status']}"
+ return r.response["body"]
+
+ # --- substitution -----------------------------------------------------
+
+ # s/// replaces the first match on each line and nothing else.
+ def test_filters_003_01(self, env):
+ assert self.body(env, "s/monday/MON/").decode() == \
+ "one MON two\nthree sunday four\nMON monday monday\n"
+
+ # s///g replaces every match on the line.
+ def test_filters_003_02(self, env):
+ assert self.body(env, "s/monday/MON/g").decode() == \
+ "one MON two\nthree sunday four\nMON MON MON\n"
+
+ # s///2 replaces only the second match.
+ def test_filters_003_03(self, env):
+ assert self.body(env, "s/monday/MON/2").decode() == \
+ "one monday two\nthree sunday four\nmonday MON monday\n"
+
+ # Several OutputSed directives accumulate into one script, applied in the
+ # order they appear. This is the configuration the manual shows.
+ def test_filters_003_04(self, env):
+ assert self.body(env, ["s/monday/MON/g", "s/sunday/SUN/g"]).decode() \
+ == "one MON two\nthree SUN four\nMON MON MON\n"
+
+ # A back reference in the replacement.
+ def test_filters_003_05(self, env):
+ assert self.body(env, r"s/\(mon\)day/[\1]/g").decode() == \
+ "one [mon] two\nthree sunday four\n[mon] [mon] [mon]\n"
+
+ # & in the replacement is the whole match.
+ def test_filters_003_06(self, env):
+ assert self.body(env, "s/monday/<&>/").decode() == \
+ "one two\nthree sunday four\n monday monday\n"
+
+ # --- addresses --------------------------------------------------------
+
+ # A line-number address.
+ def test_filters_003_07(self, env):
+ assert self.body(env, "2d").decode() == \
+ "one monday two\nmonday monday monday\n"
+
+ # $ is the last line.
+ def test_filters_003_08(self, env):
+ assert self.body(env, "$d").decode() == \
+ "one monday two\nthree sunday four\n"
+
+ # A regex range.
+ def test_filters_003_09(self, env):
+ assert self.body(env, "/sunday/,$d").decode() == "one monday two\n"
+
+ # A negated address.
+ def test_filters_003_10(self, env):
+ assert self.body(env, "/sunday/!d").decode() == "three sunday four\n"
+
+ # = writes the line number ahead of the line.
+ def test_filters_003_11(self, env):
+ assert self.body(env, "=").decode() == \
+ "1\none monday two\n2\nthree sunday four\n3\nmonday monday monday\n"
+
+ # --- hold space -------------------------------------------------------
+
+ # G appends the hold space, which starts empty: classic double spacing.
+ def test_filters_003_12(self, env):
+ assert self.body(env, "G").decode() == \
+ "one monday two\n\nthree sunday four\n\nmonday monday monday\n\n"
+
+ # h saves, then x swaps: every line is replaced by the one before it.
+ def test_filters_003_13(self, env):
+ assert self.body(env, "x").decode() == \
+ "\none monday two\nthree sunday four\n"
+
+ # H;$!d;x collects the whole document in the hold space and prints it once.
+ def test_filters_003_14(self, env):
+ assert self.body(env, ["H", "$!d", "x"]).decode() == "\n" + DOC
+
+ # --- transliteration --------------------------------------------------
+
+ def test_filters_003_15(self, env):
+ assert self.body(env, "y/aeiou/AEIOU/").decode() == \
+ "OnE mOndAy twO\nthrEE sUndAy fOUr\nmOndAy mOndAy mOndAy\n"
+
+ # --- line handling ----------------------------------------------------
+
+ # A document whose last line has no newline gets one: documented Solaris
+ # sed behaviour, and the reason mod_sed drops Content-Length.
+ def test_filters_003_16(self, env):
+ assert self.body(env, "s/monday/MON/g", path="/nonl.html").decode() \
+ == "one MON two\nthree sunday four\nMON MON MON\n"
+
+ # An empty body stays empty; mod_sed must not invent a line.
+ def test_filters_003_17(self, env):
+ assert self.body(env, "s/monday/MON/", path="/empty.html") == b""
+
+ # mod_sed drops the handler's Content-Length because the body it produces
+ # is a different size. The length that reaches the client must be the one
+ # after filtering -- ap_content_length_filter recomputes it -- and never
+ # the file's own.
+ def test_filters_003_18(self, env):
+ self.configure(env, "s/monday/MONDAYMONDAY/g")
+ r = self.get(env)
+ assert r.response["status"] == 200
+ grown = len(DOC) + 4 * len("MONDAY")
+ assert len(r.response["body"]) == grown
+ assert r.response["header"]["content-length"] == str(grown), \
+ r.response["header"]
+
+ # The handler still generates a body for HEAD -- the protocol filters
+ # discard it -- so the filter runs and HEAD must announce the same
+ # Content-Length the matching GET returns, not the file's own.
+ def test_filters_003_19(self, env):
+ self.configure(env, "s/monday/MONDAYMONDAY/g")
+ r = self.get(env, options=["-I"])
+ assert r.response, "no response to HEAD"
+ assert r.response["status"] == 200
+ grown = len(DOC) + 4 * len("MONDAY")
+ assert r.response["header"]["content-length"] == str(grown), \
+ r.response["header"]
+
+ # A handler which produces headers and no body reaches mod_sed as a lone
+ # EOS. There is nothing to evaluate, so the filter takes itself out of
+ # the chain without touching the response.
+ def test_filters_003_20(self, env):
+ self.configure(env, "s/monday/MON/")
+ r = self.get(env, "/cgi/nobody.py")
+ assert r.response, "no response"
+ assert r.response["status"] == 200
+ assert r.response["body"] == b""
+
+ # A body far larger than the 8000-byte output buffer, and with more than
+ # the 50 transient buckets mod_sed flushes at, comes back intact.
+ def test_filters_003_21(self, env):
+ body = self.body(env, "s/monday/MON/", path="/big.html")
+ assert len(body) == 500 * (len("line 000000 MON ") + 4000 + 1)
+ lines = body.decode().splitlines()
+ assert len(lines) == 500
+ assert lines[0] == "line 000000 MON " + "x" * 4000
+ assert lines[499] == "line 000499 MON " + "x" * 4000
+
+ # --- the l command ----------------------------------------------------
+
+ # l writes the line in a printable form: control characters as the escapes
+ # in trans[], everything else non-printable as a three-digit octal escape.
+ # Bytes with the top bit set used to come out as nonsense ("\/77" for 0xff)
+ # because the shift was done on a signed char.
+ @not_backported("r1938487", "mod_sed l command sign extension")
+ def test_filters_003_22(self, env):
+ body = self.body(env, "l;d", path="/bytes.html")
+ assert body == b"a\\11b\\177c\\377d\\200e\\251f\n", body
+
+ # --- error handling ---------------------------------------------------
+
+ # A branch to a label that is never defined compiles -- the label could
+ # still arrive from a later OutputSed -- and fails at the first byte of
+ # every response instead. Nothing has been written at that point, so the
+ # failure can and must still become a 500.
+ def test_filters_003_23(self, env):
+ env.httpd_error_log.add_ignored_lognos(["AH02998", "AH10394"])
+ self.configure(env, "b nolabel")
+ r = self.get(env)
+ assert r.response, "no response at all"
+ assert r.response["status"] == 500, r.response["status"]
+ assert env.httpd_error_log.scan_recent(
+ re.compile(r'.*undefined label: nolabel'))
+
+ # The same failure once the response is already on its way. huge.html is
+ # 450 KB of ordinary lines -- enough for mod_sed to hit MAX_TRANSIENT_BUCKETS
+ # and flush the start of the response to the client -- followed by one line
+ # over the 8 MB a line may grow to, which fails the evaluation.
+ #
+ # The response cannot become a 500 any more, so it has to be terminated as
+ # what it is: a truncated 200. mod_sed used to skip both the final flush
+ # and the pass because of the error status, dropping the EOS with them, and
+ # the client was left waiting on a response that never ended.
+ @not_backported("r1938485", "mod_sed EOS on an evaluation error")
+ def test_filters_003_24(self, env):
+ env.httpd_error_log.add_ignored_lognos(["AH02998", "AH10394"])
+ self.configure(env, "s/monday/MON/")
+ r = self.get(env, "/huge.html")
+ assert r.exit_code == 0, \
+ f"curl failed ({r.exit_code}): the response was never terminated"
+ assert r.response["status"] == 200
+ body = r.response["body"]
+ # Truncated: the lines evaluated before the failure, and no more.
+ assert 0 < len(body) < HUGE_HEAD_LEN + 1024, len(body)
+ assert body.startswith(b"line 000000 MON ")
+ assert env.httpd_error_log.scan_recent(
+ re.compile(r'.*error evaluating sed on output'))
+
+ # SED_ABUFSIZE is the size of the append/read queue a line may build up.
+ # Filling it exactly used to write the terminating NULL one past the end
+ # of eval->abuf, over eval->aptr itself, and the next line then wrote
+ # through that NULL pointer.
+ @skip_not_backported("r1938486", "mod_sed r and w command buffer overflow (SIGSEGV)")
+ def test_filters_003_25(self, env):
+ env.httpd_error_log.add_ignored_lognos(["AH02998"])
+ path = self.sed_path(env, "insert.txt")
+ self.configure(env, [f"r {path}"] * 20)
+ r = self.get(env)
+ assert r.response, "no response: the server died"
+ assert r.response["status"] == 200
+ # 19 of the 20 fit; the last is refused, and says so.
+ assert r.response["body"].count(b"INSERTED") == 3 * 19
+ assert env.httpd_error_log.scan_recent(
+ re.compile(r'.*too many reads after line'))
+
+ # One below the limit has always worked and must keep working.
+ def test_filters_003_26(self, env):
+ path = self.sed_path(env, "insert.txt")
+ body = self.body(env, [f"r {path}"] * 19)
+ assert body.count(b"INSERTED") == 3 * 19
+
+ # --- backreferences ---------------------------------------------------
+
+ # A starred backreference whose capture matched nothing repeats a
+ # zero-length match: the matcher steps the input pointer by the capture
+ # length, so it has to stop rather than repeat it, and the star can only
+ # ever match once. Both lines reduce to their first match plus the rest.
+ #
+ # --max-time because a regression here does not return an error, it stops
+ # answering: pyhttpd gives curl --connect-timeout but no overall limit, so
+ # without one of our own a failure hangs the whole run rather than failing
+ # it. Note the worker keeps spinning after curl gives up.
+ @skip_not_backported("r1938489", "mod_sed starred backreference loop (hangs a worker)")
+ def test_filters_003_27(self, env):
+ self.configure(env, r"s/\(a*\)\1*/X/")
+ r = self.get(env, "/backref.html", options=BOUNDED)
+ assert r.exit_code == 0, f"curl failed ({r.exit_code}): no response"
+ assert r.response["status"] == 200
+ assert r.response["body"] == b"Xb\nXb\n"
+
+ # --- input filter -----------------------------------------------------
+
+ # The same expression on a request body, which runs the same matcher.
+ @skip_not_backported("r1938489", "mod_sed starred backreference loop (hangs a worker)")
+ def test_filters_003_28(self, env):
+ self.configure(env, r"s/\(a*\)\1*/X/", input_sed=True)
+ r = env.curl_post_data(env.mkurl("http", "test1", "/cgi/echo.py"),
+ data="b\naab\n", options=list(BOUNDED))
+ assert r.exit_code == 0, f"curl failed ({r.exit_code}): no response"
+ assert r.response["status"] == 200
+ assert r.response["body"] == b"Xb\nXb\n"
+
+ # InputSed rewrites the request body before the handler sees it.
+ def test_filters_003_29(self, env):
+ self.configure(env, "s/monday/MON/g", input_sed=True)
+ r = env.curl_post_data(env.mkurl("http", "test1", "/cgi/echo.py"),
+ data="one monday two\nmonday monday\n")
+ assert r.response, "no response"
+ assert r.response["status"] == 200
+ assert r.response["body"] == b"one MON two\nMON MON\n"
+
+ # A request body with no trailing newline gets one, as the manual warns.
+ def test_filters_003_30(self, env):
+ self.configure(env, "s/monday/MON/g", input_sed=True)
+ r = env.curl_post_data(env.mkurl("http", "test1", "/cgi/echo.py"),
+ data="one monday two")
+ assert r.response, "no response"
+ assert r.response["body"] == b"one MON two\n"
+
+ # A handler which reads the request body with AP_MODE_GETLINE, not
+ # AP_MODE_READBYTES, still gets it filtered.
+ @not_backported("r1938963, r1938964", "mod_sed InputSed GETLINE and Content-Length handling")
+ def test_filters_003_33(self, env):
+ self.configure(env, "s/monday/MON/g", input_sed=True, extra="""
+
+ SetHandler aptest-getline-echo
+ SetInputFilter SED
+
+ """)
+ r = env.curl_post_data(env.mkurl("http", "test1", "/getline"),
+ data="one monday two\nmonday monday\n")
+ assert r.response, "no response"
+ assert r.response["status"] == 200
+ assert r.response["body"] == b"one MON two\nMON MON\n"
+
+ # The request Content-Length is the length of the body before it was
+ # filtered, so once mod_sed has started reading the body it is removed:
+ # a handler must not find the original length.
+ @not_backported("r1938963, r1938964", "mod_sed InputSed GETLINE and Content-Length handling")
+ def test_filters_003_34(self, env):
+ self.configure(env, "s/monday/MONDAYMONDAY/g", input_sed=True,
+ extra="""
+
+ SetHandler aptest-getline-echo
+ SetInputFilter SED
+
+ """)
+ data = "one monday two\nmonday monday\n"
+ r = env.curl_post_data(env.mkurl("http", "test1", "/getline"),
+ data=data)
+ assert r.response, "no response"
+ assert r.response["status"] == 200
+ assert r.response["body"] == \
+ data.replace("monday", "MONDAYMONDAY").encode()
+ assert "ap-test-content-length" not in r.response["header"], \
+ r.response["header"]
+
+ # --- interval expressions ---------------------------------------------
+
+ # \{m,n\} repeats a character or a bracket expression.
+ @pytest.mark.parametrize("expr, out", [
+ (r"s/e\{2\}/X/",
+ "one monday two\nthrX sunday four\nmonday monday monday\n"),
+ (r"s/[a-z]\{6\}/X/",
+ "one X two\nthree X four\nX monday monday\n"),
+ ])
+ def test_filters_003_31(self, env, expr, out):
+ assert self.body(env, expr).decode() == out
+
+ # The matcher only implements \{m,n\} for those, so applied to anything
+ # else -- a group, a backreference, the start of a group or a second
+ # interval -- the expression is rejected rather than compiled into
+ # something the matcher cannot run.
+ @pytest.mark.parametrize("expr", [
+ r"s/\(a\)\{1,2\}/X/",
+ r"s/\(a\)\1\{1,2\}/X/",
+ r"s/\(\{1\}a\)/X/",
+ r"s/a\{1\}\{2,30\}/X/",
+ ])
+ @not_backported("r1938962", "mod_sed expression compile checks")
+ def test_filters_003_32(self, env, expr):
+ err = self.configure(env, expr, fails=True)
+ assert "not allowed here" in err, err
+
+ # --- backreferences to groups -----------------------------------------
+
+ # A backreference may only refer to a group which has been closed; one
+ # inside the group it refers to is rejected.
+ @pytest.mark.parametrize("expr", [
+ pytest.param(r"s/\(\(a*\)\1\)/X/", marks=COMPILE_CHECKS),
+ pytest.param(r"s/\(\(\)\1*\)/X/", marks=COMPILE_CHECKS),
+ r"s/\(a\1\)/X/",
+ ])
+ def test_filters_003_35(self, env, expr):
+ err = self.configure(env, expr, fails=True)
+ assert "out of range" in err, err
+
+ # A backreference to a closed group, even with a later group still open.
+ @not_backported("r1938962", "mod_sed expression compile checks")
+ def test_filters_003_36(self, env):
+ assert self.body(env, r"s/\(\(mo\)n\2*\)day/[\2]/").decode() == \
+ "one [mo] two\nthree sunday four\n[mo] monday monday\n"
+
+ # --- blocks -----------------------------------------------------------
+
+ # Blocks nest up to 20 deep. Each } goes in its own directive: the
+ # rest of a line after } is not read.
+ def test_filters_003_37(self, env):
+ assert self.body(env, ["{" * 20 + "s/monday/MON/"] + ["}"] * 20) \
+ .decode() == "one MON two\nthree sunday four\nMON monday monday\n"
+
+ # Any deeper is rejected.
+ @not_backported("r1938965", "mod_sed depth and delimiter checks")
+ def test_filters_003_38(self, env):
+ err = self.configure(env, ["{" * 21 + "p"] + ["}"] * 21, fails=True)
+ assert "too many {'s" in err, err
+
+ # --- bracket expressions ----------------------------------------------
+
+ # A bracket expression which ends after a range's "-" is rejected, on
+ # its own or following a longer directive.
+ @pytest.mark.parametrize("exprs", [
+ ["s/[a-"],
+ pytest.param(["s/aaaa]*/b/", "s/[a-"], marks=COMPILE_CHECKS),
+ ])
+ def test_filters_003_39(self, env, exprs):
+ err = self.configure(env, exprs, fails=True)
+ assert "imbalance" in err, err
+
+ # --- unterminated expressions -----------------------------------------
+
+ # A regular expression without its closing delimiter is rejected, and
+ # is not completed from what an earlier, longer directive left behind.
+ @pytest.mark.parametrize("exprs", [
+ ["s/a"],
+ ["s/aaaa/b/", "s/a"],
+ ])
+ @not_backported("r1938962", "mod_sed expression compile checks")
+ def test_filters_003_40(self, env, exprs):
+ err = self.configure(env, exprs, fails=True)
+ assert "missing delimiter" in err, err
+
+ # --- delimiters -------------------------------------------------------
+
+ # Any byte other than backslash and newline can be the delimiter,
+ # including one with the high bit set.
+ @not_backported("r1938962", "mod_sed expression compile checks")
+ def test_filters_003_41(self, env):
+ assert self.body(env, [b"s\xe9monday\xe9MON\xe9",
+ b"\\\xe9sunday\xe9s\xe9u\xe9U\xe9"]).decode() \
+ == "one MON two\nthree sUnday four\nMON monday monday\n"
+
+ # An s or y command which ends before its delimiter is rejected.
+ @pytest.mark.parametrize("expr", ["s", "/monday/s", "y", "/monday/y"])
+ @not_backported("r1938965", "mod_sed depth and delimiter checks")
+ def test_filters_003_42(self, env, expr):
+ err = self.configure(env, expr, fails=True)
+ assert "command garbled" in err, err
+
+ # y maps bytes with the high bit set like any other.
+ @not_backported("r1938965", "mod_sed depth and delimiter checks")
+ def test_filters_003_43(self, env):
+ self.write_bytes(env, "latin1.html", b"caf\xe9 na\xefve\n")
+ assert self.body(env, [b"y/\xe9\xef/ei/"], path="/latin1.html") == \
+ b"cafe naive\n"
+
+ # A regular expression which no longer fits once the compiled script
+ # has filled its buffer exactly, here with the names of r commands, is
+ # rejected without writing past the buffer. Only a memory checker sees
+ # the difference.
+ def test_filters_003_44(self, env):
+ err = self.configure(env, [f"r {'A' * 998}"] * 10
+ + ["r 123456789", "/x/p"], fails=True)
+ assert "too much command text" in err, err
+
+ # An interval count is rejected as too large however many digits it
+ # has, rather than wrapping around.
+ @pytest.mark.parametrize("expr", [
+ r"s/o\{255\}/X/",
+ pytest.param(r"s/o\{4294967298\}/X/", marks=COMPILE_CHECKS),
+ pytest.param(r"s/o\{1,4294967298\}/X/", marks=COMPILE_CHECKS),
+ ])
+ def test_filters_003_45(self, env, expr):
+ err = self.configure(env, expr, fails=True)
+ assert "Range endpoint too large" in err, err
+
+ # A line number too large for a 64-bit count is rejected rather than
+ # wrapping around; one which fits, but which no document reaches, is
+ # accepted.
+ @not_backported("r1938965", "mod_sed depth and delimiter checks")
+ def test_filters_003_46(self, env):
+ err = self.configure(env, "99999999999999999999p", fails=True)
+ assert "command garbled" in err, err
+
+ def test_filters_003_47(self, env):
+ assert self.body(env, "9223372036854775807d").decode() == DOC
diff -Nru apache2-2.4.69/test/modules/filters/test_004_buffer_size.py apache2-2.4.71/test/modules/filters/test_004_buffer_size.py
--- apache2-2.4.69/test/modules/filters/test_004_buffer_size.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/filters/test_004_buffer_size.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,38 @@
+import os
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+STRICT = not_backported("r1939129", "strict BufferSize parsing")
+
+
+class TestBufferSize:
+
+ # have httpd parse the configuration, like "httpd -t" does
+ def check_config(self, env, value):
+ conf = HttpdConf(env)
+ conf.add(["", f" BufferSize {value}", ""])
+ conf.add_vhost_test1()
+ conf.install()
+ return env.run([os.path.join(env.bin_dir, 'httpd'),
+ "-t", "-d", env.server_dir,
+ "-f", os.path.join(env.server_dir, 'conf', 'httpd.conf')])
+
+ @pytest.mark.parametrize("value", ["1", "123", "131072", "00123"])
+ def test_filters_004_01(self, env, value):
+ r = self.check_config(env, value)
+ assert r.exit_code == 0, f"{r.stderr}"
+
+ # without the change, a number followed by anything else is taken
+ @pytest.mark.parametrize("value", [
+ "abc",
+ pytest.param("123abc", marks=STRICT),
+ pytest.param("12.3", marks=STRICT),
+ "0x10", "0", "-1", "9" * 40,
+ ])
+ def test_filters_004_02(self, env, value):
+ r = self.check_config(env, value)
+ assert r.exit_code != 0, f"{value} was accepted"
+ assert "BufferSize must be a size in bytes" in r.stderr
diff -Nru apache2-2.4.69/test/modules/generators/conftest.py apache2-2.4.71/test/modules/generators/conftest.py
--- apache2-2.4.69/test/modules/generators/conftest.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,35 @@
+import logging
+import os
+
+import pytest
+import sys
+
+from .env import GeneratorsTestEnv
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+
+def pytest_report_header(config, start_path):
+ env = GeneratorsTestEnv()
+ return f"generators [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> GeneratorsTestEnv:
+ level = logging.INFO
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+ logging.getLogger('').addHandler(console)
+ logging.getLogger('').setLevel(level=level)
+ env = GeneratorsTestEnv(pytestconfig=pytestconfig)
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+ return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
diff -Nru apache2-2.4.69/test/modules/generators/env.py apache2-2.4.71/test/modules/generators/env.py
--- apache2-2.4.69/test/modules/generators/env.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,39 @@
+import inspect
+import logging
+import os
+import subprocess
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+
+log = logging.getLogger(__name__)
+
+
+class GeneratorsTestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(GeneratorsTestSetup)))
+ self.add_modules(["cgid", "include", "headers", "autoindex"])
+
+
+class GeneratorsTestEnv(HttpdTestEnv):
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ # A LogLevel for an unloaded module is a fatal config error.
+ log_modules = ["include", "core"]
+ if self.has_cgid_module:
+ log_modules.insert(0, "cgid")
+ self.add_httpd_log_modules(log_modules)
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=GeneratorsTestSetup(env=self))
+
+ @property
+ def has_cgid_module(self) -> bool:
+ """Whether mod_cgid was built, shared or static."""
+ if self.has_shared_module("cgid"):
+ return True
+ p = subprocess.run([os.path.join(self.bin_dir, "httpd"), "-l"],
+ capture_output=True, text=True)
+ return "mod_cgid.c" in p.stdout
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/autoindex/index.html apache2-2.4.71/test/modules/generators/htdocs/autoindex/index.html
--- apache2-2.4.69/test/modules/generators/htdocs/autoindex/index.html 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/autoindex/index.html 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+autoindex testsautoindex tests
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/autoindex/listing/att.html apache2-2.4.71/test/modules/generators/htdocs/autoindex/listing/att.html
--- apache2-2.4.69/test/modules/generators/htdocs/autoindex/listing/att.html 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/autoindex/listing/att.html 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+AT&Tpage
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/arglen.py apache2-2.4.71/test/modules/generators/htdocs/cgi/arglen.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/arglen.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/arglen.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+import os
+
+print("Content-Type: text/plain\n")
+print(len(os.environ.get("QUERY_STRING", "")))
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/argv.py apache2-2.4.71/test/modules/generators/htdocs/cgi/argv.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/argv.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/argv.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,6 @@
+#!/usr/bin/env python3
+import sys
+
+print("Content-Type: text/plain\n")
+for arg in sys.argv[1:]:
+ print(arg)
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/envcount.py apache2-2.4.71/test/modules/generators/htdocs/cgi/envcount.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/envcount.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/envcount.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+import os
+
+print("Content-Type: text/plain\n")
+print(len(os.environ))
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/noread.py apache2-2.4.71/test/modules/generators/htdocs/cgi/noread.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/noread.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/noread.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+print("Content-Type: text/plain\n")
+print("not reading")
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/nph-hello.py apache2-2.4.71/test/modules/generators/htdocs/cgi/nph-hello.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/nph-hello.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/nph-hello.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,4 @@
+#!/usr/bin/env python3
+import sys
+
+sys.stdout.write("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nnph\n")
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/redirect.py apache2-2.4.71/test/modules/generators/htdocs/cgi/redirect.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/redirect.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/redirect.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+print("Location: http://example.invalid/target\n")
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/sleep.py apache2-2.4.71/test/modules/generators/htdocs/cgi/sleep.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/sleep.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/sleep.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,6 @@
+#!/usr/bin/env python3
+import time
+
+time.sleep(5)
+print("Content-Type: text/plain\n")
+print("too late")
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/sleep_trap.py apache2-2.4.71/test/modules/generators/htdocs/cgi/sleep_trap.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/sleep_trap.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/sleep_trap.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+import signal
+import time
+
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+time.sleep(10)
+print("Content-Type: text/plain\n")
+print("too late")
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+
diff -Nru apache2-2.4.69/test/modules/generators/htdocs/cgi/stderr_fail.py apache2-2.4.71/test/modules/generators/htdocs/cgi/stderr_fail.py
--- apache2-2.4.69/test/modules/generators/htdocs/cgi/stderr_fail.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/htdocs/cgi/stderr_fail.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+import sys
+
+sys.stderr.write("cgid-stderr-marker\n")
+sys.exit(1)
diff -Nru apache2-2.4.69/test/modules/generators/test_001_cgid.py apache2-2.4.71/test/modules/generators/test_001_cgid.py
--- apache2-2.4.69/test/modules/generators/test_001_cgid.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/test_001_cgid.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,239 @@
+import os
+import re
+import signal
+import sys
+import textwrap
+import time
+
+import pytest
+
+from pyhttpd.backport import skip_not_backported
+from pyhttpd.conf import HttpdConf
+
+
+SSI_ERROR = "[an error occurred while processing this directive]"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="mod_cgid is Unix-only")
+class TestCgid:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ env.require(env.has_cgid_module, "mod_cgid not built",
+ dep="mod_cgid",
+ detected=f"not found in {env.libexec_dir}")
+ conf = HttpdConf(env, extras={
+ 'base': textwrap.dedent(f"""
+ LogLevel cgid:trace1
+ LimitRequestLine 16384
+
+ Options +Includes
+ AcceptPathInfo On
+ AddType text/html .shtml
+ AddOutputFilter INCLUDES .shtml
+
+
+ Options -Includes +IncludesNOEXEC
+
+
+ SetHandler cgi-script
+
+ """),
+ f"cgi.{env.http_tld}": textwrap.dedent("""
+ ScriptLog logs/cgid_script.log
+ Header always set X-Cgid-Test on early
+
+ CGIDScriptTimeout 1
+
+ """) + self.setenv_location("/envcount.py/small", 300)
+ + self.setenv_location("/envcount.py/large", 4200),
+ })
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ @staticmethod
+ def setenv_location(path, count):
+ setenvs = "\n".join(f"SetEnv CGID_TEST_VAR_{i:04} v"
+ for i in range(count))
+ return f'\n{setenvs}\n\n'
+
+ def script_log(self, env):
+ return os.path.join(env.server_logs_dir, "cgid_script.log")
+
+ def get(self, env, host, path, options=None):
+ r = env.curl_get(env.mkurl("http", host, path),
+ options=['--max-time', '15'] + (options or []))
+ assert r.exit_code == 0, r.stderr
+ return r
+
+ @pytest.mark.parametrize(["query", "argv"], [
+ ["foo+bar%20baz", ["foo", "bar baz"]],
+ ["a%3Bb", ["a\\;b"]],
+ ["x=1", []],
+ ])
+ def test_generators_001_01_isindex_args(self, env, query, argv):
+ """A query string without '=' is split on '+' into argv,
+ unescaped and then shell-escaped."""
+ r = self.get(env, "cgi", f"/argv.py?{query}")
+ assert r.response["status"] == 200
+ assert r.response["body"].decode().splitlines() == argv
+
+ @skip_not_backported("cause not identified", "ScriptLog capture of a failing script's stderr")
+ def test_generators_001_02_script_log(self, env):
+ """ScriptLog set in a vhost records the script's stderr, then the
+ request headers, request body and err_headers_out."""
+ log = self.script_log(env)
+ if os.path.exists(log):
+ os.unlink(log)
+ r = env.curl_post_data(env.mkurl("http", "cgi", "/stderr_fail.py"),
+ data="posted-body-marker")
+ assert r.response["status"] == 500
+ with open(log) as fd:
+ content = fd.read()
+ assert "%error\ncgid-stderr-marker\n" in content
+ assert "%% 500 " in content
+ assert "%request\n" in content
+ assert "\nposted-body-marker\n" in content
+ assert "%response\nX-Cgid-Test: on\n" in content
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH01215", "AH10599"],
+ matches=[r".*End of script output before headers: stderr_fail\.py"])
+
+ def test_generators_001_03_timeout(self, env):
+ """CGIDScriptTimeout gives 504 for a script which says nothing."""
+ start = time.time()
+ r = self.get(env, "cgi", "/sleep.py")
+ assert r.response["status"] == 504
+ assert time.time() - start < 4
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH01220"],
+ matches=[r".*Script timed out before returning headers: sleep\.py"])
+
+ def test_generators_001_04_timeout_sigkill(self, env):
+ """A timed-out script which ignores SIGTERM is sent SIGKILL."""
+ r = self.get(env, "cgi", "/sleep_trap.py")
+ assert r.response["status"] == 504
+ self.wait_for_log(env, "AH01259")
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH01220", "AH01259"],
+ matches=[r".*Script timed out before returning headers: sleep_trap\.py"])
+
+ def test_generators_001_05_redirect(self, env):
+ """An absolute Location from a script with no Status is a 302."""
+ r = self.get(env, "cgi", "/redirect.py")
+ assert r.response["status"] == 302
+ assert r.response["header"]["location"] == "http://example.invalid/target"
+
+ def test_generators_001_06_directory(self, env):
+ """A directory mapped to cgi-script is refused."""
+ r = self.get(env, "cgi", "/ssi/")
+ assert r.response["status"] == 403
+ env.httpd_error_log.ignore_recent(lognos=["AH01265"])
+
+ @pytest.mark.xfail(reason="the script's socket is reset when it exits "
+ "with unread input, discarding its output")
+ def test_generators_001_07_body_not_read(self, env):
+ """A script which exits without reading its request body
+ still has its response delivered."""
+ r = env.curl_post_data(env.mkurl("http", "cgi", "/noread.py"),
+ data="unread")
+ env.httpd_error_log.ignore_recent(lognos=["AH02651", "AH10599"])
+ assert r.exit_code == 0, r.stderr
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "not reading\n"
+
+ @skip_not_backported("r1939165", "raised limit on CGI environment variables", backported_in="2.4.70")
+ def test_generators_001_08_env_count_large(self, env):
+ """More than 256 environment variables reach the script."""
+ r = self.get(env, "cgi", "/envcount.py/small")
+ assert r.response["status"] == 200
+ assert int(r.response["body"]) >= 300
+
+ def test_generators_001_09_env_count_overflow(self, env):
+ """A request environment larger than APACHE_ARG_MAX is refused."""
+ r = self.get(env, "cgi", "/envcount.py/large")
+ assert r.response["status"] == 503
+ # the daemon sees the aborted connection as a read error
+ env.httpd_error_log.ignore_recent(lognos=["AH10245", "AH01248"])
+
+ @skip_not_backported("r1939165", "raised limit on CGI request size", backported_in="2.4.70")
+ def test_generators_001_15_long_query(self, env):
+ """A 5000-character request URI, with a query string longer
+ than PATH_MAX, reaches the script."""
+ path = "/arglen.py"
+ query = "x=" + "y" * (5000 - len(path) - len("?x="))
+ r = self.get(env, "cgi", f"{path}?{query}")
+ assert r.response["status"] == 200
+ assert int(r.response["body"]) == len(query)
+
+ def test_generators_001_10_ssi_exec_cgi(self, env):
+ """#exec cgi= runs the script with the document's query string."""
+ r = self.get(env, "cgi", "/ssi/exec_cgi.shtml?hello+world")
+ assert r.response["status"] == 200
+ assert r.response["body"].decode().split() == ["hello", "world"]
+
+ def test_generators_001_11_ssi_exec_cgi_redirect(self, env):
+ """#exec cgi= of a redirecting script inserts a link."""
+ r = self.get(env, "cgi", "/ssi/exec_cgi_redirect.shtml")
+ assert r.response["status"] == 200
+ assert r.response["body"].decode().strip() == \
+ '' \
+ 'http://example.invalid/target'
+
+ @pytest.mark.parametrize(["path", "lognos"], [
+ ["/ssi/exec_cgi_query.shtml", ["AH01230"]],
+ ["/ssi/exec_cgi_missing.shtml", ["AH01230"]],
+ ["/ssi/exec_bogus.shtml", ["AH01231"]],
+ ["/ssi/exec_noexec.shtml", ["AH01228"]],
+ ])
+ def test_generators_001_12_ssi_exec_errors(self, env, path, lognos):
+ r = self.get(env, "cgi", path)
+ assert r.response["status"] == 200
+ assert SSI_ERROR in r.response["body"].decode()
+ env.httpd_error_log.ignore_recent(lognos=lognos)
+
+ def test_generators_001_14_ssi_exec_cgi_nph(self, env):
+ """#exec cgi= refuses an NPH script; the sub-request's failure
+ leaves the directive's output empty, not an error message."""
+ r = self.get(env, "cgi", "/ssi/exec_cgi_nph.shtml")
+ assert r.response["status"] == 200
+ assert r.response["body"].decode().strip() == ""
+ env.httpd_error_log.ignore_recent(lognos=["AH01263"])
+
+ def test_generators_001_13_ssi_exec_cmd_path_info(self, env):
+ """#exec cmd= sees the document's PATH_INFO."""
+ r = self.get(env, "cgi", "/ssi/exec_path_info.shtml/extra/path")
+ assert r.response["status"] == 200
+ assert r.response["body"].decode().strip() == "PI=/extra/path"
+
+ @skip_not_backported("r1938602", "cgid daemon pid in the trace log", backported_in="2.4.70")
+ def test_generators_001_20_daemon_killed(self, env):
+ """CGI requests are served again once the cgid daemon which died
+ unexpectedly has been restarted."""
+ pids = self.daemon_pids(env)
+ assert pids, "cgid daemon pid not logged"
+ os.kill(pids[-1], signal.SIGKILL)
+ end = time.time() + 10
+ while len(self.daemon_pids(env)) == len(pids):
+ assert time.time() < end, "cgid daemon not restarted"
+ time.sleep(0.2)
+ r = self.get(env, "cgi", "/argv.py?alive")
+ assert r.response["status"] == 200
+ assert r.response["body"].decode().split() == ["alive"]
+ env.httpd_error_log.ignore_recent(lognos=["AH01239"])
+
+ def daemon_pids(self, env):
+ """The pids of the cgid daemons started so far, oldest first."""
+ with open(env.httpd_error_log.path) as fd:
+ return [int(pid) for pid in re.findall(
+ r"cgid daemon listening on \S+, pid (\d+)", fd.read())]
+
+ def wait_for_log(self, env, logno, timeout=10):
+ end = time.time() + timeout
+ while time.time() < end:
+ with open(env.httpd_error_log.path) as fd:
+ if f" {logno}: " in fd.read():
+ return
+ time.sleep(0.2)
+ assert False, f"{logno} not logged within {timeout}s"
diff -Nru apache2-2.4.69/test/modules/generators/test_002_autoindex.py apache2-2.4.71/test/modules/generators/test_002_autoindex.py
--- apache2-2.4.69/test/modules/generators/test_002_autoindex.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/generators/test_002_autoindex.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,34 @@
+import textwrap
+
+import pytest
+
+from pyhttpd.conf import HttpdConf
+
+
+class TestAutoindex:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ domain = f"autoindex.{env.http_tld}"
+ conf = HttpdConf(env, extras={
+ 'base': textwrap.dedent(f"""
+ LogLevel autoindex:trace1
+
+ Options +Indexes
+ IndexOptions FancyIndexing ScanHTMLTitles
+
+ """),
+ })
+ conf.add_vhost(domains=[domain], port=env.http_port,
+ doc_root="htdocs/autoindex")
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_generators_002_01_scan_title_bare_amp(self, env):
+ """A ScanHTMLTitles description ending in a bare '&' (no ';'), as in
+ an "AT&T" , must be rendered without reading past the end of
+ the description. Before the fix this walked one byte past the
+ terminating NUL, which ASAN flags as a heap over-read."""
+ r = env.curl_get(env.mkurl("http", "autoindex", "/listing/"))
+ assert r.response["status"] == 200
+ assert "AT&T" in r.response["body"].decode()
diff -Nru apache2-2.4.69/test/modules/http1/env.py apache2-2.4.71/test/modules/http1/env.py
--- apache2-2.4.69/test/modules/http1/env.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/http1/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,7 @@
from typing import Dict, Any
from pyhttpd.certs import CertificateSpec
+from pyhttpd.cmodules import ensure_built
from pyhttpd.conf import HttpdConf
from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
@@ -24,19 +25,22 @@
self._setup_data_1k_1m()
def _add_h1test(self):
- local_dir = os.path.dirname(inspect.getfile(H1TestSetup))
- p = subprocess.run([self.env.apxs, '-c', 'mod_h1test.c'],
- capture_output=True,
- cwd=os.path.join(local_dir, 'mod_h1test'))
- rv = p.returncode
- if rv != 0:
- log.error(f"compiling md_h1test failed: {p.stderr}")
- raise Exception(f"compiling md_h1test failed: {p.stderr}")
+ module_dir = self.env.test_modules_dir
+ if not self.env.isWindows:
+ local_dir = os.path.dirname(inspect.getfile(H1TestSetup))
+ try:
+ ensure_built(os.path.join(local_dir, 'mod_h1test'), 'h1test',
+ self.env.apxs)
+ except RuntimeError as e:
+ log.error(f"compiling mod_h1test failed: {e}")
+ raise Exception(f"compiling mod_h1test failed: {e}")
+
+ module_dir = f"{local_dir}/mod_h1test/.libs"
modules_conf = os.path.join(self.env.server_dir, 'conf/modules.conf')
with open(modules_conf, 'a') as fd:
# load our test module which is not installed
- fd.write(f"LoadModule h1test_module \"{local_dir}/mod_h1test/.libs/mod_h1test.so\"\n")
+ fd.write(f"LoadModule h1test_module \"{module_dir}/mod_h1test.so\"\n")
def _setup_data_1k_1m(self):
s90 = "01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678\n"
diff -Nru apache2-2.4.69/test/modules/http1/htdocs/cgi/requestparser.py apache2-2.4.71/test/modules/http1/htdocs/cgi/requestparser.py
--- apache2-2.4.69/test/modules/http1/htdocs/cgi/requestparser.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/http1/htdocs/cgi/requestparser.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,7 +2,10 @@
import os
import sys
from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
+try: # python-multipart >= 0.0.13 renamed the module, "multipart" is deprecated
+ import python_multipart as multipart
+except ImportError:
+ import multipart # https://github.com/Kludex/python-multipart (`apt install python3-multipart`)
import shutil
diff -Nru apache2-2.4.69/test/modules/http1/test_004_post.py apache2-2.4.71/test/modules/http1/test_004_post.py
--- apache2-2.4.69/test/modules/http1/test_004_post.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/http1/test_004_post.py 2026-11-09 03:07:05.000000000 +0000
@@ -7,9 +7,12 @@
import pytest
+from pyhttpd.env import HttpdTestEnv
+
from .env import H1Conf
+@HttpdTestEnv.needs_multipart()
class TestPost:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http1/test_005_trailers.py apache2-2.4.71/test/modules/http1/test_005_trailers.py
--- apache2-2.4.69/test/modules/http1/test_005_trailers.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/http1/test_005_trailers.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,11 +1,13 @@
import os
import pytest
+from pyhttpd.env import HttpdTestEnv
+
from .env import H1Conf
-# The trailer tests depend on "nghttp" as no other client seems to be able to send those
-# rare things.
+# These upload to the cgi handler, which parses the multipart body.
+@HttpdTestEnv.needs_multipart()
class TestTrailers:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/Makefile.in apache2-2.4.71/test/modules/http2/Makefile.in
--- apache2-2.4.69/test/modules/http2/Makefile.in 2021-08-20 16:07:44.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/Makefile.in 1970-01-01 00:00:00.000000000 +0000
@@ -1,20 +0,0 @@
-
-# no targets: we don't want to build anything by default. if you want the
-# test programs, then "make test"
-TARGETS =
-
-bin_PROGRAMS =
-
-PROGRAM_LDADD = $(EXTRA_LDFLAGS) $(PROGRAM_DEPENDENCIES) $(EXTRA_LIBS)
-PROGRAM_DEPENDENCIES = \
- $(top_srcdir)/srclib/apr-util/libaprutil.la \
- $(top_srcdir)/srclib/apr/libapr.la
-
-include $(top_builddir)/build/rules.mk
-
-test: $(bin_PROGRAMS)
-
-# example for building a test proggie
-# dbu_OBJECTS = dbu.lo
-# dbu: $(dbu_OBJECTS)
-# $(LINK) $(dbu_OBJECTS) $(PROGRAM_LDADD)
diff -Nru apache2-2.4.69/test/modules/http2/conftest.py apache2-2.4.71/test/modules/http2/conftest.py
--- apache2-2.4.69/test/modules/http2/conftest.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -23,6 +23,16 @@
logging.getLogger('').addHandler(console)
logging.getLogger('').setLevel(level=level)
env = H2TestEnv(pytestconfig=pytestconfig)
+ # setup_httpd() raises if a module it LoadModules is missing, e.g. in a
+ # build configured with --disable-http2. Report that as the missing
+ # dependency instead.
+ env.require(env.has_shared_module("http2") and env.has_shared_module("proxy_http2"),
+ "mod_http2 and mod_proxy_http2 were not built", dep="mod_http2",
+ detected=f"no mod_http2.so and mod_proxy_http2.so in {env.libexec_dir}")
+ # the h2 vhosts are TLS, so setup_httpd() hard-requires mod_ssl and
+ # raises if it was not built. Report that as the missing dependency.
+ env.require(env.has_mod_ssl(), "no mod_ssl available", dep="mod_ssl",
+ detected=f"no mod_ssl.so in {env.libexec_dir}")
env.setup_httpd()
env.apache_access_log_clear()
env.httpd_error_log.clear_log()
diff -Nru apache2-2.4.69/test/modules/http2/env.py apache2-2.4.71/test/modules/http2/env.py
--- apache2-2.4.69/test/modules/http2/env.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -6,6 +6,7 @@
from typing import Dict, Any
from pyhttpd.certs import CertificateSpec
+from pyhttpd.cmodules import ensure_built
from pyhttpd.conf import HttpdConf
from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
@@ -25,32 +26,35 @@
self._setup_data_1k_1m()
def _add_h2test(self):
- local_dir = os.path.dirname(inspect.getfile(H2TestSetup))
- p = subprocess.run([self.env.apxs, '-c', 'mod_h2test.c'],
- capture_output=True,
- cwd=os.path.join(local_dir, 'mod_h2test'))
- rv = p.returncode
- if rv != 0:
- log.error(f"compiling md_h2test failed: {p.stderr}")
- raise Exception(f"compiling md_h2test failed: {p.stderr}")
+ module_dir = self.env.test_modules_dir
+ if not self.env.isWindows:
+ local_dir = os.path.dirname(inspect.getfile(H2TestSetup))
+ try:
+ ensure_built(os.path.join(local_dir, 'mod_h2test'), 'h2test',
+ self.env.apxs)
+ except RuntimeError as e:
+ log.error(f"compiling mod_h2test failed: {e}")
+ raise Exception(f"compiling mod_h2test failed: {e}")
+
+ module_dir = f"{local_dir}/mod_h2test/.libs"
modules_conf = os.path.join(self.env.server_dir, 'conf/modules.conf')
with open(modules_conf, 'a') as fd:
# load our test module which is not installed
- fd.write(f"LoadModule h2test_module \"{local_dir}/mod_h2test/.libs/mod_h2test.so\"\n")
+ fd.write(f"LoadModule h2test_module \"{module_dir}/mod_h2test.so\"\n")
def _setup_data_1k_1m(self):
s90 = "01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678\n"
- with open(os.path.join(self.env.gen_dir, "data-1k"), 'w') as f:
+ with open(os.path.join(self.env.gen_dir, "data-1k"), 'w', newline='') as f:
for i in range(10):
f.write(f"{i:09d}-{s90}")
- with open(os.path.join(self.env.gen_dir, "data-10k"), 'w') as f:
+ with open(os.path.join(self.env.gen_dir, "data-10k"), 'w', newline='') as f:
for i in range(100):
f.write(f"{i:09d}-{s90}")
- with open(os.path.join(self.env.gen_dir, "data-100k"), 'w') as f:
+ with open(os.path.join(self.env.gen_dir, "data-100k"), 'w', newline='') as f:
for i in range(1000):
f.write(f"{i:09d}-{s90}")
- with open(os.path.join(self.env.gen_dir, "data-1m"), 'w') as f:
+ with open(os.path.join(self.env.gen_dir, "data-1m"), 'w', newline='') as f:
for i in range(10000):
f.write(f"{i:09d}-{s90}")
test1_docs = os.path.join(self.env.server_docs_dir, 'test1')
diff -Nru apache2-2.4.69/test/modules/http2/htdocs/cgi/echo.py apache2-2.4.71/test/modules/http2/htdocs/cgi/echo.py
--- apache2-2.4.69/test/modules/http2/htdocs/cgi/echo.py 2023-03-05 10:25:32.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/htdocs/cgi/echo.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
import os, sys
-import multipart
status = '200 Ok'
diff -Nru apache2-2.4.69/test/modules/http2/htdocs/cgi/requestparser.py apache2-2.4.71/test/modules/http2/htdocs/cgi/requestparser.py
--- apache2-2.4.69/test/modules/http2/htdocs/cgi/requestparser.py 2023-05-02 10:05:52.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/htdocs/cgi/requestparser.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,7 +2,10 @@
import os
import sys
from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
+try: # python-multipart >= 0.0.13 renamed the module, "multipart" is deprecated
+ import python_multipart as multipart
+except ImportError:
+ import multipart # https://github.com/Kludex/python-multipart (`apt install python3-multipart`)
import shutil
diff -Nru apache2-2.4.69/test/modules/http2/test_003_get.py apache2-2.4.71/test/modules/http2/test_003_get.py
--- apache2-2.4.69/test/modules/http2/test_003_get.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_003_get.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestGet:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_004_post.py apache2-2.4.71/test/modules/http2/test_004_post.py
--- apache2-2.4.69/test/modules/http2/test_004_post.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_004_post.py 2026-11-09 03:07:05.000000000 +0000
@@ -13,6 +13,8 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
+@H2TestEnv.needs_multipart()
class TestPost:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_006_assets.py apache2-2.4.71/test/modules/http2/test_006_assets.py
--- apache2-2.4.69/test/modules/http2/test_006_assets.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_006_assets.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,6 +4,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp(assets=True)
class TestAssets:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_008_ranges.py apache2-2.4.71/test/modules/http2/test_008_ranges.py
--- apache2-2.4.69/test/modules/http2/test_008_ranges.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_008_ranges.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,7 @@
import re
import pytest
+from pyhttpd.backport import not_backported
from .env import H2Conf, H2TestEnv
@@ -119,6 +120,8 @@
assert found, f'request not found in {self.LOGFILE}'
# send a paced curl download that aborts in the middle of the transfer
+ @not_backported("cause not identified", "bytes_tx_O counted on an aborted "
+ "H2 transfer", platform="win32")
def test_h2_008_03(self, env, repeat):
path = '/data-100m'
url = env.mkurl("https", "test1", f'{path}?03broken')
diff -Nru apache2-2.4.69/test/modules/http2/test_009_timing.py apache2-2.4.71/test/modules/http2/test_009_timing.py
--- apache2-2.4.69/test/modules/http2/test_009_timing.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_009_timing.py 2026-11-09 03:07:05.000000000 +0000
@@ -7,7 +7,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
-@pytest.mark.skipif(not H2TestEnv().h2load_is_at_least('1.41.0'), reason="h2load misses --connect-to option")
+@H2TestEnv.needs_h2load("1.41.0", reason="h2load misses --connect-to option")
class TestTiming:
LOGFILE = ""
diff -Nru apache2-2.4.69/test/modules/http2/test_101_ssl_reneg.py apache2-2.4.71/test/modules/http2/test_101_ssl_reneg.py
--- apache2-2.4.69/test/modules/http2/test_101_ssl_reneg.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_101_ssl_reneg.py 2026-11-09 03:07:05.000000000 +0000
@@ -65,8 +65,9 @@
# try to renegotiate the cipher, should fail with correct code
def test_h2_101_02(self, env):
- if not (env.curl_is_at_least('8.2.0') or env.curl_is_less_than('8.1.0')):
- pytest.skip("need curl != 8.1.x version")
+ env.require(not env.curl_is_8_1_x(), "need curl != 8.1.x version",
+ dep="curl != 8.1.x",
+ detected=f"curl {env.curl_version_str()}")
url = env.mkurl("https", "ssl", "/renegotiate/cipher/")
r = env.curl_get(url, options=[
"-vvv", "--tlsv1.2", "--tls-max", "1.2", "--ciphers", "ECDHE-RSA-AES256-GCM-SHA384"
@@ -88,8 +89,9 @@
# try to renegotiate a client certificate from Location
# needs to fail with correct code
def test_h2_101_03(self, env):
- if not (env.curl_is_at_least('8.2.0') or env.curl_is_less_than('8.1.0')):
- pytest.skip("need curl != 8.1.x version")
+ env.require(not env.curl_is_8_1_x(), "need curl != 8.1.x version",
+ dep="curl != 8.1.x",
+ detected=f"curl {env.curl_version_str()}")
url = env.mkurl("https", "ssl", "/renegotiate/verify/")
r = env.curl_get(url, options=["-vvv", "--tlsv1.2", "--tls-max", "1.2"])
assert 0 != r.exit_code
@@ -109,8 +111,9 @@
# try to renegotiate a client certificate from Directory
# needs to fail with correct code
def test_h2_101_04(self, env):
- if not (env.curl_is_at_least('8.2.0') or env.curl_is_less_than('8.1.0')):
- pytest.skip("need curl != 8.1.x version")
+ env.require(not env.curl_is_8_1_x(), "need curl != 8.1.x version",
+ dep="curl != 8.1.x",
+ detected=f"curl {env.curl_version_str()}")
url = env.mkurl("https", "ssl", "/ssl-client-verify/index.html")
r = env.curl_get(url, options=["-vvv", "--tlsv1.2", "--tls-max", "1.2"])
assert 0 != r.exit_code, f"{r}"
@@ -130,6 +133,8 @@
# make 10 requests on the same connection, none should produce a status code
# reported by erki@example.ee
def test_h2_101_05(self, env):
+ env.require(env.has_h2load(), "h2load not available", dep="h2load",
+ detected=f"tried to run {env.h2load}")
r = env.run([env.h2load, "-n", "10", "-c", "1", "-m", "1", "-vvvv",
f"{env.https_base_url}/ssl-client-verify/index.html"])
assert 0 == r.exit_code
@@ -163,8 +168,9 @@
# Check that status works with ErrorDoc, see pull #174, fixes #172
def test_h2_101_11(self, env):
- if not (env.curl_is_at_least('8.2.0') or env.curl_is_less_than('8.1.0')):
- pytest.skip("need curl != 8.1.x version")
+ env.require(not env.curl_is_8_1_x(), "need curl != 8.1.x version",
+ dep="curl != 8.1.x",
+ detected=f"curl {env.curl_version_str()}")
url = env.mkurl("https", "ssl", "/renegotiate/err-doc-cipher")
r = env.curl_get(url, options=[
"-vvv", "--tlsv1.2", "--tls-max", "1.2", "--ciphers", "ECDHE-RSA-AES256-GCM-SHA384"
diff -Nru apache2-2.4.69/test/modules/http2/test_103_upgrade.py apache2-2.4.71/test/modules/http2/test_103_upgrade.py
--- apache2-2.4.69/test/modules/http2/test_103_upgrade.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_103_upgrade.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,6 +4,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestUpgrade:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_104_padding.py apache2-2.4.71/test/modules/http2/test_104_padding.py
--- apache2-2.4.69/test/modules/http2/test_104_padding.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_104_padding.py 2026-11-09 03:07:05.000000000 +0000
@@ -9,6 +9,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestPadding:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_105_timeout.py apache2-2.4.71/test/modules/http2/test_105_timeout.py
--- apache2-2.4.69/test/modules/http2/test_105_timeout.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_105_timeout.py 2026-11-09 03:07:05.000000000 +0000
@@ -142,8 +142,10 @@
def test_h2_105_12(self, env):
# long connection timeout, short stream timeout
# sending a slow POST
- if not env.curl_is_at_least('8.0.0'):
- pytest.skip(f'need at least curl v8.0.0 for this')
+ env.require(env.curl_is_at_least('8.0.0'),
+ 'need at least curl v8.0.0 for this',
+ dep='curl >= 8.0.0',
+ detected=f"curl {env.curl_version_str()}")
if not env.httpd_is_at_least("2.5.0"):
pytest.skip(f'need at least httpd 2.5.0 for this')
conf = H2Conf(env)
@@ -159,7 +161,7 @@
time.sleep(2)
try:
piper.send("0123456789\n")
- except BrokenPipeError:
+ except (BrokenPipeError, OSError):
break
piper.close()
assert piper.response, f'{piper}'
diff -Nru apache2-2.4.69/test/modules/http2/test_106_shutdown.py apache2-2.4.71/test/modules/http2/test_106_shutdown.py
--- apache2-2.4.69/test/modules/http2/test_106_shutdown.py 2026-09-01 10:21:58.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_106_shutdown.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,6 +1,7 @@
#
# mod-h2 test suite
# check HTTP/2 timeout behaviour
+import sys
#
import time
from threading import Thread
@@ -21,6 +22,8 @@
conf.install()
assert env.apache_restart() == 0
+ @pytest.mark.skipif(sys.platform == "win32",
+ reason="mpm_winnt restarts child process on reload, killing in-flight h2 streams")
def test_h2_106_01(self, env):
url = env.mkurl("https", "cgi", "/necho.py")
lines = 100000
@@ -87,6 +90,9 @@
#
env.httpd_error_log.ignore_recent(
lognos = [
- "AH03490" # scoreboard is full, not at MaxRequestWorkers
+ "AH03490", # scoreboard is full, not at MaxRequestWorkers
+ # MaxRequestWorkers lowered to fit the ServerLimit, as logged
+ # by prefork, worker and event
+ "AH00181", "AH00319", "AH00516",
]
)
\ No newline at end of file
diff -Nru apache2-2.4.69/test/modules/http2/test_107_frame_lengths.py apache2-2.4.71/test/modules/http2/test_107_frame_lengths.py
--- apache2-2.4.69/test/modules/http2/test_107_frame_lengths.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_107_frame_lengths.py 2026-11-09 03:07:05.000000000 +0000
@@ -16,6 +16,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestFrameLengths:
URI_PATHS = []
diff -Nru apache2-2.4.69/test/modules/http2/test_108_goaway.py apache2-2.4.71/test/modules/http2/test_108_goaway.py
--- apache2-2.4.69/test/modules/http2/test_108_goaway.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_108_goaway.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,105 @@
+#
+# mod-h2 test suite
+# check the error code of GOAWAY frames sent by the server
+#
+import os
+import socket
+import ssl
+import struct
+import sys
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+class TestGoaway:
+
+ PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'
+ FRAME_SETTINGS = 0x04
+ FRAME_GOAWAY = 0x07
+ # error codes of RFC 9113, section 7
+ ERR_NO_ERROR = 0x00
+ ERR_INADEQUATE_SECURITY = 0x0c
+ ERR_HTTP_1_1_REQUIRED = 0x0d
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env, extras={
+ 'base': [
+ "AcceptFilter http none",
+ "Timeout 1",
+ "KeepAliveTimeout 1",
+ "SSLCipherSuite HIGH:!aNULL",
+ ]
+ })
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ @staticmethod
+ def _frame(ftype, payload=b''):
+ # length, type, flags and the stream id 0
+ return len(payload).to_bytes(3, 'big') + bytes([ftype, 0]) + bytes(4) + payload
+
+ @staticmethod
+ def _recv_exact(sock, n):
+ data = b''
+ while len(data) < n:
+ chunk = sock.recv(n - len(data))
+ if not chunk:
+ break
+ data += chunk
+ return data
+
+ def _goaway_error(self, env, ctx):
+ """Open a connection, send the client preface and return the error
+ code of the first GOAWAY frame the server sends, None if there is none."""
+ with socket.create_connection(('localhost', int(env.https_port)), timeout=10) as raw:
+ with ctx.wrap_socket(raw, server_hostname='localhost') as sock:
+ assert sock.selected_alpn_protocol() == 'h2'
+ sock.sendall(self.PREFACE + self._frame(self.FRAME_SETTINGS))
+ while True:
+ head = self._recv_exact(sock, 9)
+ if len(head) < 9:
+ return None
+ length = int.from_bytes(head[0:3], 'big')
+ payload = self._recv_exact(sock, length)
+ if head[3] == self.FRAME_GOAWAY:
+ return struct.unpack('>II', payload[:8])[1]
+
+ @staticmethod
+ def _tls_context(ciphers=None, tls12_only=False):
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ ctx.set_alpn_protocols(['h2'])
+ if tls12_only:
+ ctx.maximum_version = ssl.TLSVersion.TLSv1_2
+ if ciphers:
+ ctx.set_ciphers(ciphers)
+ return ctx
+
+ # An idle connection that runs into the server timeout is closed
+ # gracefully. The GOAWAY must carry NO_ERROR and not the APR status
+ # of the timeout. (Without the change only the worker MPM and mpm_winnt
+ # send that.)
+ @(not_backported("r1939156", "HTTP/2 error codes in GOAWAY")
+ if os.environ.get("MPM") == "worker" or sys.platform == "win32"
+ else pytest.mark.usefixtures())
+ def test_h2_108_01_idle_timeout(self, env):
+ error = self._goaway_error(env, self._tls_context())
+ assert error == self.ERR_NO_ERROR, f"GOAWAY error code {error}"
+
+ # A TLS connection that is not acceptable for HTTP/2 is refused with
+ # INADEQUATE_SECURITY (RFC 9113, section 9.2.1).
+ @not_backported("r1939156", "HTTP/2 error codes in GOAWAY")
+ def test_h2_108_02_inadequate_security(self, env):
+ try:
+ ctx = self._tls_context(ciphers='AES128-SHA256', tls12_only=True)
+ error = self._goaway_error(env, ctx)
+ except ssl.SSLError as ex:
+ pytest.skip(f"cipher not available: {ex}")
+ assert error == self.ERR_INADEQUATE_SECURITY, f"GOAWAY error code {error}"
diff -Nru apache2-2.4.69/test/modules/http2/test_111_header_limit_log.py apache2-2.4.71/test/modules/http2/test_111_header_limit_log.py
--- apache2-2.4.69/test/modules/http2/test_111_header_limit_log.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_111_header_limit_log.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,53 @@
+import re
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from .env import H2Conf, H2TestEnv
+
+# h2_stream_add_header() logs a limit violation once per stream, not once per
+# offending header. The guard used to be h2_stream_is_ready(), but
+# set_error_response() only sets rtmp->http_status these days and never makes
+# the stream "ready" while headers are still being read, so every oversized
+# header logged another line.
+# AH10181 is logged from the "too many headers" branch, whose only guard was
+# h2_stream_is_ready(). Each header past LimitRequestFields hits it again.
+LOGNO = "AH10181"
+LIMIT = 5
+NHEADERS = 25
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+class TestHeaderLimitLog:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env, extras={
+ f"test1.{env.http_tld}": [
+ f"LimitRequestFields {LIMIT}",
+ "LogLevel http2:info",
+ ],
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ @not_backported("r1938328", "header limit message logged once per stream",
+ backported_in="2.4.70")
+ def test_h2_111_01(self, env):
+ env.httpd_error_log.clear_log()
+ # Many more headers than LimitRequestFields allows.
+ options = ["--http2"]
+ for i in range(NHEADERS):
+ options.extend(["-H", f"X-Extra-{i}: v"])
+ r = env.curl_get(env.mkurl("https", "test1", "/index.html"), 5,
+ options=options)
+ assert r.exit_code == 0, f"curl failed: {r.stderr}"
+ assert r.response["status"] == 431, \
+ f"expected 431, got {r.response['status']}"
+
+ with open(env.httpd_error_log.path) as fd:
+ hits = [ln for ln in fd if LOGNO in ln]
+ assert len(hits) == 1, \
+ f"{LOGNO} logged {len(hits)} times for {NHEADERS} headers over " \
+ f"LimitRequestFields {LIMIT}, expected once:\n" + "".join(hits)
diff -Nru apache2-2.4.69/test/modules/http2/test_200_header_invalid.py apache2-2.4.71/test/modules/http2/test_200_header_invalid.py
--- apache2-2.4.69/test/modules/http2/test_200_header_invalid.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_200_header_invalid.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestInvalidHeaders:
@pytest.fixture(autouse=True, scope='class')
@@ -168,11 +169,13 @@
conf.install()
assert env.apache_restart() == 0
url = env.mkurl("https", "cgi", "/")
- opt = []
- for i in range(21):
- opt += ["-H", "x{0}: 1".format(i)]
- r = env.curl_get(url, options=opt)
- assert 431 == r.response["status"]
+
+ for desc, opts in [
+ ("regular", [v for i in range(21) for v in ("-H", f"x{i}: 1")]),
+ ("cookie", [v for i in range(21) for v in ("-H", f"cookie: c{i}=v{i}")]),
+ ]:
+ r = env.curl_get(url, options=opts)
+ assert r.response["status"] == 431, f"{desc} header failed"
conf = H2Conf(env)
conf.add("""
LimitRequestFields 0
@@ -213,8 +216,12 @@
# invalid chars in method
def test_h2_200_16(self, env):
- if not env.h2load_is_at_least('1.45.0'):
- pytest.skip(f'nhttp2 version too old')
+ # this drives nghttp, not h2load: gate on the version of the client
+ # actually used, so an absent h2load does not skip a runnable test
+ env.require(env.nghttp_is_at_least('1.45.0'),
+ 'nghttp2 version too old',
+ dep='nghttp >= 1.45.0',
+ detected=f"nghttp {env.nghttp_version_str()}")
conf = H2Conf(env)
conf.add_vhost_cgi()
conf.install()
diff -Nru apache2-2.4.69/test/modules/http2/test_202_trailer.py apache2-2.4.71/test/modules/http2/test_202_trailer.py
--- apache2-2.4.69/test/modules/http2/test_202_trailer.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_202_trailer.py 2026-11-09 03:07:05.000000000 +0000
@@ -14,6 +14,7 @@
# The trailer tests depend on "nghttp" as no other client seems to be able to send those
# rare things.
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestTrailers:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_203_rfc9113.py apache2-2.4.71/test/modules/http2/test_203_rfc9113.py
--- apache2-2.4.69/test/modules/http2/test_203_rfc9113.py 2026-07-06 11:52:40.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_203_rfc9113.py 2026-11-09 03:07:05.000000000 +0000
@@ -71,3 +71,28 @@
if lognos is not None:
env.httpd_error_log.ignore_recent(lognos = lognos)
+ # RFC 9113, ch. 8.3.1: an OPTIONS request in asterisk form carries ':path: *'
+ def test_h2_203_03_options_asterisk(self, env):
+ url = env.mkurl("https", "test1", "/")
+ r = env.curl_get(url, options=['--http2', '-X', 'OPTIONS',
+ '--request-target', '*'])
+ assert r.exit_code == 0, f'curl output: {r.stderr}'
+ assert r.response["protocol"] == "HTTP/2", f'curl output: {r.stdout}'
+ assert r.response["status"] == 200, f'curl output: {r.stdout}'
+
+ # the asterisk form is only valid for OPTIONS, other ':path' values
+ # not starting with '/' remain malformed. nghttp2 may already reset
+ # the stream (curl fails), otherwise we answer with a 400.
+ @pytest.mark.parametrize(["method", "target"], [
+ ['GET', '*'],
+ ['POST', '*'],
+ ['OPTIONS', '**'],
+ ['OPTIONS', 'index.html'],
+ ])
+ def test_h2_203_04_invalid_path(self, env, method, target):
+ url = env.mkurl("https", "test1", "/")
+ r = env.curl_get(url, options=['--http2', '-X', method,
+ '--request-target', target])
+ assert r.exit_code != 0 or r.response["status"] == 400, \
+ f'curl exit: {r.exit_code}, output: {r.stdout}'
+
diff -Nru apache2-2.4.69/test/modules/http2/test_400_push.py apache2-2.4.71/test/modules/http2/test_400_push.py
--- apache2-2.4.69/test/modules/http2/test_400_push.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_400_push.py 2026-11-09 03:07:05.000000000 +0000
@@ -6,6 +6,7 @@
# The push tests depend on "nghttp"
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestPush:
@pytest.fixture(autouse=True, scope='class')
@@ -198,3 +199,20 @@
assert r.response["status"] == 200
promises = r.results["streams"][r.response["id"]]["promises"]
assert 1 == len(promises)
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+class TestPushNoDiary:
+
+ # push diary disabled, with debug logging
+ def test_h2_400_60(self, env):
+ conf = H2Conf(env)
+ conf.add("""
+ H2PushDiarySize 0
+ LogLevel http2:debug
+ """)
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+ r = env.curl_get(env.mkurl("https", "test1", "/index.html"))
+ assert r.response["status"] == 200
diff -Nru apache2-2.4.69/test/modules/http2/test_401_early_hints.py apache2-2.4.71/test/modules/http2/test_401_early_hints.py
--- apache2-2.4.69/test/modules/http2/test_401_early_hints.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_401_early_hints.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,7 @@
# The push tests depend on "nghttp"
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestEarlyHints:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_500_proxy.py apache2-2.4.71/test/modules/http2/test_500_proxy.py
--- apache2-2.4.69/test/modules/http2/test_500_proxy.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_500_proxy.py 2026-11-09 03:07:05.000000000 +0000
@@ -7,6 +7,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_nghttp()
class TestProxy:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_503_proxy_fwd.py apache2-2.4.71/test/modules/http2/test_503_proxy_fwd.py
--- apache2-2.4.69/test/modules/http2/test_503_proxy_fwd.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_503_proxy_fwd.py 2026-11-09 03:07:05.000000000 +0000
@@ -46,8 +46,10 @@
assert r.json['port'] == f'{env.http_port}'
def test_h2_503_02_fwd_proxy_h2_off(self, env):
- if not env.curl_is_at_least('8.1.0'):
- pytest.skip(f'need at least curl v8.1.0 for this')
+ env.require(env.curl_is_at_least('8.1.0'),
+ 'need at least curl v8.1.0 for this',
+ dep='curl >= 8.1.0',
+ detected=f"curl {env.curl_version_str()}")
url = f'http://localhost:{env.http_port}/hello.py'
proxy_host = f'test1.{env.http_tld}'
options = [
@@ -62,8 +64,10 @@
# test the HTTP/2 setup working
def test_h2_503_03_proxy_fwd_h2_on(self, env):
- if not env.curl_is_at_least('8.1.0'):
- pytest.skip(f'need at least curl v8.1.0 for this')
+ env.require(env.curl_is_at_least('8.1.0'),
+ 'need at least curl v8.1.0 for this',
+ dep='curl >= 8.1.0',
+ detected=f"curl {env.curl_version_str()}")
self.config_fwd_proxy(env, h2_enabled=True)
url = f'http://localhost:{env.http_port}/hello.py'
proxy_host = f'test1.{env.http_tld}'
diff -Nru apache2-2.4.69/test/modules/http2/test_600_h2proxy.py apache2-2.4.71/test/modules/http2/test_600_h2proxy.py
--- apache2-2.4.69/test/modules/http2/test_600_h2proxy.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_600_h2proxy.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,4 +1,6 @@
import pytest
+import re
+import socket
from .env import H2Conf, H2TestEnv
@@ -120,7 +122,7 @@
'UseCanonicalPhysicalPort On'
])
conf.start_vhost(domains=[f'cgi.{env.http_tld}'],
- port=5004, doc_root="htdocs/cgi")
+ port=env.http_port2, doc_root="htdocs/cgi")
conf.add("AddHandler cgi-script .py")
conf.end_vhost()
conf.install()
@@ -198,3 +200,27 @@
# stream (exit_code != 0) or give a 503 response.
if r.exit_code == 0:
assert r.response['status'] in [502, 503]
+
+ # test missing host header
+ def test_h2_600_33(self, env):
+
+ conf = H2Conf(env, extras={
+ 'base': [
+ 'ProxyPreserveHost on',
+ f'ProxyPass /test/ h2c://127.0.0.1:{env.http_port}/',
+ ],
+ })
+ conf.install()
+ assert env.apache_restart() == 0
+
+ with socket.create_connection(
+ ('localhost', int(env.http_port))) as sock:
+ sock.sendall("GET /test/\r\n\r\n".encode())
+ warn_message = re.compile(
+ r'.*HTTP/0\.9 request \(with no Host header\)'
+ r' and preserve host set, forcing hostname.*'
+ )
+ assert env.httpd_error_log.scan_recent(warn_message, timeout=5)
+ env.httpd_error_log.ignore_recent(matches=[
+ r'.*HTTP/0\.9 request \(with no Host header\).*', ],
+ lognos=["AH10511"])
diff -Nru apache2-2.4.69/test/modules/http2/test_601_h2proxy_twisted.py apache2-2.4.71/test/modules/http2/test_601_h2proxy_twisted.py
--- apache2-2.4.69/test/modules/http2/test_601_h2proxy_twisted.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_601_h2proxy_twisted.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,8 +1,10 @@
import json
import logging
import os
+import sys
import pytest
+from pyhttpd.backport import not_backported
from .env import H2Conf, H2TestEnv
@@ -18,7 +20,9 @@
assert env.apache_restart() == 0
@pytest.mark.parametrize("name", [
- "data-1k", "data-10k", "data-100k", "data-1m",
+ "data-1k", "data-10k", "data-100k",
+ pytest.param("data-1m", marks=not_backported(
+ "cause not identified", "H2 proxy upload of 1MB", platform="win32")),
])
def test_h2_601_01_echo_uploads(self, env, name):
fpath = os.path.join(env.gen_dir, name)
@@ -72,8 +76,12 @@
def test_h2_601_05_echo_fail_many(self, env):
if not env.httpd_is_at_least('2.4.58'):
pytest.skip(f'needs httpd 2.4.58')
- if not env.curl_is_at_least('8.0.0'):
- pytest.skip(f'need at least curl v8.0.0 for this')
+ if sys.platform == "win32":
+ pytest.skip("command line too long for Windows (200 parallel curl requests)")
+ env.require(env.curl_is_at_least('8.0.0'),
+ 'need at least curl v8.0.0 for this',
+ dep='curl >= 8.0.0',
+ detected=f"curl {env.curl_version_str()}")
count = 200
fpath = os.path.join(env.gen_dir, "data-100k")
args = [env.curl, '--parallel', '--parallel-max', '20']
diff -Nru apache2-2.4.69/test/modules/http2/test_700_load_get.py apache2-2.4.71/test/modules/http2/test_700_load_get.py
--- apache2-2.4.69/test/modules/http2/test_700_load_get.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_700_load_get.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,8 +4,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
-@pytest.mark.skipif(not H2TestEnv().h2load_is_at_least('1.41.0'),
- reason="h2load misses --connect-to option")
+@H2TestEnv.needs_h2load("1.41.0", reason="h2load misses --connect-to option")
class TestLoadGet:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_710_load_post_static.py apache2-2.4.71/test/modules/http2/test_710_load_post_static.py
--- apache2-2.4.69/test/modules/http2/test_710_load_post_static.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_710_load_post_static.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_h2load()
class TestLoadPostStatic:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_711_load_post_cgi.py apache2-2.4.71/test/modules/http2/test_711_load_post_cgi.py
--- apache2-2.4.69/test/modules/http2/test_711_load_post_cgi.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_711_load_post_cgi.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,7 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
+@H2TestEnv.needs_h2load()
class TestLoadCgi:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/http2/test_712_buffering.py apache2-2.4.71/test/modules/http2/test_712_buffering.py
--- apache2-2.4.69/test/modules/http2/test_712_buffering.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_712_buffering.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,4 +1,5 @@
from datetime import timedelta
+import sys
import pytest
@@ -38,6 +39,8 @@
piper.stutter_check(chunks, stutter)
def test_h2_712_02(self, env):
+ if sys.platform == "win32":
+ pytest.skip("h2proxy echo buffering unreliable on mpm_winnt")
# same as 712_01 but via mod_proxy_http2
#
url = env.mkurl("https", "cgi", "/h2proxy/h2test/echo")
@@ -48,6 +51,8 @@
piper.stutter_check(chunks, stutter)
def test_h2_712_03(self, env):
+ if sys.platform == "win32":
+ pytest.skip("h2proxy echo buffering unreliable on mpm_winnt")
# same as 712_02 but with smaller chunks
#
url = env.mkurl("https", "cgi", "/h2proxy/h2test/echo")
diff -Nru apache2-2.4.69/test/modules/http2/test_800_websockets.py apache2-2.4.71/test/modules/http2/test_800_websockets.py
--- apache2-2.4.69/test/modules/http2/test_800_websockets.py 2026-05-07 09:55:24.000000000 +0000
+++ apache2-2.4.71/test/modules/http2/test_800_websockets.py 2026-11-09 03:07:05.000000000 +0000
@@ -10,7 +10,7 @@
import packaging.version
import pytest
-import websockets
+from pyhttpd.depends import needs_dependency
from pyhttpd.result import ExecResult
from pyhttpd.ws_util import WsFrameReader, WsFrame
@@ -19,9 +19,19 @@
log = logging.getLogger(__name__)
-ws_version = packaging.version.parse(websockets.version.version)
ws_version_min = packaging.version.Version('10.4')
+# ws_server.py, which these tests run as a subprocess, needs this too. The
+# import is guarded so that an absent package is reported as the missing
+# optional dependency it is, rather than failing collection of the whole
+# module - which took the rest of the http2 suite's reporting with it.
+try:
+ import websockets
+ ws_version = packaging.version.parse(websockets.version.version)
+except ImportError:
+ websockets = None
+ ws_version = None
+
def ws_run(env: H2TestEnv, path, authority=None, do_input=None, inbytes=None,
send_close=True, timeout=5, scenario='ws-stdin',
@@ -87,8 +97,13 @@
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
@pytest.mark.skipif(condition=not H2TestEnv().httpd_is_at_least("2.4.60"),
reason=f'need at least httpd 2.4.60 for this')
-@pytest.mark.skipif(condition=ws_version < ws_version_min,
- reason=f'websockets is {ws_version}, need at least {ws_version_min}')
+@needs_dependency(f"websockets >= {ws_version_min}",
+ ws_version is not None and ws_version >= ws_version_min,
+ reason=(f'websockets is {ws_version}, need at least {ws_version_min}'
+ if ws_version is not None
+ else 'no websockets python package available'),
+ detected=(f"websockets {ws_version}" if ws_version is not None
+ else "python package 'websockets' not installed"))
class TestWebSockets:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/conftest.py apache2-2.4.71/test/modules/md/conftest.py
--- apache2-2.4.69/test/modules/md/conftest.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,10 +1,13 @@
import logging
import os
+import shutil
import sys
import pytest
sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+from pyhttpd.depends import apply_gate, strict_optional_enabled
+
from .md_env import MDTestEnv
from .md_acme import MDPebbleRunner, MDBoulderRunner
@@ -19,6 +22,29 @@
)
+def pytest_collection_modifyitems(config, items):
+ """Gate the tests that need the pebble ACME server on it being installed.
+
+ The acme fixture starts 'pebble' and 'pebble-challtestsrv'. Without them
+ every such test used to error in setup; it is a missing dependency, so
+ skip (or fail under --strict-optional) before an httpd is started.
+ """
+ if MDTestEnv.get_acme_server() != 'pebble':
+ return
+ tools = ('pebble', 'pebble-challtestsrv')
+ found = {tool: shutil.which(tool) for tool in tools}
+ if all(found.values()):
+ return
+ missing = [(f"{', '.join(t for t in tools if not found[t])} (ACME test server)",
+ "pebble ACME test server not installed",
+ ", ".join(f"{t}: {found[t] or 'not on PATH'}" for t in tools))]
+ strict = strict_optional_enabled(config)
+ md_dir = os.path.dirname(os.path.abspath(__file__))
+ for item in items:
+ if str(item.path).startswith(md_dir) and 'acme' in item.fixturenames:
+ apply_gate(item, missing, strict)
+
+
@pytest.fixture(scope="package")
def env(pytestconfig) -> MDTestEnv:
level = logging.INFO
@@ -28,6 +54,10 @@
logging.getLogger('').addHandler(console)
logging.getLogger('').setLevel(level=level)
env = MDTestEnv(pytestconfig=pytestconfig)
+ # mod_md needs jansson and libcurl to be built; setup_httpd() raises if
+ # the module it LoadModules is missing. Report the missing dependency.
+ env.require(env.has_shared_module("md"), "mod_md was not built", dep="mod_md",
+ detected=f"no mod_md.so in {env.libexec_dir}")
env.setup_httpd()
env.apache_access_log_clear()
env.httpd_error_log.clear_log()
diff -Nru apache2-2.4.69/test/modules/md/dns01.py apache2-2.4.71/test/modules/md/dns01.py
--- apache2-2.4.69/test/modules/md/dns01.py 2021-12-14 11:33:27.000000000 +0000
+++ apache2-2.4.71/test/modules/md/dns01.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,12 +1,28 @@
#!/usr/bin/env python3
+import os
import subprocess
import sys
-curl = "curl"
+from configparser import ConfigParser, ExtendedInterpolation
+
challtestsrv = "localhost:8055"
+def curl_bin():
+ """The curl binary the test framework is configured to use."""
+ if 'CURL' in os.environ:
+ return os.environ['CURL']
+ pyhttpd_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'pyhttpd')
+ config_ini = os.getenv("PYHTTPD_CONFIG", os.path.join(pyhttpd_dir, 'config.ini'))
+ config = ConfigParser(interpolation=ExtendedInterpolation())
+ config.read(config_ini)
+ return config.get('global', 'curl_bin', fallback='curl')
+
+
+curl = curl_bin()
+
+
def run(args):
sys.stderr.write(f"run: {' '.join(args)}\n")
p = subprocess.Popen(args, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
diff -Nru apache2-2.4.69/test/modules/md/dns01_record.py apache2-2.4.71/test/modules/md/dns01_record.py
--- apache2-2.4.69/test/modules/md/dns01_record.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/md/dns01_record.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+# A MDChallengeDns01 command which records the arguments it was invoked with,
+# so that a test can check how mod_md passes them on, and then hands the work
+# to dns01.py so that the challenge still succeeds.
+
+import os
+import sys
+
+sys.path.append(os.path.dirname(__file__))
+
+import dns01
+
+
+def main(argv):
+ if len(argv) < 4:
+ sys.stderr.write(f"{argv[0]} without arguments\n")
+ sys.exit(2)
+ with open(argv[1], 'a+') as fd:
+ fd.write(f'{argv}\n')
+ if argv[2] == 'setup':
+ if len(argv) != 5:
+ sys.stderr.write("wrong number of arguments: "
+ "dns01_record.py setup \n")
+ sys.exit(2)
+ rv = dns01.setup(argv[3], argv[4])
+ elif argv[2] == 'teardown':
+ rv = dns01.teardown(argv[3])
+ else:
+ sys.stderr.write(f"unknown option {argv[2]}\n")
+ rv = 2
+ sys.exit(rv)
+
+
+if __name__ == "__main__":
+ main(sys.argv)
diff -Nru apache2-2.4.69/test/modules/md/dns01_v2.py apache2-2.4.71/test/modules/md/dns01_v2.py
--- apache2-2.4.69/test/modules/md/dns01_v2.py 2024-06-06 14:29:10.000000000 +0000
+++ apache2-2.4.71/test/modules/md/dns01_v2.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,12 +1,28 @@
#!/usr/bin/env python3
+import os
import subprocess
import sys
-curl = "curl"
+from configparser import ConfigParser, ExtendedInterpolation
+
challtestsrv = "localhost:8055"
+def curl_bin():
+ """The curl binary the test framework is configured to use."""
+ if 'CURL' in os.environ:
+ return os.environ['CURL']
+ pyhttpd_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'pyhttpd')
+ config_ini = os.getenv("PYHTTPD_CONFIG", os.path.join(pyhttpd_dir, 'config.ini'))
+ config = ConfigParser(interpolation=ExtendedInterpolation())
+ config.read(config_ini)
+ return config.get('global', 'curl_bin', fallback='curl')
+
+
+curl = curl_bin()
+
+
def run(args):
sys.stderr.write(f"run: {' '.join(args)}\n")
p = subprocess.Popen(args, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
diff -Nru apache2-2.4.69/test/modules/md/md_cert_util.py apache2-2.4.71/test/modules/md/md_cert_util.py
--- apache2-2.4.69/test/modules/md/md_cert_util.py 2024-09-17 12:10:23.000000000 +0000
+++ apache2-2.4.71/test/modules/md/md_cert_util.py 2026-11-09 03:07:05.000000000 +0000
@@ -11,7 +11,12 @@
from http.client import HTTPConnection
from urllib.parse import urlparse
+from cryptography.hazmat._oid import ExtensionOID
+from cryptography.hazmat.bindings._rust import ObjectIdentifier
+from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption, load_pem_private_key
+
from cryptography import x509
+from cryptography.x509 import DNSName, ExtensionNotFound
SEC_PER_DAY = 24 * 60 * 60
@@ -21,7 +26,6 @@
class MDCertUtil(object):
# Utility class for inspecting certificates in test cases
- # Uses PyOpenSSL: https://pyopenssl.org/en/stable/index.html
@classmethod
def load_server_cert(cls, host_ip, host_port, host_name, tls=None, ciphers=None):
@@ -42,12 +46,12 @@
connection.setblocking(1)
connection.set_tlsext_host_name(host_name.encode('utf-8'))
connection.do_handshake()
- peer_cert = connection.get_peer_certificate()
- return MDCertUtil(None, cert=peer_cert)
+ ossl_cert = connection.get_peer_certificate()
+ return MDCertUtil(None, cert=ossl_cert.to_cryptography())
@classmethod
def parse_pem_cert(cls, text):
- cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, text.encode('utf-8'))
+ cert = x509.load_pem_x509_certificate(text.encode('utf-8'))
return MDCertUtil(None, cert=cert)
@classmethod
@@ -72,24 +76,26 @@
return None
def __init__(self, cert_path, cert=None):
+ self.cert = cert
+ self.privkey = None
if cert_path is not None:
self.cert_path = cert_path
# load certificate and private key
if cert_path.startswith("http"):
- cert_data = self.get_plain(cert_path, 1)
- else:
- cert_data = MDCertUtil._load_binary_file(cert_path)
-
- for file_type in (OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1):
- try:
- self.cert = OpenSSL.crypto.load_certificate(file_type, cert_data)
- except Exception as error:
- self.error = error
- if cert is not None:
- self.cert = cert
-
- if self.cert is None:
- raise self.error
+ assert False
+ try:
+ with open(cert_path) as fd:
+ cert = x509.load_pem_x509_certificate("".join(fd.readlines()).encode())
+ except Exception as error:
+ self.error = error
+ if cert is not None:
+ self.cert = cert
+ if self.cert is None:
+ raise self.error
+
+ def add_privkey(self, path, password=None):
+ with open(path) as fd:
+ self.privkey = load_pem_private_key("".join(fd.readlines()).encode(), password=password)
def get_issuer(self):
return self.cert.get_issuer()
@@ -97,21 +103,20 @@
def get_serial(self):
# the string representation of a serial number is not unique. Some
# add leading 0s to align with word boundaries.
- return ("%lx" % (self.cert.get_serial_number())).upper()
+ return ("%lx" % (self.cert.serial_number)).upper()
@staticmethod
def _get_serial(cert) -> int:
if isinstance(cert, x509.Certificate):
return cert.serial_number
if isinstance(cert, MDCertUtil):
- return cert.get_serial_number()
- elif isinstance(cert, OpenSSL.crypto.X509):
- return cert.get_serial_number()
+ return cert.cert.serial_number
elif isinstance(cert, str):
# assume a hex number
return int(cert, 16)
elif isinstance(cert, int):
return cert
+ assert False, f'{cert}'
return 0
def get_serial_number(self):
@@ -121,89 +126,33 @@
return self._get_serial(self.cert) == self._get_serial(other)
def get_not_before(self):
- tsp = self.cert.get_notBefore()
- return self._parse_tsp(tsp)
+ try:
+ return self.cert.not_valid_before_utc
+ except AttributeError:
+ return self.cert.not_valid_before
def get_not_after(self):
- tsp = self.cert.get_notAfter()
- return self._parse_tsp(tsp)
-
- def get_cn(self):
- return self.cert.get_subject().CN
+ try:
+ return self.cert.not_valid_after_utc
+ except AttributeError:
+ return self.cert.not_valid_after
def get_key_length(self):
- return self.cert.get_pubkey().bits()
+ return self.cert.public_key().key_size
def get_san_list(self):
- text = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_TEXT, self.cert).decode("utf-8")
- m = re.search(r"X509v3 Subject Alternative Name:(\s+critical)?\s*(.*)", text)
- sans_list = []
- if m:
- sans_list = m.group(2).split(",")
-
- def _strip_prefix(s):
- return s.split(":")[1] if s.strip().startswith("DNS:") else s.strip()
- return list(map(_strip_prefix, sans_list))
+ sans = self.cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
+ return sans.value.get_values_for_type(DNSName)
def get_must_staple(self):
- text = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_TEXT, self.cert).decode("utf-8")
- m = re.search(r"1.3.6.1.5.5.7.1.24:\s*\n\s*0....", text)
- if not m:
- # Newer openssl versions print this differently
- m = re.search(r"TLS Feature:\s*\n\s*status_request\s*\n", text)
- return m is not None
+ try:
+ self.cert.extensions.get_extension_for_oid(ExtensionOID.TLS_FEATURE)
+ return True
+ except ExtensionNotFound:
+ return False
@classmethod
def validate_privkey(cls, privkey_path, passphrase=None):
- privkey_data = cls._load_binary_file(privkey_path)
- if passphrase:
- privkey = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey_data, passphrase)
- else:
- privkey = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey_data)
- return privkey.check()
-
- def validate_cert_matches_priv_key(self, privkey_path):
- # Verifies that the private key and cert match.
- privkey_data = MDCertUtil._load_binary_file(privkey_path)
- privkey = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey_data)
- context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
- context.use_privatekey(privkey)
- context.use_certificate(self.cert)
- context.check_privatekey()
-
- # --------- _utils_ ---------
-
- def astr(self, s):
- return s.decode('utf-8')
-
- def _parse_tsp(self, tsp):
- # timestampss returned by PyOpenSSL are bytes
- # parse date and time part
- s = ("%s-%s-%s %s:%s:%s" % (self.astr(tsp[0:4]), self.astr(tsp[4:6]), self.astr(tsp[6:8]),
- self.astr(tsp[8:10]), self.astr(tsp[10:12]), self.astr(tsp[12:14])))
- timestamp = datetime.strptime(s, '%Y-%m-%d %H:%M:%S')
- # adjust timezone
- tz_h, tz_m = 0, 0
- m = re.match(r"([+\-]\d{2})(\d{2})", self.astr(tsp[14:]))
- if m:
- tz_h, tz_m = int(m.group(1)), int(m.group(2)) if tz_h > 0 else -1 * int(m.group(2))
- return timestamp.replace(tzinfo=self.FixedOffset(60 * tz_h + tz_m))
-
- @classmethod
- def _load_binary_file(cls, path):
- with open(path, mode="rb") as file:
- return file.read()
-
- class FixedOffset(tzinfo):
-
- def __init__(self, offset):
- self.__offset = timedelta(minutes=offset)
-
- def utcoffset(self, dt):
- return self.__offset
-
- def tzname(self, dt):
- return None
-
- def dst(self, dt):
- return timedelta(0)
+ with open(privkey_path) as fd:
+ privkey = load_pem_private_key("".join(fd.readlines()).encode(), password=passphrase)
+ return privkey is not None
diff -Nru apache2-2.4.69/test/modules/md/md_conf.py apache2-2.4.71/test/modules/md/md_conf.py
--- apache2-2.4.69/test/modules/md/md_conf.py 2025-11-04 14:51:48.000000000 +0000
+++ apache2-2.4.71/test/modules/md/md_conf.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,3 +1,5 @@
+import os
+
from .md_env import MDTestEnv
from pyhttpd.conf import HttpdConf
@@ -6,7 +8,7 @@
def __init__(self, env: MDTestEnv, text=None, std_ports=True,
local_ca=True, std_vhosts=True, proxy=False,
- admin=None):
+ admin=None, no_renewal=False):
super().__init__(env=env)
if admin is None:
@@ -25,6 +27,11 @@
f"MDCACertificateFile {env.server_dir}/acme-ca.pem",
"",
])
+ if no_renewal:
+ # A renewal still running when the server stops delays the stop,
+ # which httpd logs as a warning. A failing handler vetoes it.
+ cmd = os.path.join(env.test_dir, "../modules/md/msg_fail_on.py")
+ self.add(f"MDMessageCmd {cmd} {env.gen_dir}/message.log renewing")
if std_ports:
self.add(f"MDPortMap 80:{env.http_port} 443:{env.https_port}")
if env.ssl_module == "mod_tls":
@@ -46,7 +53,7 @@
" ProxyRequests On",
" ProxyVia On",
" # be totally open",
- " AllowCONNECT 0-56535",
+ " AllowCONNECT 0-65535",
" ",
" # No require or other restrictions, this is just a test server",
" ",
diff -Nru apache2-2.4.69/test/modules/md/md_env.py apache2-2.4.71/test/modules/md/md_env.py
--- apache2-2.4.69/test/modules/md/md_env.py 2024-09-17 12:10:23.000000000 +0000
+++ apache2-2.4.71/test/modules/md/md_env.py 2026-11-09 03:07:05.000000000 +0000
@@ -90,8 +90,9 @@
@classmethod
def has_a2md(cls):
d = os.path.dirname(inspect.getfile(HttpdTestEnv))
+ config_ini = os.getenv("PYHTTPD_CONFIG", os.path.join(d, 'config.ini'))
config = ConfigParser(interpolation=ExtendedInterpolation())
- config.read(os.path.join(d, 'config.ini'))
+ config.read(config_ini)
bin_dir = config.get('global', 'bindir')
a2md_bin = os.path.join(bin_dir, 'a2md')
return os.path.isfile(a2md_bin)
@@ -212,16 +213,21 @@
def check_acme(self):
if self._acme_server_ok:
return True
- if self._acme_server_down:
- pytest.skip(msg="ACME server not running")
- return False
- if self.is_live(self.acme_url, timeout=timedelta(seconds=0.5)):
- self._acme_server_ok = True
- return True
- else:
+ if not self._acme_server_down:
+ if self.is_live(self.acme_url, timeout=timedelta(seconds=0.5)):
+ self._acme_server_ok = True
+ return True
self._acme_server_down = True
- pytest.fail(msg="ACME server not running", pytrace=False)
- return False
+ # Both the first test to notice and every one after it report the
+ # same way: a configured-but-unreachable ACME server is a missing
+ # dependency, so skip, or fail under --strict-optional. Previously
+ # the first failed and the rest skipped, and both calls passed the
+ # 'msg' keyword that pytest removed in 8.0, raising TypeError.
+ self.require(False, "ACME server not running",
+ dep="ACME test server",
+ detected=f"ACME={self.get_acme_server()}, "
+ f"no response from {self.acme_url}")
+ return False
def get_ca_pem_file(self, hostname: str) -> Optional[str]:
pem_file = super().get_ca_pem_file(hostname)
@@ -340,7 +346,7 @@
md = self.get_md_status(domain)
assert md
assert 'state' in md, "md is unexpected: {0}".format(md)
- assert md['state'] is MDTestEnv.MD_S_COMPLETE, f"unexpected state: {md['state']}"
+ assert md['state'] is MDTestEnv.MD_S_COMPLETE, f"unexpected state: {md}"
pkey_file = self.store_domain_file(domain, self.pkey_fname(pkey))
cert_file = self.store_domain_file(domain, self.cert_fname(pkey))
r = self.run(['ls', os.path.dirname(pkey_file)])
@@ -359,7 +365,7 @@
# check private key, validate certificate, etc
MDCertUtil.validate_privkey(self.store_domain_file(domain, 'privkey.pem'))
cert = MDCertUtil(self.store_domain_file(domain, 'pubcert.pem'))
- cert.validate_cert_matches_priv_key(self.store_domain_file(domain, 'privkey.pem'))
+ cert.add_privkey(self.store_domain_file(domain, 'privkey.pem'))
# No longer check CN, it may not be set or is not trusted anyway
# assert cert.get_cn() == domain, f'CN: expected "{domain}", got {cert.get_cn()}'
# check SANs
@@ -474,14 +480,22 @@
via_domain=None, use_https=True):
try_until = time.time() + timeout
renewals = {}
+ statuses = {}
names = names.copy()
while len(names) > 0:
if time.time() >= try_until:
+ self.save_renewal_failure(names, statuses)
return False
for name in names:
- mds = self.get_md_status(name, via_domain=via_domain, use_https=use_https)
+ try:
+ mds = self.get_md_status(name, via_domain=via_domain, use_https=use_https)
+ except AssertionError:
+ self.save_renewal_failure(names, statuses)
+ raise
+ statuses[name] = mds
if mds is None:
log.debug("not managed by md: %s" % name)
+ self.save_renewal_failure(names, statuses)
return False
if 'renewal' in mds:
@@ -496,9 +510,41 @@
time.sleep(0.1)
if restart:
time.sleep(0.1)
- return self.apache_restart() == 0
+ if self.apache_restart() != 0:
+ self.save_renewal_failure(names, statuses)
+ return False
return True
+ def save_renewal_failure(self, names, statuses):
+ """Keep diagnostics before the next test clears the log and MD store.
+
+ Only logs, configuration and public job/status JSON are copied, not
+ private keys or ACME account credentials.
+ """
+ test_name = self.current_test_name or "renewal"
+ safe_name = re.sub(r'[^A-Za-z0-9_.-]', '_', test_name)
+ dest = os.path.join(self.gen_dir, "failed-tests", safe_name)
+ try:
+ os.makedirs(dest, exist_ok=True)
+ with open(os.path.join(dest, "md-status.json"), "w") as fd:
+ json.dump(statuses, fd, indent=2)
+ for src, name in [
+ (self.httpd_error_log.path, "error_log"),
+ (os.path.join(self.server_dir, "conf", "httpd.conf"), "httpd.conf"),
+ (os.path.join(self.gen_dir, "pebble.log"), "pebble.log"),
+ ]:
+ if os.path.isfile(src):
+ shutil.copyfile(src, os.path.join(dest, name))
+ for name in statuses:
+ src = self.path_job(name)
+ if os.path.isfile(src):
+ safe_domain = re.sub(r'[^A-Za-z0-9_.-]', '_', name)
+ shutil.copyfile(src, os.path.join(dest, f"{safe_domain}-job.json"))
+ log.error("renewal did not complete for %s; diagnostics in %s; status: %s",
+ names, dest, statuses)
+ except OSError:
+ log.exception("unable to save renewal diagnostics in %s", dest)
+
def is_renewing(self, name):
stat = self.get_certificate_status(name)
return 'renewal' in stat
diff -Nru apache2-2.4.69/test/modules/md/test_001_store.py apache2-2.4.71/test/modules/md/test_001_store.py
--- apache2-2.4.69/test/modules/md/test_001_store.py 2022-05-16 11:36:20.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_001_store.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,6 +4,8 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_env import MDTestEnv
@@ -11,7 +13,7 @@
return md['name']
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
class TestStore:
@pytest.fixture(autouse=True, scope='function')
@@ -21,7 +23,7 @@
# verify expected binary version
def test_md_001_001(self, env: MDTestEnv):
r = env.run([env.a2md_bin, "-V"])
- m = re.match(r'version: (\d+\.\d+\.\d+)(-git)?$', r.stdout)
+ m = re.match(r'version: (\d+\.\d+\.\d+)(-(git|dev))?$', r.stdout)
assert m, f"expected version info in '{r.stdout}'"
# verify that store is clean
diff -Nru apache2-2.4.69/test/modules/md/test_010_store_migrate.py apache2-2.4.71/test/modules/md/test_010_store_migrate.py
--- apache2-2.4.69/test/modules/md/test_010_store_migrate.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_010_store_migrate.py 2026-11-09 03:07:05.000000000 +0000
@@ -3,11 +3,13 @@
import os
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
class TestStoreMigrate:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_100_reg_add.py apache2-2.4.71/test/modules/md/test_100_reg_add.py
--- apache2-2.4.69/test/modules/md/test_100_reg_add.py 2022-05-16 11:36:20.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_100_reg_add.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,12 +2,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestRegAdd:
@pytest.fixture(autouse=True, scope='function')
diff -Nru apache2-2.4.69/test/modules/md/test_110_reg_update.py apache2-2.4.71/test/modules/md/test_110_reg_update.py
--- apache2-2.4.69/test/modules/md/test_110_reg_update.py 2022-05-16 11:36:20.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_110_reg_update.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,12 +2,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestRegUpdate:
NAME1 = "greenbytes2.de"
diff -Nru apache2-2.4.69/test/modules/md/test_120_reg_list.py apache2-2.4.71/test/modules/md/test_120_reg_list.py
--- apache2-2.4.69/test/modules/md/test_120_reg_list.py 2022-05-16 11:36:20.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_120_reg_list.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,12 +4,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestRegAdd:
@pytest.fixture(autouse=True, scope='function')
diff -Nru apache2-2.4.69/test/modules/md/test_202_acmev2_regs.py apache2-2.4.71/test/modules/md/test_202_acmev2_regs.py
--- apache2-2.4.69/test/modules/md/test_202_acmev2_regs.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_202_acmev2_regs.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,13 +4,16 @@
import json
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestAcmeAcc:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_300_conf_validate.py apache2-2.4.71/test/modules/md/test_300_conf_validate.py
--- apache2-2.4.69/test/modules/md/test_300_conf_validate.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_300_conf_validate.py 2026-11-09 03:07:05.000000000 +0000
@@ -7,12 +7,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestConf:
@pytest.fixture(autouse=True, scope='class')
@@ -101,6 +104,7 @@
# test case: two MDomains, one correct vhost name
def test_md_300_006(self, env):
MDConf(env, text="""
+ MDRenewMode manual
MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
ServerName example2.org
@@ -118,6 +122,7 @@
# test case: two MDomains, two correct vhost names
def test_md_300_007(self, env):
MDConf(env, text="""
+ MDRenewMode manual
MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
ServerName example2.org
@@ -138,6 +143,7 @@
# test case: two MDomains, overlapping vhosts
def test_md_300_008(self, env):
MDConf(env, text="""
+ MDRenewMode manual
MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
ServerName example2.org
@@ -184,6 +190,7 @@
def test_md_300_010(self, env):
conf = MDConf(env)
conf.add("""
+ MDRenewMode manual
MDomain not-forbidden.org www.not-forbidden.org mail.not-forbidden.org test3.not-forbidden.org
@@ -204,7 +211,11 @@
"not-forbidden.org", "test3.not-forbidden.org", "test4.not-forbidden.org"
])
conf.install()
- assert env.apache_fail() == 0
+ # not an error: mod_md warns and the server starts (check_coverage())
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ assert env.httpd_error_log.scan_recent(re.compile(
+ r'.* AH10040: .* the name/alias test4\.not-forbidden\.org itself is not managed'),
+ timeout=1)
env.apache_stop()
env.httpd_error_log.ignore_recent([
"AH10040" # A requested MD certificate will not match ServerName
@@ -214,6 +225,7 @@
def test_md_300_011b(self, env):
env.apache_stop()
MDConf(env, text="""
+ MDRenewMode manual
MDomain not-forbidden.org auto mail.not-forbidden.org
@@ -244,6 +256,7 @@
# test case: one md covers two vhosts
def test_md_300_013(self, env):
MDConf(env, text="""
+ MDRenewMode manual
MDomain example2.org test-a.example2.org test-b.example2.org
ServerName test-a.example2.org
@@ -344,7 +357,11 @@
"not.secret.com", "secret.com"
])
conf.install()
- assert env.apache_fail() == 0
+ # accepted: a name of the vhost that the MD does not cover is only
+ # warned about, see test_md_300_011a
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ env.apache_stop()
+ env.httpd_error_log.ignore_recent(["AH10040"])
# this is unreliable on debian
#assert env.httpd_error_log.scan_recent(
# re.compile(r'.*Virtual Host not.secret.com:0 matches Managed Domain \'secret.com\', '
@@ -355,6 +372,7 @@
# test case: use MDRequireHttps in an construct, but not in
MDRequireHttps temporary
@@ -424,6 +442,7 @@
conf = MDConf(env)
domain = f"t300-026.{env.http_tld}"
conf.add(f"""
+ MDRenewMode manual
MDomain {domain}
""")
conf.add_vhost(port=env.http_port, domains=[domain], with_ssl=False)
@@ -474,6 +493,7 @@
dalias = f"t300-028alias.{env.http_tld}"
conf.add_vhost(port=env.http_port, domains=[domaina, domainb, dalias], with_ssl=False)
conf.add(f"""
+ MDRenewMode manual
MDMembers manual
MDomain {domaina}
MDomain {domainb} {dalias}
diff -Nru apache2-2.4.69/test/modules/md/test_310_conf_store.py apache2-2.4.71/test/modules/md/test_310_conf_store.py
--- apache2-2.4.69/test/modules/md/test_310_conf_store.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_310_conf_store.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,6 +2,8 @@
import time
import pytest
+
+from pyhttpd.depends import needs_dependency
import os
from .md_conf import MDConf
@@ -12,15 +14,22 @@
NS_PER_DAY = MS_PER_DAY * 1000
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestConf:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, acme):
acme.start(config='default')
env.check_acme()
+ env.httpd_error_log.add_ignored_lognos(["AH10045", "AH10105"])
+ # the failing message command of MDConf(no_renewal=True)
+ env.httpd_error_log.add_ignored_matches([r'.*urn:org:apache:httpd:log:AH10109:.*'])
+ yield
+ env.httpd_error_log.remove_ignored_lognos(["AH10045", "AH10105"])
+ env.httpd_error_log.clear_ignored_matches()
@pytest.fixture(autouse=True, scope='function')
def _method_scope(self, env, request):
@@ -133,6 +142,7 @@
# test case: assign separate contact info based on VirtualHost
def test_md_310_107(self, env):
MDConf(env, admin="", text="""
+ MDRenewMode manual
MDomain testdomain.org www.testdomain.org mail.testdomain.org
MDomain testdomain2.org www.testdomain2.org mail.testdomain2.org
@@ -190,7 +200,7 @@
# test case: drive mode always
def test_md_310_112(self, env):
- MDConf(env, text="""
+ MDConf(env, no_renewal=True, text="""
MDRenewMode always
MDomain testdomain.org www.testdomain.org mail.testdomain.org
""").install()
@@ -245,6 +255,7 @@
# test case: automatically collect md names from vhost config
def test_md_310_117(self, env):
conf = MDConf(env, text="""
+ MDRenewMode manual
MDMember auto
MDomain testdomain.org
""")
@@ -421,7 +432,7 @@
("always", 2)
])
def test_md_310_207(self, env, renew_mode, exp_code):
- MDConf(env, text="""
+ MDConf(env, no_renewal=True, text="""
MDRenewMode %s
MDomain testdomain.org www.testdomain.org mail.testdomain.org
""" % renew_mode).install()
@@ -589,7 +600,7 @@
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
assert env.a2md(["list"]).json['output'][0]['renew-mode'] == 1
# test case: drive mode always
- MDConf(env, text="""
+ MDConf(env, no_renewal=True, text="""
MDRenewMode always
MDomain testdomain.org www.testdomain.org mail.testdomain.org
""").install()
@@ -771,6 +782,7 @@
assert env.a2md(["add", name]).exit_code == 0
assert env.a2md(["update", name, "contacts", "admin@" + name]).exit_code == 0
assert env.a2md(["update", name, "agreement", env.acme_tos]).exit_code == 0
+ MDConf(env).install()
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
# setup: drive it
assert env.a2md(["drive", name]).exit_code == 0
diff -Nru apache2-2.4.69/test/modules/md/test_502_acmev2_drive.py apache2-2.4.71/test/modules/md/test_502_acmev2_drive.py
--- apache2-2.4.69/test/modules/md/test_502_acmev2_drive.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_502_acmev2_drive.py 2026-11-09 03:07:05.000000000 +0000
@@ -7,6 +7,8 @@
from datetime import timedelta
import pytest
+
+from pyhttpd.depends import needs_dependency
from pyhttpd.certs import CertificateSpec
from .md_conf import MDConf
@@ -14,9 +16,10 @@
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestDrivev2:
@pytest.fixture(autouse=True, scope='class')
@@ -395,7 +398,7 @@
# check new cert
env.check_md_credentials([name, "test." + domain])
new_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
- assert not old_cert.same_serial_as(new_cert.get_serial)
+ assert not old_cert.same_serial_as(new_cert.get_serial())
@pytest.mark.parametrize("renew_window,test_data_list", [
("14d", [
@@ -440,6 +443,11 @@
md = env.a2md(["list", name]).json['output'][0]
assert md["renew"] == tc["renew"], \
"Expected renew == {} indicator in {}, test case {}".format(tc["renew"], md, tc)
+ env.httpd_error_log.ignore_recent(
+ lognos=[
+ "AH10045"
+ ]
+ )
@pytest.mark.parametrize("key_type,key_params,exp_key_length", [
("RSA", [2048], 2048),
@@ -466,6 +474,11 @@
# check cert key length
cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem'))
assert cert.get_key_length() == exp_key_length
+ env.httpd_error_log.ignore_recent(
+ lognos=[
+ "AH10045"
+ ]
+ )
# test_502_203 removed, as ToS agreement is not really checked in ACMEv2
@@ -550,4 +563,4 @@
# check: key file is encrypted PEM
md = env.a2md(["list", name]).json['output'][0]
acc = md['ca']['account']
- MDCertUtil.validate_privkey(env.path_account_key(acc), lambda *args: encrypt_key)
+ MDCertUtil.validate_privkey(env.path_account_key(acc), encrypt_key)
diff -Nru apache2-2.4.69/test/modules/md/test_602_roundtrip.py apache2-2.4.71/test/modules/md/test_602_roundtrip.py
--- apache2-2.4.69/test/modules/md/test_602_roundtrip.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_602_roundtrip.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,13 +4,16 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available")
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestRoundtripv2:
@pytest.fixture(autouse=True, scope='class')
@@ -19,6 +22,9 @@
env.APACHE_CONF_SRC = "data/test_roundtrip"
env.clear_store()
MDConf(env).install()
+ env.httpd_error_log.add_ignored_lognos(["AH10045"])
+ yield
+ env.httpd_error_log.remove_ignored_lognos(["AH10045"])
@pytest.fixture(autouse=True, scope='function')
def _method_scope(self, env, request):
diff -Nru apache2-2.4.69/test/modules/md/test_702_auto.py apache2-2.4.71/test/modules/md/test_702_auto.py
--- apache2-2.4.69/test/modules/md/test_702_auto.py 2025-11-04 14:51:48.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_702_auto.py 2026-11-09 03:07:05.000000000 +0000
@@ -3,6 +3,8 @@
from datetime import timedelta
import pytest
+
+from pyhttpd.depends import needs_dependency
from pyhttpd.certs import CertificateSpec
from pyhttpd.env import HttpdTestEnv
@@ -11,8 +13,9 @@
from .md_conf import MDConf
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestAutov2:
@pytest.fixture(autouse=True, scope='class')
@@ -26,6 +29,10 @@
@pytest.fixture(autouse=True, scope='function')
def _method_scope(self, env, request):
+ # A renewal can still be running after the previous test activated
+ # its certificate. Do not remove its store while the watchdog writes
+ # to it, or carry that renewal into the next test's restart.
+ assert env.apache_stop() == 0
env.clear_store()
self.test_domain = env.get_request_domain(request)
@@ -208,8 +215,8 @@
# check temporary cert from server
cert2 = MDCertUtil(env.path_fallback_cert(domain))
assert cert1.same_serial_as(cert2), \
- "Unexpected temporary certificate on vhost %s. Expected cn: %s , "\
- "but found cn: %s" % (name_a, cert2.get_cn(), cert1.get_cn())
+ f"Unexpected temporary certificate on vhost {name_a}." \
+ f" Expected cn: {cert2}, but found cn: {cert1}"
# test case: drive MD with only invalid challenges, domains should stay 503'd
def test_md_702_006(self, env):
@@ -297,6 +304,59 @@
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
env.check_md_complete(domain)
+ # Specify a valid http proxy for a single MDomain
+ def test_md_702_008b(self, env):
+ if not env.httpd_is_at_least("2.5.0"):
+ pytest.xfail("MDHttpProxy inside MDomainSet not backported")
+ domain = self.test_domain
+ domains = [domain]
+ #
+ conf = MDConf(env, admin=f"admin@{domain}", proxy=True)
+ conf.add_drive_mode("always")
+ conf.start_md(domains)
+ conf.add(f" MDHttpProxy http://localhost:{env.proxy_port}")
+ conf.end_md()
+ conf.install()
+ #
+ # - restart (-> drive), check that md is in store
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ assert env.await_completion([domain])
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ env.check_md_complete(domain)
+
+ # Specify a non-working http proxy for MDomain A and a valid http proxy for MDomain B
+ def test_md_702_008c(self, env):
+ if not env.httpd_is_at_least("2.5.0"):
+ pytest.xfail("MDHttpProxy inside MDomainSet not backported")
+ domain_a = f"a{self.test_domain}"
+ domain_b = f"b{self.test_domain}"
+ conf = MDConf(env, admin=f"admin@{domain_a}", proxy=True)
+ conf.start_md([domain_a])
+ conf.add(f" MDHttpProxy http://localhost:1")
+ conf.end_md()
+ conf.add_vhost(domains=[domain_a])
+ conf.start_md([domain_b])
+ conf.add(f" MDHttpProxy http://localhost:{env.proxy_port}")
+ conf.end_md()
+ conf.add_vhost(domains=[domain_b])
+ conf.install()
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ assert env.await_completion([domain_b], restart=False)
+ md = env.await_error(domain_a)
+ assert md
+ assert md['renewal']['errors'] > 0
+ assert md['renewal']['last']['status-description'] == 'Connection refused'
+ assert 'account' not in md['ca']
+ #
+ env.httpd_error_log.ignore_recent(
+ lognos = [
+ "AH10056" # Unsuccessful in contacting ACME server
+ ],
+ matches = [
+ r'.*Unsuccessful in contacting ACME server at .*'
+ ]
+ )
+
# Force cert renewal due to critical remaining valid duration
# Assert that new cert activation is delayed
def test_md_702_009(self, env, acme):
@@ -343,9 +403,15 @@
conf.add_md(domains)
conf.add_vhost(domains)
conf.install()
- assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
- md = env.await_error(domain)
- assert md["renewal"]["errors"] > 0
+ try:
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ md = env.await_error(domain)
+ assert md and md["renewal"]["errors"] > 0
+ finally:
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH10173", "AH10056"],
+ matches=[r'.*None of the ACME challenge methods configured for this domain are suitable.*']
+ )
#
# now the same with a 80 mapped to a supported port
conf = MDConf(env, admin="admin@" + domain)
@@ -358,16 +424,6 @@
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
env.check_md(domains)
assert env.await_completion([domain])
- #
- env.httpd_error_log.ignore_recent(
- lognos = [
- "AH10173", # None of the ACME challenge methods configured for this domain are suitable
- "AH10056" # None of the ACME challenge methods configured for this domain are suitable
- ],
- matches = [
- r'.*None of the ACME challenge methods configured for this domain are suitable.*'
- ]
- )
def test_md_702_011(self, env):
domain = self.test_domain
@@ -381,9 +437,15 @@
conf.add_md(domains)
conf.add_vhost(domains)
conf.install()
- assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
- md = env.await_error(domain)
- assert md["renewal"]["errors"] > 0
+ try:
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ md = env.await_error(domain)
+ assert md and md["renewal"]["errors"] > 0
+ finally:
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH10173", "AH10056"],
+ matches=[r'.*None of the ACME challenge methods configured for this domain are suitable.*']
+ )
#
# now the same with a 443 mapped to a supported port
conf = MDConf(env, admin="admin@" + domain)
@@ -397,16 +459,6 @@
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
env.check_md(domains)
assert env.await_completion([domain])
- #
- env.httpd_error_log.ignore_recent(
- lognos = [
- "AH10173", # None of the ACME challenge methods configured for this domain are suitable
- "AH10056" # None of the ACME challenge methods configured for this domain are suitable
- ],
- matches = [
- r'.*None of the ACME challenge methods configured for this domain are suitable.*'
- ]
- )
# test case: one MD with several dns names. sign up. remove the *first* name
# in the MD. restart. should find and keep the existing MD.
@@ -626,6 +678,16 @@
stat = env.get_md_status(domain)
assert stat["proto"]["acme-tls/1"] == []
MDConf(env).install()
+ if not env.httpd_is_at_least("2.5.0"):
+ # On 2.4.x, mod_watchdog does not stop its threads before a
+ # child exits, so the renewal can still be signing when
+ # OpenSSL's atexit handler has run, failing with APR_ENOTIMPL.
+ # Logged by the exiting child after the restart, so possibly
+ # after this test: ignore it from here on.
+ env.httpd_error_log.add_ignored_matches([
+ r'.*\(70023\)[^:]*: (signing|jwk signed message)$',
+ r'.*\(70023\)[^:]*: md\[test-md-702-041-[^]]*\]$',
+ ])
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
env.httpd_error_log.ignore_recent(matches=[
r'.*None of offered challenge types for domain.*',
@@ -847,4 +909,3 @@
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
assert env.await_completion(domains)
env.check_md_complete(domains[0])
-
diff -Nru apache2-2.4.69/test/modules/md/test_710_profiles.py apache2-2.4.71/test/modules/md/test_710_profiles.py
--- apache2-2.4.69/test/modules/md/test_710_profiles.py 2025-11-04 14:51:48.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_710_profiles.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,6 +4,8 @@
from datetime import timedelta
import pytest
+
+from pyhttpd.depends import needs_dependency
from pyhttpd.certs import CertificateSpec
from pyhttpd.env import HttpdTestEnv
@@ -12,8 +14,9 @@
from .md_conf import MDConf
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestProfiles:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_720_wildcard.py apache2-2.4.71/test/modules/md/test_720_wildcard.py
--- apache2-2.4.69/test/modules/md/test_720_wildcard.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_720_wildcard.py 2026-11-09 03:07:05.000000000 +0000
@@ -3,12 +3,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf, MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestWildcard:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_721_dns01.py apache2-2.4.71/test/modules/md/test_721_dns01.py
--- apache2-2.4.69/test/modules/md/test_721_dns01.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_721_dns01.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,99 @@
+# test the arguments mod_md invokes MDChallengeDns01 with
+import os
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.depends import needs_dependency
+
+from .md_conf import MDConf
+from .md_env import MDTestEnv
+
+
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
+class TestDns01:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env, acme):
+ env.APACHE_CONF_SRC = "data/test_auto"
+ acme.start(config='default')
+ env.check_acme()
+ env.clear_store()
+ MDConf(env).install()
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+
+ @pytest.fixture(autouse=True, scope='function')
+ def _method_scope(self, env, request):
+ env.clear_store()
+ self.test_domain = env.get_request_domain(request)
+ self.dnscmd = os.path.join(env.test_dir, "../modules/md/dns01_record.py")
+ self.dnslog = os.path.join(env.gen_dir, "dns01.log")
+ if os.path.isfile(self.dnslog):
+ os.remove(self.dnslog)
+
+ def configure_httpd(self, env, domain, version=None):
+ conf = MDConf(env)
+ conf.add("MDCAChallenges dns-01")
+ conf.add(f"MDChallengeDns01 {self.dnscmd} {self.dnslog}")
+ if version is not None:
+ conf.add(f"MDChallengeDns01Version {version}")
+ conf.add_md([domain])
+ conf.add_vhost([domain])
+ conf.install()
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+
+ def calls(self, action):
+ """The recorded argv of each invocation of the challenge command."""
+ if not os.path.isfile(self.dnslog):
+ return []
+ with open(self.dnslog) as fd:
+ argvs = [eval(line.strip()) for line in fd if line.startswith('[')]
+ return [argv for argv in argvs if len(argv) > 2 and argv[2] == action]
+
+ # the domain and the challenge token are separate arguments, whatever they
+ # contain, and version 1 tears down with the domain alone
+ def test_md_721_001(self, env):
+ domain = self.test_domain
+ self.configure_httpd(env, domain)
+ assert env.await_completion([domain])
+ setups = self.calls('setup')
+ assert len(setups) == 1, f'{setups}'
+ assert setups[0][0:4] == [self.dnscmd, self.dnslog, 'setup', domain]
+ assert len(setups[0]) == 5, f'no separate challenge argument: {setups[0]}'
+ teardowns = self.calls('teardown')
+ assert len(teardowns) == 1, f'{teardowns}'
+ assert teardowns[0] == [self.dnscmd, self.dnslog, 'teardown', domain]
+
+ # version 2 tears down with the challenge token as a further argument
+ def test_md_721_002(self, env):
+ domain = self.test_domain
+ self.configure_httpd(env, domain, version=2)
+ assert env.await_completion([domain])
+ setups = self.calls('setup')
+ assert len(setups) == 1, f'{setups}'
+ teardowns = self.calls('teardown')
+ assert len(teardowns) == 1, f'{teardowns}'
+ assert teardowns[0] == [self.dnscmd, self.dnslog, 'teardown', domain,
+ setups[0][4]]
+
+ # an argument configured with quotes stays one argument
+ @not_backported("r1938494", "MDChallengeDns01 arguments passed verbatim")
+ def test_md_721_003(self, env):
+ domain = self.test_domain
+ dnslog = os.path.join(env.gen_dir, "dns01 with spaces.log")
+ if os.path.isfile(dnslog):
+ os.remove(dnslog)
+ self.dnslog = dnslog
+ conf = MDConf(env)
+ conf.add("MDCAChallenges dns-01")
+ conf.add(f'MDChallengeDns01 "{self.dnscmd}" "{dnslog}"')
+ conf.add_md([domain])
+ conf.add_vhost([domain])
+ conf.install()
+ assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ assert env.await_completion([domain])
+ setups = self.calls('setup')
+ assert len(setups) == 1, f'{setups}'
+ assert setups[0][1] == dnslog
diff -Nru apache2-2.4.69/test/modules/md/test_730_static.py apache2-2.4.71/test/modules/md/test_730_static.py
--- apache2-2.4.69/test/modules/md/test_730_static.py 2025-11-04 14:51:48.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_730_static.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,14 +2,17 @@
from datetime import timedelta
import pytest
+
+from pyhttpd.depends import needs_dependency
from pyhttpd.certs import CertificateSpec
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestStatic:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_740_acme_errors.py apache2-2.4.71/test/modules/md/test_740_acme_errors.py
--- apache2-2.4.69/test/modules/md/test_740_acme_errors.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_740_acme_errors.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,12 +1,15 @@
# test ACME error responses and their processing
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestAcmeErrors:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_741_setup_errors.py apache2-2.4.71/test/modules/md/test_741_setup_errors.py
--- apache2-2.4.69/test/modules/md/test_741_setup_errors.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_741_setup_errors.py 2026-11-09 03:07:05.000000000 +0000
@@ -3,12 +3,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestSetupErrors:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_790_failover.py apache2-2.4.71/test/modules/md/test_790_failover.py
--- apache2-2.4.69/test/modules/md/test_790_failover.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_790_failover.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,11 +1,14 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_env import MDTestEnv
from .md_conf import MDConf
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestFailover:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_800_must_staple.py apache2-2.4.71/test/modules/md/test_800_must_staple.py
--- apache2-2.4.69/test/modules/md/test_800_must_staple.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_800_must_staple.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,13 +1,17 @@
# test mod_md must-staple support
+import time
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_cert_util import MDCertUtil
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestMustStaple:
domain = None
@@ -21,7 +25,8 @@
@pytest.fixture(autouse=True, scope='function')
def _method_scope(self, env, request):
- self.domain = env.get_class_domain(self.__class__)
+ env.clear_store()
+ self.domain = env.get_request_domain(request)
def configure_httpd(self, env, domain, add_lines=""):
conf = MDConf(env, admin="admin@" + domain)
@@ -43,6 +48,7 @@
def test_md_800_002(self, env):
self.configure_httpd(env, self.domain, "MDMustStaple off")
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
+ assert env.await_completion([self.domain])
env.check_md_complete(self.domain)
cert1 = MDCertUtil(env.store_domain_file(self.domain, 'pubcert.pem'))
assert not cert1.get_must_staple()
@@ -50,7 +56,9 @@
assert 'ocsp' not in stat or stat['ocsp'] == "no response sent"
# MD that must staple and toggle off again
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_800_003(self, env):
self.configure_httpd(env, self.domain, "MDMustStaple on")
assert env.apache_restart() == 0, f'{env.apachectl_stderr}'
@@ -66,7 +74,9 @@
assert not cert1.get_must_staple()
# MD that must staple
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
@pytest.mark.skipif(MDTestEnv.get_ssl_module() != "mod_ssl", reason="only for mod_ssl")
def test_md_800_004(self, env):
# mod_ssl stapling is off, expect no stapling
diff -Nru apache2-2.4.69/test/modules/md/test_801_stapling.py apache2-2.4.71/test/modules/md/test_801_stapling.py
--- apache2-2.4.69/test/modules/md/test_801_stapling.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_801_stapling.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,14 +4,17 @@
import time
from datetime import timedelta
import pytest
+
+from pyhttpd.depends import needs_dependency
from pyhttpd.certs import CertificateSpec
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestStapling:
@pytest.fixture(autouse=True, scope='class')
@@ -113,7 +116,9 @@
# MD with stapling on/off and mod_ssl stapling on
# expect to see stapling response in all cases
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_002(self, env):
md = self.mdA
self.configure_httpd(env, md, ssl_stapling=True).install()
@@ -183,7 +188,9 @@
assert not stat["stapling"]
# 2 MDs, md stapling on+off, ssl stapling on
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_004(self, env):
md_a = self.mdA
md_b = self.mdB
@@ -217,7 +224,9 @@
# MD, check that restart leaves response unchanged, reconfigure keep interval,
# should remove the file on restart and get a new one
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_005(self, env):
# TODO: mod_watchdog seems to have problems sometimes with fast restarts
# turn stapling on, wait for it to appear in connections
@@ -263,7 +272,9 @@
# MD, check that stapling renew window works. Set a large window
# that causes response to be retrieved all the time.
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_006(self, env):
# turn stapling on, wait for it to appear in connections
md = self.mdA
@@ -302,7 +313,9 @@
assert mtime1 != mtime3
# MD, make a MDomain with static files, check that stapling works
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_007(self, env):
# turn stapling on, wait for it to appear in connections
md = self.mdA
@@ -331,7 +344,9 @@
assert ocsp_file
# Use certificate files in direct config, check that stapling works
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_008(self, env):
# turn stapling on, wait for it to appear in connections
md = self.mdA
@@ -357,7 +372,9 @@
# Turn on stapling for a certificate without OCSP responder and issuer
# (certificates without issuer prevent mod_ssl asking around for stapling)
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_009(self, env):
md = self.mdA
domains = [md]
@@ -387,7 +404,9 @@
# Turn on stapling for an MDomain not used in any virtualhost
# There was a crash in server-status in this case
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_010(self, env):
env.clear_ocsp_store()
md = self.mdA
@@ -407,7 +426,9 @@
# scheduling.
# This checks the mistaken assert() reported in
#
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_801_011(self, env):
domains = [ f'test-801-011-{i}-{env.DOMAIN_SUFFIX}' for i in range(7)]
self.configure_httpd(env, domains, """
diff -Nru apache2-2.4.69/test/modules/md/test_810_ec.py apache2-2.4.71/test/modules/md/test_810_ec.py
--- apache2-2.4.69/test/modules/md/test_810_ec.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_810_ec.py 2026-11-09 03:07:05.000000000 +0000
@@ -3,12 +3,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestAutov2:
@pytest.fixture(autouse=True, scope='class')
@@ -54,6 +57,9 @@
{'spec': "secp256r1", 'ciphers': "ECDSA", 'keylen': 256},
{'spec': "", 'ciphers': "RSA", 'keylen': 0},
])
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH10373"]
+ )
# set EC key type override on MD and get certificate
def test_md_810_002(self, env):
@@ -70,6 +76,9 @@
{'spec': "secp384r1", 'ciphers': "ECDSA", 'keylen': 384},
{'spec': "", 'ciphers': "RSA", 'keylen': 0},
])
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH10373"]
+ )
# set two key spec, ec before rsa
def test_md_810_003a(self, env):
diff -Nru apache2-2.4.69/test/modules/md/test_820_locks.py apache2-2.4.71/test/modules/md/test_820_locks.py
--- apache2-2.4.69/test/modules/md/test_820_locks.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_820_locks.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,6 +1,8 @@
import os
import pytest
+
+from pyhttpd.depends import needs_dependency
from filelock import Timeout, FileLock
from .md_cert_util import MDCertUtil
@@ -8,8 +10,9 @@
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestLocks:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_900_notify.py apache2-2.4.71/test/modules/md/test_900_notify.py
--- apache2-2.4.69/test/modules/md/test_900_notify.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_900_notify.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,12 +5,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf, MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestNotify:
notify_cmd = None
notify_log = None
diff -Nru apache2-2.4.69/test/modules/md/test_901_message.py apache2-2.4.71/test/modules/md/test_901_message.py
--- apache2-2.4.69/test/modules/md/test_901_message.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_901_message.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,14 +5,17 @@
import time
from datetime import timedelta
import pytest
+
+from pyhttpd.depends import needs_dependency
from pyhttpd.certs import CertificateSpec
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestMessage:
@pytest.fixture(autouse=True, scope='class')
@@ -214,7 +217,9 @@
assert nlines[0].strip() == f"['{self.mcmd}', '{self.mlog}', 'expiring', '{domain}']"
# MD, check messages from stapling
- @pytest.mark.skipif(MDTestEnv.lacks_ocsp(), reason="no OCSP responder")
+ @needs_dependency("OCSP responder", not MDTestEnv.lacks_ocsp(),
+ reason="no OCSP responder",
+ detected=f"ACME={MDTestEnv.get_acme_server()} serves no OCSP")
def test_md_901_020(self, env):
domain = self.test_domain
domains = [domain]
diff -Nru apache2-2.4.69/test/modules/md/test_910_cleanups.py apache2-2.4.71/test/modules/md/test_910_cleanups.py
--- apache2-2.4.69/test/modules/md/test_910_cleanups.py 2025-02-05 12:41:51.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_910_cleanups.py 2026-11-09 03:07:05.000000000 +0000
@@ -4,12 +4,15 @@
import pytest
+from pyhttpd.depends import needs_dependency
+
from .md_conf import MDConf
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestCleanups:
@pytest.fixture(autouse=True, scope='class')
diff -Nru apache2-2.4.69/test/modules/md/test_920_status.py apache2-2.4.71/test/modules/md/test_920_status.py
--- apache2-2.4.69/test/modules/md/test_920_status.py 2025-11-04 14:51:48.000000000 +0000
+++ apache2-2.4.71/test/modules/md/test_920_status.py 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,8 @@
from datetime import timedelta
import pytest
+
+from pyhttpd.depends import needs_dependency
from pyhttpd.certs import CertificateSpec
from .md_conf import MDConf
@@ -13,8 +15,9 @@
from .md_env import MDTestEnv
-@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(),
- reason="no ACME test server configured")
+@needs_dependency("ACME test server", MDTestEnv.has_acme_server(),
+ reason="no ACME test server configured",
+ detected=f"ACME={MDTestEnv.get_acme_server()}")
class TestStatus:
@pytest.fixture(autouse=True, scope='class')
@@ -115,6 +118,7 @@
MDBaseServer on
MDPortMap http:- https:{env.https_port}
MDStapling on
+MDServerStatus on
ServerName {domain}
diff -Nru apache2-2.4.69/test/modules/metadata/conftest.py apache2-2.4.71/test/modules/metadata/conftest.py
--- apache2-2.4.69/test/modules/metadata/conftest.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,35 @@
+import logging
+import os
+
+import pytest
+import sys
+
+from .env import MetadataTestEnv
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+
+def pytest_report_header(config, start_path):
+ env = MetadataTestEnv()
+ return f"metadata [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> MetadataTestEnv:
+ level = logging.INFO
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+ logging.getLogger('').addHandler(console)
+ logging.getLogger('').setLevel(level=level)
+ env = MetadataTestEnv(pytestconfig=pytestconfig)
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+ return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
diff -Nru apache2-2.4.69/test/modules/metadata/env.py apache2-2.4.71/test/modules/metadata/env.py
--- apache2-2.4.69/test/modules/metadata/env.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,40 @@
+import inspect
+import logging
+import os
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+
+log = logging.getLogger(__name__)
+
+
+class MetadataTestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(MetadataTestSetup)))
+ self.add_modules(["mime", "mime_magic", "env", "include"])
+ # mod_mime_libmagic needs libmagic at build time, so it must not
+ # be a hard requirement; its tests skip when it is absent.
+ self.add_optional_modules(["mime_libmagic", "remoteip"])
+
+
+class MetadataTestEnv(HttpdTestEnv):
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ # Only raise the log level for mod_mime_libmagic when it is built:
+ # a LogLevel for an unloaded module is a fatal config error, which
+ # would break the whole suite (including the mod_mime_magic tests)
+ # on platforms without libmagic, such as Windows.
+ log_modules = ["mime_magic", "env", "include", "core"]
+ if self.has_libmagic_module:
+ log_modules.insert(1, "mime_libmagic")
+ self.add_httpd_log_modules(log_modules)
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=MetadataTestSetup(env=self))
+
+ @property
+ def has_libmagic_module(self) -> bool:
+ """Whether mod_mime_libmagic was built."""
+ return self.has_shared_module("mime_libmagic")
diff -Nru apache2-2.4.69/test/modules/metadata/samples.py apache2-2.4.71/test/modules/metadata/samples.py
--- apache2-2.4.69/test/modules/metadata/samples.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/samples.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,49 @@
+# Sample file contents for content type detection tests. Each is
+# served without an extension, so mod_mime sets no type and the magic
+# modules have to derive one from the content. mod_mime_magic only
+# applies its magic rules to files of at least 64 bytes, so samples
+# meant to match a rule are padded past that.
+import gzip
+import os
+
+SAMPLES = {
+ "html": b"\nhello\n\n",
+ "html-doctype": b"\nt"
+ b"hi\n",
+ # (no commas: libmagic 5.45 calls lines with a consistent number
+ # of commas text/csv)
+ "text": b"hello world this is plain ascii text\n"
+ b"with a second line of a different length\n" * 2,
+ "text-utf8": "café naïve résumé\n".encode() * 4,
+ "text-latin1": b"caf\xe9 na\xefve\n" * 4,
+ "csrc": b"#include \n\nint main(int argc, char **argv)\n{\n"
+ b" printf(\"hi\\n\");\n return 0;\n}\n",
+ "json": b'{"a": 1, "b": [1, 2, 3], "c": {"d": "e", "f": "some more text"}}\n',
+ "xml": b"\n1234\n",
+ "rfc822": b"From: a@example.com\nTo: b@example.com\nSubject: hi there\n\n"
+ b"the body of the message\n",
+ "shell": b"#!/bin/sh\necho hi\necho there\necho this is a shell script\n"
+ b"exit 0\n",
+ "png": b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
+ b"\x00\x00\x00\x10\x00\x00\x00\x10\x08\x06\x00\x00\x00\x1f\xf3\xffa"
+ + b"\x00" * 64,
+ "gif": b"GIF89a\x10\x00\x10\x00\x80\x00\x00" + b"\x00" * 64,
+ "pdf": b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<< /Type /Catalog >>\nendobj\n",
+ "gzip": gzip.compress(bytes(range(32, 127)) * 4, mtime=0),
+ "elf": b"\x7fELF\x02\x01\x01\x00" + b"\x00" * 8
+ + b"\x02\x00\x3e\x00\x01\x00\x00\x00" + b"\x00" * 100,
+ # deterministic bytes which libmagic reports as application/octet-stream
+ "binary": bytes((i * 7919) % 256 for i in range(4096)),
+ "empty": b"",
+}
+
+
+def write_samples(doc_dir):
+ """Write every sample into doc_dir, plus png.txt (PNG content
+ with a .txt extension)."""
+ os.makedirs(doc_dir, exist_ok=True)
+ for name, content in SAMPLES.items():
+ with open(os.path.join(doc_dir, name), "wb") as f:
+ f.write(content)
+ with open(os.path.join(doc_dir, "png.txt"), "wb") as f:
+ f.write(SAMPLES["png"])
diff -Nru apache2-2.4.69/test/modules/metadata/test_001_mime_magic.py apache2-2.4.71/test/modules/metadata/test_001_mime_magic.py
--- apache2-2.4.69/test/modules/metadata/test_001_mime_magic.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/test_001_mime_magic.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,136 @@
+import os
+import re
+import sys
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.conf import HttpdConf
+
+# on the tests that fail without it (the others pass there as well)
+NOT_BACKPORTED = not_backported("r1938070, r1938258-r1938260", "mod_mime_magic fixes")
+
+
+class TestMimeMagic:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ # A small magic file: one entry giving both a type and an
+ # encoding, and one with a file(1)-style descriptive note in
+ # place of the encoding. The token-based text detection is left
+ # to handle everything else.
+ magic_file = os.path.join(env.gen_dir, "magic")
+ with open(magic_file, "w") as f:
+ f.write("0\tstring\tTESTMAGIC\tapplication/x-test-magic\tx-test-encoding\n")
+ f.write("0\tstring\tTESTNOTE\tapplication/x-test-note (some note)\n")
+ # 32-bit values with the high bit set, signed and unsigned
+ f.write("0\tbelong\t0xcafebabe\tapplication/x-test-belong\n")
+ f.write("0\tulelong\t0xcafebabe\tapplication/x-test-ulelong\n")
+ # an indirect rule whose computed offset is (long at 0) - 1;
+ # for a file beginning with a zero long that is -1, which a
+ # bounds check testing only the upper bound would let through
+ f.write("0\tlelong\t0\tx\n")
+ f.write(">(0.l-1)\tbyte\tx\tapplication/x-test-indir\n")
+
+ # Files without an extension, so mod_mime sets no type and
+ # mod_mime_magic has to derive one from the content.
+ doc_dir = os.path.join(env.server_dir, "htdocs", "test1", "magic")
+ os.makedirs(doc_dir, exist_ok=True)
+ # (a rule is only tested on files of at least 64 bytes)
+ with open(os.path.join(doc_dir, "softmagic"), "wb") as f:
+ f.write(b"TESTMAGIC" + b" and some more content" * 4 + b"\n")
+ with open(os.path.join(doc_dir, "softnote"), "wb") as f:
+ f.write(b"TESTNOTE" + b" and some more content" * 4 + b"\n")
+ with open(os.path.join(doc_dir, "html-plain"), "wb") as f:
+ f.write(b"\nhello\n")
+ with open(os.path.join(doc_dir, "belong"), "wb") as f:
+ f.write(b"\xca\xfe\xba\xbe" + b"\0" * 64)
+ with open(os.path.join(doc_dir, "ulelong"), "wb") as f:
+ f.write(b"\xbe\xba\xfe\xca" + b"\0" * 64)
+ # zero long at offset 0, then padding past the 64-byte minimum
+ with open(os.path.join(doc_dir, "indir"), "wb") as f:
+ f.write(b"\0" * 100)
+ # HTML token followed by an ESC byte.
+ with open(os.path.join(doc_dir, "html-escape"), "wb") as f:
+ f.write(b"\n\x1b[1mhello\x1b[0m\n")
+
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+ MimeMagicFile "{magic_file}"
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # type and encoding from a magic file entry
+ @NOT_BACKPORTED
+ def test_metadata_001_01_softmagic(self, env):
+ url = env.mkurl("http", "test1", "/magic/softmagic")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ assert r.response["header"]["content-type"] == "application/x-test-magic"
+ assert r.response["header"]["content-encoding"] == "x-test-encoding"
+
+ # type from the HTML token, no encoding
+ def test_metadata_001_02_html(self, env):
+ url = env.mkurl("http", "test1", "/magic/html-plain")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ assert r.response["header"]["content-type"] == "text/html"
+ assert "content-encoding" not in r.response["header"]
+
+ # type from the HTML token, escape sequences in the content
+ @NOT_BACKPORTED
+ def test_metadata_001_03_html_escapes(self, env):
+ url = env.mkurl("http", "test1", "/magic/html-escape")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ assert r.response["header"]["content-type"] == "text/html"
+ assert "content-encoding" not in r.response["header"]
+
+ # type from a magic file entry followed by a descriptive note: the
+ # type must end at the whitespace, and the note is not an encoding
+ @NOT_BACKPORTED
+ def test_metadata_001_04_softmagic_note(self, env):
+ url = env.mkurl("http", "test1", "/magic/softnote")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ assert r.response["header"]["content-type"] == "application/x-test-note"
+ assert "content-encoding" not in r.response["header"]
+ assert env.httpd_error_log.scan_recent(
+ re.compile(r'.*AH10623: .*ignoring invalid content encoding.*'))
+ env.httpd_error_log.ignore_recent(lognos=["AH10623"])
+
+ # a long value with the high bit set matches the rule's value,
+ # whether compared signed or unsigned
+ # The 0xcafebabe rule value only fits a 64-bit C long: on an LLP64
+ # platform (Windows) long is 32 bits, where the parser's strtol()
+ # cannot represent it and the sign-extension corner this guards
+ # against does not arise.
+ @pytest.mark.skipif(sys.platform == "win32",
+ reason="magic value needs a 64-bit long")
+ @pytest.mark.parametrize("name", ["belong", "ulelong"])
+ @NOT_BACKPORTED
+ def test_metadata_001_05_long_high_bit(self, env, name):
+ url = env.mkurl("http", "test1", f"/magic/{name}")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ assert r.response["header"]["content-type"] == f"application/x-test-{name}"
+
+ # an indirect rule computing a negative offset must not read outside
+ # the sniff buffer. The file begins with a zero long, so ">(0.l-1)"
+ # resolves to offset -1; without the fix that slips a bounds check
+ # testing only the upper bound and reads before the buffer, which a
+ # sanitizer build (the ASan and UBSan CI jobs) aborts on, leaving the
+ # request with no response.
+ def test_metadata_001_06_indirect_negative_offset(self, env):
+ url = env.mkurl("http", "test1", "/magic/indir")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
diff -Nru apache2-2.4.69/test/modules/metadata/test_002_mime_libmagic.py apache2-2.4.71/test/modules/metadata/test_002_mime_libmagic.py
--- apache2-2.4.69/test/modules/metadata/test_002_mime_libmagic.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/test_002_mime_libmagic.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,91 @@
+import os
+import pytest
+
+from pyhttpd.conf import HttpdConf
+from .samples import write_samples
+
+
+def get_type(env, path):
+ """The Content-Type header of GET path on test1, or None."""
+ r = env.curl_get(env.mkurl("http", "test1", path))
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ assert "content-encoding" not in r.response["header"]
+ return r.response["header"].get("content-type")
+
+
+class TestMimeLibmagic:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ env.require(env.has_libmagic_module, "mod_mime_libmagic is not built",
+ dep="mod_libmagic",
+ detected=f"not found in {env.libexec_dir}")
+ write_samples(os.path.join(env.server_dir, "htdocs", "test1", "libmagic"))
+ conf = HttpdConf(env, extras={
+ 'base': "MimeLibmagic On",
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ @pytest.mark.parametrize(["name", "ctype"], [
+ ("html", "text/html"),
+ ("html-doctype", "text/html"),
+ ("text", "text/plain"),
+ ("text-utf8", "text/plain"),
+ ("text-latin1", "text/plain"),
+ ("csrc", "text/x-c"),
+ ("json", "application/json"),
+ ("xml", "text/xml"),
+ ("rfc822", "message/rfc822"),
+ ("shell", "text/x-shellscript"),
+ ("png", "image/png"),
+ ("gif", "image/gif"),
+ ("pdf", "application/pdf"),
+ # no decompression support, the compressed file itself is typed
+ ("gzip", "application/gzip"),
+ # libmagic appends ", no program header" to the type
+ ("elf", "application/x-executable"),
+ # application/octet-stream from libmagic leaves the type unset
+ ("binary", None),
+ ("empty", "text/plain"),
+ ])
+ def test_metadata_002_01_types(self, env, name, ctype):
+ assert get_type(env, f"/libmagic/{name}") == ctype
+
+ # mod_mime's extension mapping wins over the content
+ def test_metadata_002_02_extension(self, env):
+ assert get_type(env, "/libmagic/png.txt") == "text/plain"
+
+
+class TestMimeLibmagicCharset:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ env.require(env.has_libmagic_module, "mod_mime_libmagic is not built",
+ dep="mod_libmagic",
+ detected=f"not found in {env.libexec_dir}")
+ write_samples(os.path.join(env.server_dir, "htdocs", "test1", "libmagic"))
+ conf = HttpdConf(env, extras={
+ 'base': """
+ MimeLibmagic On
+ MimeLibmagicCharset On
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ @pytest.mark.parametrize(["name", "ctype"], [
+ ("html", "text/html; charset=us-ascii"),
+ ("text", "text/plain; charset=us-ascii"),
+ ("text-utf8", "text/plain; charset=utf-8"),
+ ("text-latin1", "text/plain; charset=iso-8859-1"),
+ # only text types get a charset
+ ("json", "application/json"),
+ ("png", "image/png"),
+ ("binary", None),
+ ])
+ def test_metadata_002_10_charset(self, env, name, ctype):
+ assert get_type(env, f"/libmagic/{name}") == ctype
diff -Nru apache2-2.4.69/test/modules/metadata/test_002_setenvfromfile.py apache2-2.4.71/test/modules/metadata/test_002_setenvfromfile.py
--- apache2-2.4.69/test/modules/metadata/test_002_setenvfromfile.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/test_002_setenvfromfile.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,206 @@
+import os
+import re
+import pytest
+
+from pyhttpd.backport import skip_not_backported
+from pyhttpd.conf import HttpdConf
+
+pytestmark = skip_not_backported("r1938506", "SetEnvFromFile")
+
+
+def _write_ssi_echo(path, names):
+ """An SSI page echoing each variable as NAME=[value]. An unset
+ variable echoes the default "(none)", so a set-but-empty variable
+ ("[]") is distinguishable from one that never made it into the table.
+ """
+ with open(path, "w") as f:
+ for name in names:
+ f.write(f'{name}=[]\n')
+
+
+class TestSetEnvFromFile:
+ """Parsing behaviour of a well-formed file."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ # A plain name=value pair, an explicitly empty value, a line with
+ # no '=' (also empty), a backslash line continuation, and a line
+ # padded with surrounding whitespace that must be stripped.
+ env_file = os.path.join(env.gen_dir, "setenv.env")
+ with open(env_file, "w") as f:
+ f.write("# metadata SetEnvFromFile happy-path fixture\n")
+ f.write("\n")
+ f.write("ENV_SIMPLE=simple value\n")
+ f.write("ENV_EMPTY=\n")
+ f.write("ENV_NOEQ\n")
+ f.write("ENV_CONT=first \\\n")
+ f.write("second\n")
+ f.write(" ENV_WS=trimmed value \n")
+
+ doc_dir = os.path.join(env.server_dir, "htdocs", "test1")
+ os.makedirs(doc_dir, exist_ok=True)
+ _write_ssi_echo(os.path.join(doc_dir, "fromfile.shtml"),
+ ["ENV_SIMPLE", "ENV_EMPTY", "ENV_NOEQ", "ENV_CONT",
+ "ENV_WS", "ENV_UNDEFINED"])
+
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+ SetEnvFromFile "{env_file}"
+
+ Options +Includes
+ AddType text/html .shtml
+ AddOutputFilter INCLUDES .shtml
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_metadata_002_01_parsing(self, env):
+ url = env.mkurl("http", "test1", "/fromfile.shtml")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ body = r.response["body"].decode("utf-8")
+ # a plain name=value pair
+ assert "ENV_SIMPLE=[simple value]" in body
+ # an explicitly empty value is set, not absent
+ assert "ENV_EMPTY=[]" in body
+ # a line with no '=' yields an empty value
+ assert "ENV_NOEQ=[]" in body
+ # a backslash continues the value on the next line (no space added
+ # by the join; the space here is the one before the backslash)
+ assert "ENV_CONT=[first second]" in body
+ # leading/trailing whitespace on the line is stripped, from both
+ # the name (else the echo would be "(none)") and the value (else a
+ # trailing space would remain)
+ assert "ENV_WS=[trimmed value]" in body
+ # a variable never named in the file is left unset
+ assert "ENV_UNDEFINED=[(none)]" in body
+
+
+class TestSetEnvFromFileMalformed:
+ """A line with no variable name is skipped with a warning, and later
+ lines are still parsed."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ env_file = os.path.join(env.gen_dir, "setenv-malformed.env")
+ with open(env_file, "w") as f:
+ f.write("# a line beginning with '=' has an empty name\n")
+ f.write("=orphan value\n")
+ f.write("ENV_OK=present\n")
+
+ doc_dir = os.path.join(env.server_dir, "htdocs", "test1")
+ os.makedirs(doc_dir, exist_ok=True)
+ _write_ssi_echo(os.path.join(doc_dir, "malformed.shtml"), ["ENV_OK"])
+
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+ SetEnvFromFile "{env_file}"
+
+ Options +Includes
+ AddType text/html .shtml
+ AddOutputFilter INCLUDES .shtml
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_metadata_002_02_malformed_line(self, env):
+ # The malformed line is skipped with an AH10632 warning. Where that
+ # warning surfaces is MPM-dependent: when the config is parsed before
+ # the error log is open (e.g. the event MPM on Unix) it goes to
+ # apachectl's stderr; with mpm_winnt the config is parsed again by the
+ # child with the error log already open, so it lands in the error log
+ # instead (and possibly more than once). apachectl_stderr is also
+ # None on the winnt startup path, so guard it. Accept the warning in
+ # either place.
+ stderr = env.apachectl_stderr or ""
+ warn_re = re.compile(
+ r'.*AH10632: SetEnvFromFile: Skipping malformed line.*')
+ found = "AH10632" in stderr
+ if not found:
+ try:
+ found = env.httpd_error_log.scan_recent(warn_re)
+ except TimeoutError:
+ found = False
+ assert found, "AH10632 warning not found in stderr or error log"
+ # The warning is expected here; keep it from failing the teardown log
+ # check (it may appear in the error log several times under winnt).
+ env.httpd_error_log.ignore_recent(lognos=["AH10632"])
+ # ... and the well-formed line after it is still applied
+ url = env.mkurl("http", "test1", "/malformed.shtml")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200
+ assert "ENV_OK=[present]" in r.response["body"].decode("utf-8")
+
+
+class TestSetEnvFromFileHtaccess:
+ """SetEnvFromFile is not permitted in .htaccess (RSRC_CONF|ACCESS_CONF),
+ even where AllowOverride FileInfo would allow SetEnv itself."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ ht_dir = os.path.join(env.server_dir, "htdocs", "test1", "htaccess")
+ os.makedirs(ht_dir, exist_ok=True)
+ with open(os.path.join(ht_dir, "index.html"), "w") as f:
+ f.write("hello\n")
+ # SetEnv (FileInfo) would be accepted here; SetEnvFromFile must not.
+ with open(os.path.join(ht_dir, ".htaccess"), "w") as f:
+ f.write('SetEnvFromFile "conf/whatever.env"\n')
+
+ conf = HttpdConf(env, extras={
+ 'base': f"""
+
+ AllowOverride FileInfo
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ # the server starts fine; .htaccess is parsed per request
+ assert env.apache_restart() == 0
+
+ def test_metadata_002_03_htaccess_rejected(self, env):
+ url = env.mkurl("http", "test1", "/htaccess/index.html")
+ r = env.curl_get(url)
+ assert r.response, "no response: server may have crashed"
+ # the illegal directive makes .htaccess processing fail -> 500
+ assert r.response["status"] == 500
+ # logged (at alert level, so check_error_log does not flag it, but
+ # guard anyway) with the config parser's context rejection
+ assert env.httpd_error_log.scan_recent(
+ re.compile(r'.*SetEnvFromFile not allowed here.*'))
+ env.httpd_error_log.ignore_recent(matches=[r'.*SetEnvFromFile not allowed here.*'])
+
+
+class TestSetEnvFromFileMissing:
+ """Pointing at a file that cannot be opened is a fatal config error."""
+
+ def test_metadata_002_04_missing_file(self, env):
+ missing = os.path.join(env.gen_dir, "does-not-exist.env")
+ conf = HttpdConf(env, extras={
+ 'base': f'SetEnvFromFile "{missing}"',
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ # httpd must refuse to start ...
+ assert env.apache_fail() == 0
+ # ... reporting why. The message is emitted at config-parse time;
+ # apachectl_stderr only captures it on the non-winnt start path (it is
+ # None under mpm_winnt), so only assert on it where it was captured.
+ if env.apachectl_stderr:
+ assert "Could not open file" in env.apachectl_stderr
+
+ # restore a working, running server so the log check and package
+ # teardown are clean
+ env.httpd_error_log.clear_log()
+ good = HttpdConf(env)
+ good.add_vhost_test1()
+ good.install()
+ assert env.apache_restart() == 0
diff -Nru apache2-2.4.69/test/modules/metadata/test_003_compare.py apache2-2.4.71/test/modules/metadata/test_003_compare.py
--- apache2-2.4.69/test/modules/metadata/test_003_compare.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/test_003_compare.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,86 @@
+import logging
+import os
+import pytest
+
+from pyhttpd.conf import HttpdConf
+from .samples import SAMPLES, write_samples
+
+log = logging.getLogger(__name__)
+
+# (sample, mod_mime_magic type, mod_mime_magic encoding, mod_mime_libmagic type)
+# mod_mime_magic uses the stock conf/magic and its token-based text
+# detection, which knows nothing of most of these, so many are untyped.
+EXPECTED = [
+ ("html", "text/html", None, "text/html"),
+ ("html-doctype", None, None, "text/html"),
+ ("text", None, None, "text/plain"),
+ ("text-utf8", None, None, "text/plain"),
+ ("text-latin1", None, None, "text/plain"),
+ ("csrc", "text/plain", None, "text/x-c"),
+ ("json", None, None, "application/json"),
+ ("xml", "text/xml", None, "text/xml"),
+ ("rfc822", "message/rfc822", "7bit", "message/rfc822"),
+ ("shell", None, None, "text/x-shellscript"),
+ ("png", "image/png", None, "image/png"),
+ ("gif", "image/gif", None, "image/gif"),
+ ("pdf", None, None, "application/pdf"),
+ ("gzip", "application/octet-stream", "x-gzip", "application/gzip"),
+ ("elf", None, None, "application/x-executable"),
+ ("binary", None, None, None),
+ ("empty", "text/plain", None, "text/plain"),
+ ("png.txt", "text/plain", None, "text/plain"),
+]
+
+
+def get_headers(env, host, path):
+ r = env.curl_get(env.mkurl("http", host, path))
+ assert r.response, "no response: server may have crashed"
+ assert r.response["status"] == 200, r.response
+ h = r.response["header"]
+ return h.get("content-type"), h.get("content-encoding")
+
+
+class TestCompare:
+ """The same files served by mod_mime_magic (vhost test1) and by
+ mod_mime_libmagic (vhost test2)."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ env.require(env.has_libmagic_module, "mod_mime_libmagic is not built",
+ dep="mod_libmagic",
+ detected=f"not found in {env.libexec_dir}")
+ for vhost in ["test1", "test2"]:
+ write_samples(os.path.join(env.server_dir, "htdocs", vhost, "cmp"))
+ # No MimeMagicFile in the main server, so test2 inherits no
+ # magic rules and only mod_mime_libmagic runs there.
+ conf = HttpdConf(env, extras={
+ f"test1.{env.http_tld}": f'MimeMagicFile "{env.prefix}/conf/magic"',
+ f"test2.{env.http_tld}": "MimeLibmagic On",
+ })
+ conf.add_vhost_test1()
+ conf.add_vhost_test2()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ @pytest.mark.parametrize(["name", "mm_type", "mm_enc", "lm_type"], EXPECTED)
+ def test_metadata_003_01_mime_magic(self, env, name, mm_type, mm_enc, lm_type):
+ assert get_headers(env, "test1", f"/cmp/{name}") == (mm_type, mm_enc)
+
+ @pytest.mark.parametrize(["name", "mm_type", "mm_enc", "lm_type"], EXPECTED)
+ def test_metadata_003_02_libmagic(self, env, name, mm_type, mm_enc, lm_type):
+ assert get_headers(env, "test2", f"/cmp/{name}") == (lm_type, None)
+
+ # Log a side-by-side table of what the two modules actually report.
+ def test_metadata_003_03_report(self, env):
+ rows = [("sample", "mod_mime_magic", "mod_mime_libmagic")]
+ for name in list(SAMPLES) + ["png.txt"]:
+ mm = get_headers(env, "test1", f"/cmp/{name}")
+ lm = get_headers(env, "test2", f"/cmp/{name}")
+ fmt = lambda t: f"{t[0]}" + (f" ({t[1]})" if t[1] else "")
+ rows.append((name, fmt(mm), fmt(lm)))
+ widths = [max(len(r[i]) for r in rows) for i in range(3)]
+ table = "\n".join(" ".join(c.ljust(widths[i]) for i, c in enumerate(r))
+ for r in rows)
+ log.info("content types reported:\n%s", table)
+ with open(os.path.join(env.gen_dir, "mime-compare.txt"), "w") as f:
+ f.write(table + "\n")
diff -Nru apache2-2.4.69/test/modules/metadata/test_004_remoteip.py apache2-2.4.71/test/modules/metadata/test_004_remoteip.py
--- apache2-2.4.69/test/modules/metadata/test_004_remoteip.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/metadata/test_004_remoteip.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,334 @@
+import os
+import re
+import socket
+import time
+
+import pytest
+
+from pyhttpd.backport import not_backported, skip_not_backported
+from pyhttpd.depends import needs_dependency
+
+from pyhttpd.conf import HttpdConf
+from .env import MetadataTestEnv
+
+# on the cases that fail without it (the others pass there as well)
+PER_VHOST = not_backported("r1938908", "mod_remoteip per-vhost lists",
+ backported_in="2.4.71")
+
+
+@needs_dependency("mod_remoteip", MetadataTestEnv.has_shared_module("remoteip"),
+ reason="mod_remoteip not available")
+class TestRemoteIp:
+ LOG_FILE = "test_remoteip.log"
+ PEER = "127.0.0.1"
+ CLIENT = "203.0.113.7"
+ CDN = "198.51.100.9"
+
+ # The test requests come from the internal proxy PEER and carry
+ # "X-Forwarded-For: CLIENT, CDN". The proxies are given directly or in
+ # a list file, in the server config or in the vhost. Each case expects
+ # the client and the (external) proxies seen by mod_remoteip.
+ @pytest.mark.parametrize(["scope", "directives", "listed", "proxies"], [
+ ["vhost", [f"RemoteIPInternalProxy {PEER}", f"RemoteIPTrustedProxy {CDN}"],
+ [], CDN],
+ pytest.param("vhost", [f"RemoteIPInternalProxy {PEER}",
+ "RemoteIPTrustedProxyList {list}"],
+ [CDN], CDN, marks=PER_VHOST),
+ pytest.param("vhost", ["RemoteIPTrustedProxyList {list}",
+ f"RemoteIPInternalProxy {PEER}"],
+ [CDN], CDN, marks=PER_VHOST),
+ pytest.param("vhost", ["RemoteIPTrustedProxy 10.142.1.99",
+ "RemoteIPInternalProxyList {list}"],
+ [PEER, CDN], "-", marks=PER_VHOST),
+ ["server", [f"RemoteIPInternalProxy {PEER}", "RemoteIPTrustedProxyList {list}"],
+ [CDN], CDN],
+ ])
+ def test_metadata_004_01(self, env, scope, directives, listed, proxies):
+ list_file = os.path.join(env.gen_dir, "remoteip_proxies.lst")
+ with open(list_file, "w") as f:
+ f.write("".join(f"{ip}\n" for ip in listed))
+ lines = [d.format(list=list_file) for d in directives]
+ conf = HttpdConf(env, extras={
+ "base": [
+ "RemoteIPHeader X-Forwarded-For",
+ f'CustomLog logs/{self.LOG_FILE} "%a %{{remoteip-proxy-ip-list}}n"',
+ ] + (lines if scope == "server" else [])
+ })
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], doc_root="htdocs/test1")
+ if scope == "vhost":
+ conf.add(lines)
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ log_path = os.path.join(env.server_logs_dir, self.LOG_FILE)
+ open(log_path, 'w').close()
+ r = env.curl_get(env.mkurl("https", "test1", "/"), options=[
+ '-H', f"X-Forwarded-For: {self.CLIENT}, {self.CDN}"])
+ assert r.response["status"] == 200
+ with open(log_path) as f:
+ lines = [l.strip() for l in f if l.strip()
+ and not l.startswith(f"{self.PEER} -")]
+ assert len(lines) == 1, f"expected 1 test line, got {lines}"
+ assert lines[0] == f"{self.CLIENT} {proxies}"
+
+ WARNING = r".*both the main server and the virtual host"
+
+ # The proxy entries of the main server and of a vhost are merged, the
+ # ones of the vhost taking precedence where subnets overlap, and a
+ # warning tells when both have some.
+ @pytest.mark.parametrize(["main", "vhost", "proxies", "warning"], [
+ # only one of the scopes
+ [[f"RemoteIPInternalProxy {PEER}", f"RemoteIPTrustedProxy {CDN}"],
+ [], CDN, False],
+ [[], [f"RemoteIPInternalProxy {PEER}", f"RemoteIPTrustedProxy {CDN}"],
+ CDN, False],
+ # (both scopes: merged, and warned about, only with the change)
+ # RemoteIPInternalProxy in the main server, a list in the vhost
+ pytest.param([f"RemoteIPInternalProxy {PEER}"], ["RemoteIPInternalProxyList {cdn}"],
+ "-", True, marks=PER_VHOST),
+ pytest.param([f"RemoteIPInternalProxy {PEER}"], ["RemoteIPTrustedProxyList {cdn}"],
+ CDN, True, marks=PER_VHOST),
+ # a list in the main server, a direct entry in the vhost
+ pytest.param(["RemoteIPInternalProxyList {peer}"], [f"RemoteIPTrustedProxy {CDN}"],
+ CDN, True, marks=PER_VHOST),
+ # direct entries in both
+ pytest.param([f"RemoteIPInternalProxy {PEER}"], [f"RemoteIPTrustedProxy {CDN}"],
+ CDN, True, marks=PER_VHOST),
+ # the CDN is trusted by the main server and internal for the vhost
+ pytest.param([f"RemoteIPInternalProxy {PEER}", f"RemoteIPTrustedProxy {CDN}"],
+ [f"RemoteIPInternalProxy {CDN}"], "-", True, marks=PER_VHOST),
+ pytest.param([f"RemoteIPInternalProxy {PEER}", f"RemoteIPTrustedProxy {CDN}"],
+ ["RemoteIPInternalProxyList {cdn}"], "-", True, marks=PER_VHOST),
+ ])
+ def test_metadata_004_02(self, env, main, vhost, proxies, warning):
+ files = {"cdn": self.CDN, "peer": self.PEER}
+ for name, ip in files.items():
+ with open(os.path.join(env.gen_dir, f"remoteip_{name}.lst"), "w") as f:
+ f.write(f"{ip}\n")
+ paths = {n: os.path.join(env.gen_dir, f"remoteip_{n}.lst") for n in files}
+ conf = HttpdConf(env, extras={
+ "base": [
+ "RemoteIPHeader X-Forwarded-For",
+ f'CustomLog logs/{self.LOG_FILE} "%a %{{remoteip-proxy-ip-list}}n"',
+ ] + [d.format(**paths) for d in main]
+ })
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], doc_root="htdocs/test1")
+ conf.add([d.format(**paths) for d in vhost])
+ conf.end_vhost()
+ conf.install()
+ pos = env.httpd_error_log.current_pos()
+ assert env.apache_restart() == 0
+ warned = env.httpd_error_log.wait_for(re.compile(self.WARNING), pos, timeout=1)
+ assert warned == warning
+ if warned:
+ env.httpd_error_log.ignore_recent(matches=[self.WARNING])
+
+ log_path = os.path.join(env.server_logs_dir, self.LOG_FILE)
+ open(log_path, 'w').close()
+ r = env.curl_get(env.mkurl("https", "test1", "/"), options=[
+ '-H', f"X-Forwarded-For: {self.CLIENT}, {self.CDN}"])
+ assert r.response["status"] == 200
+ with open(log_path) as f:
+ lines = [l.strip() for l in f if l.strip()
+ and not l.startswith(f"{self.PEER} -")]
+ assert len(lines) == 1, f"expected 1 test line, got {lines}"
+ assert lines[0] == f"{self.CLIENT} {proxies}"
+
+ # -- PROXY protocol --------------------------------------------------
+
+ # A v2 header: the signature, then version/command, family/transport,
+ # the length of what follows, and the addresses. The commands are LOCAL
+ # (0) and PROXY (1); a health checker such as an AWS NLB sends LOCAL,
+ # for which the receiver must keep the connection's own address and
+ # ignore any address information present (bug 63893).
+ PP2_SIG = b"\x0d\x0a\x0d\x0a\x00\x0d\x0a\x51\x55\x49\x54\x0a"
+ PP2_LOCAL = 0x20
+ PP2_PROXY = 0x21
+ PP2_UNSPEC = 0x00
+ PP2_TCP4 = 0x11
+ # CLIENT:4321 -> CDN:80, as a PROXY protocol TCPv4 address block
+ PP2_ADDRS = (socket.inet_aton(CLIENT) + socket.inet_aton(CDN)
+ + (4321).to_bytes(2, "big") + (80).to_bytes(2, "big"))
+
+ @classmethod
+ def pp2_header(cls, cmd, fam=0x00, addrs=b""):
+ return cls.PP2_SIG + bytes([cmd, fam]) + len(addrs).to_bytes(2, "big") + addrs
+
+ def pp_install(self, env):
+ """PROXY protocol on its own port, so the harness's own requests
+ on the usual ports are unaffected; log the client address seen."""
+ conf = HttpdConf(env, extras={
+ "base": [
+ f"Listen {env.http_port2}",
+ ]
+ })
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port2,
+ doc_root="htdocs/test1")
+ conf.add(["RemoteIPProxyProtocol On",
+ f'CustomLog logs/{self.LOG_FILE} "%a"'])
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def pp_request(self, env, header, request=True):
+ """Send a PROXY protocol header, then a request unless told not to;
+ return (status, client address logged) - None for each if nothing
+ came back."""
+ log_path = os.path.join(env.server_logs_dir, self.LOG_FILE)
+ open(log_path, 'w').close()
+ sock = socket.create_connection(("127.0.0.1", env.http_port2), timeout=5)
+ sock.sendall(header)
+ if request:
+ sock.sendall(f"GET / HTTP/1.1\r\nHost: test1.{env.http_tld}\r\n"
+ f"Connection: close\r\n\r\n".encode())
+ data = b""
+ try:
+ while True:
+ chunk = sock.recv(65536)
+ if not chunk:
+ break
+ data += chunk
+ except (socket.timeout, ConnectionError):
+ pass
+ sock.close()
+ status = int(data.split(b" ", 2)[1]) if data.startswith(b"HTTP/") else None
+ logged = None
+ for _ in range(20):
+ with open(log_path) as f:
+ logged = f.read().strip() or None
+ if logged or status is None:
+ break
+ time.sleep(0.1)
+ return status, logged
+
+ # Control: a PROXY command is honoured, the client is the one it names.
+ def test_metadata_004_03(self, env):
+ self.pp_install(env)
+ hdr = self.pp2_header(self.PP2_PROXY, self.PP2_TCP4, self.PP2_ADDRS)
+ assert self.pp_request(env, hdr) == (200, self.CLIENT)
+
+ # A LOCAL command (ver_cmd 0x20) is valid: the request is served as
+ # coming from the connection's own address, and nothing is logged as
+ # an error. Bug 63893.
+ @skip_not_backported("r1938927", "PROXY protocol EOF is not an error",
+ backported_in="2.4.71")
+ def test_metadata_004_04(self, env):
+ self.pp_install(env)
+ hdr = self.pp2_header(self.PP2_LOCAL, self.PP2_UNSPEC)
+ assert self.pp_request(env, hdr) == (200, self.PEER)
+
+ # LOCAL with an address block present: it must be ignored.
+ @skip_not_backported("r1938927", "PROXY protocol EOF is not an error",
+ backported_in="2.4.71")
+ def test_metadata_004_05(self, env):
+ self.pp_install(env)
+ hdr = self.pp2_header(self.PP2_LOCAL, self.PP2_TCP4, self.PP2_ADDRS)
+ assert self.pp_request(env, hdr) == (200, self.PEER)
+
+ # A health check which connects, sends an incomplete header and goes
+ # away is not an error worth logging (bug 63893 comment 12).
+ @skip_not_backported("r1938927", "PROXY protocol EOF is not an error",
+ backported_in="2.4.71")
+ def test_metadata_004_06(self, env):
+ self.pp_install(env)
+ pos = env.httpd_error_log.current_pos()
+ assert self.pp_request(env, self.PP2_SIG, request=False) == (None, None)
+ logged = env.httpd_error_log.wait_for(re.compile(r".*AH10184"), pos,
+ timeout=1)
+ assert not logged, "peer going away mid-header was logged as an error"
+
+ # -- REMOTE_PORT -----------------------------------------------------
+
+ # The port the request came from must be the one mod_remoteip found,
+ # the same one %{remote}p logs and mod_rewrite's %{REMOTE_PORT} gives,
+ # wherever a configuration asks for it: the REMOTE_PORT variable of
+ # ap_expr (here via mod_headers) and the CGI/SSI REMOTE_PORT variable
+ # (here via mod_include). Bug 70295.
+ REMOTE_PORT_FIX = not_backported("gh-896", "REMOTE_PORT from the useragent address")
+
+ def remote_port_install(self, env):
+ """A PROXY protocol vhost on http_port2 and an X-Forwarded-For one
+ on http_port, both exposing REMOTE_PORT three ways: the response
+ header 'X-Remote-Port' (ap_expr), the body of remote_port.shtml
+ (CGI variables via mod_include) and the access log (%{remote}p)."""
+ docs = os.path.join(env.server_docs_dir, "test1")
+ os.makedirs(docs, exist_ok=True)
+ with open(os.path.join(docs, "remote_port.shtml"), "w") as f:
+ f.write('\n')
+ exposed = [
+ 'Header set X-Remote-Port "expr=%{REMOTE_PORT}"',
+ "Options +Includes",
+ "AddType text/html .shtml",
+ "AddOutputFilter INCLUDES .shtml",
+ f'CustomLog logs/{self.LOG_FILE} "%a %{{remote}}p"',
+ ]
+ conf = HttpdConf(env, extras={
+ "base": [
+ f"Listen {env.http_port2}",
+ ]
+ })
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port2,
+ doc_root="htdocs/test1")
+ conf.add(["RemoteIPProxyProtocol On"] + exposed)
+ conf.end_vhost()
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port,
+ doc_root="htdocs/test1")
+ conf.add(["RemoteIPHeader X-Forwarded-For",
+ f"RemoteIPInternalProxy {self.PEER}"] + exposed)
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def remote_port_logged(self, env):
+ log_path = os.path.join(env.server_logs_dir, self.LOG_FILE)
+ for _ in range(20):
+ with open(log_path) as f:
+ lines = [l.strip() for l in f if l.strip()]
+ if lines:
+ break
+ time.sleep(0.1)
+ assert len(lines) == 1, f"expected 1 log line, got {lines}"
+ return lines[0]
+
+ def remote_port_clear_log(self, env):
+ open(os.path.join(env.server_logs_dir, self.LOG_FILE), 'w').close()
+
+ # PROXY protocol: the client's port is known (4321 in PP2_ADDRS) and
+ # every place must report it, not the proxy's TCP source port.
+ @REMOTE_PORT_FIX
+ def test_metadata_004_07(self, env):
+ self.remote_port_install(env)
+ self.remote_port_clear_log(env)
+ hdr = self.pp2_header(self.PP2_PROXY, self.PP2_TCP4, self.PP2_ADDRS)
+ sock = socket.create_connection(("127.0.0.1", env.http_port2), timeout=5)
+ sock.sendall(hdr + f"GET /remote_port.shtml HTTP/1.0\r\n"
+ f"Host: test1.{env.http_tld}\r\n\r\n".encode())
+ data = b""
+ while True:
+ chunk = sock.recv(65536)
+ if not chunk:
+ break
+ data += chunk
+ sock.close()
+ head, _, body = data.partition(b"\r\n\r\n")
+ lines = head.decode().split("\r\n")
+ assert lines[0].split(" ")[1] == "200", lines[0]
+ headers = {k.strip().lower(): v.strip() for k, v in
+ (l.split(":", 1) for l in lines[1:] if ":" in l)}
+ assert self.remote_port_logged(env) == f"{self.CLIENT} 4321"
+ assert headers.get("x-remote-port") == "4321"
+ assert body.decode().strip() == "4321"
+
+ # RemoteIPHeader: the client's port is not known, mod_remoteip reports
+ # 0 for it rather than the proxy's, and so must every place.
+ @REMOTE_PORT_FIX
+ def test_metadata_004_08(self, env):
+ self.remote_port_install(env)
+ self.remote_port_clear_log(env)
+ r = env.curl_get(env.mkurl("http", "test1", "/remote_port.shtml"), options=[
+ '-H', f"X-Forwarded-For: {self.CLIENT}"])
+ assert r.response["status"] == 200
+ assert self.remote_port_logged(env) == f"{self.CLIENT} 0"
+ assert r.response["header"]["x-remote-port"] == "0"
+ assert r.response["body"].decode().strip() == "0"
diff -Nru apache2-2.4.69/test/modules/proxy/env.py apache2-2.4.71/test/modules/proxy/env.py
--- apache2-2.4.69/test/modules/proxy/env.py 2026-09-28 17:44:38.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,6 +2,7 @@
import logging
import os
import socket
+import sys
from threading import Thread
from pyhttpd.certs import CertificateSpec
@@ -20,6 +21,7 @@
self._host = host
self._port = port
self._done = False
+ self._request = None
def start(self):
def process():
@@ -44,14 +46,28 @@
Content-Length: 5
Hello""".encode()
-
+
def _process(self):
+ _read_buffer = 8192 if sys.platform == "win32" else 4096
while not self._done:
try:
c, client_address = self._socket.accept()
try:
- data = c.recv(4096)
+ data = c.recv(_read_buffer)
+ # capture request to backend
+ self._request = data
c.sendall(self._make_response(data))
+ # Half-close for an orderly FIN instead of letting the
+ # OS tear the socket down under us. An abrupt close()
+ # right after sendall() can, depending on the platform's
+ # TCP stack and buffering, surface to the peer as a
+ # reset rather than a clean end of the response, which
+ # httpd's proxy and cache code treat as an aborted
+ # connection and a reason to discard the cache entry.
+ try:
+ c.shutdown(socket.SHUT_WR)
+ except OSError:
+ pass
finally:
c.close()
except socket.timeout:
@@ -66,7 +82,8 @@
super().__init__(env=env)
self.add_source_dir(os.path.dirname(inspect.getfile(ProxyTestSetup)))
self.add_modules(["proxy", "proxy_http", "proxy_ajp", "proxy_balancer",
- "lbmethod_byrequests", "remoteip"])
+ "proxy_connect", "proxy_uwsgi", "lbmethod_byrequests",
+ "remoteip"])
class ProxyTestEnv(HttpdTestEnv):
diff -Nru apache2-2.4.69/test/modules/proxy/test_01_http.py apache2-2.4.71/test/modules/proxy/test_01_http.py
--- apache2-2.4.69/test/modules/proxy/test_01_http.py 2023-05-02 10:05:52.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_01_http.py 2026-11-09 03:07:05.000000000 +0000
@@ -59,8 +59,10 @@
# check that we see the document we expect there (host matching worked)
# we need to explicitly provide a Host: header since mod_proxy cannot
# resolve the name via DNS.
- if not env.curl_is_at_least('8.0.0'):
- pytest.skip(f'need at least curl v8.0.0 for this')
+ env.require(env.curl_is_at_least('8.0.0'),
+ 'need at least curl v8.0.0 for this',
+ dep='curl >= 8.0.0',
+ detected=f"curl {env.curl_version_str()}")
domain = f"{via}.{env.http_tld}"
r = env.curl_get(f"http://127.0.0.1:{env.http_port}/alive.json", 5, options=[
'-H', f"Host: {domain}",
diff -Nru apache2-2.4.69/test/modules/proxy/test_02_unix.py apache2-2.4.71/test/modules/proxy/test_02_unix.py
--- apache2-2.4.69/test/modules/proxy/test_02_unix.py 2024-07-11 08:18:12.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_02_unix.py 2026-11-09 03:07:05.000000000 +0000
@@ -110,8 +110,10 @@
# check that we see the document we expect there (host matching worked)
# we need to explicitly provide a Host: header since mod_proxy cannot
# resolve the name via DNS.
- if not env.curl_is_at_least('8.0.0'):
- pytest.skip(f'need at least curl v8.0.0 for this')
+ env.require(env.curl_is_at_least('8.0.0'),
+ 'need at least curl v8.0.0 for this',
+ dep='curl >= 8.0.0',
+ detected=f"curl {env.curl_version_str()}")
domain = f"{via}.{env.http_tld}"
r = env.curl_get(f"http://127.0.0.1:{env.http_port}/alive.json", 5, options=[
'-H', f"Host: {domain}",
diff -Nru apache2-2.4.69/test/modules/proxy/test_03_response.py apache2-2.4.71/test/modules/proxy/test_03_response.py
--- apache2-2.4.69/test/modules/proxy/test_03_response.py 2026-09-28 17:44:38.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_03_response.py 2026-11-09 03:07:05.000000000 +0000
@@ -16,6 +16,16 @@
return """HTTP/1.1 200 OK\r\nContent-Type:
text/html\x00extra\r\n\r\n""".encode()
+ if "/empty-header" in path:
+ body = b"Hello"
+ return (
+ b"HTTP/1.1 200 OK\r\n"
+ b"X-Empty:\r\n"
+ b"Content-Length: 5\r\n"
+ b"\r\n"
+ + body
+ )
+
if "/forwarded" in path:
headers = data.split(b"\r\n\r\n")[0].decode("latin-1")
forwarded = []
@@ -70,6 +80,7 @@
conf.add([
"RemoteIPHeader X-Forwarded-For",
"RemoteIPTrustedProxy 0.0.0.0",
+ "RemoteIPTrustedProxy 127.0.0.1",
])
conf.end_vhost()
conf.install()
@@ -122,3 +133,22 @@
env.httpd_error_log.ignore_recent(
lognos=["AH00957", "AH00959", "AH01114"]
)
+
+
+ # empty backend response header values are valid
+ def test_proxy_03_004(self, env):
+ r = env.curl_get(env.mkurl("http", "test1", "/empty-header"))
+ assert r.response["status"] == 200
+ assert "x-empty" in r.response["header"]
+ assert r.response["body"] == b"Hello"
+
+ # a trailing empty RemoteIPHeader token must not underflow the trim pointer
+ def test_proxy_03_005(self, env):
+ env.require(env.has_shared_module("remoteip"), "need mod_remoteip for this",
+ dep="mod_remoteip",
+ detected=f"no mod_remoteip.so in {env.libexec_dir}")
+
+ r = env.curl_get(env.mkurl("http", "test1", "/forwarded"), options=[
+ '-H', 'X-Forwarded-For: 192.0.2.1,',
+ ])
+ assert r.response["status"] == 200
diff -Nru apache2-2.4.69/test/modules/proxy/test_03_uwsgi.py apache2-2.4.71/test/modules/proxy/test_03_uwsgi.py
--- apache2-2.4.69/test/modules/proxy/test_03_uwsgi.py 2026-09-29 11:28:59.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_03_uwsgi.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,91 +1,3 @@
-
-"""
-Tests for mod_proxy_uwsgi fix.
-
-Per PEP 3333, WSGI applications must not generate hop-by-hop
-headers. Remove any such headers received from the backend.
-"""
-
-import re
-
-import pytest
-
-from pyhttpd.conf import HttpdConf
-from .uwsgi_faker import UwsgiFaker
-
-
-UWSGI_PORT = 5200
-
-
-@pytest.fixture(autouse=True, scope="class")
-def _proxy_uwsgi_setup(env):
- faker = UwsgiFaker(port=UWSGI_PORT)
- faker.start()
-
- conf = HttpdConf(env)
- conf.start_vhost(
- domains=[env.d_reverse],
- port=env.http_port,
- with_ssl=False,
- )
- conf.add([
- "LogLevel proxy_uwsgi:debug",
- f"ProxyPass / uwsgi://127.0.0.1:{UWSGI_PORT}/",
- ])
- conf.end_vhost()
- conf.install()
-
- assert env.apache_restart() == 0
-
- yield
-
- faker.stop()
-
-
-class TestProxyUwsgi:
-
- def test_proxy_03_001(self, env):
- """uWSGI backend response is forwarded correctly."""
- r = env.curl_get(
- f"http://{env.d_reverse}:{env.http_port}/",
- 5,
- )
- assert r.response["status"] == 200
- assert r.json["host"] == "uwsgi-faker"
-
- def test_proxy_03_002(self, env):
- """No Transfer-Encoding header reaches the client."""
- r = env.curl_get(
- f"http://{env.d_reverse}:{env.http_port}/",
- 5,
- )
- assert r.response["status"] == 200
- assert "transfer-encoding" not in r.response["header"]
-
- def test_proxy_03_003(self, env):
- """Transfer-Encoding is stripped by mod_proxy_uwsgi."""
- r = env.curl_get(
- f"http://{env.d_reverse}:{env.http_port}/?te=1",
- 5,
- )
-
- print("response =", r.response)
- print("stderr =", getattr(r, "stderr", None))
- print("stdout =", getattr(r, "stdout", None))
- print("exitcode =", getattr(r, "exit_code", None))
- print("json =", getattr(r, "json", None))
-
- assert r.response is not None
- assert r.response["status"] == 200
- assert "transfer-encoding" not in r.response["header"]
-
- assert r.json["host"] == "uwsgi-faker"
-
- assert env.httpd_error_log.scan_recent(
- re.compile(
- r".*uwsgi: removing hop-by-hop header 'Transfer-Encoding'"
- )
- )
"""
Tests for mod_proxy_uwsgi fix.
@@ -118,6 +30,7 @@
conf.add([
"LogLevel proxy_uwsgi:debug",
f"ProxyPass / uwsgi://127.0.0.1:{UWSGI_PORT}/",
+ "LogLevel trace4",
])
conf.end_vhost()
conf.install()
@@ -153,7 +66,7 @@
"""Transfer-Encoding is stripped by mod_proxy_uwsgi."""
r = env.curl_get(
f"http://{env.d_reverse}:{env.http_port}/?te=1",
- 5,
+ 5,
)
print("response =", r.response)
diff -Nru apache2-2.4.69/test/modules/proxy/test_04_ajp.py apache2-2.4.71/test/modules/proxy/test_04_ajp.py
--- apache2-2.4.69/test/modules/proxy/test_04_ajp.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_04_ajp.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,127 @@
+import pytest
+import re
+
+from pyhttpd.conf import HttpdConf
+from .env import TCPFaker
+
+magic_numbers = b"\x41\x42"
+send_headers_type = b"\x04"
+send_chunk_type = b"\x03"
+status_code_200 = b"\x00\xc8"
+status_msg_ok = b"OK"
+ajp_max_size = 8192
+
+
+def ajp_packet_from_tomcat(body):
+ return magic_numbers + len(body).to_bytes(2, "big") + body
+
+
+class _AJPFaker(TCPFaker):
+
+ @staticmethod
+ def max_size_message(data):
+ body = send_headers_type + b"\x00" * (ajp_max_size - 1)
+ return ajp_packet_from_tomcat(body)
+
+ @staticmethod
+ def num_headers_len(data):
+ # message_type(send_headers) + status_code_200 + len(inner_body)
+ # + "OK"
+ body = (send_headers_type
+ + status_code_200
+ + len(status_msg_ok).to_bytes(2, "big")
+ + status_msg_ok
+ + b"\x00")
+ return ajp_packet_from_tomcat(body)
+
+ @staticmethod
+ def missing_null_terminator(data):
+ # message_type(send_headers) + status_code_200 + len(inner_body)
+ # + "OK" without null terminator
+ body = (send_headers_type
+ + status_code_200
+ + len(status_msg_ok).to_bytes(2, "big")
+ + status_msg_ok)
+ return ajp_packet_from_tomcat(body)
+
+ @staticmethod
+ def tiny_body_chunk(data):
+ # message_type(send_body_chunk) + chunk_size(0xffff),3 bytes total
+ chunk_body = (send_chunk_type + b"\xff\xff")
+ return ajp_packet_from_tomcat(chunk_body)
+
+
+class TestProxyAjp:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ env.require(env.has_shared_module("proxy_ajp"), "mod_proxy_ajp not available",
+ dep="mod_proxy_ajp",
+ detected=f"no mod_proxy_ajp.so in {env.libexec_dir}")
+ faker = _AJPFaker("127.0.0.1", env.http_port2)
+ faker.start()
+ conf = HttpdConf(env)
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port)
+ conf.add([
+ f"ProxyPass / ajp://127.0.0.1:{env.http_port2}/",
+ ])
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+ yield faker
+ faker.stop()
+
+ def test_proxy_004_01(self, env, _class_scope):
+ _class_scope._make_response = _AJPFaker.max_size_message
+ r = env.curl_get(env.mkurl("http", "test1", "/"))
+ assert r.response["status"] == 503
+ try:
+ found = env.httpd_error_log.scan_recent(
+ re.compile(r'.*AH01081:.*'), timeout=5)
+ except TimeoutError:
+ found = False
+
+ assert found
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH01081", "AH01080", "AH01031", "AH00878", "AH00992"])
+
+ def test_proxy_004_02(self, env, _class_scope):
+ _class_scope._make_response = _AJPFaker.tiny_body_chunk
+ env.curl_get(env.mkurl("http", "test1", "/"))
+ try:
+ found = env.httpd_error_log.scan_recent(
+ re.compile(r'.*ajp_parse_data: Message too small.*'),
+ timeout=5)
+ except TimeoutError:
+ found = False
+
+ assert found
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH00893"],
+ matches=[r'.*ajp_parse_data: Message too small.*'])
+
+ def test_proxy_004_03(self, env, _class_scope):
+ _class_scope._make_response = _AJPFaker.num_headers_len
+ r = env.curl_get(env.mkurl("http", "test1", "/"))
+ assert r.exit_code == 0
+
+ _class_scope._make_response = _AJPFaker.missing_null_terminator
+ r = env.curl_get(env.mkurl("http", "test1", "/"))
+ assert r.exit_code == 0
+
+ try:
+ found = env.httpd_error_log.scan_recent(
+ re.compile(r'.*AH03229: ajp_msg_get_uint16.*'), timeout=5)
+ except TimeoutError:
+ found = False
+ assert found
+
+ try:
+ found = env.httpd_error_log.scan_recent(
+ re.compile(r'.*AH03229: ajp_msg_get_string.*'), timeout=5)
+ except TimeoutError:
+ found = False
+ assert found
+
+ env.httpd_error_log.ignore_recent(
+ lognos=["AH03229", "AH10405", "AH00985", "AH00893"])
diff -Nru apache2-2.4.69/test/modules/proxy/test_05_uwsgi.py apache2-2.4.71/test/modules/proxy/test_05_uwsgi.py
--- apache2-2.4.69/test/modules/proxy/test_05_uwsgi.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_05_uwsgi.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,73 @@
+import pytest
+
+from pyhttpd.conf import HttpdConf
+from .env import TCPFaker
+
+
+class _UWSGIFaker(TCPFaker):
+
+ @staticmethod
+ def hello(data):
+ body = b"Hello"
+ return (
+ b"HTTP/1.1 200 OK\r\n"
+ b"Content-Type: text/plain\r\n"
+ b"Content-Length: 5\r\n"
+ b"\r\n"
+ + body
+ )
+
+ @staticmethod
+ def empty_header(data):
+ body = b"Hello"
+ return (
+ b"HTTP/1.1 200 OK\r\n"
+ b"X-Empty:\r\n"
+ b"Content-Length: 5\r\n"
+ b"\r\n"
+ + body
+ )
+
+
+class TestProxyUwsgi:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ env.require(env.has_shared_module("proxy_uwsgi"), "mod_proxy_uwsgi not available",
+ dep="mod_proxy_uwsgi",
+ detected=f"no mod_proxy_uwsgi.so in {env.libexec_dir}")
+ faker = _UWSGIFaker("127.0.0.1", env.http_port2)
+ faker.start()
+ conf = HttpdConf(env)
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port)
+ conf.add([
+ f"ProxyPass / uwsgi://127.0.0.1:{env.http_port2}/",
+ ])
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+ yield faker
+ faker.stop()
+
+ # verify uwsgi request header
+ def test_proxy_005_01(self, env, _class_scope):
+ _class_scope._make_response = _UWSGIFaker.hello
+ r = env.curl_get(env.mkurl("http", "test1", "/"))
+ assert r.response["status"] == 200
+ assert r.response["body"] == b"Hello"
+
+ data = _class_scope._request
+
+ assert data[0] == 0x00 # standard WSGI request
+ datasize = data[1] + (data[2] * 256) # read from 16bit little-endian
+ assert data[3] == 0x00 # standard WSGI request
+ assert len(data) == 4 + datasize
+
+ # empty backend response header values are valid
+ def test_proxy_005_02(self, env, _class_scope):
+ _class_scope._make_response = _UWSGIFaker.empty_header
+ r = env.curl_get(env.mkurl("http", "test1", "/"))
+ assert r.response["status"] == 200
+ assert "x-empty" in r.response["header"]
+ assert r.response["body"] == b"Hello"
+
diff -Nru apache2-2.4.69/test/modules/proxy/test_07_cache_control.py apache2-2.4.71/test/modules/proxy/test_07_cache_control.py
--- apache2-2.4.69/test/modules/proxy/test_07_cache_control.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_07_cache_control.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,173 @@
+import os
+import time
+import uuid
+
+import pytest
+
+from pyhttpd.conf import HttpdConf
+from .env import TCPFaker
+
+
+# Backend answering // with the Cache-Control header lines of
+# CASES[index], counting the requests it gets for every path.
+class _CacheFaker(TCPFaker):
+
+ def __init__(self, host, port, cases):
+ super().__init__(host, port)
+ self.cases = cases
+ self.hits = {}
+
+ def _make_response(self, data):
+ head = data.split(b"\r\n\r\n")[0].decode("latin-1").split("\r\n")
+ path = head[0].split(" ")[1]
+ self.hits[path] = self.hits.get(path, 0) + 1
+ cookie = ""
+ for line in head[1:]:
+ if line.lower().startswith("cookie:"):
+ cookie = line.split(":", 1)[1].strip()
+ cc, extra = self.cases[int(path.rsplit("/", 1)[1])]
+ body = f"hit={self.hits[path]} cookie={cookie}\n"
+ lines = ["HTTP/1.1 200 OK",
+ "Last-Modified: Thu, 01 Jan 2026 00:00:00 GMT",
+ 'ETag: "t"',
+ "Content-Type: text/plain",
+ f"Content-Length: {len(body)}",
+ "Connection: close"]
+ lines += [f"Cache-Control: {v}" for v in cc]
+ lines += extra
+ return ("\r\n".join(lines) + "\r\n\r\n" + body).encode()
+
+
+# Cache-Control field lines and further response headers of the backend
+NO_STORE = [
+ [["no-store"], []],
+ [["private"], []],
+ [["private, no-store"], []],
+ [["private,no-store,max-age=0"], []],
+ [["private, no-store, max-age=0"], []],
+ [["private , no-store , max-age=0"], []],
+ [["max-age=60, private"], []],
+ [["max-age=60, no-store"], []],
+ [["no-store, max-age=60"], []],
+ [["no-store, foo=bar"], []],
+ [["foo=bar, no-store"], []],
+ [["foo=\"a, b\", no-store"], []],
+ [["private", "no-store", "max-age=0"], []],
+]
+STORED = [
+ [["public, max-age=60"], []],
+ [["max-age=60"], []],
+ [["max-age=60, must-revalidate"], []],
+ [["must-revalidate, max-age=60"], []],
+ [["public, s-maxage=60"], []],
+ [["max-age=60, stale-while-revalidate=30"], []],
+ [["max-age=60, stale-if-error=30"], []],
+ [["foo=\"a, b\", max-age=60"], []],
+]
+QUALIFIED = [
+ [["max-age=60, private=\"X-Secret\""], []],
+ [["max-age=60, private=\"X-Secret, Set-Cookie\""], []],
+ [["max-age=60, no-cache=\"X-Secret,Set-Cookie\""], []],
+]
+USER = [
+ [["private, no-store, max-age=0"], []],
+]
+CASES = NO_STORE + STORED + QUALIFIED + USER
+for _c in QUALIFIED:
+ _c[1] += ["Set-Cookie: a=b", "X-Secret: s", "X-Public: p"]
+
+
+class TestCacheControl:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ faker = _CacheFaker("127.0.0.1", env.http_port2, CASES)
+ faker.start()
+ cache_dir = os.path.join(env.gen_dir, "cache-control")
+ os.makedirs(cache_dir, exist_ok=True)
+
+ conf = HttpdConf(env)
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port,
+ doc_root="htdocs", with_ssl=False)
+ conf.add([
+ f'CacheRoot "{cache_dir}"',
+ 'CacheEnable disk "/cc/"',
+ 'CacheHeader on',
+ 'CacheStorePrivate Off',
+ 'CacheStoreNoStore Off',
+ f"ProxyPass /cc/ http://127.0.0.1:{env.http_port2}/",
+ ])
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+ TestCacheControl.faker = faker
+ TestCacheControl.run = uuid.uuid4().hex[:12]
+ yield
+ faker.stop()
+
+ def get(self, env, index, cookie=None):
+ url = env.mkurl("http", "test1", f"/cc/{self.run}/{index}")
+ options = ["-H", f"Cookie: {cookie}"] if cookie else []
+ r = env.curl_get(url, options=options)
+ assert r.response["status"] == 200, f"{r}"
+ return r.response
+
+ def backend_hits(self, index):
+ return self.faker.hits.get(f"/{self.run}/{index}", 0)
+
+ # a response that must not be stored is never served from the cache
+ @pytest.mark.parametrize("index", range(len(NO_STORE)))
+ def test_proxy_07_001(self, env, index):
+ cc = ", ".join(CASES[index][0])
+ r1 = self.get(env, index)
+ r2 = self.get(env, index)
+ assert self.backend_hits(index) == 2, f"Cache-Control: {cc} was cached"
+ assert not r1["header"].get("x-cache", "").startswith("HIT")
+ assert not r2["header"].get("x-cache", "").startswith("HIT")
+ assert r2["body"] == b"hit=2 cookie=\n"
+
+ # a response that may be stored is served from the cache
+ @pytest.mark.parametrize("index", range(len(NO_STORE),
+ len(NO_STORE) + len(STORED)))
+ def test_proxy_07_002(self, env, index):
+ cc = ", ".join(CASES[index][0])
+ r1 = self.get(env, index)
+ # The cache entry for r1 is committed by the CACHE_SAVE filter on
+ # the EOS-only pass that follows the body, which can still be
+ # running server side for an instant after the body itself has
+ # already reached this process. Give it room to land before relying
+ # on it, instead of risking a spurious backend hit on r2.
+ time.sleep(0.1)
+ r2 = self.get(env, index)
+ assert self.backend_hits(index) == 1, f"Cache-Control: {cc} not cached"
+ assert r2["header"]["x-cache"].startswith("HIT")
+ assert r2["body"] == r1["body"]
+
+ # fields listed in private="..." and no-cache="..." are not stored,
+ # whether one or several are listed, the rest of the response is
+ @pytest.mark.parametrize("index", range(len(NO_STORE) + len(STORED),
+ len(NO_STORE) + len(STORED) + len(QUALIFIED)))
+ def test_proxy_07_003(self, env, index):
+ cc = ", ".join(CASES[index][0])
+ r1 = self.get(env, index)
+ assert r1["header"]["x-secret"] == "s"
+ assert r1["header"]["set-cookie"] == "a=b"
+ # See test_proxy_07_002: give the CACHE_SAVE filter's commit time
+ # to land before asserting on it.
+ time.sleep(0.1)
+ r2 = self.get(env, index)
+ assert self.backend_hits(index) == 1, f"Cache-Control: {cc} not cached"
+ assert r2["header"]["x-cache"].startswith("HIT")
+ assert r2["header"]["x-public"] == "p"
+ assert "x-secret" not in r2["header"], f"X-Secret stored: {cc}"
+ if "Set-Cookie" in cc:
+ assert "set-cookie" not in r2["header"], f"Set-Cookie stored: {cc}"
+
+ # a user specific response is not given to another request
+ def test_proxy_07_004(self, env):
+ index = len(CASES) - 1
+ r1 = self.get(env, index, cookie="session=first")
+ assert r1["body"] == b"hit=1 cookie=session=first\n"
+ r2 = self.get(env, index)
+ assert b"session=first" not in r2["body"]
+ assert self.backend_hits(index) == 2
diff -Nru apache2-2.4.69/test/modules/proxy/test_08_cache_vary_star.py apache2-2.4.71/test/modules/proxy/test_08_cache_vary_star.py
--- apache2-2.4.69/test/modules/proxy/test_08_cache_vary_star.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/test_08_cache_vary_star.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,133 @@
+import os
+import time
+import uuid
+
+import pytest
+
+from pyhttpd.conf import HttpdConf
+from .env import TCPFaker
+
+
+# Backend answering // with a body that depends on the request's
+# Cookie, and a Cache-Control that makes the response otherwise cacheable.
+class _IdentityFaker(TCPFaker):
+
+ def __init__(self, host, port):
+ super().__init__(host, port)
+ self.hits = {}
+
+ def _make_response(self, data):
+ head = data.split(b"\r\n\r\n")[0].decode("latin-1").split("\r\n")
+ path = head[0].split(" ")[1]
+ self.hits[path] = self.hits.get(path, 0) + 1
+ cookie = "anonymous"
+ for line in head[1:]:
+ if line.lower().startswith("cookie:"):
+ cookie = line.split(":", 1)[1].strip()
+ body = f"body-for-{cookie}\n"
+ lines = ["HTTP/1.1 200 OK",
+ "Last-Modified: Thu, 01 Jan 2026 00:00:00 GMT",
+ 'ETag: "t"',
+ "Cache-Control: public, max-age=60",
+ "Content-Type: text/plain",
+ f"Content-Length: {len(body)}",
+ "Connection: close"]
+ return ("\r\n".join(lines) + "\r\n\r\n" + body).encode()
+
+
+class TestCacheVaryStar:
+ """BZ 70289: mod_cache's cacheability check for "Vary: *" only looked
+ at r->headers_out, so a Vary: * set via "Header always" (which writes
+ to r->err_headers_out) was missed by the check, while storage itself
+ does merge err_headers_out in. This let a response meant for one
+ client be stored and served back to a different client."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ faker = _IdentityFaker("127.0.0.1", env.http_port2)
+ faker.start()
+ cache_dir = os.path.join(env.gen_dir, "cache-vary-star")
+ os.makedirs(cache_dir, exist_ok=True)
+
+ conf = HttpdConf(env)
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port,
+ doc_root="htdocs", with_ssl=False)
+ conf.add([
+ f'CacheRoot "{cache_dir}"',
+ 'CacheEnable disk "/cv/"',
+ 'CacheEnable disk "/cv2/"',
+ 'CacheEnable disk "/cv3/"',
+ 'CacheHeader on',
+ '',
+ ' Header always set Vary "*"',
+ '',
+ '',
+ ' Header set Vary "*"',
+ '',
+ f"ProxyPass /cv/ http://127.0.0.1:{env.http_port2}/",
+ f"ProxyPass /cv2/ http://127.0.0.1:{env.http_port2}/",
+ f"ProxyPass /cv3/ http://127.0.0.1:{env.http_port2}/",
+ ])
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+ TestCacheVaryStar.faker = faker
+ TestCacheVaryStar.run = uuid.uuid4().hex[:12]
+ yield
+ faker.stop()
+
+ def get(self, env, path_prefix="/cv/", index=0, cookie=None):
+ url = env.mkurl("http", "test1", f"{path_prefix}{self.run}/{index}")
+ options = ["-H", f"Cookie: {cookie}"] if cookie else []
+ r = env.curl_get(url, options=options)
+ assert r.response["status"] == 200, f"{r}"
+ return r.response
+
+ def backend_hits(self, index=0):
+ return self.faker.hits.get(f"/{self.run}/{index}", 0)
+
+ # a victim's response must never be handed to another client under a
+ # Vary: * header that was set via "Header always"
+ def test_proxy_08_001(self, env):
+ r1 = self.get(env, cookie="session=victim")
+ assert r1["body"] == b"body-for-session=victim\n"
+ # give the CACHE_SAVE filter's commit a moment to land, the same
+ # way test_07_cache_control.py does for its own positive cases;
+ # here it only matters because we are proving a negative.
+ time.sleep(0.1)
+ r2 = self.get(env)
+ assert not r2["header"].get("x-cache", "").startswith("HIT"), \
+ "Vary: * response (via Header always) was served from cache"
+ assert r2["body"] == b"body-for-anonymous\n", \
+ "victim's response body leaked to an anonymous request"
+ assert self.backend_hits() == 2, "Vary: * response was stored"
+
+ # positive control: the same disk cache setup, minus "Header always
+ # set Vary *", does cache and serve a repeat request from disk. This
+ # rules out test_proxy_08_001 passing merely because disk storage is
+ # broken in this setup (e.g. a missing CacheRoot directory) rather
+ # than because mod_cache correctly rejected the Vary: * response.
+ def test_proxy_08_002(self, env):
+ r1 = self.get(env, path_prefix="/cv2/", index=1)
+ assert r1["body"] == b"body-for-anonymous\n"
+ time.sleep(0.1)
+ r2 = self.get(env, path_prefix="/cv2/", index=1)
+ assert r2["header"]["x-cache"].startswith("HIT"), \
+ "response without Vary: * was not served from the disk cache"
+ assert r2["body"] == r1["body"]
+ assert self.backend_hits(1) == 1, \
+ "response without Vary: * was not cached"
+
+ # a victim's response must never be handed to another client under a
+ # Vary: * header set via plain "Header set" (which writes to
+ # r->headers_out directly, the case the pre-fix code already checked)
+ def test_proxy_08_003(self, env):
+ r1 = self.get(env, path_prefix="/cv3/", index=2, cookie="session=victim")
+ assert r1["body"] == b"body-for-session=victim\n"
+ time.sleep(0.1)
+ r2 = self.get(env, path_prefix="/cv3/", index=2)
+ assert not r2["header"].get("x-cache", "").startswith("HIT"), \
+ "Vary: * response (via headers_out) was served from cache"
+ assert r2["body"] == b"body-for-anonymous\n", \
+ "victim's response body leaked to an anonymous request"
+ assert self.backend_hits(2) == 2, "Vary: * response was stored"
diff -Nru apache2-2.4.69/test/modules/proxy/uwsgi_faker.py apache2-2.4.71/test/modules/proxy/uwsgi_faker.py
--- apache2-2.4.69/test/modules/proxy/uwsgi_faker.py 2026-09-29 11:28:59.000000000 +0000
+++ apache2-2.4.71/test/modules/proxy/uwsgi_faker.py 2026-11-09 03:07:05.000000000 +0000
@@ -144,149 +144,3 @@
break
t = threading.Thread(target=_handle, args=(conn,), daemon=True)
t.start()
-"""
-Minimal fake uWSGI backend for testing mod_proxy_uwsgi.
-
-Listens on a TCP port and speaks the uWSGI wire protocol. mod_proxy_uwsgi
-sends a binary uwsgi packet (4-byte header + CGI key/value pairs) followed by
-the optional request body, and expects a plain HTTP/1.x response back.
-
-The QUERY_STRING CGI variable controls what the response looks like:
-
- QUERY_STRING="" - normal 200 response, no Transfer-Encoding
- QUERY_STRING="te=1" - 200 response with Transfer-Encoding: chunked injected
- (simulates a buggy Python app; mod_proxy_uwsgi must
- strip it before forwarding to the client)
-"""
-import socket
-import struct
-import threading
-
-
-BODY = b'{"host": "uwsgi-faker"}'
-
-
-def _parse_uwsgi_vars(data):
- """Parse the CGI key/value payload from a uwsgi packet.
-
- Packet layout (from the uWSGI protocol spec):
- [modifier1: u8][payload_len: u16le][modifier2: u8][payload: bytes]
- Payload is a sequence of:
- [keylen: u16le][key: bytes][vallen: u16le][val: bytes]
-
- Returns a dict of the decoded variables, or {} on parse error.
- """
- if len(data) < 4:
- return {}
- payload_len = struct.unpack_from(" end:
- break
- klen = struct.unpack_from(" end:
- break
- key = data[pos:pos + klen].decode("latin-1")
- pos += klen
- if pos + 2 > end:
- break
- vlen = struct.unpack_from(" end:
- break
- val = data[pos:pos + vlen].decode("latin-1")
- pos += vlen
- vars_[key] = val
- return vars_
-
-
-def _recv_uwsgi_request(conn):
- """Read a complete uwsgi request packet from the connection.
-
- Returns the raw bytes of the complete packet (header + payload), or b""
- on connection close.
- """
- # Read the 4-byte uwsgi header
- header = b""
- while len(header) < 4:
- chunk = conn.recv(4 - len(header))
- if not chunk:
- return b""
- header += chunk
-
- payload_len = struct.unpack_from(" SSLTestEnv:
+ level = logging.INFO
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+ logging.getLogger('').addHandler(console)
+ logging.getLogger('').setLevel(level=level)
+ env = SSLTestEnv(pytestconfig=pytestconfig)
+ # setup_httpd() raises outright when a required module is not built,
+ # which is neither a skip nor a useful failure. mod_ssl being absent is
+ # a missing dependency, so report it as one before we get there.
+ env.require(env.has_mod_ssl(), "no mod_ssl available", dep="mod_ssl",
+ detected=f"no mod_ssl.so in {env.libexec_dir}")
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+ return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def require_openssl(env):
+ env.require(env.has_tool("openssl"), "openssl not installed",
+ dep="openssl", detected="no openssl on $PATH")
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
diff -Nru apache2-2.4.69/test/modules/ssl/env.py apache2-2.4.71/test/modules/ssl/env.py
--- apache2-2.4.69/test/modules/ssl/env.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/ssl/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,26 @@
+import inspect
+import logging
+import os
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+
+log = logging.getLogger(__name__)
+
+
+class SSLTestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(SSLTestSetup)))
+ self.add_modules(["ssl"])
+ self.add_optional_modules(["socache_redis"])
+
+
+class SSLTestEnv(HttpdTestEnv):
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ self.add_httpd_log_modules(["http", "ssl", "core"])
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=SSLTestSetup(env=self))
\ No newline at end of file
diff -Nru apache2-2.4.69/test/modules/ssl/test_001_sni.py apache2-2.4.71/test/modules/ssl/test_001_sni.py
--- apache2-2.4.69/test/modules/ssl/test_001_sni.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/ssl/test_001_sni.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,38 @@
+import os
+import pytest
+
+from pyhttpd.conf import HttpdConf
+
+
+class TestSNI:
+ LOG_FILE = "test_sni.log"
+
+ @pytest.fixture(autouse=True, scope="class")
+ def _class_scope(self, env):
+ conf = HttpdConf(env, extras={
+ "base":
+ f'CustomLog logs/{self.LOG_FILE} "%{{SSL_TLS_SNI}}x"'
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # check sni logging characters
+ def test_ssl_001_01(self, env):
+ log_path = os.path.join(env.server_logs_dir, self.LOG_FILE)
+
+ open(log_path, 'w').close()
+ sni = "httpd\x01\n2024\".org"
+
+ r = env.run(args=[
+ 'openssl', 's_client',
+ '-connect', f"localhost:{env.https_port}",
+ '-servername', sni
+ ], intext="GET / HTTP/1.1\n\n")
+ assert r.exit_code == 0
+
+ with open(log_path, 'rb') as f:
+ log_content = f.read()
+
+ assert sni.encode() not in log_content, \
+ f"found unescaped characters in {self.LOG_FILE}.log"
diff -Nru apache2-2.4.69/test/modules/ssl/test_003_verify_client_perdir.py apache2-2.4.71/test/modules/ssl/test_003_verify_client_perdir.py
--- apache2-2.4.69/test/modules/ssl/test_003_verify_client_perdir.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/ssl/test_003_verify_client_perdir.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,144 @@
+import os
+import re
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.certs import CertificateSpec
+from pyhttpd.conf import HttpdConf
+
+# SSLVerifyClient in a asks for the certificate once the handshake
+# has finished, and the two protocol families do that quite differently:
+# ssl_hook_Access_classic() renegotiates for TLSv1.2 and below, while
+# ssl_hook_Access_modern() uses TLSv1.3 Post-Handshake Authentication
+# (RFC 8446), logging AH10129 as it starts and AH10158 when it cannot proceed.
+#
+# What the enclosing virtual host asks for matters as much as the protocol:
+# with "SSLVerifyClient optional" the certificate is requested during the
+# initial handshake, which changes what is left for the per-directory setting
+# to do.
+PHA_STARTED = "AH10129"
+VHOSTS = {"unset": "", "optional": "SSLVerifyClient optional"}
+
+
+class TestVerifyClientPerDir:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ doc_dir = os.path.join(env.server_dir, "htdocs", "test1", "secure")
+ os.makedirs(doc_dir, exist_ok=True)
+ with open(os.path.join(doc_dir, "index.html"), "w") as f:
+ f.write("secret\n")
+ env.httpd_error_log.add_ignored_lognos(
+ ["AH02261", "AH02262", "AH02263", "AH10158", "AH10373",
+ "AH02040"])
+ env.httpd_error_log.add_ignored_matches([
+ r'.*SSL Library Error.*',
+ r'.*certificate verify failed.*',
+ ])
+
+ def install(self, env, proto, vhost_verify, vhost_depth=5, loc_depth=None,
+ loc_verify="SSLVerifyClient require"):
+ loc_lines = loc_verify
+ if loc_depth is not None:
+ loc_lines += f"\n SSLVerifyDepth {loc_depth}"
+ conf = HttpdConf(env, extras={
+ f"test1.{env.http_tld}": f"""
+ LogLevel ssl:debug
+ SSLProtocol -all +{proto}
+ SSLCACertificateFile "{env.ca.cert_file}"
+ SSLVerifyDepth {vhost_depth}
+ {vhost_verify}
+
+ {loc_lines}
+
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def get(self, env, proto, version, vhost_verify="",
+ path="/secure/index.html", with_cert=True,
+ vhost_depth=5, loc_depth=None,
+ loc_verify="SSLVerifyClient require"):
+ self.install(env, proto, vhost_verify, vhost_depth, loc_depth,
+ loc_verify)
+ env.httpd_error_log.clear_log()
+ # Pin the version at both ends: "--tlsvX" alone is a floor, not a pin.
+ # Force HTTP/1.1 because per-directory verification is refused outright
+ # on an HTTP/2 secondary connection, which would look like a protocol
+ # failure while proving nothing about the TLS version.
+ options = ["-v", "--http1.1", f"--tlsv{version}", "--tls-max", version]
+ if with_cert:
+ creds = env.ca.issue_cert(
+ CertificateSpec(name="perdir-client", client=True))
+ options.extend(["--cert", creds.cert_file,
+ "--key", creds.pkey_file])
+ r = env.curl_get(env.mkurl("https", "test1", path), options=options)
+ if r.exit_code == 0:
+ got = re.search(r'SSL connection using (TLSv[0-9.]+)', r.stderr or "")
+ assert got and got.group(1) == proto, \
+ f"negotiated {got and got.group(1)}, wanted {proto}"
+ return r
+
+ def logged(self, env, logno):
+ with open(env.httpd_error_log.path) as fd:
+ return any(logno in line for line in fd)
+
+ # TLSv1.2 reaches the per-directory setting by renegotiating, whatever the
+ # virtual host asked for.
+ @pytest.mark.parametrize("vhost", list(VHOSTS), ids=list(VHOSTS))
+ def test_ssl_003_01(self, env, vhost):
+ r = self.get(env, "TLSv1.2", "1.2", VHOSTS[vhost])
+ assert r.exit_code == 0, f"handshake failed: {r.stderr}"
+ assert r.response["status"] == 200
+
+ # On TLSv1.3 it uses Post-Handshake Authentication, and a client which
+ # offers the extension is served just the same.
+ def test_ssl_003_02(self, env):
+ r = self.get(env, "TLSv1.3", "1.3", VHOSTS["unset"])
+ assert r.exit_code == 0, f"handshake failed: {r.stderr}"
+ assert r.response["status"] == 200
+ assert self.logged(env, PHA_STARTED), \
+ f"{PHA_STARTED} absent: the certificate was not requested " \
+ "post-handshake, so this is not exercising PHA"
+
+ # Without a certificate the location is refused, so the cases above are
+ # really being verified rather than served unconditionally.
+ @pytest.mark.parametrize("vhost", list(VHOSTS), ids=list(VHOSTS))
+ def test_ssl_003_03(self, env, vhost):
+ r = self.get(env, "TLSv1.3", "1.3", VHOSTS[vhost], with_cert=False)
+ assert not (r.exit_code == 0 and r.response
+ and r.response["status"] == 200), \
+ "resource served without a client certificate"
+
+ # The rest of the virtual host is unaffected.
+ def test_ssl_003_04(self, env):
+ r = self.get(env, "TLSv1.3", "1.3", VHOSTS["unset"], path="/index.html")
+ assert r.exit_code == 0, f"handshake failed: {r.stderr}"
+ assert r.response["status"] == 200
+
+ # A virtual host asking for an optional certificate has already collected
+ # one during the handshake, so tightening to "require" for a location has
+ # nothing left to ask for. This used to attempt Post-Handshake
+ # Authentication anyway, which OpenSSL refuses once a certificate was
+ # requested in the handshake - SSL_R_INVALID_CONFIG, reported as AH10158
+ # and turned into 403, even for a client which offered the
+ # post_handshake_auth extension and presented a valid certificate.
+ @not_backported("r1938504", "no SSL_VERIFY_CLIENT_ONCE with post-handshake auth")
+ def test_ssl_003_05(self, env):
+ r = self.get(env, "TLSv1.3", "1.3", VHOSTS["optional"])
+ assert r.exit_code == 0, f"handshake failed: {r.stderr}"
+ assert r.response["status"] == 200
+
+ # SSLVerifyDepth 0 accepts only a self-signed certificate, so the CA
+ # issued one used here is refused - which establishes that the depth is
+ # applied at all, before asking where it is applied from.
+ @pytest.mark.parametrize("proto,version", [("TLSv1.2", "1.2"),
+ ("TLSv1.3", "1.3")])
+ def test_ssl_003_06(self, env, proto, version):
+ r = self.get(env, proto, version, VHOSTS["unset"], vhost_depth=0)
+ assert not (r.exit_code == 0 and r.response
+ and r.response["status"] == 200), \
+ "certificate accepted at SSLVerifyDepth 0"
diff -Nru apache2-2.4.69/test/modules/ssl/test_004_socache_redis.py apache2-2.4.71/test/modules/ssl/test_004_socache_redis.py
--- apache2-2.4.69/test/modules/ssl/test_004_socache_redis.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/ssl/test_004_socache_redis.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,91 @@
+import socket
+import time
+from threading import Thread
+
+import pytest
+
+from pyhttpd.backport import not_backported
+from pyhttpd.depends import needs_dependency
+
+from pyhttpd.conf import HttpdConf
+from .env import SSLTestEnv
+
+
+class SilentRedis:
+ # accepts connections and never answers, so a client waits for its
+ # read timeout
+
+ def __init__(self):
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._socket.bind(('127.0.0.1', 0))
+ self._socket.listen(5)
+ self._socket.settimeout(0.2)
+ self._done = False
+ self._conns = []
+ self._thread = Thread(target=self._run, daemon=True)
+
+ @property
+ def port(self):
+ return self._socket.getsockname()[1]
+
+ def _run(self):
+ while not self._done:
+ try:
+ self._conns.append(self._socket.accept()[0])
+ except socket.timeout:
+ pass
+
+ def start(self):
+ self._thread.start()
+
+ def stop(self):
+ self._done = True
+ self._thread.join(timeout=5)
+ for c in self._conns:
+ c.close()
+ self._socket.close()
+
+
+@needs_dependency("mod_socache_redis", SSLTestEnv.has_shared_module("socache_redis"),
+ reason="mod_socache_redis not available")
+class TestSocacheRedis:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ # storing the session of a TLSv1.2 handshake waits for the redis reply
+ env.httpd_error_log.add_ignored_lognos(["AH03478"])
+ yield
+ env.httpd_error_log.remove_ignored_lognos(["AH03478"])
+
+ # RedisTimeout is passed to the redis client in whole seconds, a
+ # positive value is rounded up, 0 does not wait at all
+ @pytest.mark.parametrize(["timeout", "seconds"], [
+ ["0", 0],
+ ] + [pytest.param(timeout, seconds, marks=not_backported(
+ "r1938833", "RedisTimeout unit conversion"))
+ for timeout, seconds in [["1ms", 1], ["1s", 1], ["1500ms", 2]]])
+ def test_ssl_004_01(self, env, timeout, seconds):
+ redis = SilentRedis()
+ redis.start()
+ try:
+ conf = HttpdConf(env, extras={
+ "base": [
+ f"SSLSessionCache redis:127.0.0.1:{redis.port}",
+ f"RedisTimeout {timeout}",
+ "SSLProtocol TLSv1.2",
+ ]
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "test1", "/")
+ start = time.monotonic()
+ r = env.curl_get(url, options=["--tlsv1.2", "--tls-max", "1.2",
+ "--max-time", "10"])
+ elapsed = time.monotonic() - start
+ finally:
+ redis.stop()
+ assert r.exit_code == 0, f"{r.stdout}{r.stderr}"
+ assert r.response["status"] == 200
+ assert elapsed >= seconds - 0.2, f"waited only {elapsed:.1f}s"
+ assert elapsed < seconds + 1.5, f"waited {elapsed:.1f}s"
diff -Nru apache2-2.4.69/test/modules/ssl/test_005_vhost_compat.py apache2-2.4.71/test/modules/ssl/test_005_vhost_compat.py
--- apache2-2.4.69/test/modules/ssl/test_005_vhost_compat.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/ssl/test_005_vhost_compat.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,309 @@
+import os
+import re
+
+import pytest
+
+from pyhttpd.certs import CertificateSpec
+from pyhttpd.conf import HttpdConf
+
+
+class TestVhostCompatSecure:
+ """Under the default 'secure' SSLVHostSNIPolicy, requests routed to a
+ virtual host with different SSL parameters (certificate or auth
+ settings) than the handshake host are rejected with 421."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ alt_domain = f"test2.{env.http_tld}"
+ alt_creds = env.ca.issue_cert(
+ CertificateSpec(name="ssl005-alt", domains=[alt_domain],
+ key_type='rsa2048'))
+
+ for name in ("test1", "test2"):
+ doc = os.path.join(env.server_dir, "htdocs", name)
+ os.makedirs(doc, exist_ok=True)
+ with open(os.path.join(doc, "index.html"), "w") as f:
+ f.write(f"vhost {name}\n")
+
+ conf = HttpdConf(env, extras={
+ f"test1.{env.http_tld}": "LogLevel ssl:debug",
+ alt_domain: f"""
+ LogLevel ssl:debug
+ SSLCertificateFile "{alt_creds.cert_file}"
+ SSLCertificateKeyFile "{alt_creds.pkey_file}"
+ """,
+ })
+ conf.add_vhost_test1()
+ conf.start_vhost(domains=[alt_domain],
+ port=env.https_port,
+ doc_root="htdocs/test2",
+ with_certificates=False)
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ env.httpd_error_log.add_ignored_lognos(["AH02032"])
+
+ # SNI selects a vhost and Host: names a different one with a
+ # different certificate. Must return 421.
+ def test_ssl_005_01(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ alt_host = f"test2.{env.http_tld}"
+ r = env.curl_get(url, options=[
+ "--http1.1", "-H", f"Host: {alt_host}",
+ ])
+ assert r.exit_code == 0, f"curl failed: {r.stderr}"
+ assert r.response["status"] == 421
+
+ # Same vhost for SNI and Host: — must succeed.
+ def test_ssl_005_02(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_get(url, options=["--http1.1"])
+ assert r.exit_code == 0, f"curl failed: {r.stderr}"
+ assert r.response["status"] == 200
+
+ # No SNI at all, with Host: selecting a vhost whose SSL parameters
+ # differ from the default (handshake) host. Must return 421.
+ def test_ssl_005_03(self, env):
+ alt_host = f"test2.{env.http_tld}"
+ r = env.run([
+ 'openssl', 's_client',
+ '-connect', f'127.0.0.1:{env.https_port}',
+ '-CAfile', env.ca.cert_file,
+ '-noservername',
+ '-tls1_3',
+ '-ign_eof',
+ ], intext=(
+ f"GET /index.html HTTP/1.1\r\n"
+ f"Host: {alt_host}\r\n"
+ f"Connection: close\r\n"
+ f"\r\n"
+ ))
+ assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
+ assert re.search(r'HTTP/1\.[01] 421', r.stdout), \
+ f"expected 421 in response, got: {r.stdout[:500]}"
+
+ # No SNI, but Host: matches the default (handshake) vhost — must
+ # succeed because the SSL parameters are the same.
+ def test_ssl_005_04(self, env):
+ default_host = f"test1.{env.http_tld}"
+ r = env.run([
+ 'openssl', 's_client',
+ '-connect', f'127.0.0.1:{env.https_port}',
+ '-CAfile', env.ca.cert_file,
+ '-noservername',
+ '-tls1_3',
+ '-ign_eof',
+ ], intext=(
+ f"GET /index.html HTTP/1.1\r\n"
+ f"Host: {default_host}\r\n"
+ f"Connection: close\r\n"
+ f"\r\n"
+ ))
+ assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
+ assert re.search(r'HTTP/1\.[01] 200', r.stdout), \
+ f"expected 200 in response, got: {r.stdout[:500]}"
+
+ # TLS 1.3 session resumption: establish a session with vhost A, then
+ # resume it without SNI while Host: names vhost B (different SSL
+ # config). The resumed connection must also be rejected with 421.
+ def test_ssl_005_05(self, env):
+ default_host = f"test1.{env.http_tld}"
+ alt_host = f"test2.{env.http_tld}"
+ sess_dir = os.path.join(env.gen_dir, "ssl005")
+ os.makedirs(sess_dir, exist_ok=True)
+ sess_file = os.path.join(sess_dir, "session.pem")
+
+ r = env.run([
+ 'openssl', 's_client',
+ '-connect', f'127.0.0.1:{env.https_port}',
+ '-CAfile', env.ca.cert_file,
+ '-servername', default_host,
+ '-tls1_3',
+ '-sess_out', sess_file,
+ '-ign_eof',
+ ], intext=(
+ f"GET /index.html HTTP/1.1\r\n"
+ f"Host: {default_host}\r\n"
+ f"Connection: close\r\n"
+ f"\r\n"
+ ))
+ assert r.exit_code == 0, f"session setup failed: {r.stderr}"
+ assert os.path.isfile(sess_file), "no session ticket saved"
+
+ r = env.run([
+ 'openssl', 's_client',
+ '-connect', f'127.0.0.1:{env.https_port}',
+ '-CAfile', env.ca.cert_file,
+ '-noservername',
+ '-tls1_3',
+ '-sess_in', sess_file,
+ '-ign_eof',
+ ], intext=(
+ f"GET /index.html HTTP/1.1\r\n"
+ f"Host: {alt_host}\r\n"
+ f"Connection: close\r\n"
+ f"\r\n"
+ ))
+ assert r.exit_code == 0, f"resumed connection failed: {r.stderr}"
+ assert re.search(r'HTTP/1\.[01] 421', r.stdout), \
+ f"expected 421 on resumed connection, got: {r.stdout[:500]}"
+
+
+class TestVhostCompatAuthonly:
+ """Under 'authonly' SSLVHostSNIPolicy, vhosts with different server
+ certificates but identical authentication settings are compatible.
+ This is the regression fix for PR 69743: the 'secure' default was
+ too strict for deployments that intentionally use different certs on
+ the same IP:port."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env, request):
+ has_policy = env.has_directive("SSLVHostSNIPolicy")
+ if not has_policy:
+ request.applymarker(pytest.mark.xfail(
+ reason="SSLVHostSNIPolicy not available", strict=False))
+ alt_domain = f"test2.{env.http_tld}"
+ alt_creds = env.ca.issue_cert(
+ CertificateSpec(name="ssl005-authonly", domains=[alt_domain],
+ key_type='rsa2048'))
+
+ for name in ("test1", "test2"):
+ doc = os.path.join(env.server_dir, "htdocs", name)
+ os.makedirs(doc, exist_ok=True)
+ with open(os.path.join(doc, "index.html"), "w") as f:
+ f.write(f"vhost {name}\n")
+
+ extras = {
+ f"test1.{env.http_tld}": "LogLevel ssl:debug",
+ alt_domain: f"""
+ LogLevel ssl:debug
+ SSLCertificateFile "{alt_creds.cert_file}"
+ SSLCertificateKeyFile "{alt_creds.pkey_file}"
+ """,
+ }
+ if has_policy:
+ extras['base'] = 'SSLVHostSNIPolicy authonly'
+ conf = HttpdConf(env, extras=extras)
+ conf.add_vhost_test1()
+ conf.start_vhost(domains=[alt_domain],
+ port=env.https_port,
+ doc_root="htdocs/test2",
+ with_certificates=False)
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # Under 'authonly', a Host: mismatch to a vhost with a different
+ # certificate but same auth settings is allowed. This is the
+ # regression that PR 69743 fixed.
+ def test_ssl_005_10(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ alt_host = f"test2.{env.http_tld}"
+ r = env.curl_get(url, options=[
+ "--http1.1", "-H", f"Host: {alt_host}",
+ ])
+ assert r.exit_code == 0, f"curl failed: {r.stderr}"
+ assert r.response["status"] == 200
+
+ # Same check without SNI — should also be allowed under 'authonly'
+ # when only the certificate differs.
+ def test_ssl_005_11(self, env):
+ alt_host = f"test2.{env.http_tld}"
+ r = env.run([
+ 'openssl', 's_client',
+ '-connect', f'127.0.0.1:{env.https_port}',
+ '-CAfile', env.ca.cert_file,
+ '-noservername',
+ '-tls1_3',
+ '-ign_eof',
+ ], intext=(
+ f"GET /index.html HTTP/1.1\r\n"
+ f"Host: {alt_host}\r\n"
+ f"Connection: close\r\n"
+ f"\r\n"
+ ))
+ assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
+ assert re.search(r'HTTP/1\.[01] 200', r.stdout), \
+ f"expected 200 in response, got: {r.stdout[:500]}"
+
+
+class TestVhostCompatAuthonlyAuth:
+ """Under 'authonly' SSLVHostSNIPolicy, vhosts with different client
+ authentication settings (different SSLCACertificateFile) must still
+ be blocked — this is the core CVE-2025-23048 scenario."""
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env, request):
+ has_policy = env.has_directive("SSLVHostSNIPolicy")
+ if not has_policy:
+ request.applymarker(pytest.mark.xfail(
+ reason="SSLVHostSNIPolicy not available", strict=False))
+ alt_domain = f"test2.{env.http_tld}"
+
+ sub_ca = env.ca.issue_cert(
+ CertificateSpec(name="ssl005-subca"))
+ sub_ca_file = os.path.join(env.server_dir, "ssl005-subca.pem")
+ sub_ca.save_cert_pem(sub_ca_file)
+
+ for name in ("test1", "test2"):
+ doc = os.path.join(env.server_dir, "htdocs", name)
+ os.makedirs(doc, exist_ok=True)
+ with open(os.path.join(doc, "index.html"), "w") as f:
+ f.write(f"vhost {name}\n")
+
+ extras = {
+ f"test1.{env.http_tld}": f"""
+ LogLevel ssl:debug
+ SSLVerifyClient optional
+ SSLCACertificateFile "{env.ca.cert_file}"
+ """,
+ alt_domain: f"""
+ LogLevel ssl:debug
+ SSLVerifyClient optional
+ SSLCACertificateFile "{sub_ca_file}"
+ """,
+ }
+ if has_policy:
+ extras['base'] = 'SSLVHostSNIPolicy authonly'
+ conf = HttpdConf(env, extras=extras)
+ conf.add_vhost_test1()
+ conf.start_vhost(domains=[alt_domain],
+ port=env.https_port,
+ doc_root="htdocs/test2")
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ env.httpd_error_log.add_ignored_lognos(["AH02032"])
+
+ # Even under 'authonly', different SSLCACertificateFile between
+ # vhosts must return 421.
+ def test_ssl_005_20(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ alt_host = f"test2.{env.http_tld}"
+ r = env.curl_get(url, options=[
+ "--http1.1", "-H", f"Host: {alt_host}",
+ ])
+ assert r.exit_code == 0, f"curl failed: {r.stderr}"
+ assert r.response["status"] == 421
+
+ # Without SNI, same scenario — must also be 421.
+ def test_ssl_005_21(self, env):
+ alt_host = f"test2.{env.http_tld}"
+ r = env.run([
+ 'openssl', 's_client',
+ '-connect', f'127.0.0.1:{env.https_port}',
+ '-CAfile', env.ca.cert_file,
+ '-noservername',
+ '-tls1_3',
+ '-ign_eof',
+ ], intext=(
+ f"GET /index.html HTTP/1.1\r\n"
+ f"Host: {alt_host}\r\n"
+ f"Connection: close\r\n"
+ f"\r\n"
+ ))
+ assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
+ assert re.search(r'HTTP/1\.[01] 421', r.stdout), \
+ f"expected 421 in response, got: {r.stdout[:500]}"
diff -Nru apache2-2.4.69/test/modules/ssl/test_006_ct_shutdown.py apache2-2.4.71/test/modules/ssl/test_006_ct_shutdown.py
--- apache2-2.4.69/test/modules/ssl/test_006_ct_shutdown.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/modules/ssl/test_006_ct_shutdown.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,30 @@
+"""SSL-CT must join its service thread before APR destroys the child pool."""
+import os
+import time
+
+from pyhttpd.conf import HttpdConf
+
+
+def test_ssl_ct_clean_shutdown(env):
+ env.require(env.has_shared_module("ssl_ct"), "mod_ssl_ct was not built",
+ dep="mod_ssl_ct", detected=f"no mod_ssl_ct.so in {env.libexec_dir}")
+ assert env.apache_stop() == 0
+ scts = os.path.join(env.server_dir, "scts")
+ os.makedirs(scts, exist_ok=True)
+ conf = HttpdConf(env)
+ if "ssl_ct" not in env.static_modules():
+ conf.add(f'LoadModule ssl_ct_module "{env.libexec_dir}/mod_ssl_ct.so"')
+ conf.add([
+ f'CTSCTStorage "{scts}"',
+ "LogLevel ssl_ct:debug",
+ ])
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0, env.apachectl_stderr
+ # The service thread checks MPM state once per second. Stop while it is
+ # asleep: in an APR pool-debug build, the join from pchild's pre-cleanup
+ # deadlocked with apr_thread_exit() while the thread's pool shared
+ # pchild's mutex. Keep shutdown warnings visible to the fixtures.
+ time.sleep(0.1)
+ assert env.apache_stop() == 0
+ env.check_error_log()
diff -Nru apache2-2.4.69/test/pyhttpd/backport.py apache2-2.4.71/test/pyhttpd/backport.py
--- apache2-2.4.69/test/pyhttpd/backport.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/backport.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,90 @@
+"""Marks for tests of changes the httpd under test does not have yet.
+
+Trunk (2.5) gets a fix and its test first; the test is then carried over to
+2.4.x before, or without, the fix. On 2.4.x such a test is an expected
+failure, or has to be skipped outright when failing means hanging, crashing
+the server or leaving errors in the error log. On trunk the marks do nothing.
+
+When the change is backported, add backported_in, the 2.4.x version it first
+appears in, on trunk and copy the file over; older 2.4.x releases keep the
+expectation.
+
+ pytestmark = not_backported("r1939033", "nonce-count validation")
+
+ @skip_not_backported("r1938487", "recursion limit in mod_sed",
+ backported_in="2.4.71")
+ def test_...
+
+platform limits the expectation to one sys.platform, for a failure on 2.4.x
+which trunk does not have there.
+
+The marks only record their arguments; pyhttpd.gating applies them at
+collection, against the httpd that pyhttpd.build found. Both suites use these.
+"""
+
+import re
+import sys
+
+import pytest
+
+
+def _version_tuple(version) -> tuple:
+ if isinstance(version, tuple):
+ return version + (0,) * (3 - len(version))
+ m = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", str(version))
+ if not m:
+ raise ValueError(f"cannot parse httpd version {version!r}")
+ return int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
+
+
+def has_change(server_version, backported_in: str = None) -> bool:
+ """Whether a server of this version has a trunk change: trunk always
+ does, 2.4.x only from the version it was backported in. A -dev suffix
+ counts as the release, a backport lands while the tree is -dev.
+
+ *server_version* is a string ("2.4.70-dev") or a (major, minor, patch)
+ tuple."""
+ version = _version_tuple(server_version)
+ if version >= (2, 5, 0):
+ return True
+ return backported_in is not None and version >= _version_tuple(backported_in)
+
+
+def lacks_change(server_version, backported_in: str = None,
+ platform: str = None) -> bool:
+ """Whether the expectation of a not_backported mark holds here: the
+ server lacks the change, and *platform* (if given) is this one."""
+ return (not has_change(server_version, backported_in)
+ and (platform is None or sys.platform == platform))
+
+
+def reason(rev: str, what: str, platform: str = None) -> str:
+ return f"{what} ({rev}) not backported" + (f" [{platform}]" if platform else "")
+
+
+def _mark(name: str, rev: str, what: str, backported_in, platform):
+ kwargs = {k: v for k, v in
+ (("backported_in", backported_in), ("platform", platform))
+ if v is not None}
+ return getattr(pytest.mark, name)(rev, what, **kwargs)
+
+
+def not_backported(rev: str, what: str, backported_in: str = None,
+ platform: str = None):
+ """Expected to fail until *what* (trunk change *rev*) is backported.
+
+ Only a failure of the test function counts; an error in the setup or
+ teardown of its fixtures is reported as an error. Mark only the tests
+ (or parameters, via pytest.param(..., marks=...)) that fail without the
+ change: on the others the mark would hide a regression.
+
+ Not strict: a test that happens to pass is reported as XPASS."""
+ return _mark("not_backported", rev, what, backported_in, platform)
+
+
+def skip_not_backported(rev: str, what: str, backported_in: str = None,
+ platform: str = None):
+ """Not run until *what* (trunk change *rev*) is backported. For tests
+ which would hang, crash the server, or fail the error log check in their
+ teardown, none of which an xfail contains."""
+ return _mark("skip_not_backported", rev, what, backported_in, platform)
diff -Nru apache2-2.4.69/test/pyhttpd/build-win.bat apache2-2.4.71/test/pyhttpd/build-win.bat
--- apache2-2.4.69/test/pyhttpd/build-win.bat 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/build-win.bat 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,293 @@
+@ECHO off
+@REM
+@REM Build script for Apache httpd on Windows using CMake + vcpkg.
+@REM
+@REM This script bootstraps a complete build environment from scratch:
+@REM - Installs PortableGit if git is not available
+@REM - Installs Visual Studio 2022 Build Tools (cl, cmake, nmake) if not found
+@REM - Installs dependencies (apr, openssl, curl, etc.) via vcpkg
+@REM - Clones the httpd source if not already in the source tree
+@REM - Builds and installs httpd
+@REM
+@REM Usage: build-win.bat [repo_url] [branch]
+@REM
+@REM repo_url Git repository URL (default: https://github.com/apache/httpd.git)
+@REM branch Git branch to clone (default: trunk)
+@REM
+@REM If run from within the httpd source tree, uses that source directly.
+@REM Otherwise clones the repo into %USERPROFILE%\httpd-trunk.
+@REM
+@REM Examples:
+@REM build-win.bat
+@REM build-win.bat https://github.com/myfork/httpd.git
+@REM build-win.bat https://github.com/myfork/httpd.git my-branch
+@REM build-win.bat https://github.com/apache/httpd.git 2.4.x
+@REM
+
+@REM -------------------------------------
+@REM Make sure the PATH is limited, but only
+@REM if build tools are not already available
+@REM -------------------------------------
+where cl.exe >nul 2>&1 && where cmake.exe >nul 2>&1 && where nmake.exe >nul 2>&1
+if %ERRORLEVEL% EQU 0 goto :path_ok
+ @REM Find powershell.exe while PATH still has it, before we reset
+ SET "POWERSHELL_DIR="
+ for /f "delims=" %%i in ('where powershell.exe 2^>nul') do (
+ if not defined POWERSHELL_DIR set "POWERSHELL_DIR=%%~dpi"
+ )
+ set "PATH=C:\Windows\system32;C:\Windows"
+ if defined POWERSHELL_DIR set "PATH=%PATH%;%POWERSHELL_DIR%"
+:path_ok
+
+@REM -------------------------------------
+@REM check for git otherwise install it
+@REM -------------------------------------
+where git.exe >nul 2>&1
+if %ERRORLEVEL% EQU 0 goto :git_ok
+
+@REM git not in PATH, check if PortableGit exists but PATH is missing it
+if EXIST "%USERPROFILE%\PortableGit\bin\git.exe" (
+ echo "PortableGit found, adding to PATH..."
+ SET "PATH=%USERPROFILE%\PortableGit\bin;%PATH%"
+ goto :git_ok
+)
+
+@REM PortableGit not installed, download and install it
+echo "git.exe not found, installing PortableGit..."
+curl -L -o git-portable.exe "https://github.com/git-for-windows/git/releases/download/v2.48.1.windows.1/PortableGit-2.48.1-64-bit.7z.exe"
+git-portable.exe -y -o"%USERPROFILE%\PortableGit"
+if NOT EXIST "%USERPROFILE%\PortableGit\bin\git.exe" (
+ echo "ERROR: PortableGit installation failed."
+ exit /b 1
+)
+SET "PATH=%USERPROFILE%\PortableGit\bin;%PATH%"
+echo "PortableGit installed successfully."
+
+:git_ok
+
+@REM -------------------------------------
+@REM Variables used in this script
+@REM -------------------------------------
+@REM httpd git repo URL and branch: use arguments or defaults
+SET "HTTPD_REPO=%~1"
+if "%HTTPD_REPO%"=="" SET "HTTPD_REPO=https://github.com/apache/httpd.git"
+SET "HTTPD_BRANCH=%~2"
+if "%HTTPD_BRANCH%"=="" SET "HTTPD_BRANCH=trunk"
+
+@REM Find httpd source: try script location first, then fall back to clone
+SET "SCRIPT_DIR=%~dp0"
+for %%i in ("%SCRIPT_DIR%..\..") do SET "CANDIDATE=%%~fi"
+if EXIST "%CANDIDATE%\include\httpd.h" (
+ SET "HTTPD_SRC=%CANDIDATE%"
+ echo "Using existing httpd source in %HTTPD_SRC%"
+ goto :src_ok
+)
+SET "HTTPD_SRC=%USERPROFILE%\httpd-trunk"
+if EXIST "%HTTPD_SRC%\include\httpd.h" (
+ echo "Using existing httpd source in %HTTPD_SRC%"
+ if EXIST "%HTTPD_SRC%\.git" (
+ echo "Updating %HTTPD_SRC%"
+ git -C "%HTTPD_SRC%" pull
+ )
+ goto :src_ok
+)
+if EXIST "%HTTPD_SRC%\.git" (
+ echo "Updating incomplete httpd clone in %HTTPD_SRC%..."
+ git -C "%HTTPD_SRC%" pull
+) else (
+ echo "Cloning httpd source in %HTTPD_SRC%..."
+ git clone -b "%HTTPD_BRANCH%" "%HTTPD_REPO%" "%HTTPD_SRC%"
+)
+if NOT EXIST "%HTTPD_SRC%\include\httpd.h" (
+ echo "ERROR: httpd source not found after clone."
+ exit /b 1
+)
+cd /d "%HTTPD_SRC%\test\pyhttpd"
+:src_ok
+
+SET CWD=%CD%
+SET "CWD=%CWD:\=/%"
+@REM -------------------------------------
+SET BUILD_TYPE="Release"
+SET VCPKG_ROOT=%CWD%\vcpkg
+SET VCPKG_TRIPLET=x64-windows-release
+SET VCPKG_DIRECTORY="vcpkg\installed\%VCPKG_TRIPLET%"
+SET VCPKG_DIRECTORY_LIB=%VCPKG_DIRECTORY%
+SET GENERATOR="NMake Makefiles"
+@REM SET GENERATOR="Ninja"
+SET CMAKE_VERSION="4.0"
+SET HTTPD_INSTALL_DIRECTORY="C:/Users/%USERNAME%/Projects/http-debug/apache"
+
+@REM -------------------------------------
+@REM Check for MS build tools cl.exe cmake etc.
+@REM -------------------------------------
+where cl.exe >nul 2>&1 && where cmake.exe >nul 2>&1 && where nmake.exe >nul 2>&1
+if %ERRORLEVEL% EQU 0 (
+ echo "cl.exe, cmake.exe and nmake.exe already in PATH, skipping vcvars64.bat"
+ SET "VCVARS_DONE=1"
+ goto :tools_ok
+)
+
+SET "VCVARS_DONE=0"
+if EXIST "C:\Program Files\Microsoft Visual Studio\2022\Professional\VC\Auxiliary\Build\vcvars64.bat" (
+ echo "found1: Professional edition"
+ SET "VCVARS_BAT=C:\Program Files\Microsoft Visual Studio\2022\Professional\VC\Auxiliary\Build\vcvars64.bat"
+ goto :tools_ok
+)
+if EXIST "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" (
+ echo "found2: Community edition"
+ SET "VCVARS_BAT=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
+ goto :tools_ok
+)
+if EXIST "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat" (
+ echo "found3: Build Tools edition"
+ SET "VCVARS_BAT=C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
+ goto :tools_ok
+)
+
+@REM None found - tell the user to install Visual Studio Build Tools
+echo "ERROR: No Visual Studio 2022 build tools found."
+echo "Please install one of:"
+echo " - Visual Studio 2022 Professional"
+echo " - Visual Studio 2022 Community"
+echo " - Visual Studio 2022 Build Tools"
+echo "from https://visualstudio.microsoft.com/downloads/"
+
+@REM -------------------------------------
+@REM try to downlaod and install it.
+@REM -------------------------------------
+
+curl -L -o vs_buildtools.exe "https://aka.ms/vs/17/release/vs_buildtools.exe"
+vs_buildtools.exe --quiet --wait --norestart --nocache ^
+ --add Microsoft.VisualStudio.Workload.VCTools ^
+ --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ^
+ --add Microsoft.VisualStudio.Component.Windows11SDK.22621 ^
+ --includeRecommended
+
+@REM After install, check again for BuildTools vcvars64.bat
+if NOT EXIST "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat" (
+ echo "ERROR: Build Tools installation failed or vcvars64.bat not found."
+ exit /b 1
+)
+echo "found3: Build Tools edition (just installed)"
+call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
+
+@REM Verify cl.exe, cmake.exe and nmake.exe are now available
+where cl.exe >nul 2>&1
+if %ERRORLEVEL% NEQ 0 (
+ echo "ERROR: cl.exe still not found after installing Build Tools."
+ exit /b 1
+)
+where cmake.exe >nul 2>&1
+if %ERRORLEVEL% NEQ 0 (
+ echo "ERROR: cmake.exe not found. Please install CMake."
+ exit /b 1
+)
+where nmake.exe >nul 2>&1
+if %ERRORLEVEL% NEQ 0 (
+ echo "ERROR: nmake.exe not found after installing Build Tools."
+ exit /b 1
+)
+SET "VCVARS_DONE=1"
+
+:tools_ok
+
+@REM -------------------------------------
+@REM Setup build env using VS build 2022
+@REM -------------------------------------
+if "%VCVARS_DONE%" == "1" (
+ echo "Build environment already configured, skipping vcvars64.bat"
+) else (
+ call "%VCVARS_BAT%"
+)
+
+where cl.exe >nul 2>&1
+if %ERRORLEVEL% NEQ 0 (
+ echo "ERROR: cl.exe still not found after installing Build Tools."
+ exit /b 1
+)
+where cmake.exe >nul 2>&1
+if %ERRORLEVEL% NEQ 0 (
+ echo "ERROR: cmake.exe not found in PATH. Please install CMake."
+ exit /b 1
+)
+where nmake.exe >nul 2>&1
+if %ERRORLEVEL% NEQ 0 (
+ echo "ERROR: nmake.exe not found in PATH."
+ exit /b 1
+)
+
+@REM -------------------------------------
+@REM Installs via MS vcpkg manager all the
+@REM packages needed to build httpd
+@REM -------------------------------------
+if NOT EXIST vcpkg\ (
+ echo "Adding...."
+ git submodule add --force https://github.com/microsoft/vcpkg.git vcpkg
+ PUSHD vcpkg\
+ CALL bootstrap-vcpkg.bat -disableMetrics
+) else (
+ echo "Updating...."
+ git submodule update --remote --merge vcpkg\
+ PUSHD vcpkg\
+)
+
+@REM For some reason using the manifest doesn't install the default-features
+vcpkg.exe install --triplet %VCPKG_TRIPLET% apr[private-headers] apr-util pcre2 openssl nghttp2 curl libxml2 jansson
+
+POPD
+
+@REM -------------------------------------
+@REM Create a temp dir for cmake files
+@REM -------------------------------------
+if not EXIST build-asf-win\ (
+ mkdir build-asf-win\
+) else (
+ rmdir /s /q build-asf-win\
+ mkdir build-asf-win\
+)
+
+PUSHD build-asf-win\
+
+@REM -------------------------------------
+@REM Run cmake so that it configures the
+@REM and creates the needed makefiles
+@REM -------------------------------------
+cmake "%HTTPD_SRC%" -B . ^
+ -G %GENERATOR% ^
+ -DCMAKE_BUILD_TYPE=%BUILD_TYPE% ^
+ -DCMAKE_TOOLCHAIN_FILE=%CWD%/vcpkg/scripts/buildsystems/vcpkg.cmake ^
+ -DVCPKG_TARGET_TRIPLET=%VCPKG_TRIPLET% ^
+ -DNGHTTP2_INCLUDE_DIR=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/include ^
+ -DAPR_INCLUDE_DIR=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/include ^
+ -DJANSSON_INCLUDE_DIR=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/include ^
+ "-DAPR_LIBRARIES=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/libapr-1.lib;%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/libaprutil-1.lib" ^
+ -DNGHTTP2_LIBRARIES=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/nghttp2.lib ^
+ -DJANSSON_LIBRARIES=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/jansson.lib ^
+ -DENABLE_MIME_MAGIC=A ^
+ -DBUILD_TEST_MODULES=true ^
+ -DCMAKE_POLICY_VERSION_MINIMUM=%CMAKE_VERSION% ^
+ --install-prefix %HTTPD_INSTALL_DIRECTORY%
+
+@REM -------------------------------------
+@REM Build httpd
+@REM -------------------------------------
+cmake --build .
+
+@REM -------------------------------------
+@REM Install httpd only
+@REM -------------------------------------
+cmake --install . --config %BUILD_TYPE%
+POPD
+
+@REM -------------------------------------
+@REM Copy the dependencies.
+@REM -------------------------------------
+if %BUILD_TYPE% == "Debug" (
+ SET VCPKG_DIRECTORY_LIB=%VCPKG_DIRECTORY_LIB%\debug
+)
+
+@REM -------------------------------------
+@REM Copy the libraries needed to run httpd
+@REM -------------------------------------
+xcopy %VCPKG_DIRECTORY_LIB%\bin\* %HTTPD_INSTALL_DIRECTORY%\bin /y /f /i
diff -Nru apache2-2.4.69/test/pyhttpd/build.py apache2-2.4.71/test/pyhttpd/build.py
--- apache2-2.4.69/test/pyhttpd/build.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/build.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,514 @@
+"""Build discovery: which httpd is under test, and what it can do.
+
+One place answers that for both suites. ``resolve_build(config)`` finds the
+httpd from, in order of precedence, ``--apxs``, ``--httpd`` (+ ``--conf``), or
+the ``config.ini`` that the autoconf build generates for the modules/ suite,
+probes it once per session, and returns an :class:`HttpdInfo`. The collection
+time gating in :mod:`pyhttpd.gating` and the report header read it; the
+classic framework builds its configuration on the same object.
+
+The probing mirrors the discovery logic in Apache::TestConfigParse (perl-apache-test):
+run ``httpd -v`` / ``-V`` / ``-l`` and parse the inherited install ``httpd.conf``
+to learn the server version, MPM, compile-time ``-D`` defines, and the full set
+of available modules (static + dynamically loaded).
+
+The merged module set drives ```` blocks, the module-name token
+resolution in :mod:`apache_pytest.config`, and (eventually) the ``need_module``
+test marks.
+"""
+
+from __future__ import annotations
+
+import configparser
+import os
+import re
+import subprocess
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+
+# httpd -V emits lines like: -D APACHE_MPM_DIR="server/mpm/prefork"
+_DEFINE_RE = re.compile(r"^\s*-D\s+([A-Z0-9_]+)(?:=(.*))?\s*$")
+# httpd -l / module conf lines end in ".c"; -l indents them two spaces.
+_STATIC_MOD_RE = re.compile(r"^\s*(\w+\.c)\s*$")
+# LoadModule foo_module modules/mod_foo.so (optionally commented with leading #,
+# the file optionally quoted)
+_LOADMODULE_RE = re.compile(r'^\s*(#?)\s*LoadModule\s+(\S+)\s+(?:"([^"]+)"|(\S+))',
+ re.IGNORECASE)
+# Include conf/extra/*.conf, IncludeOptional "conf.d/*.conf"
+_INCLUDE_RE = re.compile(r'^\s*Include(?:Optional)?\s+(?:"([^"]+)"|(\S+))',
+ re.IGNORECASE)
+_VERSION_RE = re.compile(r"Apache/(\d+)\.(\d+)\.(\d+)")
+
+
+@dataclass
+class LoadDirective:
+ """One ``LoadModule symbol file`` mapping from an install conf."""
+
+ symbol: str # e.g. "session_module"
+ so: str # path as written, e.g. "modules/mod_session.so"
+ active: bool # False if the line was commented out
+
+ @property
+ def cname(self) -> str:
+ """Module source name, e.g. "mod_session.c" (used as the modules-set key)."""
+ stem = Path(self.so).name
+ for suffix in (".so", ".dll"):
+ if stem.endswith(suffix):
+ stem = stem[: -len(suffix)]
+ break
+ if stem.startswith("lib"): # libphp.so -> mod_php.c
+ stem = "mod_" + stem[3:]
+ return f"{stem}.c"
+
+ @property
+ def is_mpm(self) -> bool:
+ return self.symbol.startswith("mpm_") or "mpm_" in Path(self.so).name
+
+
+@dataclass
+class HttpdInfo:
+ """Everything probed from an httpd binary + its inherited config."""
+
+ httpd: Path
+ version: tuple[int, int, int]
+ mpm: str
+ defines: dict[str, str | bool] = field(default_factory=dict)
+ # Set of module source-file names, e.g. {"core.c", "mod_proxy.c", ...}.
+ # This is the union of statically-compiled modules and the modules the
+ # inherited httpd.conf loads (active LoadModule lines) — matching
+ # Apache::Test's $self->{modules} hash keyed on ".c". Membership here
+ # means "the classic suite's server has this module", which is what drives
+ # the blocks and the need_module marks. (The modules/ suite
+ # writes its own LoadModule lines and asks HttpdTestEnv.has_shared_module.)
+ modules: set[str] = field(default_factory=set)
+ # Every LoadModule directive discovered in the inherited conf, with absolute
+ # .so paths. Re-emitted into the generated httpd.conf so the test server
+ # actually loads them (the generated conf does not Include the install conf).
+ load_directives: list[LoadDirective] = field(default_factory=list)
+ # TAKE1 directives inherited from the install conf (wanted_config in
+ # TestConfigParse.pm:34): ServerAdmin, TypesConfig, DocumentRoot, ServerRoot.
+ # Keyed by directive name (as written), value is the single argument.
+ inherited: dict[str, str] = field(default_factory=dict)
+ # Modules compiled into the binary (``httpd -l``), e.g. {"mod_so.c"}.
+ static_modules: set[str] = field(default_factory=set)
+ # Where the build was found: "apxs", "httpd" or "config.ini".
+ source: str = ""
+ # Install prefix, if known.
+ prefix: Path | None = None
+ # apxs of the build, if known: builds the test modules.
+ apxs: Path | None = None
+ # The install httpd.conf the LoadModule lines were inherited from.
+ inherited_conf: Path | None = None
+ # Where the build's shared modules are (apxs LIBEXECDIR).
+ libexecdir: Path | None = None
+ # Extra -D defines given with --defines.
+ extra_defines: list[str] = field(default_factory=list)
+
+ @property
+ def version_str(self) -> str:
+ return ".".join(str(p) for p in self.version)
+
+ def has_module(self, name: str) -> bool:
+ """True if module ``name`` is available.
+
+ Accepts every form the Perl tests use: a bare name (``status``), a
+ ``mod_``-prefixed name (``mod_status``), or any of those with a ``.c``/
+ ``.so`` suffix. Modules are stored as source names (``mod_status.c``),
+ so a bare name is also tried with the ``mod_`` prefix -- matching
+ Apache::Test's need_module/have_module, which accept ``need_module 'status'``.
+ """
+ stem = name
+ for suffix in (".c", ".so"):
+ if stem.endswith(suffix):
+ stem = stem[: -len(suffix)]
+ break
+ candidates = {f"{stem}.c"}
+ if not stem.startswith("mod_"):
+ candidates.add(f"mod_{stem}.c")
+ return bool(candidates & self.modules)
+
+ def version_at_least(self, major: int, minor: int, patch: int = 0) -> bool:
+ return self.version >= (major, minor, patch)
+
+
+def _run(cmd: list[str]) -> str:
+ """Run a probe command, returning combined stdout (httpd prints to stdout)."""
+ proc = subprocess.run( # noqa: S603 - trusted, caller-provided httpd path
+ cmd,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ # httpd -V can exit non-zero if it wants a config; we only need the banner.
+ return proc.stdout + proc.stderr
+
+
+def probe_version(httpd: Path) -> tuple[int, int, int]:
+ out = _run([str(httpd), "-v"])
+ m = _VERSION_RE.search(out)
+ if not m:
+ raise RuntimeError(f"could not parse version from `{httpd} -v`:\n{out}")
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
+
+
+def probe_defines_and_mpm(httpd: Path) -> tuple[dict[str, str | bool], str]:
+ """Parse ``httpd -V`` for ``-D`` defines, server MPM, and module magic info."""
+ out = _run([str(httpd), "-V"])
+ defines: dict[str, str | bool] = {}
+ mpm = ""
+ for line in out.splitlines():
+ m = _DEFINE_RE.match(line)
+ if m:
+ key, val = m.group(1), m.group(2)
+ defines[key] = val.strip('"') if val else True
+ continue
+ # "Server MPM: event" (2.4+/2.5)
+ sm = re.match(r"\s*Server MPM:\s*(.+)", line, re.IGNORECASE)
+ if sm:
+ mpm = sm.group(1).strip().lower()
+ if not mpm:
+ # 2.0-era fallback: derive from APACHE_MPM_DIR=server/mpm/
+ mpm_dir = defines.get("APACHE_MPM_DIR")
+ if isinstance(mpm_dir, str):
+ mpm = Path(mpm_dir).name.lower()
+ return defines, mpm
+
+
+def probe_static_modules(httpd: Path) -> set[str]:
+ """Modules compiled statically into the binary (``httpd -l``)."""
+ out = _run([str(httpd), "-l"])
+ mods: set[str] = set()
+ for line in out.splitlines():
+ m = _STATIC_MOD_RE.match(line)
+ if m:
+ mods.add(m.group(1))
+ return mods
+
+
+def parse_loadmodules(conf: Path, server_root: Path,
+ _seen: set | None = None) -> list[LoadDirective]:
+ """Parse ``LoadModule`` lines (active and commented) from an install httpd.conf.
+
+ The suite is built with ``--enable-load-all-modules`` upstream; locally the
+ stock install conf lists most modules commented out. We capture both so the
+ generated config can re-emit them and so module-presence checks succeed.
+ Relative ``.so`` paths are resolved against ``server_root`` (the install
+ prefix, ``apxs -q PREFIX``), and so are the files of active ``Include`` /
+ ``IncludeOptional`` lines, which are followed (distributions load their
+ modules from conf.d/ or mods-enabled/). Sections are not evaluated: a
+ LoadModule in an ```` counts as active.
+ """
+ seen = set() if _seen is None else _seen
+ directives: list[LoadDirective] = []
+ if not conf.is_file() or conf.resolve() in seen:
+ return directives
+ seen.add(conf.resolve())
+ for raw in conf.read_text(errors="replace").splitlines():
+ inc = _INCLUDE_RE.match(raw)
+ if inc:
+ pattern = Path(inc.group(1) or inc.group(2))
+ if not pattern.is_absolute():
+ pattern = server_root / pattern
+ if pattern.is_dir():
+ pattern = pattern / "*"
+ for path in sorted(pattern.parent.glob(pattern.name)):
+ directives.extend(parse_loadmodules(path, server_root, seen))
+ continue
+ m = _LOADMODULE_RE.match(raw)
+ if not m:
+ continue
+ commented, symbol, so = m.group(1), m.group(2), m.group(3) or m.group(4)
+ so_path = Path(so)
+ if not so_path.is_absolute():
+ so_path = server_root / so_path
+ directives.append(
+ LoadDirective(symbol=symbol, so=str(so_path), active=(commented == ""))
+ )
+ return directives
+
+
+# TAKE1 directives inherited from the install conf (wanted_config TAKE1).
+# ServerRoot is intentionally NOT applied (spec_apply maps it to a no-op so the
+# test serverroot wins); we still record it for completeness but config.py
+# ignores it.
+_INHERIT_TAKE1 = ("ServerAdmin", "TypesConfig", "DocumentRoot", "ServerRoot")
+_TAKE1_RE = re.compile(
+ r"^\s*(" + "|".join(_INHERIT_TAKE1) + r")\s+(.+?)\s*$"
+)
+
+
+def parse_inherited_take1(conf: Path) -> dict[str, str]:
+ """Parse last-wins TAKE1 directives from an install conf (wanted_config)."""
+ found: dict[str, str] = {}
+ if not conf.is_file():
+ return found
+ for raw in conf.read_text(errors="replace").splitlines():
+ if raw.lstrip().startswith("#"):
+ continue
+ m = _TAKE1_RE.match(raw)
+ if m:
+ found[m.group(1)] = m.group(2).strip().strip('"')
+ return found
+
+
+def probe(
+ httpd: Path,
+ inherited_conf: Path | None = None,
+ server_root: Path | None = None,
+) -> HttpdInfo:
+ """Full probe of an httpd binary and (optionally) its install config.
+
+ :param httpd: path to the httpd executable.
+ :param inherited_conf: install ``httpd.conf`` to scan for LoadModule lines.
+ Typically ``/conf/httpd.conf`` derived from ``apxs -q SYSCONFDIR``.
+ :param server_root: install prefix used to resolve relative ``.so`` paths
+ (``apxs -q PREFIX``). Defaults to the parent of ``inherited_conf``'s dir.
+ """
+ version = probe_version(httpd)
+ defines, mpm = probe_defines_and_mpm(httpd)
+ static_modules = probe_static_modules(httpd)
+ modules = set(static_modules)
+ load_directives: list[LoadDirective] = []
+ inherited: dict[str, str] = {}
+ if inherited_conf is not None:
+ root = server_root or inherited_conf.parent.parent
+ inherited = parse_inherited_take1(inherited_conf)
+ all_directives = parse_loadmodules(inherited_conf, root)
+ # Inherit only ACTIVE LoadModule lines, matching Apache::Test's
+ # inherit_load_module (which parses the live config, not commented
+ # lines). A build intended for the suite activates all modules
+ # (--enable-load-all-modules); locally the install conf is activated to
+ # match. Commented lines are ignored entirely.
+ # (once each: httpd itself warns about loading a module twice)
+ symbols: set[str] = set()
+ load_directives = [d for d in all_directives if d.active
+ and not (d.symbol in symbols or symbols.add(d.symbol))]
+ for d in load_directives:
+ if Path(d.so).exists():
+ modules.add(d.cname)
+ return HttpdInfo(
+ httpd=httpd,
+ version=version,
+ mpm=mpm,
+ defines=defines,
+ modules=modules,
+ load_directives=load_directives,
+ inherited=inherited,
+ static_modules=static_modules,
+ inherited_conf=inherited_conf,
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Finding the build under test
+# --------------------------------------------------------------------------- #
+
+
+class NoServerError(Exception):
+ """Raised when no httpd could be located from the options or config.ini.
+
+ A plain exception, not pytest.fail, so the collection time callers can
+ catch it without turning into an INTERNALERROR.
+ """
+
+
+class TargetError(NoServerError):
+ """An httpd was named with --apxs or --httpd, and it is not usable.
+
+ Never answered with another build: testing something other than what
+ was asked for is worse than not testing.
+ """
+
+
+def apxs_query(apxs: Path, var: str) -> str:
+ proc = subprocess.run( # noqa: S603 - trusted path
+ ["perl", str(apxs), "-q", var], capture_output=True, text=True, check=True
+ )
+ return proc.stdout.strip()
+
+
+_apxs_cache: dict[str, dict[str, str]] = {}
+
+
+def _apxs_vars(apxs: Path) -> dict[str, str]:
+ """The apxs variables that locate a build; TargetError if apxs is unusable."""
+ if str(apxs) in _apxs_cache:
+ return _apxs_cache[str(apxs)]
+ if not apxs.is_file():
+ raise TargetError(f"apxs {apxs}: no such file")
+ found = {}
+ for var in ("SBINDIR", "SYSCONFDIR", "PREFIX", "LIBEXECDIR"):
+ try:
+ found[var] = apxs_query(apxs, var)
+ except (OSError, subprocess.CalledProcessError) as exc:
+ detail = getattr(exc, "stderr", None) or exc
+ raise TargetError(f"apxs {apxs}: 'apxs -q {var}' failed: {detail}") from exc
+ _apxs_cache[str(apxs)] = found
+ return found
+
+
+def resolve_paths(
+ config,
+) -> tuple[Path, Path | None, Path | None, Path | None, list[str]]:
+ """Resolve (httpd, apxs, inherited_conf, install_prefix, defines) from options.
+
+ --httpd, --conf and --prefix win over what --apxs reports. Raises
+ :class:`NoServerError` if neither --httpd nor --apxs is given, and
+ :class:`TargetError` if what was given is not usable.
+ """
+ apxs_opt = config.getoption("--apxs")
+ httpd_opt = config.getoption("--httpd")
+ defines = [d for d in config.getoption("--defines").split() if d]
+
+ apxs = Path(apxs_opt) if apxs_opt else None
+ inherited_conf: Path | None = None
+ install_prefix: Path | None = None
+ if apxs is not None:
+ found = _apxs_vars(apxs)
+ install_prefix = Path(found["PREFIX"])
+ inherited_conf = Path(found["SYSCONFDIR"]) / "httpd.conf"
+ if httpd_opt is None:
+ httpd_opt = str(Path(found["SBINDIR"]) / "httpd")
+
+ conf_opt = config.getoption("--conf")
+ prefix_opt = config.getoption("--prefix")
+ if conf_opt is not None:
+ inherited_conf = Path(conf_opt)
+ if prefix_opt is not None:
+ install_prefix = Path(prefix_opt)
+ elif inherited_conf is not None and install_prefix is None:
+ install_prefix = inherited_conf.parent.parent
+
+ if httpd_opt is None:
+ raise NoServerError("must pass --httpd or --apxs")
+ if not Path(httpd_opt).is_file():
+ given = "--httpd" if config.getoption("--httpd") else f"the httpd of apxs {apxs}"
+ raise TargetError(f"{given} {httpd_opt}: no such file")
+ if inherited_conf is not None and not inherited_conf.is_file():
+ given = "--conf" if conf_opt is not None else f"the httpd.conf of apxs {apxs}"
+ raise TargetError(f"{given} {inherited_conf}: no such file")
+ return Path(httpd_opt), apxs, inherited_conf, install_prefix, defines
+
+
+def config_ini_path() -> Path:
+ """The autoconf generated config.ini of the modules/ suite."""
+ return Path(os.getenv("PYHTTPD_CONFIG", Path(__file__).resolve().parent / "config.ini"))
+
+
+def _probe_target(httpd: Path, apxs: Path | None, inherited_conf: Path | None,
+ prefix: Path | None, libexecdir: Path | None,
+ defines: list[str], source: str) -> HttpdInfo:
+ try:
+ info = probe(httpd, inherited_conf, prefix)
+ except (OSError, RuntimeError) as exc:
+ raise TargetError(f"{httpd}: {exc}") from exc
+ info.source = source
+ info.prefix = prefix
+ info.apxs = apxs
+ info.libexecdir = libexecdir
+ info.extra_defines = defines
+ return info
+
+
+def _probe_options(config) -> HttpdInfo:
+ """The build named by --apxs / --httpd (+ --conf, --prefix)."""
+ httpd, apxs, inherited_conf, prefix, defines = resolve_paths(config)
+ if apxs is not None:
+ libexecdir = Path(_apxs_vars(apxs)["LIBEXECDIR"])
+ else:
+ # /bin/httpd and /modules, the layout of Windows too
+ libexecdir = (prefix if prefix is not None
+ else httpd.resolve().parent.parent) / "modules"
+ return _probe_target(httpd, apxs, inherited_conf, prefix, libexecdir, defines,
+ "apxs" if apxs is not None else "httpd")
+
+
+def _probe_config_ini(path: Path, defines: list[str]) -> HttpdInfo:
+ """Probe the build described by a generated config.ini.
+
+ Through its apxs when it has one, exactly as if that had been passed with
+ --apxs, so both suites see the build the way they do in a
+ ``make check-all-pytest`` run.
+ """
+ ini = configparser.ConfigParser(interpolation=configparser.ExtendedInterpolation())
+ ini.read(path)
+ apxs: Path | None = Path(ini.get("global", "apxs", fallback="") or "")
+ if apxs is not None and apxs.name and apxs.is_file():
+ found = _apxs_vars(apxs)
+ httpd = Path(found["SBINDIR"]) / "httpd"
+ conf = Path(found["SYSCONFDIR"]) / "httpd.conf"
+ prefix = Path(found["PREFIX"])
+ libexecdir = Path(found["LIBEXECDIR"])
+ else:
+ apxs = None
+ name = ini.get("httpd", "name", fallback="httpd")
+ if sys.platform == "win32":
+ httpd = Path(ini.get("global", "bindir")) / f"{name}.exe"
+ else:
+ httpd = Path(ini.get("global", "sbindir",
+ fallback=ini.get("global", "bindir"))) / name
+ prefix = Path(ini.get("global", "prefix"))
+ conf = prefix / "conf" / "httpd.conf"
+ libexecdir = Path(ini.get("global", "libexecdir", fallback=str(prefix / "modules")))
+ if not httpd.is_file():
+ raise NoServerError(f"{path}: no httpd binary at {httpd}")
+ return _probe_target(httpd, apxs, conf if conf.is_file() else None, prefix,
+ libexecdir, defines, "config.ini")
+
+
+_BUILD_KEY = None
+# The build of the session, for code without a pytest config to ask: an
+# HttpdTestEnv() created at import time to evaluate a skipif condition.
+_current: HttpdInfo | None = None
+_why_none = ""
+
+
+def current() -> HttpdInfo | None:
+ """The build resolve_build() found for this session; None before, or without."""
+ return _current
+
+
+def why_no_build() -> str:
+ """Why resolve_build() found no httpd, for the failure of a fixture needing one."""
+ return _why_none or "no httpd: pass --apxs or --httpd, or configure the tree"
+
+
+def resolve_build(config) -> HttpdInfo | None:
+ """Probe the httpd under test, once per session; None if there is none.
+
+ Precedence: ``--apxs``, then ``--httpd`` (+ ``--conf``), then the
+ config.ini generated by the build. An httpd named on the command line
+ that is not usable raises :class:`TargetError`; it is never replaced by
+ the build of config.ini. None (nothing named, no usable config.ini) is
+ not an error here: callers that need a server fail with why_no_build().
+
+ pyhttpd.plugin calls this in pytest_configure, so that everything after
+ it - the HttpdTestEnv of modules/, collection time gating, the classic
+ suite's fixture, the report header - tests and gates the same build.
+ """
+ global _BUILD_KEY, _current, _why_none # noqa: PLW0603
+ import pytest
+
+ if _BUILD_KEY is None:
+ _BUILD_KEY = pytest.StashKey[HttpdInfo | None]()
+ if _BUILD_KEY in config.stash:
+ return config.stash[_BUILD_KEY]
+
+ info: HttpdInfo | None = None
+ _why_none = ""
+ if config.getoption("--apxs") or config.getoption("--httpd"):
+ info = _probe_options(config)
+ else:
+ ini = config_ini_path()
+ defines = [d for d in config.getoption("--defines").split() if d]
+ if ini.is_file():
+ try:
+ info = _probe_config_ini(ini, defines)
+ except NoServerError as exc:
+ _why_none = f"no httpd: {exc}"
+ else:
+ _why_none = f"no httpd: pass --apxs or --httpd, or configure the tree ({ini})"
+ config.stash[_BUILD_KEY] = info
+ _current = info
+ return info
diff -Nru apache2-2.4.69/test/pyhttpd/classic_errorlog.py apache2-2.4.71/test/pyhttpd/classic_errorlog.py
--- apache2-2.4.69/test/pyhttpd/classic_errorlog.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/classic_errorlog.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,243 @@
+"""Error-log check for the classic (pytest_suite) tests.
+
+Like the modules/ suite, an unexpected error or warning in the httpd error log
+fails the test that was running (as a teardown error), and a crash or a
+sanitizer report always does. The classic tests share one long-running server
+and many of them provoke errors on purpose, so what counts as expected is
+declared here, not in the test files, which stay identical to upstream's
+httpd-tests:
+
+* ``BACKGROUND_*`` -- logged by timers of the shared server (proxy health
+ checks of a dead backend, mod_proxy_beacon announcements), whatever test
+ happens to be running. Ignored everywhere.
+* ``PER_FILE`` -- logged because a test file exercises that error path.
+ Ignored in that file only, by AH number, or by regex for lines without one.
+
+The log is read after each test and again when the file's last test is done.
+A line logged just after a test returns is attributed to the next one, which
+is why the lists are per file, not per test. What the server logs while it
+starts, and while it stops after the last test (a child crashing on the way
+out shows nowhere else), is checked by check_startup() and check_shutdown(),
+which the classic suite's server fixture calls.
+
+``--classic-elog-report=FILE`` measures instead of checking: no ignores, no
+failures, one JSON line per test with what it was attributed, plus a summary.
+That is how the tables below were built.
+"""
+import json
+import os
+import re
+from collections import Counter
+
+import pytest
+
+from .log import HttpdErrorLog
+
+# AH02683/AH02704: mod_ssl_ct's SCT maintenance daemon, every 30 seconds. Not
+# built on macOS, found by a Linux run.
+# AH01871: mod_optional_fn_export logs every request. At APLOG_DEBUG on trunk
+# (git mirror c649960592) but at APLOG_ERR on a branch without that change,
+# i.e. 2.4.x for now.
+BACKGROUND_LOGNOS = ["AH10577", "AH10164", "AH10569", "AH02683", "AH02704",
+ "AH01871"]
+BACKGROUND_MATCHES = [r".*\bHCOH: attempt to connect to .* failed"]
+
+_SSL_NOEXT =r".*SSL Library Error: error:0A000117:SSL routines::extension not received"
+
+# file name -> (AH numbers, regexes for lines without one)
+PER_FILE = {
+ "test_CVE_2005_2700.py": (["AH10158"], [_SSL_NOEXT]),
+ "test_CVE_2008_2364.py": (["AH00898"], []),
+ "test_CVE_2009_1195.py": (["AH01228", "AH01374"], []),
+ "test_CVE_2019_0215.py": (["AH10158"], [_SSL_NOEXT]),
+ "test_aaa.py": (["AH01617", "AH01631", "AH01794", "AH01807"], []),
+ "test_acceptpathinfo.py": (["AH01266"], []),
+ "test_access.py": (["AH01797"], []),
+ "test_actions.py": (["AH01599"], []),
+ "test_alias.py": (["AH00674", "AH01264"], []),
+ "test_allowmethods.py": (["AH01623"], []),
+ "test_authz_core.py": (["AH01630", "AH01631"], []),
+ "test_basicauth.py": (["AH01618", "AH10096", "AH10158"], [_SSL_NOEXT]),
+ "test_cgi.py": (["AH01215", "AH01266", "AH10599"], [
+ r".*End of script output before headers: bogus",
+ r".*malformed header from script 'bogus-sh.sh'"]),
+ # 400: test_dav_mswdv_null_deref on a server without r1939426 (2.4.x)
+ "test_dav.py": (["AH10484", "AH10619"], [
+ r".*\[dav:error\].* \[(400|404|423), #0\]"]),
+ "test_deflate.py": (["AH01387", "AH01392"], []),
+ "test_digest.py": (["AH01786", "AH01794"], []),
+ "test_dir.py": (["AH01276", "AH01599"], []),
+ "test_expr.py": (["AH01630"], []),
+ "test_ext_filter.py": (["AH01270"], []), # LimitRequestBody 6: ENOSPC -> 413
+ "test_fakeauth.py": (["AH10158"], [_SSL_NOEXT]),
+ "test_hostcheck.py": (["AH10157"], []),
+ "test_http_strict.py": (["AH00135", "AH01215", "AH02428", "AH02429", "AH02430"], [
+ r".*malformed header from script 'send_hdr.pl'"]),
+ "test_include.py": ([
+ "AH01228", "AH01329", "AH01330", "AH01337", "AH01346", "AH01352",
+ "AH01369", "AH01371", "AH01372", "AH01374", "AH01412"], [
+ r".*\(20023\)The given path was above the root path:.*unable to include file",
+ r".*\[include:error\].* unable to include \""]),
+ "test_ldap.py": (["AH01617", "AH01631"], []),
+ "test_mergeslashes.py": (["AH01630"], []),
+ "test_negotiation.py": (["AH00690"], []),
+ "test_ocsp.py": (["AH03239", "AH10373"], []),
+ "test_pha.py": (["AH02018", "AH10158", "AH10228"], [_SSL_NOEXT]),
+ "test_proxy.py": (["AH02229"], []),
+ "test_proxy_balancer.py": ([
+ "AH00957", "AH00959", "AH01114", "AH01170", "AH01171", "AH10187"], []),
+ "test_proxy_beacon.py": (["AH01170"], []),
+ "test_proxy_beacon_input.py": (["AH10187"], []),
+ "test_proxy_hcheck.py": (["AH10187"], []),
+ "test_remoteip.py": (["AH03499"], []),
+ "test_require.py": (["AH02229", "AH02262", "AH10158"], [_SSL_NOEXT]),
+ "test_rewrite.py": (["AH10410", "AH10411", "AH10508"], [
+ r".*RewriteRule '.*' and URI '.*' exceeded maximum length"]),
+ "test_session.py": (["AH01817"], []),
+ "test_verify.py": (["AH02262", "AH10158"], [_SSL_NOEXT]),
+}
+
+_state = {"log": None, "tests": 0, "with_lines": 0, "counts": Counter(), "samples": {}}
+_RE_LOGNO = re.compile(r'\[(?P[^:\]]+):(?Perror|warn)\].* (?PAH\d+):')
+_RE_LEVEL = re.compile(r'\[(?P[^:\]]+):(?Perror|warn)\]')
+
+
+def pytest_addoption(parser):
+ parser.getgroup("httpd").addoption(
+ "--classic-elog-report", action="store", default=None, metavar="FILE",
+ help="instead of failing on unexpected errors/warnings in the error log "
+ "during classic tests, write what each test was attributed to FILE "
+ "(JSON lines, no ignores applied) and summarize it")
+
+
+def pytest_configure(config):
+ path = config.getoption("--classic-elog-report", default=None)
+ if path:
+ open(path, "w").close()
+
+
+def _key(line: str) -> str:
+ m = _RE_LOGNO.search(line)
+ if m:
+ return f"{m['mod']}:{m['level']} {m['logno']}"
+ m = _RE_LEVEL.search(line)
+ if m:
+ return f"{m['mod']}:{m['level']} (no AH number)"
+ return "other (crash/sanitizer)"
+
+
+def _uses_server(item) -> bool:
+ return "framework" in getattr(item, "fixturenames", ())
+
+
+def _log_for(request) -> HttpdErrorLog:
+ if _state["log"] is None:
+ config = request.getfixturevalue("config")
+ # starts at the end of the log: server start-up is not a test's doing
+ _state["log"] = HttpdErrorLog(f"{config.vars['t_logs']}/error_log")
+ return _state["log"]
+
+
+def _fail_on(errors, warnings, what):
+ if errors or warnings:
+ lines = "\n".join(l.rstrip() for l in (errors + warnings)[:20])
+ more = len(errors) + len(warnings) - 20
+ pytest.fail(f"apache logged {len(errors)} errors and {len(warnings)} warnings "
+ f"during {what}:\n{lines}" + (f"\n... and {more} more" if more > 0 else ""),
+ pytrace=False)
+
+
+@pytest.fixture(autouse=True, scope="module")
+def _classic_elog_module(request):
+ """Sets the module's ignores, and checks what is left when it is done."""
+ module = request.module
+ if not any(_uses_server(i) and getattr(i, "module", None) is module
+ for i in request.session.items):
+ yield
+ return
+ report = request.config.getoption("--classic-elog-report", default=None)
+ log = _log_for(request)
+ log.clear_ignored_matches()
+ log.clear_ignored_lognos()
+ if not report:
+ nos, matches = PER_FILE.get(os.path.basename(module.__file__), ([], []))
+ log.add_ignored_lognos(BACKGROUND_LOGNOS + nos)
+ log.add_ignored_matches(BACKGROUND_MATCHES + matches)
+ yield
+ if not report:
+ _fail_on(*log.get_missed(), f"the tests of {os.path.basename(module.__file__)}")
+
+
+@pytest.fixture(autouse=True)
+def _classic_elog(request):
+ if not _uses_server(request.node):
+ yield
+ return
+ path = request.config.getoption("--classic-elog-report", default=None)
+ log = _log_for(request)
+ yield
+ errors, warnings = log.get_missed()
+ if not path:
+ _fail_on(errors, warnings, "this test")
+ return
+ _state["tests"] += 1
+ if not (errors or warnings):
+ return
+ _state["with_lines"] += 1
+ for line in errors + warnings:
+ key = _key(line)
+ _state["counts"][key] += 1
+ _state["samples"].setdefault(key, line.rstrip())
+ with open(path, "a") as fd:
+ fd.write(json.dumps({"test": request.node.nodeid,
+ "errors": [l.rstrip() for l in errors],
+ "warnings": [l.rstrip() for l in warnings]}) + "\n")
+
+
+def _check_window(config, log: HttpdErrorLog, what: str, warnings_count=True) -> None:
+ path = config.getoption("--classic-elog-report", default=None)
+ log.clear_ignored_matches()
+ log.clear_ignored_lognos()
+ if not path:
+ log.add_ignored_lognos(BACKGROUND_LOGNOS)
+ log.add_ignored_matches(BACKGROUND_MATCHES)
+ errors, warnings = log.get_missed()
+ if not path:
+ _fail_on(errors, warnings if warnings_count else [], what)
+ elif errors or warnings:
+ with open(path, "a") as fd:
+ fd.write(json.dumps({"test": f"<{what}>",
+ "errors": [l.rstrip() for l in errors],
+ "warnings": [l.rstrip() for l in warnings]}) + "\n")
+
+
+def check_startup(config, error_log: str, since: int) -> None:
+ """Fail on errors (and crashes) the server logged from *since*, its log
+ size before it was started, up to now: its start. Call once it is
+ serving. Not on warnings: the shared t/conf makes httpd warn at every
+ start (a CRL without SSLCARevocationCheck, a revoked proxy client
+ certificate, SSLRandomSeed with OpenSSL 3, ...), and which ones depends
+ on the httpd and the TLS library."""
+ log = HttpdErrorLog(error_log)
+ log.start_at(since)
+ _check_window(config, log, "the start of the server", warnings_count=False)
+
+
+def check_shutdown(config) -> None:
+ """Fail on what the server logged after the last test was checked: its
+ shutdown. Call once it has stopped."""
+ if _state["log"] is not None:
+ _check_window(config, _state["log"], "the shutdown of the server")
+
+
+def pytest_terminal_summary(terminalreporter, config):
+ if not config.getoption("--classic-elog-report", default=None) or not _state["tests"]:
+ return
+ tr = terminalreporter
+ total = sum(_state["counts"].values())
+ tr.section("classic error-log report (report only)")
+ tr.write_line(f"{_state['with_lines']} of {_state['tests']} classic tests were "
+ f"attributed {total} error/warning lines")
+ for key, n in _state["counts"].most_common(25):
+ tr.write_line(f"{n:6d} {key}")
+ tr.write_line(f" e.g. {_state['samples'][key][:200]}")
diff -Nru apache2-2.4.69/test/pyhttpd/cmodules.py apache2-2.4.71/test/pyhttpd/cmodules.py
--- apache2-2.4.69/test/pyhttpd/cmodules.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/cmodules.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,154 @@
+"""Build the C modules the tests load, with apxs.
+
+Both suites carry small modules that are not part of httpd: mod_aptest,
+mod_h1test and mod_h2test of the modules/ suite, and the c-modules/ of
+pytest_suite. They are built in place, ``mod_x.c`` in a directory of its own
+giving ``.libs/mod_x.so``, and built once: next to the result goes a stamp
+recording what the build depended on, and a module is rebuilt when
+
+* the .so is missing,
+* a source or header of the module changed,
+* the httpd it is built against changed: another apxs, or the same apxs after
+ the httpd it belongs to was rebuilt with another module magic number or
+ version (a module built for 2.4 is refused by 2.5 with "expected signature
+ 41503234 but saw 41503235"), or
+* the defines or include directories differ.
+
+``--clean-modules`` removes the results, see clean_module_dir().
+"""
+
+import hashlib
+import json
+import logging
+import os
+import shutil
+import subprocess
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Sequence, Union
+
+log = logging.getLogger(__name__)
+
+LIB_SUBDIR = ".libs" # where apxs/libtool leave the result
+
+_include_fingerprints: Dict[str, str] = {}
+
+
+def _sha256_files(paths: Iterable[Path]) -> str:
+ h = hashlib.sha256()
+ for p in sorted(paths):
+ h.update(p.name.encode())
+ try:
+ h.update(p.read_bytes())
+ except OSError:
+ h.update(b"")
+ return h.hexdigest()
+
+
+def _apxs_command(apxs: Union[str, Path]) -> List[str]:
+ # apxs is a script; run it directly when it can be, as its shebang names
+ # the perl it was built for, else through the perl on the PATH.
+ apxs = str(apxs)
+ return [apxs] if os.access(apxs, os.X_OK) else ["perl", apxs]
+
+
+def apxs_query(apxs: Union[str, Path], var: str) -> str:
+ proc = subprocess.run([*_apxs_command(apxs), "-q", var],
+ capture_output=True, text=True, check=True)
+ return proc.stdout.strip()
+
+
+def httpd_fingerprint(apxs: Union[str, Path]) -> str:
+ """Identifies the httpd that modules built with *apxs* are made for."""
+ key = os.path.realpath(str(apxs))
+ if key not in _include_fingerprints:
+ try:
+ include_dir = Path(apxs_query(apxs, "INCLUDEDIR"))
+ sha = _sha256_files([include_dir / "ap_mmn.h",
+ include_dir / "ap_release.h"])
+ except (OSError, subprocess.CalledProcessError):
+ sha = "unknown"
+ _include_fingerprints[key] = f"{key}:{sha}"
+ return _include_fingerprints[key]
+
+
+def module_so(src_dir: Union[str, Path], name: str) -> Path:
+ return Path(src_dir) / LIB_SUBDIR / f"mod_{name}.so"
+
+
+def _stamp_data(src_dir: Path, apxs, defines: Sequence[str],
+ include_dirs: Sequence[Path], extra_inputs: Sequence[Path]) -> dict:
+ sources = [p for p in src_dir.iterdir()
+ if p.is_file() and p.suffix in (".c", ".h")]
+ return {
+ "httpd": httpd_fingerprint(apxs),
+ "sources": _sha256_files(sources),
+ "extra": _sha256_files(extra_inputs),
+ "defines": list(defines),
+ "includes": [str(p) for p in include_dirs],
+ }
+
+
+def ensure_built(src_dir: Union[str, Path], name: str, apxs: Union[str, Path], *,
+ defines: Sequence[str] = (),
+ include_dirs: Sequence[Union[str, Path]] = (),
+ extra_inputs: Sequence[Union[str, Path]] = (),
+ force: bool = False) -> Path:
+ """Build ``mod_.c`` in *src_dir* unless the result is up to date.
+
+ :param defines: ``-D`` flags for the compile
+ :param include_dirs: ``-I`` directories
+ :param extra_inputs: further files the result depends on, e.g. a generated
+ header in one of *include_dirs*
+ :returns: the path of the .so
+ :raises RuntimeError: if apxs fails, with its output
+ """
+ src_dir = Path(src_dir)
+ so = module_so(src_dir, name)
+ stamp_file = so.with_suffix(".stamp")
+ include_dirs = [Path(p) for p in include_dirs]
+ stamp = _stamp_data(src_dir, apxs, defines, include_dirs,
+ [Path(p) for p in extra_inputs])
+ if not force and so.is_file() and stamp_file.is_file():
+ try:
+ if json.loads(stamp_file.read_text()) == stamp:
+ return so
+ except (OSError, ValueError):
+ pass
+ cmd = _apxs_command(apxs)
+ for d in defines:
+ cmd += ["-D", d]
+ for d in include_dirs:
+ cmd += ["-I", str(d)]
+ cmd += ["-c", f"mod_{name}.c"]
+ log.debug("building mod_%s: %s", name, cmd)
+ stamp_file.unlink(missing_ok=True)
+ proc = subprocess.run(cmd, cwd=src_dir, capture_output=True, text=True,
+ check=False)
+ if proc.returncode != 0 or not so.is_file():
+ raise RuntimeError(
+ f"apxs failed building mod_{name} (exit {proc.returncode}):\n"
+ f"$ {' '.join(cmd)}\n{proc.stdout}\n{proc.stderr}")
+ stamp_file.write_text(json.dumps(stamp, indent=1))
+ return so
+
+
+def clean_module_dir(src_dir: Union[str, Path]) -> None:
+ """Remove the build results of the module in *src_dir*, like ``make clean``:
+ the ``.libs`` directory and libtool's intermediates."""
+ src_dir = Path(src_dir)
+ libs = src_dir / LIB_SUBDIR
+ if libs.is_dir():
+ shutil.rmtree(libs)
+ for pattern in ("*.o", "*.lo", "*.slo", "*.la"):
+ for f in src_dir.glob(pattern):
+ f.unlink(missing_ok=True)
+
+
+def clean_test_modules(test_dir: Union[str, Path]) -> None:
+ """Clean every module of the modules/ suite under test/: mod_aptest of the
+ framework, and the mod_* directories next to the tests."""
+ test_dir = Path(test_dir)
+ for pattern in ("pyhttpd/mod_*", "modules/*/mod_*"):
+ for d in test_dir.glob(pattern):
+ if d.is_dir() and any(d.glob("mod_*.c")):
+ clean_module_dir(d)
diff -Nru apache2-2.4.69/test/pyhttpd/conf/httpd.conf.template apache2-2.4.71/test/pyhttpd/conf/httpd.conf.template
--- apache2-2.4.69/test/pyhttpd/conf/httpd.conf.template 2023-06-01 14:01:51.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/conf/httpd.conf.template 2026-11-09 03:07:05.000000000 +0000
@@ -1,6 +1,9 @@
ServerName localhost
ServerRoot "${server_dir}"
+DefaultRuntimeDir logs
+PidFile httpd.pid
+
Include "conf/modules.conf"
DocumentRoot "${server_dir}/htdocs"
@@ -57,4 +60,15 @@
Options +ExecCGI
+
+
+ ScriptInterpreterSource Script
+
+
+
+
+
+ ScriptInterpreterSource Script
+
+
diff -Nru apache2-2.4.69/test/pyhttpd/conf/mime.types apache2-2.4.71/test/pyhttpd/conf/mime.types
--- apache2-2.4.69/test/pyhttpd/conf/mime.types 2025-12-18 16:07:35.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/conf/mime.types 2026-11-09 03:07:05.000000000 +0000
@@ -1309,6 +1309,7 @@
chemical/x-csml csml
# chemical/x-pdb
chemical/x-xyz xyz
+image/avif avif
image/bmp bmp
image/cgm cgm
# image/example
diff -Nru apache2-2.4.69/test/pyhttpd/conf/stop.conf.template apache2-2.4.71/test/pyhttpd/conf/stop.conf.template
--- apache2-2.4.69/test/pyhttpd/conf/stop.conf.template 2021-12-14 11:26:52.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/conf/stop.conf.template 2026-11-09 03:07:05.000000000 +0000
@@ -5,6 +5,11 @@
ServerName localhost
ServerRoot "${server_dir}"
+# Must agree with httpd.conf, or the running server cannot be found:
+# the built-in default varies between httpd versions.
+DefaultRuntimeDir logs
+PidFile httpd.pid
+
Include "conf/modules.conf"
DocumentRoot "${server_dir}/htdocs"
diff -Nru apache2-2.4.69/test/pyhttpd/depends.py apache2-2.4.71/test/pyhttpd/depends.py
--- apache2-2.4.69/test/pyhttpd/depends.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/depends.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,255 @@
+"""Optional-dependency gating for the httpd integration test suite.
+
+Many test cases need something that is not part of httpd itself: the 'curl'
+or 'nghttp' clients, the 'websockets' python package, an ACME test server,
+a shared module that was not built, ... By default such a test is skipped
+when its dependency is missing, which is the right thing for a developer
+working on an unrelated part of the tree.
+
+In a CI environment that is *supposed* to provide those dependencies, a skip
+instead hides a broken configuration: the run stays green while the coverage
+silently disappears. Strict mode exists for those environments. Enable it
+with --strict-optional or PYHTTPD_STRICT_OPTIONAL=1 and a missing dependency
+becomes a failure naming what was expected and what was found instead.
+
+Only *missing dependencies* are affected. A test skipped because it does not
+apply here - wrong mpm, httpd too old, STRESS_TEST not set - keeps skipping
+in strict mode. Those keep using a plain pytest.mark.skipif, and when a test
+carries both kinds of gate the "does not apply" one wins.
+
+Usage:
+
+ from pyhttpd.depends import needs_dependency
+
+ @needs_dependency("a2md", MDTestEnv.has_a2md(), reason="no a2md available")
+ class TestStore:
+ ...
+
+Note the polarity: skipif takes "skip when True", needs_dependency takes
+"available when True".
+
+This module is also a pytest plugin, loaded from test/conftest.py. It
+deliberately imports nothing from pyhttpd.env so that it can be loaded on
+its own with '-p pyhttpd.depends' by a test that has no httpd installed.
+"""
+
+import os
+
+import pytest
+
+STRICT_ENV_VAR = "PYHTTPD_STRICT_OPTIONAL"
+MARKER_NAME = "needs_dependency"
+
+_FALSEY = ("", "0", "false", "no", "off")
+
+_HOWTO = (f"To go back to skipping missing optional dependencies, drop "
+ f"--strict-optional and unset {STRICT_ENV_VAR}.")
+
+# set at collection time on items that must hard-fail in strict mode
+strict_fail_key = pytest.StashKey[str]()
+
+
+def strict_optional_enabled(config=None) -> bool:
+ """Whether a missing optional dependency must fail instead of skip.
+
+ *config* may be a pytest Config, or None for an HttpdTestEnv built
+ outside a pytest run. The environment variable wins when set to anything
+ truthy, so that CI can enable strict mode without a command line.
+ """
+ if os.environ.get(STRICT_ENV_VAR, "").strip().lower() not in _FALSEY:
+ return True
+ if config is None:
+ return False
+ # passing a default keeps this quiet when the plugin is not registered
+ return bool(config.getoption("strict_optional", False))
+
+
+def strict_failure_message(dep, expected=None, detected=None, where=None) -> str:
+ """Format a strict-optional failure. The one place that does so.
+
+ The dependency name carries what was expected ("h2load >= 1.41.0"), so
+ *expected* - the reason the test would otherwise have skipped - is
+ labelled for what it is. Those reasons are worded as an absence ("no
+ a2md available"), which under an "expected:" label read backwards.
+ """
+ lines = [f"[strict-optional] missing optional dependency: {dep}"]
+ if expected:
+ lines.append(f" reason: {expected}")
+ if detected is not None:
+ lines.append(f" detected: {detected}")
+ if where:
+ lines.append(f" test: {where}")
+ lines.append(f" {_HOWTO}")
+ return "\n".join(lines)
+
+
+def needs_dependency(dep, available, reason=None, detected=None):
+ """Declare that a test, class or module needs optional dependency *dep*.
+
+ :param dep: short name for reports, e.g. "a2md", "h2load >= 1.41.0"
+ :param available: truthy if the dependency was found. May be a zero-arg
+ callable, evaluated once at collection rather than at import.
+ :param reason: skip reason used in non-strict mode. Keep the wording of
+ the skipif being replaced, so that -rs output does not change.
+ :param detected: what was found instead, e.g. "curl 7.88.1". Only used
+ in the strict-mode message. May also be a zero-arg callable.
+ """
+ return pytest.mark.needs_dependency(
+ dep=dep, available=available, reason=reason, detected=detected,
+ )
+
+
+def _resolve(mark):
+ """Return (dep, available, reason, detected) for one marker.
+
+ Positional arguments are tolerated, so a bare
+ pytest.mark.needs_dependency("x", cond) works as well.
+ """
+ if "dep" in mark.kwargs:
+ dep = mark.kwargs["dep"]
+ elif mark.args:
+ dep = mark.args[0]
+ else:
+ dep = ""
+
+ if "available" in mark.kwargs:
+ available = mark.kwargs["available"]
+ elif len(mark.args) > 1:
+ available = mark.args[1]
+ else:
+ # a marker carrying only a name is metadata, it never gates
+ available = True
+ if callable(available):
+ available = available()
+
+ detected = mark.kwargs.get("detected")
+ if callable(detected):
+ detected = detected()
+
+ reason = mark.kwargs.get("reason") or f"missing optional dependency: {dep}"
+ return dep, bool(available), reason, detected
+
+
+def apply_gate(item, missing, strict):
+ """Skip *item*, or arm it to fail, for the dependencies in *missing*.
+
+ *missing* is a list of (dep, reason, detected) in the order the gates
+ were declared. Shared by the needs_dependency marker below and by the
+ whole-suite gates in test/conftest.py, so that a dependency of the
+ framework itself reports exactly like a dependency of a single test,
+ with the same "does not apply here" precedence.
+
+ Calling this twice for one item accumulates, so a test missing two
+ different dependencies is told about both.
+ """
+ if not missing:
+ return
+ skip_reason = "; ".join(r for _, r, _ in missing)
+
+ if not strict:
+ item.add_marker(pytest.mark.skip(reason=skip_reason))
+ return
+
+ gate = _other_gate(item)
+ if gate is _GATE_ACTIVE:
+ # does not apply here, let pytest skip it with its own reason
+ return
+ if gate is _GATE_UNKNOWN:
+ # a string condition we cannot evaluate, keep the old behaviour
+ item.add_marker(pytest.mark.skip(reason=skip_reason))
+ return
+
+ msg = "\n\n".join(
+ strict_failure_message(dep, expected=reason, detected=detected,
+ where=item.nodeid)
+ for dep, reason, detected in missing)
+ previous = item.stash.get(strict_fail_key, None)
+ item.stash[strict_fail_key] = (
+ msg if previous is None else f"{previous}\n\n{msg}")
+
+
+_GATE_ACTIVE = "active"
+_GATE_UNKNOWN = "unknown"
+
+
+def _other_gate(item):
+ """Whether this item is already skipped by something else.
+
+ That is the "does not apply here" case: an httpd version gate, an mpm
+ gate, a STRESS_TEST gate, an unconditional skip. Those keep skipping in
+ strict mode, so they take precedence over a missing dependency.
+
+ Returns _GATE_ACTIVE when a skip is certain, _GATE_UNKNOWN for a string
+ condition only pytest can eval() in the module namespace, and None when
+ nothing else gates the item. This covers the marker forms the suite
+ actually uses, without reaching into pytest's private skipping API.
+
+ Must be called before we inject a skip marker of our own.
+ """
+ for mark in item.iter_markers():
+ if mark.name == "skip":
+ return _GATE_ACTIVE
+ if mark.name != "skipif":
+ continue
+ if "condition" in mark.kwargs:
+ conditions = [mark.kwargs["condition"]]
+ else:
+ conditions = list(mark.args)
+ for cond in conditions:
+ if isinstance(cond, str):
+ return _GATE_UNKNOWN
+ if cond:
+ return _GATE_ACTIVE
+ return None
+
+
+def pytest_addoption(parser):
+ group = parser.getgroup("httpd", "apache httpd test suite")
+ group.addoption(
+ "--strict-optional",
+ dest="strict_optional",
+ action="store_true",
+ default=False,
+ help="Fail instead of skip when an optional dependency declared with "
+ "@needs_dependency() or env.require() is missing. Also enabled "
+ f"by {STRICT_ENV_VAR}=1. Tests that do not apply here (mpm, "
+ "httpd version, STRESS_TEST) keep skipping.",
+ )
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "needs_dependency(dep, available, reason=None, detected=None): "
+ "declare an optional dependency. Missing means skip by default and "
+ "fail under --strict-optional. Use the needs_dependency() helper "
+ "from pyhttpd.depends rather than this marker directly.",
+ )
+
+
+def pytest_collection_modifyitems(config, items):
+ strict = strict_optional_enabled(config)
+ for item in items:
+ missing = [(dep, reason, detected)
+ for dep, ok, reason, detected
+ in (_resolve(m) for m in item.iter_markers(name=MARKER_NAME))
+ if not ok]
+ if not missing:
+ continue
+ # markers arrive closest-first (function, class, module), report
+ # them in the order they are written instead
+ missing.reverse()
+ apply_gate(item, missing, strict)
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_runtest_setup(item):
+ """Fail before any fixture runs, so that a test about to fail for a
+ missing dependency does not first go and start an httpd.
+
+ Only ever armed for items carrying no active skip of their own, see
+ pytest_collection_modifyitems, so this cannot race pytest's own skipping.
+ """
+ msg = item.stash.get(strict_fail_key, None)
+ if msg is not None:
+ pytest.fail(msg, pytrace=False)
diff -Nru apache2-2.4.69/test/pyhttpd/env.py apache2-2.4.71/test/pyhttpd/env.py
--- apache2-2.4.69/test/pyhttpd/env.py 2026-09-28 20:21:42.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/env.py 2026-11-09 03:07:05.000000000 +0000
@@ -3,11 +3,13 @@
import logging
import re
import os
+import signal
import shutil
import stat
import subprocess
import sys
import time
+import platform
from datetime import datetime, timedelta
from string import Template
from typing import List, Optional
@@ -15,8 +17,11 @@
from configparser import ConfigParser, ExtendedInterpolation
from urllib.parse import urlparse
+from .cmodules import ensure_built
from .certs import Credentials, HttpdTestCA, CertificateSpec
+from .instance import HttpdInstance, HttpdStartError
from .log import HttpdErrorLog
+from .ports import PORT_KEYS, resolve_ports
from .nghttp import Nghttp
from .result import ExecResult
@@ -30,6 +35,9 @@
class HttpdTestSetup:
+ # modules which exist only on Unix
+ UNIX_ONLY_MODULES = ("unixd", "suexec")
+
# the modules we want to load
MODULES = [
"log_config",
@@ -82,6 +90,20 @@
def add_optional_modules(self, modules: List[str]):
self._optional_modules.extend(modules)
+ def _cgi_module(self):
+ """The name of a CGI module this build has: mod_cgid for preference,
+ mod_cgi otherwise. There is no mod_cgid on Windows."""
+ if sys.platform != "win32" \
+ and os.path.isfile(os.path.join(self.env.libexec_dir,
+ "mod_cgid.so")):
+ return "cgid"
+ return "cgi"
+
+ def add_cgi_module(self):
+ """Load a CGI module, for a suite which needs CGI but does not care
+ which of mod_cgid and mod_cgi runs it."""
+ self.add_modules([self._cgi_module()])
+
def make(self):
self._make_dirs()
self._make_conf()
@@ -92,6 +114,7 @@
self.add_modules([self.env.ssl_module])
self._make_modules_conf()
self._make_htdocs()
+
self._add_aptest()
self._build_clients()
self.env.clear_curl_headerfiles()
@@ -99,8 +122,11 @@
def _make_dirs(self):
if not os.path.exists(self.env.gen_dir):
os.makedirs(self.env.gen_dir)
- if not os.path.exists(self.env.server_logs_dir):
- os.makedirs(self.env.server_logs_dir)
+ # wipe logs from any previous module run to avoid stale files in the archive
+ if os.path.isdir(self.env.server_logs_dir):
+ shutil.rmtree(self.env.server_logs_dir,
+ ignore_errors=(sys.platform == "win32"))
+ os.makedirs(self.env.server_logs_dir, exist_ok=True)
def _make_conf(self):
# remove anything from another run/test suite
@@ -130,14 +156,7 @@
fd.write(t.substitute(var_map))
def _get_builtin_modules(self) -> set:
- httpd_bin = os.path.join(self.env.bin_dir, 'httpd')
- p = subprocess.run([httpd_bin, '-l'], capture_output=True, text=True)
- builtin = set()
- for line in p.stdout.splitlines():
- match = re.match(r'^\s+mod_(.+)\.c$', line)
- if match:
- builtin.add(match.group(1))
- return builtin
+ return HttpdTestEnv.static_modules()
def _make_modules_conf(self):
loaded = set()
@@ -146,21 +165,25 @@
with open(modules_conf, 'w') as fd:
# issue load directives for all modules we want that are shared
missing_mods = list()
- for m in self._modules:
+ modules = self._modules
+ modules = [self._cgi_module() if m == "cgid" else m
+ for m in modules]
+ for m in modules:
match = re.match(r'^mod_(.+)$', m)
if match:
m = match.group(1)
if m in loaded:
continue
+ if self.env.isWindows and m in HttpdTestSetup.UNIX_ONLY_MODULES:
+ continue
mod_path = os.path.join(self.env.libexec_dir, f"mod_{m}.so")
if m in builtin_modules:
fd.write(f"#built-in: LoadModule {m}_module \"{mod_path}\"\n")
elif os.path.isfile(mod_path):
fd.write(f"LoadModule {m}_module \"{mod_path}\"\n")
- elif m in self.env.dso_modules:
- missing_mods.append(m)
else:
- fd.write(f"#built static: LoadModule {m}_module \"{mod_path}\"\n")
+ # neither compiled in nor built as a DSO
+ missing_mods.append(m)
loaded.add(m)
for m in self._optional_modules:
match = re.match(r'^mod_(.+)$', m)
@@ -187,30 +210,55 @@
srcdocs = os.path.join(d, 'htdocs')
if os.path.isdir(srcdocs):
shutil.copytree(srcdocs, dest_dir, dirs_exist_ok=True)
- # make all contained .py scripts executable
for dirpath, _dirnames, filenames in os.walk(dest_dir):
for fname in filenames:
- if re.match(r'.+\.py', fname):
- py_file = os.path.join(dirpath, fname)
- st = os.stat(py_file)
- os.chmod(py_file, st.st_mode | stat.S_IEXEC)
+ fpath = os.path.join(dirpath, fname)
+ if sys.platform == "win32":
+ # normalize line endings to LF so test assertions match
+ try:
+ data = open(fpath, 'rb').read()
+ if b'\r\n' in data and b'\x00' not in data:
+ open(fpath, 'wb').write(data.replace(b'\r\n', b'\n'))
+ except OSError:
+ pass
+ if re.search(r'\.(py|cgi)$', fname):
+ if sys.platform == "win32":
+ self._fix_shebang(fpath)
+ st = os.stat(fpath)
+ os.chmod(fpath, st.st_mode | stat.S_IEXEC)
+
+ @staticmethod
+ def _fix_shebang(py_file):
+ python_path = sys.executable
+ python_path = python_path.replace("\\", "/")
+ with open(py_file, 'r') as f:
+ lines = f.readlines()
+ if lines and re.match(r'#!\s*/usr/bin/env\s+python', lines[0]):
+ lines[0] = f"#!{python_path}\n"
+ with open(py_file, 'w', newline='\n') as f:
+ f.writelines(lines)
def _add_aptest(self):
- local_dir = os.path.dirname(inspect.getfile(HttpdTestSetup))
- p = subprocess.run([self.env.apxs, '-c', 'mod_aptest.c'],
- capture_output=True,
- cwd=os.path.join(local_dir, 'mod_aptest'))
- rv = p.returncode
- if rv != 0:
- log.error(f"compiling mod_aptest failed: {p.stderr}")
- raise Exception(f"compiling mod_aptest failed: {p.stderr}")
+ module_dir = self.env.test_modules_dir
+ if not self.env.isWindows:
+ local_dir = os.path.dirname(inspect.getfile(HttpdTestSetup))
+ try:
+ ensure_built(os.path.join(local_dir, 'mod_aptest'), 'aptest',
+ self.env.apxs)
+ except RuntimeError as e:
+ log.error(f"compiling mod_aptest failed: {e}")
+ raise Exception(f"compiling mod_aptest failed: {e}")
+
+ module_dir = os.path.join(local_dir, "mod_aptest", ".libs")
modules_conf = os.path.join(self.env.server_dir, 'conf/modules.conf')
with open(modules_conf, 'a') as fd:
- # load our test module which is not installed
- fd.write(f"LoadModule aptest_module \"{local_dir}/mod_aptest/.libs/mod_aptest.so\"\n")
+ # load our test module
+ fd.write(f"LoadModule aptest_module \"{module_dir}/mod_aptest.so\"\n")
def _build_clients(self):
+ if self.env.test_modules_dir:
+ return
clients_dir = os.path.join(
os.path.dirname(os.path.dirname(inspect.getfile(HttpdTestSetup))),
'clients')
@@ -224,30 +272,157 @@
class HttpdTestEnv:
LIBEXEC_DIR = None
+ # entries shared across all modules, archived once in _shared
+ SHARED_SERVER_ENTRIES = {'ca', 'ca1', 'ca2', 'md', 'acme-ca.pem',
+ 'eab.json'}
+
+ @staticmethod
+ def has_tool(name: str) -> bool:
+ return bool(shutil.which(name))
@classmethod
def has_python_package(cls, name: str) -> bool:
- if name in sys.modules:
- # already loaded
- return True
- elif (spec := importlib.util.find_spec(name)) is not None:
- module = importlib.util.module_from_spec(spec)
- sys.modules[name] = module
- spec.loader.exec_module(module)
- return True
- else:
- return False
+ return name in sys.modules or importlib.util.find_spec(name) is not None
@classmethod
def get_ssl_module(cls):
return os.environ['SSL'] if 'SSL' in os.environ else 'mod_ssl'
+ _static_modules = {}
+
+ @classmethod
+ def static_modules(cls) -> set:
+ """The modules compiled into the httpd under test, by the name
+ LoadModule and add_modules() use: "so", "http2", "mpm_event"..."""
+ env = HttpdTestEnv()
+ httpd_bin = os.path.join(env.bin_dir, 'httpd')
+ if httpd_bin not in cls._static_modules:
+ names = set()
+ try:
+ p = subprocess.run([httpd_bin, '-l'], capture_output=True, text=True)
+ out = p.stdout
+ except OSError:
+ out = ""
+ for line in out.splitlines():
+ m = re.match(r'^\s+(mod_|mpm_)?(.+)\.c$', line)
+ if m and m.group(1) == 'mod_':
+ names.add(m.group(2))
+ elif m and m.group(1) == 'mpm_': # mpm_winnt.c
+ names.add(f"mpm_{m.group(2)}")
+ elif m and m.group(2) in ('event', 'worker', 'prefork', 'motorz'):
+ names.add(f"mpm_{m.group(2)}")
+ cls._static_modules[httpd_bin] = names
+ return cls._static_modules[httpd_bin]
+
@classmethod
def has_shared_module(cls, name):
+ """Whether mod_ is there to be loaded: built as a DSO, or
+ compiled into httpd, when there is nothing to load."""
if cls.LIBEXEC_DIR is None:
env = HttpdTestEnv() # will initialized it
path = os.path.join(cls.LIBEXEC_DIR, f"mod_{name}.so")
- return os.path.isfile(path)
+ return os.path.isfile(path) or name in cls.static_modules()
+
+ @classmethod
+ def has_mod_ssl(cls):
+ """Whether mod_ssl is installed.
+
+ Not the same question as get_ssl_module(), which only reports which
+ TLS module $SSL selected for this run. A test that is "only for
+ mod_ssl" is not applicable under SSL=mod_tls and should skip; a test
+ that needs mod_ssl to be installed at all has a real dependency.
+ """
+ return cls.has_shared_module("ssl")
+
+ _directives = None
+
+ def has_directive(self, name: str) -> bool:
+ if HttpdTestEnv._directives is None:
+ httpd_bin = os.path.join(self._bin_dir, 'httpd')
+ try:
+ p = subprocess.run([httpd_bin, '-L'],
+ capture_output=True, text=True)
+ HttpdTestEnv._directives = p.stdout
+ except Exception:
+ HttpdTestEnv._directives = ""
+ return name in HttpdTestEnv._directives
+
+ _nghttp_gate = {}
+
+ @classmethod
+ def needs_nghttp(cls, assets: bool = False):
+ """A marker gating a test, class or module on the nghttp client.
+
+ nghttp is the only client that can drive PUSH, trailers and explicit
+ frame padding, so the tests that use it have no fallback. It is named
+ unconditionally in config.ini, which says nothing about whether it is
+ installed, so the binary has to be run to find out.
+
+ :param assets: also require the '-a' asset-fetching support, which
+ nghttp only has when built against libxml2
+ """
+ from .depends import needs_dependency
+ if assets not in cls._nghttp_gate:
+ env = HttpdTestEnv()
+ ok = env.has_nghttp_get_assets() if assets else env.has_nghttp()
+ if assets:
+ reason = "nghttp not available or misses -a (needs libxml2)"
+ else:
+ reason = "nghttp not available"
+ cls._nghttp_gate[assets] = needs_dependency(
+ "nghttp", ok, reason=reason,
+ detected=f"tried to run {env._nghttp}")
+ return cls._nghttp_gate[assets]
+
+ _h2load_gate = {}
+
+ @classmethod
+ def needs_h2load(cls, minv: str = None, reason: str = None):
+ """A marker gating a test, class or module on the h2load client.
+
+ :param minv: also require at least this h2load version. 1.41.0 is
+ the one that matters here, it brought --connect-to.
+ :param reason: skip reason, when the default wording is too vague
+ about what the version is needed for.
+ """
+ from .depends import needs_dependency
+ key = (minv, reason)
+ if key not in cls._h2load_gate:
+ env = HttpdTestEnv()
+ if minv is None:
+ ok = env.has_h2load()
+ dep = "h2load"
+ default_reason = "h2load not available"
+ else:
+ ok = env.h2load_is_at_least(minv)
+ dep = f"h2load >= {minv}"
+ default_reason = f"h2load unavailable or older than {minv}"
+ found = env.h2load_version_str()
+ cls._h2load_gate[key] = needs_dependency(
+ dep, ok, reason=reason or default_reason,
+ detected=(f"h2load {found}" if found is not None
+ else f"tried to run {env._h2load}"))
+ return cls._h2load_gate[key]
+
+ _multipart_gate = None
+
+ @classmethod
+ def needs_multipart(cls):
+ """A marker gating a test, class or module on the multipart package.
+
+ The CGI helpers in htdocs/cgi/ parse file uploads with it. They
+ import it only for a multipart/ body, so this belongs on the tests
+ that upload a file, not on everything that touches a CGI.
+ """
+ from .depends import needs_dependency
+ if cls._multipart_gate is None:
+ cls._multipart_gate = needs_dependency(
+ "multipart", cls.has_python_package('python_multipart')
+ or cls.has_python_package('multipart'),
+ reason="no multipart python package available",
+ detected="python package 'multipart' not installed "
+ "(pip install python-multipart)")
+ return cls._multipart_gate
def __init__(self, pytestconfig=None):
self._our_dir = os.path.dirname(inspect.getfile(Dummy))
@@ -260,8 +435,15 @@
self._apxs = self.config.get('global', 'apxs')
self._prefix = self.config.get('global', 'prefix')
self._apachectl = self.config.get('global', 'apachectl')
+
+ self._libexec_dir = self.config.get('global', 'libexecdir')
+ HttpdTestEnv.LIBEXEC_DIR = self._libexec_dir
+
if HttpdTestEnv.LIBEXEC_DIR is None:
HttpdTestEnv.LIBEXEC_DIR = self._libexec_dir = self.get_apxs_var('LIBEXECDIR')
+
+ self._httpd_ver = self.config.get('httpd', 'version')
+
self._curl = self.config.get('global', 'curl_bin')
if 'CURL' in os.environ:
self._curl = os.environ['CURL']
@@ -271,27 +453,46 @@
self._h2load = self.config.get('global', 'h2load')
if self._h2load is None:
self._h2load = 'h2load'
+ self._test_modules_dir = self.config.get('global', 'pre_built_test_binaries_dir',
+ fallback=None)
- self._http_port = int(self.config.get('test', 'http_port'))
- self._http_port2 = int(self.config.get('test', 'http_port2'))
- self._https_port = int(self.config.get('test', 'https_port'))
- self._proxy_port = int(self.config.get('test', 'proxy_port'))
- self._ws_port = int(self.config.get('test', 'ws_port'))
+ ports = resolve_ports(
+ {key: int(self.config.get('test', key)) for key in PORT_KEYS},
+ base_port=(pytestconfig.getoption('--base-port', default=None)
+ if pytestconfig is not None else None),
+ fixed=(bool(pytestconfig.getoption('--fixed-ports', default=False))
+ if pytestconfig is not None else True))
+ self._http_port = ports['http_port']
+ self._http_port2 = ports['http_port2']
+ self._https_port = ports['https_port']
+ self._proxy_port = ports['proxy_port']
+ self._ws_port = ports['ws_port']
self._http_tld = self.config.get('test', 'http_tld')
self._test_dir = self.config.get('test', 'test_dir')
+
+ # In cmake determining the test directory is a headache and this is a simple workaround
+ if not self._test_dir:
+ self._test_dir = os.getcwd() + "/pyhttpd" if "pyhttpd" not in os.getcwd() else os.getcwd()
+
self._clients_dir = os.path.join(os.path.dirname(self._test_dir), 'clients')
self._gen_dir = self.config.get('test', 'gen_dir')
+
self._server_dir = os.path.join(self._gen_dir, 'apache')
self._server_conf_dir = os.path.join(self._server_dir, "conf")
self._server_docs_dir = os.path.join(self._server_dir, "htdocs")
- self._server_logs_dir = os.path.join(self.server_dir, "logs")
+ self._server_logs_dir = os.path.join(self._server_dir, "logs")
+ self._server_run_dir = os.path.join(self._server_dir, "run")
self._server_access_log = os.path.join(self._server_logs_dir, "access_log")
- self._error_log = HttpdErrorLog(os.path.join(self._server_logs_dir, "error_log"))
+ error_log_sep = "." if self.isWindows else "_"
+ self._error_log = HttpdErrorLog(os.path.join(self._server_logs_dir, f"error{error_log_sep}log"))
self._apachectl_stderr = None
+ self._instance = None
+
+ self._apply_target()
self._dso_modules = self.config.get('httpd', 'dso_modules').split(' ')
self._mpm_modules = self.config.get('httpd', 'mpm_modules').split(' ')
- self._mpm_module = f"mpm_{os.environ['MPM']}" if 'MPM' in os.environ else 'mpm_event'
+ self._mpm_module = f"mpm_{os.environ['MPM']}" if 'MPM' in os.environ else 'mpm_winnt' if self.isWindows else 'mpm_event'
self._ssl_module = self.get_ssl_module()
if len(self._ssl_module.strip()) == 0:
self._ssl_module = None
@@ -301,8 +502,13 @@
self._https_base = f"https://{self._httpd_addr}:{self.https_port}"
self._verbosity = pytestconfig.option.verbose if pytestconfig is not None else 0
+ # imported here, not at module scope, to keep env.py free of a
+ # top-level pytest dependency: it is also built outside pytest runs
+ from .depends import strict_optional_enabled
+ self._strict_optional = strict_optional_enabled(pytestconfig)
self._test_conf = os.path.join(self._server_conf_dir, "test.conf")
self._httpd_base_conf = []
+ self._httpd_env = {}
self._httpd_log_modules = ['aptest']
self._log_interesting = None
self._setup = None
@@ -319,14 +525,73 @@
self._curl_headerfiles_n = 0
self._curl_version = None
self._h2load_version = None
+ self._nghttp_version = None
self._current_test = None
+ def _apply_target(self):
+ """Point this environment at the httpd of the session.
+
+ config.ini, generated by the build system from config.ini.in, names
+ the build of this tree. When another one is tested (--apxs, --httpd),
+ pyhttpd.build.resolve_build() found it before collection, and the
+ server, its version and its modules come from there, whether or not
+ the environment was created with a pytest config. Ports, clients and
+ directories stay as config.ini has them.
+ """
+ from .build import current
+ target = current()
+ if target is None or target.source == "config.ini":
+ return
+ httpd_path = os.path.abspath(str(target.httpd))
+ self._bin_dir = os.path.dirname(httpd_path)
+ self._prefix = (str(target.prefix) if target.prefix is not None
+ else os.path.dirname(self._bin_dir))
+ self._libexec_dir = str(target.libexecdir)
+ HttpdTestEnv.LIBEXEC_DIR = self._libexec_dir
+ if self.isWindows:
+ self._apachectl = httpd_path
+ else:
+ ctl = os.path.join(self._bin_dir, "apachectl")
+ self._apachectl = ctl if os.path.isfile(ctl) else httpd_path
+ self._apxs = (str(target.apxs) if target.apxs is not None
+ else os.path.join(self._bin_dir, "apxs"))
+ self._httpd_ver = target.version_str
+ dso = []
+ mpm = []
+ if target.apxs is None and target.inherited_conf is not None:
+ # --httpd with --conf, as on Windows: the modules the conf loads
+ for d in target.load_directives:
+ name = d.symbol[:-len("_module")] if d.symbol.endswith("_module") \
+ else d.symbol
+ (mpm if name.startswith("mpm_") else dso).append(name)
+ elif os.path.isdir(self._libexec_dir):
+ # the shared modules that were built
+ for so in sorted(os.listdir(self._libexec_dir)):
+ m = re.match(r'^mod_(.+)\.so$', so)
+ if m:
+ (mpm if m.group(1).startswith("mpm_") else dso).append(m.group(1))
+ self.config.set('httpd', 'dso_modules', ' '.join(dso))
+ self.config.set('httpd', 'mpm_modules', ' '.join(mpm))
+
def add_httpd_conf(self, lines: List[str]):
self._httpd_base_conf.extend(lines)
def add_httpd_log_modules(self, modules: List[str]):
self._httpd_log_modules.extend(modules)
+ def set_httpd_env(self, name: str, value: Optional[str]):
+ """Add a variable to the environment httpd is started with, or
+ remove it again when passed None.
+
+ Used by tests for modules which take their input from the
+ environment rather than from the configuration, such as
+ mod_systemd reading $NOTIFY_SOCKET.
+ """
+ if value is None:
+ self._httpd_env.pop(name, None)
+ else:
+ self._httpd_env[name] = value
+
def issue_certs(self):
if self._ca is None:
self._ca = HttpdTestCA.create_root(name=self.http_tld,
@@ -356,6 +621,81 @@
f"apache logged {len(errors)} errors and {len(warnings)} warnings: \n"\
"{0}\n{1}\n".format("\n".join(errors), "\n".join(warnings))
+ # combines httpd version & revision to identify the archive folder
+ def _archive_version(self):
+ if not hasattr(self, '_cached_archive_version'):
+ version = self.get_httpd_version()
+ r = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'],
+ capture_output=True, text=True,
+ cwd=self._our_dir)
+ if r.returncode == 0:
+ version = f"{version}-{r.stdout.strip()}"
+ else:
+ r = subprocess.run(['svn', 'info', '--show-item', 'revision'],
+ capture_output=True, text=True,
+ cwd=self._our_dir)
+
+ if r.returncode == 0:
+ version = f"{version}-r{r.stdout.strip()}"
+ self._cached_archive_version = version
+ return self._cached_archive_version
+
+ def archive_logs(self, package_name, archive_dir):
+ version = self._archive_version()
+ dest = os.path.join(archive_dir, version, package_name)
+ os.makedirs(dest, exist_ok=True)
+
+ # merge into existing dest to preserve per-test confs
+ shutil.copytree(self._server_dir, dest, ignore=self.ignore_files,
+ dirs_exist_ok=True)
+
+ shared_dest = os.path.join(archive_dir, version, '_shared')
+
+ if os.path.isdir(shared_dest):
+ shutil.rmtree(shared_dest)
+ os.makedirs(shared_dest)
+
+ for entry in self.SHARED_SERVER_ENTRIES:
+ src = os.path.join(self._server_dir, entry)
+ entry_dest = os.path.join(shared_dest, entry)
+ if os.path.isdir(src):
+ shutil.copytree(src, entry_dest)
+ elif os.path.isfile(src):
+ shutil.copy2(src, entry_dest)
+
+ shared_conf_dest = os.path.join(shared_dest, 'conf')
+ os.makedirs(shared_conf_dest)
+
+ # copy them once
+ for f in ['httpd.conf', 'mime.types', 'stop.conf']:
+ src = os.path.join(self._server_conf_dir, f)
+ if os.path.isfile(src):
+ shutil.copy2(src, shared_conf_dest)
+
+ def archive_test_conf(self, test_name, package_name, archive_dir):
+ version = self._archive_version()
+ dest = os.path.join(archive_dir, version, package_name, 'conf')
+ if not os.path.isdir(dest):
+ os.makedirs(dest)
+ test_conf = os.path.join(self._server_conf_dir, 'test.conf')
+ if os.path.isfile(test_conf):
+ safe_name = test_name.replace('/', '_').replace('\\', '_')
+ dest_file = os.path.join(dest, f"{safe_name}.conf")
+ shutil.copy(test_conf, dest_file)
+
+ # return files to ignore
+ def ignore_files(self, d, entries):
+ ignored = [e for e in entries if
+ e.endswith('.sock') or e in self.SHARED_SERVER_ENTRIES]
+ # shared confs goes to _shared
+ # test.conf is captured per-test
+ if os.path.basename(d) == 'conf':
+ ignored += ['httpd.conf', 'mime.types', 'stop.conf', 'test.conf']
+ # htdocs are static test fixtures not useful in the archive
+ if os.path.basename(d) == os.path.basename(self._server_dir):
+ ignored += ['htdocs']
+ return ignored
+
@property
def curl(self) -> str:
return self._curl
@@ -441,6 +781,10 @@
return self._server_logs_dir
@property
+ def server_run_dir(self) -> str:
+ return self._server_run_dir
+
+ @property
def libexec_dir(self) -> str:
return HttpdTestEnv.LIBEXEC_DIR
@@ -482,10 +826,43 @@
def set_current_test_name(self, val) -> None:
self._current_test = val
+ def require(self, condition: bool, reason: str,
+ dep: str = None, detected=None) -> None:
+ """Skip, or fail under strict-optional mode, on a missing dependency.
+
+ The in-test counterpart of the @needs_dependency decorator, for
+ checks that can only be made once a test is running. Use it instead
+ of a bare pytest.skip() whenever the reason is "something we expected
+ to be installed is not", and a plain pytest.skip() when the reason is
+ "this test does not apply here".
+
+ :param condition: truthy if the dependency is present
+ :param reason: skip reason, also the "reason" line when strict
+ :param dep: short dependency name for the strict message
+ :param detected: what was found instead, for the strict message
+ """
+ import pytest
+ if condition:
+ return
+ if self._strict_optional:
+ from .depends import strict_failure_message
+ pytest.fail(strict_failure_message(
+ dep or reason, expected=reason, detected=detected,
+ where=self.current_test_name), pytrace=False)
+ pytest.skip(reason)
+
@property
- def apachectl_stderr(self):
+ def apachectl_stderr(self) -> str:
return self._apachectl_stderr
+ @property
+ def test_modules_dir(self) -> str:
+ return self._test_modules_dir
+
+ @property
+ def isWindows(self) -> bool:
+ return platform.system().lower() == "windows"
+
def add_cert_specs(self, specs: List[CertificateSpec]):
self._cert_specs.extend(specs)
@@ -503,64 +880,118 @@
hv = self._versiontuple(self.get_httpd_version())
return hv >= self._versiontuple(minv)
+ @staticmethod
+ def _run_version(args):
+ """Run a --version style command, returning its stdout or None.
+
+ None means the binary is not there or would not run, which callers
+ must distinguish from "ran fine but is too old". config.ini/default
+ may just be the bare command name ("h2load"), not a verified path,
+ so catching OSError here is what keeps an uninstalled tool from
+ crashing collection with FileNotFoundError.
+ """
+ try:
+ p = subprocess.run(args, capture_output=True, text=True)
+ except OSError:
+ return None
+ return p.stdout if p.returncode == 0 else None
+
+ def _probe_h2load(self):
+ """The h2load version tuple, or None when h2load is unusable."""
+ if self._h2load_version is None:
+ self._h2load_version = False # probed, not found
+ out = self._run_version([self._h2load, '--version']) if self._h2load else None
+ if out is not None:
+ m = re.match(r'h2load nghttp2/(\S+)', out.strip())
+ if m:
+ self._h2load_version = self._versiontuple(m.group(1))
+ return self._h2load_version or None
+
def has_h2load(self):
- return self._h2load != ""
+ return self._probe_h2load() is not None
+
+ def h2load_version_str(self):
+ """'1.41.0', or None when h2load is absent. For strict messages."""
+ v = self._probe_h2load()
+ return None if v is None else ".".join(str(n) for n in v)
def h2load_is_at_least(self, minv):
- if not self.has_h2load():
- return False
- if self._h2load_version is None:
- p = subprocess.run([self._h2load, '--version'], capture_output=True, text=True)
- if p.returncode != 0:
- return False
- s = p.stdout.strip()
- m = re.match(r'h2load nghttp2/(\S+)', s)
- if m:
- self._h2load_version = self._versiontuple(m.group(1))
- if self._h2load_version is not None:
- return self._h2load_version >= self._versiontuple(minv)
- return False
+ v = self._probe_h2load()
+ return v is not None and v >= self._versiontuple(minv)
- def curl_is_at_least(self, minv):
+ def _probe_curl(self):
+ """The curl version tuple, or None when curl is unusable."""
if self._curl_version is None:
- p = subprocess.run([self._curl, '-V'], capture_output=True, text=True)
- if p.returncode != 0:
- return False
- for l in p.stdout.splitlines():
- m = re.match(r'curl ([0-9.]+)[- ].*', l)
- if m:
- self._curl_version = self._versiontuple(m.group(1))
- break
- if self._curl_version is not None:
- return self._curl_version >= self._versiontuple(minv)
- return False
+ self._curl_version = False # probed, not found
+ out = self._run_version([self._curl, '-V']) if self._curl else None
+ if out is not None:
+ for l in out.splitlines():
+ m = re.match(r'curl ([0-9.]+)[- ].*', l)
+ if m:
+ self._curl_version = self._versiontuple(m.group(1))
+ break
+ return self._curl_version or None
+
+ def has_curl(self):
+ return self._probe_curl() is not None
+
+ def curl_version_str(self):
+ """'8.4.0', or None when curl is absent. For strict messages."""
+ v = self._probe_curl()
+ return None if v is None else ".".join(str(n) for n in v)
+
+ def curl_is_at_least(self, minv):
+ v = self._probe_curl()
+ return v is not None and v >= self._versiontuple(minv)
def curl_is_less_than(self, version):
- if self._curl_version is None:
- p = subprocess.run([self._curl, '-V'], capture_output=True, text=True)
- if p.returncode != 0:
- return False
- for l in p.stdout.splitlines():
- m = re.match(r'curl ([0-9.]+)[- ].*', l)
+ v = self._probe_curl()
+ return v is not None and v < self._versiontuple(version)
+
+ def curl_is_8_1_x(self):
+ """Whether curl is one of the 8.1.x releases.
+
+ Those mishandle TLS renegotiation; 8.0.x and 8.2.0+ are both fine.
+ Returns False when curl is absent, so combine it with has_curl() when
+ the test also needs curl to exist at all.
+ """
+ v = self._probe_curl()
+ return (v is not None
+ and self._versiontuple('8.1.0') <= v < self._versiontuple('8.2.0'))
+
+ def _probe_nghttp(self):
+ """The nghttp version tuple, or None when nghttp is unusable.
+
+ config.ini always names a binary, so the name alone proves nothing
+ about whether nghttp is installed: it has to be run. 'nghttp
+ --version' prints 'nghttp2/1.64.0', the nghttp2 library version.
+ """
+ if self._nghttp_version is None:
+ self._nghttp_version = False # probed, not found
+ out = self._run_version([self._nghttp, '--version']) if self._nghttp else None
+ if out is not None:
+ m = re.search(r'nghttp2/(\S+)', out.strip())
if m:
- self._curl_version = self._versiontuple(m.group(1))
- break
- if self._curl_version is not None:
- return self._curl_version < self._versiontuple(version)
- return False
+ self._nghttp_version = self._versiontuple(m.group(1))
+ return self._nghttp_version or None
def has_nghttp(self):
- return self._nghttp != ""
+ return self._probe_nghttp() is not None
+
+ def nghttp_version_str(self):
+ """'1.64.0', or None when nghttp is absent. For strict messages."""
+ v = self._probe_nghttp()
+ return None if v is None else ".".join(str(n) for n in v)
+
+ def nghttp_is_at_least(self, minv):
+ v = self._probe_nghttp()
+ return v is not None and v >= self._versiontuple(minv)
def has_nghttp_get_assets(self):
if not self.has_nghttp():
return False
- args = [self._nghttp, "-a"]
- p = subprocess.run(args, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
- rv = p.returncode
- if rv != 0:
- return False
- return p.stderr == ""
+ p = subprocess.run([self._nghttp, "-a"], capture_output=True, text=True)
+ return p.returncode == 0 and p.stderr == ""
def get_apxs_var(self, name: str) -> str:
p = subprocess.run([self._apxs, "-q", name], capture_output=True, text=True)
@@ -569,7 +1000,7 @@
return p.stdout.strip()
def get_httpd_version(self) -> str:
- return self.get_apxs_var("HTTPD_VERSION")
+ return self._httpd_ver
def mkpath(self, path):
if not os.path.exists(path):
@@ -685,61 +1116,118 @@
p for p in os.environ.get('PATH', '').split(os.pathsep)
if '/shims' not in p
]
- venv_bin = os.path.join(self._our_dir, '.venv', 'bin')
+ venv_bin = os.path.join(os.path.dirname(self._our_dir), '.venv', 'bin')
if os.path.isdir(venv_bin) and venv_bin not in parts:
parts.insert(0, venv_bin)
env = os.environ.copy()
env['PATH'] = os.pathsep.join(parts)
+ env.update(self._httpd_env)
+ return env
+
+ @property
+ def instance(self) -> HttpdInstance:
+ """The httpd of this environment; see pyhttpd.instance."""
+ if self._instance is None:
+ self._instance = HttpdInstance(
+ os.path.join(self._bin_dir, 'httpd'),
+ ["-d", self._server_dir,
+ "-f", os.path.join(self._server_conf_dir, 'httpd.conf')],
+ ports=[self._http_port],
+ error_log=self._error_log,
+ pid_files=[os.path.join(d, 'httpd.pid')
+ for d in (self._server_logs_dir, self._server_dir)],
+ env=self._server_env(),
+ stderr_file=os.path.join(self._server_dir, 'start.stderr'))
+ return self._instance
+
+ def _server_env(self) -> dict:
+ env = self._clean_path_env()
+ # what apachectl did: the messages of the server in a known language
+ env.setdefault('LANG', 'C')
return env
def _run_apachectl(self, cmd) -> ExecResult:
+ """Run "httpd -k " and return the result. Only for tests that
+ look at what httpd itself reports; use apache_restart() and friends
+ to control the server."""
conf_file = 'stop.conf' if cmd == 'stop' else 'httpd.conf'
- args = [self._apachectl,
+ args = [os.path.join(self._bin_dir, 'httpd'),
"-d", self.server_dir,
- "-f", os.path.join(self._server_dir, f'conf/{conf_file}'),
+ "-f", os.path.join(self._server_dir, 'conf', conf_file),
"-k", cmd]
- r = self.run(args, env=self._clean_path_env())
+ r = self.run(args, env=self._server_env())
self._apachectl_stderr = r.stderr
if r.exit_code != 0:
log.warning(f"failed: {r}")
return r
+ def _start_instance(self) -> int:
+ """Start the server, and wait for it to answer. 0 if it does."""
+ self._apachectl_stderr = None
+ # tests change the environment of the server between starts
+ self.instance.env = self._server_env()
+ try:
+ self.instance.start()
+ except HttpdStartError as e:
+ self._apachectl_stderr = e.stderr
+ log.debug(f"httpd did not start: {e}")
+ return e.returncode if e.returncode else -1
+ self._apachectl_stderr = self.instance.stderr_text()
+ return 0 if self.is_live(self._http_base, timeout=timedelta(seconds=10)) else -1
+
def apache_reload(self):
- r = self._run_apachectl("graceful")
- if r.exit_code == 0:
- timeout = timedelta(seconds=10)
- return 0 if self.is_live(self._http_base, timeout=timeout) else -1
- return r.exit_code
+ return 0 if self.instance.restart(graceful=True) else -1
def apache_restart(self):
self.apache_stop()
- r = self._run_apachectl("start")
- if r.exit_code == 0:
- timeout = timedelta(seconds=10)
- return 0 if self.is_live(self._http_base, timeout=timeout) else -1
- return r.exit_code
-
+ return self._start_instance()
+
def apache_stop(self):
- r = self._run_apachectl("stop")
- if r.exit_code == 0:
- timeout = timedelta(seconds=10)
- return 0 if self.is_dead(self._http_base, timeout=timeout) else -1
- return r
+ return 0 if self.instance.stop() else -1
def apache_graceful_stop(self):
log.debug("stop apache")
- self._run_apachectl("graceful-stop")
- return 0 if self.is_dead() else -1
+ return 0 if self.instance.graceful_stop() else -1
def apache_fail(self):
+ """Start the server with a configuration it has to reject. 0 if it
+ did: httpd exited with an error before serving. -1 if it started
+ after all, or neither started nor exited in time (that is a stuck
+ server, not a rejected configuration). The server is stopped either
+ way, and apachectl_stderr has what it reported.
+ """
log.debug("expect apache fail")
- self._run_apachectl("stop")
- rv = self._run_apachectl("start")
- if rv == 0:
- rv = 0 if self.is_dead() else -1
- else:
- rv = 0
- return rv
+ self.apache_stop()
+ self._apachectl_stderr = None
+ self.instance.env = self._server_env()
+ try:
+ self.instance.start()
+ except HttpdStartError as e:
+ self._apachectl_stderr = e.stderr
+ if e.returncode > 0:
+ return 0
+ log.error(f"httpd neither started nor failed: {e}")
+ return -1
+ self._apachectl_stderr = self.instance.stderr_text()
+ log.error("httpd started where it was expected to fail")
+ self.instance.stop()
+ return -1
+
+ def apache_hard_restart(self) -> int:
+ """Restart without the "graceful" flag, so the MPM starts over."""
+ return 0 if self.instance.restart(graceful=False) else -1
+
+ def read_pid_file(self, name: str = 'httpd.pid') -> Optional[int]:
+ # Where PidFile lands depends on how the httpd under test resolves
+ # a relative path against DefaultRuntimeDir, which has differed
+ # between versions; look in both places rather than assume.
+ for d in (self._server_logs_dir, self._server_dir):
+ try:
+ with open(os.path.join(d, name)) as fd:
+ return int(fd.read().strip())
+ except (OSError, ValueError):
+ continue
+ return None
def apache_access_log_clear(self):
if os.path.isfile(self._server_access_log):
@@ -902,7 +1390,8 @@
def curl_protocol_version(self, url, timeout=5, options=None):
if not options:
options = []
- options.extend(["-w", "%{http_version}\n", "-o", "/dev/null"])
+ devnull = "NUL" if sys.platform == "win32" else "/dev/null"
+ options.extend(["-w", "%{http_version}\n", "-o", devnull])
r = self.curl_raw(url, timeout=timeout, options=options)
if r.exit_code == 0 and r.response:
return r.response["body"].decode('utf-8').rstrip()
@@ -940,7 +1429,7 @@
fpath = os.path.join(indir, fname)
s10 = "0123456789"
s = (101 * s10) + s10[0:3]
- with open(fpath, 'w') as fd:
+ with open(fpath, 'w', newline='') as fd:
for i in range(int(fsize / 1024)):
fd.write(f"{i:09d}-{s}\n")
remain = int(fsize % 1024)
diff -Nru apache2-2.4.69/test/pyhttpd/gating.py apache2-2.4.71/test/pyhttpd/gating.py
--- apache2-2.4.69/test/pyhttpd/gating.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/gating.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,164 @@
+"""Collection time gating shared by both suites.
+
+One vocabulary of markers, evaluated once per session against the httpd that
+:func:`pyhttpd.build.resolve_build` found:
+
+ need_module(*names) every named module is available
+ need_cgi() mod_cgi or mod_cgid
+ need_php() a PHP SAPI module, or php-fpm via mod_proxy_fcgi
+ need_ssl() mod_ssl
+ need_lwp() always satisfied (kept from the Perl vocabulary)
+ need_min_apache_version(v) httpd >= v
+ not_backported(rev, what, backported_in=None, platform=None)
+ expected to fail on httpd without the trunk change
+ (the test itself: errors in fixtures stay errors)
+ skip_not_backported(rev, what, backported_in=None, platform=None)
+ not run there (hangs, crashes, or leaves errors
+ in the error log)
+
+and, from pyhttpd.depends, ``needs_dependency`` for everything that is not
+httpd itself (a client tool, a python package, an ACME server, ...).
+
+A missing module, CGI/PHP/SSL support or dependency is a *missing dependency*:
+the test is skipped, or fails under --strict-optional. A test that does not
+apply here (httpd too old, change not backported) always skips or xfails. The
+"does not apply" gates are added first, so in strict mode they win, exactly as
+for ``needs_dependency`` (see pyhttpd.depends).
+
+On trunk the backport marks do nothing; see pyhttpd.backport for backported_in
+and platform.
+
+This is a pytest plugin, loaded from test/conftest.py. The markers themselves
+are registered in test/pyproject.toml.
+"""
+
+import pytest
+
+from .backport import lacks_change, reason
+from .build import HttpdInfo, resolve_build
+from .depends import apply_gate, strict_optional_enabled
+
+GATE_MARKERS = (
+ "need_module", "need_cgi", "need_php", "need_ssl", "need_lwp",
+ "need_min_apache_version", "not_backported", "skip_not_backported",
+)
+
+_PHP_SAPI_MODULES = ("sapi_apache2", "mod_php4", "mod_php5", "mod_php7", "mod_php")
+
+# callables (HttpdInfo) -> None that may add to info.modules, for modules the
+# suite builds itself and which therefore exist at test time, not yet now
+_module_providers = []
+_augmented = []
+
+
+def add_module_provider(provider) -> None:
+ """Let a suite declare modules it will build before it runs its tests.
+
+ The classic suite compiles its c-modules/ in a session fixture, so
+ need_module("authany") has to be satisfied at collection time already.
+ """
+ if provider not in _module_providers:
+ _module_providers.append(provider)
+
+
+def _augment(info: HttpdInfo) -> None:
+ if any(i is info for i in _augmented):
+ return
+ _augmented.append(info)
+ for provider in _module_providers:
+ provider(info)
+
+
+def _parse_version(ver) -> tuple:
+ parts = tuple(int(x) for x in str(ver).split("."))
+ return parts + (0,) * (3 - len(parts))
+
+
+def _applies(item, info: HttpdInfo) -> None:
+ """Add the "does not apply to this httpd" skips and xfails."""
+ for mark in item.iter_markers(name="need_min_apache_version"):
+ ver = mark.args[0]
+ if info.version < _parse_version(ver):
+ item.add_marker(pytest.mark.skip(
+ reason=f"httpd {info.version_str} < required {ver}"))
+ return
+ for mark in item.iter_markers(name="skip_not_backported"):
+ if lacks_change(info.version, mark.kwargs.get("backported_in"),
+ mark.kwargs.get("platform")):
+ rev, what = mark.args
+ item.add_marker(pytest.mark.skip(
+ reason=reason(rev, what, mark.kwargs.get("platform"))))
+ return
+ for mark in item.iter_markers(name="not_backported"):
+ if lacks_change(info.version, mark.kwargs.get("backported_in"),
+ mark.kwargs.get("platform")):
+ rev, what = mark.args
+ item.stash[_EXPECTED_FAILURE] = reason(rev, what, mark.kwargs.get("platform"))
+ return
+
+
+# not_backported is an xfail of the test itself, not of its fixtures: a
+# pytest.mark.xfail would also turn a broken server setup, a missing client or
+# an error log check failing in teardown into an expected failure.
+_EXPECTED_FAILURE = pytest.StashKey[str]()
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_makereport(item, call):
+ rep = yield
+ why = item.stash.get(_EXPECTED_FAILURE, None)
+ if why is None or call.when != "call" or rep.skipped \
+ or item.config.option.runxfail:
+ return rep
+ # what pytest reports for a non-strict xfail: XFAIL, or XPASS
+ if rep.failed:
+ rep.outcome = "skipped"
+ rep.wasxfail = why
+ return rep
+
+
+def _missing(item, info: HttpdInfo, php_fpm: bool) -> list:
+ """The unmet dependencies of *item* as (dep, reason, detected) tuples."""
+ where = f"httpd {info.version_str}" + (f" in {info.prefix}" if info.prefix else "")
+ missing = []
+ for mark in item.iter_markers(name="need_module"):
+ for name in mark.args:
+ if not info.has_module(name):
+ missing.append((f"module {name}", f"module {name} not available",
+ f"not built or loaded: {where}"))
+ if any(item.iter_markers(name="need_cgi")) and not (
+ info.has_module("mod_cgi") or info.has_module("mod_cgid")
+ ):
+ missing.append(("mod_cgi or mod_cgid",
+ "no CGI module (mod_cgi/mod_cgid) available", where))
+ if any(item.iter_markers(name="need_php")):
+ have_sapi = any(info.has_module(m) for m in _PHP_SAPI_MODULES)
+ have_fpm = php_fpm and info.has_module("mod_proxy_fcgi")
+ if not (have_sapi or have_fpm):
+ missing.append(("PHP", "no PHP SAPI module or php-fpm available",
+ f"no SAPI module, no --php-fpm: {where}"))
+ if any(item.iter_markers(name="need_ssl")) and not info.has_module("mod_ssl"):
+ missing.append(("mod_ssl", "mod_ssl not available", where))
+ # need_lwp is always satisfied (httpx is always present); no check.
+ return missing
+
+
+# Runs before pyhttpd.depends' hook: the skips added here are what "does not
+# apply" means to it in strict mode.
+@pytest.hookimpl(tryfirst=True)
+def pytest_collection_modifyitems(config, items):
+ gated = [item for item in items
+ if any(item.get_closest_marker(name) for name in GATE_MARKERS)]
+ if not gated:
+ return
+ info = resolve_build(config)
+ if info is None:
+ # no httpd to evaluate against: leave the tests to fail or skip
+ # naturally at fixture setup
+ return
+ _augment(info)
+ strict = strict_optional_enabled(config)
+ php_fpm = bool(config.getoption("--php-fpm", default=None))
+ for item in gated:
+ _applies(item, info)
+ apply_gate(item, _missing(item, info, php_fpm), strict)
diff -Nru apache2-2.4.69/test/pyhttpd/install_tools.bat apache2-2.4.71/test/pyhttpd/install_tools.bat
--- apache2-2.4.69/test/pyhttpd/install_tools.bat 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/install_tools.bat 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,277 @@
+@echo off
+rem
+rem install_tools.bat -- install curl CLI and nghttp2 for pyhttpd tests.
+rem
+rem Usage:
+rem install_tools.bat C:\path\to\httpd\prefix
+rem
+rem Uses vcpkg to install curl (with CLI tool) and nghttp2, then copies
+rem binaries into the httpd prefix bin/ directory so the test framework
+rem can find them.
+rem
+
+setlocal enabledelayedexpansion
+
+set "HERE=%~dp0"
+if "%HERE:~-1%"=="\" set "HERE=%HERE:~0,-1%"
+pushd "%HERE%\.."
+set "TEST_DIR=%CD%"
+popd
+
+rem --- target prefix (where httpd is installed) -------------------------------
+if not "%~1"=="" (
+ set "PREFIX=%~1"
+) else (
+ echo install_tools.bat: ERROR: specify the httpd install prefix as argument. >&2
+ echo Example: install_tools.bat "C:\Program Files (x86)\APR" >&2
+ exit /b 1
+)
+
+if not exist "%PREFIX%\bin\httpd.exe" (
+ echo install_tools.bat: ERROR: httpd.exe not found in "%PREFIX%\bin". >&2
+ echo Is "%PREFIX%" the correct httpd install prefix? >&2
+ exit /b 1
+)
+
+rem --- python detection and install --------------------------------------------
+set PYTHON=
+for /f "delims=" %%P in ('where python.exe 2^>nul') do (
+ if not defined PYTHON (
+ echo %%P | findstr /i "hostedtoolcache" >nul
+ if not errorlevel 1 set "PYTHON=%%P"
+ )
+)
+if not defined PYTHON (
+ for /f "delims=" %%D in ('dir /b /o-n "%LOCALAPPDATA%\Programs\Python\Python3*" 2^>nul') do (
+ if not defined PYTHON set "PYTHON=%LOCALAPPDATA%\Programs\Python\%%D\python.exe"
+ )
+)
+if not defined PYTHON (
+ for /f "delims=" %%D in ('dir /b /o-n "%PROGRAMFILES%\Python3*" 2^>nul') do (
+ if not defined PYTHON set "PYTHON=%PROGRAMFILES%\%%D\python.exe"
+ )
+)
+if defined PYTHON (
+ echo install_tools.bat: Python found at "!PYTHON!" >&2
+ goto :python_ok
+)
+
+echo install_tools.bat: Python not found, installing... >&2
+rem Query the Python API for the latest stable release and extract the version
+set "PY_VERSION="
+curl -s -o "%TEMP%\pyrel.json" "https://www.python.org/api/v2/downloads/release/?is_published=true&pre_release=false&version=3"
+for /f "delims=" %%V in ('powershell -NoProfile -Command "$ProgressPreference='SilentlyContinue'; $j = Get-Content '%TEMP%\pyrel.json' -Raw | ConvertFrom-Json; ($j | Where-Object { $_.is_latest -eq $true }).name -replace 'Python ', ''" 2^>nul') do (
+ set "PY_VERSION=%%V"
+)
+del "%TEMP%\pyrel.json" 2>nul
+if not defined PY_VERSION (
+ echo install_tools.bat: ERROR: could not detect latest Python version. >&2
+ echo Install Python manually from https://www.python.org/downloads/windows/ >&2
+ exit /b 1
+)
+echo install_tools.bat: latest Python version: %PY_VERSION% >&2
+
+set "PY_INSTALLER=python-%PY_VERSION%-amd64.exe"
+set "PY_URL=https://www.python.org/ftp/python/%PY_VERSION%/%PY_INSTALLER%"
+echo install_tools.bat: downloading %PY_URL%... >&2
+curl -L -o "%TEMP%\%PY_INSTALLER%" "%PY_URL%"
+if errorlevel 1 (
+ echo install_tools.bat: ERROR: download failed. >&2
+ exit /b 1
+)
+
+echo install_tools.bat: installing Python %PY_VERSION%... >&2
+"%TEMP%\%PY_INSTALLER%" /quiet InstallAllUsers=0 PrependPath=0 Include_launcher=0
+if errorlevel 1 (
+ echo install_tools.bat: ERROR: Python installation failed. >&2
+ exit /b 1
+)
+del "%TEMP%\%PY_INSTALLER%" 2>nul
+
+rem Re-detect after install
+set PYTHON=
+for /f "delims=" %%D in ('dir /b /o-n "%LOCALAPPDATA%\Programs\Python\Python3*" 2^>nul') do (
+ if not defined PYTHON set "PYTHON=%LOCALAPPDATA%\Programs\Python\%%D\python.exe"
+)
+if not defined PYTHON (
+ echo install_tools.bat: ERROR: Python still not found after install. >&2
+ exit /b 1
+)
+echo install_tools.bat: Python installed at "!PYTHON!" >&2
+
+:python_ok
+
+rem --- uv detection and install ------------------------------------------------
+"!PYTHON!" -m uv --version >nul 2>&1
+if not errorlevel 1 (
+ echo install_tools.bat: uv already installed. >&2
+ goto :uv_ok
+)
+echo install_tools.bat: uv not found, installing via pip... >&2
+"!PYTHON!" -m pip install uv
+if errorlevel 1 (
+ echo install_tools.bat: ERROR: failed to install uv. >&2
+ exit /b 1
+)
+"!PYTHON!" -m uv --version >nul 2>&1
+if errorlevel 1 (
+ echo install_tools.bat: ERROR: uv still not working after install. >&2
+ exit /b 1
+)
+echo install_tools.bat: uv installed successfully. >&2
+
+:uv_ok
+
+rem --- perl detection and install (Strawberry Perl) ----------------------------
+set PERL=
+for /f "delims=" %%P in ('where perl.exe 2^>nul') do (
+ if not defined PERL set "PERL=%%P"
+)
+if not defined PERL (
+ if exist "C:\Strawberry\perl\bin\perl.exe" set "PERL=C:\Strawberry\perl\bin\perl.exe"
+)
+if defined PERL (
+ echo install_tools.bat: Perl found at "!PERL!" >&2
+ goto :perl_ok
+)
+
+echo install_tools.bat: Perl not found, installing Strawberry Perl... >&2
+rem Get the latest 64bit.msi download URL from GitHub releases API
+set "PERL_URL="
+for /f "tokens=2 delims= " %%U in ('curl -s https://api.github.com/repos/StrawberryPerl/Perl-Dist-Strawberry/releases/latest ^| findstr /i "browser_download_url.*64bit.msi"') do (
+ if not defined PERL_URL set "PERL_URL=%%~U"
+)
+if not defined PERL_URL (
+ echo install_tools.bat: ERROR: could not detect latest Strawberry Perl installer. >&2
+ echo Install Perl manually from https://strawberryperl.com >&2
+ exit /b 1
+)
+for %%F in ("%PERL_URL%") do set "PERL_MSI=%%~nxF"
+echo install_tools.bat: downloading %PERL_URL%... >&2
+curl -L -o "%TEMP%\%PERL_MSI%" "%PERL_URL%"
+if errorlevel 1 (
+ echo install_tools.bat: ERROR: download failed. >&2
+ exit /b 1
+)
+
+echo install_tools.bat: installing Strawberry Perl %PERL_VERSION%... >&2
+msiexec /i "%TEMP%\%PERL_MSI%" /quiet /norestart INSTALLDIR="C:\Strawberry"
+if errorlevel 1 (
+ echo install_tools.bat: ERROR: Strawberry Perl installation failed. >&2
+ exit /b 1
+)
+del "%TEMP%\%PERL_MSI%" 2>nul
+
+rem Re-detect after install
+set PERL=
+if exist "C:\Strawberry\perl\bin\perl.exe" (
+ set "PERL=C:\Strawberry\perl\bin\perl.exe"
+ set "PATH=C:\Strawberry\perl\bin;C:\Strawberry\c\bin;!PATH!"
+)
+if not defined PERL (
+ echo install_tools.bat: ERROR: Perl still not found after install. >&2
+ exit /b 1
+)
+echo install_tools.bat: Strawberry Perl installed at "!PERL!" >&2
+
+:perl_ok
+
+rem --- vcpkg location ---------------------------------------------------------
+if not defined VCPKG_TRIPLET set "VCPKG_TRIPLET=x64-windows-release"
+
+rem Honor VCPKG_DIR from the environment (e.g. set by CI)
+if defined VCPKG_DIR (
+ if not defined VCPKG_INSTALLED set "VCPKG_INSTALLED=%VCPKG_DIR%\installed\%VCPKG_TRIPLET%"
+ if exist "%VCPKG_DIR%\vcpkg.exe" (
+ set "VCPKG_EXE=%VCPKG_DIR%\vcpkg.exe"
+ echo install_tools.bat: using VCPKG_DIR from environment: "!VCPKG_DIR!" >&2
+ goto :vcpkg_ok
+ )
+)
+
+rem Try vcpkg on PATH (e.g. GitHub Actions has C:\vcpkg)
+set "VCPKG_EXE="
+for /f "delims=" %%V in ('where vcpkg.exe 2^>nul') do (
+ if not defined VCPKG_EXE set "VCPKG_EXE=%%V"
+)
+if defined VCPKG_EXE (
+ echo install_tools.bat: using vcpkg from PATH: "!VCPKG_EXE!" >&2
+ for %%F in ("!VCPKG_EXE!") do set "VCPKG_DIR=%%~dpF"
+ if "!VCPKG_DIR:~-1!"=="\" set "VCPKG_DIR=!VCPKG_DIR:~0,-1!"
+ if not defined VCPKG_INSTALLED set "VCPKG_INSTALLED=!VCPKG_DIR!\installed\%VCPKG_TRIPLET%"
+ goto :vcpkg_ok
+)
+
+rem Fall back to cloning vcpkg locally
+set "VCPKG_DIR=%TEST_DIR%\vcpkg"
+if not defined VCPKG_INSTALLED set "VCPKG_INSTALLED=%VCPKG_DIR%\installed\%VCPKG_TRIPLET%"
+
+rem --- bootstrap vcpkg if needed ----------------------------------------------
+if not exist "%VCPKG_DIR%\" (
+ echo install_tools.bat: cloning vcpkg... >&2
+ git clone https://github.com/microsoft/vcpkg.git "%VCPKG_DIR%"
+ if errorlevel 1 (
+ echo install_tools.bat: ERROR: git clone failed. >&2
+ exit /b 1
+ )
+) else (
+ echo install_tools.bat: vcpkg already present, updating... >&2
+ pushd "%VCPKG_DIR%"
+ git pull
+ popd
+)
+
+if not exist "%VCPKG_DIR%\vcpkg.exe" (
+ echo install_tools.bat: bootstrapping vcpkg... >&2
+ pushd "%VCPKG_DIR%"
+ icacls scripts\tls12-download.exe /grant "%USERNAME%":RX >nul 2>&1
+ call bootstrap-vcpkg.bat
+ if errorlevel 1 (
+ popd
+ echo install_tools.bat: ERROR: vcpkg bootstrap failed. >&2
+ exit /b 1
+ )
+ popd
+)
+
+icacls "%VCPKG_DIR%\vcpkg.exe" /grant "%USERNAME%":RX >nul 2>&1
+set "VCPKG_EXE=%VCPKG_DIR%\vcpkg.exe"
+
+:vcpkg_ok
+
+rem --- install packages -------------------------------------------------------
+echo install_tools.bat: installing curl[core,tool,http2,openssl] and nghttp2 via vcpkg... >&2
+"%VCPKG_EXE%" install --recurse --triplet %VCPKG_TRIPLET% curl[core,tool,http2,openssl] nghttp2
+if errorlevel 1 (
+ echo install_tools.bat: ERROR: vcpkg install failed. >&2
+ exit /b 1
+)
+
+rem --- copy binaries to httpd prefix ------------------------------------------
+echo install_tools.bat: copying binaries to "%PREFIX%\bin"... >&2
+
+if exist "%VCPKG_INSTALLED%\tools\curl\curl.exe" (
+ copy /y "%VCPKG_INSTALLED%\tools\curl\curl.exe" "%PREFIX%\bin\" >nul
+ echo curl.exe installed >&2
+) else (
+ echo WARNING: curl.exe not found in vcpkg output >&2
+ dir /s /b "%VCPKG_INSTALLED%\*curl*.exe" 2>nul
+)
+
+rem Copy all supporting DLLs (curl needs libcurl, openssl, zlib, nghttp2, etc.)
+if exist "%VCPKG_INSTALLED%\bin\*.dll" (
+ copy /y "%VCPKG_INSTALLED%\bin\*.dll" "%PREFIX%\bin\" >nul
+ echo vcpkg DLLs copied >&2
+)
+
+rem --- verify -----------------------------------------------------------------
+if exist "%PREFIX%\bin\curl.exe" (
+ echo install_tools.bat: verifying curl: >&2
+ "%PREFIX%\bin\curl.exe" --version
+) else (
+ echo install_tools.bat: WARNING: curl.exe was not installed. >&2
+ exit /b 1
+)
+
+echo install_tools.bat: done. >&2
+exit /b 0
diff -Nru apache2-2.4.69/test/pyhttpd/instance.py apache2-2.4.71/test/pyhttpd/instance.py
--- apache2-2.4.69/test/pyhttpd/instance.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/instance.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,430 @@
+"""One running httpd: start, stop, restart, reap.
+
+The server-control core shared by both suites: HttpdTestEnv (modules/),
+HttpdServer (pytest_suite) and PrivateServer (throwaway servers) all drive an
+HttpdInstance, and differ only in how they build the command line.
+
+The server runs in the foreground (``-DFOREGROUND``) under our control, in its
+own session on Unix, so that
+
+* the whole process group, MPM children included, can be signalled and no
+ child is left holding a port after a failed or interrupted run,
+* startup problems show on the stderr we captured, whether the configuration
+ is rejected or a module fails in post_config,
+* graceful and hard restarts are the signals ``httpd -k`` would send to the
+ parent, and we wait for the new generation to be serving: "httpd -k
+ restart" only sends the signal, and a request answered straight afterwards
+ may have been served by the old generation, with the old config, about to
+ be killed.
+
+On Windows there are no process groups and no signals; the parent is told to
+shut down or restart through the named events of the winnt MPM.
+
+An instance knows nothing about tests, curl or certificates. Callers that
+want a stricter notion of "live" than "the ports accept connections" check
+it themselves once start() returns.
+"""
+
+import contextlib
+import errno
+import logging
+import os
+import re
+import signal
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Sequence, Union
+
+from .log import HttpdErrorLog
+
+log = logging.getLogger(__name__)
+
+IS_WINDOWS = sys.platform == "win32"
+
+# Logged by every MPM once the new generation is serving. The startup flag
+# makes it appear whatever the LogLevel is.
+RE_RESUMING = re.compile(r'.* configured -- resuming normal operations$')
+
+
+class HttpdStartError(RuntimeError):
+ """httpd did not come up. ``returncode`` is its exit code, or -1 when it
+ was still running but never became ready; ``stderr`` is what it wrote."""
+
+ def __init__(self, message: str, returncode: int = -1, stderr: str = ""):
+ super().__init__(message)
+ self.returncode = returncode
+ self.stderr = stderr
+
+
+def _win_pid_alive(pid: int) -> bool:
+ # os.kill(pid, 0) is no probe on Windows: 0 is CTRL_C_EVENT, which it
+ # sends. Ask for the exit code instead.
+ import ctypes
+ from ctypes import wintypes
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+ STILL_ACTIVE = 259
+ ERROR_ACCESS_DENIED = 5
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.OpenProcess.restype = wintypes.HANDLE
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
+ kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
+ if not handle:
+ # exists, but is not ours to query
+ return ctypes.get_last_error() == ERROR_ACCESS_DENIED
+ try:
+ code = wintypes.DWORD()
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
+ return False
+ return code.value == STILL_ACTIVE
+ finally:
+ kernel32.CloseHandle(handle)
+
+
+def pid_alive(pid: int) -> bool:
+ """Whether a process with this pid exists (``kill(0)``)."""
+ if pid <= 0:
+ return False
+ if IS_WINDOWS:
+ return _win_pid_alive(pid)
+ try:
+ os.kill(pid, 0)
+ except OSError as exc:
+ # ESRCH: no such process. EPERM: exists but not ours, still alive.
+ return exc.errno == errno.EPERM
+ return True
+
+
+def read_pid(pid_file: Union[str, Path]) -> int:
+ """The pid in a pid file; 0 if missing, empty or garbage."""
+ try:
+ return int(Path(pid_file).read_text().strip())
+ except (OSError, ValueError):
+ return 0
+
+
+def signal_group(pid: int, sig: int) -> None:
+ """Send *sig* to the process group led by *pid*, else to *pid* alone.
+
+ Children of an httpd started with start_new_session share the parent's
+ group id (== its pid), so signalling the group reaches the parent and
+ all its workers. On Windows there are no groups; signal the pid.
+ """
+ if pid <= 0:
+ return
+ if IS_WINDOWS:
+ with contextlib.suppress(OSError):
+ os.kill(pid, sig)
+ return
+ try:
+ os.killpg(pid, sig)
+ except OSError as exc:
+ if exc.errno == errno.ESRCH:
+ return
+ with contextlib.suppress(OSError):
+ os.kill(pid, sig)
+
+
+def port_open(port: int, host: str = "127.0.0.1", timeout: float = 0.25) -> bool:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(timeout)
+ return s.connect_ex((host, port)) == 0
+
+
+def free_port() -> int:
+ """A port nobody listens on right now."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+class HttpdInstance:
+ """A single httpd, started, stopped and restarted by the test code.
+
+ :param httpd: the httpd executable
+ :param args: the command line after the executable, e.g.
+ ``["-d", root, "-f", conf, "-DFOREGROUND"]``. -DFOREGROUND is added
+ when missing.
+ :param ports: ports that must accept connections once started
+ :param error_log: the ErrorLog of the server, as a path or an
+ HttpdErrorLog. Needed to wait for restarts to complete.
+ :param pid_files: pid files that may name a server left behind by an
+ earlier run; whatever they name is stopped before a start.
+ :param env: environment of the server, default is ours
+ :param stderr_file: where the stderr of the server goes (default: a
+ temporary file)
+ :param configtest_first: run ``httpd -t`` before starting. Costs a
+ second parse; the foreground start reports the same errors.
+ """
+
+ def __init__(self, httpd: Union[str, Path], args: Sequence[str], *,
+ ports: Iterable[int] = (),
+ error_log: Union[None, str, Path, HttpdErrorLog] = None,
+ pid_files: Iterable[Union[str, Path]] = (),
+ env: Optional[Dict[str, str]] = None,
+ stderr_file: Union[None, str, Path] = None,
+ configtest_first: bool = False):
+ self.httpd = str(httpd)
+ self.args: List[str] = list(args)
+ if "-DFOREGROUND" not in self.args:
+ self.args.append("-DFOREGROUND")
+ self.ports: List[int] = list(ports)
+ if isinstance(error_log, (str, Path)):
+ error_log = HttpdErrorLog(str(error_log))
+ self.error_log: Optional[HttpdErrorLog] = error_log
+ self.pid_files = [Path(p) for p in pid_files]
+ self.env = env
+ self._stderr_file = Path(stderr_file) if stderr_file else None
+ self.configtest_first = configtest_first
+ self.proc: Optional[subprocess.Popen] = None
+
+ # -- state ------------------------------------------------------------
+
+ @property
+ def pid(self) -> int:
+ """Pid of the parent process, 0 if not running."""
+ return self.proc.pid if self.is_running() else 0
+
+ def is_running(self) -> bool:
+ return self.proc is not None and self.proc.poll() is None
+
+ def all_ports_open(self) -> bool:
+ return all(port_open(p) for p in self.ports)
+
+ def stderr_text(self) -> str:
+ """What the server wrote to stderr since it was last started."""
+ if self._stderr_file is None:
+ return ""
+ try:
+ return self._stderr_file.read_text(errors="replace")
+ except OSError:
+ return ""
+
+ # -- command line -----------------------------------------------------
+
+ def argv(self, *extra: str) -> List[str]:
+ return [self.httpd, *self.args, *extra]
+
+ def configtest(self) -> subprocess.CompletedProcess:
+ """Run ``httpd ... -t``."""
+ return subprocess.run(self.argv("-t"), capture_output=True, text=True,
+ env=self.env, check=False)
+
+ # -- stale servers ----------------------------------------------------
+
+ def kill_stale(self, timeout: float = 10.0) -> None:
+ """Stop whatever the pid files name: a server left behind by a crashed
+ or interrupted run. Only those pids and their groups are touched."""
+ for pid_file in self.pid_files:
+ pid = read_pid(pid_file)
+ if pid and pid_alive(pid) and not (self.proc and pid == self.proc.pid):
+ # the parent first, as "httpd -k stop" does: it shuts down its
+ # children, and whatever they started, in order
+ with contextlib.suppress(OSError):
+ os.kill(pid, signal.SIGTERM)
+ if not self._wait_gone(pid, timeout):
+ signal_group(pid, signal.SIGTERM if IS_WINDOWS else signal.SIGKILL)
+ self._wait_gone(pid, timeout)
+ self._reap_group(pid)
+ with contextlib.suppress(OSError):
+ if pid_file.exists() and not pid_alive(pid):
+ pid_file.unlink()
+
+ @staticmethod
+ def _reap_group(pgid: int, timeout: float = 2.0) -> None:
+ """Make sure nothing of the process group of a stopped parent is left:
+ a helper that outlived it would hold ports or files. Normally the
+ group is empty by now and this does nothing."""
+ if IS_WINDOWS or pgid <= 0:
+ return
+ for sig in (signal.SIGTERM, signal.SIGKILL):
+ try:
+ os.killpg(pgid, 0) # any member left?
+ except OSError:
+ return
+ signal_group(pgid, sig)
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ try:
+ os.killpg(pgid, 0)
+ except OSError:
+ return
+ time.sleep(0.05)
+
+ @staticmethod
+ def _wait_gone(pid: int, timeout: float) -> bool:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if not pid_alive(pid):
+ return True
+ time.sleep(0.05)
+ return not pid_alive(pid)
+
+ # -- start ------------------------------------------------------------
+
+ def start(self, timeout: float = 30.0) -> None:
+ """Start the server and wait until it is serving.
+
+ Raises HttpdStartError if it does not come up, with its exit code
+ and the stderr it wrote; nothing is left running in that case.
+ """
+ self.stop()
+ self.kill_stale()
+ if self.configtest_first:
+ check = self.configtest()
+ if check.returncode != 0:
+ raise HttpdStartError(
+ f"httpd config test failed:\n{check.stdout}\n{check.stderr}",
+ check.returncode, check.stderr)
+
+ if self._stderr_file is None:
+ fd, name = tempfile.mkstemp(prefix="httpd-stderr-")
+ os.close(fd)
+ self._stderr_file = Path(name)
+ log_pos = self.error_log.current_pos() if self.error_log else 0
+ popen_kwargs = {}
+ if not IS_WINDOWS:
+ popen_kwargs["start_new_session"] = True
+ argv = self.argv()
+ log.debug("starting httpd: %s", argv)
+ with open(self._stderr_file, "wb") as err:
+ self.proc = subprocess.Popen( # noqa: S603 - trusted paths
+ argv, env=self.env, stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL, stderr=err, **popen_kwargs)
+
+ deadline = time.monotonic() + timeout
+ resumed = self.error_log is None
+ while time.monotonic() < deadline:
+ rc = self.proc.poll()
+ if rc is not None:
+ self.stop()
+ raise HttpdStartError(
+ f"httpd exited early (code {rc})", rc, self.stderr_text())
+ if not resumed:
+ resumed = self.error_log.wait_for(RE_RESUMING, log_pos, timeout=0.1)
+ if resumed and self.all_ports_open():
+ return
+ if resumed:
+ time.sleep(0.05)
+ stderr = self.stderr_text()
+ self.stop()
+ raise HttpdStartError(
+ f"httpd not serving {self.ports or ''} within {timeout}s", -1, stderr)
+
+ # -- stop -------------------------------------------------------------
+
+ def stop(self, timeout: float = 10.0) -> bool:
+ """Stop the server, the whole process group, now. Idempotent. Returns
+ True when nothing of it is left, and its ports are closed."""
+ proc = self.proc
+ if proc is not None:
+ if IS_WINDOWS:
+ self._win_shutdown(proc, timeout)
+ elif proc.poll() is None:
+ # The parent only, as "httpd -k stop" does: it shuts down its
+ # children, and the processes they started (mod_md runs
+ # commands, for one), in order. Signalling the whole group at
+ # once makes such helpers die with SIGTERM and report errors.
+ with contextlib.suppress(OSError):
+ os.kill(proc.pid, signal.SIGTERM)
+ try:
+ proc.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ signal_group(proc.pid, signal.SIGKILL)
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ proc.wait(timeout=timeout)
+ else:
+ with contextlib.suppress(Exception):
+ proc.wait(timeout=0)
+ if not IS_WINDOWS:
+ self._reap_group(proc.pid)
+ self.proc = None
+ self.kill_stale(timeout)
+ return self._wait_ports_closed(timeout)
+
+ def graceful_stop(self, timeout: float = 30.0) -> bool:
+ """Ask the parent to stop once its children are done, and wait for
+ that. Escalates to a hard stop when it takes longer than *timeout*."""
+ proc = self.proc
+ if proc is None or proc.poll() is not None:
+ return self.stop(timeout)
+ if not IS_WINDOWS: # there is no graceful stop signal on Windows
+ with contextlib.suppress(OSError):
+ os.kill(proc.pid, signal.SIGWINCH)
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ proc.wait(timeout=timeout)
+ # reaps the group, and hard-stops when the wait above ran out
+ return self.stop(timeout)
+
+ def _wait_ports_closed(self, timeout: float) -> bool:
+ deadline = time.monotonic() + timeout
+ while any(port_open(p) for p in self.ports):
+ if time.monotonic() > deadline:
+ log.warning("ports %s still open after stop", self.ports)
+ return False
+ time.sleep(0.05)
+ return True
+
+ # -- restart ----------------------------------------------------------
+
+ def restart(self, graceful: bool = True, timeout: float = 10.0) -> bool:
+ """Have the running parent restart and wait for the new generation to
+ be serving. Graceful lets in-flight requests finish; otherwise the
+ MPM starts over. False if there is no server, or the restart did not
+ show up in the error log within *timeout*."""
+ if not self.is_running():
+ log.error("no httpd process to restart")
+ return False
+ log_pos = self.error_log.current_pos() if self.error_log else 0
+ if IS_WINDOWS:
+ if not self._win_signal(f"ap{self.proc.pid}_restart"):
+ return False
+ else:
+ os.kill(self.proc.pid, signal.SIGUSR1 if graceful else signal.SIGHUP)
+ if self.error_log is not None and not self.error_log.wait_for(
+ RE_RESUMING, log_pos, timeout=timeout):
+ log.warning("no restart logged after %s within %ss",
+ "graceful" if graceful else "restart", timeout)
+ return False
+ deadline = time.monotonic() + timeout
+ while not self.all_ports_open():
+ if time.monotonic() > deadline:
+ return False
+ time.sleep(0.05)
+ return True
+
+ def send_signal(self, sig: int) -> None:
+ """Signal the parent process only."""
+ if self.is_running():
+ os.kill(self.proc.pid, sig)
+
+ # -- Windows ----------------------------------------------------------
+
+ @staticmethod
+ def _win_signal(event_name: str) -> bool:
+ import ctypes
+ handle = ctypes.windll.kernel32.OpenEventW(0x0002, False, event_name)
+ if not handle:
+ log.error("cannot open event %s", event_name)
+ return False
+ ctypes.windll.kernel32.SetEvent(handle)
+ ctypes.windll.kernel32.CloseHandle(handle)
+ return True
+
+ def _win_shutdown(self, proc: subprocess.Popen, timeout: float) -> None:
+ if proc.poll() is not None:
+ return
+ log.debug("stopping httpd (signal shutdown event)")
+ if not self._win_signal(f"ap{proc.pid}_shutdown"):
+ log.warning("falling back to terminate")
+ proc.terminate()
+ try:
+ proc.wait(timeout=max(timeout, 30))
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait(timeout=5)
diff -Nru apache2-2.4.69/test/pyhttpd/log.py apache2-2.4.71/test/pyhttpd/log.py
--- apache2-2.4.69/test/pyhttpd/log.py 2026-09-28 20:21:42.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/log.py 2026-11-09 03:07:05.000000000 +0000
@@ -41,6 +41,11 @@
def path(self) -> str:
return self._path
+ def start_at(self, pos: int):
+ """Check from file position *pos* on, e.g. the size of the log before
+ the server was started."""
+ self._start_pos = self._recent_pos = pos
+
def clear_log(self):
if os.path.isfile(self.path):
os.truncate(self.path, 0)
@@ -146,6 +151,31 @@
self._caught_matches = set()
return errors, warnings
+ def current_pos(self) -> int:
+ """The end of the log as it stands now, to wait_for() lines
+ logged after it.
+ """
+ if not os.path.isfile(self._path):
+ return 0
+ with open(self._path) as fd:
+ return fd.seek(0, SEEK_END)
+
+ def wait_for(self, pattern: re.Pattern, start_pos: int, timeout=10) -> bool:
+ """Wait for a line matching pattern to be logged after start_pos,
+ as returned by current_pos().
+ """
+ end = datetime.now() + timedelta(seconds=timeout)
+ while True:
+ if os.path.isfile(self._path):
+ with open(self._path) as fd:
+ fd.seek(start_pos, os.SEEK_SET)
+ for line in fd:
+ if pattern.match(line):
+ return True
+ if datetime.now() > end:
+ return False
+ time.sleep(.1)
+
def scan_recent(self, pattern: re.Pattern, timeout=10):
if not os.path.isfile(self.path):
return False
diff -Nru apache2-2.4.69/test/pyhttpd/mod_aptest/mod_aptest.c apache2-2.4.71/test/pyhttpd/mod_aptest/mod_aptest.c
--- apache2-2.4.69/test/pyhttpd/mod_aptest/mod_aptest.c 2023-01-09 07:35:18.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/mod_aptest/mod_aptest.c 2026-11-09 03:07:05.000000000 +0000
@@ -51,6 +51,85 @@
return DECLINED;
}
+/*
+ * Register the methods named in an "AP-Test-Allow-Methods" request header
+ * via ap_allow_methods(), so a test can check how they are reflected in the
+ * Allow response header.
+ */
+static int aptest_allow_methods(request_rec *r)
+{
+ const char *methods = apr_table_get(r->headers_in, "AP-Test-Allow-Methods");
+ char *list, *name, *last;
+
+ if (methods == NULL) {
+ return DECLINED;
+ }
+
+ list = apr_pstrdup(r->pool, methods);
+ for (name = apr_strtok(list, ", ", &last); name != NULL;
+ name = apr_strtok(NULL, ", ", &last)) {
+ ap_allow_methods(r, MERGE_ALLOW, name, NULL);
+ }
+
+ return DECLINED;
+}
+
+/*
+ * Handler "aptest-getline-echo": echo the request body back, reading it
+ * with AP_MODE_GETLINE rather than AP_MODE_READBYTES. The Content-Length
+ * request header left once the body has been read, if any, is returned in
+ * an "AP-Test-Content-Length" response header.
+ */
+static int aptest_getline_echo(request_rec *r)
+{
+ apr_bucket_brigade *bb, *body;
+ const char *clen;
+ char *data;
+ apr_size_t len;
+ apr_status_t rv;
+ int seen_eos = 0;
+
+ if (strcmp(r->handler, "aptest-getline-echo")) {
+ return DECLINED;
+ }
+
+ bb = apr_brigade_create(r->pool, r->connection->bucket_alloc);
+ body = apr_brigade_create(r->pool, r->connection->bucket_alloc);
+ while (!seen_eos) {
+ rv = ap_get_brigade(r->input_filters, bb, AP_MODE_GETLINE,
+ APR_BLOCK_READ, HUGE_STRING_LEN);
+ if (rv != APR_SUCCESS) {
+ ap_log_rerror(APLOG_MARK, APLOG_ERR, rv, r,
+ "aptest-getline-echo: reading body failed");
+ return ap_map_http_request_error(rv, HTTP_BAD_REQUEST);
+ }
+ if (!APR_BRIGADE_EMPTY(bb)
+ && APR_BUCKET_IS_EOS(APR_BRIGADE_LAST(bb))) {
+ seen_eos = 1;
+ }
+ rv = apr_brigade_pflatten(bb, &data, &len, r->pool);
+ if (rv == APR_SUCCESS) {
+ rv = apr_brigade_write(body, NULL, NULL, data, len);
+ }
+ if (rv != APR_SUCCESS) {
+ return HTTP_INTERNAL_SERVER_ERROR;
+ }
+ apr_brigade_cleanup(bb);
+ }
+
+ clen = apr_table_get(r->headers_in, "Content-Length");
+ if (clen) {
+ apr_table_setn(r->headers_out, "AP-Test-Content-Length", clen);
+ }
+ ap_set_content_type(r, "text/plain");
+ if (apr_brigade_pflatten(body, &data, &len, r->pool) != APR_SUCCESS) {
+ return HTTP_INTERNAL_SERVER_ERROR;
+ }
+ ap_rwrite(data, len, r);
+
+ return OK;
+}
+
/* Install this module into the apache2 infrastructure.
*/
static void aptest_hooks(apr_pool_t *pool)
@@ -61,6 +140,8 @@
/* test case monitoring */
ap_hook_post_read_request(aptest_post_read_request, NULL,
NULL, APR_HOOK_MIDDLE);
+ ap_hook_fixups(aptest_allow_methods, NULL, NULL, APR_HOOK_MIDDLE);
+ ap_hook_handler(aptest_getline_echo, NULL, NULL, APR_HOOK_MIDDLE);
}
diff -Nru apache2-2.4.69/test/pyhttpd/plugin.py apache2-2.4.71/test/pyhttpd/plugin.py
--- apache2-2.4.69/test/pyhttpd/plugin.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/plugin.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,93 @@
+"""Command-line options for the test suites under test/.
+
+Registered here, in a plugin the root conftest loads, rather than in the
+conftests of modules/ or pytest_suite/: pytest only sees options from conftests
+on the invocation path, so options defined in a subdirectory conftest are
+"unrecognized" when pytest is run from test/ with no path arguments. A single
+registration point also settles the options both suites share (--httpd, --conf),
+since pytest rejects a duplicate addoption.
+
+It also settles, once and before anything is collected, which httpd the
+session tests: see pyhttpd.build.resolve_build.
+"""
+import pytest
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_configure(config):
+ from .build import TargetError, resolve_build
+ try:
+ resolve_build(config)
+ except TargetError as exc:
+ raise pytest.UsageError(f"httpd under test: {exc}") from exc
+
+
+def pytest_addoption(parser):
+ # modules/ (pyhttpd)
+ parser.addoption("--repeat", action="store", type=int, default=1,
+ help='Number of times to repeat each test')
+ parser.addoption("--all", action="store_true")
+ parser.addoption("--archive", action="store", default=None,
+ help='Archive the server dir after each test package '
+ 'to the specified folder')
+
+ group = parser.getgroup("httpd")
+ group.addoption("--base-port", action="store", type=int, default=None,
+ help="modules/: listen on this port and the next four instead of "
+ "free ports picked per test environment")
+ group.addoption("--fixed-ports", action="store_true", default=False,
+ help="modules/: listen on the ports of the build's config.ini "
+ "(5001 ...) instead of free ports")
+ group.addoption("--dynamic-ports", action="store_true", default=False,
+ help="modules/: free ports; this is the default, the option is "
+ "only kept for existing scripts")
+ # shared by both suites
+ group.addoption("--httpd", action="store", default=None,
+ help="path to httpd binary (modules/: used to derive prefix, "
+ "version, etc.; pytest_suite: default is derived from --apxs)")
+ group.addoption("--conf", action="store", default=None,
+ help="path to the installed httpd.conf, for LoadModule discovery "
+ "(default: the one of --apxs; needed where apxs is not, e.g. "
+ "on Windows)")
+ # pytest_suite
+ group.addoption("--apxs", action="store", default=None, help="path to apxs")
+ group.addoption(
+ "--defines",
+ action="store",
+ default="",
+ help="space-separated extra -D defines (e.g. 'LDAP')",
+ )
+ group.addoption(
+ "--php-fpm",
+ action="store",
+ default=None,
+ help="path to a php-fpm binary; enables the t/php tests via mod_proxy_fcgi "
+ "(any version/location -- the path is the only PHP-specific input)",
+ )
+ group.addoption(
+ "--php-fpm-port",
+ action="store",
+ type=int,
+ default=8999,
+ help="TCP port for the managed php-fpm pool (default 8999)",
+ )
+ group.addoption(
+ "--clean-modules",
+ action="store_true",
+ default=False,
+ help="remove all compiled C-module artifacts before building (emulate make clean)",
+ )
+ group.addoption(
+ "--prefix",
+ action="store",
+ default=None,
+ help="server install prefix for resolving relative module paths "
+ "(default: derived from --conf path)",
+ )
+ group.addoption(
+ "--with-perl",
+ action="store",
+ default=None,
+ help="path to the perl interpreter for generated scripts and "
+ "rewrite prg-maps (default: /usr/bin/perl or APACHE_TEST_PERL env)",
+ )
diff -Nru apache2-2.4.69/test/pyhttpd/ports.py apache2-2.4.71/test/pyhttpd/ports.py
--- apache2-2.4.69/test/pyhttpd/ports.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/ports.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,60 @@
+"""The ports the modules/ suite's httpd listens on.
+
+By default the five test ports are free ports, picked per test environment
+(full runs of both suites on event and worker showed no difference from the
+fixed ports). Two options change that:
+
+ --base-port N use N, N+1, ... instead, e.g. to know the ports in advance
+ --fixed-ports use the build's config.ini ports (5001 ...), which is what
+ the suite used before; also what an environment created
+ without a pytest config gets
+
+Tests read their ports from the environment (env.http_port etc.) and the
+generated configuration, pebble's included, is built from the same values, so
+any of the three works. Dynamic ports, and one directory per instance, are
+what running several servers side by side (pytest-xdist) needs.
+"""
+
+import random
+import socket
+from typing import Dict, List
+
+# config.ini key, in the order --base-port hands out consecutive numbers
+PORT_KEYS = ("https_port", "http_port", "proxy_port", "http_port2", "ws_port")
+
+# Below the ephemeral ranges of Linux (32768-) and macOS/BSD (49152-), so that
+# a port is not taken by an outgoing connection between picking and binding.
+DYNAMIC_RANGE = (20000, 30000)
+
+
+def free_ports(count: int, host: str = "127.0.0.1") -> List[int]:
+ """*count* distinct ports that nobody listens on right now."""
+ socks, ports = [], []
+ try:
+ while len(ports) < count:
+ port = random.randrange(*DYNAMIC_RANGE)
+ if port in ports:
+ continue
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ s.bind((host, port))
+ except OSError:
+ s.close()
+ continue
+ socks.append(s)
+ ports.append(port)
+ finally:
+ # held until all are chosen, so they are distinct
+ for s in socks:
+ s.close()
+ return ports
+
+
+def resolve_ports(defaults: Dict[str, int], base_port: int = None,
+ fixed: bool = False) -> Dict[str, int]:
+ """The ports to use, by PORT_KEYS name; *defaults* are the config.ini ones."""
+ if base_port:
+ return {key: base_port + i for i, key in enumerate(PORT_KEYS)}
+ if fixed:
+ return {key: defaults[key] for key in PORT_KEYS}
+ return dict(zip(PORT_KEYS, free_ports(len(PORT_KEYS))))
diff -Nru apache2-2.4.69/test/pyhttpd/pyproject.toml apache2-2.4.71/test/pyhttpd/pyproject.toml
--- apache2-2.4.69/test/pyhttpd/pyproject.toml 2026-09-28 20:21:42.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/pyproject.toml 1970-01-01 00:00:00.000000000 +0000
@@ -1,16 +0,0 @@
-[project]
-name = "httpd-pyhttpd-tests"
-version = "0.1.0"
-requires-python = ">=3.9"
-dependencies = [
- "pytest>=7.0",
- "cryptography",
- "filelock",
- "python-multipart",
- "pyopenssl",
- "packaging",
- "websockets",
-]
-
-[tool.uv]
-package = false
diff -Nru apache2-2.4.69/test/pyhttpd/runtests.sh apache2-2.4.71/test/pyhttpd/runtests.sh
--- apache2-2.4.69/test/pyhttpd/runtests.sh 2026-07-18 15:32:22.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/runtests.sh 1970-01-01 00:00:00.000000000 +0000
@@ -1,87 +0,0 @@
-#!/bin/sh
-#
-# runtests.sh -- run the pyhttpd modules/ test suite.
-#
-# Manages a local .venv under pyhttpd/ so that pytest and the CGI helper scripts
-# (which httpd forks) both use the same Python with all required packages
-# (cryptography, python-multipart, websockets, etc.) available.
-#
-# Usage:
-# ./runtests.sh # run all modules/ tests
-# ./runtests.sh modules/http1 # run a specific suite
-# ./runtests.sh -k post -v # any pytest args pass through
-#
-# Environment:
-# PYHTTPD_TARGETS space-separated list of test paths (default: modules)
-#
-set -eu
-
-here="$(cd "$(dirname "$0")" && pwd)"
-
-# --- ensure the venv exists and is current ----------------------------------
-# The suite baselines on uv (https://docs.astral.sh/uv/) as its dependency and
-# venv manager: it reads pyproject.toml + uv.lock, so there is a single source
-# of truth for dependencies. We invoke .venv/bin/pytest directly rather than
-# `uv run` so the suite works even where `uv run` is shimmed/unavailable.
-#
-# Create $here/.venv on first run, and rebuild it when pyproject.toml is newer
-# than the venv (i.e. dependencies changed). Absolute paths throughout, so this
-# behaves identically regardless of the caller's cwd. This block is kept
-# byte-for-byte identical in pytest_suite/runtests.sh and pyhttpd/runtests.sh
-# -- edit both together.
-PYTEST="$here/.venv/bin/pytest"
-if [ ! -x "$PYTEST" ] || [ "$here/pyproject.toml" -nt "$here/.venv" ]; then
- if ! command -v uv >/dev/null 2>&1; then
- echo "runtests.sh: ERROR: 'uv' is required but not installed." >&2
- echo " Install it from https://docs.astral.sh/uv/ and re-run." >&2
- exit 1
- fi
- echo "runtests.sh: (re)creating $here/.venv via 'uv sync'..." >&2
- uv sync --project "$here"
- # Mark the venv as freshly built so the staleness check above won't retrigger
- # until pyproject.toml changes again.
- touch "$here/.venv"
-fi
-
-# Prepend the venv's bin dir so that CGI scripts forked by httpd also resolve
-# python3 to the venv's interpreter (which has all packages installed), and so
-# that any shim wrappers earlier on PATH are shadowed.
-export PATH="$here/.venv/bin:$PATH"
-
-# The modules/ test suite lives in test/, a sibling of this script's directory
-# (test/pyhttpd/) -- cd there so both the default target and any
-# PYHTTPD_TARGETS/positional path the caller supplies resolve the same way
-# regardless of where runtests.sh was invoked from.
-cd "$(dirname "$here")"
-
-# Only fall back to the "modules" default when the caller gave no positional
-# test path of their own -- otherwise it would always tag along after theirs
-# (`pytest modules modules/http1`), silently widening any subset selection
-# back out to the full suite. A positional path is recognized by actually
-# existing on disk (relative to test/, our cwd at this point) -- this avoids
-# both having to enumerate every pytest flag that takes a separate-word value
-# (-k, -m, -p, --tb, --maxfail, -n from pytest-xdist, ...) and misdetecting a
-# -k/-m expression that happens to contain '/' (this suite's own parametrize
-# IDs look like "/006/006.css", so "-k 006/006" is a realistic selector, and
-# it does not exist as a path).
-have_path=0
-for arg in "$@"; do
- case "$arg" in
- -*) ;;
- # Strip a trailing ::nodeid (pytest's file::Class::test node-selector
- # syntax) before checking existence -- only the file/dir part is real.
- *) [ -e "${arg%%::*}" ] && have_path=1 ;;
- esac
-done
-
-if [ -n "${PYHTTPD_TARGETS:-}" ]; then
- targets="$PYHTTPD_TARGETS"
-elif [ "$have_path" = 1 ]; then
- targets=""
-else
- targets="modules"
-fi
-
-echo "runtests.sh: $PYTEST $targets $*" >&2
-# shellcheck disable=SC2086 # $targets is an intentional word-split path list
-exec "$PYTEST" $targets "$@"
diff -Nru apache2-2.4.69/test/pyhttpd/sslca.py apache2-2.4.71/test/pyhttpd/sslca.py
--- apache2-2.4.69/test/pyhttpd/sslca.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/sslca.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,331 @@
+"""The certificate authority tree of the classic (Apache::Test) SSL tests.
+
+Python port of Apache::TestSSLCA on the ``cryptography`` package, like the
+rest of the certificate code of this suite (pyhttpd.certs); no openssl command
+line tool is needed to build it. It writes, under ``/asf``, a
+self-signed CA plus server and client certificates in the layout and under
+the file names of the Perl framework, so that the ``t/conf/ssl/*.conf.in``
+files work unchanged:
+
+ certs/ca.crt, keys/ca.pem the CA
+ certs/.crt, keys/.pem server, server2, server_des3,
+ server2_des3 (+ _dsa variants),
+ client_ok, client_snakeoil,
+ client_colon, client_revoked
+ proxy/.pem certificate and key of each client
+ crl/ca-bundle.crl the CRL, with client_revoked in it
+ index.txt, serial the database of "openssl ca" /
+ "openssl ocsp -index", which
+ t/htdocs/modules/cgi/ocsp.pl uses
+
+Differences from the Perl original:
+* RSA keys are 2048 bit, digest sha256 (what modern openssl defaults to).
+* The CA certificate carries keyCertSign and cRLSign: OpenSSL 3 refuses to
+ verify a chain through a CA without them.
+* DSA server variants (``server_dsa`` etc.) are generated too, for config files
+ that may reference them.
+* Keys are PKCS#8 (openssl 3 writes that too); the _des3 ones and the CA key
+ are encrypted with the passphrase ``httpd``.
+"""
+
+import datetime
+import hashlib
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import dsa, rsa
+from cryptography.x509.name import _ASN1Type
+from cryptography.x509.oid import (ExtendedKeyUsageOID, NameOID,
+ ObjectIdentifier)
+
+PASS = "httpd"
+DAYS = 365
+EMAIL_FIELD = "emailAddress"
+
+# CA distinguished name (ca_dn{asf}).
+CA_DN = {
+ "C": "US",
+ "ST": "California",
+ "L": "San Francisco",
+ "O": "ASF",
+ "OU": "httpd-test",
+ "CN": "",
+ EMAIL_FIELD: "test-dev@httpd.apache.org",
+}
+
+# Per-cert DN overrides (cert_dn). CN for server* is set to the servername at
+# generation time (matching generate() in the Perl module).
+CERT_DN: Dict[str, Dict[str, str]] = {
+ "client_snakeoil": {
+ "C": "AU", "ST": "Queensland", "L": "Mackay",
+ "O": "Snake Oil, Ltd.", "OU": "Staff",
+ },
+ "client_ok": {},
+ "client_colon": {"CN": "user:colon"},
+ "client_revoked": {},
+ "server": {"CN": "localhost", "OU": "httpd-test/rsa-test"},
+ "server2": {"CN": "localhost", "OU": "httpd-test/rsa-test-2"},
+ "server_des3": {"CN": "localhost", "OU": "httpd-test/rsa-des3-test"},
+ "server2_des3": {"CN": "localhost", "OU": "httpd-test/rsa-des3-test-2"},
+}
+# DSA variants of every server cert (Perl derives these dynamically).
+for _k in list(CERT_DN):
+ if _k.startswith("server"):
+ _dsa = dict(CERT_DN[_k])
+ _dsa["OU"] = _dsa.get("OU", "").replace("rsa", "dsa")
+ CERT_DN[f"{_k}_dsa"] = _dsa
+
+# DN attributes in the order "openssl req" and the policy of "openssl ca" put them
+_DN_ATTRS: List[Tuple[str, ObjectIdentifier]] = [
+ ("C", NameOID.COUNTRY_NAME),
+ ("ST", NameOID.STATE_OR_PROVINCE_NAME),
+ ("L", NameOID.LOCALITY_NAME),
+ ("O", NameOID.ORGANIZATION_NAME),
+ ("OU", NameOID.ORGANIZATIONAL_UNIT_NAME),
+ ("CN", NameOID.COMMON_NAME),
+ (EMAIL_FIELD, NameOID.EMAIL_ADDRESS),
+]
+
+# nsComment, and the custom OID of the client_ok certificate: the DER encoded
+# UTF8String "Lemons" (0c=UTF8String, 06=length, 4c656d6f6e73="Lemons"), which
+# t/ssl/require.t's certext location asserts: "Lemons" in PeerExtList().
+_NS_COMMENT_OID = ObjectIdentifier("2.16.840.1.113730.1.13")
+_CLIENT_OK_OID = ObjectIdentifier("1.3.6.1.4.1.18060.12.0")
+_NS_COMMENT = b"This Is A Comment"
+
+
+def _openssl_time(t: datetime.datetime) -> str:
+ return t.strftime("%y%m%d%H%M%SZ")
+
+
+def x509_name_hash(name: x509.Name) -> str:
+ """The hash OpenSSL names certificate and CRL files in a directory by
+ (``openssl x509 -hash``, ``openssl crl -hash``): the first four bytes,
+ little endian, of the SHA-1 of the canonical form of the name. In that
+ form every value is a UTF8String, with leading and trailing whitespace
+ removed, runs of whitespace collapsed to one space, and ASCII upper case
+ made lower case.
+ """
+ canon = []
+ for rdn in name.rdns:
+ canon.append(x509.RelativeDistinguishedName([
+ x509.NameAttribute(
+ a.oid, " ".join(a.value.split()).translate(_ASCII_LOWER),
+ _type=_ASN1Type.UTF8String)
+ for a in rdn]))
+ der = x509.Name(canon).public_bytes()
+ # the canonical encoding is the RDNs without the SEQUENCE around them
+ if der[1] < 0x80:
+ body = der[2:]
+ else:
+ body = der[2 + (der[1] & 0x7F):]
+ return f"{int.from_bytes(hashlib.sha1(body).digest()[:4], 'little'):08x}"
+
+
+_ASCII_LOWER = {c: c + 32 for c in range(ord("A"), ord("Z") + 1)}
+
+
+class SSLCA:
+ def __init__(self, ca_root: Path, servername: str, *,
+ openssl: Optional[str] = None) -> None:
+ # ca_root is e.g. t/conf/ssl/ca ; the CA tree lives under ca_root/asf.
+ # *openssl* is accepted for compatibility and unused.
+ self.root = Path(ca_root)
+ self.ca = "asf"
+ self.servername = servername
+ self.dir = self.root / self.ca
+ self._serial = 1
+ self._index: List[str] = []
+ self._ca_key = None
+ self._ca_cert: Optional[x509.Certificate] = None
+ self._dsa_params = None
+
+ # -- helpers ----------------------------------------------------------
+
+ def _dn(self, name: str) -> Dict[str, str]:
+ dn = dict(CA_DN)
+ dn["CN"] = dn["CN"] or name
+ dn.update(CERT_DN.get(name, {}))
+ if name.startswith("server"):
+ dn["CN"] = self.servername
+ return dn
+
+ @staticmethod
+ def _x509_name(dn: Dict[str, str]) -> x509.Name:
+ return x509.Name([x509.NameAttribute(oid, dn[key])
+ for key, oid in _DN_ATTRS if dn.get(key)])
+
+ @staticmethod
+ def _slash_dn(dn: Dict[str, str]) -> str:
+ # as in the database of "openssl ca": a "/" in a value is escaped
+ return "".join(f"/{key}={dn[key].replace('/', chr(92) + '/')}"
+ for key, _ in _DN_ATTRS if dn.get(key))
+
+ @staticmethod
+ def _key_pem(key, encrypted: bool) -> bytes:
+ enc = (serialization.BestAvailableEncryption(PASS.encode())
+ if encrypted else serialization.NoEncryption())
+ return key.private_bytes(serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8, enc)
+
+ def _write(self, rel: str, data: bytes) -> None:
+ (self.dir / rel).write_bytes(data)
+
+ # -- generation steps -------------------------------------------------
+
+ def _init_dirs(self) -> None:
+ for d in ("keys", "newcerts", "certs", "crl", "export", "csr", "conf", "proxy"):
+ (self.dir / d).mkdir(parents=True, exist_ok=True)
+
+ def _new_ca(self, now: datetime.datetime) -> None:
+ dn = self._dn("ca")
+ name = self._x509_name(dn)
+ self._ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ ski = x509.SubjectKeyIdentifier.from_public_key(self._ca_key.public_key())
+ self._ca_cert = (
+ x509.CertificateBuilder()
+ .subject_name(name).issuer_name(name)
+ .public_key(self._ca_key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now).not_valid_after(now + datetime.timedelta(days=DAYS))
+ .add_extension(ski, critical=False)
+ .add_extension(x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski),
+ critical=False)
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+ # OpenSSL 3's verifier rejects a CA cert used to sign certs unless
+ # it carries keyCertSign (and cRLSign for the CRL).
+ .add_extension(x509.KeyUsage(
+ digital_signature=False, content_commitment=False,
+ key_encipherment=False, data_encipherment=False,
+ key_agreement=False, key_cert_sign=True, crl_sign=True,
+ encipher_only=False, decipher_only=False), critical=True)
+ .sign(self._ca_key, hashes.SHA256())
+ )
+ self._write("keys/ca.pem", self._key_pem(self._ca_key, True))
+ self._write("certs/ca.crt", self._ca_cert.public_bytes(serialization.Encoding.PEM))
+
+ def _new_key(self, name: str):
+ if "dsa" in name:
+ if self._dsa_params is None:
+ self._dsa_params = dsa.generate_parameters(key_size=2048)
+ key = self._dsa_params.generate_private_key()
+ else:
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ self._write(f"keys/{name}.pem", self._key_pem(key, "_des3" in name))
+ return key
+
+ def _extensions(self, name: str, dn: Dict[str, str], pub) -> List[Tuple[x509.ExtensionType, bool]]:
+ exts: List[Tuple[x509.ExtensionType, bool]] = []
+ mail = dn[EMAIL_FIELD]
+ if "client_ok" in name: # client_ok_ext
+ exts.append((x509.UnrecognizedExtension(
+ _NS_COMMENT_OID, bytes([0x16, len(_NS_COMMENT)]) + _NS_COMMENT), False))
+ exts.append((x509.UnrecognizedExtension(
+ _CLIENT_OK_OID, bytes.fromhex("0c064c656d6f6e73")), False))
+ exts.append((x509.SubjectAlternativeName([x509.RFC822Name(mail)]), False))
+ elif "client" in name: # client_ext
+ exts.append((x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), False))
+ if "server" in name: # server_ext
+ exts.append((x509.SubjectAlternativeName([x509.DNSName(dn["CN"])]), False))
+ exts.append((x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), False))
+ return exts
+
+ def _new_cert(self, name: str, key, now: datetime.datetime) -> x509.Certificate:
+ dn = self._dn(name)
+ ca_ski = x509.SubjectKeyIdentifier.from_public_key(self._ca_key.public_key())
+ builder = (
+ x509.CertificateBuilder()
+ .subject_name(self._x509_name(dn))
+ .issuer_name(self._ca_cert.subject)
+ .public_key(key.public_key())
+ .serial_number(self._serial)
+ .not_valid_before(now).not_valid_after(now + datetime.timedelta(days=DAYS))
+ )
+ if "server" in name:
+ # the key identifiers are part of server_ext; the client
+ # certificates never had them
+ builder = (
+ builder
+ .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
+ critical=False)
+ .add_extension(
+ x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_ski),
+ critical=False)
+ )
+ for ext, critical in self._extensions(name, dn, key.public_key()):
+ builder = builder.add_extension(ext, critical=critical)
+ cert = builder.sign(self._ca_key, hashes.SHA256())
+ pem = cert.public_bytes(serialization.Encoding.PEM)
+ self._write(f"certs/{name}.crt", pem)
+ self._write(f"newcerts/{self._serial:02X}.pem", pem)
+ # the "openssl ca" database: valid, expires, no revocation, serial,
+ # file "unknown", subject
+ self._index.append(
+ f"V\t{_openssl_time(cert.not_valid_after_utc)}\t\t{self._serial:02X}"
+ f"\tunknown\t{self._slash_dn(dn)}")
+ self._serial += 1
+ return cert
+
+ def _make_proxy_cert(self, name: str, key) -> None:
+ """Certificate and key in one file, proxy/.pem (make_proxy_cert)."""
+ crt = (self.dir / "certs" / f"{name}.crt").read_bytes()
+ self._write(f"proxy/{name}.pem", crt + self._key_pem(key, False))
+
+ def _revoke(self, name: str, cert: x509.Certificate, now: datetime.datetime,
+ revoked: List[Tuple[x509.Certificate, datetime.datetime]]) -> None:
+ # the database line of the certificate becomes an "R" line:
+ # R, expires, revoked at, serial, file, subject
+ for i, line in enumerate(self._index):
+ fields = line.split("\t")
+ if fields[3] == f"{cert.serial_number:02X}":
+ fields[0] = "R"
+ fields[2] = _openssl_time(now)
+ self._index[i] = "\t".join(fields)
+ revoked.append((cert, now))
+
+ def _write_crl(self, now: datetime.datetime,
+ revoked: List[Tuple[x509.Certificate, datetime.datetime]]) -> None:
+ ca_ski = x509.SubjectKeyIdentifier.from_public_key(self._ca_key.public_key())
+ builder = (
+ x509.CertificateRevocationListBuilder()
+ .issuer_name(self._ca_cert.subject)
+ .last_update(now).next_update(now + datetime.timedelta(days=DAYS))
+ .add_extension(x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_ski),
+ critical=False)
+ .add_extension(x509.CRLNumber(1), critical=False)
+ )
+ for cert, when in revoked:
+ builder = builder.add_revoked_certificate(
+ x509.RevokedCertificateBuilder()
+ .serial_number(cert.serial_number).revocation_date(when).build())
+ crl = builder.sign(self._ca_key, hashes.SHA256())
+ self._write("crl/ca-bundle.crl", crl.public_bytes(serialization.Encoding.PEM))
+ # SSLProxyCARevocationPath finds the CRL of an issuer by the hash of
+ # the issuer's name: .r0
+ link = self.dir / "crl" / f"{x509_name_hash(self._ca_cert.subject)}.r0"
+ link.unlink(missing_ok=True)
+ link.symlink_to("ca-bundle.crl")
+
+ def generate(self) -> Path:
+ """Build the full CA tree (idempotent). Returns the CA root dir."""
+ if self.dir.is_dir() and (self.dir / "certs" / "ca.crt").exists():
+ return self.root # already generated
+ now = (datetime.datetime.now(datetime.timezone.utc)
+ .replace(microsecond=0) - datetime.timedelta(minutes=1))
+ self._init_dirs()
+ self._new_ca(now)
+ revoked: List[Tuple[x509.Certificate, datetime.datetime]] = []
+ for name in CERT_DN:
+ key = self._new_key(name)
+ cert = self._new_cert(name, key, now)
+ if name.endswith("_revoked"):
+ self._revoke(name, cert, now, revoked)
+ if name.startswith("client_"):
+ self._make_proxy_cert(name, key)
+ # crl/ca-bundle.crl is referenced by SSLCARevocationFile
+ self._write_crl(now, revoked)
+ self._write("index.txt", ("\n".join(self._index) + "\n").encode())
+ self._write("serial", f"{self._serial:02X}\n".encode())
+ return self.root
diff -Nru apache2-2.4.69/test/pyhttpd/uv.lock apache2-2.4.71/test/pyhttpd/uv.lock
--- apache2-2.4.69/test/pyhttpd/uv.lock 2026-09-28 20:21:42.000000000 +0000
+++ apache2-2.4.71/test/pyhttpd/uv.lock 1970-01-01 00:00:00.000000000 +0000
@@ -1,679 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.9"
-resolution-markers = [
- "python_full_version >= '3.10'",
- "python_full_version > '3.9' and python_full_version < '3.10'",
- "python_full_version <= '3.9'",
-]
-
-[[package]]
-name = "cffi"
-version = "2.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" },
- { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
- { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
- { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
- { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
- { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
- { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
- { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
- { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
- { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
- { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
- { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
- { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
- { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
- { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
- { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
- { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
- { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
- { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
- { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
- { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
- { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
- { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
- { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
- { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
- { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
- { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
- { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
- { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
- { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
- { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
- { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
- { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
- { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
- { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
- { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
- { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
- { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
- { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
- { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
- { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
- { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
- { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
- { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
- { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
- { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
- { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
- { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
- { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
- { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
- { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
- { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
- { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
- { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
- { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
- { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
- { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
- { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
- { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
- { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
- { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
- { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
- { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
- { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
- { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
- { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
- { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
- { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
- { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
- { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" },
- { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" },
- { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" },
- { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" },
- { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" },
- { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" },
- { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" },
- { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" },
- { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" },
- { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" },
- { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" },
- { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "cryptography"
-version = "47.0.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version <= '3.9'",
-]
-dependencies = [
- { name = "cffi", marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" },
- { name = "typing-extensions", marker = "python_full_version <= '3.9'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
- { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
- { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
- { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
- { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
- { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
- { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
- { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
- { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
- { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
- { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
- { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
- { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
- { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
- { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
- { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
- { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
- { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
- { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
- { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
- { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
- { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
- { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
- { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
- { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
- { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
- { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
- { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
- { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
- { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
- { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
- { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
- { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
- { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
- { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
- { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
- { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
- { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
- { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
- { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
- { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
- { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" },
- { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" },
- { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" },
- { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" },
- { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" },
- { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" },
-]
-
-[[package]]
-name = "cryptography"
-version = "48.0.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.10'",
- "python_full_version > '3.9' and python_full_version < '3.10'",
-]
-dependencies = [
- { name = "cffi", marker = "python_full_version > '3.9' and platform_python_implementation != 'PyPy'" },
- { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
- { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
- { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
- { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
- { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
- { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
- { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
- { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
- { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
- { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
- { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
- { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
- { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
- { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
- { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
- { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
- { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
- { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
- { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
- { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
- { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
- { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
- { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
- { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
- { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
- { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
- { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
- { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
- { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
- { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
- { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
- { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
- { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
- { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
- { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
- { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
- { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
- { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
- { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
- { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
- { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
- { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
- { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
- { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
- { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
- { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
- { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
- { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
-]
-
-[[package]]
-name = "exceptiongroup"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
-]
-
-[[package]]
-name = "filelock"
-version = "3.19.1"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version > '3.9' and python_full_version < '3.10'",
- "python_full_version <= '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
-]
-
-[[package]]
-name = "filelock"
-version = "3.29.1"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.10'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" },
-]
-
-[[package]]
-name = "httpd-pyhttpd-tests"
-version = "0.1.0"
-source = { virtual = "." }
-dependencies = [
- { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" },
- { name = "cryptography", version = "48.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" },
- { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
- { name = "filelock", version = "3.29.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "packaging" },
- { name = "pyopenssl" },
- { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
- { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "python-multipart", version = "0.0.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
- { name = "python-multipart", version = "0.0.30", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
- { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "cryptography" },
- { name = "filelock" },
- { name = "packaging" },
- { name = "pyopenssl" },
- { name = "pytest", specifier = ">=7.0" },
- { name = "python-multipart" },
- { name = "websockets" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version > '3.9' and python_full_version < '3.10'",
- "python_full_version <= '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.10'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
-]
-
-[[package]]
-name = "packaging"
-version = "26.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "pycparser"
-version = "2.23"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version > '3.9' and python_full_version < '3.10'",
- "python_full_version <= '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
-]
-
-[[package]]
-name = "pycparser"
-version = "3.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.10'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.20.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
-]
-
-[[package]]
-name = "pyopenssl"
-version = "26.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" },
- { name = "cryptography", version = "48.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
-]
-
-[[package]]
-name = "pytest"
-version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version > '3.9' and python_full_version < '3.10'",
- "python_full_version <= '3.9'",
-]
-dependencies = [
- { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
- { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
- { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
- { name = "packaging", marker = "python_full_version < '3.10'" },
- { name = "pluggy", marker = "python_full_version < '3.10'" },
- { name = "pygments", marker = "python_full_version < '3.10'" },
- { name = "tomli", marker = "python_full_version < '3.10'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
-]
-
-[[package]]
-name = "pytest"
-version = "9.0.3"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.10'",
-]
-dependencies = [
- { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
- { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
- { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "packaging", marker = "python_full_version >= '3.10'" },
- { name = "pluggy", marker = "python_full_version >= '3.10'" },
- { name = "pygments", marker = "python_full_version >= '3.10'" },
- { name = "tomli", marker = "python_full_version == '3.10.*'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
-]
-
-[[package]]
-name = "python-multipart"
-version = "0.0.20"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version > '3.9' and python_full_version < '3.10'",
- "python_full_version <= '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
-]
-
-[[package]]
-name = "python-multipart"
-version = "0.0.30"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.10'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" },
-]
-
-[[package]]
-name = "tomli"
-version = "2.4.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
- { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
- { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
- { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
- { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
- { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
- { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
- { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
- { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
- { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
- { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
- { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
- { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
- { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
- { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
- { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
- { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
- { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
- { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
- { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
- { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
- { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
- { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
- { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
- { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
- { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
- { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
- { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
- { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
- { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
- { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
- { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
- { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
- { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
- { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
- { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
- { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
- { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
- { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
- { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
- { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
- { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
- { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
- { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
- { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
- { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.15.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
-]
-
-[[package]]
-name = "websockets"
-version = "15.0.1"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version > '3.9' and python_full_version < '3.10'",
- "python_full_version <= '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
- { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
- { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
- { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
- { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
- { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
- { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
- { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
- { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
- { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
- { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
- { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
- { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
- { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
- { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
- { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
- { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
- { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
- { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
- { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
- { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
- { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
- { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
- { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
- { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
- { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
- { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
- { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
- { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
- { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
- { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
- { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
- { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
- { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
- { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
- { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
- { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
- { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
- { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
- { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
- { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
- { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
- { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" },
- { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" },
- { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" },
- { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" },
- { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" },
- { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" },
- { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" },
- { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" },
- { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" },
- { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" },
- { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" },
- { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
- { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
- { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
- { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
- { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
- { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
- { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" },
- { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" },
- { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" },
- { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" },
- { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" },
- { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" },
- { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
-]
-
-[[package]]
-name = "websockets"
-version = "16.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.10'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
- { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
- { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
- { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
- { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
- { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
- { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
- { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
- { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
- { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
- { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
- { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
- { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
- { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
- { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
- { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
- { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
- { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
- { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
- { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
- { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
- { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
- { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
- { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
- { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
- { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
- { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
- { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
- { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
- { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
- { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
- { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
- { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
- { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
- { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
- { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
- { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
- { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
- { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
- { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
- { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
- { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
- { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
- { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
- { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
- { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
- { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
- { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
- { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
- { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
- { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
- { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
- { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
- { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
- { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
- { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
- { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
-]
diff -Nru apache2-2.4.69/test/pyproject.toml apache2-2.4.71/test/pyproject.toml
--- apache2-2.4.69/test/pyproject.toml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pyproject.toml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,41 @@
+[project]
+name = "httpd-tests"
+version = "0.1.0"
+description = "Python/pytest test suites for Apache httpd (modules/ and pytest_suite/)"
+requires-python = ">=3.11"
+dependencies = [
+ "pytest>=8.0",
+ "httpx>=0.27",
+ "cryptography",
+ "filelock",
+ "python-multipart",
+ "pyopenssl",
+ "packaging",
+ "websockets",
+]
+
+[tool.uv]
+package = false
+
+[tool.pytest.ini_options]
+python_files = ["test_*.py", "*_test.py"]
+# test_*.py: the self-tests of the framework, first, as they take seconds
+testpaths = ["test_*.py", "modules", "pytest_suite/tests"]
+# "." makes the pyhttpd framework package importable; pytest_suite makes the
+# apache_pytest framework package importable.
+pythonpath = [".", "pytest_suite"]
+# importlib import mode lets same-named test modules coexist across category
+# dirs (e.g. ssl/test_all.py and php/test_all.py) without __init__.py files or
+# import-file-mismatch collisions.
+addopts = "-ra --import-mode=importlib --strict-markers"
+markers = [
+ "need_module(*names): skip unless all named httpd modules are available",
+ "need_min_apache_version(ver): skip unless the server is at least ver",
+ "need_cgi(): skip unless a CGI module (mod_cgi or mod_cgid) is available",
+ "need_php(): skip unless a PHP SAPI module or php-fpm is available",
+ "need_ssl(): skip unless mod_ssl is available",
+ "need_lwp(): always satisfied (the httpx client is always present)",
+ "needs_dependency(dep, available, reason=None, detected=None): optional dependency; skip if missing, fail under --strict-optional. Use pyhttpd.depends.needs_dependency()",
+ "not_backported(rev, what, backported_in=None, platform=None): expected to fail on httpd without the trunk change rev (2.4.x, or older than backported_in). Use pyhttpd.backport.not_backported()",
+ "skip_not_backported(rev, what, backported_in=None, platform=None): like not_backported but not run (hangs, crashes, error log). Use pyhttpd.backport.skip_not_backported()",
+]
diff -Nru apache2-2.4.69/test/pytest_suite/.gitignore apache2-2.4.71/test/pytest_suite/.gitignore
--- apache2-2.4.69/test/pytest_suite/.gitignore 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/.gitignore 2026-11-09 03:07:05.000000000 +0000
@@ -31,7 +31,6 @@
t/conf/cacheroot/
t/logs/
t/state/
-t/md/
# Generated htpasswd / htdigest files
t/basic1
diff -Nru apache2-2.4.69/test/pytest_suite/README.md apache2-2.4.71/test/pytest_suite/README.md
--- apache2-2.4.69/test/pytest_suite/README.md 2026-07-18 15:32:22.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/README.md 2026-11-09 03:07:05.000000000 +0000
@@ -1,16 +1,27 @@
# Apache httpd test suite — Python / pytest
> **Provenance / how this fits in `test/`.** This `pytest_suite/` is a
-> self-contained snapshot ported from the `httpd-tests` repository (the Python
-> port of the classic Perl `Apache::Test` suite). It **coexists alongside**
-> `test/pyhttpd/` and `test/modules/` — it does *not* use pyhttpd's framework
-> or its root `conftest.py`. It has its own `conftest.py` and `pyproject.toml`,
-> so pytest treats this directory as its own rootdir; run it from here (or via
-> `test/run-all-tests.sh`), never by pointing pytest at `test/` as a whole.
-> The two suites are complementary: this one covers the classic core/modules/
-> security/SSL/PHP tests; pyhttpd covers HTTP/2, mod_md (ACME), HTTP/1 and proxy.
-> To re-sync from upstream, re-copy the source tree (source files only — exclude
-> `.venv/`, caches, and generated artifacts; see `.gitignore`).
+> snapshot of the `httpd-tests` repository (the Python port of the classic Perl
+> `Apache::Test` suite). It is one half of a single pytest project rooted in
+> `test/`, alongside `test/modules/` (the pyhttpd suites). Both halves share the
+> framework in `test/pyhttpd/` (finding and probing the httpd build, `need_*` and
+> `not_backported` gating, starting and stopping httpd, ports, the test CA, the
+> C-module builder); `apache_pytest/` here is a thin layer on top of it that keeps
+> the test files' imports working, so they can stay identical to upstream's.
+> The Python environment (`test/pyproject.toml`, `test/uv.lock`, `test/.venv`),
+> the pytest configuration and the command-line options (registered by
+> `test/pyhttpd/plugin.py`) are `test/`'s. Run it with `test/runtests.sh`,
+> alone (`./runtests.sh pytest_suite/tests`) or together with `modules/`.
+> The two halves are complementary: this one covers the classic core/modules/
+> security/SSL/PHP tests; `modules/` covers HTTP/2, mod_md (ACME), HTTP/1 and proxy.
+>
+> **Re-syncing from upstream `httpd-tests`:** copy the test files and assets
+> only: `tests/t/`, `t/` and `c-modules/` (not `.venv/`, caches or generated
+> artifacts; see `.gitignore`). Never copy upstream's `conftest.py` or
+> `apache_pytest/` over this directory's: the framework here is the shared one.
+> After a copy, run `pytest pytest_suite/tests` from `test/`. A new upstream test
+> that needs a framework API missing from `apache_pytest/` fails at import, and
+> the API is added to `pyhttpd/` and re-exported there.
A self-contained [pytest](https://pytest.org) port of the Apache httpd
integration test suite (historically the Perl `Apache::Test` framework). It
@@ -28,25 +39,28 @@
```sh
# 1. Create the virtualenv (pytest + httpx). Needs `uv` (https://docs.astral.sh/uv/);
-# reads pyproject.toml + uv.lock. runtests.sh also does this for you on first run.
-uv sync
-
-# 2. Run the whole suite against your httpd build.
-./runtests.sh --apxs /path/to/your/httpd/bin/apxs
+# reads ../pyproject.toml + ../uv.lock. ../runtests.sh also does this for you
+# on first run.
+uv sync --project ..
+
+# 2. Run the whole suite against your httpd build, from test/.
+cd ..
+./runtests.sh --apxs=/path/to/your/httpd/bin/apxs pytest_suite/tests
# ...or let it auto-detect apxs / httpd / php-fpm from $PATH or env vars:
-APXS=$HOME/root/httpd/bin/apxs PHP_FPM=/usr/sbin/php-fpm8.3 ./runtests.sh
+APXS=$HOME/root/httpd/bin/apxs PHP_FPM=/usr/sbin/php-fpm8.3 ./runtests.sh pytest_suite/tests
```
-`runtests.sh` is the self-evident entry point: it locates the venv's `pytest`,
-fills in `--apxs` / `--php-fpm` from environment variables or `$PATH`, clears a
-stale CGI socket, and forwards any extra arguments to pytest.
+`test/runtests.sh` is the entry point: it locates the venv's `pytest`, fills in
+`--apxs` / `--php-fpm` from environment variables or `$PATH`, clears a stale CGI
+socket, and forwards any extra arguments to pytest. Paths are relative to
+`test/`; a path relative to this directory (`tests/t/php`) works as well.
```sh
./runtests.sh tests/t/modules # run one category
./runtests.sh tests/t/modules/test_alias.py # run one file
-./runtests.sh -k rewrite -v # any pytest args pass through
-./runtests.sh --php-fpm /opt/local/sbin/php-fpm83 tests/t/php # PHP tests
+./runtests.sh pytest_suite/tests -k rewrite -v # any pytest args pass through
+./runtests.sh --php-fpm=/opt/local/sbin/php-fpm83 tests/t/php # PHP tests
```
@@ -54,10 +68,10 @@
| Requirement | How it's provided | Notes |
|---|---|---|
-| Python ≥ 3.11 + pytest + httpx | `uv sync` (uses `pyproject.toml` / `uv.lock`) | The only Python deps. |
+| Python ≥ 3.11 + pytest + httpx | `uv sync` (uses `test/pyproject.toml` / `test/uv.lock`) | The only Python deps. |
| A built Apache httpd | `--apxs` / `--httpd` (or `APXS` / `HTTPD` env) | The server under test. Built with shared modules so the suite can load them. |
| `php-fpm` (optional) | `--php-fpm` (or `PHP_FPM` env) | Any PHP 7/8 version. Without it, the PHP tests skip. |
-| `openssl` CLI | auto (on `$PATH`) | Used to generate the test SSL CA/certs. |
+| `openssl` CLI | auto (on `$PATH`) | Optional. The test SSL CA/certs are generated in Python (`pyhttpd/sslca.py`, `cryptography`); the CLI is only run by a few tests (the OCSP responder, `proxy_hcheck`), which skip without it. |
The suite probes the httpd build (`httpd -v/-V/-l` and its installed
`httpd.conf`) to learn its version, MPM, and available modules, then **skips**
@@ -81,17 +95,15 @@
## Layout
```
-python/
-├── runtests.sh # entry point (this is how you run the suite)
-├── pyproject.toml # deps + pytest config
-├── conftest.py # fixtures: server lifecycle, the `http` client, need_* gating
-├── apache_pytest/ # the framework (port of Apache::Test internals)
-│ ├── probe.py # inspect the httpd build (version, MPM, modules)
+pytest_suite/ # (the runner, pyproject.toml and uv.lock are in test/)
+├── conftest.py # fixtures: server lifecycle, the `http` client
+├── apache_pytest/ # classic-suite layer over test/pyhttpd (port of Apache::Test internals)
+│ ├── probe.py # re-exports pyhttpd.build (inspect the httpd build)
│ ├── config.py # generate httpd.conf from t/conf/*.conf.in
-│ ├── cmodules.py # compile c-modules/ via apxs
-│ ├── sslca.py # generate the SSL test CA + certs
+│ ├── cmodules.py # which c-modules/ to build (built by pyhttpd.cmodules)
+│ ├── sslca.py # re-exports pyhttpd.sslca (the SSL test CA + certs)
│ ├── fpm.py # manage a php-fpm daemon
-│ ├── server.py # start/stop httpd
+│ ├── server.py # the generated config's httpd command line (runs on pyhttpd.instance)
│ ├── client.py # the `http` fixture (HTTP/HTTPS + raw sockets)
│ ├── rawsocket.py # raw-socket helper for protocol tests
│ └── testapi.py # t_cmp() + need_* markers used by tests
@@ -318,12 +330,19 @@
## Notes & gotchas
-* **`uv run` vs the venv.** `runtests.sh` invokes `.venv/bin/pytest` directly so
+* **`uv run` vs the venv.** `runtests.sh` invokes `test/.venv/bin/pytest` directly so
it works in environments where `uv run` is shimmed/unavailable. If you call
- pytest yourself, use `.venv/bin/pytest` (or activate the venv).
+ pytest yourself, use `../.venv/bin/pytest` (or activate the venv).
+* **Write options as `--apxs=PATH`**, not `--apxs PATH`, when calling pytest
+ yourself: pytest's early argument scan cannot yet tell that the second form's
+ value is not a test path, and picks the wrong rootdir. `runtests.sh` joins them.
+* **The error log is checked.** An unexpected error or warning logged while a
+ test runs fails it (as a teardown error). Tests that provoke errors on purpose
+ are covered by the lists in `test/pyhttpd/classic_errorlog.py`, not in the test
+ files; a new test with a deliberate error path adds its AH number there.
* **Same-named test modules** across categories (e.g. `ssl/test_all.py` and
`php/test_all.py`) coexist thanks to `--import-mode=importlib` +
- `pythonpath=["."]` in `pyproject.toml`.
+ `pythonpath` in `test/pyproject.toml`.
* **Stale CGI socket.** A killed run can leave `t/logs/cgisock*`, which breaks a
fresh start; `runtests.sh` removes it automatically.
* **No orphans.** The server is launched in its own process group and reaped on
diff -Nru apache2-2.4.69/test/pytest_suite/apache_pytest/__init__.py apache2-2.4.71/test/pytest_suite/apache_pytest/__init__.py
--- apache2-2.4.69/test/pytest_suite/apache_pytest/__init__.py 2026-06-03 17:21:22.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/apache_pytest/__init__.py 2026-11-09 03:07:05.000000000 +0000
@@ -16,6 +16,7 @@
need_module,
need_php,
need_ssl,
+ not_backported,
t_cmp,
)
@@ -32,6 +33,7 @@
"need_module",
"need_php",
"need_ssl",
+ "not_backported",
"probe",
"t_cmp",
]
diff -Nru apache2-2.4.69/test/pytest_suite/apache_pytest/cmodules.py apache2-2.4.71/test/pytest_suite/apache_pytest/cmodules.py
--- apache2-2.4.69/test/pytest_suite/apache_pytest/cmodules.py 2026-06-03 17:21:22.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/apache_pytest/cmodules.py 2026-11-09 03:07:05.000000000 +0000
@@ -21,14 +21,13 @@
from __future__ import annotations
import re
-import shutil
-import subprocess
from dataclasses import dataclass
from pathlib import Path
-from .probe import HttpdInfo
+from pyhttpd.cmodules import LIB_SUBDIR as _LIB_SUBDIR
+from pyhttpd.cmodules import clean_module_dir, ensure_built
-_LIB_SUBDIR = ".libs" # apxs output dir for Apache 2.x (TestConfigC %lib_dir)
+from .probe import HttpdInfo
# Hook phases registered by APACHE_HTTPD_TEST_MODULE for Apache 2.x
# (TestConfigC @cmodule_phases).
@@ -190,16 +189,6 @@
return mods, skipped
-def _apxs_cmd(apxs: Path, defines: list[str], include_dir: Path, src: Path) -> list[str]:
- # apxs is a Perl script; invoke via perl to sidestep exec-bit issues.
- # Mirrors: $(APXS) -D APACHE2 -D APACHE2_4 -I -c mod_NAME.c
- cmd = ["perl", str(apxs)]
- for d in defines:
- cmd += ["-D", d]
- cmd += ["-I", str(include_dir), "-c", str(src)]
- return cmd
-
-
def clean_modules(cmodules_dir: Path) -> None:
"""Remove all apxs build artifacts from ``cmodules_dir`` (emulate ``make clean``).
@@ -209,12 +198,7 @@
The next ``compile_all`` call will rebuild everything from scratch.
"""
for sub in sorted(p for p in cmodules_dir.iterdir() if p.is_dir()):
- libs = sub / _LIB_SUBDIR
- if libs.is_dir():
- shutil.rmtree(libs)
- for pattern in ("*.o", "*.lo", "*.slo", "*.la"):
- for f in sub.glob(pattern):
- f.unlink(missing_ok=True)
+ clean_module_dir(sub)
header = cmodules_dir / "apache_httpd_test.h"
header.unlink(missing_ok=True)
@@ -233,26 +217,16 @@
:raises RuntimeError: if apxs fails for a module (captured output included).
"""
defines = defines if defines is not None else ["APACHE2", "APACHE2_4"]
- generate_header(cmodules_dir / "apache_httpd_test.h")
+ header = cmodules_dir / "apache_httpd_test.h"
+ generate_header(header)
mods, skipped = discover(cmodules_dir, info)
- apxs_mtime = apxs.stat().st_mtime
loads: list[tuple[str, Path]] = []
for mod in mods:
- needs = force or not mod.so.exists() or (
- mod.so.stat().st_mtime < mod.src.stat().st_mtime
- ) or (
- mod.so.stat().st_mtime < apxs_mtime
- )
- if needs:
- cmd = _apxs_cmd(apxs, defines, cmodules_dir, mod.src)
- proc = subprocess.run( # noqa: S603 - trusted paths
- cmd, cwd=mod.src_dir, capture_output=True, text=True, check=False
- )
- if proc.returncode != 0 or not mod.so.exists():
- raise RuntimeError(
- f"apxs failed building mod_{mod.name} (exit {proc.returncode}):\n"
- f"$ {' '.join(cmd)}\n{proc.stdout}\n{proc.stderr}"
- )
+ # rebuilt when the source, the generated header, the defines or the
+ # httpd it is built against change (see pyhttpd.cmodules)
+ ensure_built(mod.src_dir, mod.name, apxs, defines=defines,
+ include_dirs=[cmodules_dir], extra_inputs=[header],
+ force=force)
loads.append((mod.symbol, mod.so))
return loads, skipped
diff -Nru apache2-2.4.69/test/pytest_suite/apache_pytest/private_server.py apache2-2.4.71/test/pytest_suite/apache_pytest/private_server.py
--- apache2-2.4.69/test/pytest_suite/apache_pytest/private_server.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/apache_pytest/private_server.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,155 @@
+"""A throwaway private httpd for tests that must restart or reconfigure the
+server (the suite's own server is shared, so they cannot use it).
+
+It serves one small and one large static file. Module and path settings are
+taken from the suite's generated config so that they match the build under test.
+"""
+
+import re
+import socket
+import subprocess
+import time
+
+from pyhttpd.instance import HttpdInstance, free_port # noqa: F401 (free_port re-exported)
+
+BODY_LENGTH = 64 * 1024 * 1024
+SMALL_LENGTH = 1000
+
+# What the private server needs, taken from the suite's generated config so that
+# paths and module names are the ones this build uses.
+WANTED_MODULES = ("mpm_", "unixd_module", "authz_core_module",
+ "unique_id_module", "headers_module", "echo_module")
+
+
+def load_module_lines(config):
+ text = open(config.vars["t_conf_file"], encoding="utf-8").read()
+ lines = []
+ for m in re.finditer(r"^\s*(LoadModule\s+(\S+)\s+.*)$", text, re.M):
+ if m.group(2).startswith(WANTED_MODULES[0]) or m.group(2) in WANTED_MODULES:
+ lines.append(m.group(1).strip())
+ return lines
+
+
+def read_headers(sock):
+ data = b""
+ while b"\r\n\r\n" not in data:
+ chunk = sock.recv(4096)
+ if not chunk:
+ break
+ data += chunk
+ head, _, body = data.partition(b"\r\n\r\n")
+ return head, body
+
+
+def child_pids(parent):
+ """Pids of the direct children of parent (portable: macOS and Linux)."""
+ out = subprocess.run(["ps", "-A", "-o", "pid=,ppid="],
+ capture_output=True, text=True).stdout
+ pids = set()
+ for line in out.splitlines():
+ fields = line.split()
+ if len(fields) == 2 and int(fields[1]) == parent:
+ pids.add(int(fields[0]))
+ return pids
+
+
+def get(port, path, rcvbuf=None):
+ """Send an HTTP/1.0 GET; return the open socket (the body ends at close)."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ if rcvbuf:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
+ sock.settimeout(30)
+ sock.connect(("127.0.0.1", port))
+ sock.sendall(f"GET {path} HTTP/1.0\r\n\r\n".encode())
+ return sock
+
+
+class PrivateServer:
+ """A throwaway httpd with one small and one large static file."""
+
+ def __init__(self, config, tmp_path, extra=(), big_length=BODY_LENGTH):
+ self.config = config
+ self.tmp_path = tmp_path
+ self.extra = list(extra)
+ self.big_length = big_length
+ self.port = free_port()
+ self.instance = None
+
+ def start(self):
+ tmp_path = self.tmp_path
+ docroot = tmp_path / "htdocs"
+ docroot.mkdir()
+ (docroot / "big.bin").write_bytes(b"x" * self.big_length)
+ (docroot / "small.txt").write_bytes(b"y" * SMALL_LENGTH)
+
+ conf = tmp_path / "httpd.conf"
+ conf.write_text(
+ "\n".join(
+ [
+ f'ServerRoot "{tmp_path}"',
+ "ServerName localhost",
+ f"Listen 127.0.0.1:{self.port}",
+ f'PidFile "{tmp_path}/httpd.pid"',
+ f'ErrorLog "{tmp_path}/error_log"',
+ "LogLevel info",
+ *load_module_lines(self.config),
+ "StartServers 1",
+ "ThreadsPerChild 10",
+ f'DocumentRoot "{docroot}"',
+ "",
+ " TypesConfig /dev/null",
+ "",
+ *self.extra,
+ "",
+ ]
+ )
+ )
+ # Its own process group, so that the children go away with the parent.
+ self.instance = HttpdInstance(
+ self.config.info.httpd, ["-f", str(conf)],
+ ports=[self.port], error_log=tmp_path / "error_log",
+ stderr_file=tmp_path / "stderr", configtest_first=True)
+ self.instance.start(timeout=20)
+
+ @property
+ def proc(self):
+ return self.instance.proc if self.instance else None
+
+ def stop(self):
+ if self.instance is not None:
+ self.instance.stop(timeout=15)
+
+ def __enter__(self):
+ self.start()
+ return self
+
+ def __exit__(self, *exc):
+ self.stop()
+
+ def graceful(self, timeout=30):
+ """Ask for a graceful restart and return once the parent has done it. A
+ slow (instrumented) parent can take seconds to act on the signal, and a
+ test that goes on before that is not restarting the server when it
+ thinks it is."""
+ self.instance.restart(graceful=True, timeout=timeout)
+
+ def error_log(self):
+ return (self.tmp_path / "error_log").read_text(errors="replace")
+
+ def small_ok(self, timeout):
+ """True if /small.txt is served completely within timeout seconds."""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ try:
+ s = get(self.port, "/small.txt")
+ s.settimeout(1)
+ h, b = read_headers(s)
+ while chunk := s.recv(4096):
+ b += chunk
+ s.close()
+ if b" 200 " in h.split(b"\r\n")[0] and len(b) == SMALL_LENGTH:
+ return True
+ except OSError:
+ pass
+ time.sleep(0.1)
+ return False
diff -Nru apache2-2.4.69/test/pytest_suite/apache_pytest/probe.py apache2-2.4.71/test/pytest_suite/apache_pytest/probe.py
--- apache2-2.4.69/test/pytest_suite/apache_pytest/probe.py 2026-06-03 16:20:30.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/apache_pytest/probe.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,249 +1,12 @@
-"""Toolchain probing for httpd.
+"""Compatibility re-export: the probe now lives in :mod:`pyhttpd.build`."""
-Mirrors the discovery logic in Apache::TestConfigParse (perl-apache-test):
-run ``httpd -v`` / ``-V`` / ``-l`` and parse the inherited install ``httpd.conf``
-to learn the server version, MPM, compile-time ``-D`` defines, and the full set
-of available modules (static + dynamically loaded).
-
-The merged module set drives ```` blocks, the module-name token
-resolution in :mod:`apache_pytest.config`, and (eventually) the ``need_module``
-test marks.
-"""
-
-from __future__ import annotations
-
-import re
-import subprocess
-from dataclasses import dataclass, field
-from pathlib import Path
-
-# httpd -V emits lines like: -D APACHE_MPM_DIR="server/mpm/prefork"
-_DEFINE_RE = re.compile(r"^\s*-D\s+([A-Z0-9_]+)(?:=(.*))?\s*$")
-# httpd -l / module conf lines end in ".c"; -l indents them two spaces.
-_STATIC_MOD_RE = re.compile(r"^\s*(\w+\.c)\s*$")
-# LoadModule foo_module modules/mod_foo.so (optionally commented with leading #)
-_LOADMODULE_RE = re.compile(r"^\s*(#?)\s*LoadModule\s+(\S+)\s+(\S+)")
-_VERSION_RE = re.compile(r"Apache/(\d+)\.(\d+)\.(\d+)")
-
-
-@dataclass
-class LoadDirective:
- """One ``LoadModule symbol file`` mapping from an install conf."""
-
- symbol: str # e.g. "session_module"
- so: str # path as written, e.g. "modules/mod_session.so"
- active: bool # False if the line was commented out
-
- @property
- def cname(self) -> str:
- """Module source name, e.g. "mod_session.c" (used as the modules-set key)."""
- stem = Path(self.so).name
- for suffix in (".so", ".dll"):
- if stem.endswith(suffix):
- stem = stem[: -len(suffix)]
- break
- if stem.startswith("lib"): # libphp.so -> mod_php.c
- stem = "mod_" + stem[3:]
- return f"{stem}.c"
-
- @property
- def is_mpm(self) -> bool:
- return self.symbol.startswith("mpm_") or "mpm_" in Path(self.so).name
-
-
-@dataclass
-class HttpdInfo:
- """Everything probed from an httpd binary + its inherited config."""
-
- httpd: Path
- version: tuple[int, int, int]
- mpm: str
- defines: dict[str, str | bool] = field(default_factory=dict)
- # Set of module source-file names, e.g. {"core.c", "mod_proxy.c", ...}.
- # This is the union of statically-compiled modules and modules available
- # via LoadModule in the inherited httpd.conf (active OR commented) — matching
- # Apache::Test's $self->{modules} hash keyed on ".c". Membership here
- # means "this module exists and can be loaded", which is what drives the
- # blocks and the need_module marks.
- modules: set[str] = field(default_factory=set)
- # Every LoadModule directive discovered in the inherited conf, with absolute
- # .so paths. Re-emitted into the generated httpd.conf so the test server
- # actually loads them (the generated conf does not Include the install conf).
- load_directives: list[LoadDirective] = field(default_factory=list)
- # TAKE1 directives inherited from the install conf (wanted_config in
- # TestConfigParse.pm:34): ServerAdmin, TypesConfig, DocumentRoot, ServerRoot.
- # Keyed by directive name (as written), value is the single argument.
- inherited: dict[str, str] = field(default_factory=dict)
-
- @property
- def version_str(self) -> str:
- return ".".join(str(p) for p in self.version)
-
- def has_module(self, name: str) -> bool:
- """True if module ``name`` is available.
-
- Accepts every form the Perl tests use: a bare name (``status``), a
- ``mod_``-prefixed name (``mod_status``), or any of those with a ``.c``/
- ``.so`` suffix. Modules are stored as source names (``mod_status.c``),
- so a bare name is also tried with the ``mod_`` prefix -- matching
- Apache::Test's need_module/have_module, which accept ``need_module 'status'``.
- """
- stem = name
- for suffix in (".c", ".so"):
- if stem.endswith(suffix):
- stem = stem[: -len(suffix)]
- break
- candidates = {f"{stem}.c"}
- if not stem.startswith("mod_"):
- candidates.add(f"mod_{stem}.c")
- return bool(candidates & self.modules)
-
- def version_at_least(self, major: int, minor: int, patch: int = 0) -> bool:
- return self.version >= (major, minor, patch)
-
-
-def _run(cmd: list[str]) -> str:
- """Run a probe command, returning combined stdout (httpd prints to stdout)."""
- proc = subprocess.run( # noqa: S603 - trusted, caller-provided httpd path
- cmd,
- capture_output=True,
- text=True,
- check=False,
- )
- # httpd -V can exit non-zero if it wants a config; we only need the banner.
- return proc.stdout + proc.stderr
-
-
-def probe_version(httpd: Path) -> tuple[int, int, int]:
- out = _run([str(httpd), "-v"])
- m = _VERSION_RE.search(out)
- if not m:
- raise RuntimeError(f"could not parse version from `{httpd} -v`:\n{out}")
- return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
-
-
-def probe_defines_and_mpm(httpd: Path) -> tuple[dict[str, str | bool], str]:
- """Parse ``httpd -V`` for ``-D`` defines, server MPM, and module magic info."""
- out = _run([str(httpd), "-V"])
- defines: dict[str, str | bool] = {}
- mpm = ""
- for line in out.splitlines():
- m = _DEFINE_RE.match(line)
- if m:
- key, val = m.group(1), m.group(2)
- defines[key] = val.strip('"') if val else True
- continue
- # "Server MPM: event" (2.4+/2.5)
- sm = re.match(r"\s*Server MPM:\s*(.+)", line, re.IGNORECASE)
- if sm:
- mpm = sm.group(1).strip().lower()
- if not mpm:
- # 2.0-era fallback: derive from APACHE_MPM_DIR=server/mpm/
- mpm_dir = defines.get("APACHE_MPM_DIR")
- if isinstance(mpm_dir, str):
- mpm = Path(mpm_dir).name.lower()
- return defines, mpm
-
-
-def probe_static_modules(httpd: Path) -> set[str]:
- """Modules compiled statically into the binary (``httpd -l``)."""
- out = _run([str(httpd), "-l"])
- mods: set[str] = set()
- for line in out.splitlines():
- m = _STATIC_MOD_RE.match(line)
- if m:
- mods.add(m.group(1))
- return mods
-
-
-def parse_loadmodules(conf: Path, server_root: Path) -> list[LoadDirective]:
- """Parse ``LoadModule`` lines (active and commented) from an install httpd.conf.
-
- The suite is built with ``--enable-load-all-modules`` upstream; locally the
- stock install conf lists most modules commented out. We capture both so the
- generated config can re-emit them and so module-presence checks succeed.
- Relative ``.so`` paths are resolved against ``server_root`` (the install
- prefix, ``apxs -q PREFIX``). ``Include`` directives are not followed — the
- stock conf lists modules directly, sufficient for the local build target.
- """
- directives: list[LoadDirective] = []
- if not conf.is_file():
- return directives
- for raw in conf.read_text(errors="replace").splitlines():
- m = _LOADMODULE_RE.match(raw)
- if not m:
- continue
- commented, symbol, so = m.group(1), m.group(2), m.group(3)
- so_path = Path(so)
- if not so_path.is_absolute():
- so_path = server_root / so_path
- directives.append(
- LoadDirective(symbol=symbol, so=str(so_path), active=(commented == ""))
- )
- return directives
-
-
-# TAKE1 directives inherited from the install conf (wanted_config TAKE1).
-# ServerRoot is intentionally NOT applied (spec_apply maps it to a no-op so the
-# test serverroot wins); we still record it for completeness but config.py
-# ignores it.
-_INHERIT_TAKE1 = ("ServerAdmin", "TypesConfig", "DocumentRoot", "ServerRoot")
-_TAKE1_RE = re.compile(
- r"^\s*(" + "|".join(_INHERIT_TAKE1) + r")\s+(.+?)\s*$"
+from pyhttpd.build import ( # noqa: F401
+ HttpdInfo,
+ LoadDirective,
+ parse_inherited_take1,
+ parse_loadmodules,
+ probe,
+ probe_defines_and_mpm,
+ probe_static_modules,
+ probe_version,
)
-
-
-def parse_inherited_take1(conf: Path) -> dict[str, str]:
- """Parse last-wins TAKE1 directives from an install conf (wanted_config)."""
- found: dict[str, str] = {}
- if not conf.is_file():
- return found
- for raw in conf.read_text(errors="replace").splitlines():
- if raw.lstrip().startswith("#"):
- continue
- m = _TAKE1_RE.match(raw)
- if m:
- found[m.group(1)] = m.group(2).strip().strip('"')
- return found
-
-
-def probe(
- httpd: Path,
- inherited_conf: Path | None = None,
- server_root: Path | None = None,
-) -> HttpdInfo:
- """Full probe of an httpd binary and (optionally) its install config.
-
- :param httpd: path to the httpd executable.
- :param inherited_conf: install ``httpd.conf`` to scan for LoadModule lines.
- Typically ``/conf/httpd.conf`` derived from ``apxs -q SYSCONFDIR``.
- :param server_root: install prefix used to resolve relative ``.so`` paths
- (``apxs -q PREFIX``). Defaults to the parent of ``inherited_conf``'s dir.
- """
- version = probe_version(httpd)
- defines, mpm = probe_defines_and_mpm(httpd)
- modules = probe_static_modules(httpd)
- load_directives: list[LoadDirective] = []
- inherited: dict[str, str] = {}
- if inherited_conf is not None:
- root = server_root or inherited_conf.parent.parent
- inherited = parse_inherited_take1(inherited_conf)
- all_directives = parse_loadmodules(inherited_conf, root)
- # Inherit only ACTIVE LoadModule lines, matching Apache::Test's
- # inherit_load_module (which parses the live config, not commented
- # lines). A build intended for the suite activates all modules
- # (--enable-load-all-modules); locally the install conf is activated to
- # match. Commented lines are ignored entirely.
- load_directives = [d for d in all_directives if d.active]
- for d in load_directives:
- if Path(d.so).exists():
- modules.add(d.cname)
- return HttpdInfo(
- httpd=httpd,
- version=version,
- mpm=mpm,
- defines=defines,
- modules=modules,
- load_directives=load_directives,
- inherited=inherited,
- )
diff -Nru apache2-2.4.69/test/pytest_suite/apache_pytest/server.py apache2-2.4.71/test/pytest_suite/apache_pytest/server.py
--- apache2-2.4.69/test/pytest_suite/apache_pytest/server.py 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/apache_pytest/server.py 2026-11-09 03:07:05.000000000 +0000
@@ -2,94 +2,56 @@
Port of the lifecycle bits of Apache::TestServer. Builds the same command line
(``httpd -d -f -D APACHE2 -D APACHE2_4 ``), starts
-the server, waits for its port to accept connections, and stops it cleanly.
+the server, waits for it to serve, and stops it cleanly.
-Orphan/stale-state handling mirrors Apache::TestServer's ``sub start`` (which
-calls ``sub stop`` up front to clear any prior server) and ``sub stop`` (which
-reads the pid file, sends ``SIGTERM``, polls, and finally unlinks the pid
-file). Because the event MPM parent forks children, we launch httpd in its own
-session (``start_new_session=True``/``setsid``) so the whole process group can
-be signalled with ``os.killpg`` -- escalating to ``SIGKILL`` if needed -- and
-no children are left holding ports after a failed or interrupted run.
+The process handling -- stale servers cleared up front like Apache::TestServer's
+``sub start`` does via ``sub stop``, its own session so the whole process group
+can be signalled and no children are left holding ports after a failed or
+interrupted run -- is :class:`pyhttpd.instance.HttpdInstance`, shared with the
+modules/ suite. This class only knows the command line of the generated config.
"""
from __future__ import annotations
-import contextlib
-import errno
-import os
-import sys
-import signal
-import socket
import subprocess
-import time
from pathlib import Path
-from .config import TestConfig
-
+from pyhttpd.instance import HttpdInstance, HttpdStartError
-def _pid_alive(pid: int) -> bool:
- """Return True if a process with ``pid`` exists (``kill(0)`` probe).
-
- Mirrors Apache::TestServer's ``kill 0, $pid`` liveness check in ``sub
- ping``/``sub stop``.
- """
- if pid <= 0:
- return False
- try:
- os.kill(pid, 0)
- except OSError as exc:
- # ESRCH: no such process. EPERM: exists but not ours (still alive).
- return exc.errno == errno.EPERM
- return True
-
-
-def _read_pid(pid_file: Path) -> int:
- """Read the PID from ``pid_file``; return 0 if missing/empty/garbage.
-
- Mirrors Apache::TestServer ``sub pid``, including tolerance for a pid file
- that exists but has not been written to yet (returns 0).
- """
- try:
- text = pid_file.read_text().strip()
- except OSError:
- return 0
- try:
- return int(text)
- except ValueError:
- return 0
-
-
-def _killpg_or_pid(pid: int, sig: int) -> None:
- """Send ``sig`` to the process group led by ``pid``, falling back to ``pid``.
-
- Children of an httpd parent launched with ``start_new_session=True`` share
- the parent's process group id (== parent pid), so signalling the group with
- ``os.killpg`` reaches the parent and all its workers. If the process is not
- a group leader (no such pgid) we fall back to signalling the bare pid.
-
- On Windows there are no process groups; use ``os.kill`` directly.
- """
- if pid <= 0:
- return
- if sys.platform == "win32":
- with contextlib.suppress(OSError):
- os.kill(pid, sig)
- return
- try:
- os.killpg(pid, sig)
- except OSError as exc:
- if exc.errno == errno.ESRCH:
- return # already gone
- # Not a group leader (or no permission for the group): try the pid.
- with contextlib.suppress(OSError):
- os.kill(pid, sig)
+from .config import TestConfig
class HttpdServer:
def __init__(self, config: TestConfig) -> None:
self.config = config
- self.proc: subprocess.Popen[bytes] | None = None
+ self._instance: HttpdInstance | None = None
+
+ @property
+ def instance(self) -> HttpdInstance:
+ if self._instance is None:
+ vars_ = self.config.vars
+ self._instance = HttpdInstance(
+ self.config.info.httpd,
+ [
+ "-d",
+ vars_["serverroot"],
+ "-f",
+ vars_["t_conf_file"],
+ "-DFOREGROUND",
+ *[a for d in self.dversion_defines() for a in ("-D", d)],
+ ],
+ ports=[int(vars_["port"])],
+ error_log=Path(vars_["t_logs"]) / "error_log",
+ pid_files=[Path(vars_["t_pid_file"])],
+ stderr_file=Path(vars_["t_logs"]) / "httpd.stderr",
+ # Like Apache::TestServer, validate the config before launch
+ configtest_first=True,
+ )
+ return self._instance
+
+ @property
+ def proc(self) -> subprocess.Popen[bytes] | None:
+ return self.instance.proc
# -- command line -----------------------------------------------------
@@ -108,26 +70,13 @@
return defs
def args(self) -> list[str]:
- vars_ = self.config.vars
- argv = [
- str(self.config.info.httpd),
- "-d",
- vars_["serverroot"],
- "-f",
- vars_["t_conf_file"],
- "-DFOREGROUND",
- ]
- for d in self.dversion_defines():
- argv += ["-D", d]
- return argv
+ return self.instance.argv()
# -- syntax check -----------------------------------------------------
def configtest(self) -> subprocess.CompletedProcess[str]:
"""Run ``httpd ... -t`` to validate the generated config before launch."""
- return subprocess.run( # noqa: S603 - trusted paths
- [*self.args(), "-t"], capture_output=True, text=True, check=False
- )
+ return self.instance.configtest()
# -- lifecycle --------------------------------------------------------
@@ -135,107 +84,18 @@
def port(self) -> int:
return int(self.config.vars["port"])
- @property
- def _pid_file(self) -> Path:
- return Path(self.config.vars["t_pid_file"])
-
- def _port_open(self) -> bool:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.settimeout(0.25)
- return s.connect_ex(("127.0.0.1", self.port)) == 0
-
- @classmethod
- def kill_stale(cls, pid_file: Path, *, timeout: float = 10.0) -> int:
- """Defensively clear a server left behind by a prior/crashed run.
-
- Mirrors the up-front ``$self->stop`` that Apache::TestServer's ``sub
- start`` performs: if ``pid_file`` names a live process, signal its
- process group with ``SIGTERM`` (escalating to ``SIGKILL`` after
- ``timeout``), then unlink the (now stale) pid file. Targets only the
- PID recorded in the file and that PID's process group -- never a broad
- pattern match. Safe to call when no pid file exists.
-
- Returns the PID that was found in the file (0 if none).
- """
- pid = _read_pid(pid_file)
- if pid and _pid_alive(pid):
- _killpg_or_pid(pid, signal.SIGTERM)
- deadline = time.monotonic() + timeout
- while time.monotonic() < deadline:
- if not _pid_alive(pid):
- break
- time.sleep(0.1)
- if _pid_alive(pid):
- kill_sig = signal.SIGTERM if sys.platform == "win32" else signal.SIGKILL
- _killpg_or_pid(pid, kill_sig)
- if sys.platform != "win32":
- with contextlib.suppress(OSError):
- os.waitpid(pid, 0) # reap if it happens to be our child
- if pid_file.exists():
- with contextlib.suppress(OSError):
- pid_file.unlink()
- return pid
-
def start(self, *, timeout: float = 30.0) -> None:
- # Like Apache::TestServer::start, clear any server (and stale pid file)
- # left over from a prior run before launching a new one.
- self.kill_stale(self._pid_file)
-
- check = self.configtest()
- if check.returncode != 0:
+ try:
+ self.instance.start(timeout=timeout)
+ except HttpdStartError as exc:
raise RuntimeError(
- f"httpd config test failed:\n{check.stdout}\n{check.stderr}"
- )
- # start_new_session=True (setsid) puts httpd in its own process group so
- # the parent and all forked MPM children can be signalled together via
- # os.killpg, guaranteeing no orphaned children survive a failed start.
- popen_kwargs = {}
- if sys.platform != "win32":
- popen_kwargs["start_new_session"] = True
- self.proc = subprocess.Popen( # noqa: S603 - trusted paths
- self.args(), **popen_kwargs
- )
- deadline = time.monotonic() + timeout
- while time.monotonic() < deadline:
- if self.proc.poll() is not None:
- code = self.proc.returncode
- self.stop() # reap any children that did spawn
- raise RuntimeError(
- f"httpd exited early (code {code}); "
- f"see {self.config.vars['t_logs']}/error_log"
- )
- if self._port_open():
- return
- time.sleep(0.1)
- self.stop()
- raise TimeoutError(f"httpd did not start within {timeout}s on port {self.port}")
+ f"{exc}; see {self.config.vars['t_logs']}/error_log\n{exc.stderr}"
+ ) from exc
def stop(self, *, timeout: float = 10.0) -> None:
"""Stop the server, reaping the whole process group, idempotently.
- Mirrors Apache::TestServer ``sub stop``: ``SIGTERM`` the server, wait,
- then unlink the pid file. We signal the launched process *group* so the
- event MPM children die with the parent, escalating to ``SIGKILL`` if
- the group does not exit within ``timeout``. Safe to call when the
- server was never started; also clears any matching stale pid file.
+ Safe to call when the server was never started; also clears any
+ matching stale pid file.
"""
- proc = self.proc
- if proc is not None and proc.poll() is None:
- pgid_pid = proc.pid
- _killpg_or_pid(pgid_pid, signal.SIGTERM)
- try:
- proc.wait(timeout=timeout)
- except subprocess.TimeoutExpired:
- kill_sig = signal.SIGTERM if sys.platform == "win32" else signal.SIGKILL
- _killpg_or_pid(pgid_pid, kill_sig)
- with contextlib.suppress(subprocess.TimeoutExpired):
- proc.wait(timeout=timeout)
- elif proc is not None:
- # Process exited on its own; reap it (no-op if already reaped).
- with contextlib.suppress(Exception):
- proc.wait(timeout=0)
-
- # Whether or not we had a live Popen handle, defensively clear anything
- # the pid file still points at and remove the file.
- self.kill_stale(self._pid_file, timeout=timeout)
- self.proc = None
+ self.instance.stop(timeout=timeout)
diff -Nru apache2-2.4.69/test/pytest_suite/apache_pytest/sslca.py apache2-2.4.71/test/pytest_suite/apache_pytest/sslca.py
--- apache2-2.4.69/test/pytest_suite/apache_pytest/sslca.py 2026-06-03 16:20:30.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/apache_pytest/sslca.py 2026-11-09 03:07:05.000000000 +0000
@@ -1,262 +1,10 @@
-"""Generate the SSL Certificate Authority and test certificates.
+"""Compatibility re-export: the CA generator now lives in :mod:`pyhttpd.sslca`."""
-Python port of Apache::TestSSLCA. Builds, under ``t/conf/ssl/ca/asf``, a self-
-signed CA plus server and client certificates that the SSL test configs reference
-(``server.crt``/``server.pem``, ``server_des3.*``, ``client_ok``/``client_snakeoil``/
-``client_revoked``/``client_colon``, proxy client PEMs, and a CRL). The layout and
-filenames match the Perl framework so the existing ``t/conf/ssl/*.conf.in`` files
-work unchanged.
-
-Notes vs. the Perl original:
-* RSA keys are 2048-bit, digest sha256 (matches modern openssl defaults).
-* DSA server variants (``server_dsa`` etc.) are generated too, for config files
- that may reference them; on OpenSSL 3.x DSA keygen still works.
-* All passphrases are ``httpd`` (``$pass`` in the original).
-"""
-
-from __future__ import annotations
-
-import subprocess
-from pathlib import Path
-
-PASS = "httpd"
-DAYS = "365"
-DGST = "sha256"
-EMAIL_FIELD = "emailAddress"
-
-# CA distinguished name (ca_dn{asf}).
-CA_DN = {
- "C": "US",
- "ST": "California",
- "L": "San Francisco",
- "O": "ASF",
- "OU": "httpd-test",
- "CN": "",
- EMAIL_FIELD: "test-dev@httpd.apache.org",
-}
-
-# Per-cert DN overrides (cert_dn). CN for server* is set to the servername at
-# generation time (matching generate() in the Perl module).
-CERT_DN: dict[str, dict[str, str]] = {
- "client_snakeoil": {
- "C": "AU", "ST": "Queensland", "L": "Mackay",
- "O": "Snake Oil, Ltd.", "OU": "Staff",
- },
- "client_ok": {},
- "client_colon": {"CN": "user:colon"},
- "client_revoked": {},
- "server": {"CN": "localhost", "OU": "httpd-test/rsa-test"},
- "server2": {"CN": "localhost", "OU": "httpd-test/rsa-test-2"},
- "server_des3": {"CN": "localhost", "OU": "httpd-test/rsa-des3-test"},
- "server2_des3": {"CN": "localhost", "OU": "httpd-test/rsa-des3-test-2"},
-}
-# DSA variants of every server cert (Perl derives these dynamically).
-for _k in list(CERT_DN):
- if _k.startswith("server"):
- _dsa = dict(CERT_DN[_k])
- _dsa["OU"] = _dsa.get("OU", "").replace("rsa", "dsa")
- CERT_DN[f"{_k}_dsa"] = _dsa
-
-
-class SSLCA:
- def __init__(self, ca_root: Path, servername: str, *, openssl: str = "openssl") -> None:
- # ca_root is e.g. t/conf/ssl/ca ; the CA tree lives under ca_root/asf.
- self.root = ca_root
- self.ca = "asf"
- self.servername = servername
- self.openssl = openssl
- self.dir = ca_root / self.ca
-
- # -- helpers ----------------------------------------------------------
-
- def _run(self, *args: str, cwd: Path) -> None:
- cmd = [self.openssl, *args]
- proc = subprocess.run( # noqa: S603 - trusted openssl invocation
- cmd, cwd=cwd, capture_output=True, text=True, check=False
- )
- if proc.returncode != 0:
- raise RuntimeError(
- f"openssl failed: {' '.join(cmd)}\n{proc.stdout}\n{proc.stderr}"
- )
-
- def _dn(self, name: str) -> dict[str, str]:
- dn = dict(CA_DN)
- dn["CN"] = dn["CN"] or name
- dn.update(CERT_DN.get(name, {}))
- if name.startswith("server"):
- dn["CN"] = self.servername
- return dn
-
- def _config_file(self, name: str) -> Path:
- """Write conf/.cnf (openssl config) and return its path."""
- dn = self._dn(name)
- cnf = self.dir / "conf" / f"{name}.cnf"
- cnf.write_text(
- f"mail = {dn[EMAIL_FIELD]}\n"
- f"CN = {dn['CN']}\n\n"
- "[ req ]\n"
- "distinguished_name = req_distinguished_name\n"
- "attributes = req_attributes\n"
- "prompt = no\n"
- "default_bits = 2048\n"
- f"output_password = {PASS}\n\n"
- "[ req_distinguished_name ]\n"
- f"C = {dn['C']}\n"
- f"ST = {dn['ST']}\n"
- f"L = {dn['L']}\n"
- f"O = {dn['O']}\n"
- f"OU = {dn['OU']}\n"
- "CN = $CN\n"
- f"{EMAIL_FIELD} = $mail\n\n"
- "[ req_attributes ]\n"
- f"challengePassword = {PASS}\n\n"
- "[ ca ]\n"
- "default_ca = CA_default\n\n"
- "[ CA_default ]\n"
- "certs = certs\n"
- "new_certs_dir = newcerts\n"
- "crl_dir = crl\n"
- "database = index.txt\n"
- "serial = serial\n"
- "certificate = certs/ca.crt\n"
- "crl = crl/ca-bundle.crl\n"
- "private_key = keys/ca.pem\n"
- "default_days = 365\n"
- "default_crl_days = 365\n"
- f"default_md = {DGST}\n"
- "preserve = no\n\n"
- "[ policy_anything ]\n"
- "countryName = optional\n"
- "stateOrProvinceName = optional\n"
- "localityName = optional\n"
- "organizationName = optional\n"
- "organizationalUnitName = optional\n"
- "commonName = supplied\n"
- f"{EMAIL_FIELD} = optional\n\n"
- "[ client_ok_ext ]\n"
- "nsComment = This Is A Comment\n"
- # Custom OID extension carrying the DER-encoded UTF8String "Lemons"
- # (0c=UTF8String, 06=len 6, 4c656d6f6e73="Lemons"). t/ssl/require.t's
- # certext location asserts: "Lemons" in PeerExtList().
- # Faithful to Apache::TestSSLCA's client_ok_ext.
- "1.3.6.1.4.1.18060.12.0 = DER:0c064c656d6f6e73\n"
- "subjectAltName = email:$mail\n\n"
- "[ client_ext ]\n"
- "extendedKeyUsage = clientAuth\n\n"
- "[ server_ext ]\n"
- "subjectAltName = DNS:$CN\n"
- "extendedKeyUsage = serverAuth\n"
- "subjectKeyIdentifier = hash\n"
- "authorityKeyIdentifier = keyid,issuer\n\n"
- "[ ca_ext ]\n"
- "subjectKeyIdentifier = hash\n"
- "authorityKeyIdentifier = keyid:always,issuer\n"
- "basicConstraints = critical,CA:true\n"
- # OpenSSL 3.x's verifier rejects a CA cert used to sign certs unless
- # it carries keyCertSign (and cRLSign for the CRL). The Perl-era
- # certs predate this strictness; add it so modern clients (httpx via
- # OpenSSL 3) can verify the chain.
- "keyUsage = critical,keyCertSign,cRLSign\n"
- )
- return cnf
-
- # -- generation steps -------------------------------------------------
-
- def _init_dirs(self) -> None:
- for d in ("keys", "newcerts", "certs", "crl", "export", "csr", "conf", "proxy"):
- (self.dir / d).mkdir(parents=True, exist_ok=True)
- (self.dir / "index.txt").write_text("")
- (self.dir / "serial").write_text("01\n")
-
- def _new_ca(self) -> None:
- self._config_file("ca")
- self._run(
- "req", "-new", "-x509", "-extensions", "ca_ext",
- "-keyout", "keys/ca.pem", "-out", "certs/ca.crt",
- "-days", DAYS, "-config", "conf/ca.cnf",
- "-passout", f"pass:{PASS}",
- cwd=self.dir,
- )
-
- def _new_key(self, name: str) -> None:
- out = f"keys/{name}.pem"
- if "dsa" in name:
- param = self.dir / "dsa-param"
- if not param.exists():
- self._run("dsaparam", "-out", "dsa-param", "2048", cwd=self.dir)
- args = ["gendsa", "-out", out]
- if "_des3" in name:
- args += ["-des3", "-passout", f"pass:{PASS}"]
- args += ["dsa-param"]
- self._run(*args, cwd=self.dir)
- else:
- args = ["genrsa", "-out", out]
- if "_des3" in name:
- args += ["-des3", "-passout", f"pass:{PASS}"]
- args += ["2048"]
- self._run(*args, cwd=self.dir)
-
- def _new_cert(self, name: str) -> None:
- self._config_file(name)
- self._run(
- "req", "-new", "-key", f"keys/{name}.pem", "-out", f"csr/{name}.csr",
- "-passin", f"pass:{PASS}", "-passout", f"pass:{PASS}",
- "-config", f"conf/{name}.cnf",
- cwd=self.dir,
- )
- exts: list[str] = []
- if "client" in name:
- exts = ["-extensions", "client_ext"]
- if "client_ok" in name:
- exts = ["-extensions", "client_ok_ext"]
- if "server" in name:
- exts = ["-extensions", "server_ext"]
- self._run(
- "ca", "-policy", "policy_anything",
- "-in", f"csr/{name}.csr", "-out", f"certs/{name}.crt",
- "-passin", f"pass:{PASS}", "-config", f"conf/{name}.cnf",
- "-batch", *exts,
- cwd=self.dir,
- )
-
- def _make_proxy_cert(self, name: str) -> None:
- """Concatenate cert + key into proxy/.pem (make_proxy_cert)."""
- crt = (self.dir / "certs" / f"{name}.crt").read_text()
- key = (self.dir / "keys" / f"{name}.pem").read_text()
- (self.dir / "proxy" / f"{name}.pem").write_text(crt + key)
-
- def _revoke_cert(self, name: str) -> None:
- self._run(
- "ca", "-revoke", f"certs/{name}.crt",
- "-config", "conf/ca.cnf", "-passin", f"pass:{PASS}",
- cwd=self.dir,
- )
- self._run(
- "ca", "-gencrl", "-out", "crl/ca-bundle.crl",
- "-config", "conf/ca.cnf", "-passin", f"pass:{PASS}",
- cwd=self.dir,
- )
-
- def generate(self) -> Path:
- """Build the full CA tree (idempotent). Returns the CA root dir."""
- if self.dir.is_dir() and (self.dir / "certs" / "ca.crt").exists():
- return self.root # already generated
- self._init_dirs()
- self._new_ca()
- # Ensure a CRL exists even if nothing is revoked.
- for name in CERT_DN:
- self._new_key(name)
- self._new_cert(name)
- if name.endswith("_revoked"):
- self._revoke_cert(name)
- if name.startswith("client_"):
- self._make_proxy_cert(name)
- # Guarantee crl/ca-bundle.crl exists (referenced by SSLCARevocationFile).
- crl = self.dir / "crl" / "ca-bundle.crl"
- if not crl.exists():
- self._run(
- "ca", "-gencrl", "-out", "crl/ca-bundle.crl",
- "-config", "conf/ca.cnf", "-passin", f"pass:{PASS}",
- cwd=self.dir,
- )
- return self.root
+from pyhttpd.sslca import ( # noqa: F401
+ CA_DN,
+ CERT_DN,
+ DAYS,
+ EMAIL_FIELD,
+ PASS,
+ SSLCA,
+)
diff -Nru apache2-2.4.69/test/pytest_suite/apache_pytest/testapi.py apache2-2.4.71/test/pytest_suite/apache_pytest/testapi.py
--- apache2-2.4.69/test/pytest_suite/apache_pytest/testapi.py 2026-06-03 16:20:30.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/apache_pytest/testapi.py 2026-11-09 03:07:05.000000000 +0000
@@ -10,9 +10,9 @@
* ``have_min_apache_version()`` -> :func:`have_min_apache_version` (runtime gate)
* ``GET`` / ``GET_BODY`` / ... -> methods on the ``http`` fixture (client.py)
-The requirement markers (need_*) are evaluated at collection time by a
-``pytest_collection_modifyitems`` hook in conftest.py, which probes the target
-httpd once and skips tests whose requirements aren't met -- the analog of
+The requirement markers (need_*) are evaluated at collection time by
+pyhttpd.gating, which probes the target httpd once and skips tests whose
+requirements aren't met -- the analog of
Apache::Test's plan-time skipping.
"""
@@ -23,6 +23,9 @@
import pytest
+from pyhttpd.backport import has_change as has_trunk_change # noqa: F401
+from pyhttpd.backport import not_backported # noqa: F401 (one definition, both suites)
+
# --------------------------------------------------------------------------- #
# Assertions / comparison
# --------------------------------------------------------------------------- #
@@ -60,8 +63,8 @@
# --------------------------------------------------------------------------- #
# Requirement markers (need_*) -- thin wrappers over pytest.mark
# --------------------------------------------------------------------------- #
-# Each marker records its requirement as marker args; the collection hook in
-# conftest.py reads them against the probed HttpdInfo and skips as needed.
+# Each marker records its requirement as marker args; pyhttpd.gating reads them
+# against the probed HttpdInfo at collection time and skips as needed.
def need_module(*names: str) -> pytest.MarkDecorator:
diff -Nru apache2-2.4.69/test/pytest_suite/conftest.py apache2-2.4.71/test/pytest_suite/conftest.py
--- apache2-2.4.69/test/pytest_suite/conftest.py 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/conftest.py 2026-11-09 03:07:05.000000000 +0000
@@ -18,10 +18,13 @@
from __future__ import annotations
-import subprocess
from pathlib import Path
import pytest
+from pyhttpd.build import resolve_build as _resolve_build
+from pyhttpd.build import why_no_build as _why_no_build
+from pyhttpd.classic_errorlog import check_shutdown, check_startup
+from pyhttpd.gating import add_module_provider
from apache_pytest import HttpdServer, TestClient, TestConfig, compile_all, probe
from apache_pytest.cmodules import clean_modules
@@ -35,200 +38,27 @@
REPO_ROOT = SUITE_ROOT
-def pytest_addoption(parser: pytest.Parser) -> None:
- group = parser.getgroup("httpd")
- group.addoption("--httpd", action="store", default=None, help="path to httpd binary")
- group.addoption("--apxs", action="store", default=None, help="path to apxs")
- group.addoption(
- "--defines",
- action="store",
- default="",
- help="space-separated extra -D defines (e.g. 'LDAP')",
- )
- group.addoption(
- "--php-fpm",
- action="store",
- default=None,
- help="path to a php-fpm binary; enables the t/php tests via mod_proxy_fcgi "
- "(any version/location -- the path is the only PHP-specific input)",
- )
- group.addoption(
- "--php-fpm-port",
- action="store",
- type=int,
- default=8999,
- help="TCP port for the managed php-fpm pool (default 8999)",
- )
- group.addoption(
- "--clean-modules",
- action="store_true",
- default=False,
- help="remove all compiled C-module artifacts before building (emulate make clean)",
- )
- group.addoption(
- "--conf",
- action="store",
- default=None,
- help="path to the installed httpd.conf (for LoadModule discovery "
- "when --apxs is not available, e.g. on Windows)",
- )
- group.addoption(
- "--prefix",
- action="store",
- default=None,
- help="server install prefix for resolving relative module paths "
- "(default: derived from --conf path)",
- )
- group.addoption(
- "--with-perl",
- action="store",
- default=None,
- help="path to the perl interpreter for generated scripts and "
- "rewrite prg-maps (default: /usr/bin/perl or APACHE_TEST_PERL env)",
- )
-
-
-def pytest_configure(config: pytest.Config) -> None:
- for marker in (
- "need_module(*names): skip unless all named httpd modules are available",
- "need_min_apache_version(ver): skip unless server >= ver",
- "need_cgi(): skip unless a CGI module is available",
- "need_php(): skip unless a PHP SAPI module is available",
- "need_ssl(): skip unless mod_ssl is available",
- "need_lwp(): always satisfied (httpx client always present)",
- ):
- config.addinivalue_line("markers", marker)
-
-
-def _apxs_query(apxs: Path, var: str) -> str:
- proc = subprocess.run( # noqa: S603 - trusted path
- ["perl", str(apxs), "-q", var], capture_output=True, text=True, check=True
- )
- return proc.stdout.strip()
-
-
-class _NoServerError(Exception):
- """Raised when neither --httpd nor --apxs was provided."""
-
-
-def _resolve_paths(
- config: pytest.Config,
-) -> tuple[Path, Path | None, Path | None, Path | None, list[str]]:
- """Resolve (httpd, apxs, inherited_conf, install_prefix, defines) from options.
-
- Raises :class:`_NoServerError` if neither --httpd nor --apxs is given. (A
- plain exception, not pytest.fail, so the collection-time probe can catch it
- without turning into an INTERNALERROR; the fixture converts it to a clean
- skip/fail at setup time.)
- """
- apxs_opt = config.getoption("--apxs")
- httpd_opt = config.getoption("--httpd")
- defines = [d for d in config.getoption("--defines").split() if d]
-
- apxs = Path(apxs_opt) if apxs_opt else None
- inherited_conf: Path | None = None
- install_prefix: Path | None = None
- if apxs is not None:
- sbindir = Path(_apxs_query(apxs, "SBINDIR"))
- sysconfdir = Path(_apxs_query(apxs, "SYSCONFDIR"))
- install_prefix = Path(_apxs_query(apxs, "PREFIX"))
- inherited_conf = sysconfdir / "httpd.conf"
- if httpd_opt is None:
- httpd_opt = str(sbindir / "httpd")
-
- conf_opt = config.getoption("--conf")
- prefix_opt = config.getoption("--prefix")
- if conf_opt is not None and inherited_conf is None:
- inherited_conf = Path(conf_opt)
- if prefix_opt is not None:
- install_prefix = Path(prefix_opt)
- elif inherited_conf is not None and install_prefix is None:
- install_prefix = inherited_conf.parent.parent
-
- if httpd_opt is None:
- raise _NoServerError("must pass --httpd or --apxs")
- return Path(httpd_opt), apxs, inherited_conf, install_prefix, defines
-
-
# --------------------------------------------------------------------------- #
# Collection-time requirement gating (need_* markers)
# --------------------------------------------------------------------------- #
-_probe_cache: HttpdInfo | None = None
-
+# The markers are evaluated by pyhttpd.gating, against the build that
+# pyhttpd.build found. The bundled C test modules (authany, echo_post,
+# input_body_filter, ...) are compiled and LoadModule'd into the generated
+# test config by the session fixture below, so need_module("authany") etc.
+# should be satisfied at collection time too. Declare the C modules that WILL
+# be built (honoring the same HTTPD_TEST_REQUIRE_APACHE gating discover()
+# applies).
-def _probed_info(config: pytest.Config) -> HttpdInfo | None:
- """Probe the target httpd once for collection-time requirement checks.
- Returns None if no httpd was specified (so requirement markers can't be
- evaluated -- tests are left to fail/skip naturally at fixture setup).
- """
- global _probe_cache # noqa: PLW0603 - simple memo across the collection hook
- if _probe_cache is not None:
- return _probe_cache
- try:
- httpd, _apxs, inherited_conf, install_prefix, _defines = _resolve_paths(config)
- except Exception:
- return None
- info = probe(httpd, inherited_conf, install_prefix)
- # The bundled C test modules (authany, echo_post, input_body_filter, ...) are
- # compiled and LoadModule'd into the generated test config by the session
- # fixture, so need_module("authany") etc. should be satisfied at collection
- # time too. Augment the probed set with the C modules that WILL be built
- # (honoring the same HTTPD_TEST_REQUIRE_APACHE gating discover() applies).
+def _provide_cmodules(info: HttpdInfo) -> None:
from apache_pytest.cmodules import discover
cmods, _skipped = discover(REPO_ROOT / "c-modules", info)
for mod in cmods:
info.modules.add(f"mod_{mod.name}.c")
- _probe_cache = info
- return _probe_cache
-def _unmet_requirement(
- item: pytest.Item, info: HttpdInfo, *, php_fpm: bool = False
-) -> str | None:
- """Return a skip reason if any need_* marker on ``item`` is unmet, else None.
-
- ``php_fpm`` indicates a php-fpm binary was provided (and mod_proxy_fcgi is
- available), which satisfies need_php even without an in-process PHP SAPI.
- """
- for mark in item.iter_markers(name="need_module"):
- for name in mark.args:
- if not info.has_module(name):
- return f"module {name} not available"
- for mark in item.iter_markers(name="need_min_apache_version"):
- ver = mark.args[0]
- parts = tuple(int(x) for x in str(ver).split("."))
- parts += (0,) * (3 - len(parts))
- if info.version < parts:
- return f"httpd {info.version_str} < required {ver}"
- if any(item.iter_markers(name="need_cgi")) and not (
- info.has_module("mod_cgi") or info.has_module("mod_cgid")
- ):
- return "no CGI module (mod_cgi/mod_cgid) available"
- if any(item.iter_markers(name="need_php")):
- php_mods = ("sapi_apache2", "mod_php4", "mod_php5", "mod_php7", "mod_php")
- have_sapi = any(info.has_module(m) for m in php_mods)
- have_fpm = php_fpm and info.has_module("mod_proxy_fcgi")
- if not (have_sapi or have_fpm):
- return "no PHP SAPI module or php-fpm available"
- if any(item.iter_markers(name="need_ssl")) and not info.has_module("mod_ssl"):
- return "mod_ssl not available"
- # need_lwp is always satisfied (httpx is always present); no check.
- return None
-
-
-def pytest_collection_modifyitems(
- config: pytest.Config, items: list[pytest.Item]
-) -> None:
- info = _probed_info(config)
- if info is None:
- return
- php_fpm = bool(config.getoption("--php-fpm"))
- for item in items:
- reason = _unmet_requirement(item, info, php_fpm=php_fpm)
- if reason:
- item.add_marker(pytest.mark.skip(reason=reason))
+add_module_provider(_provide_cmodules)
# --------------------------------------------------------------------------- #
@@ -237,11 +67,15 @@
@pytest.fixture(scope="session")
def framework(request: pytest.FixtureRequest):
"""Probe, configure, build C modules, start httpd; yield (config, server)."""
- try:
- httpd, apxs, inherited_conf, install_prefix, defines = _resolve_paths(request.config)
- except _NoServerError as exc:
- pytest.fail(str(exc))
+ # the httpd of the session, the one the need_* marks were evaluated for
+ target = _resolve_build(request.config)
+ if target is None:
+ pytest.fail(_why_no_build())
+ httpd, apxs = target.httpd, target.apxs
+ inherited_conf, install_prefix = target.inherited_conf, target.prefix
+ defines = target.extra_defines
+ # probed again: gating added the c-modules/ that will be built to target
info = probe(httpd, inherited_conf, install_prefix)
perl_opt = request.config.getoption("--with-perl")
@@ -309,11 +143,14 @@
server = HttpdServer(config)
server.start()
try:
+ check_startup(request.config, str(error_log),
+ int(config.vars["session_log_start"]))
yield config, server
finally:
server.stop()
if fpm_mgr is not None:
fpm_mgr.stop()
+ check_shutdown(request.config)
@pytest.fixture(scope="session")
diff -Nru apache2-2.4.69/test/pytest_suite/gen_test_header.py apache2-2.4.71/test/pytest_suite/gen_test_header.py
--- apache2-2.4.69/test/pytest_suite/gen_test_header.py 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/gen_test_header.py 2026-11-09 03:07:05.000000000 +0000
@@ -9,6 +9,10 @@
import types
from pathlib import Path
+# CMake runs this script directly, without pytest's pythonpath settings.
+# cmodules.py also uses the shared, standard-library-only pyhttpd builder.
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
# Stub out the apache_pytest package so cmodules.py can be loaded
# without pulling in httpx and other test-only dependencies.
pkg = types.ModuleType('apache_pytest')
diff -Nru apache2-2.4.69/test/pytest_suite/pyproject.toml apache2-2.4.71/test/pytest_suite/pyproject.toml
--- apache2-2.4.69/test/pytest_suite/pyproject.toml 2026-06-03 16:20:30.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/pyproject.toml 1970-01-01 00:00:00.000000000 +0000
@@ -1,29 +0,0 @@
-[project]
-name = "httpd-pytest"
-version = "0.1.0"
-description = "Self-contained pytest port of the Apache httpd integration test suite"
-requires-python = ">=3.11"
-dependencies = [
- "pytest>=8.0",
- "httpx>=0.27",
-]
-
-[tool.pytest.ini_options]
-# Eventually tests live under ../t/ per category (apache, modules, ssl, ...).
-# Phase 1 ships the framework + smoke/parity tests under tests/.
-testpaths = ["tests"]
-python_files = ["test_*.py", "*_test.py"]
-# Make the framework package (python/apache_pytest) importable from conftest and
-# tests regardless of import mode.
-pythonpath = ["."]
-# importlib import mode lets same-named test modules coexist across category
-# dirs (e.g. ssl/test_all.py and php/test_all.py) without __init__.py files or
-# import-file-mismatch collisions.
-addopts = "-ra --import-mode=importlib"
-
-[tool.ruff]
-line-length = 100
-target-version = "py311"
-
-[tool.uv]
-package = false
diff -Nru apache2-2.4.69/test/pytest_suite/ruff.toml apache2-2.4.71/test/pytest_suite/ruff.toml
--- apache2-2.4.69/test/pytest_suite/ruff.toml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/ruff.toml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,2 @@
+line-length = 100
+target-version = "py311"
diff -Nru apache2-2.4.69/test/pytest_suite/runtests.bat apache2-2.4.71/test/pytest_suite/runtests.bat
--- apache2-2.4.69/test/pytest_suite/runtests.bat 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/runtests.bat 1970-01-01 00:00:00.000000000 +0000
@@ -1,164 +0,0 @@
-@echo off
-rem
-rem runtests.bat -- run the Apache httpd pytest suite on Windows.
-rem
-rem This is the Windows equivalent of runtests.sh. It needs a built
-rem Apache httpd (the server under test) located via apxs, and
-rem optionally a php-fpm binary to run the PHP tests.
-rem
-rem Usage:
-rem runtests.bat -- auto-detect apxs/httpd/php-fpm/perl on PATH
-rem runtests.bat --apxs C:\path\apxs -- point at a specific build
-rem runtests.bat --with-perl C:\path\perl.exe -- point at a specific perl
-rem runtests.bat tests\t\modules -- run a subset (any pytest args pass through)
-rem runtests.bat -k status -v -- extra pytest args pass through too
-rem
-rem Environment overrides (used when the matching --flag is not supplied):
-rem APXS path to apxs
-rem HTTPD path to httpd
-rem PHP_FPM path to php-fpm
-rem APACHE_TEST_PERL path to perl
-rem
-
-setlocal enabledelayedexpansion
-
-set "HERE=%~dp0"
-rem Remove trailing backslash
-if "%HERE:~-1%"=="\" set "HERE=%HERE:~0,-1%"
-
-cd /d "%HERE%"
-
-rem --- python location: find the newest native Windows Python 3.x
-set PYTHON=
-for /f "delims=" %%D in ('dir /b /o-n "%LOCALAPPDATA%\Programs\Python\Python3*" 2^>nul') do (
- if not defined PYTHON set "PYTHON=%LOCALAPPDATA%\Programs\Python\%%D\python.exe"
-)
-if not defined PYTHON (
- for /f "delims=" %%D in ('dir /b /o-n "%PROGRAMFILES%\Python3*" 2^>nul') do (
- if not defined PYTHON set "PYTHON=%PROGRAMFILES%\%%D\python.exe"
- )
-)
-if not defined PYTHON (
- echo runtests.bat: ERROR: Python interpreter not found. >&2
- echo Install Python from https://www.python.org or set PYTHON=... >&2
- exit /b 1
-)
-
-rem --- ensure the venv exists and is current ----------------------------------
-set "PYTEST=%HERE%\.venv\Scripts\pytest.exe"
-
-set "NEED_SYNC=0"
-if not exist "%PYTEST%" set "NEED_SYNC=1"
-
-rem Check if pyproject.toml is newer than .venv (approximate: compare timestamps
-rem via xcopy /D /L which lists files that are newer than the destination).
-if exist "%HERE%\.venv" (
- for /f %%F in ('xcopy /D /L /Y "%HERE%\pyproject.toml" "%HERE%\.venv\" 2^>nul ^| C:\Windows\System32\find.exe /c "pyproject.toml"') do (
- if %%F gtr 0 set "NEED_SYNC=1"
- )
-) else (
- set "NEED_SYNC=1"
-)
-
-if "%NEED_SYNC%"=="1" (
- %PYTHON% -m uv --version >nul 2>&1
- if errorlevel 1 (
- echo runtests.bat: ERROR: 'uv' is required but not installed. >&2
- echo Install it from https://docs.astral.sh/uv/ and re-run. >&2
- echo %PYTHON% -m pip install uv >&2
- exit /b 1
- )
- echo runtests.bat: ^(re^)creating %HERE%\.venv via 'uv sync'... >&2
- %PYTHON% -m uv sync --project "%HERE%"
- if errorlevel 1 exit /b 1
- rem Touch .venv so the staleness check won't retrigger
- copy /b "%HERE%\.venv" +,, "%HERE%\.venv" >nul 2>&1
-)
-
-rem --- discover apxs / httpd / php-fpm ----------------------------------------
-if not defined APXS (
- for %%C in (apxs.exe) do (
- if "!APXS!"=="" (
- for /f "delims=" %%P in ('where %%C 2^>nul') do (
- if "!APXS!"=="" set "APXS=%%P"
- )
- )
- )
-)
-
-if not defined PHP_FPM (
- for %%C in (php-fpm.exe php-fpm8.3.exe php-fpm83.exe php-fpm8.2.exe php-fpm82.exe) do (
- if "!PHP_FPM!"=="" (
- for /f "delims=" %%P in ('where %%C 2^>nul') do (
- if "!PHP_FPM!"=="" set "PHP_FPM=%%P"
- )
- )
- )
-)
-
-if not defined APACHE_TEST_PERL (
- for %%C in (perl.exe) do (
- for /f "delims=" %%P in ('where %%C 2^>nul') do (
- if "!APACHE_TEST_PERL!"=="" set "APACHE_TEST_PERL=%%P"
- )
- )
-)
-
-rem Build the default flag set only for values the user did not pass explicitly.
-set "AUTO_ARGS="
-
-rem Check if user passed --apxs or --httpd on the command line.
-set "HAS_SERVER_FLAG=0"
-for %%A in (%*) do (
- echo %%A | findstr /b /c:"--apxs" /c:"--httpd" >nul 2>&1 && set "HAS_SERVER_FLAG=1"
-)
-
-if "%HAS_SERVER_FLAG%"=="0" (
- if defined APXS (
- set "AUTO_ARGS=--apxs=!APXS!"
- ) else if defined HTTPD (
- set "AUTO_ARGS=--httpd=!HTTPD!"
- ) else (
- echo runtests.bat: ERROR: no apxs/httpd found. >&2
- echo Pass --apxs=C:\path\to\apxs, or set APXS=... or HTTPD=... >&2
- exit /b 1
- )
-)
-
-rem Check if user passed --php-fpm on the command line.
-set "HAS_PHP_FLAG=0"
-for %%A in (%*) do (
- echo %%A | findstr /b /c:"--php-fpm" >nul 2>&1 && set "HAS_PHP_FLAG=1"
-)
-
-if "%HAS_PHP_FLAG%"=="0" (
- if defined PHP_FPM (
- set "AUTO_ARGS=!AUTO_ARGS! --php-fpm=!PHP_FPM!"
- ) else (
- echo runtests.bat: note: no php-fpm found; PHP tests will skip. >&2
- echo Set PHP_FPM=C:\path\to\php-fpm to run them. >&2
- )
-)
-
-rem Check if user passed --with-perl on the command line.
-set "HAS_PERL_FLAG=0"
-for %%A in (%*) do (
- echo %%A | findstr /b /c:"--with-perl" >nul 2>&1 && set "HAS_PERL_FLAG=1"
-)
-
-if "%HAS_PERL_FLAG%"=="0" (
- if defined APACHE_TEST_PERL (
- set "AUTO_ARGS=!AUTO_ARGS! --with-perl=!APACHE_TEST_PERL!"
- ) else (
- echo runtests.bat: note: no perl found; rewrite prg-map tests may fail. >&2
- echo Pass --with-perl=C:\path\to\perl.exe or set APACHE_TEST_PERL=... >&2
- )
-)
-
-rem A stale mod_cgid socket in t\logs can break a fresh run; clear it first.
-del /q "%HERE%\t\logs\cgisock*" 2>nul
-
-rem --- run --------------------------------------------------------------------
-echo runtests.bat: %PYTEST% %AUTO_ARGS% %* >&2
-"%PYTEST%" %AUTO_ARGS% %*
-exit /b %errorlevel%
diff -Nru apache2-2.4.69/test/pytest_suite/runtests.sh apache2-2.4.71/test/pytest_suite/runtests.sh
--- apache2-2.4.69/test/pytest_suite/runtests.sh 2026-07-18 15:32:22.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/runtests.sh 1970-01-01 00:00:00.000000000 +0000
@@ -1,104 +0,0 @@
-#!/bin/sh
-#
-# runtests.sh -- run the Apache httpd pytest suite.
-#
-# This is the self-contained Python/pytest port of the Apache httpd test suite.
-# It needs a built Apache httpd (the server under test) located via apxs, and
-# optionally a php-fpm binary to run the PHP tests.
-#
-# Usage:
-# ./runtests.sh # auto-detect apxs/httpd/php-fpm on PATH
-# ./runtests.sh --apxs /path/to/apxs # point at a specific build
-# ./runtests.sh tests/t/modules # run a subset (any pytest args pass through)
-# ./runtests.sh -k status -v # extra pytest args pass through too
-#
-# Environment overrides (used when the matching --flag is not supplied):
-# APXS path to apxs (default: first 'apxs' on PATH)
-# HTTPD path to httpd (default: derived from apxs, i.e. apxs -q SBINDIR/httpd)
-# PHP_FPM path to php-fpm (default: first 'php-fpm'/'php-fpm8*'/'php-fpm83' on PATH; PHP tests skip if none)
-#
-# Examples:
-# APXS=$HOME/root/httpd/bin/apxs ./runtests.sh
-# PHP_FPM=/opt/local/sbin/php-fpm83 ./runtests.sh tests/t/php
-#
-set -eu
-
-here="$(cd "$(dirname "$0")" && pwd)"
-cd "$here"
-
-# --- ensure the venv exists and is current ----------------------------------
-# The suite baselines on uv (https://docs.astral.sh/uv/) as its dependency and
-# venv manager: it reads pyproject.toml + uv.lock, so there is a single source
-# of truth for dependencies. We invoke .venv/bin/pytest directly rather than
-# `uv run` so the suite works even where `uv run` is shimmed/unavailable.
-#
-# Create $here/.venv on first run, and rebuild it when pyproject.toml is newer
-# than the venv (i.e. dependencies changed). Absolute paths throughout, so this
-# behaves identically regardless of the caller's cwd. This block is kept
-# byte-for-byte identical in pytest_suite/runtests.sh and pyhttpd/runtests.sh
-# -- edit both together.
-PYTEST="$here/.venv/bin/pytest"
-if [ ! -x "$PYTEST" ] || [ "$here/pyproject.toml" -nt "$here/.venv" ]; then
- if ! command -v uv >/dev/null 2>&1; then
- echo "runtests.sh: ERROR: 'uv' is required but not installed." >&2
- echo " Install it from https://docs.astral.sh/uv/ and re-run." >&2
- exit 1
- fi
- echo "runtests.sh: (re)creating $here/.venv via 'uv sync'..." >&2
- uv sync --project "$here"
- # Mark the venv as freshly built so the staleness check above won't retrigger
- # until pyproject.toml changes again.
- touch "$here/.venv"
-fi
-
-# --- discover apxs / httpd / php-fpm ----------------------------------------
-# Any of these may be overridden by passing the matching --flag through to
-# pytest (those take precedence); here we only fill in defaults via env/PATH.
-discover() {
- # discover VAR cmd1 cmd2 ... -> echo first one found on PATH
- shift
- for c in "$@"; do
- if command -v "$c" >/dev/null 2>&1; then command -v "$c"; return 0; fi
- done
- return 1
-}
-
-APXS="${APXS:-$(discover APXS apxs || true)}"
-PHP_FPM="${PHP_FPM:-$(discover PHP_FPM php-fpm php-fpm8.3 php-fpm83 php-fpm8.2 php-fpm82 || true)}"
-
-# Build the default flag set only for values the user did not pass explicitly.
-auto_args=""
-case " $* " in
- *" --apxs"*|*" --httpd"*) : ;; # user specified a server; don't auto-add
- *)
- if [ -n "${APXS:-}" ]; then
- auto_args="--apxs=$APXS"
- elif [ -n "${HTTPD:-}" ]; then
- auto_args="--httpd=$HTTPD"
- else
- echo "runtests.sh: ERROR: no apxs/httpd found." >&2
- echo " Pass --apxs=/path/to/apxs, or set APXS=... or HTTPD=..." >&2
- exit 1
- fi
- ;;
-esac
-
-case " $* " in
- *" --php-fpm"*) : ;; # user specified php-fpm; don't auto-add
- *)
- if [ -n "${PHP_FPM:-}" ]; then
- auto_args="$auto_args --php-fpm=$PHP_FPM"
- else
- echo "runtests.sh: note: no php-fpm found; PHP tests will skip." >&2
- echo " Set PHP_FPM=/path/to/php-fpm (any version) to run them." >&2
- fi
- ;;
-esac
-
-# A stale mod_cgid socket in t/logs can break a fresh run; clear it first.
-rm -f "$here/t/logs/cgisock"* 2>/dev/null || true
-
-# --- run --------------------------------------------------------------------
-echo "runtests.sh: $PYTEST $auto_args $*" >&2
-# shellcheck disable=SC2086 # auto_args is an intentional word-split flag list
-exec "$PYTEST" $auto_args "$@"
diff -Nru apache2-2.4.69/test/pytest_suite/t/conf/extra.conf.in apache2-2.4.71/test/pytest_suite/t/conf/extra.conf.in
--- apache2-2.4.69/test/pytest_suite/t/conf/extra.conf.in 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/conf/extra.conf.in 2026-11-09 03:07:05.000000000 +0000
@@ -135,6 +135,15 @@
LanguagePriority zh-TW fr fu en de
+
+ Options +MultiViews
+ AddType text/html;qs=0.9 .html
+ AddType application/xhtml+xml;qs=1.0 .xhtml
+ AddType "text/html;charset=utf-8;qs=0.8" .qsafter
+ AddType "text/html;QS=0.8;charset=utf-8" .qsbefore
+ AddType "application/example;foo=\"a;b\";q=0.7;qs=0.5;bar=\"c, d\"" .quoted
+
+
@@ -647,6 +656,9 @@
DAV On
+ = 2.5.1>
+ DAVMSext All
+
@@ -884,6 +896,8 @@
Alias /modules/ldap/group @DocumentRoot@
Alias /modules/ldap/refer @DocumentRoot@
Alias /modules/ldap/search @DocumentRoot@
+ Alias /modules/ldap/subreq @DocumentRoot@
+ Alias /modules/ldap/target @DocumentRoot@
# Simple user lookup
@@ -924,6 +938,40 @@
Require ldap-search cn=Subgroup
+ # Subrequest issued from the fixups phase (mod_rewrite -U) into a second
+ # per-dir config with identical LDAP settings. A subrequest only re-runs
+ # authn/authz when its per-dir config differs from the parent's, and at
+ # mod_rewrite's fixup the parent still holds the LDAP connection it used
+ # for authz. On platforms where APR_THREAD_MUTEX_DEFAULT is recursive
+ # (Windows) the subrequest used to be handed the parent's connection, and
+ # the parent's cleanup then ran against an already released connection.
+
+
+ AuthLDAPURL "ldap://localhost:8389/dc=example,dc=com?uid"
+ AuthLDAPBindDN "cn=httpd,dc=example,dc=com"
+ AuthLDAPBindPassword mod_authnz_ldap
+ AuthType Basic
+ AuthName ldap-subreq@httpd.apache.org
+ AuthBasicProvider ldap
+ Require ldap-group cn=Group One,dc=example,dc=com
+ RewriteEngine On
+ RewriteCond /modules/ldap/target/foobar.html -U
+ RewriteRule ^ - [E=LDAP_SUBREQ:ok]
+
+ Header set X-LDAP-Subreq "%{LDAP_SUBREQ}e"
+
+
+
+ AuthLDAPURL "ldap://localhost:8389/dc=example,dc=com?uid"
+ AuthLDAPBindDN "cn=httpd,dc=example,dc=com"
+ AuthLDAPBindPassword mod_authnz_ldap
+ AuthType Basic
+ AuthName ldap-target@httpd.apache.org
+ AuthBasicProvider ldap
+ Require ldap-group cn=Group One,dc=example,dc=com
+
+
+
##
@@ -1538,6 +1586,10 @@
CheckSpelling on
CheckCaseOnly on
+ # Point a short URI at a longer, nonexistent base name in a CheckSpelling
+ # directory. This exercised the r->uri + (urlen - pglen) underflow in
+ # check_speling(); the server must answer cleanly rather than read OOB.
+ Alias /sp-oob @SERVERROOT@/htdocs/modules/speling/nocase/nonexistent-long-name.html
diff -Nru apache2-2.4.69/test/pytest_suite/t/conf/proxy_beacon.conf.in apache2-2.4.71/test/pytest_suite/t/conf/proxy_beacon.conf.in
--- apache2-2.4.69/test/pytest_suite/t/conf/proxy_beacon.conf.in 2026-06-05 08:02:50.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/conf/proxy_beacon.conf.in 2026-11-09 03:07:05.000000000 +0000
@@ -27,6 +27,11 @@
+ # Let mod_watchdog's singleton move to another child when its owner dies.
+ # The default lock is not released by a SIGKILLed holder, so the hand-off
+ # tests in test_proxy_beacon_input.py would never see a new owner.
+ Mutex sysvsem watchdog-callback
+
# Received-beacon + balancer add/route logging in the error_log.
LogLevel proxy_beacon:info proxy_balancer:debug
@@ -49,7 +54,7 @@
# An initially-empty balancer with spare slots for runtime members.
- ProxySet growth=10
+ ProxySet growth=60
ProxyPass /beacon balancer://beacon
@@ -72,7 +77,7 @@
ProxyBeaconAddress 127.0.0.1:@PORT@
ProxyBeaconAdvertise http://127.0.0.1:1
- ProxyBeaconSecret wrong-secret
+ ProxyBeaconSecret wrong-secret-0123456789
ProxyBeaconInterval 1
@@ -97,6 +102,118 @@
ProxyPass /cap balancer://cap
+ #
+ # Ownership / hand-off scenario (third receiver). balancer://handoff holds one
+ # STATIC member (configured by the admin, never announces) plus room for
+ # dynamic ones. The tests announce a member once and then fall silent, kill
+ # the watchdog-owning child, and expect the NEW owner to evict the silent
+ # member after ProxyBeaconTimeout -- and to leave the static member alone.
+ #
+ Define BEACON_HANDOFF_PORT @NextAvailablePort@
+
+
+ ProxyBeaconListen 127.0.0.1:${BEACON_HANDOFF_PORT}
+ ProxyBeaconBalancer handoff
+ ProxyBeaconSecret beacon-test-shared-secret
+ ProxyBeaconTimeout 3
+
+ BalancerMember http://127.0.0.11:19000
+ ProxySet growth=5
+
+ ProxyPass /handoff balancer://handoff
+
+ SetHandler balancer-manager
+ Require all granted
+
+
+
+ #
+ # Sub-second freshness window (fourth receiver): ProxyBeaconMaxSkew 500ms must
+ # mean 500ms, not "rounds to 0, so fall back to the 30s default".
+ #
+ Define BEACON_SKEW_PORT @NextAvailablePort@
+
+
+ ProxyBeaconListen 127.0.0.1:${BEACON_SKEW_PORT}
+ ProxyBeaconBalancer skew
+ ProxyBeaconSecret beacon-test-shared-secret
+ ProxyBeaconTimeout 0
+ ProxyBeaconMaxSkew 500ms
+
+ ProxySet growth=5
+
+ ProxyPass /skew balancer://skew
+
+
+ #
+ # Table reclamation (fifth receiver): members are evicted after 1s of silence
+ # and their records reclaimed once a replay of their last message would be
+ # stale anyway (2 * MaxSkew + 1s = 5s here). growth=100 is the most a balancer
+ # accepts, so announcing 256 urls leaves 100 members and 156 failed adds: a
+ # full tracking table.
+ #
+ Define BEACON_RECLAIM_PORT @NextAvailablePort@
+
+
+ ProxyBeaconListen 127.0.0.1:${BEACON_RECLAIM_PORT}
+ ProxyBeaconBalancer reclaim
+ ProxyBeaconSecret beacon-test-shared-secret
+ ProxyBeaconTimeout 1
+ ProxyBeaconMaxSkew 2
+
+ ProxySet growth=100
+
+ ProxyPass /reclaim balancer://reclaim
+
+
+ #
+ # Random-key secret (sixth receiver): "hex:" is the SipHash key itself, with
+ # no MD5 step, so only a sender signing with the raw bytes is accepted.
+ #
+ Define BEACON_HEXKEY_PORT @NextAvailablePort@
+
+
+ ProxyBeaconListen 127.0.0.1:${BEACON_HEXKEY_PORT}
+ ProxyBeaconBalancer hexkey
+ ProxyBeaconSecret hex:000102030405060708090a0b0c0d0e0f
+ ProxyBeaconTimeout 0
+
+ ProxySet growth=5
+
+ ProxyPass /hexkey balancer://hexkey
+
+
+ #
+ # A listener whose bind always fails (192.0.2.1 is TEST-NET-1, never a local
+ # address): the open must be retried with a growing delay, not on every tick
+ # and not never.
+ #
+
+ ProxyBeaconListen 192.0.2.1:9
+ ProxyBeaconSecret beacon-test-shared-secret
+
+
+ #
+ # Ownership against administrator-added members: workers are added through
+ # this server's balancer-manager (any child may handle that), then announced.
+ #
+ Define BEACON_OWNSYNC_PORT @NextAvailablePort@
+
+
+ ProxyBeaconListen 127.0.0.1:${BEACON_OWNSYNC_PORT}
+ ProxyBeaconBalancer ownsync
+ ProxyBeaconSecret beacon-test-shared-secret
+ ProxyBeaconTimeout 0
+
+ ProxySet growth=40
+
+ ProxyPass /ownsync balancer://ownsync
+
+ SetHandler balancer-manager
+ Require all granted
+
+
+
# Two backends competing for the single cap slot (announce every 1s).
ProxyBeaconAddress 127.0.0.1:${BEACON_CAP_PORT}
diff -Nru apache2-2.4.69/test/pytest_suite/t/conf/proxy_hcheck.conf.in apache2-2.4.71/test/pytest_suite/t/conf/proxy_hcheck.conf.in
--- apache2-2.4.69/test/pytest_suite/t/conf/proxy_hcheck.conf.in 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/conf/proxy_hcheck.conf.in 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,107 @@
+#t/TEST -proxy_hcheck
+#
+# Health-checked balancers for t/modules/test_proxy_hcheck.py. The backends are
+# not httpd: the tests start small socket servers on the ports allocated here,
+# so that they control exactly what each health check sees. hcinterval=1 with
+# one pass / one fail makes a worker change state within a couple of seconds.
+
+
+
+
+
+ Define HC_URI_PORT @NextAvailablePort@
+ Define HC_HDR_PORT @NextAvailablePort@
+ Define HC_MANYHDR_PORT @NextAvailablePort@
+ Define HC_SLOW_PORT @NextAvailablePort@
+ Define HC_BIGEXPR_PORT @NextAvailablePort@
+ Define HC_BIGPLAIN_PORT @NextAvailablePort@
+ Define HC_PATHA_PORT @NextAvailablePort@
+ Define HC_PATHB_PORT @NextAvailablePort@
+ Define HC_PATHC_PORT @NextAvailablePort@
+ Define HC_PATHD_PORT @NextAvailablePort@
+ Define HC_PATHE_PORT @NextAvailablePort@
+ Define HC_PATHF_PORT @NextAvailablePort@
+ Define HC_PATHG_PORT @NextAvailablePort@
+ Define HC_TLSBAD_PORT @NextAvailablePort@
+ Define HC_TLSGOOD_PORT @NextAvailablePort@
+
+
+ LogLevel proxy_hcheck:debug
+
+ # The probe path is changed at run time through the balancer-manager.
+
+ BalancerMember http://127.0.0.1:${HC_URI_PORT} hcmethod=GET hcuri=/old hcinterval=1 hcpasses=1 hcfails=1
+
+ ProxyPass /hcuri balancer://hcuri
+
+ # A HEAD probe against a backend that sends a truncated response.
+
+ BalancerMember http://127.0.0.1:${HC_HDR_PORT} hcmethod=HEAD hcuri=/ hcinterval=1 hcpasses=1 hcfails=1
+
+ ProxyPass /hchdr balancer://hchdr
+
+ # HTTPS probes. Certificates are not chain-verified (SSLProxyVerify none)
+ # so that only the host name check is in play: the "bad" backend presents a
+ # certificate for another name, the "good" one for localhost.
+
+ SSLProxyEngine on
+ SSLProxyVerify none
+ SSLProxyCheckPeerName on
+
+ BalancerMember https://localhost:${HC_TLSBAD_PORT} hcmethod=GET hcuri=/ hcinterval=1 hcpasses=1 hcfails=1
+
+ ProxyPass /hctlsbad balancer://hctlsbad
+
+ BalancerMember https://localhost:${HC_TLSGOOD_PORT} hcmethod=GET hcuri=/ hcinterval=1 hcpasses=1 hcfails=1
+
+ ProxyPass /hctlsgood balancer://hctlsgood
+
+
+ # How the probe path is put together from the worker URL and hcuri.
+
+ BalancerMember http://127.0.0.1:${HC_PATHA_PORT} hcmethod=GET hcuri=ping hcinterval=1 hcpasses=1 hcfails=1
+
+
+ BalancerMember http://127.0.0.1:${HC_PATHB_PORT}/app hcmethod=GET hcuri=/ping hcinterval=1 hcpasses=1 hcfails=1
+
+
+ BalancerMember http://127.0.0.1:${HC_PATHC_PORT}/app/ hcmethod=GET hcuri=ping hcinterval=1 hcpasses=1 hcfails=1
+
+
+ BalancerMember http://127.0.0.1:${HC_PATHD_PORT} hcmethod=GET hcinterval=1 hcpasses=1 hcfails=1
+
+
+ BalancerMember http://127.0.0.1:${HC_PATHE_PORT}/app hcmethod=GET hcinterval=1 hcpasses=1 hcfails=1
+
+
+ BalancerMember http://127.0.0.1:${HC_PATHF_PORT}/app/ hcmethod=GET hcinterval=1 hcpasses=1 hcfails=1
+
+
+ BalancerMember http://127.0.0.1:${HC_PATHG_PORT}/app hcmethod=GET hcuri=/ hcinterval=1 hcpasses=1 hcfails=1
+
+
+ # Limits on what a probe will take from a backend.
+ ProxyHCExpr nomaint {hc('body') !~ /UNDER-MAINTENANCE/}
+
+ BalancerMember http://127.0.0.1:${HC_MANYHDR_PORT} hcmethod=GET hcuri=/ hcinterval=1 hcpasses=1 hcfails=1
+
+ # timeout=2: also the overall budget of one probe
+
+ BalancerMember http://127.0.0.1:${HC_SLOW_PORT} hcmethod=GET hcuri=/ hcinterval=1 hcpasses=1 hcfails=1 timeout=2
+
+
+ BalancerMember http://127.0.0.1:${HC_BIGEXPR_PORT} hcmethod=GET hcuri=/ hcinterval=1 hcpasses=1 hcfails=1 hcexpr=nomaint
+
+
+ BalancerMember http://127.0.0.1:${HC_BIGPLAIN_PORT} hcmethod=GET hcuri=/ hcinterval=1 hcpasses=1 hcfails=1
+
+
+
+ SetHandler balancer-manager
+ Require all granted
+
+
+
+
+
+
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+after
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+before
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+html
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+xhtml
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+quoted
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+html
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1 @@
+xht
diff -Nru apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var
--- apache2-2.4.69/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,5 @@
+URI: tm1.htm
+Content-Type: text/html; qs=0.9
+
+URI: tm2.xht
+Content-Type: application/xhtml+xml; qs=1.0
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_connections.py apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_connections.py
--- apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_connections.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_connections.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,134 @@
+r"""mpm_motorz: connection ids.
+
+c->id names the worker slot running a connection (child * ThreadLimit + slot), as
+in the worker and event MPMs. mod_cgid keeps one entry per id and assumes the
+ids stay within the number of worker slots. The child also holds a limited
+number of connections, whether or not they use a worker.
+
+The id is read back from mod_unique_id, whose last four bytes are c->id.
+"""
+
+import base64
+import socket
+import sys
+import time
+
+import pytest
+
+from apache_pytest.private_server import PrivateServer
+
+THREADS = 4
+CHILDREN = 2
+
+
+def unique_id_conn_id(uid):
+ raw = base64.urlsafe_b64decode(uid + "=" * (-len(uid) % 4))
+ return int.from_bytes(raw[-4:], "big")
+
+
+def request_id(port, path="/small.txt"):
+ with socket.create_connection(("127.0.0.1", port), timeout=10) as s:
+ s.sendall(f"GET {path} HTTP/1.0\r\n\r\n".encode())
+ data = b""
+ while chunk := s.recv(4096):
+ data += chunk
+ head = data.partition(b"\r\n\r\n")[0].decode()
+ for line in head.split("\r\n"):
+ if line.lower().startswith("x-uid:"):
+ return unique_id_conn_id(line.split(":", 1)[1].strip())
+ raise AssertionError(f"no X-Uid header in {head!r}")
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="forking MPM")
+def test_connection_ids_stay_within_the_worker_slots(config, tmp_path):
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+ if not (config.info.has_module("unique_id") and config.info.has_module("headers")):
+ pytest.skip("needs mod_unique_id and mod_headers")
+
+ extra = [
+ f"StartServers {CHILDREN}",
+ f"ThreadLimit {THREADS}",
+ f"ThreadsPerChild {THREADS}",
+ "ServerLimit 4",
+ 'Header always set X-Uid "%{UNIQUE_ID}e"',
+ ]
+ with PrivateServer(config, tmp_path, extra=extra, big_length=1024) as server:
+ ids = [request_id(server.port) for _ in range(200)]
+
+ limit = 4 * THREADS # ServerLimit * ThreadLimit
+ assert max(ids) < limit, f"id {max(ids)} outside the {limit} worker slots"
+
+
+def served(sock, wait):
+ """True once a 200 response to the request already sent on sock arrives."""
+ sock.settimeout(wait)
+ try:
+ return sock.recv(4096).startswith(b"HTTP/1.1 200")
+ except OSError:
+ return False
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="forking MPM")
+def test_connections_are_limited_per_child(config, tmp_path):
+ """Idle keep-alive connections use no worker, so the workers cannot limit
+ them. With 4 workers and AsyncRequestWorkerFactor 1 the child holds about
+ 4 + 4 connections; the rest wait in the listen backlog until room is made."""
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+
+ extra = ["StartServers 1", "ThreadsPerChild 4", "AsyncRequestWorkerFactor 1",
+ "KeepAliveTimeout 120"]
+ total = 40
+ socks = []
+ with PrivateServer(config, tmp_path, extra=extra, big_length=1024) as server:
+ try:
+ for _ in range(total):
+ s = socket.create_connection(("127.0.0.1", server.port), timeout=10)
+ s.sendall(b"GET /small.txt HTTP/1.1\r\nHost: localhost\r\n\r\n")
+ socks.append(s)
+ time.sleep(1)
+ first = [s for s in socks if served(s, 0.2)]
+ assert 4 <= len(first) <= 12, f"{len(first)} of {total} connections accepted"
+
+ # Room is made when some of them go away, and is filled again.
+ rest = [s for s in socks if s not in first]
+ for s in first:
+ s.close()
+ deadline = time.monotonic() + 15
+ second = []
+ while time.monotonic() < deadline and len(second) < len(first):
+ second += [s for s in rest if s not in second and served(s, 0.2)]
+ assert len(second) >= len(first) // 2, \
+ f"{len(second)} waiting connections served after {len(first)} closed"
+ time.sleep(1)
+ second += [s for s in rest if s not in second and served(s, 0.2)]
+ assert len(second) <= 12, f"{len(second)} connections held after the restart of accepting"
+ finally:
+ for s in socks:
+ s.close()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="forking MPM")
+def test_connections_of_a_protocol_that_sets_no_state_are_closed(config, tmp_path):
+ """mod_echo returns OK from process_connection without setting a connection
+ state. Such a connection must be closed when the client leaves; if it is
+ not, it counts against the connection limit for ever and the child stops
+ accepting."""
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+ if not config.info.has_module("echo"):
+ pytest.skip("needs mod_echo")
+
+ extra = ["StartServers 1", "ThreadsPerChild 4", "AsyncRequestWorkerFactor 1",
+ "ProtocolEcho On"]
+ with PrivateServer(config, tmp_path, extra=extra, big_length=1024) as server:
+ results = []
+ for _ in range(20): # more than the limit of 8
+ with socket.create_connection(("127.0.0.1", server.port), timeout=3) as s:
+ s.sendall(b"hi\r\n")
+ try:
+ results.append(s.recv(100) == b"hi\r\n")
+ except OSError:
+ results.append(False)
+ assert all(results), "".join("Y" if r else "n" for r in results)
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_graceful.py apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_graceful.py
--- apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_graceful.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_graceful.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,175 @@
+r"""mpm_motorz: a child told to stop gracefully finishes the connections it has.
+
+The server delivers a large static file. The client reads a little of it and
+stops, so that the server fills the socket buffers and the connection is parked
+in the MPM waiting to write. Then the parent is sent the graceful restart
+signal. The old child must keep serving that connection until the whole body
+has been delivered, while the new generation answers other requests.
+
+The body has to come from the core handler: a handler that writes it itself
+(mod_test_rwrite) blocks a worker thread for the whole transfer, and the
+connection is then never parked, so the test passes even without the drain.
+
+The tests run a private httpd: restarting the suite's own server would change
+its state (its worker set, the log, ...) for the tests that run after this one.
+
+A peer that keeps writing to a connection the server is closing must not keep
+the old child alive: the lingering close has one fixed deadline.
+
+A draining child keeps its process slot, and no replacement can start while
+every slot is taken. The other tests open a download, restart, and repeat, so
+that several old children are draining at once, then check that new connections
+are still served (default ServerLimit) and that the parent says so when they are
+not (ServerLimit 2).
+"""
+
+import socket
+import sys
+import time
+
+import pytest
+
+from apache_pytest.private_server import (BODY_LENGTH, PrivateServer,
+ child_pids, get, read_headers)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="signals")
+def test_graceful_restart_does_not_cut_a_download(config, tmp_path):
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+
+ sock = None
+ with PrivateServer(config, tmp_path) as server:
+ try:
+ sock = get(server.port, "/big.bin", rcvbuf=4096)
+ head, body = read_headers(sock)
+ assert b" 200 " in head.split(b"\r\n")[0], head
+ received = len(body)
+
+ # Let the server run into the closed window, then restart gracefully.
+ time.sleep(1)
+ server.graceful()
+
+ # The new generation answers other requests while the old child drains.
+ assert server.small_ok(20), "no response after the graceful restart"
+
+ # The old child has had time to exit if it had dropped the connection.
+ time.sleep(1)
+ while chunk := sock.recv(65536):
+ received += len(chunk)
+ finally:
+ if sock:
+ sock.close()
+
+ assert received == BODY_LENGTH
+
+
+def open_downloads_across_restarts(server, rounds):
+ """Park a download, restart gracefully, and repeat; return the sockets and
+ whether /small.txt was served after the last restart."""
+ socks = []
+ served = True
+ for _ in range(rounds):
+ sock = get(server.port, "/big.bin", rcvbuf=4096)
+ head, body = read_headers(sock)
+ assert b" 200 " in head.split(b"\r\n")[0], head
+ socks.append([sock, len(body)])
+ time.sleep(1) # the server fills the buffers and parks it
+ server.graceful()
+ served = server.small_ok(5)
+ return socks, served
+
+
+def finish_downloads(socks, big_length):
+ for entry in socks:
+ sock = entry[0]
+ while chunk := sock.recv(65536):
+ entry[1] += len(chunk)
+ sock.close()
+ assert entry[1] == big_length
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="signals")
+def test_restarts_with_open_downloads_do_not_starve_new_connections(config, tmp_path):
+ """Each old child keeps its slot while its download runs. With the default
+ ServerLimit there is still room for the next generation, so a new request
+ is served after every restart."""
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+
+ big = 16 * 1024 * 1024
+ socks = []
+ with PrivateServer(config, tmp_path, big_length=big) as server:
+ try:
+ socks, served = open_downloads_across_restarts(server, 3)
+ assert served, "no child accepted a new connection after the restarts"
+ assert "scoreboard is full" not in server.error_log()
+ finally:
+ for entry in socks:
+ entry[0].close()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="signals")
+def test_full_scoreboard_is_reported_once_and_recovers(config, tmp_path):
+ """With ServerLimit 2 the second restart finds both slots held by draining
+ children, so nothing can start. The parent must say so, once, and serve
+ again as soon as a draining child is done."""
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+
+ big = 16 * 1024 * 1024
+ socks = []
+ with PrivateServer(config, tmp_path, extra=["ServerLimit 2"],
+ big_length=big) as server:
+ try:
+ socks, _ = open_downloads_across_restarts(server, 2)
+ # The parent checks the slots once a second; give it a few rounds.
+ time.sleep(4)
+ log = server.error_log()
+ assert log.count("scoreboard is full") == 1, log
+
+ finish_downloads(socks, big)
+ socks = []
+ assert server.small_ok(20), "no recovery after the downloads finished"
+ finally:
+ for entry in socks:
+ entry[0].close()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="signals")
+def test_trickle_input_does_not_extend_lingering_close(config, tmp_path):
+ """The client sends one byte every 250 ms to a connection the server has
+ finished with. The linger deadline is fixed when the lingering close
+ starts, so once a graceful restart shortens it the old child must be gone
+ after a couple of seconds although the client never stops writing."""
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+
+ with PrivateServer(config, tmp_path) as server:
+ sock = socket.create_connection(("127.0.0.1", server.port), timeout=10)
+ try:
+ sock.sendall(b"GET /small.txt HTTP/1.1\r\nHost: localhost\r\n"
+ b"Connection: close\r\n\r\n")
+ while sock.recv(4096): # until the server shuts its side down
+ pass
+
+ old = child_pids(server.proc.pid)
+ assert old, "no child process found"
+ server.graceful()
+
+ start = time.monotonic()
+ gone = None
+ while time.monotonic() - start < 15:
+ try:
+ sock.send(b"x")
+ except OSError:
+ pass # the server may have closed already
+ if not (old & child_pids(server.proc.pid)):
+ gone = time.monotonic() - start
+ break
+ time.sleep(0.25)
+ assert gone is not None, "old child still alive while the peer trickles"
+ assert gone < 8, f"old child needed {gone:.1f}s to exit"
+ assert server.small_ok(5), "new generation does not answer"
+ finally:
+ sock.close()
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_hooks.py apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_hooks.py
--- apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_hooks.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_hooks.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,149 @@
+r"""mpm_motorz: suspend_connection / resume_connection hooks.
+
+A connection that is parked between requests is not tied to a worker thread: the
+next request may be run by another one. mpm_common.h says the suspend hook runs
+on the thread that was processing the connection and the resume hook on the
+thread that will continue it, so a module that keeps per-thread state can follow
+the connection.
+
+A small module, built with apxs, logs both hooks and each request with the
+thread it ran on. For every connection the log must read
+request, (suspend, resume, request)*, with each suspend on the thread of the
+request before it and each resume on the thread of the request after it.
+"""
+
+import re
+import socket
+import subprocess
+import sys
+import threading
+import time
+
+import pytest
+
+from apache_pytest.private_server import PrivateServer
+
+MODULE_SOURCE = r'''
+#include "httpd.h"
+#include "http_config.h"
+#include "http_log.h"
+#include "http_main.h"
+#include "http_protocol.h"
+#include "mpm_common.h"
+#include "apr_portable.h"
+#include
+
+static unsigned long tid(void)
+{
+ return (unsigned long)(apr_uintptr_t)apr_os_thread_current();
+}
+
+static void hook_suspend(conn_rec *c, request_rec *r)
+{
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf,
+ "HOOKTEST suspend c=%pp tid=%lu", c, tid());
+}
+
+static void hook_resume(conn_rec *c, request_rec *r)
+{
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf,
+ "HOOKTEST resume c=%pp tid=%lu", c, tid());
+}
+
+static int hooks_handler(request_rec *r)
+{
+ if (strcmp(r->handler, "conn-hooks")) {
+ return DECLINED;
+ }
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf,
+ "HOOKTEST request c=%pp tid=%lu", r->connection, tid());
+ ap_set_content_type(r, "text/plain");
+ ap_rputs("ok\n", r);
+ return OK;
+}
+
+static void register_hooks(apr_pool_t *p)
+{
+ ap_hook_suspend_connection(hook_suspend, NULL, NULL, APR_HOOK_MIDDLE);
+ ap_hook_resume_connection(hook_resume, NULL, NULL, APR_HOOK_MIDDLE);
+ ap_hook_handler(hooks_handler, NULL, NULL, APR_HOOK_MIDDLE);
+}
+
+AP_DECLARE_MODULE(conn_hooks) = {
+ STANDARD20_MODULE_STUFF, NULL, NULL, NULL, NULL, NULL, register_hooks
+};
+'''
+
+EVENT = re.compile(r"HOOKTEST (suspend|resume|request) c=(\S+) tid=(\d+)")
+
+
+def build_module(config, tmp_path):
+ src = tmp_path / "mod_conn_hooks.c"
+ src.write_text(MODULE_SOURCE)
+ proc = subprocess.run(["perl", str(config.apxs), "-c", str(src)],
+ cwd=tmp_path, capture_output=True, text=True)
+ so = tmp_path / ".libs" / "mod_conn_hooks.so"
+ assert proc.returncode == 0 and so.exists(), proc.stdout + proc.stderr
+ return so
+
+
+def keepalive_requests(port, count, gap):
+ with socket.create_connection(("127.0.0.1", port), timeout=10) as s:
+ for _ in range(count):
+ s.sendall(b"GET /hooks HTTP/1.1\r\nHost: localhost\r\n\r\n")
+ data = b""
+ while not data.endswith(b"ok\n"):
+ chunk = s.recv(4096)
+ assert chunk, "connection closed"
+ data += chunk
+ time.sleep(gap)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="forking MPM")
+def test_hooks_run_on_the_threads_that_hand_over_and_take_over(config, tmp_path):
+ if config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+ if config.apxs is None:
+ pytest.skip("needs --apxs to build a module")
+
+ so = build_module(config, tmp_path)
+ extra = [f"LoadModule conn_hooks_module {so}",
+ "", " SetHandler conn-hooks", "",
+ "KeepAliveTimeout 30"]
+ (tmp_path / "srv").mkdir()
+ with PrivateServer(config, tmp_path / "srv", extra=extra,
+ big_length=1024) as server:
+ threads = [threading.Thread(target=keepalive_requests,
+ args=(server.port, 25, 0.02))
+ for _ in range(4)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ time.sleep(0.5)
+ log = server.error_log()
+
+ per_conn = {}
+ for kind, conn, tid in EVENT.findall(log):
+ per_conn.setdefault(conn, []).append((kind, tid))
+ assert len(per_conn) >= 4, per_conn.keys()
+
+ resumes = 0
+ moved = 0
+ for conn, events in per_conn.items():
+ kinds = [k for k, _ in events]
+ # request, then repeated suspend, resume, request; it may end parked.
+ expected = ["request"]
+ while len(expected) < len(kinds):
+ expected += ["suspend", "resume", "request"]
+ assert kinds == expected[:len(kinds)], f"{conn}: {kinds}"
+ for (k1, t1), (k2, t2) in zip(events, events[1:]):
+ if k1 == "request" and k2 == "suspend":
+ assert t1 == t2, f"{conn}: suspended on another thread"
+ if k1 == "resume" and k2 == "request":
+ assert t1 == t2, f"{conn}: resumed on another thread"
+ if k1 == "suspend" and k2 == "resume":
+ resumes += 1
+ moved += t1 != t2
+ assert resumes >= 4 * 20, f"only {resumes} resumes were logged"
+ assert moved > 0, "no connection ever moved to another thread; weak test"
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_limits.py apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_limits.py
--- apache2-2.4.69/test/pytest_suite/tests/t/apache/test_motorz_limits.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/apache/test_motorz_limits.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,43 @@
+r"""mpm_motorz: the default ServerLimit is the larger of 16 and twice StartServers,
+capped at the compile-time maximum, and must not overflow for a huge StartServers.
+
+Each case runs `httpd -t` on the generated test config plus one StartServers.
+"""
+
+import subprocess
+
+import pytest
+
+MAX_SERVER_LIMIT = 200000
+
+
+def configtest(server, tmp_path, start_servers):
+ conf = tmp_path / "startservers.conf"
+ conf.write_text(
+ f'Include "{server.config.vars["t_conf_file"]}"\n'
+ f"StartServers {start_servers}\n")
+ argv = [a for a in server.args() if a != "-DFOREGROUND"]
+ argv[argv.index("-f") + 1] = str(conf)
+ return subprocess.run([*argv, "-t"], capture_output=True, text=True)
+
+
+@pytest.mark.parametrize("start_servers", [
+ MAX_SERVER_LIMIT // 2, # twice this is exactly the maximum
+ MAX_SERVER_LIMIT // 2 + 1, # twice this is just over it
+ MAX_SERVER_LIMIT,
+ MAX_SERVER_LIMIT + 1,
+ 1073741824, # twice this overflows a 32-bit int
+ 2147483647,
+])
+def test_default_server_limit_does_not_overflow(server, tmp_path, start_servers):
+ if server.config.info.mpm != "motorz":
+ pytest.skip("motorz only")
+ r = configtest(server, tmp_path, start_servers)
+ assert r.returncode == 0 and "Syntax OK" in r.stderr, r.stderr
+ assert "ServerLimit of -" not in r.stderr, r.stderr
+ warning = (f"StartServers of {start_servers} exceeds ServerLimit of "
+ f"{MAX_SERVER_LIMIT}, decreasing to {MAX_SERVER_LIMIT}")
+ if start_servers > MAX_SERVER_LIMIT:
+ assert warning in r.stderr, r.stderr
+ else:
+ assert "exceeds ServerLimit" not in r.stderr, r.stderr
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/modules/test_dav.py apache2-2.4.71/test/pytest_suite/tests/t/modules/test_dav.py
--- apache2-2.4.69/test/pytest_suite/tests/t/modules/test_dav.py 2026-06-05 17:48:53.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/modules/test_dav.py 2026-11-09 03:07:05.000000000 +0000
@@ -19,7 +19,7 @@
import httpx
import pytest
-from apache_pytest import need_module, t_cmp
+from apache_pytest import need_module, not_backported, t_cmp
DIR = "modules/dav"
URI = f"/{DIR}/dav.html"
@@ -207,3 +207,42 @@
os.rmdir(p)
except OSError:
pass
+
+
+@need_module("dav")
+@not_backported("r1939426", "DAVMSext (ms_wdv) null dereference")
+def test_dav_mswdv_null_deref(http):
+ """BZ 70051: a PUT/DELETE/MOVE/LOCK whose dav_get_resource() fails (e.g. an
+ extraneous path component under an existing file) must not crash the
+ worker. Under DAVMSext, check_locked_by_other() in ms_wdv.c used to
+ dereference its lockdb unconditionally in the out: label, even on the
+ paths that goto out before open_lockdb() runs and leave it NULL.
+ """
+ htdocs = http.vars("documentroot")
+ os.makedirs(os.path.join(htdocs, DIR), exist_ok=True)
+ name = "mswdv-70051.html"
+ fpath = os.path.join(htdocs, DIR, name)
+ if os.path.exists(fpath):
+ os.unlink(fpath)
+
+ base = http.base_url
+ url = f"{base}/{DIR}/{name}"
+
+ with httpx.Client(timeout=30.0) as c:
+ resp = c.put(url, content=BODY)
+ assert resp.is_success, f"seed PUT failed (got {resp.status_code})"
+
+ # dav_fs_get_resource() rejects the extra path component below the
+ # plain file with an error -- dav_get_resource() fails before any
+ # lockdb is opened. Pre-fix, check_locked_by_other()'s "out:" label
+ # dereferenced that still-NULL lockdb and crashed the worker.
+ resp = c.put(f"{url}/extra", content="boom")
+ assert resp.status_code in (403, 404, 409), \
+ f"PUT past a file should be a clean DAV error, got {resp.status_code}"
+
+ # the worker must still be alive: a normal request must succeed.
+ resp = c.get(url)
+ assert resp.status_code == 200, \
+ f"server did not survive the request (got {resp.status_code})"
+
+ os.unlink(fpath)
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/modules/test_ldap.py apache2-2.4.71/test/pytest_suite/tests/t/modules/test_ldap.py
--- apache2-2.4.69/test/pytest_suite/tests/t/modules/test_ldap.py 2026-09-28 13:16:28.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/modules/test_ldap.py 2026-11-09 03:07:05.000000000 +0000
@@ -27,6 +27,8 @@
("/modules/ldap/refer/", "alpha", "Alpha", 401),
("/modules/ldap/refer/", "beta", "Beta", 200),
("/modules/ldap/search/", "unchecked", "unchecked", 200),
+ ("/modules/ldap/subreq/foobar.html", "delta", "Delta", 200),
+ ("/modules/ldap/target/foobar.html", "delta", "Delta", 200),
]
@@ -46,3 +48,18 @@
r = http.GET(url)
creds = f"{username}/{password}" if username else "no credentials"
assert t_cmp(r.status_code, expected), f"test for {url} with {creds}"
+
+
+@need_module("authnz_ldap", "rewrite", "headers")
+def test_ldap_fixups_subrequest(http):
+ """A mod_rewrite -U subrequest from the parent's fixups phase lands in a
+ second per-dir config with the same LDAP settings, so its own LDAP authn
+ and authz run while the parent still holds its authz connection. The
+ parent must complete, and the header proves the subrequest succeeded."""
+ if not _ldap_configured(http):
+ pytest.skip("LDAP testing not configured (run with --defines LDAP)")
+ r = http.GET("/modules/ldap/subreq/foobar.html",
+ auth=httpx.BasicAuth("delta", "Delta"))
+ assert t_cmp(r.status_code, 200), "parent request with nested LDAP subrequest"
+ assert t_cmp(r.headers.get("x-ldap-subreq"), "ok"), \
+ "subrequest into second LDAP per-dir config did not succeed"
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/modules/test_negotiation.py apache2-2.4.71/test/pytest_suite/tests/t/modules/test_negotiation.py
--- apache2-2.4.69/test/pytest_suite/tests/t/modules/test_negotiation.py 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/modules/test_negotiation.py 2026-11-09 03:07:05.000000000 +0000
@@ -3,15 +3,18 @@
Covers default-language selection, explicit variant requests, Accept-Language
obedience (plain + compressed + typemap), quality-rating preferences, a
non-existent highest-quality fallback, a typemap query-string case, and
-Accept content-type negotiation (with 406 cases).
+Accept content-type negotiation (with 406 cases), and the source quality
+("qs") of a variant not showing up in the Content-Type of the response.
Perl original used ``need_module('negotiation') && need_cgi &&
need_module('mime')``.
"""
+import re
+
import pytest
-from apache_pytest import need_cgi, need_module, t_cmp
+from apache_pytest import need_cgi, need_module, not_backported, t_cmp
EN, FR, DE, FU, BU, ZH = "en", "fr", "de", "fu", "bu", "zh-TW"
@@ -153,3 +156,45 @@
else:
assert t_cmp(r.status_code, 406), \
f"expect Not Acceptable for Accept: {accept}"
+
+
+XML = "application/xhtml+xml"
+
+# "qs" only decides between variants, the other parameters are kept
+QS_PARAMS = [
+ ("after", "text/html;charset=utf-8"),
+ ("before", "text/html;charset=utf-8"),
+ ("quoted", 'application/example;foo="a;b";q=0.7;bar="c, d"'),
+]
+
+
+def _content_type(resp):
+ assert resp.status_code == 200
+ return re.sub(r";\s+", ";", resp.headers["content-type"])
+
+
+@need_module("negotiation", "mime")
+# (a type map was right without the change, MultiViews was not)
+@pytest.mark.parametrize("path", [
+ pytest.param("doc", marks=not_backported(
+ "r1939268", "stripping qs from the negotiated Content-Type")),
+ "typemap.var",
+])
+@pytest.mark.parametrize("accept,expected", [
+ (f"text/html, {XML}", XML),
+ ("text/html", "text/html"),
+ (f"text/html, {XML};q=0.5", "text/html"),
+])
+def test_source_quality(http, path, accept, expected):
+ r = http.GET(f"/modules/negotiation/qs/{path}", headers={"Accept": accept})
+ assert t_cmp(_content_type(r), expected), \
+ "qs selects the variant but is not sent in Content-Type"
+
+
+@need_module("negotiation", "mime")
+@pytest.mark.parametrize("name,expected", QS_PARAMS, ids=[n for n, _ in QS_PARAMS])
+@not_backported("r1939268", "stripping qs from the negotiated Content-Type")
+def test_source_quality_parameters(http, name, expected):
+ r = http.GET(f"/modules/negotiation/qs/{name}", headers={"Accept": "*/*"})
+ assert t_cmp(_content_type(r), expected), \
+ "only qs is removed from the Content-Type"
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/modules/test_proxy_beacon.py apache2-2.4.71/test/pytest_suite/tests/t/modules/test_proxy_beacon.py
--- apache2-2.4.69/test/pytest_suite/tests/t/modules/test_proxy_beacon.py 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/modules/test_proxy_beacon.py 2026-11-09 03:07:05.000000000 +0000
@@ -91,7 +91,8 @@
# Qualify by balancer://beacon so the capacity-test balancer (below) doesn't
# perturb these counts.
added = [ln for ln in session_loglines
- if "added backend" in ln and "balancer://beacon" in ln]
+ if f"added backend http://{vars_['servername']}:{vars_['port']} "
+ "to balancer://beacon" in ln]
assert len(added) == 1, (
f"backend should be added exactly once; saw {len(added)}: {added}")
assert not [ln for ln in loglines if "failed to add worker" in ln
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/modules/test_proxy_beacon_input.py apache2-2.4.71/test/pytest_suite/tests/t/modules/test_proxy_beacon_input.py
--- apache2-2.4.69/test/pytest_suite/tests/t/modules/test_proxy_beacon_input.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/modules/test_proxy_beacon_input.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,908 @@
+r"""Hostile-input and member-identity tests for mod_proxy_beacon's receiver.
+
+test_proxy_beacon.py covers the happy-path lifecycle. These tests inject
+hand-built, correctly *signed* announcements (a "valid MAC" sender that is not the
+bundled sender, e.g. a buggy or compromised backend holding the shared secret) and
+assert the receiver survives them. They use the same t/conf/proxy_beacon.conf.in
+as test_proxy_beacon.py: the main server listens for beacons on its own UDP port
+for balancer://beacon, authenticated with the secret below.
+
+Like test_proxy_beacon.py these are skipped under the prefork MPM (mod_watchdog's
+singleton runs in a child process).
+"""
+
+import os
+import re
+import subprocess
+import urllib.parse
+from datetime import datetime
+import signal
+import socket
+import struct
+import time
+from pathlib import Path
+
+import pytest
+
+from apache_pytest import need_module
+
+SECRET = b"beacon-test-shared-secret"
+M64 = (1 << 64) - 1
+
+
+def _siphash24(key: bytes, data: bytes) -> int:
+ """Reference SipHash-2-4 (what apr_siphash24_auth computes)."""
+ def rotl(x, b):
+ return ((x << b) | (x >> (64 - b))) & M64
+
+ k0, k1 = struct.unpack(" bytes:
+ import hashlib
+ return hashlib.md5(data).digest()
+
+
+def signed_beacon(url: str, seq: int = 1, ts_us: int = None, key: bytes = None) -> bytes:
+ """A correctly signed announcement, as the bundled sender would build it.
+
+ key is the 16-byte SipHash key; the default is what a passphrase secret
+ derives (MD5 of it)."""
+ if ts_us is None:
+ ts_us = int(time.time() * 1_000_000)
+ base = f"BEACON url={url} host=probe pid=1 seq={seq} ts={ts_us}"
+ mac = _siphash24(key or _md5(SECRET), base.encode()).to_bytes(8, "little").hex()
+ return f"{base} mac={mac}".encode()
+
+
+def signed_raw(base: str, trailer: bytes = b"") -> bytes:
+ """Sign an arbitrary frame prefix (it need not be well-formed)."""
+ mac = _siphash24(_md5(SECRET), base.encode()).to_bytes(8, "little").hex()
+ return f"{base} mac={mac}".encode() + trailer
+
+
+def send_udp(port: int, payload: bytes) -> None:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
+ s.sendto(payload, ("127.0.0.1", port))
+
+
+def log_since(error_log: Path, start: int) -> list:
+ with error_log.open("r", errors="replace") as fh:
+ fh.seek(start)
+ return fh.read().splitlines()
+
+
+def test_siphash_reference_vector():
+ """Guard the test helper itself: SipHash-2-4 paper vector (key 00..0f, empty)."""
+ key = bytes(range(16))
+ assert _siphash24(key, b"") == 0x726FDB47DD0E0E31
+ assert _siphash24(key, bytes(range(15))) == 0xA129CA6149BE45E5
+
+
+def inject(http, urls, probe_url, port=None, balancer="beacon"):
+ """Send one signed announcement per url, then a valid one for probe_url.
+
+ The receiver handles datagrams in order, so once probe_url is reported as
+ added every earlier datagram has definitely been processed -- no timing
+ guesses and no reliance on a log line the hostile url itself may or may not
+ produce. Asserts that no child crashed; returns the log lines written since
+ the first datagram was sent.
+ """
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+
+ port = int(port or http.vars("port"))
+ error_log = Path(http.vars("t_logs")) / "error_log"
+
+ # The listener socket is opened by the watchdog some time after startup, and
+ # a datagram sent before that is simply lost. The legit backend announces
+ # on its own; wait until the receiver has logged one of its beacons.
+ session_start = int(http.vars("session_log_start") or 0)
+ deadline = time.time() + 15
+ while not any("received: BEACON" in ln
+ for ln in log_since(error_log, session_start)):
+ assert time.time() < deadline, "beacon receiver never came up"
+ time.sleep(0.25)
+
+ start = error_log.stat().st_size
+
+ now_us = int(time.time() * 1_000_000)
+ for seq, item in enumerate(list(urls) + [probe_url]):
+ if callable(item): # built at send time
+ item = item()
+ if isinstance(item, bytes): # a hand-built datagram, sent as is
+ send_udp(port, item)
+ else:
+ send_udp(port, signed_beacon(item, seq=seq, ts_us=now_us + seq))
+
+ added = f"added backend {probe_url} to balancer://{balancer}"
+ deadline = time.time() + 10
+ lines = []
+ while time.time() < deadline:
+ time.sleep(0.5)
+ lines = log_since(error_log, start)
+ if any(added in ln for ln in lines):
+ break
+ time.sleep(1) # let the parent report a child crash, if there was one
+ lines = log_since(error_log, start)
+
+ crashed = [ln for ln in lines if "exit signal" in ln]
+ assert not crashed, f"a child crashed on a signed announcement: {crashed}"
+ assert any(added in ln for ln in lines), (
+ "the valid announcement after the hostile one(s) was never processed; "
+ f"log tail: {lines[-10:]}")
+
+ # Still serving.
+ assert http.GET("/index.html").status_code == 200
+ return lines
+
+
+def added_members(lines, balancer="beacon"):
+ """Members reported added to balancer://, as a list of urls."""
+ out = []
+ for ln in lines:
+ if "added backend " in ln and f" to balancer://{balancer}" in ln:
+ out.append(ln.split("added backend ", 1)[1].split(" ", 1)[0])
+ return out
+
+
+need = need_module("proxy_beacon", "watchdog", "proxy_balancer", "proxy_http")
+
+
+@need
+def test_overlong_hostname_does_not_corrupt_worker_array(http):
+ """A hostname the proxy cannot store makes worker definition fail *after* it
+ appended a half-built entry to the balancer's worker array; the next lookup
+ then dereferenced the NULL shared-worker pointer and killed the child."""
+ inject(http, ["http://" + "a" * 300 + ".invalid:81"],
+ "http://127.0.0.3:18091")
+
+
+@need
+def test_overlong_uds_path_does_not_crash(http):
+ """unix:/|http://localhost -- the original reproducer."""
+ inject(http, ["unix:/" + "b" * 300 + "|http://localhost"],
+ "http://127.0.0.3:18092")
+
+
+# ---- member identity: an announced url names an origin, canonically ----------
+
+@need
+def test_default_port_is_dropped_from_identity(http):
+ """http://host:80 is the same origin as http://host. The proxy stores the
+ worker as http://host; the member must be tracked, added and enabled under
+ that name, not under the announced spelling (which then matches no worker,
+ leaves it 'Init Dis' and still logs a successful add)."""
+ lines = inject(http, ["http://127.0.0.4:80"], "http://127.0.0.3:18093")
+ assert "http://127.0.0.4" in added_members(lines)
+ assert not [ln for ln in lines if "127.0.0.4" in ln
+ and "failed to enable" in ln]
+
+
+@need
+def test_scheme_and_host_case_are_canonicalized(http):
+ lines = inject(http, ["HTTP://Canon.Invalid:8081"], "http://127.0.0.3:18094")
+ assert "http://canon.invalid:8081" in added_members(lines)
+
+
+@need
+def test_equivalent_spellings_are_one_member(http):
+ """Same origin spelled three ways: one member, one lease, one add."""
+ lines = inject(http, ["http://127.0.0.7:8082", "http://127.0.0.7:8082/",
+ "HTTP://127.0.0.7:8082"], "http://127.0.0.3:18095")
+ assert added_members(lines).count("http://127.0.0.7:8082") == 1
+
+
+@need
+@pytest.mark.parametrize("n,url", enumerate([
+ "http://127.0.0.8:8083/one", # path: would alias /one and /one/two
+ "http://127.0.0.8:8083/one/two",
+ "http://127.0.0.8:8083?x=1", # query
+ "http://127.0.0.8:8083/#frag", # fragment
+ "http://user:pw@127.0.0.8:8083", # userinfo
+ "http://127.0.0.8:0", # port 0
+ "http://127.0.0.8:-80", # strtol sign wraps to port 65456
+ "http://127.0.0.8:+80",
+ "http://127.0.0.8:65536", # truncates to port 0
+ "http://127.0.0.8:99999", # truncates to port 34463
+ "http://127.0.0.8:4294967376", # 2^32 + 80: truncates to port 80
+ "abcdefghijklmnop://127.0.0.8:8085", # 16 chars: does not fit the worker
+ "http://" + "h" * 256 + ":8086", # host does not fit the worker
+]))
+def test_non_origin_urls_are_rejected(http, n, url):
+ lines = inject(http, [url], f"http://127.0.0.3:{18100 + n}")
+ assert not [m for m in added_members(lines) if "127.0.0.8" in m], (
+ f"{url!r} must not become a member")
+
+
+@need
+@pytest.mark.parametrize("n,url", enumerate([
+ "https://127.0.0.9:8443",
+ "uwsgi://127.0.0.9:3031", # a mod_proxy scheme apr does not know
+ "fcgi://127.0.0.9:9000",
+]))
+def test_proxy_scheme_origins_are_accepted(http, n, url):
+ lines = inject(http, [url], f"http://127.0.0.3:{18121 + n}")
+ assert url in added_members(lines)
+
+
+@need
+def test_highest_port_is_accepted(http):
+ lines = inject(http, ["http://127.0.0.14:65535"], "http://127.0.0.3:18120")
+ assert "http://127.0.0.14:65535" in added_members(lines)
+
+
+# ---- the log of received messages is bounded ---------------------------------
+
+@need
+def test_received_log_lines_are_bounded(http):
+ """An authentic message can be replayed within the freshness window as often
+ as the sender likes. Its "received:" line is logged for the first twenty in
+ a second; the rest are counted, and the count is logged once."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+ port = int(http.vars("port"))
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ session_start = int(http.vars("session_log_start") or 0)
+ assert wait_for(error_log, session_start, "received: BEACON", 15)
+
+ # A url with a path is refused after it was logged, so no member is added.
+ marker = "127.0.0.15:8300/replayed"
+ start = error_log.stat().st_size
+ now_us = int(time.time() * 1_000_000)
+ for i in range(300):
+ send_udp(port, signed_beacon(f"http://{marker}", seq=i, ts_us=now_us + i))
+ time.sleep(1.5)
+ # The next message opens a new second, in which the count is logged.
+ send_udp(port, signed_beacon(f"http://{marker}", seq=1000))
+ time.sleep(1.5)
+
+ lines = log_since(error_log, start)
+ received = [ln for ln in lines if "received:" in ln and marker in ln]
+ assert 0 < len(received) < 70, f"{len(received)} lines for 301 messages"
+ assert any("received: lines left out" in ln for ln in lines), lines[-5:]
+
+
+# ---- ownership and watchdog hand-off -----------------------------------------
+
+STATIC_MEMBER = "http://127.0.0.11:19000" # BalancerMember in balancer://handoff
+
+
+def conf_port(http, name):
+ conf = Path(http.vars("t_logs")).parent / "conf" / "proxy_beacon.conf"
+ m = re.search(rf"Define BEACON_{name}_PORT (\d+)", conf.read_text())
+ assert m, f"BEACON_{name}_PORT not found in {conf}"
+ return int(m.group(1))
+
+
+def handoff_port(http):
+ return conf_port(http, "HANDOFF")
+
+
+def wait_for(error_log, start, needle, timeout):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ for ln in log_since(error_log, start):
+ if needle in ln:
+ return ln
+ time.sleep(0.2)
+ return None
+
+
+@need
+def test_beacon_does_not_manage_members_it_did_not_create(http):
+ """A BalancerMember from the configuration is not beacon's to enable or
+ evict, even if some backend announces exactly its url."""
+ port = handoff_port(http)
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ start = error_log.stat().st_size
+ inject(http, [STATIC_MEMBER], "http://127.0.0.3:18200",
+ port=port, balancer="handoff")
+ # Outlast ProxyBeaconTimeout (3s) plus the 1s sweep: had beacon adopted the
+ # static member it would be evicted by now.
+ time.sleep(6)
+ lines = log_since(error_log, start)
+ assert STATIC_MEMBER not in added_members(lines, "handoff")
+ assert not [ln for ln in lines if f"evicted backend {STATIC_MEMBER}" in ln]
+
+
+@need
+def test_new_watchdog_owner_evicts_silent_member(http):
+ """Leases live in the watchdog owner's private memory. When the owner dies
+ the replacement used to start with none, so a member that had gone silent
+ stayed enabled forever. It must adopt the beacon-created members and expire
+ the silent one after ProxyBeaconTimeout."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+
+ port = handoff_port(http)
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ member = "http://127.0.0.3:18201"
+
+ # Wait for the receiver, announce once, and never again.
+ session_start = int(http.vars("session_log_start") or 0)
+ assert wait_for(error_log, session_start, "received: BEACON", 15)
+ start = error_log.stat().st_size
+ send_udp(port, signed_beacon(member))
+
+ line = wait_for(error_log, start, f"added backend {member} to balancer://handoff", 10)
+ assert line, "member was never added"
+ owner = int(re.search(r"\[pid (\d+):", line).group(1))
+
+ # Kill the owner straight away -- well inside the 3s timeout, so the old
+ # owner never gets to evict anything.
+ os.kill(owner, signal.SIGKILL)
+
+ evicted = wait_for(error_log, start, f"evicted backend {member} from balancer://handoff", 40)
+ assert evicted, "silent member was never evicted by the new watchdog owner"
+ new_owner = int(re.search(r"\[pid (\d+):", evicted).group(1))
+ assert new_owner != owner
+
+ lines = log_since(error_log, start)
+ assert any("took over" in ln and "balancer://handoff" in ln for ln in lines)
+ # The static member was not adopted along with it.
+ assert not [ln for ln in lines if f"evicted backend {STATIC_MEMBER}" in ln]
+
+
+@need
+def test_new_owner_reenables_adopted_evicted_member(http):
+ """A member that was already evicted when the owner died is adopted as
+ evicted; its next announcement must be handled by the new owner (which has
+ not run any other balancer operation yet) and re-enable it."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+
+ port = handoff_port(http)
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ member = "http://127.0.0.3:18202"
+
+ session_start = int(http.vars("session_log_start") or 0)
+ assert wait_for(error_log, session_start, "received: BEACON", 15)
+ start = error_log.stat().st_size
+ seq = 0
+
+ def announce():
+ nonlocal seq
+ seq += 1
+ send_udp(port, signed_beacon(member, seq=seq))
+
+ announce()
+ line = wait_for(error_log, start, f"added backend {member} to balancer://handoff", 10)
+ assert line, "member was never added"
+ owner = int(re.search(r"\[pid (\d+):", line).group(1))
+
+ # Let the old owner evict it (ProxyBeaconTimeout is 3s), then kill the owner.
+ assert wait_for(error_log, start,
+ f"evicted backend {member} from balancer://handoff", 10)
+ os.kill(owner, signal.SIGKILL)
+
+ # Announce continuously: datagrams sent while no one is listening are lost,
+ # but one lands in the window between the new owner binding its socket and
+ # its first processing tick, which is when it knows nothing but the adopted
+ # (evicted) lease.
+ deadline = time.time() + 40
+ reenabled = None
+ while time.time() < deadline and not reenabled:
+ announce()
+ time.sleep(0.02)
+ for ln in log_since(error_log, start):
+ if f"re-enabled backend {member} in balancer://handoff" in ln:
+ reenabled = ln
+ break
+
+ lines = log_since(error_log, start)
+ crashed = [ln for ln in lines
+ if "exit signal" in ln and "Killed" not in ln]
+ assert not crashed, f"the new owner crashed: {crashed}"
+ assert reenabled, "adopted evicted member was never re-enabled"
+ assert int(re.search(r"\[pid (\d+):", reenabled).group(1)) != owner
+
+
+# ---- wire grammar and timestamp strictness -----------------------------------
+#
+# Each frame below is correctly signed but malformed. A parser that finds fields
+# by searching the string accepts them; the receiver must not. The member url in
+# each is unique, and the valid probe sent after it proves it was processed.
+
+def frame_cases():
+ now = int(time.time() * 1_000_000)
+ stale = now - 10_000_000
+
+ def b(url, ts_part, marker="BEACON", sep=" "):
+ return f"{marker}{sep}url={url}{sep}host=probe{sep}pid=1{sep}seq=1{sep}{ts_part}"
+
+ return [
+ ("ts-trailing-garbage", lambda u: signed_raw(b(u, f"ts={now}x"))),
+ ("ts-signed", lambda u: signed_raw(b(u, f"ts=+{now}"))),
+ ("ts-too-long", lambda u: signed_raw(b(u, f"ts={now}0000000"))),
+ ("duplicate-ts", lambda u: signed_raw(b(u, f"ts={stale} ts={now}"))),
+ ("duplicate-url", lambda u: signed_raw(b(u, f"ts={now} url=http://127.0.0.99:1"))),
+ ("no-marker", lambda u: signed_raw(b(u, f"ts={now}", marker="XEACON"))),
+ ("double-space", lambda u: signed_raw(b(u, f"ts={now}", sep=" "))),
+ ("embedded-nul", lambda u: signed_raw(b(u, f"ts={now}"), b"\x00junk")),
+ ("oversize", lambda u: signed_raw(b(u, f"ts={now}") + " host=" + "h" * 3000)),
+ ]
+
+
+@need
+@pytest.mark.parametrize("n,case", enumerate(frame_cases()),
+ ids=[c[0] for c in frame_cases()])
+def test_malformed_frames_are_dropped(http, n, case):
+ name, build = case
+ member = f"http://127.0.0.12:{8100 + n}"
+ lines = inject(http, [build(member)], f"http://127.0.0.3:{18300 + n}")
+ assert member not in added_members(lines), (
+ f"malformed frame {name!r} must not register a member")
+
+
+@need
+def test_wellformed_frame_still_accepted(http):
+ """Guard for the cases above: the same hand-built shape, but valid."""
+ now = int(time.time() * 1_000_000)
+ member = "http://127.0.0.12:8199"
+ frame = signed_raw(f"BEACON url={member} host=probe pid=1 seq=1 ts={now}")
+ lines = inject(http, [frame], "http://127.0.0.3:18399")
+ assert member in added_members(lines)
+
+
+@need
+def test_subsecond_max_skew_is_honoured(http):
+ """ProxyBeaconMaxSkew 500ms: a timestamp 700ms old is stale. (It used to be
+ rounded down to 0 seconds, which then selected the 30 second default.)"""
+ port = conf_port(http, "SKEW")
+ old, fresh = "http://127.0.0.13:8201", "http://127.0.0.13:8202"
+ # Signed only when sent: inject() can take longer than the window to
+ # get there.
+ lines = inject(
+ http,
+ [lambda: signed_beacon(old, seq=1,
+ ts_us=int(time.time() * 1_000_000) - 700_000),
+ lambda: signed_beacon(fresh, seq=2)],
+ "http://127.0.0.13:8203", port=port, balancer="skew")
+ members = added_members(lines, "skew")
+ assert old not in members, "a 700ms-old timestamp passed a 500ms window"
+ assert fresh in members
+
+
+# ---- tracking-table capacity -------------------------------------------------
+
+TABLE_CAP = 256 # BEACON_MAX_MEMBERS
+
+
+@need
+def test_silent_members_are_reclaimed_so_new_ones_can_join(http):
+ """The receiver tracks at most 256 urls. Records of members that have been
+ evicted, and of adds that failed, used to stay forever, so once 256 distinct
+ urls had ever announced the table was full for good and every later
+ announcement -- from any new backend -- was dropped. They are reclaimed once
+ a replay of their last message could no longer be fresh."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+
+ port = conf_port(http, "RECLAIM")
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ session_start = int(http.vars("session_log_start") or 0)
+ assert wait_for(error_log, session_start, "received: BEACON", 15)
+
+ start = error_log.stat().st_size
+ members = [f"http://10.1.{i // 200}.{i % 200 + 1}:9000" for i in range(TABLE_CAP)]
+ now_us = int(time.time() * 1_000_000)
+ for i, url in enumerate(members):
+ send_udp(port, signed_beacon(url, seq=i, ts_us=now_us + i))
+
+ # A balancer takes at most 100 runtime members: 100 get added, the other 156
+ # fail -- and all 256 occupy a tracking record.
+ deadline = time.time() + 30
+ while time.time() < deadline:
+ added = set(added_members(log_since(error_log, start), "reclaim"))
+ if len(added) >= 100:
+ break
+ time.sleep(0.5)
+ assert len(added) == 100, f"{len(added)} members added, expected 100"
+
+ # Everyone falls silent: members are evicted after 1s, and every record is
+ # reclaimable after 2 * MaxSkew + 1s.
+ time.sleep(10)
+
+ # The table has room again: a newcomer is looked at (and fails, because the
+ # balancer really is full) instead of being dropped for want of a record.
+ mark = error_log.stat().st_size
+ send_udp(port, signed_beacon("http://10.2.0.1:9000", seq=1000))
+ time.sleep(3)
+ lines = log_since(error_log, mark)
+ assert any("balancer add failed" in ln for ln in lines), lines[-5:]
+ assert not any("member table full" in ln for ln in lines)
+
+ # An evicted member that comes back is re-adopted from the balancer.
+ again = sorted(added)[0]
+ mark = error_log.stat().st_size
+ send_udp(port, signed_beacon(again, seq=1001))
+ assert wait_for(error_log, mark,
+ f"backend {again} already in balancer://reclaim", 10)
+
+
+# ---- operator intent ---------------------------------------------------------
+
+def manager_post(http, query):
+ """POST to the handoff vhost's balancer-manager (nonce taken from its page)."""
+ r = http.GET("/balancer-manager")
+ assert r.status_code == 200, "balancer-manager not reachable"
+ m = re.search(r"b=handoff[^\"]*?nonce=([0-9a-fA-F-]+)", r.text.replace("&", "&"))
+ assert m, "no nonce for balancer://handoff"
+ vars_ = http.vars()
+ referer = {"Referer": f"http://{vars_['servername']}:{http.vhost_port('proxy_beacon_handoff')}/balancer-manager"}
+ return http.POST("/balancer-manager", content=(query + "&nonce=" + m.group(1)).encode(),
+ headers=referer)
+
+
+def member_status(http, member):
+ """The balancer-manager's status text for one member of balancer://handoff."""
+ r = http.GET("/balancer-manager")
+ host = member.split("://", 1)[1]
+ for row in re.split(r"", r.text):
+ if host in row and "balancer://" not in row.split(host)[0][-40:]:
+ cells = re.findall(r"| ]*>(.*?) | ", row, re.S)
+ for c in cells:
+ if re.search(r"\b(Init|Ok|Dis|Stop|Drn)\b", c):
+ return re.sub(r"<[^>]+>", "", c).strip()
+ return None
+
+
+@need
+def test_beacon_leaves_operator_flags_alone(http):
+ """Beacon owns a member's Disabled flag. An administrator who needs a member
+ held out of rotation regardless of announcements uses Stopped (or Drain),
+ which beacon never touches, so it survives an evict / re-enable cycle."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+ http.module("proxy_beacon_handoff")
+ try:
+ port = handoff_port(http)
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ member = "http://127.0.0.15:8300"
+
+ session_start = int(http.vars("session_log_start") or 0)
+ assert wait_for(error_log, session_start, "received: BEACON", 15)
+ start = error_log.stat().st_size
+ seq = 0
+
+ def announce():
+ nonlocal seq
+ seq += 1
+ send_udp(port, signed_beacon(member, seq=seq))
+
+ announce()
+ assert wait_for(error_log, start, f"added backend {member} to balancer://handoff", 10)
+
+ r = manager_post(http, f"w={member}&b=handoff&w_status_S=1")
+ assert r.status_code == 200
+ assert "Stop" in (member_status(http, member) or ""), member_status(http, member)
+
+ # Silent past ProxyBeaconTimeout (3s): beacon evicts it...
+ assert wait_for(error_log, start, f"evicted backend {member} from balancer://handoff", 10)
+ # ...then it announces again and beacon re-enables it.
+ announce()
+ assert wait_for(error_log, start, f"re-enabled backend {member} in balancer://handoff", 10)
+
+ status = member_status(http, member) or ""
+ assert "Stop" in status, f"beacon cleared the administrator's Stop: {status!r}"
+ assert "Dis" not in status, f"member should be re-enabled: {status!r}"
+ finally:
+ http.module(None)
+
+
+# ---- random-key secret -------------------------------------------------------
+
+HEX_KEY = bytes(range(16)) # ProxyBeaconSecret hex:000102...0f
+
+
+@need
+def test_hex_secret_is_the_key_itself(http):
+ """hex:<32 digits> is used as the MAC key directly. A sender signing with the
+ raw bytes is accepted; one that signs with MD5 of the same text (what a
+ passphrase would give) is not."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+ port = conf_port(http, "HEXKEY")
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ session_start = int(http.vars("session_log_start") or 0)
+ assert wait_for(error_log, session_start, "received: BEACON", 15)
+
+ bad, good = "http://127.0.0.16:8401", "http://127.0.0.16:8402"
+ start = error_log.stat().st_size
+ now = int(time.time() * 1_000_000)
+ send_udp(port, signed_beacon(bad, seq=1, ts_us=now,
+ key=_md5(b"hex:000102030405060708090a0b0c0d0e0f")))
+ send_udp(port, signed_beacon(good, seq=2, ts_us=now + 1, key=HEX_KEY))
+ assert wait_for(error_log, start, f"added backend {good} to balancer://hexkey", 10)
+ time.sleep(1)
+ assert bad not in added_members(log_since(error_log, start), "hexkey")
+
+
+# ---- ProxyBeaconSecret validation (httpd -t on a wrapped config) --------------
+
+def configtest(server, tmp_path, secret):
+ """`httpd -t` on the generated test config plus one vhost with this secret."""
+ conf = tmp_path / "secret.conf"
+ conf.write_text(
+ f'Include "{server.config.vars["t_conf_file"]}"\n'
+ "\n"
+ " ProxyBeaconListen 127.0.0.1:65302\n"
+ f' ProxyBeaconSecret "{secret}"\n'
+ "\n")
+ argv = [a for a in server.args() if a != "-DFOREGROUND"]
+ argv[argv.index("-f") + 1] = str(conf)
+ return subprocess.run([*argv, "-t"], capture_output=True, text=True)
+
+
+@need
+@pytest.mark.parametrize("secret", [
+ "hex:000102030405060708090a0b0c0d0e0f",
+ "hex:000102030405060708090A0B0C0D0E0F",
+ "x" * 16, # shortest passphrase
+])
+def test_valid_secrets_are_accepted(server, tmp_path, secret):
+ r = configtest(server, tmp_path, secret)
+ assert r.returncode == 0 and "Syntax OK" in r.stderr, r.stderr
+
+
+@need
+@pytest.mark.parametrize("secret,message", [
+ ("short", "at least 16"),
+ ("x" * 15, "at least 16"), # one short of the minimum
+ ("hex:abc", "exactly 32 hex digits"),
+ ("hex:" + "0" * 34, "exactly 32 hex digits"),
+ ("hex:zz" + "0" * 30, "non-hex digit"),
+])
+def test_invalid_secrets_are_rejected(server, tmp_path, secret, message):
+ r = configtest(server, tmp_path, secret)
+ assert r.returncode != 0, "config with this secret should not load"
+ assert message in r.stderr, r.stderr
+
+
+def advertise_configtest(server, tmp_path, url):
+ """`httpd -t` on the generated test config plus one vhost advertising url."""
+ conf = tmp_path / "advertise.conf"
+ conf.write_text(
+ f'Include "{server.config.vars["t_conf_file"]}"\n'
+ "\n"
+ " ProxyBeaconAddress 127.0.0.1:65302\n"
+ f" ProxyBeaconAdvertise {url}\n"
+ f' ProxyBeaconSecret "{"x" * 16}"\n'
+ "\n")
+ argv = [a for a in server.args() if a != "-DFOREGROUND"]
+ argv[argv.index("-f") + 1] = str(conf)
+ return subprocess.run([*argv, "-t"], capture_output=True, text=True)
+
+
+@need
+def test_advertised_highest_port_is_accepted(server, tmp_path):
+ r = advertise_configtest(server, tmp_path, "http://127.0.0.14:65535")
+ assert r.returncode == 0 and "Syntax OK" in r.stderr, r.stderr
+
+
+@need
+@pytest.mark.parametrize("url", [
+ "http://127.0.0.14:65536",
+ "http://127.0.0.14:99999",
+ "http://127.0.0.14:4294967376",
+])
+def test_advertised_out_of_range_port_is_rejected(server, tmp_path, url):
+ r = advertise_configtest(server, tmp_path, url)
+ assert r.returncode != 0, "config advertising this url should not load"
+ assert "is not a valid scheme://host[:port] URL" in r.stderr, r.stderr
+
+
+# ---- permanent configuration errors fail `httpd -t` --------------------------
+
+def vhost_configtest(server, tmp_path, body):
+ """`httpd -t` on the generated test config plus one vhost with this body."""
+ conf = tmp_path / "vhost.conf"
+ conf.write_text(
+ f'Include "{server.config.vars["t_conf_file"]}"\n'
+ "\n"
+ f"{body}\n"
+ "\n")
+ argv = [a for a in server.args() if a != "-DFOREGROUND"]
+ argv[argv.index("-f") + 1] = str(conf)
+ return subprocess.run([*argv, "-t"], capture_output=True, text=True)
+
+
+SECRET_DIRECTIVE = 'ProxyBeaconSecret "xxxxxxxxxxxxxxxx"'
+
+
+@need
+def test_listener_with_a_balancer_passes_configtest(server, tmp_path):
+ """Control for the cases below: the same shape, but valid."""
+ r = vhost_configtest(server, tmp_path,
+ f"ProxyBeaconListen 127.0.0.1:65302\n{SECRET_DIRECTIVE}\n"
+ "ProxyBeaconBalancer beacon")
+ assert r.returncode == 0 and "Syntax OK" in r.stderr, r.stderr
+
+
+@need
+@pytest.mark.parametrize("body", [
+ "ProxyBeaconListen 127.0.0.1:65302", # listener
+ "ProxyBeaconAddress 127.0.0.1:65302", # sender
+])
+def test_missing_secret_fails_configtest(server, tmp_path, body):
+ r = vhost_configtest(server, tmp_path, body)
+ assert r.returncode != 0, "a channel without a secret must not pass -t"
+ assert "ProxyBeaconSecret is required" in r.stderr, r.stderr
+
+
+@need
+@pytest.mark.parametrize("body,message", [
+ (f"ProxyBeaconAddress justahost\n{SECRET_DIRECTIVE}", "must be host:port"),
+ (f"ProxyBeaconAddress 127.0.0.1\n{SECRET_DIRECTIVE}", "must be host:port"),
+ (f"ProxyBeaconAddress [::1\n{SECRET_DIRECTIVE}", "must be host:port"),
+ (f"ProxyBeaconListen [::1\n{SECRET_DIRECTIVE}", "must be [host][:port]"),
+ (f"ProxyBeaconListen 127.0.0.1:65302\n{SECRET_DIRECTIVE}\n"
+ "ProxyBeaconBalancer nosuchbalancer", "is not defined for this server"),
+])
+def test_malformed_address_or_unknown_balancer_fails_configtest(
+ server, tmp_path, body, message):
+ r = vhost_configtest(server, tmp_path, body)
+ assert r.returncode != 0, "this configuration must not pass -t"
+ assert message in r.stderr, r.stderr
+
+
+@need
+def test_listener_shared_with_another_server_is_warned_about(server, tmp_path):
+ """The main server's ProxyBeaconListen takes no argument; so does this one."""
+ r = vhost_configtest(server, tmp_path, f"ProxyBeaconListen\n{SECRET_DIRECTIVE}")
+ assert r.returncode == 0 and "Syntax OK" in r.stderr, r.stderr
+ assert "is also written for another server" in r.stderr, r.stderr
+
+
+@need
+@pytest.mark.parametrize("address", [":65302", "65302", "127.0.0.1", "[::1]:65302",
+ "udp://127.0.0.1:65302"])
+def test_listen_address_forms_pass_configtest(server, tmp_path, address):
+ r = vhost_configtest(server, tmp_path,
+ f"ProxyBeaconListen {address}\n{SECRET_DIRECTIVE}")
+ assert r.returncode == 0 and "Syntax OK" in r.stderr, r.stderr
+
+
+@need
+@pytest.mark.parametrize("address", [":", ":0", ":65536", ":80x", "[::1"])
+def test_bad_listen_address_forms_fail_configtest(server, tmp_path, address):
+ r = vhost_configtest(server, tmp_path,
+ f"ProxyBeaconListen {address}\n{SECRET_DIRECTIVE}")
+ assert r.returncode != 0, f"{address!r} must not pass -t"
+ assert "must be [host][:port]" in r.stderr, r.stderr
+
+
+# ---- socket-open retry back-off ----------------------------------------------
+
+LOG_TS = re.compile(r"^\[\w{3} (\w{3}\s+\d+ [\d:.]+ \d{4})\]")
+
+
+def log_time(line):
+ m = LOG_TS.match(line)
+ return datetime.strptime(re.sub(r"\s+", " ", m.group(1)), "%b %d %H:%M:%S.%f %Y")
+
+
+@need
+def test_failed_socket_open_is_retried_with_backoff(http):
+ """The nobind vhost cannot bind. The open is retried after 30s, then after
+ 60s (doubling) -- not on every watchdog tick, and not never."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ session_start = int(http.vars("session_log_start") or 0)
+
+ def attempts():
+ """Attempt times per watchdog-owning pid: a new owner (after a hand-off
+ test killed the old one) starts its own retry cycle."""
+ by_pid = {}
+ for ln in log_since(error_log, session_start):
+ if "bind to 192.0.2.1:9 failed" in ln:
+ pid = re.search(r"\[pid (\d+)", ln).group(1)
+ by_pid.setdefault(pid, []).append(log_time(ln))
+ return by_pid
+
+ def best():
+ return max(attempts().values(), key=len, default=[])
+
+ deadline = time.time() + 75
+ while len(best()) < 2 and time.time() < deadline:
+ time.sleep(1)
+ assert len(best()) >= 2, f"never retried the failed open: {attempts()}"
+
+ for pid, times in attempts().items():
+ gaps = [(b - a).total_seconds() for a, b in zip(times, times[1:])]
+ for i, gap in enumerate(gaps):
+ expected = 30 * 2 ** i
+ assert expected - 3 <= gap <= expected + 8, (
+ f"pid {pid}: retry {i + 1} came {gap:.1f}s after the previous "
+ f"attempt, expected about {expected}s: {gaps}")
+
+
+# ---- administrator-added members ---------------------------------------------
+
+def ownsync_post(http, query):
+ """POST to the ownsync vhost's balancer-manager on a fresh connection, so
+ that different children get to handle it."""
+ close = {"Connection": "close"}
+ r = http.GET("/balancer-manager", headers=close)
+ assert r.status_code == 200, "balancer-manager not reachable"
+ m = re.search(r"b=ownsync[^\"]*?nonce=([0-9a-fA-F-]+)", r.text.replace("&", "&"))
+ assert m, "no nonce for balancer://ownsync"
+ vars_ = http.vars()
+ referer = {"Referer": f"http://{vars_['servername']}:"
+ f"{http.vhost_port('proxy_beacon_ownsync')}/balancer-manager"}
+ return http.POST("/balancer-manager",
+ content=(query + "&nonce=" + m.group(1)).encode(),
+ headers={**close, **referer})
+
+
+@need
+def test_admin_added_members_are_not_taken_over(http):
+ """Workers added through the balancer-manager (possibly by a child other than
+ the watchdog owner) are not beacon's, even when announced right away: the
+ owner must see them before deciding, and leave them alone."""
+ if http.have_module("mpm_prefork"):
+ pytest.skip("proxy_beacon not run under the prefork MPM")
+ http.module("proxy_beacon_ownsync")
+ try:
+ port = conf_port(http, "OWNSYNC")
+ error_log = Path(http.vars("t_logs")) / "error_log"
+ session_start = int(http.vars("session_log_start") or 0)
+ assert wait_for(error_log, session_start, "received: BEACON", 15)
+ start = error_log.stat().st_size
+
+ urls = [f"http://127.0.0.{30 + i}:8600" for i in range(12)]
+ for i, url in enumerate(urls):
+ r = ownsync_post(http, "b=ownsync&b_wyes=1&b_nwrkr=" + urllib.parse.quote(url, safe=""))
+ assert r.status_code == 200, r.status_code
+ send_udp(port, signed_beacon(url, seq=i + 1))
+
+ assert wait_for(error_log, start, "member not managed by beacon", 10)
+ time.sleep(2)
+ page = http.GET("/balancer-manager", headers={"Connection": "close"}).text
+ assert all(u.split("://", 1)[1] in page for u in urls), (
+ "the administrator's workers were not all added")
+
+ lines = log_since(error_log, start)
+ taken = [ln for ln in lines if any(u in ln for u in urls)
+ and ("added backend" in ln or "already in balancer" in ln
+ or "re-enabled" in ln or "evicted" in ln)]
+ assert not taken, f"beacon acted on an administrator's worker: {taken[:3]}"
+ finally:
+ http.module(None)
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/modules/test_proxy_hcheck.py apache2-2.4.71/test/pytest_suite/tests/t/modules/test_proxy_hcheck.py
--- apache2-2.4.69/test/pytest_suite/tests/t/modules/test_proxy_hcheck.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/modules/test_proxy_hcheck.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,424 @@
+r"""Tests for mod_proxy_hcheck.
+
+The backends are plain socket servers started here (not httpd), so each test
+controls exactly what the health check sees. Worker state is read from the
+balancer-manager of the t/conf/proxy_hcheck.conf.in vhost: "Ok" while the
+worker is healthy, "HcFl" once a health check has failed it.
+"""
+
+import re
+import shutil
+import socketserver
+import ssl
+import subprocess
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+import pytest
+
+from apache_pytest import need_module, not_backported
+
+need = need_module("proxy_hcheck", "proxy_balancer", "proxy_http", "watchdog")
+
+
+def conf_port(http, name):
+ conf = Path(http.vars("t_logs")).parent / "conf" / "proxy_hcheck.conf"
+ m = re.search(rf"Define HC_{name}_PORT (\d+)", conf.read_text())
+ assert m, f"HC_{name}_PORT not found in {conf}"
+ return int(m.group(1))
+
+
+class Backend:
+ """A TCP server that records each request head and answers via respond()."""
+
+ def __init__(self, port, respond, tls=None):
+ self.requests = [] # [(method, target, {lower-case header: value})]
+ self.sni = [] # server names seen in TLS ClientHellos (None = none)
+ self.respond = respond # (backend, request) -> bytes or an iterable of them
+ self.stopping = False
+ self.tls = None
+ if tls:
+ self.tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ self.tls.load_cert_chain(*tls)
+ self.tls.sni_callback = lambda sock, name, ctx: self.sni.append(name)
+ outer = self
+
+ class Handler(socketserver.BaseRequestHandler):
+ def handle(self):
+ data = b""
+ conn = self.request
+ conn.settimeout(5)
+ if outer.tls:
+ try:
+ conn = outer.tls.wrap_socket(conn, server_side=True)
+ except (ssl.SSLError, OSError):
+ return
+ self.request = conn
+ try:
+ while b"\r\n\r\n" not in data:
+ chunk = self.request.recv(4096)
+ if not chunk:
+ return
+ data += chunk
+ except OSError:
+ return
+ lines = data.split(b"\r\n\r\n")[0].decode("latin-1").split("\r\n")
+ method, target, _ = lines[0].split(" ", 2)
+ headers = {}
+ for ln in lines[1:]:
+ k, _, v = ln.partition(":")
+ headers[k.strip().lower()] = v.strip()
+ req = (method, target, headers)
+ outer.requests.append(req)
+ try:
+ out = outer.respond(outer, req)
+ if isinstance(out, (bytes, bytearray)):
+ out = [out]
+ for chunk in out: # a generator may stream, slowly
+ if outer.stopping:
+ break
+ self.request.sendall(chunk)
+ except OSError:
+ pass
+
+ class Server(socketserver.ThreadingTCPServer):
+ allow_reuse_address = True
+ daemon_threads = True
+
+ self.server = Server(("127.0.0.1", port), Handler)
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
+
+ def close(self):
+ self.stopping = True
+ self.server.shutdown()
+ self.server.server_close()
+
+ def targets(self):
+ return [t for _, t, _ in self.requests]
+
+
+def ok_response(backend, req):
+ return (b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
+
+
+def worker_status(http, port):
+ """The balancer-manager's status text for the worker on port ."""
+ r = http.GET("/balancer-manager")
+ assert r.status_code == 200, "balancer-manager not reachable"
+ needle = re.compile(rf":{port}\b")
+ for row in re.split(r"", r.text):
+ if needle.search(row):
+ for cell in re.findall(r"]*>(.*?) | ", row, re.S):
+ text = re.sub(r"<[^>]+>", "", cell).strip()
+ if re.search(r"\b(Init|Ok|Dis|Stop|HcFl|Err)\b", text):
+ return text
+ return None
+
+
+def wait_until(fn, timeout, interval=0.3):
+ deadline = time.time() + timeout
+ last = None
+ while time.time() < deadline:
+ last = fn()
+ if last:
+ return last
+ time.sleep(interval)
+ return last
+
+
+def is_ok(http, port):
+ st = worker_status(http, port) or ""
+ return "Ok" in st and "HcFl" not in st
+
+
+def goes_bad(http, backend, port, bad):
+ """Start from a healthy worker, then make the backend misbehave.
+
+ A worker whose backend is not up yet is already failing, so "it is failing"
+ proves nothing: first see it pass, and only then switch the response."""
+ backend.respond = ok_response
+ assert wait_until(lambda: is_ok(http, port), 20), (
+ f"worker never became healthy: {worker_status(http, port)!r}")
+ backend.respond = bad
+ return wait_until(lambda: "HcFl" in (worker_status(http, port) or ""), 20)
+
+
+@pytest.fixture
+def hc_http(http):
+ http.module("proxy_hcheck")
+ try:
+ yield http
+ finally:
+ http.module(None)
+
+
+@need
+def test_complete_response_keeps_worker_healthy(hc_http):
+ """Control: a proper response passes the health check."""
+ port = conf_port(hc_http, "HDR")
+ b = Backend(port, ok_response)
+ try:
+ assert wait_until(lambda: b.requests, 15), "no health check arrived"
+ time.sleep(3)
+ assert "HcFl" not in (worker_status(hc_http, port) or "")
+ assert "Ok" in (worker_status(hc_http, port) or "")
+ finally:
+ b.close()
+
+
+@need
+@not_backported("r1939353, r1939390", "hcheck rejecting a truncated response")
+def test_truncated_response_headers_fail_the_check(hc_http):
+ """A response whose headers end at EOF, without the blank line that
+ terminates them, is not a valid response. It used to be accepted, so a
+ backend that was cutting its responses short was reported healthy."""
+ port = conf_port(hc_http, "HDR")
+
+ def truncated(backend, req):
+ return b"HTTP/1.1 200 OK\r\nServer: broken\r\n" # ...and closes
+
+ b = Backend(port, ok_response)
+ try:
+ assert goes_bad(hc_http, b, port, truncated), (
+ f"truncated headers passed: {worker_status(hc_http, port)!r}")
+ finally:
+ b.close()
+
+
+def manager_post(http, balancer, query):
+ """POST to the vhost's balancer-manager (the nonce comes from its page)."""
+ r = http.GET("/balancer-manager")
+ assert r.status_code == 200, "balancer-manager not reachable"
+ m = re.search(rf"b={balancer}[^\"]*?nonce=([0-9a-fA-F-]+)", r.text.replace("&", "&"))
+ assert m, f"no nonce for balancer://{balancer}"
+ v = http.vars()
+ port = http.vhost_port("proxy_hcheck")
+ referer = {"Referer": f"http://{v['servername']}:{port}/balancer-manager"}
+ return http.POST("/balancer-manager",
+ content=(query + "&nonce=" + m.group(1)).encode(),
+ headers=referer)
+
+
+@need
+@not_backported("r1939353, r1939390", "hcheck following a changed hcuri")
+def test_changed_probe_uri_is_used(hc_http):
+ """The probe URI is read from the worker when the health check is first set
+ up, and was never looked at again, so changing it in the balancer-manager
+ had no effect on what the backend saw."""
+ port = conf_port(hc_http, "URI")
+ b = Backend(port, ok_response)
+ try:
+ assert wait_until(lambda: "/old" in b.targets(), 15), "no /old probe"
+
+ r = manager_post(hc_http, "hcuri", f"w=http://127.0.0.1:{port}&b=hcuri&w_hu=/new")
+ assert r.status_code == 200
+ assert "/new | " in hc_http.GET("/balancer-manager").text, (
+ "the balancer-manager did not store the new URI")
+
+ mark = len(b.requests)
+ assert wait_until(lambda: "/new" in b.targets()[mark:], 15), (
+ f"probes after the change still went to {sorted(set(b.targets()[mark:]))}")
+ finally:
+ b.close()
+
+
+def make_cert(directory, name):
+ """A self-signed certificate for `name`; returns (certfile, keyfile) or None."""
+ for exe in (shutil.which("openssl"), "/opt/homebrew/opt/openssl@3/bin/openssl",
+ "/opt/local/bin/openssl"):
+ if not exe:
+ continue
+ cert, key = f"{directory}/{name}.crt", f"{directory}/{name}.key"
+ r = subprocess.run(
+ [exe, "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2",
+ "-subj", f"/CN={name}", "-addext", f"subjectAltName=DNS:{name}",
+ "-keyout", key, "-out", cert],
+ capture_output=True)
+ if r.returncode == 0:
+ return cert, key
+ return None
+
+
+@pytest.fixture
+def certs():
+ with tempfile.TemporaryDirectory() as d:
+ bad, good = make_cert(d, "wrong.invalid"), make_cert(d, "localhost")
+ if not bad or not good:
+ pytest.skip("no openssl able to make a test certificate")
+ yield {"bad": bad, "good": good}
+
+
+def tls_need(http):
+ if not http.have_module("ssl"):
+ pytest.skip("mod_ssl is required")
+
+
+@need
+@not_backported("r1939353, r1939390", "hcheck SNI on HTTPS probes")
+def test_https_probe_sends_sni_and_checks_the_name(hc_http, certs):
+ """A health check of an https worker used to send no SNI and skip the host
+ name check, so a backend presenting the certificate of another host passed
+ even though real requests to it are refused."""
+ tls_need(hc_http)
+ port = conf_port(hc_http, "TLSBAD")
+ b = Backend(port, ok_response, tls=certs["bad"])
+ try:
+ assert wait_until(lambda: b.sni, 15), "no TLS handshake reached the backend"
+ assert "localhost" in b.sni, f"SNI seen by the backend: {b.sni}"
+ # The worker starts out failing (nothing was listening yet), so what
+ # matters is that, with handshakes happening, it never turns healthy.
+ assert not wait_until(lambda: is_ok(hc_http, port), 8), (
+ "wrong-host certificate passed the check: "
+ f"{worker_status(hc_http, port)!r}")
+ finally:
+ b.close()
+
+
+@need
+@not_backported("r1939353, r1939390", "hcheck SNI on HTTPS probes")
+def test_https_probe_with_matching_certificate_passes(hc_http, certs):
+ """Control for the above: the right certificate keeps the worker healthy."""
+ tls_need(hc_http)
+ port = conf_port(hc_http, "TLSGOOD")
+ b = Backend(port, ok_response, tls=certs["good"])
+ try:
+ assert wait_until(lambda: b.requests, 15), "no health check request arrived"
+ time.sleep(3)
+ status = worker_status(hc_http, port) or ""
+ assert "Ok" in status and "HcFl" not in status, status
+ assert "localhost" in b.sni
+ finally:
+ b.close()
+
+
+# ---- limits on what a probe accepts from a backend ----------------------------
+
+@need
+@not_backported("r1939353, r1939390", "hcheck response header limit")
+def test_response_with_too_many_headers_fails_the_check(hc_http):
+ port = conf_port(hc_http, "MANYHDR")
+
+ def many(backend, req):
+ hdrs = b"".join(b"X-Filler-%d: v\r\n" % i for i in range(2000))
+ return b"HTTP/1.1 200 OK\r\n" + hdrs + b"Content-Length: 0\r\nConnection: close\r\n\r\n"
+
+ b = Backend(port, ok_response)
+ try:
+ assert goes_bad(hc_http, b, port, many), (
+ f"2000 response headers were accepted: {worker_status(hc_http, port)!r}")
+ finally:
+ b.close()
+
+
+@need
+@not_backported("r1939353, r1939390", "hcheck cutting off an endless response")
+def test_endless_response_is_cut_off(hc_http):
+ """A backend that keeps sending (a byte every half second here) never trips
+ a per-read timeout. The probe needs an overall deadline (the worker's
+ timeout, 2s here) or it holds a checking thread, and keeps growing its
+ buffer, for as long as the backend cares to go on."""
+ port = conf_port(hc_http, "SLOW")
+
+ def endless(backend, req):
+ yield b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
+ while True:
+ time.sleep(0.5)
+ yield b"x"
+
+ b = Backend(port, ok_response)
+ try:
+ assert goes_bad(hc_http, b, port, endless), (
+ f"an endless response was never cut off: {worker_status(hc_http, port)!r}")
+ finally:
+ b.close()
+
+
+def big_ok(size):
+ def respond(backend, req):
+ yield (b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" % size)
+ sent = 0
+ while sent < size:
+ n = min(65536, size - sent)
+ yield b"x" * n
+ sent += n
+ return respond
+
+
+@need
+@not_backported("r1939353, r1939390", "hcheck not keeping a large body")
+def test_large_body_is_not_kept_for_a_condition(hc_http):
+ """With a condition on the body, a probe keeps the body in memory to evaluate
+ it. A 3MB body is more than that is allowed to take, and fails the check."""
+ port = conf_port(hc_http, "BIGEXPR")
+ b = Backend(port, ok_response)
+ try:
+ assert goes_bad(hc_http, b, port, big_ok(3 * 1024 * 1024)), (
+ f"a 3MB body was kept and evaluated: {worker_status(hc_http, port)!r}")
+ finally:
+ b.close()
+
+
+@need
+def test_large_body_without_a_condition_is_fine(hc_http):
+ """Control: with no condition the body is not needed, is read and dropped,
+ and the check passes as before."""
+ port = conf_port(hc_http, "BIGPLAIN")
+ b = Backend(port, big_ok(3 * 1024 * 1024))
+ try:
+ assert wait_until(lambda: len(b.requests) >= 2, 15), "probes stopped arriving"
+ status = worker_status(hc_http, port) or ""
+ assert "Ok" in status and "HcFl" not in status, status
+ finally:
+ b.close()
+
+
+# ---- the probe's request line ---------------------------------------------------
+
+_JOIN = not_backported("r1939353, r1939390", "hcheck joining the worker path and hcuri")
+
+
+@need
+@pytest.mark.parametrize("name,expected", [
+ pytest.param("PATHA", "/ping", marks=_JOIN), # http://host:port + hcuri "ping"
+ pytest.param("PATHB", "/app/ping", marks=_JOIN), # http://host:port/app + hcuri "/ping"
+ pytest.param("PATHC", "/app/ping", marks=_JOIN), # http://host:port/app/ + hcuri "ping"
+ pytest.param("PATHD", "/", marks=_JOIN), # http://host:port + no hcuri
+ ("PATHE", "/app"), # http://host:port/app + no hcuri
+ ("PATHF", "/app/"), # http://host:port/app/ + no hcuri: kept as is
+ pytest.param("PATHG", "/app/", marks=_JOIN), # http://host:port/app + hcuri "/"
+])
+def test_probe_path_joins_worker_path_and_hcuri(hc_http, name, expected):
+ """One slash between the worker's path and hcuri, and a request target that
+ starts with one: a bare "ping" used to be sent as "GET ping HTTP/1.0", and a
+ path plus "/ping" as "/app//ping"."""
+ b = Backend(conf_port(hc_http, name), ok_response)
+ try:
+ assert wait_until(lambda: b.requests, 15), "no health check arrived"
+ assert set(b.targets()) == {expected}, b.targets()
+ finally:
+ b.close()
+
+
+@need
+def test_check_worker_is_created_once(hc_http):
+ """The cache of per-worker check state is looked up on every cycle; if the
+ lookup were to miss, a new long-lived check worker would be created each
+ time. (Guards the lifetime of the cache's keys.)"""
+ port = conf_port(hc_http, "URI")
+ error_log = Path(hc_http.vars("t_logs")) / "error_log"
+ b = Backend(port, ok_response)
+ try:
+ assert wait_until(lambda: len(b.requests) >= 5, 20), "too few health checks"
+ finally:
+ b.close()
+ session_start = int(hc_http.vars("session_log_start") or 0)
+ with error_log.open("r", errors="replace") as fh:
+ fh.seek(session_start)
+ created = [ln for ln in fh if "Creating hc worker" in ln
+ and f"127.0.0.1:{port}" in ln]
+ # Once per process: the cache is per process, and a child which takes
+ # over the singleton watchdog from one that exited builds its own.
+ pids = [re.search(r"\[pid (\d+)", ln).group(1) for ln in created]
+ assert created and len(pids) == len(set(pids)), created
diff -Nru apache2-2.4.69/test/pytest_suite/tests/t/modules/test_speling.py apache2-2.4.71/test/pytest_suite/tests/t/modules/test_speling.py
--- apache2-2.4.69/test/pytest_suite/tests/t/modules/test_speling.py 2026-09-28 16:36:07.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/t/modules/test_speling.py 2026-11-09 03:07:05.000000000 +0000
@@ -59,3 +59,17 @@
# Only redirect responses carry a corrected-filename body.
if expected not in (200, 404):
assert t_cmp(r.text, _REDIRECT_BODY), "Redirect ok"
+
+
+@need_module("mod_speling")
+def test_speling_short_uri_alias(http):
+ """Regression for the check_speling() out-of-bounds read.
+
+ ``Alias /sp-oob`` maps a short URI to a longer, nonexistent base name in
+ a CheckSpelling directory. postgood (the base name) is then longer than
+ r->uri, so r->uri + (urlen - pglen) underflowed and strcmp read before
+ the start of the buffer. The server must answer cleanly (404) instead of
+ crashing or reading out of bounds.
+ """
+ r = http.GET("/sp-oob")
+ assert t_cmp(r.status_code, 404), "short-URI alias must not read OOB"
diff -Nru apache2-2.4.69/test/pytest_suite/tests/test_backport_marks.py apache2-2.4.71/test/pytest_suite/tests/test_backport_marks.py
--- apache2-2.4.69/test/pytest_suite/tests/test_backport_marks.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/tests/test_backport_marks.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,27 @@
+"""Self-test for the not_backported mark: which servers it expects a failure
+on. Needs no httpd."""
+import pytest
+
+from apache_pytest.testapi import has_trunk_change, not_backported
+
+
+@pytest.mark.parametrize("version,since,expected", [
+ ((2, 4, 70), None, False),
+ ((2, 4, 70), "2.4.71", False),
+ ((2, 4, 71), "2.4.71", True),
+ ((2, 4, 72), "2.4.71", True),
+ # numeric, not textual, comparison
+ ((2, 4, 9), "2.4.10", False),
+ ((2, 4, 10), "2.4.9", True),
+ ((2, 5, 0), None, True),
+ ((2, 5, 1), None, True),
+ ((2, 5, 1), "2.4.71", True),
+])
+def test_has_trunk_change(version, since, expected):
+ assert has_trunk_change(version, since) is expected
+
+
+def test_mark_carries_backported_in():
+ mark = not_backported("r1", "a change", backported_in="2.4.71").mark
+ assert mark.args == ("r1", "a change")
+ assert mark.kwargs == {"backported_in": "2.4.71"}
diff -Nru apache2-2.4.69/test/pytest_suite/uv.lock apache2-2.4.71/test/pytest_suite/uv.lock
--- apache2-2.4.69/test/pytest_suite/uv.lock 2026-06-03 16:20:30.000000000 +0000
+++ apache2-2.4.71/test/pytest_suite/uv.lock 1970-01-01 00:00:00.000000000 +0000
@@ -1,156 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.11"
-
-[[package]]
-name = "anyio"
-version = "4.13.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2026.5.20"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "h11"
-version = "0.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
-]
-
-[[package]]
-name = "httpcore"
-version = "1.0.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
-]
-
-[[package]]
-name = "httpd-pytest"
-version = "0.1.0"
-source = { virtual = "." }
-dependencies = [
- { name = "httpx" },
- { name = "pytest" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "httpx", specifier = ">=0.27" },
- { name = "pytest", specifier = ">=8.0" },
-]
-
-[[package]]
-name = "httpx"
-version = "0.28.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
- { name = "idna" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
-]
-
-[[package]]
-name = "idna"
-version = "3.18"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
-]
-
-[[package]]
-name = "packaging"
-version = "26.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.20.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
-]
-
-[[package]]
-name = "pytest"
-version = "9.0.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.15.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
-]
diff -Nru apache2-2.4.69/test/run-all-tests.sh apache2-2.4.71/test/run-all-tests.sh
--- apache2-2.4.69/test/run-all-tests.sh 2026-07-17 18:15:55.000000000 +0000
+++ apache2-2.4.71/test/run-all-tests.sh 1970-01-01 00:00:00.000000000 +0000
@@ -1,196 +0,0 @@
-#!/bin/sh
-#
-# run-all-tests.sh -- unified runner for the two Python test suites under test/.
-#
-# Runs both pytest-based suites against the same built httpd:
-# 1. pytest_suite/ -- the self-contained port of the classic Apache::Test
-# suite (core HTTP, modules, security CVEs, SSL, PHP). Run FIRST.
-# 2. pyhttpd tests under modules/ -- httpd's own HTTP/2, mod_md, HTTP/1,
-# proxy and core tests (uses curl/nghttp/h2load; configured via
-# pyhttpd/config.ini).
-#
-# Both run against the same httpd; pytest_suite locates it via --apxs, while
-# the pyhttpd tests read pyhttpd/config.ini (generated by httpd's configure).
-#
-# Usage:
-# ./run-all-tests.sh # run both suites (pytest_suite first)
-# ./run-all-tests.sh --only=pysuite # run only pytest_suite
-# ./run-all-tests.sh --only=pyhttpd # run only the pyhttpd modules/ tests
-# ./run-all-tests.sh --apxs /path/to/apxs # override the httpd build
-# ./run-all-tests.sh -k status -v # extra args pass through to BOTH pytests
-# ./run-all-tests.sh --clean-modules # wipe compiled C-module artifacts before
-# # building (emulate make clean; pytest_suite only)
-#
-# Environment overrides:
-# APXS path to apxs (default: read from pyhttpd/config.ini, else $PATH)
-# PHP_FPM path to php-fpm for pytest_suite's PHP tests (optional)
-# PYHTTPD_TARGETS pyhttpd test paths (default: "modules")
-#
-set -eu
-
-usage() {
- cat <<'EOF'
-Usage: run-all-tests.sh [OPTIONS] [PATHS...]
-
-Run both Python test suites against a built httpd.
-
-Suites (run in order):
- 1. pytest_suite/ -- classic Apache::Test port (HTTP, modules, SSL, CVEs)
- 2. modules/ -- pyhttpd tests (HTTP/2, mod_md, proxy, core)
-
-Options:
- --only=pysuite run only pytest_suite
- --only=pyhttpd run only the pyhttpd modules/ tests
- --apxs=PATH path to apxs for the pytest_suite build
- --clean-modules wipe compiled C-module artifacts before building
- (emulate make clean; pytest_suite only)
- -k EXPR pytest keyword filter (passed to both suites)
- -m EXPR pytest marker filter (passed to both suites)
- -v, -x, ... other pytest flags (passed to both suites)
- -h, --help show this help and exit
-
-Positional PATHS are forwarded to pytest_suite only (e.g. tests/t/modules/foo).
-The pyhttpd suite selects tests via PYHTTPD_TARGETS or auto-detection.
-
-Environment:
- APXS path to apxs (default: config.ini, then \$PATH)
- PHP_FPM path to php-fpm for PHP tests in pytest_suite (optional)
- PYHTTPD_TARGETS space-separated list of pyhttpd test paths (default: modules/*)
-EOF
-}
-
-here="$(cd "$(dirname "$0")" && pwd)"
-suite_dir="$here/pytest_suite"
-config_ini="$here/pyhttpd/config.ini"
-
-# Parse args once. Pull out --only and --apxs (space and = forms). Remaining
-# arguments are split into:
-# flags (start with '-', e.g. -v -k NAME -x): passed to BOTH pytests.
-# paths (positional, e.g. tests/t/modules/...): these are pytest_suite
-# paths and go ONLY to pytest_suite. The pyhttpd side selects its
-# tests via PYHTTPD_TARGETS (or its auto-detected default), since a
-# pytest_suite path is meaningless there.
-#
-# The hard part is telling a positional test path from the value of a flag that
-# takes a separate word (e.g. `--tb short`, `--maxfail 3`, `-n 4`). We handle it
-# two ways: (a) the common value-flags -k/-m/-p are known to consume the next
-# word, and (b) any OTHER bare word is treated as a pysuite path only if it
-# actually exists on disk -- a flag value like "short"/"3"/"no" never does, so
-# it stays with `flags` (attached to its preceding flag) instead of being
-# misrouted to pysuite-only paths and stripped from what pyhttpd receives.
-only=""
-apxs_opt=""
-flags=""
-pysuite_flags=""
-paths=""
-expect_apxs=0
-expect_flagval=0
-for arg in "$@"; do
- if [ "$expect_apxs" = "1" ]; then apxs_opt="$arg"; expect_apxs=0; continue; fi
- if [ "$expect_flagval" = "1" ]; then flags="$flags $arg"; expect_flagval=0; continue; fi
- case "$arg" in
- -h|--help) usage; exit 0 ;;
- --only=*) only="${arg#--only=}" ;;
- --apxs) expect_apxs=1 ;;
- --apxs=*) apxs_opt="${arg#--apxs=}" ;;
- --clean-modules) pysuite_flags="$pysuite_flags $arg" ;; # pysuite-only; pyhttpd has no C modules
- -k|-m|-p) flags="$flags $arg"; expect_flagval=1 ;; # take a value next
- -*) flags="$flags $arg" ;;
- # A real pysuite path exists relative to pytest_suite/ (how users type
- # it, e.g. "tests/t/php") or to our cwd; strip any ::nodeid suffix
- # first. Anything else is a stray flag value -> keep it with the flags.
- *) if [ -e "$suite_dir/${arg%%::*}" ] || [ -e "${arg%%::*}" ]; then
- paths="$paths $arg"
- else
- flags="$flags $arg"
- fi
- ;;
- esac
-done
-
-# --- locate apxs (for pytest_suite) -----------------------------------------
-if [ -z "$apxs_opt" ]; then
- if [ -n "${APXS:-}" ]; then
- apxs_opt="$APXS"
- elif [ -f "$config_ini" ]; then
- # Resolve apxs = ${exec_prefix}/bin/apxs from config.ini (simple expand).
- prefix=$(sed -n 's/^prefix = //p' "$config_ini" | head -1)
- apxs_opt="$prefix/bin/apxs"
- elif command -v apxs >/dev/null 2>&1; then
- apxs_opt="$(command -v apxs)"
- fi
-fi
-
-php_args=""
-[ -n "${PHP_FPM:-}" ] && php_args="--php-fpm=$PHP_FPM"
-
-rc=0
-skipped="" # names of suites that did NOT run (so we never report them "passed")
-
-run_pysuite() {
- echo "=========================================================="
- echo "[1/2] pytest_suite (classic Apache::Test port)"
- echo "=========================================================="
- if [ -z "$apxs_opt" ]; then
- echo "run-all-tests.sh: ERROR: no apxs found for pytest_suite." >&2
- echo " Pass --apxs=/path/to/apxs or set APXS=..." >&2
- return 2
- fi
- # runtests.sh handles venv + cgisock cleanup + flag assembly.
- # pytest_suite gets both the shared flags and any positional paths.
- # pysuite_flags holds options only meaningful to this suite (e.g. --clean-modules).
- # shellcheck disable=SC2086
- ( cd "$suite_dir" && ./runtests.sh --apxs="$apxs_opt" $php_args $flags $pysuite_flags $paths ) || return $?
-}
-
-run_pyhttpd() {
- echo "=========================================================="
- echo "[2/2] pyhttpd suites (modules/: http2, md, http1, proxy, core)"
- echo "=========================================================="
- if [ ! -f "$config_ini" ]; then
- echo "run-all-tests.sh: note: pyhttpd/config.ini not found;" >&2
- echo " build httpd with its test config (configure) to run these." >&2
- skipped="$skipped pyhttpd"
- return 0
- fi
- # runtests.sh manages the venv, prepends its bin/ to PATH (so CGI
- # scripts forked by httpd also use the venv's Python), and runs pytest.
- # PYHTTPD_TARGETS can override which modules/ subdirs are run.
- # shellcheck disable=SC2086
- ( cd "$here/pyhttpd" && ./runtests.sh $flags ) || return $?
-}
-
-case "$only" in
- pysuite) run_pysuite || rc=$? ;;
- pyhttpd) run_pyhttpd || rc=$? ;;
- "")
- # Both, pytest_suite first. Keep going if the first fails so we get
- # results from both; report a non-zero overall rc if either failed.
- run_pysuite || rc=$?
- run_pyhttpd || rc=$?
- ;;
- *)
- echo "run-all-tests.sh: unknown --only=$only (use pysuite|pyhttpd)" >&2
- exit 2
- ;;
-esac
-
-echo "=========================================================="
-if [ "$rc" -ne 0 ]; then
- echo "SOME TESTS FAILED (rc=$rc)"
-elif [ -n "$skipped" ]; then
- # Nothing failed, but at least one suite never ran -- don't claim success
- # for a suite that was skipped (e.g. pyhttpd with no config.ini).
- echo "PASSED, BUT SKIPPED:$skipped (not run -- see notes above)"
-else
- echo "ALL SUITES PASSED"
-fi
-echo "=========================================================="
-
-# If a suite was skipped and the user explicitly asked for ONLY that suite,
-# treat "ran nothing" as a failure -- otherwise --only=pyhttpd could exit 0
-# having executed zero tests.
-if [ -n "$skipped" ] && [ -n "$only" ] && [ "$rc" -eq 0 ]; then
- exit 3
-fi
-exit "$rc"
diff -Nru apache2-2.4.69/test/run-one-test.sh apache2-2.4.71/test/run-one-test.sh
--- apache2-2.4.69/test/run-one-test.sh 2026-09-29 09:09:07.000000000 +0000
+++ apache2-2.4.71/test/run-one-test.sh 2026-11-09 03:07:05.000000000 +0000
@@ -1,13 +1,13 @@
#!/bin/sh
#
# run-one-test.sh -- re-run a single test (file, directory or ::nodeid) via
-# run-all-tests.sh, picking the right suite from the path.
+# runtests.sh. The path can be given the way the test output prints it.
#
# Usage:
# ./run-one-test.sh PATH[::nodeid] [pytest args...]
#
-# PATH may be given as printed in the run-all-tests.sh output, or as a real
-# filesystem path from anywhere, e.g.:
+# PATH may be given as printed in the pytest output, or as a real filesystem
+# path from anywhere, e.g.:
# ./run-one-test.sh tests/t/apache/test_404.py # pytest_suite
# ./run-one-test.sh tests/t/apache/test_404.py::test_x -v
# ./run-one-test.sh modules/http2/test_003_get.py # pyhttpd
@@ -92,15 +92,8 @@
fi
case "$abs" in
- "$here/pytest_suite/"*)
- exec "$here/run-all-tests.sh" --only=pysuite "$@" \
- "${abs#"$here/pytest_suite/"}$node"
- ;;
- "$here/modules/"*)
- # pyhttpd paths are taken from PYHTTPD_TARGETS, relative to test/.
- PYHTTPD_TARGETS="${abs#"$here/"}$node"
- export PYHTTPD_TARGETS
- exec "$here/run-all-tests.sh" --only=pyhttpd "$@"
+ "$here/pytest_suite/"*|"$here/modules/"*)
+ exec "$here/runtests.sh" "$@" "${abs#"$here/"}$node"
;;
*)
echo "run-one-test.sh: $abs is not under pytest_suite/ or modules/" >&2
diff -Nru apache2-2.4.69/test/runtests.bat apache2-2.4.71/test/runtests.bat
--- apache2-2.4.69/test/runtests.bat 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/runtests.bat 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,179 @@
+@echo off
+rem
+rem runtests.bat -- run the Python test suites on Windows: modules/ and
+rem pytest_suite/ in one pytest session against one built httpd.
+rem
+rem This is the Windows equivalent of runtests.sh. It manages the .venv in
+rem test\ so that pytest and the CGI helper scripts (which httpd forks) use the
+rem same Python with all required packages, finds apxs/php-fpm/perl when it
+rem can, and runs pytest from test\.
+rem
+rem Usage:
+rem runtests.bat --httpd C:\path\httpd.exe --conf C:\path\httpd.conf modules
+rem runtests.bat --apxs=C:\path\apxs pytest_suite\tests -- the classic suite
+rem runtests.bat --httpd ... modules\http2 -- a specific suite
+rem runtests.bat --httpd ... modules -k post -v -- any pytest args pass through
+rem
+rem Name the suite to run: without a path both run, and pytest_suite\ has not
+rem been exercised on Windows CI. Paths are relative to test\.
+rem
+rem Environment overrides (used when the matching --flag is not supplied):
+rem PYTHON path to python (auto-detected if not set)
+rem APXS path to apxs
+rem HTTPD path to httpd
+rem PHP_FPM path to php-fpm
+rem APACHE_TEST_PERL path to perl
+rem
+
+setlocal enabledelayedexpansion
+
+set "HERE=%~dp0"
+rem Remove trailing backslash
+if "%HERE:~-1%"=="\" set "HERE=%HERE:~0,-1%"
+set "PROJ=%HERE%"
+
+rem --- python location: find the newest native Windows Python 3.x -------------
+if not defined PYTHON (
+ set PYTHON=
+ for /f "delims=" %%P in ('where python.exe 2^>nul') do (
+ if not defined PYTHON (
+ echo %%P | findstr /i "hostedtoolcache" >nul
+ if not errorlevel 1 set "PYTHON=%%P"
+ )
+ )
+)
+if not defined PYTHON (
+ for /f "delims=" %%D in ('dir /b /o-n "%LOCALAPPDATA%\Programs\Python\Python3*" 2^>nul') do (
+ if not defined PYTHON set "PYTHON=%LOCALAPPDATA%\Programs\Python\%%D\python.exe"
+ )
+)
+if not defined PYTHON (
+ for /f "delims=" %%D in ('dir /b /o-n "%PROGRAMFILES%\Python3*" 2^>nul') do (
+ if not defined PYTHON set "PYTHON=%PROGRAMFILES%\%%D\python.exe"
+ )
+)
+if not defined PYTHON (
+ echo runtests.bat: ERROR: Python interpreter not found. >&2
+ echo Install Python from https://www.python.org or set PYTHON=... >&2
+ exit /b 1
+)
+
+rem --- ensure the venv exists and is current ----------------------------------
+set "PYTEST=%PROJ%\.venv\Scripts\pytest.exe"
+
+set "NEED_SYNC=0"
+if not exist "%PYTEST%" set "NEED_SYNC=1"
+
+rem Check if pyproject.toml is newer than .venv (approximate: compare timestamps
+rem via xcopy /D /L which lists files that are newer than the destination).
+if exist "%PROJ%\.venv" (
+ for /f %%F in ('xcopy /D /L /Y "%PROJ%\pyproject.toml" "%PROJ%\.venv\" 2^>nul ^| C:\Windows\System32\find.exe /c "pyproject.toml"') do (
+ if %%F gtr 0 set "NEED_SYNC=1"
+ )
+) else (
+ set "NEED_SYNC=1"
+)
+
+if "%NEED_SYNC%"=="1" (
+ %PYTHON% -m uv --version >nul 2>&1
+ if errorlevel 1 (
+ echo runtests.bat: ERROR: 'uv' is required but not installed. >&2
+ echo Install it from https://docs.astral.sh/uv/ and re-run. >&2
+ echo %PYTHON% -m pip install uv >&2
+ exit /b 1
+ )
+ echo runtests.bat: ^(re^)creating %PROJ%\.venv via 'uv sync'... >&2
+ %PYTHON% -m uv sync --project "%PROJ%"
+ if errorlevel 1 exit /b 1
+ rem Touch .venv so the staleness check won't retrigger
+ copy /b "%PROJ%\.venv" +,, "%PROJ%\.venv" >nul 2>&1
+)
+
+rem --- discover apxs / php-fpm / perl -----------------------------------------
+if not defined APXS (
+ for %%C in (apxs.exe) do (
+ if "!APXS!"=="" (
+ for /f "delims=" %%P in ('where %%C 2^>nul') do (
+ if "!APXS!"=="" set "APXS=%%P"
+ )
+ )
+ )
+)
+
+if not defined PHP_FPM (
+ for %%C in (php-fpm.exe php-fpm8.3.exe php-fpm83.exe php-fpm8.2.exe php-fpm82.exe) do (
+ if "!PHP_FPM!"=="" (
+ for /f "delims=" %%P in ('where %%C 2^>nul') do (
+ if "!PHP_FPM!"=="" set "PHP_FPM=%%P"
+ )
+ )
+ )
+)
+
+if not defined APACHE_TEST_PERL (
+ for %%C in (perl.exe) do (
+ for /f "delims=" %%P in ('where %%C 2^>nul') do (
+ if "!APACHE_TEST_PERL!"=="" set "APACHE_TEST_PERL=%%P"
+ )
+ )
+)
+
+rem Build the default flag set only for values the user did not pass explicitly.
+set "AUTO_ARGS="
+
+set "HAS_SERVER_FLAG=0"
+for %%A in (%*) do (
+ echo %%A | findstr /b /c:"--apxs" /c:"--httpd" >nul 2>&1 && set "HAS_SERVER_FLAG=1"
+)
+
+if "%HAS_SERVER_FLAG%"=="0" (
+ if defined APXS (
+ set "AUTO_ARGS=--apxs=!APXS!"
+ ) else if defined HTTPD (
+ set "AUTO_ARGS=--httpd=!HTTPD!"
+ ) else (
+ echo runtests.bat: note: no apxs/httpd given or found; using the build of >&2
+ echo pyhttpd\config.ini ^(or PYHTTPD_CONFIG^) if there is one. >&2
+ )
+)
+
+set "HAS_PHP_FLAG=0"
+for %%A in (%*) do (
+ echo %%A | findstr /b /c:"--php-fpm" >nul 2>&1 && set "HAS_PHP_FLAG=1"
+)
+
+if "%HAS_PHP_FLAG%"=="0" (
+ if defined PHP_FPM (
+ set "AUTO_ARGS=!AUTO_ARGS! --php-fpm=!PHP_FPM!"
+ ) else (
+ echo runtests.bat: note: no php-fpm found; PHP tests will skip. >&2
+ echo Set PHP_FPM=C:\path\to\php-fpm to run them. >&2
+ )
+)
+
+set "HAS_PERL_FLAG=0"
+for %%A in (%*) do (
+ echo %%A | findstr /b /c:"--with-perl" >nul 2>&1 && set "HAS_PERL_FLAG=1"
+)
+
+if "%HAS_PERL_FLAG%"=="0" (
+ if defined APACHE_TEST_PERL (
+ set "AUTO_ARGS=!AUTO_ARGS! --with-perl=!APACHE_TEST_PERL!"
+ ) else (
+ echo runtests.bat: note: no perl found; rewrite prg-map tests may fail. >&2
+ echo Pass --with-perl=C:\path\to\perl.exe or set APACHE_TEST_PERL=... >&2
+ )
+)
+
+rem A stale mod_cgid socket in the classic suite's t\logs can break a fresh run.
+del /q "%HERE%\pytest_suite\t\logs\cgisock*" 2>nul
+
+rem --- Windows-specific exclusions --------------------------------------------
+set "WIN_IGNORE=--ignore=modules\md"
+set "WIN_SKIP=not test_800_websockets and not test_02_unix"
+
+rem --- run, from test\ ----------------------------------------------------------
+cd /d "%HERE%"
+echo runtests.bat: "%PYTEST%" %AUTO_ARGS% %WIN_IGNORE% -k "%WIN_SKIP%" %* >&2
+"%PYTEST%" %AUTO_ARGS% %WIN_IGNORE% -k "%WIN_SKIP%" %*
+exit /b %errorlevel%
diff -Nru apache2-2.4.69/test/runtests.sh apache2-2.4.71/test/runtests.sh
--- apache2-2.4.69/test/runtests.sh 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/runtests.sh 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,149 @@
+#!/bin/sh
+#
+# runtests.sh -- run the Python test suites: modules/ (HTTP/2, mod_md, HTTP/1,
+# proxy, core, ...) and pytest_suite/ (the classic Apache::Test port), in one
+# pytest session against one built httpd.
+#
+# It makes sure the venv exists and is current (via uv), puts the venv's bin/
+# first on PATH (CGI scripts forked by httpd must use the same Python and
+# packages as pytest), fills in --apxs and --php-fpm when they can be found,
+# and runs pytest from test/. Everything else goes straight to pytest.
+#
+# Usage:
+# ./runtests.sh # both suites, build found via APXS/config.ini/PATH
+# ./runtests.sh --apxs /path/to/apxs # a specific build
+# ./runtests.sh modules # only the modules/ suites
+# ./runtests.sh pytest_suite/tests # only the classic suite
+# ./runtests.sh modules/http2 -k 003 -v
+# ./runtests.sh -m "not slow" # markers and -k select across both
+#
+# A path relative to pytest_suite/ (tests/t/php, how the classic suite always
+# printed them) is accepted as well.
+#
+# Environment (each used when the matching --flag is not given):
+# APXS path to apxs (default: the build of this tree, named by the
+# configure-generated pyhttpd/config.ini or by
+# PYHTTPD_CONFIG; without one, the first 'apxs'
+# on PATH)
+# HTTPD path to httpd (when there is no apxs)
+# PHP_FPM path to php-fpm (default: first php-fpm on PATH; PHP tests skip if none)
+#
+set -eu
+
+here="$(cd "$(dirname "$0")" && pwd)"
+cd "$here"
+
+# --- ensure the venv exists and is current ----------------------------------
+# uv (https://docs.astral.sh/uv/) manages the dependencies: it reads
+# pyproject.toml + uv.lock, the single source for both suites. We call
+# .venv/bin/pytest directly rather than `uv run`, so this also works where
+# `uv run` is shimmed or unavailable.
+#
+# Create .venv on first run, and rebuild it when pyproject.toml or uv.lock is
+# newer than the venv (i.e. the dependencies changed).
+PYTEST="$here/.venv/bin/pytest"
+if [ ! -x "$PYTEST" ] || [ "$here/pyproject.toml" -nt "$here/.venv" ] \
+ || [ "$here/uv.lock" -nt "$here/.venv" ]; then
+ if ! command -v uv >/dev/null 2>&1; then
+ echo "runtests.sh: ERROR: 'uv' is required but not installed." >&2
+ echo " Install it from https://docs.astral.sh/uv/ and re-run." >&2
+ exit 1
+ fi
+ echo "runtests.sh: (re)creating $here/.venv via 'uv sync'..." >&2
+ uv sync --project "$here"
+ # Mark the venv as freshly built so the staleness check above won't
+ # retrigger until pyproject.toml or uv.lock changes again.
+ touch "$here/.venv"
+fi
+
+# CGI scripts forked by httpd resolve python3 to the venv's interpreter, and
+# any shim wrappers earlier on PATH are shadowed.
+export PATH="$here/.venv/bin:$PATH"
+
+# --- discover apxs / php-fpm ---------------------------------------------------
+discover() {
+ # discover cmd1 cmd2 ... -> echo the first one found on PATH
+ for c in "$@"; do
+ if command -v "$c" >/dev/null 2>&1; then command -v "$c"; return 0; fi
+ done
+ return 1
+}
+
+# A configured tree tests its own build: an apxs on PATH may well be another
+# httpd (a distribution's), so it is only looked for when there is none.
+config_ini="${PYHTTPD_CONFIG:-$here/pyhttpd/config.ini}"
+if [ -z "${APXS:-}" ] && [ -z "${HTTPD:-}" ] && [ ! -f "$config_ini" ]; then
+ APXS="$(discover apxs || true)"
+fi
+PHP_FPM="${PHP_FPM:-$(discover php-fpm php-fpm8.3 php-fpm83 php-fpm8.2 php-fpm82 || true)}"
+
+# Arguments: a path that does not exist relative to test/ but does relative to
+# pytest_suite/ is a classic-suite path. Everything else is passed on as is,
+# the values of options included (`-k tests` must stay `-k tests`).
+# (A ::nodeid suffix is not part of the path.)
+have_server=0
+have_php=0
+for arg in "$@"; do
+ case "$arg" in
+ --apxs|--apxs=*|--httpd|--httpd=*) have_server=1 ;;
+ --php-fpm|--php-fpm=*) have_php=1 ;;
+ esac
+done
+
+# Build the argument list in "$@" form, so that spaces survive.
+n=$#
+while [ "$n" -gt 0 ]; do
+ arg="$1"; shift; n=$((n - 1))
+ case "$arg" in
+ # `--apxs PATH` would make pytest take PATH for a test path and look
+ # for conftest.py there instead of here, so join option and value.
+ --apxs|--httpd|--conf|--php-fpm|--with-perl|--prefix)
+ if [ "$n" -gt 0 ]; then arg="$arg=$1"; shift; n=$((n - 1)); fi ;;
+ # options of pytest and of the suites that take a separate value:
+ # pass the value on untouched
+ -k|-m|-p|-o|-c|-W|-n|--tb|--deselect|--ignore|--ignore-glob|--rootdir|\
+ --basetemp|--junitxml|--junit-xml|--maxfail|--durations|--log-level|\
+ --log-file|--confcutdir|--import-mode|--color|--capture|--defines|\
+ --php-fpm-port|--base-port|--classic-elog-report|--archive|--repeat)
+ if [ "$n" -gt 0 ]; then
+ set -- "$@" "$arg"; arg="$1"; shift; n=$((n - 1))
+ fi ;;
+ -*) ;;
+ *) p="${arg%%::*}"
+ if [ ! -e "$p" ] && [ -e "pytest_suite/$p" ]; then
+ arg="pytest_suite/$arg"
+ fi
+ ;;
+ esac
+ set -- "$@" "$arg"
+done
+
+if [ "$have_server" = 0 ]; then
+ if [ -n "${APXS:-}" ]; then
+ set -- "--apxs=$APXS" "$@"
+ elif [ -n "${HTTPD:-}" ]; then
+ set -- "--httpd=$HTTPD" "$@"
+ elif [ -f "$config_ini" ]; then
+ echo "runtests.sh: testing the build of $config_ini" >&2
+ else
+ # the framework self-tests need none; the suites fail and say why
+ echo "runtests.sh: note: no httpd to test: no apxs on PATH and no" >&2
+ echo " $config_ini (configure the tree)." >&2
+ echo " Pass --apxs=/path/to/apxs, or set APXS=... or HTTPD=..." >&2
+ fi
+fi
+
+if [ "$have_php" = 0 ]; then
+ if [ -n "${PHP_FPM:-}" ]; then
+ set -- "--php-fpm=$PHP_FPM" "$@"
+ else
+ echo "runtests.sh: note: no php-fpm found; PHP tests will skip." >&2
+ echo " Set PHP_FPM=/path/to/php-fpm (any version) to run them." >&2
+ fi
+fi
+
+# A stale mod_cgid socket in the classic suite's t/logs can break a fresh run.
+rm -f "$here"/pytest_suite/t/logs/cgisock* 2>/dev/null || true
+
+echo "runtests.sh: $PYTEST $*" >&2
+exec "$PYTEST" "$@"
diff -Nru apache2-2.4.69/test/test_backport_marks.py apache2-2.4.71/test/test_backport_marks.py
--- apache2-2.4.69/test/test_backport_marks.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_backport_marks.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,122 @@
+"""Self-test for pyhttpd/backport.py and its use by pyhttpd.gating: which
+servers a not_backported mark expects a failure on, and what it turns into an
+expected failure there. Needs no built httpd."""
+from pathlib import Path
+
+import pytest
+
+from pyhttpd import backport, gating
+from pyhttpd.build import HttpdInfo
+
+
+@pytest.mark.parametrize("server,since,expected", [
+ ("2.4.70", None, False),
+ ("2.4.70-dev", None, False),
+ ("2.4.70", "2.4.71", False),
+ ("2.4.71-dev", "2.4.71", True),
+ ("2.4.71", "2.4.71", True),
+ ("2.4.72", "2.4.71", True),
+ # numeric, not textual, comparison
+ ("2.4.9", "2.4.10", False),
+ ("2.4.10", "2.4.9", True),
+ ("2.5.0", None, True),
+ ("2.5.1-dev", None, True),
+ ("2.5.1-dev", "2.4.71", True),
+])
+def test_has_change(server, since, expected):
+ assert backport.has_change(server, since) is expected
+
+
+def test_unparsable_version():
+ with pytest.raises(ValueError):
+ backport.has_change("trunk")
+
+
+def test_marks_record_their_arguments():
+ xfail = backport.not_backported("r1", "a change", backported_in="2.4.71",
+ platform="win32").mark
+ skip = backport.skip_not_backported("r1", "a change").mark
+ assert xfail.name == "not_backported"
+ assert xfail.args == ("r1", "a change")
+ assert xfail.kwargs == {"backported_in": "2.4.71", "platform": "win32"}
+ assert skip.name == "skip_not_backported"
+ assert skip.kwargs == {}
+
+
+@pytest.mark.parametrize("server,since,lacks", [
+ ("2.4.70", None, True),
+ ("2.4.70", "2.4.71", True),
+ ("2.4.71-dev", "2.4.71", False),
+ ("2.5.1-dev", None, False),
+ # the classic suite hands over a tuple
+ ((2, 4, 70), None, True),
+ ((2, 5, 1), None, False),
+])
+def test_lacks_change(server, since, lacks):
+ assert backport.lacks_change(server, since) is lacks
+
+
+@pytest.mark.parametrize("server,platform,expected", [
+ ("2.4.70", "win32", True),
+ ("2.4.70", "linux", False),
+ ("2.4.70", None, True),
+ ("2.5.1-dev", "win32", False),
+])
+def test_platform(monkeypatch, server, platform, expected):
+ monkeypatch.setattr(backport.sys, "platform", "win32")
+ assert backport.lacks_change(server, platform=platform) is expected
+
+
+_GATED = '''
+import pytest
+from pyhttpd.backport import not_backported
+
+@pytest.fixture
+def broken():
+ raise RuntimeError("the server did not start")
+
+@pytest.fixture
+def broken_teardown():
+ yield
+ raise RuntimeError("the error log check failed")
+
+@not_backported("r1", "a change")
+def test_setup_error(broken):
+ pass
+
+@not_backported("r1", "a change")
+def test_teardown_error(broken_teardown):
+ pass
+
+@not_backported("r1", "a change")
+def test_fails():
+ assert False
+
+@not_backported("r1", "a change")
+def test_passes():
+ pass
+'''
+
+
+@pytest.fixture
+def gated_run(pytester, monkeypatch):
+ """Run _GATED, with pyhttpd.gating, as if against an httpd of *version*."""
+ def run(version):
+ info = HttpdInfo(httpd=Path("httpd"), version=version, mpm="event")
+ monkeypatch.setattr(gating, "resolve_build", lambda config: info)
+ pytester.makepyfile(_GATED)
+ pytester.makeini("[pytest]\nmarkers =\n not_backported\n")
+ return pytester.runpytest_inprocess("-p", "pyhttpd.gating", "-p", "no:cacheprovider")
+ return run
+
+
+def test_not_backported_is_an_xfail_of_the_test_only(gated_run):
+ result = gated_run((2, 4, 70))
+ # a broken fixture is an error, not an expected failure (the body of
+ # test_teardown_error passes: XPASS, and an error)
+ result.assert_outcomes(errors=2, xfailed=1, xpassed=2)
+
+
+def test_not_backported_does_nothing_with_the_change(gated_run):
+ result = gated_run((2, 5, 1))
+ result.assert_outcomes(errors=2, failed=1, passed=2)
diff -Nru apache2-2.4.69/test/test_build.py apache2-2.4.71/test/test_build.py
--- apache2-2.4.69/test/test_build.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_build.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,72 @@
+"""Self-test for pyhttpd/build.py: one httpd under test for the whole session,
+whichever way it was named, and what is learned about it."""
+import os
+
+import pytest
+
+from pyhttpd.build import current, parse_loadmodules, resolve_build
+from pyhttpd.env import HttpdTestEnv
+
+
+@pytest.fixture
+def subprocess_run(pytester, monkeypatch):
+ monkeypatch.setenv("PYTHONPATH", os.path.dirname(os.path.abspath(__file__)))
+ pytester.makepyfile("def test_nothing():\n pass\n")
+
+ def run(*args):
+ return pytester.runpytest_subprocess("-p", "pyhttpd.plugin",
+ "-p", "no:cacheprovider", *args)
+ return run
+
+
+@pytest.mark.parametrize("option", ["--apxs", "--httpd"])
+def test_unusable_target_is_a_usage_error(subprocess_run, tmp_path, option):
+ # and not a run against the build of config.ini instead
+ result = subprocess_run(f"{option}={tmp_path / 'nonexistent'}")
+ assert result.ret == pytest.ExitCode.USAGE_ERROR
+ result.stderr.fnmatch_lines(["*httpd under test:*nonexistent*no such file*"])
+
+
+def test_the_session_has_one_target(pytestconfig):
+ info = resolve_build(pytestconfig)
+ if info is None:
+ pytest.skip("no httpd build to test against")
+ assert current() is info
+ # an environment created at import time, without the pytest config,
+ # tests that httpd too, not the one config.ini names
+ env = HttpdTestEnv()
+ assert os.path.realpath(os.path.join(env.bin_dir, "httpd")) == \
+ os.path.realpath(info.httpd)
+ assert env.get_httpd_version().startswith(info.version_str)
+ if info.source != "config.ini":
+ assert os.path.realpath(env.libexec_dir) == os.path.realpath(info.libexecdir)
+
+
+def test_static_modules_count_as_available(pytestconfig):
+ info = resolve_build(pytestconfig)
+ if info is None:
+ pytest.skip("no httpd build to test against")
+ # mod_so is always compiled in, never a DSO
+ assert "mod_so.c" in info.static_modules
+ assert HttpdTestEnv.has_shared_module("so")
+
+
+def test_loadmodule_lines(tmp_path):
+ (tmp_path / "conf.d").mkdir()
+ (tmp_path / "httpd.conf").write_text(
+ 'LoadModule a_module modules/mod_a.so\n'
+ '#LoadModule b_module modules/mod_b.so\n'
+ 'LoadModule c_module "/opt/my modules/mod_c.so"\n'
+ 'IncludeOptional conf.d/*.conf\n'
+ 'Include "conf.d/missing.conf"\n')
+ (tmp_path / "conf.d" / "d.conf").write_text(
+ 'loadmodule d_module modules/mod_d.so\n'
+ 'Include httpd.conf\n') # a loop is followed once
+ found = {(d.symbol, d.so, d.active)
+ for d in parse_loadmodules(tmp_path / "httpd.conf", tmp_path)}
+ assert found == {
+ ("a_module", str(tmp_path / "modules/mod_a.so"), True),
+ ("b_module", str(tmp_path / "modules/mod_b.so"), False),
+ ("c_module", "/opt/my modules/mod_c.so", True),
+ ("d_module", str(tmp_path / "modules/mod_d.so"), True),
+ }
diff -Nru apache2-2.4.69/test/test_classic_errorlog.py apache2-2.4.71/test/test_classic_errorlog.py
--- apache2-2.4.69/test/test_classic_errorlog.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_classic_errorlog.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,100 @@
+"""Self-test for pyhttpd/classic_errorlog.py: what the shared server of the
+classic suite logs while it starts and stops counts, not only what it logs
+during the tests. Needs no built httpd: a fake 'framework' fixture writes
+the error log. Run in a subprocess, as the plugin keeps session state."""
+import os
+
+import pytest
+
+_SUITE = '''
+import pytest
+from pyhttpd.classic_errorlog import check_shutdown, check_startup
+
+LINE = "[Wed Oct 28 12:00:00.000000 2026] [{0}] [pid 1] {1}\\n"
+
+@pytest.fixture(scope="session")
+def config(tmp_path_factory):
+ class Config:
+ vars = {"t_logs": str(tmp_path_factory.mktemp("logs"))}
+ open(Config.vars["t_logs"] + "/error_log", "w").close()
+ return Config
+
+def log(config, level, msg):
+ with open(config.vars["t_logs"] + "/error_log", "a") as fd:
+ fd.write(LINE.format(level, msg))
+
+@pytest.fixture(scope="session")
+def framework(request, config):
+ log(config, "mpm_event:notice", "AH00489: configured -- resuming normal operations")
+ if "{start}":
+ log(config, "ssl:warn", "AH01909: server certificate does not match")
+ if "{start_error}":
+ log(config, "md:error", "AH10046: setup store failed")
+ check_startup(request.config, config.vars["t_logs"] + "/error_log", 0)
+ yield config, None
+ if "{stop}":
+ log(config, "core:notice", "AH00052: child pid 2 exit signal Segmentation fault (11)")
+ check_shutdown(request.config)
+
+def test_one(framework):
+ pass
+'''
+
+
+@pytest.fixture
+def run(pytester, monkeypatch):
+ monkeypatch.setenv("PYTHONPATH", os.path.dirname(os.path.abspath(__file__)))
+
+ def run(start="", start_error="", stop=""):
+ pytester.makepyfile(_SUITE.replace("{start}", start)
+ .replace("{start_error}", start_error)
+ .replace("{stop}", stop))
+ return pytester.runpytest_subprocess("-p", "pyhttpd.classic_errorlog",
+ "-p", "no:cacheprovider")
+ return run
+
+
+def test_clean_start_and_stop_pass(run):
+ run().assert_outcomes(passed=1)
+
+
+def test_crash_while_stopping_fails(run):
+ result = run(stop="yes")
+ result.assert_outcomes(passed=1, errors=1)
+ result.stdout.fnmatch_lines(["*during the shutdown of the server*",
+ "*exit signal Segmentation fault*"])
+
+
+def test_error_while_starting_fails(run):
+ result = run(start_error="yes")
+ result.assert_outcomes(errors=1)
+ result.stdout.fnmatch_lines(["*during the start of the server*", "*AH10046*"])
+
+
+def test_warning_while_starting_passes(run):
+ # the shared t/conf makes httpd warn at every start
+ run(start="yes").assert_outcomes(passed=1)
+
+
+@pytest.mark.parametrize("filename,logno,errors", [
+ ("test_ldap", "AH01617", 0),
+ ("test_ldap", "AH01631", 0),
+ ("test_ldap", "AH99999", 1),
+ ("test_unrelated", "AH01617", 1),
+ ("test_unrelated", "AH01631", 1),
+])
+def test_ldap_expected_errors_are_file_scoped(pytester, monkeypatch,
+ filename, logno, errors):
+ monkeypatch.setenv("PYTHONPATH", os.path.dirname(os.path.abspath(__file__)))
+ pytester.makeconftest(_SUITE.split("def test_one")[0]
+ .replace("{start}", "")
+ .replace("{start_error}", "")
+ .replace("{stop}", ""))
+ pytester.makepyfile(**{filename: f'''
+def test_auth_failure(framework, config):
+ with open(config.vars["t_logs"] + "/error_log", "a") as fd:
+ fd.write("[auth_basic:error] [pid 1] {logno}: authentication failure\\n")
+'''})
+ result = pytester.runpytest_subprocess("-p", "pyhttpd.classic_errorlog",
+ "-p", "no:cacheprovider")
+ result.assert_outcomes(passed=1, errors=errors)
diff -Nru apache2-2.4.69/test/test_cmodule_header.py apache2-2.4.71/test/test_cmodule_header.py
--- apache2-2.4.69/test/test_cmodule_header.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_cmodule_header.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,17 @@
+"""CMake's header generator must work without pytest or test dependencies."""
+import subprocess
+import sys
+from pathlib import Path
+
+
+def test_header_generator_standalone(tmp_path):
+ script = Path(__file__).parent / "pytest_suite" / "gen_test_header.py"
+ dest = tmp_path / "apache_httpd_test.h"
+ # -I ignores PYTHONPATH; -S excludes site-packages. This is the direct
+ # invocation used by CMake, from outside the test project.
+ result = subprocess.run([sys.executable, "-I", "-S", str(script), str(dest)],
+ cwd=tmp_path, capture_output=True, text=True, check=False)
+ assert result.returncode == 0, result.stderr
+ header = dest.read_text()
+ assert "#define APACHE_HTTPD_TEST_MODULE(name)" in header
+ assert "#define APACHE_HTTPD_TEST_HANDLER NULL" in header
diff -Nru apache2-2.4.69/test/test_config_off_t_marks.py apache2-2.4.71/test/test_config_off_t_marks.py
--- apache2-2.4.69/test/test_config_off_t_marks.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_config_off_t_marks.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,20 @@
+"""Parameter-level expectations must cover the no-digits cache parsing."""
+import pytest
+from pyhttpd import backport
+
+from modules.core import test_012_config_off_t as off_t
+
+
+@pytest.mark.parametrize("directive", [d for d in off_t.DIRECTIVES
+ if d.startswith("Cache")])
+@pytest.mark.parametrize("value", ["abc", "abc123", "k", "+", "-"])
+def test_cache_no_digits_expectations(directive, value):
+ marks = next(m.args[1] for m in
+ off_t.TestConfigOffT.test_core_012_002.pytestmark
+ if m.name == "parametrize")
+ case = next(c for c in marks if c.values == (directive, value))
+ assert len(case.marks) == 1
+ mark = case.marks[0].mark
+ assert mark.name == "not_backported"
+ assert backport.lacks_change("2.4.70", **mark.kwargs)
+ assert not backport.lacks_change("2.5.1", **mark.kwargs)
diff -Nru apache2-2.4.69/test/test_instance.py apache2-2.4.71/test/test_instance.py
--- apache2-2.4.69/test/test_instance.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_instance.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,180 @@
+"""Self-test for pyhttpd/instance.py: starting, stopping and restarting a bare
+httpd. Needs a built httpd (--apxs, --httpd or the build's config.ini), but no
+module beyond the MPM, and uses a free port and a temporary directory."""
+import os
+import subprocess
+import time
+from pathlib import Path
+
+import pytest
+
+from pyhttpd.build import resolve_build
+from pyhttpd.instance import (HttpdInstance, HttpdStartError, free_port,
+ pid_alive, port_open, read_pid)
+
+
+@pytest.fixture(scope="module")
+def build(pytestconfig):
+ info = resolve_build(pytestconfig)
+ if info is None or info.prefix is None:
+ pytest.skip("no httpd build to test against")
+ return info
+
+
+def _module_lines(build):
+ """LoadModule lines for what a bare server needs, for the modules that
+ are not compiled in."""
+ static = subprocess.run([str(build.httpd), "-l"], capture_output=True,
+ text=True).stdout
+ mpm = os.environ.get("MPM", "event")
+ lines = []
+ for name in (f"mpm_{mpm}", "unixd", "authz_core"):
+ so = Path(build.prefix) / "modules" / f"mod_{name}.so"
+ if f"mod_{name}.c" in static:
+ continue
+ if so.is_file():
+ lines.append(f"LoadModule {name}_module {so}")
+ return lines
+
+
+@pytest.fixture
+def server(build, tmp_path):
+ port = free_port()
+ conf = tmp_path / "httpd.conf"
+ conf.write_text("\n".join([
+ f'ServerRoot "{tmp_path}"',
+ "ServerName localhost",
+ f"Listen 127.0.0.1:{port}",
+ f'PidFile "{tmp_path}/httpd.pid"',
+ f'ErrorLog "{tmp_path}/error_log"',
+ "LogLevel warn",
+ *_module_lines(build),
+ "StartServers 1",
+ "ThreadsPerChild 5",
+ ""]))
+ inst = HttpdInstance(build.httpd, ["-f", str(conf)], ports=[port],
+ error_log=tmp_path / "error_log",
+ pid_files=[tmp_path / "httpd.pid"],
+ stderr_file=tmp_path / "stderr")
+ yield inst, tmp_path, conf
+ inst.stop()
+
+
+def _children(parent):
+ out = subprocess.run(["ps", "-A", "-o", "pid=,ppid="], capture_output=True,
+ text=True).stdout
+ return {int(a) for a, b in (ln.split() for ln in out.splitlines() if ln.strip())
+ if int(b) == parent}
+
+
+def test_start_stop(server):
+ inst, _, _ = server
+ assert not inst.is_running() and inst.pid == 0
+ inst.start()
+ assert inst.is_running() and inst.pid > 0
+ assert inst.all_ports_open()
+ pid = inst.pid
+ assert inst.stop() is True
+ assert not pid_alive(pid)
+ assert not port_open(inst.ports[0])
+
+
+def test_stop_is_idempotent(server):
+ inst, _, _ = server
+ assert inst.stop() is True # never started
+ inst.start()
+ assert inst.stop() is True
+ assert inst.stop() is True # already stopped
+
+
+def test_stop_reaps_children(server):
+ inst, _, _ = server
+ inst.start()
+ children = _children(inst.pid)
+ assert children, "the MPM has started no children"
+ inst.stop()
+ assert not any(pid_alive(c) for c in children)
+
+
+def test_bad_config_reports_the_error(server):
+ inst, _, conf = server
+ conf.write_text(conf.read_text() + "\nNoSuchDirective on\n")
+ with pytest.raises(HttpdStartError) as e:
+ inst.start()
+ assert "NoSuchDirective" in e.value.stderr
+ assert e.value.returncode != 0
+ assert not inst.is_running()
+ assert not port_open(inst.ports[0])
+
+
+def test_bad_config_found_by_configtest_first(server):
+ inst, _, conf = server
+ inst.configtest_first = True
+ conf.write_text(conf.read_text() + "\nNoSuchDirective on\n")
+ with pytest.raises(HttpdStartError) as e:
+ inst.start()
+ assert "NoSuchDirective" in e.value.stderr
+
+
+def test_start_replaces_a_stale_server(server):
+ inst, tmp_path, _ = server
+ inst.start()
+ stale = inst.pid
+ # a server from an earlier run: known only by its pid file
+ inst.proc.poll()
+ other = HttpdInstance(inst.httpd, inst.args, ports=inst.ports,
+ error_log=tmp_path / "error_log",
+ pid_files=[tmp_path / "httpd.pid"],
+ stderr_file=tmp_path / "stderr2")
+ assert read_pid(tmp_path / "httpd.pid") == stale
+ other.start()
+ try:
+ # a real stale server is somebody else's child, reaped by init;
+ # this one is ours and is a zombie until we wait for it
+ assert inst.proc.wait(timeout=10) is not None
+ assert not pid_alive(stale)
+ assert other.is_running()
+ finally:
+ other.stop()
+ inst.proc = None
+
+
+def test_graceful_restart_keeps_the_parent(server):
+ inst, _, _ = server
+ inst.start()
+ parent, children = inst.pid, _children(inst.pid)
+ assert inst.restart(graceful=True)
+ assert inst.pid == parent and inst.all_ports_open()
+ deadline = time.monotonic() + 10
+ while time.monotonic() < deadline and children & _children(parent):
+ time.sleep(0.1) # the old generation drains
+ assert not (children & _children(parent))
+
+
+def test_hard_restart_serves_again(server):
+ inst, tmp_path, _ = server
+ inst.start()
+ before = (tmp_path / "error_log").read_text().count("resuming normal operations")
+ assert inst.restart(graceful=False)
+ after = (tmp_path / "error_log").read_text().count("resuming normal operations")
+ assert after == before + 1
+ assert inst.all_ports_open()
+
+
+def test_restart_without_server(server):
+ inst, _, _ = server
+ assert inst.restart() is False
+
+
+def test_graceful_stop(server):
+ inst, _, _ = server
+ inst.start()
+ pid = inst.pid
+ assert inst.graceful_stop() is True
+ assert not pid_alive(pid)
+
+
+def test_stderr_of_a_good_start_is_available(server):
+ inst, _, _ = server
+ inst.start()
+ assert isinstance(inst.stderr_text(), str)
diff -Nru apache2-2.4.69/test/test_md_renewal.py apache2-2.4.71/test/test_md_renewal.py
--- apache2-2.4.69/test/test_md_renewal.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_md_renewal.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,115 @@
+"""Renewal failures keep their diagnostics without hiding test failures."""
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from modules.md.md_env import MDTestEnv
+from pyhttpd.log import HttpdErrorLog
+
+from modules.md import test_702_auto as auto
+
+
+def test_renewal_failure_preserves_diagnostics(tmp_path):
+ env = MDTestEnv.__new__(MDTestEnv)
+ env._gen_dir = str(tmp_path)
+ env._server_dir = str(tmp_path / "apache")
+ env._store_dir = str(tmp_path / "store")
+ env.set_current_test_name("test_renewal[http/01]")
+ logs = Path(env.server_dir) / "logs"
+ logs.mkdir(parents=True)
+ (logs / "error_log").write_text("renewal error\n")
+ env._error_log = HttpdErrorLog(str(logs / "error_log"))
+ conf = Path(env.server_dir) / "conf"
+ conf.mkdir()
+ (conf / "httpd.conf").write_text("MDPortMap 80:12345\n")
+ (tmp_path / "pebble.log").write_text("challenge failed\n")
+ staging = Path(env.store_stagings()) / "example.org"
+ staging.mkdir(parents=True)
+ (staging / "job.json").write_text('{"errors": 1}')
+ (staging / "privkey.pem").write_text("private key -- must not be copied")
+ statuses = {"example.org": {"renewal": {"errors": 1}}}
+
+ env.save_renewal_failure(["example.org"], statuses)
+ env.httpd_error_log.clear_log()
+ env.clear_store()
+
+ dest = tmp_path / "failed-tests" / "test_renewal_http_01_"
+ assert (dest / "error_log").read_text() == "renewal error\n"
+ assert (dest / "pebble.log").read_text() == "challenge failed\n"
+ assert json.loads((dest / "md-status.json").read_text()) == statuses
+ assert json.loads((dest / "example.org-job.json").read_text()) == {"errors": 1}
+ assert not list(dest.rglob("*.pem"))
+
+
+def test_await_completion_keeps_failure_status(monkeypatch):
+ calls = []
+ status = {"renewal": {"errors": 1}}
+ env = SimpleNamespace(
+ get_md_status=lambda *args, **kwargs: status,
+ save_renewal_failure=lambda names, statuses: calls.append((names, statuses)),
+ )
+ times = iter([0, 0, 60])
+ monkeypatch.setattr("modules.md.md_env.time.time", lambda: next(times))
+ monkeypatch.setattr("modules.md.md_env.time.sleep", lambda duration: None)
+ assert not MDTestEnv.await_completion(env, ["example.org"])
+ assert calls == [(["example.org"], {"example.org": status})]
+
+
+def test_await_completion_preserves_client_failure(monkeypatch):
+ calls = []
+
+ def get_md_status(*args, **kwargs):
+ raise AssertionError("curl failed")
+
+ env = SimpleNamespace(
+ get_md_status=get_md_status,
+ save_renewal_failure=lambda names, statuses: calls.append((names, statuses)),
+ )
+ with pytest.raises(AssertionError, match="curl failed"):
+ MDTestEnv.await_completion(env, ["example.org"])
+ assert calls == [(["example.org"], {})]
+
+
+def test_store_reset_stops_the_previous_server():
+ calls = []
+ env = SimpleNamespace(
+ apache_stop=lambda: calls.append("stop") or 0,
+ clear_store=lambda: calls.append("clear"),
+ get_request_domain=lambda request: "example.org",
+ )
+ test = auto.TestAutov2()
+ auto.TestAutov2._method_scope.__wrapped__(test, env, None)
+ assert calls == ["stop", "clear"]
+
+
+@pytest.mark.parametrize("method", ["test_md_702_010", "test_md_702_011"])
+def test_expected_challenge_errors_handled_before_completion_failure(monkeypatch, method):
+ calls = []
+
+ class Conf:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __getattr__(self, name):
+ return lambda *args: None
+
+ def await_completion(names):
+ assert calls == ["ignored"]
+ return False
+
+ monkeypatch.setattr(auto, "MDConf", Conf)
+ env = SimpleNamespace(
+ http_port=5002, https_port=5001, apachectl_stderr="",
+ apache_restart=lambda: 0,
+ await_error=lambda domain: {"renewal": {"errors": 1}},
+ check_md=lambda domains: None,
+ await_completion=await_completion,
+ httpd_error_log=SimpleNamespace(
+ ignore_recent=lambda **kwargs: calls.append("ignored")),
+ )
+ test = auto.TestAutov2()
+ test.test_domain = "example.org"
+ with pytest.raises(AssertionError):
+ getattr(test, method)(env)
+ assert calls == ["ignored"]
diff -Nru apache2-2.4.69/test/test_strict_optional.py apache2-2.4.71/test/test_strict_optional.py
--- apache2-2.4.69/test/test_strict_optional.py 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/test_strict_optional.py 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,308 @@
+"""Self-test for the optional-dependency gating in pyhttpd/depends.py.
+
+Runs a synthetic test file in a pytester subprocess, with and without
+--strict-optional, and asserts skipped-vs-failed outcomes. The subprocess
+loads the real plugin with '-p pyhttpd.depends' and nothing else, so this
+needs no built httpd, no config.ini and no network.
+"""
+import os
+
+import pytest
+
+from pyhttpd.env import HttpdTestEnv
+
+TEST_DIR = os.path.dirname(os.path.abspath(__file__))
+
+pytest_plugins = ["pytester"]
+
+
+class _StubErrorLog:
+ """Enough of HttpdErrorLog for the autouse _package_scope fixture."""
+
+ def clear_ignored_matches(self):
+ pass
+
+ def clear_ignored_lognos(self):
+ pass
+
+ def add_ignored_lognos(self, lognos):
+ pass
+
+
+class _StubEnv:
+ """Satisfies the autouse fixtures in test/conftest.py.
+
+ Those are written for a test that drives a real httpd; this module
+ drives a pytester subprocess instead, so every one of them is a no-op
+ here. apache_stop() must report success, _package_scope asserts on it.
+ """
+
+ httpd_error_log = _StubErrorLog()
+
+ def set_current_test_name(self, name):
+ pass
+
+ def check_error_log(self):
+ pass
+
+ def apache_stop(self):
+ return 0
+
+
+@pytest.fixture(scope="package")
+def env():
+ return _StubEnv()
+
+
+SYNTHETIC = """
+import pytest
+from pyhttpd.depends import needs_dependency
+
+# dependency missing: skip by default, fail when strict
+@needs_dependency("frobnicator", False,
+ reason="frobnicator >= 2.0 not installed",
+ detected="frobnicator 1.0 at /usr/bin/frobnicator")
+def test_dep_missing():
+ assert False, "must never run"
+
+# dependency present: always runs
+@needs_dependency("widget", True, reason="widget not installed")
+def test_dep_present():
+ assert True
+
+# not applicable: skips in both modes
+@pytest.mark.skipif(True, reason="httpd too old")
+def test_not_applicable():
+ assert False, "must never run"
+
+# both kinds of gate on one test: "not applicable" wins, even when strict
+@pytest.mark.skipif(True, reason="STRESS_TEST not set in env")
+@needs_dependency("frobnicator", False, reason="frobnicator not installed")
+def test_not_applicable_beats_missing_dep():
+ assert False, "must never run"
+
+# an inactive skipif must not shield a missing dependency
+@pytest.mark.skipif(False, reason="httpd too old")
+@needs_dependency("frobnicator", False, reason="frobnicator not installed")
+def test_inactive_skipif_does_not_shield():
+ assert False, "must never run"
+
+# two dependencies missing at once, both should be reported
+@needs_dependency("OCSP responder", False, reason="no OCSP responder")
+@needs_dependency("mod_ssl", False, reason="no mod_ssl available")
+class TestStacked:
+ def test_stacked(self):
+ assert False, "must never run"
+
+# a class-level marker must reach the method
+@needs_dependency("a2md", False, reason="no a2md available")
+class TestClassLevel:
+ def test_class_level(self):
+ assert False, "must never run"
+"""
+
+MODULE_LEVEL = """
+import pytest
+from pyhttpd.depends import needs_dependency
+
+# module-level pytestmark, which a per-decorator scan would not see at all
+pytestmark = [needs_dependency("nghttp", False, reason="nghttp not available")]
+
+def test_module_gated():
+ assert False, "must never run"
+"""
+
+LAZY = """
+from pyhttpd.depends import needs_dependency
+
+CALLS = []
+
+def probe():
+ CALLS.append(1)
+ return False
+
+@needs_dependency("lazy", probe, reason="lazy dep missing")
+def test_lazy():
+ assert False, "must never run"
+
+def test_probe_ran_once():
+ assert CALLS == [1]
+"""
+
+# test_dep_present is the only one that runs; of the rest, test_dep_missing,
+# TestStacked and TestClassLevel are dependency gates and the other three
+# are "not applicable" or shielded.
+ALL_SKIPPED = dict(passed=1, skipped=6)
+STRICT_SPLIT = dict(passed=1, skipped=2, errors=4)
+
+
+@pytest.fixture
+def synth(pytester, monkeypatch):
+ # never inherit strict mode from the outer run
+ monkeypatch.delenv("PYHTTPD_STRICT_OPTIONAL", raising=False)
+ # let the subprocess import pyhttpd.depends
+ monkeypatch.setenv("PYTHONPATH", os.pathsep.join(
+ [TEST_DIR, os.environ.get("PYTHONPATH", "")]).rstrip(os.pathsep))
+ pytester.makepyfile(test_synthetic=SYNTHETIC, test_modlevel=MODULE_LEVEL,
+ test_lazy=LAZY)
+ return pytester
+
+
+def _run(pytester, *args):
+ return pytester.runpytest_subprocess("-p", "pyhttpd.depends",
+ "test_synthetic.py", *args)
+
+
+def test_flag_is_advertised(synth):
+ r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "--help")
+ r.stdout.fnmatch_lines(["*--strict-optional*"])
+
+
+def test_default_is_off_and_missing_dep_skips(synth):
+ r = _run(synth)
+ r.assert_outcomes(**ALL_SKIPPED)
+ assert r.ret == 0
+
+
+def test_default_skip_keeps_the_original_reason(synth):
+ r = _run(synth, "-rs")
+ r.stdout.fnmatch_lines(["*frobnicator >= 2.0 not installed*"])
+ r.stdout.fnmatch_lines(["*no a2md available*"])
+
+
+def test_strict_flag_fails_on_missing_dep(synth):
+ r = _run(synth, "--strict-optional")
+ r.assert_outcomes(**STRICT_SPLIT)
+ assert r.ret != 0
+ r.stdout.fnmatch_lines([
+ "*[[]strict-optional[]] missing optional dependency: frobnicator*",
+ "*reason:*frobnicator >= 2.0 not installed*",
+ "*detected: frobnicator 1.0 at /usr/bin/frobnicator*",
+ "*drop*--strict-optional*",
+ ])
+
+
+def test_strict_reports_every_missing_dep(synth):
+ r = _run(synth, "--strict-optional")
+ r.stdout.fnmatch_lines(["*missing optional dependency: OCSP responder*"])
+ r.stdout.fnmatch_lines(["*missing optional dependency: mod_ssl*"])
+
+
+def test_not_applicable_still_skips_when_strict(synth):
+ r = _run(synth, "--strict-optional", "-rs")
+ # a plain skipif carrying no dependency marker at all
+ r.stdout.fnmatch_lines(["*httpd too old*"])
+ # and one stacked on a missing dependency: the skipif wins, and the
+ # reason reported is its own, not the dependency's
+ r.stdout.fnmatch_lines(["*STRESS_TEST not set in env*"])
+
+
+def test_env_var_enables_strict(synth, monkeypatch):
+ monkeypatch.setenv("PYHTTPD_STRICT_OPTIONAL", "1")
+ r = _run(synth)
+ r.assert_outcomes(**STRICT_SPLIT)
+ assert r.ret != 0
+
+
+@pytest.mark.parametrize("val", ["", "0", "false", "no", "off"])
+def test_env_var_falsey_values_keep_strict_off(synth, monkeypatch, val):
+ monkeypatch.setenv("PYHTTPD_STRICT_OPTIONAL", val)
+ r = _run(synth)
+ r.assert_outcomes(**ALL_SKIPPED)
+ assert r.ret == 0
+
+
+def test_module_level_pytestmark_is_honoured(synth):
+ r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "test_modlevel.py")
+ r.assert_outcomes(skipped=1)
+ r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "test_modlevel.py",
+ "--strict-optional")
+ r.assert_outcomes(errors=1)
+
+
+def test_callable_available_is_evaluated_once_at_collection(synth):
+ r = synth.runpytest_subprocess("-p", "pyhttpd.depends", "test_lazy.py")
+ r.assert_outcomes(passed=1, skipped=1)
+
+
+# --- env.require(), the in-test counterpart of the decorator ---------------
+#
+# Exercised against a stand-in rather than a real HttpdTestEnv, which would
+# need a built httpd and a running server just to reach the one branch that
+# matters here.
+
+class _RequireEnv:
+ require = HttpdTestEnv.require
+
+ def __init__(self, strict):
+ self._strict_optional = strict
+ self.current_test_name = "unit::require"
+
+
+def test_require_is_a_noop_when_the_dependency_is_present():
+ _RequireEnv(strict=False).require(True, "need curl")
+ _RequireEnv(strict=True).require(True, "need curl")
+
+
+def test_require_skips_when_not_strict():
+ with pytest.raises(BaseException) as exc:
+ _RequireEnv(strict=False).require(
+ False, "need at least curl v8.0.0 for this",
+ dep="curl", detected="curl 7.88.1")
+ assert exc.value.__class__.__name__ == "Skipped"
+ assert "need at least curl v8.0.0 for this" in str(exc.value.args[0])
+
+
+def test_require_reports_the_dependency_name_not_the_reason():
+ """dep is what is missing; reason is why the test wanted it."""
+ with pytest.raises(BaseException) as exc:
+ _RequireEnv(strict=True).require(False, "no mod_ssl available")
+ # no dep= given, so the reason stands in for the name
+ assert "missing optional dependency: no mod_ssl available" in str(exc.value.args[0])
+
+
+# --- the tool probes -------------------------------------------------------
+#
+# config.ini always names curl/nghttp/h2load, so the name alone says nothing
+# about whether the tool is installed. Probing used to raise FileNotFoundError
+# out of collection; it must report "absent" instead.
+
+class _ProbeEnv:
+ """Drives the curl probe without a config.ini or a built httpd."""
+
+ _run_version = staticmethod(HttpdTestEnv._run_version)
+ _versiontuple = HttpdTestEnv._versiontuple
+ _probe_curl = HttpdTestEnv._probe_curl
+ has_curl = HttpdTestEnv.has_curl
+ curl_version_str = HttpdTestEnv.curl_version_str
+ curl_is_at_least = HttpdTestEnv.curl_is_at_least
+ curl_is_less_than = HttpdTestEnv.curl_is_less_than
+ curl_is_8_1_x = HttpdTestEnv.curl_is_8_1_x
+
+ def __init__(self, curl):
+ self._curl = curl
+ self._curl_version = None
+
+
+@pytest.mark.parametrize("curl", ["/nonexistent/curl", "curl-does-not-exist", ""])
+def test_absent_curl_probes_as_missing_rather_than_raising(curl):
+ env = _ProbeEnv(curl)
+ assert env.has_curl() is False
+ assert env.curl_version_str() is None
+ # every comparison answers False rather than blowing up collection
+ assert env.curl_is_at_least('8.0.0') is False
+ assert env.curl_is_less_than('8.0.0') is False
+ assert env.curl_is_8_1_x() is False
+
+
+def test_require_fails_when_strict_and_names_the_dependency():
+ with pytest.raises(BaseException) as exc:
+ _RequireEnv(strict=True).require(
+ False, "need at least curl v8.0.0 for this",
+ dep="curl", detected="curl 7.88.1")
+ assert exc.value.__class__.__name__ == "Failed"
+ msg = str(exc.value.args[0])
+ assert "[strict-optional] missing optional dependency: curl" in msg
+ assert "reason: need at least curl v8.0.0 for this" in msg
+ assert "detected: curl 7.88.1" in msg
+ assert "unit::require" in msg
diff -Nru apache2-2.4.69/test/test_travis_conditions.sh apache2-2.4.71/test/test_travis_conditions.sh
--- apache2-2.4.69/test/test_travis_conditions.sh 2022-03-07 11:07:32.000000000 +0000
+++ apache2-2.4.71/test/test_travis_conditions.sh 2026-11-09 03:07:05.000000000 +0000
@@ -33,7 +33,6 @@
testit '{"tag": "2.4.49"}' 2.4.x
testit '{"tag": "2.5.59"}' trunk
testit '{"branch": "2.4.x"}' 2.4.x
-testit '{"branch": "candidate-2.4.49"}' 2.4.x
testit '{"branch": "2.4.55-candidate"}' 2.4.x
testit '{"branch": "2.4-backport-branch"}' 2.4.x
testit '{"branch": "2.4.x-something"}' 2.4.x
diff -Nru apache2-2.4.69/test/travis_Dockerfile_slapd.centos apache2-2.4.71/test/travis_Dockerfile_slapd.centos
--- apache2-2.4.69/test/travis_Dockerfile_slapd.centos 2025-01-31 14:38:37.000000000 +0000
+++ apache2-2.4.71/test/travis_Dockerfile_slapd.centos 2026-11-09 03:07:05.000000000 +0000
@@ -1,5 +1,5 @@
FROM quay.io/centos/centos:stream9
RUN rpm -q openldap-servers || (dnf install --refresh -y epel-release && \
- dnf install --nobest -y openldap-clients openldap-servers openldap-devel && \
+ dnf install --refresh --nobest -y openldap-clients openldap-servers openldap-devel && \
dnf -y clean all --enablerepo='*')
CMD /usr/sbin/slapd -u ldap -d1 '-h ldap:// ldapi:///'
diff -Nru apache2-2.4.69/test/travis_before_linux.sh apache2-2.4.71/test/travis_before_linux.sh
--- apache2-2.4.69/test/travis_before_linux.sh 2026-06-15 10:04:24.000000000 +0000
+++ apache2-2.4.71/test/travis_before_linux.sh 2026-11-09 03:07:05.000000000 +0000
@@ -1,11 +1,13 @@
#!/bin/bash -xe
+# The 2.4.x workflow caches ~/root under a key which does not name what is
+# in it, and sets CLEAR_CACHE for the jobs which must start afresh.
if test -v CLEAR_CACHE; then
rm -rf $HOME/root
fi
-: Travis tag = ${TRAVIS_TAG}
-: Travis branch = ${TRAVIS_BRANCH}
+# The 2.4.x workflow does not set MFLAGS.
+: ${MFLAGS=-j2}
: /etc/hosts --
cat /etc/hosts
@@ -21,61 +23,86 @@
cat /etc/hosts
fi
+# Build and install $1 at version $2 into the cached
+# $HOME/root/$1-$2, configured with the arguments in $3 and bootstrapped
+# with ./buildconf $4, unless it was restored from the cache. The
+# source goes in $HOME/build, which is not cached; it is a git checkout
+# or, with TEST_APR_TARBALL, the release tarball.
+#
+# The cache key covers APR and APR-util together, along with the
+# configuration, $CC and, for a branch, the commit which
+# gha-resolve-deps.sh resolved it to, so an install restored here is
+# usable as-is. The build uses that same commit, since resolving the
+# branch again would race with it moving.
function install_apx() {
local name=$1
local version=$2
+ local config=$3
+ local buildconf=$4
local prefix=${HOME}/root/${name}-${version}
local build=${HOME}/build/${name}-${version}
local giturl=https://github.com/apache/${name}.git
- local config=$3
- local buildconf=$4
- local commit=tarball
-
- mkdir -p ${HOME}/build
+ local ref commit=tarball
if ! test -v TEST_APR_TARBALL; then
- case $version in
- trunk|*.x) ref=refs/heads/${version} ;;
- *) ref=refs/tags/${version} ;;
- esac
-
- # Fetch the object ID (hash) of latest commit
- commit=`git ls-remote ${giturl} ${ref} | cut -f1`
- if test -z "$commit"; then
- : Could not determine latest commit hash for ${ref} in ${giturl} - check branch is valid?
- exit 1
- fi
- fi
-
- # Blow away the cached install root if the cached install is stale
- # or doesn't match the expected configuration.
- grep -q "${version} ${commit} ${config} CC=$CC" ${HOME}/root/.key-${name} || rm -rf ${prefix}
+ case ${name}:${version} in
+ apr:trunk|apr:*.x) ref=${APR_COMMIT:-refs/heads/${version}} ;;
+ apr-util:trunk|apr-util:*.x) ref=${APU_COMMIT:-refs/heads/${version}} ;;
+ *) ref=refs/tags/${version} ;;
+ esac
+ fi
+
+ # Without $ROOTID (see gha-resolve-deps.sh) the cache key does not say
+ # what was built, so an install restored here may be stale or built
+ # otherwise: pin the ref to a commit, and rebuild unless that commit,
+ # the configuration and $CC are those the install was made from.
+ if ! test -v ROOTID; then
+ if ! test -v TEST_APR_TARBALL; then
+ commit=${ref}
+ if ! [[ ${commit} =~ ^[0-9a-f]{40}$ ]]; then
+ commit=`git ls-remote ${giturl} ${ref} | cut -f1`
+ if test -z "$commit"; then
+ : Could not determine latest commit hash for ${ref} in ${giturl} - check branch is valid?
+ exit 1
+ fi
+ ref=${commit}
+ fi
+ fi
+ grep -q "${version} ${commit} ${config} CC=$CC" ${HOME}/root/.key-${name} || rm -rf ${prefix}
+ fi
if test -d ${prefix}; then
return 0
fi
if test -v TEST_APR_TARBALL; then
- curl https://archive.apache.org/dist/apr/${name}-${version}.tar.gz > apx.tar.gz
- tar -C ${HOME}/build -xzf apx.tar.gz
- rm apx.tar.gz
- pushd ${build}
+ mkdir -p ${HOME}/build
+ curl https://archive.apache.org/dist/apr/${name}-${version}.tar.gz |
+ tar -C ${HOME}/build -xzf -
+ pushd ${build}
else
- git init -q ${build}
- pushd $build
- # Clone and checkout the commit identified above.
- git remote add origin ${giturl}
- git fetch -q --depth=1 origin ${commit}
- git checkout ${commit}
- ./buildconf ${buildconf}
+ mkdir -p ${build}
+ pushd ${build}
+ git init -q
+ git fetch -q --depth=1 ${giturl} ${ref}
+ git checkout -q FETCH_HEAD
+ ./buildconf ${buildconf}
fi
-
./configure --prefix=${prefix} ${config}
- make -j2
+ # glibc's C23 const-preserving strrchr() et al trip
+ # -Wdiscarded-qualifiers in APR 1.7.x.
+ case " ${NOTEST_CFLAGS-} " in
+ *" -Werror "*)
+ make $MFLAGS NOTEST_CFLAGS="${NOTEST_CFLAGS} -Wno-error=discarded-qualifiers" ;;
+ *)
+ make $MFLAGS ;;
+ esac
make install
popd
- echo ${version} ${commit} "${config}" "CC=${CC}" > ${HOME}/root/.key-${name}
+ if ! test -v ROOTID; then
+ echo ${version} ${commit} "${config}" "CC=${CC}" > ${HOME}/root/.key-${name}
+ fi
}
# Allow to load $HOME/build/apache/httpd/.gdbinit
@@ -101,10 +128,10 @@
: Purging cache since "perl -V" output has changed
rm -rf ~/perl5
fi
-
+
cpanm --local-lib=~/perl5 local::lib && eval $(perl -I ~/perl5/lib/perl5/ -Mlocal::lib)
- pkgs="Net::SSL LWP::Protocol::https \
+ pkgs="LWP::Protocol::https \
LWP::Protocol::AnyEvent::http ExtUtils::Embed Test::More \
AnyEvent DateTime HTTP::DAV FCGI \
AnyEvent::WebSocket::Client Apache::Test"
@@ -138,26 +165,35 @@
# Build the requested version of OpenSSL if it's not already installed
# in the cached ~/root
if test -v TEST_OPENSSL3; then
- # For a branch, rebuild if the remote branch has updated.
- if test -v TEST_OPENSSL3_BRANCH -a -f $HOME/root/openssl-is-${TEST_OPENSSL3}; then
- latest=`git ls-remote https://github.com/openssl/openssl refs/heads/${TEST_OPENSSL3_BRANCH} | cut -f1`
- : Got branch latest commit ${latest}
- if grep -q ^${latest} $HOME/root/openssl-is-${TEST_OPENSSL3}; then
- : Cached repos already at ${latest}
- else
- : Forcing rebuild
- rm -f $HOME/root/openssl-is-${TEST_OPENSSL3}
+ # The cache key covers $TEST_OPENSSL3, $OPENSSL_CONFIG and, for a
+ # branch build, the resolved commit, so an install found here is
+ # current. Without $ROOTID it does not, so the commit built is
+ # recorded, and a build of a branch is redone if the branch has moved.
+ if ! test -v ROOTID; then
+ if test -v TEST_OPENSSL3_BRANCH -a -f $HOME/root/openssl-is-${TEST_OPENSSL3}; then
+ latest=`git ls-remote https://github.com/openssl/openssl refs/heads/${TEST_OPENSSL3_BRANCH} | cut -f1`
+ : Got branch latest commit ${latest}
+ if grep -q ^${latest} $HOME/root/openssl-is-${TEST_OPENSSL3}; then
+ : Cached repos already at ${latest}
+ else
+ : Forcing rebuild
+ rm -f $HOME/root/openssl-is-${TEST_OPENSSL3}
+ fi
fi
+ test -f $HOME/root/openssl-is-${TEST_OPENSSL3} || rm -rf $HOME/root/openssl3
fi
- if ! test -f $HOME/root/openssl-is-${TEST_OPENSSL3}; then
- # Remove any previous install.
- rm -rf $HOME/root/openssl3
-
+ if ! test -d $HOME/root/openssl3; then
mkdir -p build/openssl
pushd build/openssl
if test -v TEST_OPENSSL3_BRANCH; then
git clone --depth=1 -b $TEST_OPENSSL3_BRANCH -q https://github.com/openssl/openssl openssl-${TEST_OPENSSL3}
+ # Build the commit named in the cache key, not whatever
+ # the branch tip has become since it was resolved.
+ if test -n "${OPENSSL_COMMIT-}"; then
+ git -C openssl-${TEST_OPENSSL3} fetch -q --depth=1 origin ${OPENSSL_COMMIT}
+ git -C openssl-${TEST_OPENSSL3} checkout -q ${OPENSSL_COMMIT}
+ fi
else
curl -L "https://github.com/openssl/openssl/releases/download/openssl-${TEST_OPENSSL3}/openssl-${TEST_OPENSSL3}.tar.gz" |
tar -xzf -
@@ -169,11 +205,13 @@
'-Wl,-rpath=$(LIBRPATH)'
make $MFLAGS
make install_sw
- if test -d .git; then
- : Caching git commit hash:
- git rev-parse HEAD | tee $HOME/root/openssl-is-${TEST_OPENSSL3}
- else
- touch $HOME/root/openssl-is-${TEST_OPENSSL3}
+ if ! test -v ROOTID; then
+ if test -d .git; then
+ : Caching git commit hash:
+ git rev-parse HEAD | tee $HOME/root/openssl-is-${TEST_OPENSSL3}
+ else
+ touch $HOME/root/openssl-is-${TEST_OPENSSL3}
+ fi
fi
popd
fi
@@ -189,6 +227,25 @@
fi
fi
+# Build the requested version of nghttp2 if it's not already installed
+# in the cached ~/root; the nghttp/h2load tools come from the package.
+if test -v TEST_NGHTTP2; then
+ if ! test -d $HOME/root/nghttp2; then
+ mkdir -p build/nghttp2
+ pushd build/nghttp2
+ curl -L "https://github.com/nghttp2/nghttp2/releases/download/v${TEST_NGHTTP2}/nghttp2-${TEST_NGHTTP2}.tar.xz" |
+ tar -xJf -
+ cd nghttp2-${TEST_NGHTTP2}
+ ./configure --prefix=$HOME/root/nghttp2 --enable-lib-only
+ make $MFLAGS
+ make install
+ popd
+ fi
+
+ : -- Using nghttp2 from $HOME/root/nghttp2 --
+ grep -H '^Version' $HOME/root/nghttp2/lib/pkgconfig/libnghttp2.pc
+fi
+
if test -v APR_VERSION; then
install_apx apr ${APR_VERSION} "${APR_CONFIG}"
ldd $HOME/root/apr-${APR_VERSION}/lib/libapr-?.so || true
diff -Nru apache2-2.4.69/test/travis_run_linux.sh apache2-2.4.71/test/travis_run_linux.sh
--- apache2-2.4.69/test/travis_run_linux.sh 2026-06-15 10:04:24.000000000 +0000
+++ apache2-2.4.71/test/travis_run_linux.sh 2026-11-09 03:07:05.000000000 +0000
@@ -1,5 +1,17 @@
#!/bin/bash -ex
+# Not every branch has every MPM: MotorZ is on trunk only, for now. A job
+# which configures a branch without it has nothing to build or test, so it
+# says so and succeeds; the same workflow can then be used on both branches.
+case "${CONFIG:-}" in
+ *--with-mpm=motorz*)
+ if ! test -d server/mpm/motorz; then
+ echo "::notice::This branch has no MotorZ MPM, nothing to do."
+ exit 0
+ fi
+ ;;
+esac
+
# Test for APLOGNO() macro errors (duplicates, empty args) etc. For
# trunk, run the updater script to see if it fails. If it succeeds
# and changes any files (because there was a missing argument), the
@@ -31,8 +43,8 @@
PREFIX=${PREFIX:-$HOME/build/httpd-root}
-# If perl-framework testing is required it is checked out here by
-# _before_linux.sh:
+# If perl-framework testing is required, the workflow checks it out
+# here:
if test -d test/perl-framework; then
CONFIG="$CONFIG --enable-load-all-modules"
if grep -q ^check: Makefile.in; then
@@ -55,8 +67,8 @@
fi
# Pick up the rustls install built previously.
-if test -v TEST_MOD_TLS -a RUSTLS_VERSION; then
- CONFIG="$CONFIG --with-tls --with-rustls=$HOME/root/rustls"
+if test -v TEST_MOD_TLS -a -v RUSTLS_VERSION; then
+ CONFIG="$CONFIG --with-tls --with-rustls=$HOME/root/rustls"
fi
if test -v TEST_OPENSSL3; then
@@ -67,6 +79,13 @@
openssl version
fi
+if test -v TEST_NGHTTP2; then
+ # APACHE_CHECK_NGHTTP2 only yields -L, so an RPATH is needed here or
+ # mod_http2.so resolves to the system libnghttp2 at run-time.
+ CONFIG="$CONFIG --with-nghttp2=$HOME/root/nghttp2"
+ export LDFLAGS="${LDFLAGS:-} -Wl,-rpath,$HOME/root/nghttp2/lib"
+fi
+
srcdir=$PWD
if test -v TEST_VPATH; then
@@ -74,9 +93,19 @@
cd ../vpath
fi
+builddir=$PWD
+
$srcdir/configure --prefix=$PREFIX $CONFIG
make $MFLAGS
+if test -v TEST_NGHTTP2; then
+ ldd modules/http2/.libs/mod_http2.so | grep --color=always nghttp2
+ if ! ldd modules/http2/.libs/mod_http2.so | grep -q "$HOME/root/nghttp2/lib"; then
+ : mod_http2 is not linked against the nghttp2 built from source
+ exit 1
+ fi
+fi
+
if test -v TEST_OPENSSL3; then
# Clear the library/run paths so that anything else run during
# testing is not forced to use the custom OpenSSL build; e.g. perl,
@@ -133,37 +162,62 @@
sudo sysctl -w kernel.core_pattern=core || true
ulimit -c unlimited 2>/dev/null || true
-if test -v WITH_TEST_SUITE; then
- make check TESTS="${TESTS}" TEST_CONFIG="${TEST_ARGS}"
- RV=$?
-else
- test -v TEST_INSTALL || make install
- pushd test/perl-framework
- perl Makefile.PL -apxs $PREFIX/bin/apxs
- make test APACHE_TEST_EXTRA_ARGS="${TEST_ARGS} ${TESTS}" | tee test.log
+if ! test -v NO_TEST_FRAMEWORK; then
+ # 2.4.x has no libcheck-based unit tests.
+ if grep -q httpdunit Makefile.in; then
+ : Running libcheck-based unit tests.
+ if ! prove -v ./test/httpdunit; then
+ RV=1
+ fi
+ fi
+
+ if test -v WITH_TEST_SUITE -a $RV -eq 0; then
+ make check-test-framework TESTS="${TESTS}" TEST_CONFIG="${TEST_ARGS}" | tee test.log
RV=${PIPESTATUS[0]}
# re-run failing tests with -v, avoiding set -e
if [ $RV -ne 0 ]; then
- #mv t/logs/error_log t/logs/error_log_save
+ # mv test/perl-framework/t/logs/error_log test/perl-framework/t/logs/error_log_save
FAILERS=""
while read FAILER; do
FAILERS="$FAILERS $FAILER"
done < <(awk '/Failed:/{print $1}' test.log)
if [ -n "$FAILERS" ]; then
- t/TEST -v $FAILERS || true
+ make check-test-framework TESTS="-v $FAILERS" || true
fi
# set -e would have killed us after the original t/TEST
rm -f test.log
- #mv t/logs/error_log_save t/logs/error_log
+ # mv test/perl-framework/t/logs/error_log_save test/perl-framework/t/logs/error_log
false
fi
- popd
-fi
+ else
+ test -v TEST_INSTALL || make install
+ pushd test/perl-framework
+ perl Makefile.PL -apxs $PREFIX/bin/apxs
+ make test APACHE_TEST_EXTRA_ARGS="${TEST_ARGS} ${TESTS}" | tee test.log
+ RV=${PIPESTATUS[0]}
+ # re-run failing tests with -v, avoiding set -e
+ if [ $RV -ne 0 ]; then
+ # mv t/logs/error_log t/logs/error_log_save
+ FAILERS=""
+ while read FAILER; do
+ FAILERS="$FAILERS $FAILER"
+ done < <(awk '/Failed:/{print $1}' test.log)
+ if [ -n "$FAILERS" ]; then
+ t/TEST -v $FAILERS || true
+ fi
+ # set -e would have killed us after the original t/TEST
+ rm -f test.log
+ # mv t/logs/error_log_save t/logs/error_log
+ false
+ fi
+ popd
+ fi
-# Skip further testing if a core dump was created during the test
-# suite run above.
-if test $RV -eq 0 && test -n "`ls test/perl-framework/t/core{,.*} 2>/dev/null`"; then
- RV=4
+ # Skip further testing if a core dump was created during the test
+ # suite run above.
+ if test $RV -eq 0 && test -n "`ls test/perl-framework/t/core{,.*} 2>/dev/null`"; then
+ RV=4
+ fi
fi
if test \( -v TEST_SSL -o -v TEST_OPENSSL3 \) \
@@ -179,9 +233,6 @@
./t/TEST -defines "TEST_SSL_DES3_KEY TEST_SSL_PASSPHRASE_EXEC" t/ssl
RV=$?
- # Log the OpenSSL version.
- grep 'mod_ssl.*compiled against' t/logs/error_log | tail -n 1
-
# Test various session cache backends
for cache in shmcb redis:localhost:6379 memcache:localhost:11211; do
test $RV -eq 0 || break
@@ -189,6 +240,13 @@
SSL_SESSCACHE=$cache ./t/TEST -sslproto TLSv1.2 -defines TEST_SSL_SESSCACHE -start
./t/TEST t/ssl
RV=$?
+ if test $RV -eq 0; then
+ # TODO: only really useful in e.g. triggering
+ # server segfaults which are caught later, doesn't
+ # directly catch non-200 responses etc.
+ $builddir/support/ab -qd -n 4000 -c 20 -f TLS1.2 https://localhost:8532/
+ RV=$?
+ fi
./t/TEST -stop
SRV=$?
if test $RV -eq 0 -a $SRV -ne 0; then
@@ -214,35 +272,33 @@
grep -v ':\(debug\|trace[12345678]\)\]' test/perl-framework/t/logs/error_log
fi
-if test -v TEST_CORE -a $RV -eq 0; then
- # Run HTTP/2 tests.
- MPM=event py.test-3 test/modules/core
- RV=$?
-fi
-
-if test -v TEST_H2 -a $RV -eq 0; then
- # Build the test clients
- (cd test/clients && make)
- # Run HTTP/2 tests.
- MPM=event py.test-3 test/modules/http2
- RV=$?
- if test $RV -eq 0; then
- MPM=worker py.test-3 test/modules/http2
- RV=$?
+if test -v TEST_PYTEST -a $RV -eq 0; then
+ # Run all available pytest-based test suites against this build, via
+ # the unified `make check-all-pytest` target: test/pytest_suite/ (the
+ # self-contained port of the classic Apache::Test suite, incl. PHP
+ # tests if PHP_FPM is set) and every test/modules/*/ suite (core,
+ # http1, http2, proxy, ssl, aaa, ...), in one pytest session. This
+ # replaces the old per-suite TEST_CORE / TEST_PROXY / TEST_H2 /
+ # TEST_MD flags, which each invoked py.test-3 directly against a
+ # `make install`ed tree; check-all-pytest instead builds and tests
+ # entirely from the in-tree check/ build, needing no install. This is
+ # the same on trunk and 2.4.x.
+ #
+ # Suites whose dependencies are missing skip themselves: modules/md
+ # without `pebble` on PATH (a job wanting md coverage must install
+ # pebble first, see README.ci), modules/http2 when mod_http2 wasn't
+ # built (e.g. the UBSan job's --disable-http2).
+ #
+ # A job can narrow this down with PYTEST_ARGS: test paths relative to
+ # test/ and any pytest options; see README.ci.
+ if test -v TEST_LDAP && ! test -v PYTEST_ARGS; then
+ # Only pytest_suite has LDAP coverage, and it needs the LDAP
+ # define to configure the mod_authnz_ldap vhosts. The other
+ # suites are covered by the pytest-specific jobs.
+ PYTEST_ARGS="--defines=LDAP pytest_suite/tests/t/modules/test_ldap.py"
fi
-fi
-
-if test -v TEST_MD -a $RV -eq 0; then
- # Run ACME tests.
- # need the go based pebble as ACME test server
- # which is a package on debian sid, but not on focal
- export GOPATH=${PREFIX}/gocode
- mkdir -p "${GOPATH}"
- export PATH="${GOROOT}/bin:${GOPATH}/bin:${PATH}"
- go get -u github.com/letsencrypt/pebble/...
- (cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./...)
-
- py.test-3 test/modules/md
+ (cd test/clients && make)
+ make check-all-pytest PYTEST_ARGS="${PYTEST_ARGS:-}"
RV=$?
fi
diff -Nru apache2-2.4.69/test/unit/authn.c apache2-2.4.71/test/unit/authn.c
--- apache2-2.4.69/test/unit/authn.c 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/unit/authn.c 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,155 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "../httpdunit.h"
+
+#include "httpd.h"
+#include "http_config.h"
+#include "http_protocol.h"
+
+/*
+ * Test Fixture -- runs once per test
+ */
+
+static apr_pool_t *g_pool;
+static request_rec *g_request;
+
+static void authn_setup(void)
+{
+ if (apr_pool_create(&g_pool, NULL) != APR_SUCCESS) {
+ exit(1);
+ }
+
+ /* Stub out just enough of a request_req to get the tests working.
+ * Unfortunately this couples us to implementation details in the code being
+ * tested, but the logic to get a "real" request_rec requires spinning up
+ * half of the world. */
+ g_request = apr_pcalloc(g_pool, sizeof(*g_request));
+ if (!g_request) {
+ exit(1);
+ }
+
+ g_request->pool = g_pool;
+ g_request->headers_in = apr_table_make(g_pool, 1);
+
+ if (!g_request->headers_in) {
+ exit(1);
+ }
+}
+
+static void authn_teardown(void)
+{
+ apr_pool_destroy(g_pool);
+}
+
+/*
+ * ap_get_basic_auth_components()
+ */
+
+static const char * const basic_auth_cases[][3] = {
+ /*
+ * case[0] - Authorization header value
+ * case[1] - expected username
+ * case[2] - expected password
+ */
+ { "Basic Ym9iOm15cGFzcw==", "bob", "mypass" },
+ { "Basic Ym9iOm15cGFzcw==", "bob", "mypass" },
+ { "Basic Ym9iOg==", "bob", "" },
+ { "Basic Om15cGFzcw==", "", "mypass" },
+ { "Basic Og==", "", "" },
+
+ /*
+ * Dubious compatibility cases
+ */
+
+ /* HT is disallowed per 7235, but ap_get_basic_auth_pw() allowed it */
+ { "Basic \tYm9iOm15cGFzcw==", "bob", "mypass" },
+ /* username without colon separator, technically disallowed per 2617 */
+ { "Basic Ym9i", "bob", "" },
+ /* no data at all, technically disallowed per 2617/7235 */
+ { "Basic ", "", "" },
+ { "Basic", "", "" },
+ /* completely invalid junk, disallowed per 2617/7235 */
+ { "Basic ?*J#kd92%$@", "", "" },
+};
+static const size_t basic_auth_cases_len = sizeof(basic_auth_cases) /
+ sizeof(basic_auth_cases[0]);
+
+HTTPD_START_LOOP_TEST(test_get_basic_auth_components_correctly_decodes_credentials, basic_auth_cases_len)
+{
+ const char *header_val = basic_auth_cases[_i][0];
+ const char *expected_user = basic_auth_cases[_i][1];
+ const char *expected_pass = basic_auth_cases[_i][2];
+
+ apr_status_t status;
+ const char *username;
+ const char *password;
+
+ apr_table_setn(g_request->headers_in, "Authorization", header_val);
+
+ status = ap_get_basic_auth_components(g_request, &username, &password);
+
+ ck_assert_int_eq(status, APR_SUCCESS);
+ ck_assert_str_eq(username, expected_user);
+ ck_assert_str_eq(password, expected_pass);
+}
+END_TEST
+
+START_TEST(test_get_basic_auth_components_fails_without_Authorization_header)
+{
+ apr_status_t status;
+
+ status = ap_get_basic_auth_components(g_request, NULL, NULL);
+ ck_assert_int_eq(status, APR_EINVAL);
+}
+END_TEST
+
+START_TEST(test_get_basic_auth_components_fails_with_non_Basic_credentials)
+{
+ apr_status_t status;
+
+ apr_table_setn(g_request->headers_in, "Authorization",
+ "Digest Ym9iOm15cGFzcw==");
+
+ status = ap_get_basic_auth_components(g_request, NULL, NULL);
+ ck_assert_int_eq(status, APR_EINVAL);
+}
+END_TEST
+
+START_TEST(test_get_basic_auth_components_uses_Proxy_Authorization_for_proxied_requests)
+{
+ apr_status_t status;
+ const char *username;
+ const char *password;
+
+ g_request->proxyreq = PROXYREQ_PROXY;
+ apr_table_setn(g_request->headers_in, "Proxy-Authorization",
+ "Basic Ym9iOm15cGFzcw==");
+
+ status = ap_get_basic_auth_components(g_request, &username, &password);
+
+ ck_assert_int_eq(status, APR_SUCCESS);
+ ck_assert_str_eq(username, "bob");
+ ck_assert_str_eq(password, "mypass");
+}
+END_TEST
+
+/*
+ * Test Case Boilerplate
+ */
+HTTPD_BEGIN_TEST_CASE_WITH_FIXTURE(authn, authn_setup, authn_teardown)
+#include "test/unit/authn.tests"
+HTTPD_END_TEST_CASE
diff -Nru apache2-2.4.69/test/unit/base64.c apache2-2.4.71/test/unit/base64.c
--- apache2-2.4.69/test/unit/base64.c 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/unit/base64.c 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,175 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "../httpdunit.h"
+
+#include "httpd.h"
+
+/*
+ * Test Fixture -- runs once per test
+ */
+
+static apr_pool_t *g_pool;
+
+static void base64_setup(void)
+{
+ if (apr_pool_create(&g_pool, NULL) != APR_SUCCESS) {
+ exit(1);
+ }
+}
+
+static void base64_teardown(void)
+{
+ apr_pool_destroy(g_pool);
+}
+
+/*
+ * Tests
+ */
+
+/*
+ * case[0]: encoded value
+ * case[1]: expected decoded value
+ */
+static const char * const base64_cases[][2] = {
+ /* Test case 1 derived from RFC 3548 sec. 7. */
+ { "FPucA9l+", "\x14\xfb\x9c\x03\xd9\x7e" },
+ { "aGVsbG8=", "hello" },
+ { "dGVzdA==", "test" },
+ { "", "" },
+};
+static const size_t base64_cases_len = sizeof(base64_cases) /
+ sizeof(base64_cases[0]);
+
+HTTPD_START_LOOP_TEST(strict_decoding_works, base64_cases_len)
+{
+ const char *encoded = base64_cases[_i][0];
+ const char *expected = base64_cases[_i][1];
+ char *decoded;
+ apr_size_t len;
+ apr_status_t status;
+
+ status = ap_pbase64decode_strict(g_pool, encoded, &decoded, &len);
+
+ ck_assert_int_eq(status, APR_SUCCESS);
+ ck_assert_uint_eq(len, strlen(expected));
+ ck_assert(!memcmp(decoded, expected, len));
+}
+END_TEST
+
+START_TEST(strict_decoding_works_with_embedded_nulls)
+{
+ static unsigned char expected[] = { 'n', '\0', 'u', '\0', 'l', '\0', 'l' };
+ char *decoded;
+ apr_size_t len;
+ apr_status_t status;
+
+ status = ap_pbase64decode_strict(g_pool, "bgB1AGwAbA==", &decoded, &len);
+
+ ck_assert_int_eq(status, APR_SUCCESS);
+ ck_assert_uint_eq(len, sizeof(expected));
+ ck_assert(!memcmp(decoded, expected, len));
+}
+END_TEST
+
+START_TEST(strict_decoding_produces_null_terminated_buffer)
+{
+ char *decoded;
+ apr_size_t len;
+
+ ap_pbase64decode_strict(g_pool, "aaaabbbbccccdddd", &decoded, &len);
+
+ ck_assert(decoded[len] == '\0');
+}
+END_TEST
+
+START_TEST(strict_decoding_allows_all_base64_characters)
+{
+ char *decoded;
+ apr_size_t len;
+ apr_status_t status;
+
+ status = ap_pbase64decode_strict(g_pool,
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "abcdefghijklmnopqrstuvwxyz"
+ "0123456789+/",
+ &decoded, &len);;
+
+ ck_assert_int_eq(status, APR_SUCCESS);
+}
+END_TEST
+
+static const char * const invalid_chars[] = {
+ "bad?",
+ "not-good",
+ "also_bad",
+ "a\x01sd",
+};
+static const size_t invalid_chars_len = sizeof(invalid_chars) /
+ sizeof(invalid_chars[0]);
+
+HTTPD_START_LOOP_TEST(strict_decoding_rejects_non_base64_characters, invalid_chars_len)
+{
+ char *decoded = NULL;
+ apr_size_t len = (apr_size_t) -1;
+ apr_status_t status;
+
+ status = ap_pbase64decode_strict(g_pool, invalid_chars[_i], &decoded, &len);
+
+ ck_assert_int_eq(status, APR_EINVAL);
+ ck_assert(decoded == NULL);
+ ck_assert_uint_eq(len, (apr_size_t) -1);
+}
+END_TEST
+
+static const char * const invalid_padding[] = {
+ "AAAA=", /* no padding needed here */
+ "AA=", /* not enough padding */
+ "AA===", /* too much padding */
+ "A===", /* only one or two padding characters allowed */
+ "A==",
+ "==",
+ "AAA=AAA=", /* mid-string padding prohibited */
+ "AAA", /* missing padding entirely */
+ "AA", /* missing padding entirely */
+ "A", /* just completely wrong */
+ "AAb=", /* one-padded strings must end in one of AEIMQUYcgkosw048 */
+ "Ab==", /* two-padded strings must end in one of AQgw */
+};
+static const size_t invalid_padding_len = sizeof(invalid_padding) /
+ sizeof(invalid_padding[0]);
+
+HTTPD_START_LOOP_TEST(strict_decoding_rejects_incorrect_padding, invalid_padding_len)
+{
+ char *decoded = NULL;
+ apr_size_t len = (apr_size_t) -1;
+ apr_status_t status;
+
+ status = ap_pbase64decode_strict(g_pool, invalid_padding[_i], &decoded,
+ &len);
+
+ ck_assert_int_eq(status, APR_EINVAL);
+ ck_assert(decoded == NULL);
+ ck_assert_uint_eq(len, (apr_size_t) -1);
+}
+END_TEST
+
+/*
+ * Test Case Boilerplate
+ */
+HTTPD_BEGIN_TEST_CASE_WITH_FIXTURE(base64, base64_setup, base64_teardown)
+#include "test/unit/base64.tests"
+HTTPD_END_TEST_CASE
diff -Nru apache2-2.4.69/test/unit/util.c apache2-2.4.71/test/unit/util.c
--- apache2-2.4.69/test/unit/util.c 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/unit/util.c 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,226 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "../httpdunit.h"
+
+#include "httpd.h"
+#include "apr_strings.h"
+
+/*
+ * Test Fixture -- runs once per test
+ */
+
+static apr_pool_t *g_pool;
+
+static void util_setup(void)
+{
+ if (apr_pool_create(&g_pool, NULL) != APR_SUCCESS) {
+ exit(1);
+ }
+}
+
+static void util_teardown(void)
+{
+ apr_pool_destroy(g_pool);
+}
+
+/*
+ * ap_test_token()
+ */
+
+struct ap_test_token_case {
+ const char *list;
+ const char *token;
+ int expected;
+};
+
+const struct ap_test_token_case ap_test_token_cases[] = {
+ { "one, two, three", "one", 1 },
+ { "one, two, three", "two", 1 },
+ { "one, two, three", "three", 1 },
+ { "one,two,three", "two", 1 },
+ { NULL, "token", 0 },
+
+ /* Regression test for CVE-2017-7668 */
+ { "one, two, \0three", "three", 0 },
+
+ /*
+ * Dubious compatibility cases
+ */
+ { ",\x01,one,,two,/,,three,,", "one", 1 },
+ { ",\x01,one,,two,/,,three,,", "two", 1 },
+ { ",\x01,one,,two,/,,three,,", "three", 1 },
+ { ",\x01,one,,two,/,,three,,", "\x01", 0 },
+ { ",\x01,one,,two,/,,three,,", "/", 0 },
+};
+
+const size_t ap_test_token_cases_len = sizeof(ap_test_token_cases) /
+ sizeof(ap_test_token_cases[0]);
+
+HTTPD_START_LOOP_TEST(find_token_correctly_parses_token_list, ap_test_token_cases_len)
+{
+ const struct ap_test_token_case *c = &ap_test_token_cases[_i];
+ int result;
+
+ result = ap_find_token(g_pool, c->list, c->token);
+ ck_assert_int_eq(result, c->expected);
+}
+END_TEST
+
+
+/*
+ * ap_cstr_stripws()
+ */
+
+struct ap_cstr_stripws_case {
+ const char *input;
+ const char *expected;
+};
+
+const struct ap_cstr_stripws_case ap_cstr_stripws_cases[] = {
+ { "", "" },
+ { " ", "" },
+ { "\t\r\n", "" },
+ { "value", "value" },
+ { "value ", "value" },
+ { "value\t ", "value" },
+ { " value ", " value" },
+};
+
+const size_t ap_cstr_stripws_cases_len = sizeof(ap_cstr_stripws_cases) /
+ sizeof(ap_cstr_stripws_cases[0]);
+
+HTTPD_START_LOOP_TEST(cstr_stripws_strips_trailing_whitespace, ap_cstr_stripws_cases_len)
+{
+ const struct ap_cstr_stripws_case *c = &ap_cstr_stripws_cases[_i];
+ char *str = apr_pstrdup(g_pool, c->input);
+ char *end = ap_cstr_stripws(str);
+
+ ck_assert_str_eq(str, c->expected);
+ ck_assert_ptr_eq(end, str + strlen(c->expected));
+}
+END_TEST
+
+
+/*
+ * ap_escape_quotes()
+ */
+
+struct ap_escape_quotes_case {
+ const char *input;
+ const char *expected;
+};
+
+#define ESCQ "\\\""
+
+const struct ap_escape_quotes_case ap_test_escape_quotes_cases[] = {
+ { "one", "one" },
+ { "o\"ne", "o"ESCQ"ne" },
+ { "o"ESCQ"ne", "o"ESCQ"ne" },
+ { "one\\", "one\\" },
+ { "o\\ne", "o\\ne" },
+ { "o\\"ESCQ"ne", "o\\\\"ESCQ"ne" }, /* 1st \ escapes 2nd, following '"' needs \ */
+};
+
+const size_t ap_test_escape_quotes_cases_len = sizeof(ap_test_escape_quotes_cases) /
+ sizeof(ap_test_escape_quotes_cases[0]);
+
+HTTPD_START_LOOP_TEST(check_escape_quotes, ap_test_escape_quotes_cases_len)
+{
+ const struct ap_escape_quotes_case *c = &ap_test_escape_quotes_cases[_i];
+ const char *result;
+
+ result = ap_escape_quotes(g_pool, c->input);
+ ck_assert_str_eq(result, c->expected);
+}
+END_TEST
+
+
+/*
+ * ap_size_parameter_parse()
+ */
+
+struct ap_size_parameter_parse_case {
+ const char *input;
+ apr_status_t expected_rv;
+ apr_int64_t expected; /* for a 64-bit apr_off_t */
+};
+
+const struct ap_size_parameter_parse_case ap_size_parameter_parse_cases[] = {
+ { "0", APR_SUCCESS, 0 },
+ { "1", APR_SUCCESS, 1 },
+ { "-1", APR_SUCCESS, -1 },
+ { "1024", APR_SUCCESS, 1024 },
+ { "0b", APR_SUCCESS, 0 },
+ { "1b", APR_SUCCESS, 1 },
+ { "512B", APR_SUCCESS, 512 },
+ { "1k", APR_SUCCESS, 1024 },
+ { "1K", APR_SUCCESS, 1024 },
+ { "10k", APR_SUCCESS, 10240 },
+ { "1m", APR_SUCCESS, 1024 * 1024 },
+ { "2M", APR_SUCCESS, 2 * 1024 * 1024 },
+ { "1g", APR_SUCCESS, 1024 * 1024 * 1024 },
+ { "4G", APR_SUCCESS, APR_INT64_C(4) * 1024 * 1024 * 1024 },
+ { "-2k", APR_SUCCESS, -2048 },
+ { "", APR_EINVAL, 0 },
+ { "k", APR_EINVAL, 0 },
+ { "-", APR_EINVAL, 0 },
+ { "1t", APR_EINVAL, 0 },
+ { "1kb", APR_EINVAL, 0 },
+ { "1 k", APR_EINVAL, 0 },
+ { "1k ", APR_EINVAL, 0 },
+ { "2 ", APR_EINVAL, 0 },
+ { "0x10", APR_EINVAL, 0 },
+ { "1.5k", APR_EINVAL, 0 },
+ /* overflows whether apr_off_t is 32 or 64 bits wide */
+ { "99999999999999999999", APR_ERANGE, 0 },
+ { "9223372036854775807k", APR_ERANGE, 0 },
+ { "9007199254740992m", APR_ERANGE, 0 },
+ { "-9007199254740992m", APR_ERANGE, 0 },
+ { "8589934592g", APR_ERANGE, 0 },
+};
+
+const size_t ap_size_parameter_parse_cases_len =
+ sizeof(ap_size_parameter_parse_cases) /
+ sizeof(ap_size_parameter_parse_cases[0]);
+
+HTTPD_START_LOOP_TEST(size_parameter_parse_handles_suffixes, ap_size_parameter_parse_cases_len)
+{
+ const struct ap_size_parameter_parse_case *c = &ap_size_parameter_parse_cases[_i];
+ apr_status_t expected_rv = c->expected_rv;
+ apr_off_t value = 0;
+ apr_status_t rv;
+
+ /* A value which does not fit a narrower apr_off_t is a range error. */
+ if (expected_rv == APR_SUCCESS && (apr_off_t)c->expected != c->expected) {
+ expected_rv = APR_ERANGE;
+ }
+
+ rv = ap_size_parameter_parse(c->input, &value);
+ ck_assert_int_eq(rv, expected_rv);
+ if (expected_rv == APR_SUCCESS) {
+ ck_assert_int_eq(value, (apr_off_t)c->expected);
+ }
+}
+END_TEST
+
+
+/*
+ * Test Case Boilerplate
+ */
+HTTPD_BEGIN_TEST_CASE_WITH_FIXTURE(util, util_setup, util_teardown)
+#include "test/unit/util.tests"
+HTTPD_END_TEST_CASE
diff -Nru apache2-2.4.69/test/uv.lock apache2-2.4.71/test/uv.lock
--- apache2-2.4.69/test/uv.lock 1970-01-01 00:00:00.000000000 +0000
+++ apache2-2.4.71/test/uv.lock 2026-11-09 03:07:05.000000000 +0000
@@ -0,0 +1,536 @@
+version = 1
+revision = 5
+requires-python = ">=3.11"
+
+[[package]]
+name = "anyio"
+version = "4.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.10.23"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
+ { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "50.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
+ { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
+ { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
+ { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
+ { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
+ { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
+ { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
+ { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" },
+ { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" },
+ { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" },
+ { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "4.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpd-tests"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "cryptography" },
+ { name = "filelock" },
+ { name = "httpx" },
+ { name = "packaging" },
+ { name = "pyopenssl" },
+ { name = "pytest" },
+ { name = "python-multipart" },
+ { name = "websockets" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "cryptography" },
+ { name = "filelock" },
+ { name = "httpx", specifier = ">=0.27" },
+ { name = "packaging" },
+ { name = "pyopenssl" },
+ { name = "pytest", specifier = ">=8.0" },
+ { name = "python-multipart" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
+]
+
+[[package]]
+name = "pyopenssl"
+version = "26.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "17.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz", hash = "sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329", size = 188878, upload-time = "2026-10-19T16:58:05.813Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/b8/282f114a67886fccde02a77ff296501ad7fea25f4d696b2b1b337cbd27da/websockets-17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45cc1ccb12ced6d242335159e8221733c8696da5ad0b6ceef6debb0f5ce89110", size = 218223, upload-time = "2026-10-19T16:54:25.998Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ba/8043c303aa60af053e092c1d667165f46d6729217ce2af795a23a3cd700a/websockets-17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46ae05aa1aecd47d4d7de0f05ce2d1d2eb418f8de614964d754cc3b9a409c400", size = 215908, upload-time = "2026-10-19T16:54:27.785Z" },
+ { url = "https://files.pythonhosted.org/packages/21/3d/5d0eaa9b29db6c999a2cf749605141796cff21a5e235b2488bd39dcdcac9/websockets-17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43f270a5f21e12db08c73d83849a877c02ab7b7d0fb6e2718b5101d9c810212f", size = 216181, upload-time = "2026-10-19T16:54:29.267Z" },
+ { url = "https://files.pythonhosted.org/packages/43/fb/33080a7b2d8427b343056eab32ba78235a4bfbbdc79b5593688c7b7c62ef/websockets-17.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d875224bac1a05a20acade268a7a0e3498e7f8cf890acb7c2b23bacd2bd3659", size = 225246, upload-time = "2026-10-19T16:54:30.807Z" },
+ { url = "https://files.pythonhosted.org/packages/61/8c/d4220455cee00adb553c602ba4f1f6a47cd882d86dd9e5124679c0c95c75/websockets-17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ad409b7ea102339041281117cf2dadfe71edbb653dc48037cc55d516fb7f448", size = 225517, upload-time = "2026-10-19T16:54:32.182Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a3/0ff8e4f0195884ae446844185e81bf7619673d816424b8a58bb5812dd4b8/websockets-17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d43c311f3519fb1057b6114dc24e9060bc3fb6aedf4aa11160484154b66b63", size = 226751, upload-time = "2026-10-19T16:54:33.52Z" },
+ { url = "https://files.pythonhosted.org/packages/06/cf/82c88e3fbb6a75a62b2997932664bccfe68256661c69144bd48020160cf1/websockets-17.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9eacb382f400bb5865e7e6d24bbe709a84510291e707f169dd4a24ee4a5ac6a", size = 230063, upload-time = "2026-10-19T16:54:34.855Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/e4/9131d3b73504902a83d10b1e1244319377ca72755351998ac9c408ba6ddb/websockets-17.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f09f145b89ea292cf6c25fd7c8c0d90207fd6a9e54c7828499669996a1e5bdcc", size = 227310, upload-time = "2026-10-19T16:54:36.248Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b2/2208506afb45d181b0d6ccf377d9d79985fbcbf871af02ef4f7f3d405f5b/websockets-17.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:49fb5b4960ebe1bc557b435da8687deef1302a08f030a33c5606fa6fb11b773b", size = 226087, upload-time = "2026-10-19T16:54:37.583Z" },
+ { url = "https://files.pythonhosted.org/packages/39/53/da73e3809f1d4e8c3346ca89a4bed7995de5708f2cb14c9359172a85f5a5/websockets-17.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:13cffaedde6e06183860fc92d88fe1d190abda73837fe4c14f5d4803c472f5dd", size = 223360, upload-time = "2026-10-19T16:54:38.91Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/7b/eaf828fd414401672e615570b9f2bfd84342b49c86ffb2ae735e4aada32f/websockets-17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ef2950896673a9809e8e5ab857525924fbcacfc98b858b0509ccf50d341f80f4", size = 226240, upload-time = "2026-10-19T16:54:40.294Z" },
+ { url = "https://files.pythonhosted.org/packages/79/bd/4b6aa6e761c8d89aa6defdd83e40a6b2d9aa21bd01520c8e8470fe8d8681/websockets-17.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2d08ca4bf5a9cdb6fd81b86b51bd5cfb10527d3a1199888f2b02cc6c6a3d5f80", size = 224833, upload-time = "2026-10-19T16:54:41.939Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a8/0a6aea08cad7db2c1aa53dbd3ece3ebc09e420e8301cb02f2b812a728a5b/websockets-17.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e0c0ed37472c4729f3001066510a656486f08f149dda1ebfd3dfe4450c9756e", size = 225599, upload-time = "2026-10-19T16:54:43.54Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/82/3998ea56beb079f43fc39dd5935ef8dc150f915bf2de8bd6ddcd50c0fae6/websockets-17.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62059d739af7fc035ed6e63c5390118a60a5e9aa5a5541cd0486a83e741842c7", size = 226643, upload-time = "2026-10-19T16:54:44.914Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/a5/6568add8d0a2f37c5780c6de62b5b8ee191ca9d2d0b8ff2123b8277c41b7/websockets-17.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc6f87d03e3691154673aaa8f08991723d6e3394c83ef2875c7a9049eac9595c", size = 224223, upload-time = "2026-10-19T16:54:46.429Z" },
+ { url = "https://files.pythonhosted.org/packages/72/79/b11bc05b0f64d8b14901443a4b01ea80d039cc68b260250b4cb488070aca/websockets-17.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3d78d93846eddc990551c1c31914d90d71957ca3af468969826f7ed0d7d1d51c", size = 225255, upload-time = "2026-10-19T16:54:47.82Z" },
+ { url = "https://files.pythonhosted.org/packages/71/f9/9a8902074db7044b64a05feff349e4f44144a961310d3419bab612f00dd1/websockets-17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2a377c7132b1e69be7a50d7434d7f26f69e93c70166ebb6d8f8cabf802ac4506", size = 225494, upload-time = "2026-10-19T16:54:49.171Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ba/7b873060ce6764207a24c10e81294739abffe2a90dc67c7f7db5042c596c/websockets-17.3-cp311-cp311-win32.whl", hash = "sha256:29b01520c39ef2f23c88b002f11e6ef4165675f6c92f5f3578fd587d69b5a5fc", size = 218720, upload-time = "2026-10-19T16:54:50.501Z" },
+ { url = "https://files.pythonhosted.org/packages/28/11/7469f3f28513ec0d43f10b1c88bf970b0b6f0aa8190c42731954297e824c/websockets-17.3-cp311-cp311-win_amd64.whl", hash = "sha256:d338813a440bf6d2f0df55140d3f3080284410b1ebfe9df50e3847bcdb1a17bb", size = 219017, upload-time = "2026-10-19T16:54:51.843Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/fd/4724610baabbba94a44c5d1cf1b1a33ff77031e94a51addb0387c7f56b10/websockets-17.3-cp311-cp311-win_arm64.whl", hash = "sha256:6a8248b1ba8861e48e4c7ced3895a1264d2a3b617de72ec94172ce6e64bf16c7", size = 218950, upload-time = "2026-10-19T16:54:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/29/902ffe19c0d35603102f31490dea342f083bc77d8f8bcdc7d8b16f5810f9/websockets-17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:41665c7ea01d3ae497a0c61fd22d3424230d18782a5fceb79f04db9ccb2042c9", size = 218250, upload-time = "2026-10-19T16:54:54.718Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/66/f56a2637b1aac263205b0e3cd7b2ff2e2f8b404b8118c991f4d122fd381f/websockets-17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8f7469408740cf06a79b01605f2ae0b2acc8839c03fcbbf05da49baee12d3302", size = 215927, upload-time = "2026-10-19T16:54:56.124Z" },
+ { url = "https://files.pythonhosted.org/packages/22/7d/a27f25451c4cc62141992b51005688481210f3d3e63de3dc66f9e882ba59/websockets-17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d68da281ac208b6236ffa8beef23fe7c7c956d8bf09b421d447232fd94441b35", size = 216196, upload-time = "2026-10-19T16:54:57.538Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/77/40bbc1a5c016cf9f598dea21d1250a93245c995eafaec6401d0e4eb4e647/websockets-17.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078d1d41f45ee34eed5a4f3bbe7c588caecbdd6e12dfca73e8c1b0e803773793", size = 225516, upload-time = "2026-10-19T16:54:59.104Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/9c/91889c8c62fb7d16cd58e3074473a259303d6229bf0ce496aec87e66f3dc/websockets-17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7f391838e8adcf1f2fe71561d9e5a9f74ea7ffa5052eb2db329aac677383136", size = 225795, upload-time = "2026-10-19T16:55:00.438Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/29/2e5e2158e9920f806cd45f3cbc81cf5f3eef2b9e5a74b9ef049e2a3578e3/websockets-17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15a165673bb3fb57facbf040edf5c0556d20ccd2e0ccc5f23ebb69fb715fdc1", size = 227034, upload-time = "2026-10-19T16:55:02.065Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/af/fbab5c4098b26026b877bfc139b1ea0d7c6eaf7a86b84544933b4c445f82/websockets-17.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28d1935c16782a1e3650c9baa79069b016fff739e292e4cb14bbb830d6930491", size = 229863, upload-time = "2026-10-19T16:55:03.415Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/72/d6e460a991607794072fe3fdfcb3221a0342af61586f5bd7ea40bddcfd21/websockets-17.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c534c9524a2acd0b97b7022074bec3784ac273c683cf9535087e437672ea8531", size = 227669, upload-time = "2026-10-19T16:55:04.763Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/8f/1c7da1cfb632e141e019cf2e0bbbd4e6dc8c198c8cc29544e4acf1a89f56/websockets-17.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b459d29c8b82a420a98fa0ce8e9cf787ce0062d98cc90604ef9574fb3cc26ff8", size = 226429, upload-time = "2026-10-19T16:55:06.112Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/63/c48ef8bbfbd982f2cfae1cfcf9e63f5bfbb8468fd37ca700507efc0b0007/websockets-17.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53209dffd91b0aefed9ea630fa6ad5c2944d6210ba3a8a3a8664dc1575289b14", size = 223653, upload-time = "2026-10-19T16:55:07.473Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/37/7bedaad48ae8e89eaa4f3942c11be0c41de77ce2118a475f3ba05e9b5a0f/websockets-17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9662a9fe457820c60365b3209e03cbd8e4a817195d55de65a80fa680b4bea136", size = 226465, upload-time = "2026-10-19T16:55:08.791Z" },
+ { url = "https://files.pythonhosted.org/packages/33/cf/ab9a8a0de4769ca1dce392021b6a922e21b4d3687fe9e1ab4db585a088f8/websockets-17.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5670a762c7ae388bf8aa04199fd436180a4d28318dca9c1eae7b38604676dbfd", size = 225071, upload-time = "2026-10-19T16:55:10.127Z" },
+ { url = "https://files.pythonhosted.org/packages/89/96/64d8460cec95f873c61c6d13a09b435915150640525ae56d5871400aec09/websockets-17.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ed59cb4ee258674a6ed9b8515e42079db02cb3be94f133fdd3f200183a40c49d", size = 225841, upload-time = "2026-10-19T16:55:11.519Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/98/2b83211ee3087db4ae66533782664c0e283cf4a08cbb14036018d0803e09/websockets-17.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f78bb2e82fe88912c9d7b25c98e57a9b08655579af89f164fd4c0657a075d62", size = 226945, upload-time = "2026-10-19T16:55:12.85Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/fe/6b2a37d422d7f4dfc0c92c95a14649d9bd662243ed8b0774523ed2703604/websockets-17.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:71250e010eede33c1d638e2c7a98288520d67f782fd5eceecaeca3129233a09a", size = 224502, upload-time = "2026-10-19T16:55:14.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c5/364015c9311c89ea6085d54c70d654c5d27ba05014d44ef9fd9daa09462b/websockets-17.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:729989759feb81ff6493879a0d60a0bd7d2eee41001bbec0381a080318439382", size = 225483, upload-time = "2026-10-19T16:55:15.899Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b3/75d0cae8398816ed34a16a78252bfc8651f2f883cc5a568e1198395f15df/websockets-17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90d8c4fa5ba293cf92de351fbc292cdaadb1a1d58d031a1a97ff9b8fb6250ec7", size = 225761, upload-time = "2026-10-19T16:55:17.28Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/af/9a7c4b2e87f47a2a6a066acdc2157e9b7cb6222a224b95a432f54ae786ce/websockets-17.3-cp312-cp312-win32.whl", hash = "sha256:0824e6ecee838cc28e6481062574c7fde49f9a2cedfacd36f0e91cb4b37fd6fe", size = 218722, upload-time = "2026-10-19T16:55:18.658Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/e8/e52953a8280a1e51cb9307218443f68c8f4dc4b9952db6021d5c12d03c0f/websockets-17.3-cp312-cp312-win_amd64.whl", hash = "sha256:6644eecd99c4e8cdf72e646e17b391bb70b054f44becde37dcdc48cbec7e1527", size = 219024, upload-time = "2026-10-19T16:55:19.987Z" },
+ { url = "https://files.pythonhosted.org/packages/85/a4/005eb7c1f5c1a249c1231a6ed83a54ada0b6b8ffab33a608ca20e3034ea0/websockets-17.3-cp312-cp312-win_arm64.whl", hash = "sha256:4a9133dbaedc6f1af358127275676171d9a1bb3e61f47ee62b2ff9acb78af3d0", size = 218950, upload-time = "2026-10-19T16:55:22.161Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/1d/c1f4a1c45c5ca2c35ae9fd7cac0e491f8a014c9a86933a5da7b04ff825cf/websockets-17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:52a2ca6f401fb49a8d5d9e0e48ade5f58f1bdda33132907b0e4e3b702628fa4e", size = 218243, upload-time = "2026-10-19T16:55:23.851Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/e8/f1b47d0ced4964ae748931a54347fd27b06b62e8ea7837a9de3aee0c5999/websockets-17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80578e91764bfca297ddca322c00a3136d993b829e9839e7fa378a19c1fcc25d", size = 215927, upload-time = "2026-10-19T16:55:25.317Z" },
+ { url = "https://files.pythonhosted.org/packages/16/df/ab0ed0445d8d81a133c8fed760f3b4f743a2f563804fdbecb2c32fe05d94/websockets-17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe7a168d805c8a7bf1c2de55b7ab2bc958586bfd88d0e87235a92cac66b590a7", size = 216185, upload-time = "2026-10-19T16:55:26.678Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/51/b30fd6c5a33dac76a78083be125e3f9804f3d735da494ff03a46834f31f4/websockets-17.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:54fcd24944e6d127d198c28dc363967b7131ff0c97833962c2ef2048018de02d", size = 225574, upload-time = "2026-10-19T16:55:28.097Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/8a/8ede659919c0805d1fcc79c2ed16588d3419f1687c5117ba46f0217750ec/websockets-17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de2c12555bf05546f3ee75733f2c81de70aab7f530e86d0d2162b0ddcb3c39ae", size = 225854, upload-time = "2026-10-19T16:55:29.491Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/6a/e75d10e3896edc61320062890f71cdc0767dc4a54e2a26a943b703042d13/websockets-17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c95d25b5382ee0e6191ecafdeabefdc1f7bff8dfee29239b9b79e760ef3b04bd", size = 227091, upload-time = "2026-10-19T16:55:31.146Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/d3/658f1e153566e5edae02812122d9f020c30785d8b53c4bda61b8e976f77f/websockets-17.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8f244dfee05ea29b8064701880865eb1684f3a704a5665bd10b64694444dbbd4", size = 228526, upload-time = "2026-10-19T16:55:32.524Z" },
+ { url = "https://files.pythonhosted.org/packages/28/00/002f95a02d721d8c6ec2676c5aee904a823838a9c205fc0b31eedd882a7b/websockets-17.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e99440ef446c7833690c1754e23b0188d440f3cb89812b0764c697a7880cb33", size = 227718, upload-time = "2026-10-19T16:55:34.085Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/9c/d36ec9499a62ec4e2a4eb570f93c6fcedd80ad5685e0f9d6866e8cfbbb84/websockets-17.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:26838e2d95d7004c52e8c7b6b51676c872093a3b55a497824f899116a15a8a55", size = 226472, upload-time = "2026-10-19T16:55:35.484Z" },
+ { url = "https://files.pythonhosted.org/packages/36/5d/3a6d74e409f24ae4e8661a9d74d6645f71b3e91425a527c0e3f2e78bb15b/websockets-17.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:508775efa11d26aec186d10154359f52276e2baf9ca201b52356330883276dfd", size = 223710, upload-time = "2026-10-19T16:55:36.853Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/90/e08eb5d69b73cf5babf80922a8c964019b48339ce5c694a5d0c0f083c5a8/websockets-17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2789d203babb6ceb2770aadc2394c1f99263ec37fba57a93064f9a8ad899e8ff", size = 226532, upload-time = "2026-10-19T16:55:38.191Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/45/b1916efa1dad5af33beafe5c536810e1c80856b7e913f671c35031c57745/websockets-17.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cfef7f7aa434a1e632e0ebda14b39f4f1d7975e0fce5c77ea7b43156fabf9bd8", size = 225129, upload-time = "2026-10-19T16:55:39.635Z" },
+ { url = "https://files.pythonhosted.org/packages/04/c1/d427abb3f11e92f102f70f8e73addb767fc62c614565c0ee3f5657fa3551/websockets-17.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:39f532fa0d9026de4b9315a175998646112776baad54d55348435c11b05d034e", size = 225900, upload-time = "2026-10-19T16:55:41.033Z" },
+ { url = "https://files.pythonhosted.org/packages/07/2b/462fab8e4b09eaa4eac2f40fcff8ee30f851aa40fb518efb0177262f6590/websockets-17.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:85d6daa3a7ca404570ae130babbeb347ef9d3faca5300fb13cfa4bb48c3d30d1", size = 227007, upload-time = "2026-10-19T16:55:42.314Z" },
+ { url = "https://files.pythonhosted.org/packages/71/36/7404a4376021a0cc7ae9fcbdb1196c0c8aa48df4fd370c185f4f5b677bff/websockets-17.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ce39283ef08b0f28478e920d4d89a1fc169176e568e8a6a821332673145522eb", size = 224565, upload-time = "2026-10-19T16:55:43.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/5f/b8ada5787d11e6edbf58e2d680235fb3b2fb3298991cc310566f4d6c2fbf/websockets-17.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23046098ebf844753cfd211033624a8247520e5bf2194c24cb1a8c222917acff", size = 225516, upload-time = "2026-10-19T16:55:45.398Z" },
+ { url = "https://files.pythonhosted.org/packages/23/1c/0533f760237ac6604efd0bf98c18ea4c1381f2cadd8c8d0b05faecb07a80/websockets-17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:724224d4c457207271239114bf43afafc3eca4a1b4661ccdd428d11869a03bbd", size = 225797, upload-time = "2026-10-19T16:55:46.989Z" },
+ { url = "https://files.pythonhosted.org/packages/97/b4/0e580e8f96197130ebdb32b727e9c891594c930be30d8a9de9aa0bbb7e2c/websockets-17.3-cp313-cp313-win32.whl", hash = "sha256:5af7070d80d278832cbca9c3c3dd44b30a26dcfa3ae1e0f6f19ff34edc27b689", size = 218712, upload-time = "2026-10-19T16:55:48.379Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/6c/db5c907d1cbea84444bc759ff63db97fc586988d11a34d5565e8b50080fa/websockets-17.3-cp313-cp313-win_amd64.whl", hash = "sha256:66027e401c70d0cf55021b91b8a7ec2cd5810e20602f945b4fe6ae8b37ed232b", size = 219026, upload-time = "2026-10-19T16:55:50.688Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/d3/30f04bec5b9cf0c00c98c038494b210ece923a0b05b6df38a162b759e5ed/websockets-17.3-cp313-cp313-win_arm64.whl", hash = "sha256:6b14914e67044724c31a9360c7b89e9d070117c630746eefdc1620d967545acd", size = 218958, upload-time = "2026-10-19T16:55:52.085Z" },
+ { url = "https://files.pythonhosted.org/packages/27/ad/f65e365bbf38d61164b0c2f64776fc7714bfa59ac51e492b038a35f4ab1b/websockets-17.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:bc8fd230ef013951900c1fda100eebe4a4b1b407dde2a44186995042dda2c628", size = 218288, upload-time = "2026-10-19T16:55:53.387Z" },
+ { url = "https://files.pythonhosted.org/packages/48/1c/33271eba53b4927a1b91d99d838a6d7b888e6f708425262c911450d73696/websockets-17.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cdb8e4b0f6a41b857c74c03116e8ffbef59ef8e533e934b532843178990d01d5", size = 215977, upload-time = "2026-10-19T16:55:55.063Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/f3/6d982c60f6c7c95e5faed53d39263df273ecfda904c7d6920c37eee58dd1/websockets-17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:018fab26140ce221c3853454a608e4e7004f4f10641cb97819dcd5b917bb2e99", size = 216193, upload-time = "2026-10-19T16:55:56.424Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c6/4ff6b8d2a4b32719d4658bbd791ee9d19939a47a69b961c961fd9dc3a64c/websockets-17.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd0f3037d501178a761fd2814370aca547f79e99a200a61a1f6e7b79ec3d78b9", size = 225573, upload-time = "2026-10-19T16:55:58.252Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/8f/46e4030947f5590cb61832901f44a3edb07ef8d1e27f6c06b42c5f1c4d33/websockets-17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8dbb3862aa649de95660b6337d97101977f160b5b354d07b82fe309874d2c8bc", size = 225927, upload-time = "2026-10-19T16:55:59.561Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/1b/3db30fdf4fbefa4ac463060d3f87e2d444f960aa1234028468c09b88bffc/websockets-17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af37a1e6b6bd506b597d5fb4cf111fb527d1b2223608a5d441d4738827cfd166", size = 227169, upload-time = "2026-10-19T16:56:01.029Z" },
+ { url = "https://files.pythonhosted.org/packages/02/ed/849799cfc0e3e359b75ca1d50605376da52dc9ef0b2a53931e3f5991f96e/websockets-17.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:740bba4223fa5216d4d6c5dcd29e697f37229b8a61b69e1a50527b0ea993ab2c", size = 228542, upload-time = "2026-10-19T16:56:02.377Z" },
+ { url = "https://files.pythonhosted.org/packages/19/53/dcc397e20ce792fffd9a8f79b6333275748ee7a23f807e576166a0b16edd/websockets-17.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4170556762c6fd2913d99dfdd8dbb4c927584899df7facb1034627e355263c45", size = 227772, upload-time = "2026-10-19T16:56:03.768Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/36/e5935782f71e9e22247b2a2e562da05e18d8945c49f2692ca1bdaceec178/websockets-17.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d355b22270be8062360b9df2ee0b10277590789a1059257916fb9e256a0cff80", size = 226518, upload-time = "2026-10-19T16:56:05.332Z" },
+ { url = "https://files.pythonhosted.org/packages/27/76/bd5978a1e403fc646230cf35cf7c06b40b3a232d440881b9e89ede229b18/websockets-17.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4aa85b34c21cf97b3470793d0bf0763d32bfd1e67b83067a0a3ba8dc3474dacf", size = 223766, upload-time = "2026-10-19T16:56:06.703Z" },
+ { url = "https://files.pythonhosted.org/packages/da/87/c66eb39bd851d5c14d9eb75aa64d03cfcf2ded77927ca067b5917291d19d/websockets-17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0e2e7c2c1a4f25db7b6596738d810c10732e77f852247300a1bc49e364d6dc9", size = 226644, upload-time = "2026-10-19T16:56:08.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/ae/b9a31d5497fa0b73629a097b173a26123da2ed656b562114644bc623414b/websockets-17.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7fa69212ea60ea24e2d8aff23fe102b1f87f1b27a320f92d2e7b3468997a496a", size = 225107, upload-time = "2026-10-19T16:56:09.627Z" },
+ { url = "https://files.pythonhosted.org/packages/61/0c/b773a102f84b60b2052b2cfde773d2f3cd3e8eaa93a145f54450460549cf/websockets-17.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:80a6e37e235780f189558ab804f3cbc01c18f5c578995739b49613d544d34ebf", size = 225891, upload-time = "2026-10-19T16:56:10.973Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/e7e538575dd4912dea9ea4c9ae86519aa1420969d9cb5cf02fd10fd20ba7/websockets-17.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:628efe13630ac3397e7d1278b6be6f22f37a86d638a324334b4cd43c1daf9928", size = 227064, upload-time = "2026-10-19T16:56:12.295Z" },
+ { url = "https://files.pythonhosted.org/packages/28/87/bf2b928c156deefd0c50285a48fc985e2ca85d284e7d9981cc5b79b0e696/websockets-17.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:443fa28652682d2a3ec3eca49af1b87cb2523561257683e62e03a98967b22138", size = 224618, upload-time = "2026-10-19T16:56:13.73Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/d59817ba3535f787c1cf8990a01b3d2896a7d9d8f3b5d55bed22fce595a6/websockets-17.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6705af9d1005127302c04d9f1533ba56e289336c465923ef878430e99295a0b9", size = 225571, upload-time = "2026-10-19T16:56:15.199Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/96/8210379dac889e16c75099aefffa200dc55dc8cb030010f309a7eb9c75c1/websockets-17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9bde5d82d7ac4859e0cb3a35e148f537a8d4e56aa43f42582b5f263982f095ae", size = 225869, upload-time = "2026-10-19T16:56:16.569Z" },
+ { url = "https://files.pythonhosted.org/packages/97/0b/d4741c977fec9e6482beffece572d544d42d230f56fc3ef504f96521063a/websockets-17.3-cp314-cp314-win32.whl", hash = "sha256:be7be57097a65a8fab3d0f0a10d12931deb626a7d8082eb732b1ae41f3a202e3", size = 218232, upload-time = "2026-10-19T16:56:18.028Z" },
+ { url = "https://files.pythonhosted.org/packages/33/69/2f9ce899776d0c522f7f44080cad4228a1c14ec13b0125a2efc12ebe41c9/websockets-17.3-cp314-cp314-win_amd64.whl", hash = "sha256:2b06213c8e1ce93b18d0476d7a5e647436c8f23d099888c9065e930e80f4bed5", size = 218582, upload-time = "2026-10-19T16:56:19.399Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/e9/11b885666d72add674440a3435c059bea325616959cec8be42605948714c/websockets-17.3-cp314-cp314-win_arm64.whl", hash = "sha256:6b7b540bb321f7f0a7b8f5d107f941bb361e3a154c0b594dae0394997b91b743", size = 218465, upload-time = "2026-10-19T16:56:21.005Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/0e/0c0f40ab8eefe9d5a6c78b4e267d8e37d70d3db8dd195574e0ab867e4f81/websockets-17.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c9d06edfd974e918b40e0662a7e76dac13125529ee5ec9fbe42dc0d6faa209c3", size = 218614, upload-time = "2026-10-19T16:56:22.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/93/d22b3d63aa439bc01a9477880070d05e0b257b0548294786a33be3e842ec/websockets-17.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a0bcf66289b9d017c175562c35c5304b5cc3ed56aded994ae30b0a9654ba405", size = 216151, upload-time = "2026-10-19T16:56:23.95Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/92/f60a9672cdeb8cb4f7156e6e83c76d59fdc586737a97ac77129e9fc188a2/websockets-17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f3ed747b7651668131029c720fbc32c7ee2d129bdad200e6c8f4037b20f7913a", size = 216354, upload-time = "2026-10-19T16:56:25.251Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/da/2be8c5f3c6612d4523f95b074e2352bc11bd090cf121d32bc11af1d52e70/websockets-17.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5868a02c4f6d698ddd19b4c9742748c03dec045289915806845189d96bc3d314", size = 225937, upload-time = "2026-10-19T16:56:26.726Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1c/cb84aec871840acd66dfa84520bf7b8e48ce70bdb3f4baa4efd097fbe6b1/websockets-17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa87ea2ad64e59fe5622c1dc5716106b6d6e7e4b26b848cb5f5710a75c292ef5", size = 226215, upload-time = "2026-10-19T16:56:28.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/7b/cfb65557accec2eda2c80288e13eca6f1c797c579375906eb73f4053cfd1/websockets-17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65e937576141ac50fac32fa9736dbb3e0e4a7769f91be004011a09216c6ab05f", size = 227389, upload-time = "2026-10-19T16:56:29.518Z" },
+ { url = "https://files.pythonhosted.org/packages/70/a3/7e08ec4b23e376ed228831aa44b2f2a3f2e7025cc66dda4b2e53dcc2619e/websockets-17.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:35381b575f70a08d4241e73c0cde4672bb9b2a9aae8767ca6b7fb61305a052c8", size = 229534, upload-time = "2026-10-19T16:56:31.072Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/7d/8a813e3e53956f876cfaad4f6358625bbd94168aa8046c44e2f3561f12af/websockets-17.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5142798871a248b422f353e0684d51dfa119de3403a6fa0904b7cb806d9790ce", size = 227959, upload-time = "2026-10-19T16:56:32.656Z" },
+ { url = "https://files.pythonhosted.org/packages/41/23/5f3dab6ab44f2f30042db83a658a57129d5e3c0425c798b3c3e727bc060a/websockets-17.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e164c4a6868d11988c7f2386e3278ccff21c665cd7d08279b62709dd37a3167c", size = 226691, upload-time = "2026-10-19T16:56:34.101Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/49/99a3fb5a2e94d677fbd0d014a6f9a56a3d2b8e8007b7175727bc4fc4ae94/websockets-17.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10b24e836ee0233ca078e1244f6ba40343a22127a9a736f46a7e7b18de2cc711", size = 224225, upload-time = "2026-10-19T16:56:35.452Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/e5/f503a4d757858d083c90ea85dd1fd636ae9eb63c9552c75a4c38514ee7ed/websockets-17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56a76556ab04ab54a675acce2cac8b4e74ea2cec558a7c9ed37e0367b04b5d50", size = 226984, upload-time = "2026-10-19T16:56:36.887Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a6/8586844ad4b21988837624e1f4b331bc71919c85a6eb1ee3c8bd29893f27/websockets-17.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:33221e49be79d7bfe6d12bc52ef9458b256ab74fa8ac5660acfa3065de1c7870", size = 225436, upload-time = "2026-10-19T16:56:38.213Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b9/f419506494d6ffd205a9c3258f56ff5aeae6976de26361057de74f4ab659/websockets-17.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2fa79b9a48ae59744316c71a34cb9f76f30c93e117338522c5135cb7eac10351", size = 226325, upload-time = "2026-10-19T16:56:39.606Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c5/55a58faa67149e84cb98b33f9d8fda95242677ea07b78f3ecaf2e77877d6/websockets-17.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f87f02afe05c1c246012e3de100a77cfbabd17db709fe93cf074271ab239b312", size = 227342, upload-time = "2026-10-19T16:56:41.041Z" },
+ { url = "https://files.pythonhosted.org/packages/35/e0/fbe561dff24b6ef1b2034877f37b56eb9b81b3c103e2a8b03819bf2b3b6d/websockets-17.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8135a549e27e9941a70e2d2156676ef2ffeff4b06e9d564dbaaae963482afdef", size = 225036, upload-time = "2026-10-19T16:56:42.433Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/37/d80753064f8370a24d3bec74807c3928d7b0ebef5381956e2a0108b365c2/websockets-17.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ace8dd1efa5f44c482d091aa38322e20e3ffe5adcdba6b67ad9f4e7da58f3f68", size = 225807, upload-time = "2026-10-19T16:56:43.774Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/9d/a11d3168522a2959c4a5661728744d62b9eee344abdba5f9fbc1d359742f/websockets-17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2129c9a5fc05a230ce088745a790af073e582b584badc7b05db067ee326f56d7", size = 226241, upload-time = "2026-10-19T16:56:45.088Z" },
+ { url = "https://files.pythonhosted.org/packages/06/28/16107b3b83fd2b5024bfecf8d000140f555204a0191202f9c4c981137810/websockets-17.3-cp314-cp314t-win32.whl", hash = "sha256:af7c474f0d8f4d2a196370e85033873c7b063a0b5da9a4f143aa023e3bd25407", size = 218365, upload-time = "2026-10-19T16:56:46.41Z" },
+ { url = "https://files.pythonhosted.org/packages/77/dd/8b00e1dd552788d8c7762d7ef61de8f2f238d7357b1a02d4c4650b112196/websockets-17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c63ad657695e8f1a48624754cb6b464118ac50d4c1d477c26461c98390f0a62", size = 218706, upload-time = "2026-10-19T16:56:47.754Z" },
+ { url = "https://files.pythonhosted.org/packages/39/eb/5d90d57a5cbe614b2f13433aaf1c0b068c804589379061ba06812a185a89/websockets-17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:3c4f631dc8d00e470cde3ec0a35d4b58a817bea592411956f1cf4099dbb306b3", size = 218622, upload-time = "2026-10-19T16:56:49.07Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/41/1efe5ecae1f32495dc75641704f959bd2d8e56cdc825f3acc5e010a51960/websockets-17.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5", size = 218211, upload-time = "2026-10-19T16:56:50.423Z" },
+ { url = "https://files.pythonhosted.org/packages/01/ec/4edb653533241203db3f11522e122338d4fda50a41ce2a542029086f8eda/websockets-17.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32029441af953e980b9940f042b337fe4ad9a97fa8b17a4ed878ec67b5b89ef4", size = 215944, upload-time = "2026-10-19T16:56:51.766Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b9/ec450b9650925dd5cf27c1f36d0464786fe3ac702ed656879c4c0dd5c4df/websockets-17.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff", size = 216164, upload-time = "2026-10-19T16:56:53.125Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1e/63f7fbff9f0540a0331f1a62424c9ff24faa6ba8aec264f55e9e31017eea/websockets-17.3-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bdc658c9474864893e0b6b94f1390bf6a912952df1870dd93bb7d1a902b5628", size = 225608, upload-time = "2026-10-19T16:56:54.442Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/1b/f6679ad61cb8c110033a1b30d561d5c1052983e202157251ff1778f27932/websockets-17.3-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1", size = 226042, upload-time = "2026-10-19T16:56:55.727Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c6/ed993aaef8046cbbb939ae367ee3dd5383dbd055f0500a5b8aed101168b6/websockets-17.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc", size = 227203, upload-time = "2026-10-19T16:56:57.076Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/96/7057dc71affe6c8252df8648f5a225642228a6fdd8d046a6b9aa1fb0e9eb/websockets-17.3-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a004c1fe5a72c496f28c6c1d8c2dc5cfe3278d99478e18f8b838c70a912b366f", size = 228900, upload-time = "2026-10-19T16:56:58.633Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e3/8b3268bb7e72b465e55172b86d55f58aa277fb6d9bac92aee615345874df/websockets-17.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35fe35d20baa661cef4a12ee7bc42eaf2376d43ef9816ab29dd47ca957f7a73a", size = 227684, upload-time = "2026-10-19T16:56:59.975Z" },
+ { url = "https://files.pythonhosted.org/packages/59/15/fa31c0a12240b1225c3712ff080d2f7ede4f695ca90e59fdb58e8affabba/websockets-17.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8b3bede7d583bc9f47d8693a29984882e59077db1a5238ad977fc70ae8169741", size = 226580, upload-time = "2026-10-19T16:57:01.371Z" },
+ { url = "https://files.pythonhosted.org/packages/22/b5/75d911c206a3035e2ecfad107878e62c06e574b2437fa2cfb4ecef436fa3/websockets-17.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dafbb58a149d1ebd5767b3f064f911c5ee0897608005e51c3904b0ada2a8d99c", size = 223967, upload-time = "2026-10-19T16:57:02.839Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/f8/f98aa36f611acd5770819f6690125c20d55f221fd6db8d00d284b5f78e89/websockets-17.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80326bc571164bbbb6bead2e35d0c943b23b24fde629032197c58151529d24d8", size = 226689, upload-time = "2026-10-19T16:57:04.458Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/36/d52f5dff7597de6fbfc26c3d126abd668137915719aebf97abcc3be77eca/websockets-17.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:7d53fc4b90ef6e11de639dc18744d9be8cbd70ccb5ce80027aaffe7b1ab0215a", size = 225353, upload-time = "2026-10-19T16:57:05.857Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/10/dcd70ad9f9cd20e8af43284ac250af610e8bf9022da25f5b409153d033b4/websockets-17.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:fdd00937f15f5f93c8e53d8060a8c4c010431ab595a9b5b50ddd569a04603944", size = 225879, upload-time = "2026-10-19T16:57:07.249Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ab/f26c6bfa99d38ca3d1bf23145306d97c5d0c75e133a8d837373e4b3d4629/websockets-17.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1dae5cd6c92d4440b15f20aa2987baa73c47ea14baa0fec8536a542e82e0ab33", size = 227031, upload-time = "2026-10-19T16:57:08.817Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/3f/c8fd74cdb526cc65295b7468f2f84ba16678646d8174288822f99f510274/websockets-17.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b225c656efa2dedecc81651d2290170af25887b7de4f38c5efc7c5ae2b9442ab", size = 224842, upload-time = "2026-10-19T16:57:10.298Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c5/b3a07ce1c54fc0f5441642f5352662b90d7e658c966730eaf6d78a5f20bc/websockets-17.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:bd645b5d2f810a181c44f4a71135e7c365b44f4be9ed3d1892635be13a857553", size = 225744, upload-time = "2026-10-19T16:57:11.615Z" },
+ { url = "https://files.pythonhosted.org/packages/66/37/8321a8a6ab0215d4df0238e49ace5f539364cf337bb122b85c788e8c93ec/websockets-17.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d2605a3b2af8d66f8b75312d223642c065ec773446c04566d9030ad5e2db13c9", size = 226006, upload-time = "2026-10-19T16:57:13.049Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/04/8c457a7e6a260b673bf4e6f2f422eb4af5f11c48505748359706f24f1bef/websockets-17.3-cp315-cp315-win32.whl", hash = "sha256:82e60c00cfb8493a5ba27cdf9b3695fdd153f666f73e11f18968f78ead1d8705", size = 218196, upload-time = "2026-10-19T16:57:14.41Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/7c/c2df129d9e344b99bb388bcac54fbbdfeec29e6c716c12dc9a3398757940/websockets-17.3-cp315-cp315-win_amd64.whl", hash = "sha256:4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c", size = 218490, upload-time = "2026-10-19T16:57:16.234Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/4f/721b778c85559b2949e46246838e31a346dd815b3cdd8460fe517235675a/websockets-17.3-cp315-cp315-win_arm64.whl", hash = "sha256:55494475206113dbaf731dc8c5ffd5e1094175b753b7bbaad09fe3d7df61165c", size = 218403, upload-time = "2026-10-19T16:57:17.631Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/28/241d9ebb4dad79346b5a6abf9f14d7685b12ca358d250eea4920cdb1caf3/websockets-17.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:23cb6f6d64e3a389bb8ba150f947f9a6c8c0aca383e814ca3c5cffab738d5646", size = 218523, upload-time = "2026-10-19T16:57:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/bb/559fe539992102c66cbca38b488af6c7b184ab7736d819afc0f72268a2b4/websockets-17.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f27e7e8a85512b6a4b490d5756edde8e6772883a5800e1476172e44eda3f7d7c", size = 216103, upload-time = "2026-10-19T16:57:20.412Z" },
+ { url = "https://files.pythonhosted.org/packages/34/ee/e8ade4f13a3d0da68636fe44bb8ac1a3e36fc57070512788320dead42067/websockets-17.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6342f79d6f50e680c8aa59e0615d745e867e51ffd59c77093be60b7c433067af", size = 216316, upload-time = "2026-10-19T16:57:21.761Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/06/4812b2a7a5e6571d9be425e76cf54ee69aaa15720caa02307785aa5b19a7/websockets-17.3-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fc23e72bd7f8a8e540fed2244524d0a9aeb6ae3325f3d56f04a7a3debe5b04c2", size = 226467, upload-time = "2026-10-19T16:57:23.077Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/e8/adfc05d7f9b6b0cce6cc554426c50b7058609119b6b84a39171fe5ed4e50/websockets-17.3-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:722ab747ff5dc28070b1a8c020dba6466f13f2ab56bc251ad301fefa2454aa1e", size = 226736, upload-time = "2026-10-19T16:57:24.419Z" },
+ { url = "https://files.pythonhosted.org/packages/96/b2/d5b93dd933552554507edae9e3a4890269a8ea8b46019450ea9c8a30e7cc/websockets-17.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d20d6ea5aabbeba2e054d33e405192ef75dc12947bbd00ec97d23b14969d325", size = 228065, upload-time = "2026-10-19T16:57:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/10/37/642f0f1cf1c4814147a4f0586f796a0b38e42b69e5a296bfc270fb660051/websockets-17.3-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:90cc9973c51811918247a78d9ab2f54871296e4e37e69a98aba4d3c9acedbf62", size = 229692, upload-time = "2026-10-19T16:57:27.63Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/33/eac81cf62ac394b61db6a27ccbe8fb9ae382016ef73ede1edf9242a6a43e/websockets-17.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63b87c93e28957e6dc2bb92d31727a863c9bf4a4daa080f0995fdcb0242c9166", size = 228503, upload-time = "2026-10-19T16:57:29.031Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/84/e057788a9bd056f4f7d3a06b59efaaf724bbc6f2d1e1d316f8a93c951abc/websockets-17.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f7431e71072962d3ed1768038200aa4f1e76d9c2d7bedd554d57a55faaf6644", size = 227379, upload-time = "2026-10-19T16:57:30.571Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a7/cca1f9f45b893bb5b7545f2b234f910e07fcd2f8065724ac1a62a0961be2/websockets-17.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:534ed583fd4323de45d196535a444cebd1af2105e6e4e0cdcbf8cea266a8747a", size = 224719, upload-time = "2026-10-19T16:57:31.895Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/a0/5d07451027150c802ce4c9999e86994a3f0de5f4dbe3d1cce46995999269/websockets-17.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8879b6a4040ee925c638548238ab7fe612cf5fa8752fe847346da0da8be81c3f", size = 227564, upload-time = "2026-10-19T16:57:33.236Z" },
+ { url = "https://files.pythonhosted.org/packages/62/90/835f37e13fac0c615a700caae0d39deff1de2d2b9b341836a786f9b359e0/websockets-17.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58e5d1e935a90878a935c7d2737a25c71cf4f1c13b6e6bef454ab722c4f338bb", size = 226218, upload-time = "2026-10-19T16:57:34.876Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8a/718639939b947fe715501e8073350fd3f8a42f8b5eba3c0c2389c5836513/websockets-17.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:0a657b51e4a5f4ac577c11e706ff9a884ea5aaf3e2666bae283a251c63e66acb", size = 226712, upload-time = "2026-10-19T16:57:36.248Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/45/6bef8572b9b0dee51369b69776af1e4c7cc3a807f199dc3c165bbcf1199a/websockets-17.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:411aa0fb234f42a1444dea1e177890ddfc577b36ffdfafec25cb0d2c818e1af8", size = 227819, upload-time = "2026-10-19T16:57:37.586Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/3f/3b726d0b46a132e641d2d8c0243ea8a7d39464dbaf28c7a206e51fbae681/websockets-17.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:623d4b44f64efdf235b1e8dce266057073d93cde6197e7d7684f20c297f67f30", size = 225573, upload-time = "2026-10-19T16:57:39.285Z" },
+ { url = "https://files.pythonhosted.org/packages/90/96/cd972a12e1412d9b42c42ef42efbd81aa396ca9408d982d524012f011a8d/websockets-17.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5d0c871efc696ef5eb8514df64b86ab28736db753ebb42a2101f3b562f4849c0", size = 226430, upload-time = "2026-10-19T16:57:40.915Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/2d/e11524771588188a39f6da234779d4942496b96c5a10b92f3df30db48608/websockets-17.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1db2604e6aedb7a2cb2f6e0575d6c9a852ccac8239703a930ac83b63309177a5", size = 226728, upload-time = "2026-10-19T16:57:42.245Z" },
+ { url = "https://files.pythonhosted.org/packages/17/9b/63722aed973f53ea579e1ce0c4563a2b8b536629e556feff26e4c6a2a60d/websockets-17.3-cp315-cp315t-win32.whl", hash = "sha256:07246d4f0293ef0b87eff3d7dd88c1f03dd9f678fa39f6de87b2af8504e7e0de", size = 218325, upload-time = "2026-10-19T16:57:43.585Z" },
+ { url = "https://files.pythonhosted.org/packages/34/07/5e0c92e225546f1789c957c0383852797be7c899b07207e9b6eb4a93473f/websockets-17.3-cp315-cp315t-win_amd64.whl", hash = "sha256:856bbb24f2ee6436af7609267e73309e4bb0a0f9982f79f1c2e01019279c6671", size = 218641, upload-time = "2026-10-19T16:57:44.918Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/e4/339663de65c44ab3eb5209890e86eed26e1a131d6afab3df6f1c3e5b4d8c/websockets-17.3-cp315-cp315t-win_arm64.whl", hash = "sha256:a22c4956a4d858200dba83f0691bb692d77b0eeddfca80157820d20b85dea4f1", size = 218564, upload-time = "2026-10-19T16:57:46.332Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/f1/b462b1518779adf0b6fce305beee07357a79cf0724bbe61113ed6521882e/websockets-17.3-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:34c78052277053bcb09810ac8e9ba4029f9e3e277833fc18ec1c928a6246ac91", size = 215839, upload-time = "2026-10-19T16:57:47.742Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/59/7dcea408892967516cc00d491ee7bba48577059c6eb0811f436b720bcd98/websockets-17.3-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:ef07e0b6b879f699a16fc7a513b974246fdeaef44908a021130cf4afe6a1c89c", size = 216114, upload-time = "2026-10-19T16:57:49.111Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/b7/2d447e461f1160dc8dcdd8b143e2adba54d402e5f828eeb1f8469970c937/websockets-17.3-pp311-pypy311_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99c5637b74e2805ee4de9e2a43c8b45f678e50abb1c2da9880f0372f6498ba2f", size = 217065, upload-time = "2026-10-19T16:57:50.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/51/35aa5edd12b23e0001b85517ad447342c07a29fcee62009b0859825b77a7/websockets-17.3-pp311-pypy311_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c460aeb12c2bd1c204944d9e9199595bf9e505eb542018d304bf9e63cb60cad", size = 216952, upload-time = "2026-10-19T16:57:51.72Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/db/537306b92236d761b99dc56a7316c0ad46f2854cd0756585b3a9d3fe5639/websockets-17.3-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1c174b366565e101f903f9d1cc96d5508f805759dd37d012743f1c73ec0d7b", size = 217844, upload-time = "2026-10-19T16:57:53.144Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/c2/494bed87536f3822872e57b07ac9afa744af88ff64dda664b9caa32b1a72/websockets-17.3-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:c4b3f2c7d84f303da873b634d0beef83b5e1137294c3de4e23102841dac0cf4a", size = 306274, upload-time = "2026-10-19T16:57:54.525Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/1a/5582103e0362f86c4725051ded89e585ceaf750f26beace530b7e42c81c0/websockets-17.3-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:0e65260a1b1d7b81651f47343d836de70b88b02358ae3658efa6382104ec37b1", size = 215872, upload-time = "2026-10-19T16:57:55.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/a7/f69d65ed3ff9ae73d428a918300c23a992e4d50683090b8dc33cc3341d5a/websockets-17.3-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:94c02b0702656f083c098bdbd93bce22a7aa7a2b34ba56d2472efb67dab7cf58", size = 216136, upload-time = "2026-10-19T16:57:57.251Z" },
+ { url = "https://files.pythonhosted.org/packages/69/76/c64fda4ac18a3aeb32e66acec481011a766add480b6362fad80e2c3c1963/websockets-17.3-pp312-pypy312_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ade76f8c84ade325bbed96806357fffd353fcc29a5166d6dc6d39381f6d9f76c", size = 217080, upload-time = "2026-10-19T16:57:58.593Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/6d/085a3f0e6152bbe79917973f3d33cd008437de6c04675e2f15253b921f21/websockets-17.3-pp312-pypy312_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a215749c9a88ef2842e880215193e56ae60b856773c99702b078236bc6d4adb", size = 216966, upload-time = "2026-10-19T16:57:59.924Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/b8/7eb904583ea2090b329f50487e6f8dc58eb558712b9f3c16f289f0be668e/websockets-17.3-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7b5d23ef144b32645d97bd99f382c4394c443395f45fe1576786838d26e308", size = 217828, upload-time = "2026-10-19T16:58:01.822Z" },
+ { url = "https://files.pythonhosted.org/packages/79/36/f54b4a1cd9456ca0a65a76d77d181621433793aca633dfeed214748ff4f0/websockets-17.3-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:13eace1b315345ebac5b27821f5dcce24e5cf9976ab18f79e00a59bdd67e5d19", size = 306301, upload-time = "2026-10-19T16:58:03.161Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" },
+]