| """Raw socket helper for protocol-level tests. |
| |
| Port of the raw-socket side of Apache::TestRequest (``vhost_socket`` / ``getline`` |
| / ``socket_trace``). Many httpd tests bypass the HTTP client and speak HTTP (or a |
| malformed approximation of it) directly over a TCP socket -- e.g. CVE regression |
| tests that send a hand-built request line and assert on the raw status line. |
| |
| :class:`VhostSocket` wraps a ``socket.socket`` with the small line-oriented API |
| those tests use: ``print`` (send), ``getline`` (read one CRLF/LF-terminated line), |
| ``read`` (drain), and ``connected``. It is a context manager so callers can use |
| ``with http.vhost_socket(...) as sock:``. |
| """ |
| |
| from __future__ import annotations |
| |
| import socket |
| import ssl as _ssl |
| |
| |
| class VhostSocket: |
| """A thin line-oriented wrapper over a connected TCP (optionally TLS) socket.""" |
| |
| def __init__(self, sock: socket.socket) -> None: |
| self._sock = sock |
| self._buf = b"" |
| self._trace = False |
| |
| # -- Apache::TestRequest socket API ---------------------------------- |
| |
| @property |
| def connected(self) -> bool: |
| try: |
| self._sock.getpeername() |
| return True |
| except OSError: |
| return False |
| |
| def socket_trace(self, on: bool = True) -> None: |
| """Enable echoing of sent/received data (Apache::TestRequest::socket_trace).""" |
| self._trace = on |
| |
| def print(self, data: str | bytes) -> int: |
| """Send ``data`` (str encoded latin-1, preserving raw bytes). Returns 1 (ok).""" |
| raw = data.encode("latin-1") if isinstance(data, str) else data |
| if self._trace: |
| print(f"S> {raw!r}") |
| self._sock.sendall(raw) |
| return 1 |
| |
| # Perl idiom: $sock->print(...). Keep `send` too for clarity. |
| send = print |
| |
| def getline(self, timeout: float | None = 10.0) -> str | None: |
| """Read one line (up to and incl. the newline), returned as a str. |
| |
| Mirrors Apache::TestRequest::getline: returns the line including its |
| trailing ``\\n``, or None at EOF. Buffers across reads. |
| """ |
| if timeout is not None: |
| self._sock.settimeout(timeout) |
| while b"\n" not in self._buf: |
| try: |
| chunk = self._sock.recv(4096) |
| except (TimeoutError, socket.timeout): |
| break |
| if not chunk: |
| break |
| self._buf += chunk |
| if not self._buf: |
| return None |
| nl = self._buf.find(b"\n") |
| if nl == -1: |
| line, self._buf = self._buf, b"" |
| else: |
| line, self._buf = self._buf[: nl + 1], self._buf[nl + 1 :] |
| text = line.decode("latin-1") |
| if self._trace: |
| print(f"C< {text!r}") |
| return text |
| |
| def read(self, timeout: float | None = 10.0) -> str: |
| """Drain and return everything still readable (decoded latin-1).""" |
| if timeout is not None: |
| self._sock.settimeout(timeout) |
| data = self._buf |
| self._buf = b"" |
| while True: |
| try: |
| chunk = self._sock.recv(4096) |
| except (TimeoutError, socket.timeout): |
| break |
| if not chunk: |
| break |
| data += chunk |
| return data.decode("latin-1") |
| |
| def close(self) -> None: |
| try: |
| self._sock.close() |
| except OSError: |
| pass |
| |
| def __enter__(self) -> VhostSocket: |
| return self |
| |
| def __exit__(self, *exc: object) -> None: |
| self.close() |
| |
| |
| def open_vhost_socket( |
| host: str, port: int, *, use_ssl: bool = False, timeout: float = 10.0 |
| ) -> VhostSocket: |
| """Open a raw TCP (optionally TLS) connection to ``host:port``. |
| |
| TLS connections are wrapped without certificate verification -- protocol |
| tests care about the wire behaviour, not the peer identity (matching the |
| Perl tests, which use IO::Socket::SSL with the test CA / no verification). |
| """ |
| raw = socket.create_connection((host, port), timeout=timeout) |
| if use_ssl: |
| ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT) |
| ctx.check_hostname = False |
| ctx.verify_mode = _ssl.CERT_NONE |
| raw = ctx.wrap_socket(raw, server_hostname=host) |
| return VhostSocket(raw) |