Files
PacketStation/tests/server/fakeclam.py
T

169 lines
5.4 KiB
Python
Raw Normal View History

"""A fake clamd for tests: a threading TCP server that speaks the part of the clamd protocol Packet Station uses.
PING answers PONG. INSTREAM reads 4-byte big-endian chunk lengths and the chunks until a zero length, then answers
`stream: OK`, or `stream: Eicar-Test-Signature FOUND` when the bytes hold the EICAR test string. A command sent as
`z<CMD>\0` gets its answer ended by NUL, `n<CMD>\n` or a bare `<CMD>\n` by a newline.
`FakeClam(refuse=True)` reserves a port and closes it, so a connection is refused. `mode="garbage"` answers
INSTREAM with a line that is not a clamd answer.
"""
from __future__ import annotations
import socket
import socketserver
import struct
import threading
from typing import Any
# Built from parts so the source file itself does not hold the test string (a desktop scanner would flag it).
EICAR = rb"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + b"EICAR-STANDARD-" + b"ANTIVIRUS-TEST-FILE!$H+H*"
SIGNATURE = "Eicar-Test-Signature"
def _read_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("client closed the stream early")
buf += chunk
return buf
def _read_command(sock: socket.socket) -> tuple[str, bytes]:
"""The command name and the terminator its answer uses."""
first = _read_exact(sock, 1)
if first == b"z":
end = b"\0"
elif first == b"n":
end = b"\n"
else:
end = b"\n"
buf = b"" if first in (b"z", b"n") else first
while True:
c = _read_exact(sock, 1)
if c in (b"\0", b"\n"):
break
buf += c
if len(buf) > 64:
break
return buf.decode("ascii", "replace"), end
class _Handler(socketserver.BaseRequestHandler):
server: _Server
def handle(self) -> None:
sock: socket.socket = self.request
sock.settimeout(10)
try:
cmd, end = _read_command(sock)
except (ConnectionError, OSError):
return
fake = self.server.fake
with fake.lock:
fake.commands.append(cmd)
if cmd == "PING":
sock.sendall(b"PONG" + end)
return
if cmd == "INSTREAM":
data = b""
try:
while True:
(n,) = struct.unpack(">I", _read_exact(sock, 4))
if n == 0:
break
data += _read_exact(sock, n)
except (ConnectionError, OSError):
return
with fake.lock:
fake.scanned.append(data)
if fake.mode == "garbage":
sock.sendall(b"what is this" + end)
elif EICAR in data:
sock.sendall(f"stream: {SIGNATURE} FOUND".encode() + end)
else:
sock.sendall(b"stream: OK" + end)
return
sock.sendall(b"UNKNOWN COMMAND" + end)
class _Server(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
fake: FakeClam
class FakeClam:
"""Start with `with FakeClam() as clam:`; point settings at `clam.host` and `clam.port`."""
def __init__(self, *, refuse: bool = False, mode: str = "normal") -> None:
self.host = "127.0.0.1"
self.refuse = refuse
self.mode = mode
self.lock = threading.Lock()
self.commands: list[str] = []
self.scanned: list[bytes] = []
self._server: _Server | None = None
self._thread: threading.Thread | None = None
self.port = 0
@property
def calls(self) -> int:
with self.lock:
return len(self.commands)
def start(self) -> FakeClam:
if self.refuse:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((self.host, 0))
self.port = int(s.getsockname()[1])
s.close() # nothing listens there now: a connect is refused
return self
self._server = _Server((self.host, 0), _Handler)
self._server.fake = self
self.port = int(self._server.server_address[1])
self._thread = threading.Thread(target=self._server.serve_forever, name="fakeclam", daemon=True)
self._thread.start()
return self
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._thread is not None:
self._thread.join(5)
self._thread = None
def __enter__(self) -> FakeClam:
return self.start()
def __exit__(self, *exc: Any) -> None:
self.stop()
def ping(host: str, port: int) -> bytes:
"""A raw zPING, for checking the fake itself."""
with socket.create_connection((host, port), timeout=5) as s:
s.sendall(b"zPING\0")
return s.recv(64)
def instream(host: str, port: int, data: bytes, chunk: int = 7) -> bytes:
"""A raw zINSTREAM in small chunks, for checking the fake itself."""
with socket.create_connection((host, port), timeout=5) as s:
s.sendall(b"zINSTREAM\0")
for i in range(0, len(data), chunk):
part = data[i : i + chunk]
s.sendall(struct.pack(">I", len(part)) + part)
s.sendall(struct.pack(">I", 0))
out = b""
while not out.endswith(b"\0"):
got = s.recv(256)
if not got:
break
out += got
return out