blob: 40467082ccacccc3d45877f700d9231c2ffb2d91 [file]
"""Toolchain probing for httpd.
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 ``<IfModule>`` 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 "<name>.c". Membership here
# means "this module exists and can be loaded", which is what drives the
# <IfModule> 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/<name>
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*$"
)
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 ``<prefix>/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,
)