blob: 853cd21c700f2d76ac69683d139401a8bc2035c9 [file]
"""Compile the bundled C test modules via apxs.
Port of the build half of Apache::TestConfigC. For each ``c-modules/<name>/`` dir
containing ``mod_<name>.c``, this:
1. generates ``c-modules/apache_httpd_test.h`` (the shared header every test
module ``#include``s, including the ``APACHE_HTTPD_TEST_MODULE`` macro and the
per-phase/per-config ``#ifndef ... NULL`` fallbacks);
2. honors ``#define HTTPD_TEST_REQUIRE_APACHE <ver>`` gating in each source,
skipping modules that require a different/newer httpd;
3. invokes apxs to build each ``.so``;
4. returns ``(symbol, so_path)`` load pairs for the config preamble.
The C sources are NOT ported — they compile exactly as the Perl framework builds
them (custom C modules need only be compiled, per the migration requirement). The
generated header is reproduced functionally (the C preprocessor is insensitive to
the macro's internal whitespace), and correctness is proven by the modules
actually compiling and loading in the smoke test.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from pyhttpd.cmodules import LIB_SUBDIR as _LIB_SUBDIR
from pyhttpd.cmodules import clean_module_dir, ensure_built
from .probe import HttpdInfo
# Hook phases registered by APACHE_HTTPD_TEST_MODULE for Apache 2.x
# (TestConfigC @cmodule_phases).
_HOOK_PHASES = [
"post_read_request",
"translate_name",
"header_parser",
"access_checker",
"check_user_id",
"auth_checker",
"type_checker",
"fixups",
"handler",
"log_transaction",
"child_init",
]
# Module-struct config slots (TestConfigC @cmodule_config_names).
_CONFIG_NAMES = [
"per_dir_create",
"per_dir_merge",
"per_srv_create",
"per_srv_merge",
"commands",
]
# Static portion of the header (TestConfigC __DATA__), verbatim.
_HEADER_PRELUDE = """\
#ifndef APACHE_HTTPD_TEST_H
#define APACHE_HTTPD_TEST_H
/* headers present in both 1.x and 2.x */
#include "httpd.h"
#include "http_config.h"
#include "http_protocol.h"
#include "http_request.h"
#include "http_log.h"
#include "http_main.h"
#include "http_core.h"
#include "ap_config.h"
#ifdef APACHE1
#define AP_METHOD_BIT 1
typedef size_t apr_size_t;
typedef array_header apr_array_header_t;
#define APR_OFF_T_FMT "ld"
#define APR_SIZE_T_FMT "lu"
#endif /* APACHE1 */
#ifdef APACHE2
#ifndef APACHE_HTTPD_TEST_HOOK_ORDER
#define APACHE_HTTPD_TEST_HOOK_ORDER APR_HOOK_MIDDLE
#endif
#include "ap_compat.h"
#endif /* APACHE2 */
#endif /* APACHE_HTTPD_TEST_H */
"""
_REQUIRE_RE = re.compile(
r"^\s*#define\s+HTTPD_TEST_REQUIRE_APACHE\s+(\d+(?:\.\d+){0,2})", re.MULTILINE
)
@dataclass
class CModule:
name: str # e.g. "echo_post"
src_dir: Path
src: Path
@property
def symbol(self) -> str:
# APACHE_HTTPD_TEST_MODULE(name) registers <name>_module; LoadModule wants
# the bare module name as the symbol (TestConfigC: sym => "${name}_module").
return self.name
@property
def so(self) -> Path:
return self.src_dir / _LIB_SUBDIR / f"mod_{self.name}.so"
def _define_name(token: str) -> str:
# Perl cmodule_define_name uppercases the token: "APACHE_HTTPD_TEST_\U$name"
# (TestConfigC.pm:340-343). The module sources #define the UPPERCASE form
# (e.g. APACHE_HTTPD_TEST_COMMANDS), so the header's #ifndef guards and the
# module-struct macro must use the same case or every per-module override
# (commands/handler/...) silently falls through to the NULL fallback.
return token if token == "NULL" else f"APACHE_HTTPD_TEST_{token.upper()}"
def _ifndef_null(token: str) -> str:
h = _define_name(token)
return f"#ifndef {h}\n#define {h} NULL\n#endif\n"
def generate_header(dest: Path) -> None:
"""Write ``apache_httpd_test.h`` (cmodules_generate_include, Apache 2.x)."""
parts: list[str] = [_HEADER_PRELUDE]
# per-phase + per-config NULL fallbacks
parts += [_ifndef_null(p) for p in _HOOK_PHASES]
parts += [_ifndef_null(c) for c in _CONFIG_NAMES]
# EXTRA_HOOKS fallback
parts.append(
"#ifndef APACHE_HTTPD_TEST_EXTRA_HOOKS\n"
"#define APACHE_HTTPD_TEST_EXTRA_HOOKS(p) do { } while (0)\n"
"#endif\n"
)
# APACHE_HTTPD_TEST_MODULE(name) macro (backslash-continued).
config_hooks = ", ".join(_define_name(c) for c in _CONFIG_NAMES)
hook_calls = "".join(
f" if ({_define_name(p)} != NULL) "
f"ap_hook_{p}({_define_name(p)}, NULL, NULL, APACHE_HTTPD_TEST_HOOK_ORDER); "
for p in _HOOK_PHASES
)
macro_body = (
f"static void name ## _register_hooks(apr_pool_t *p) {{ "
f"{hook_calls}APACHE_HTTPD_TEST_EXTRA_HOOKS(p); }} "
f"module AP_MODULE_DECLARE_DATA name ## _module = {{ "
f"STANDARD20_MODULE_STUFF, {config_hooks}, "
f"name ## _register_hooks }}"
)
macro = "#define APACHE_HTTPD_TEST_MODULE(name) \\\n " + macro_body + "\n"
parts.append(macro)
dest.write_text("\n".join(parts))
def _module_requirement(src: Path) -> str | None:
m = _REQUIRE_RE.search(src.read_text(errors="replace"))
return m.group(1) if m else None
def _meets_requirement(req: str, info: HttpdInfo) -> bool:
"""True if the running httpd satisfies a HTTPD_TEST_REQUIRE_APACHE value.
A bare integer ("1"/"2") means "major version must equal"; a dotted value
("2.1", "2.4.49") means "version must be >=" (TestConfigC::cmodule_find).
"""
if "." not in req:
return info.version[0] == int(req)
parts = tuple(int(x) for x in req.split("."))
parts = parts + (0,) * (3 - len(parts))
return info.version >= parts
def discover(cmodules_dir: Path, info: HttpdInfo) -> tuple[list[CModule], dict[str, str]]:
"""Find buildable modules; return (modules, {skipped_name: reason})."""
mods: list[CModule] = []
skipped: dict[str, str] = {}
if not cmodules_dir.is_dir():
return mods, skipped
for sub in sorted(p for p in cmodules_dir.iterdir() if p.is_dir()):
src = sub / f"mod_{sub.name}.c"
if not src.is_file():
continue
req = _module_requirement(src)
if req and not _meets_requirement(req, info):
skipped[sub.name] = f"requires Apache {req}"
continue
mods.append(CModule(name=sub.name, src_dir=sub, src=src))
return mods, skipped
def clean_modules(cmodules_dir: Path) -> None:
"""Remove all apxs build artifacts from ``cmodules_dir`` (emulate ``make clean``).
Deletes the ``.libs/`` directory and libtool intermediates (``*.o``, ``*.lo``,
``*.slo``, ``*.la``) from every module subdirectory, and the generated
``apache_httpd_test.h`` header. Safe to call when nothing has been built yet.
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()):
clean_module_dir(sub)
header = cmodules_dir / "apache_httpd_test.h"
header.unlink(missing_ok=True)
def compile_all(
cmodules_dir: Path,
apxs: Path,
info: HttpdInfo,
*,
defines: list[str] | None = None,
force: bool = False,
) -> tuple[list[tuple[str, Path]], dict[str, str]]:
"""Generate the header, build each eligible module, return load pairs + skips.
:param defines: extra ``-D`` flags (default ``["APACHE2", "APACHE2_4"]``).
:raises RuntimeError: if apxs fails for a module (captured output included).
"""
defines = defines if defines is not None else ["APACHE2", "APACHE2_4"]
header = cmodules_dir / "apache_httpd_test.h"
generate_header(header)
mods, skipped = discover(cmodules_dir, info)
loads: list[tuple[str, Path]] = []
for mod in mods:
# 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