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.*
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;
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..3fa111716ca
--- /dev/null
+++ b/test/modules/generators/env.py
@@ -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"])
+
+
+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 --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..afaeb3234ad
--- /dev/null
+++ b/test/modules/generators/test_001_cgid.py
@@ -0,0 +1,203 @@
+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):
+ if not env.has_cgid_module:
+ pytest.skip("mod_cgid not built")
+ 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"