From 08f9089eb49d3df9ef3105442488710d1417ef6a Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 25 Sep 2026 20:13:52 +0100 Subject: [PATCH 1/4] * .gitignore: Ignore *.gcda. No functional change. [skip ci] Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ad5af3e4884..58dcb2a7f32 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.lo *.la *.slo +*.gcda *.so *.vcproj *.vcproj.* From 28e2f552117247a601e5c2523a36184a95ece592 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 25 Sep 2026 20:21:22 +0100 Subject: [PATCH 2/4] * modules/generators/mod_cgid.c (cgid_server): Log the daemon's pid at TRACE1. Co-Authored-By: Claude Opus 5.5 (1M context) --- modules/generators/mod_cgid.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/generators/mod_cgid.c b/modules/generators/mod_cgid.c index 42bd384c20a..543277112de 100644 --- a/modules/generators/mod_cgid.c +++ b/modules/generators/mod_cgid.c @@ -801,6 +801,10 @@ static int cgid_server(void *data) return rc; } + ap_log_error(APLOG_MARK, APLOG_TRACE1, 0, main_server, + "cgid daemon listening on %s, pid %" APR_PID_T_FMT, + sockname, getpid()); + while (!daemon_should_exit) { int errfileno; char *argv0 = NULL; From 50b74e77f545bd26674f5c24c0a7e7cf31ee58e6 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 25 Sep 2026 20:22:42 +0100 Subject: [PATCH 3/4] New test suite. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/modules/generators/__init__.py | 0 test/modules/generators/conftest.py | 35 +++ test/modules/generators/env.py | 25 +++ test/modules/generators/htdocs/cgi/argv.py | 6 + test/modules/generators/htdocs/cgi/noread.py | 3 + .../generators/htdocs/cgi/nph-hello.py | 4 + .../modules/generators/htdocs/cgi/redirect.py | 2 + test/modules/generators/htdocs/cgi/sleep.py | 6 + .../generators/htdocs/cgi/sleep_trap.py | 8 + .../htdocs/cgi/ssi/exec_bogus.shtml | 1 + .../generators/htdocs/cgi/ssi/exec_cgi.shtml | 1 + .../htdocs/cgi/ssi/exec_cgi_missing.shtml | 1 + .../htdocs/cgi/ssi/exec_cgi_nph.shtml | 1 + .../htdocs/cgi/ssi/exec_cgi_query.shtml | 1 + .../htdocs/cgi/ssi/exec_cgi_redirect.shtml | 1 + .../htdocs/cgi/ssi/exec_noexec.shtml | 1 + .../htdocs/cgi/ssi/exec_path_info.shtml | 1 + .../generators/htdocs/cgi/stderr_fail.py | 5 + test/modules/generators/test_001_cgid.py | 201 ++++++++++++++++++ 19 files changed, 303 insertions(+) create mode 100644 test/modules/generators/__init__.py create mode 100644 test/modules/generators/conftest.py create mode 100644 test/modules/generators/env.py create mode 100644 test/modules/generators/htdocs/cgi/argv.py create mode 100644 test/modules/generators/htdocs/cgi/noread.py create mode 100644 test/modules/generators/htdocs/cgi/nph-hello.py create mode 100644 test/modules/generators/htdocs/cgi/redirect.py create mode 100644 test/modules/generators/htdocs/cgi/sleep.py create mode 100644 test/modules/generators/htdocs/cgi/sleep_trap.py create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml create mode 100644 test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml create mode 100644 test/modules/generators/htdocs/cgi/stderr_fail.py create mode 100644 test/modules/generators/test_001_cgid.py diff --git a/test/modules/generators/__init__.py b/test/modules/generators/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/modules/generators/conftest.py b/test/modules/generators/conftest.py new file mode 100644 index 00000000000..c10995fc7ee --- /dev/null +++ b/test/modules/generators/conftest.py @@ -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 --git a/test/modules/generators/env.py b/test/modules/generators/env.py new file mode 100644 index 00000000000..8006652e3a2 --- /dev/null +++ b/test/modules/generators/env.py @@ -0,0 +1,25 @@ +import inspect +import logging +import os + +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"]) + + +class GeneratorsTestEnv(HttpdTestEnv): + + def __init__(self, pytestconfig=None): + super().__init__(pytestconfig=pytestconfig) + self.add_httpd_log_modules(["cgid", "include", "core"]) + + def setup_httpd(self, setup: HttpdTestSetup = None): + super().setup_httpd(setup=GeneratorsTestSetup(env=self)) diff --git a/test/modules/generators/htdocs/cgi/argv.py b/test/modules/generators/htdocs/cgi/argv.py new file mode 100644 index 00000000000..dfa0285e1f9 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/argv.py @@ -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 --git a/test/modules/generators/htdocs/cgi/noread.py b/test/modules/generators/htdocs/cgi/noread.py new file mode 100644 index 00000000000..f66e144e349 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/noread.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +print("Content-Type: text/plain\n") +print("not reading") diff --git a/test/modules/generators/htdocs/cgi/nph-hello.py b/test/modules/generators/htdocs/cgi/nph-hello.py new file mode 100644 index 00000000000..6249a6a7fd0 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/nph-hello.py @@ -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 --git a/test/modules/generators/htdocs/cgi/redirect.py b/test/modules/generators/htdocs/cgi/redirect.py new file mode 100644 index 00000000000..a662ea3cc94 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/redirect.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +print("Location: http://example.invalid/target\n") diff --git a/test/modules/generators/htdocs/cgi/sleep.py b/test/modules/generators/htdocs/cgi/sleep.py new file mode 100644 index 00000000000..aea2d1fbee9 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/sleep.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +import time + +time.sleep(5) +print("Content-Type: text/plain\n") +print("too late") diff --git a/test/modules/generators/htdocs/cgi/sleep_trap.py b/test/modules/generators/htdocs/cgi/sleep_trap.py new file mode 100644 index 00000000000..e8c46430d8c --- /dev/null +++ b/test/modules/generators/htdocs/cgi/sleep_trap.py @@ -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 --git a/test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml new file mode 100644 index 00000000000..4045ffc5bca --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_bogus.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml new file mode 100644 index 00000000000..cabe2003d3e --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_cgi.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml new file mode 100644 index 00000000000..6e66d9c15ef --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_missing.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml new file mode 100644 index 00000000000..f0aa9f0eefd --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_nph.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml new file mode 100644 index 00000000000..81f3f8ec160 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_query.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml new file mode 100644 index 00000000000..c24a0e76673 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_cgi_redirect.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml new file mode 100644 index 00000000000..58ab9871722 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_noexec.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml b/test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml new file mode 100644 index 00000000000..94b4750bf40 --- /dev/null +++ b/test/modules/generators/htdocs/cgi/ssi/exec_path_info.shtml @@ -0,0 +1 @@ + diff --git a/test/modules/generators/htdocs/cgi/stderr_fail.py b/test/modules/generators/htdocs/cgi/stderr_fail.py new file mode 100644 index 00000000000..50bbd920a2a --- /dev/null +++ b/test/modules/generators/htdocs/cgi/stderr_fail.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import sys + +sys.stderr.write("cgid-stderr-marker\n") +sys.exit(1) diff --git a/test/modules/generators/test_001_cgid.py b/test/modules/generators/test_001_cgid.py new file mode 100644 index 00000000000..2b54d4fe683 --- /dev/null +++ b/test/modules/generators/test_001_cgid.py @@ -0,0 +1,201 @@ +import os +import re +import signal +import sys +import textwrap +import time + +import pytest + +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): + conf = HttpdConf(env, extras={ + 'base': textwrap.dedent(f""" + LogLevel cgid:trace1 + + 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 + + """), + }) + conf.add_vhost_cgi() + conf.install() + assert env.apache_restart() == 0 + + 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 + + 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" + + 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" + + 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" From a71266e7d5541b46c2d6d934aa76a2ba9a2fb28e Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 25 Sep 2026 22:11:23 +0100 Subject: [PATCH 4/4] * test/modules/generators/env.py (GeneratorsTestEnv.has_cgid_module): New. (GeneratorsTestEnv.__init__): Only log cgid if it is built. * test/modules/generators/test_001_cgid.py (TestCgid._class_scope): Skip if mod_cgid is not built. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/modules/generators/env.py | 16 +++++++++++++++- test/modules/generators/test_001_cgid.py | 2 ++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/test/modules/generators/env.py b/test/modules/generators/env.py index 8006652e3a2..3fa111716ca 100644 --- a/test/modules/generators/env.py +++ b/test/modules/generators/env.py @@ -1,6 +1,7 @@ import inspect import logging import os +import subprocess from pyhttpd.env import HttpdTestEnv, HttpdTestSetup @@ -19,7 +20,20 @@ class GeneratorsTestEnv(HttpdTestEnv): def __init__(self, pytestconfig=None): super().__init__(pytestconfig=pytestconfig) - self.add_httpd_log_modules(["cgid", "include", "core"]) + # 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 --git a/test/modules/generators/test_001_cgid.py b/test/modules/generators/test_001_cgid.py index 2b54d4fe683..afaeb3234ad 100644 --- a/test/modules/generators/test_001_cgid.py +++ b/test/modules/generators/test_001_cgid.py @@ -18,6 +18,8 @@ class TestCgid: @pytest.fixture(autouse=True, scope='class') def _class_scope(self, env): + if not env.has_cgid_module: + pytest.skip("mod_cgid not built") conf = HttpdConf(env, extras={ 'base': textwrap.dedent(f""" LogLevel cgid:trace1