Files
samiandClaude Sonnet 5 3325efc1c8 pkg: add tools/testserver — the hostile HTTP server
Zero-dependency Python 3.11+ single file. 16 failure modes selectable by
URL path and combinable with '+': no-range, lies-about-accept-ranges,
etag-changes, flaky-reset (TCP RST mid-body, clean on the 3rd try),
slow-loris, redirect-chain, 401-basic, 401-digest, 403-without-referer,
416-always, content-length-mismatch, expiring-signed-url, throttled,
chunked-no-length, utf8/legacy content-disposition. Deterministic
synthetic bodies (byte i = f(seed, path, i)) with a /sha256/ reference
route so any range is independently verifiable. /__control {"reset":true}
clears flaky-mode counters between cases.

selftest.py exercises every mode (43 checks) and is registered as the
`testserver_selftest` CTest. README documents the full surface — CORE's
M1 DoD is written against it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
2026-09-09 19:22:36 +04:00

292 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Smoke test for testserver.py — every hostile mode answers the way its contract says.
Not a substitute for CORE's own conformance against it; this just guards the server from
regressing. Run directly (`tools/testserver/selftest.py`) or via ctest.
"""
from __future__ import annotations
import hashlib
import http.client
import json
import socket
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
SERVER = HERE / "testserver.py"
SEED = 42
_fail = 0
def check(name: str, cond: bool, detail: str = "") -> None:
global _fail
mark = "\033[32mok\033[0m" if cond else "\033[31mFAIL\033[0m"
print(f" {mark} {name}" + (f" — {detail}" if detail and not cond else ""))
if not cond:
_fail += 1
class Server:
def __init__(self) -> None:
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), "--port", "0", "--seed", str(SEED),
"--loris-seconds", "1", "--throttle-bps", str(256 * 1024)],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
)
self.port = int(self.proc.stdout.readline().strip())
for _ in range(50):
try:
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
break
except OSError:
time.sleep(0.05)
def conn(self) -> http.client.HTTPConnection:
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
def get(self, path: str, headers: dict | None = None):
c = self.conn()
c.request("GET", path, headers=headers or {})
r = c.getresponse()
body = r.read()
c.close()
return r, body
def raw_get(self, path: str, headers: dict | None = None) -> tuple[int, dict, bytes, bool]:
"""Returns (status, headers, body_bytes, clean_eof). Tolerates a mid-body RST."""
s = socket.create_connection(("127.0.0.1", self.port), timeout=10)
req = f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{self.port}\r\nConnection: close\r\n"
for k, v in (headers or {}).items():
req += f"{k}: {v}\r\n"
req += "\r\n"
s.sendall(req.encode())
chunks = []
clean = True
try:
while True:
b = s.recv(65536)
if not b:
break
chunks.append(b)
except ConnectionResetError:
clean = False
finally:
s.close()
raw = b"".join(chunks)
head, _, body = raw.partition(b"\r\n\r\n")
lines = head.split(b"\r\n")
status = int(lines[0].split()[1]) if lines and lines[0] else 0
hdrs = {}
for ln in lines[1:]:
if b":" in ln:
k, v = ln.split(b":", 1)
hdrs[k.decode().strip().lower()] = v.decode().strip()
return status, hdrs, body, clean
def close(self) -> None:
self.proc.terminate()
try:
self.proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self.proc.kill()
def ref_sha(srv: Server, path_mode: str, size_spec: str) -> tuple[str, int]:
r, body = srv.get(f"/{path_mode}/sha256/{size_spec}")
j = json.loads(body)
return j["sha256"], j["size"]
def main() -> int:
srv = Server()
print(f"testserver on :{srv.port} seed={SEED}")
try:
# health
r, body = srv.get("/__health")
check("health", r.status == 200 and body == b"ok")
# plain: full body matches the reference hash, Range works, 206 + Content-Range
want, size = ref_sha(srv, "plain", "256K")
r, body = srv.get("/plain/file/256K")
check("plain full 200", r.status == 200 and len(body) == size)
check("plain sha256 matches", hashlib.sha256(body).hexdigest() == want)
check("plain advertises Accept-Ranges", r.getheader("Accept-Ranges") == "bytes")
r, body = srv.get("/plain/file/256K", {"Range": "bytes=1000-1999"})
check("plain range 206", r.status == 206 and len(body) == 1000)
check("plain Content-Range", r.getheader("Content-Range") == f"bytes 1000-1999/{size}")
check("plain range bytes correct", body == full_slice(srv, "plain", "256K", 1000, 2000))
# no-range: no Accept-Ranges, Range ignored -> 200 full
r, body = srv.get("/no-range/file/128K", {"Range": "bytes=0-1023"})
check("no-range ignores Range (200)", r.status == 200 and len(body) == 128 * 1024)
check("no-range hides Accept-Ranges", r.getheader("Accept-Ranges") is None)
# lies-about-accept-ranges: advertises, ignores
r, body = srv.get("/lies-about-accept-ranges/file/128K", {"Range": "bytes=0-1023"})
check("lies advertises Accept-Ranges", r.getheader("Accept-Ranges") == "bytes")
check("lies still sends 200 full", r.status == 200 and len(body) == 128 * 1024)
# etag-changes: ETag differs across requests; If-Range mismatch -> 200
r1, _ = srv.get("/etag-changes/file/64K")
r2, _ = srv.get("/etag-changes/file/64K")
check("etag-changes: ETag varies", r1.getheader("ETag") != r2.getheader("ETag"))
r, body = srv.get("/etag-changes/file/64K",
{"Range": "bytes=0-99", "If-Range": '"stale"'})
check("etag-changes: If-Range mismatch -> 200 full", r.status == 200 and len(body) == 64 * 1024)
# 416-always: Range -> 416 with Content-Range */size; plain GET -> 200
r, body = srv.get("/416-always/file/64K", {"Range": "bytes=0-99"})
check("416-always range -> 416", r.status == 416)
check("416-always Content-Range */n", r.getheader("Content-Range") == f"bytes */{64*1024}")
r, body = srv.get("/416-always/file/64K")
check("416-always plain GET -> 200", r.status == 200 and len(body) == 64 * 1024)
# redirect-chain: 302s then 200 (http.client doesn't auto-follow; do it by hand)
status, body, hops = follow(srv, "/redirect-chain/file/16K")
check("redirect-chain lands 200", status == 200 and len(body) == 16 * 1024)
check("redirect-chain actually bounced", hops >= 3, f"{hops} hops")
# 401-basic
r, _ = srv.get("/401-basic/file/16K")
check("401-basic challenges", r.status == 401 and "Basic" in (r.getheader("WWW-Authenticate") or ""))
import base64 as _b64
tok = _b64.b64encode(b"test:test").decode()
r, body = srv.get("/401-basic/file/16K", {"Authorization": f"Basic {tok}"})
check("401-basic accepts test:test", r.status == 200 and len(body) == 16 * 1024)
# 403-without-referer
r, _ = srv.get("/403-without-referer/file/16K")
check("403 without Referer", r.status == 403)
r, body = srv.get("/403-without-referer/file/16K",
{"Referer": f"http://127.0.0.1:{srv.port}/page"})
check("403 mode ok with Referer", r.status == 200 and len(body) == 16 * 1024)
# content-length-mismatch: header length > bytes delivered, unclean close
st, hdrs, body, clean = srv.raw_get("/content-length-mismatch/file/64K")
check("clen-mismatch: declared > received",
int(hdrs.get("content-length", "0")) > len(body), f"{hdrs.get('content-length')} vs {len(body)}")
check("clen-mismatch: connection not clean", clean is False)
# flaky-reset: first two attempts cut + RST, third is clean & correct
want, size = ref_sha(srv, "flaky-reset", "64K")
results = [srv.raw_get("/flaky-reset/file/64K") for _ in range(3)]
check("flaky-reset: attempt 1 unclean", results[0][3] is False and len(results[0][2]) < size)
check("flaky-reset: attempt 2 unclean", results[1][3] is False)
check("flaky-reset: attempt 3 clean & full", results[2][3] is True and len(results[2][2]) == size)
check("flaky-reset: attempt 3 hash ok", hashlib.sha256(results[2][2]).hexdigest() == want)
# chunked-no-length
st, hdrs, body, clean = srv.raw_get("/chunked-no-length/file/32K")
check("chunked: TE chunked, no CL",
hdrs.get("transfer-encoding") == "chunked" and "content-length" not in hdrs)
dechunked = dechunk(body)
want, size = ref_sha(srv, "chunked-no-length", "32K")
check("chunked: body decodes to full file", len(dechunked) == size)
check("chunked: hash ok", hashlib.sha256(dechunked).hexdigest() == want)
# throttled: 256K at 256 KiB/s budget -> takes >= ~0.9s
t0 = time.monotonic()
r, body = srv.get("/throttled/file/512K")
dt = time.monotonic() - t0
check("throttled: rate-limited", dt >= 0.9 and len(body) == 512 * 1024, f"{dt:.2f}s")
# slow-loris: eventually completes, slowly
t0 = time.monotonic()
st, hdrs, body, clean = srv.raw_get("/slow-loris/file/8K")
dt = time.monotonic() - t0
check("slow-loris: completes", len(body) == 8 * 1024, f"got {len(body)}")
check("slow-loris: was slow", dt >= 0.5, f"{dt:.2f}s")
# utf8 / legacy content-disposition
r, _ = srv.get("/utf8-content-disposition/file/8K")
cd = r.getheader("Content-Disposition") or ""
check("utf8 CD has filename*=UTF-8''", "filename*=UTF-8''%E2%82%AC" in cd)
r, _ = srv.get("/legacy-content-disposition/file/8K")
cd = r.getheader("Content-Disposition") or ""
check("legacy CD has encoded-word", "=?UTF-8?B?" in cd)
# expiring-signed-url
r, body = srv.get("/expiring-signed-url/sign/8K?ttl=2")
signed = json.loads(body)["url"]
path = signed.split(f":{srv.port}", 1)[1]
r, body = srv.get(path)
check("signed URL works before expiry", r.status == 200 and len(body) == 8 * 1024)
r, _ = srv.get("/expiring-signed-url/file/8K")
check("unsigned request rejected", r.status == 403)
time.sleep(2.1)
r, body = srv.get(path)
check("signed URL rejected after expiry", r.status == 403 and b"expired" in body)
# __control reset: prime the counter to attempt 2, reset, next attempt is 1 again
# (unclean) rather than 3 (which would be clean).
srv.raw_get("/flaky-reset/file/4K") # attempt 1
srv.raw_get("/flaky-reset/file/4K") # attempt 2
_, _, _, clean_before = srv.raw_get("/flaky-reset/file/4K") # attempt 3 -> clean
check("flaky-reset: 3rd attempt would be clean", clean_before is True)
srv.raw_get("/flaky-reset/file/4K") # attempt 1 again after this reset...
c = srv.conn()
payload = b'{"reset":true}'
c.request("POST", "/__control", body=payload,
headers={"Content-Length": str(len(payload))})
rr = c.getresponse()
rr.read()
c.close()
check("__control reset 200", rr.status == 200)
_, _, _, clean1 = srv.raw_get("/flaky-reset/file/4K") # attempt 1 post-reset
_, _, _, clean2 = srv.raw_get("/flaky-reset/file/4K") # attempt 2 post-reset
check("__control reset the counter (1st post-reset unclean)", clean1 is False)
check("__control reset the counter (2nd post-reset unclean)", clean2 is False)
finally:
srv.close()
print()
if _fail:
print(f"\033[31m{_fail} check(s) failed\033[0m")
return 1
print("\033[32mall checks passed\033[0m")
return 0
def full_slice(srv: Server, mode: str, size_spec: str, lo: int, hi: int) -> bytes:
r, body = srv.get(f"/{mode}/file/{size_spec}", {"Range": f"bytes={lo}-{hi-1}"})
return body
def follow(srv: Server, path: str, limit: int = 10) -> tuple[int, bytes, int]:
"""Manually follow 3xx Location headers. Returns (final_status, body, hop_count)."""
hops = 0
for _ in range(limit):
r, body = srv.get(path)
if r.status in (301, 302, 303, 307, 308):
loc = r.getheader("Location") or ""
path = loc.split(f":{srv.port}", 1)[1] if f":{srv.port}" in loc else loc
hops += 1
continue
return r.status, body, hops
return 0, b"", hops
def dechunk(raw: bytes) -> bytes:
out = bytearray()
i = 0
while i < len(raw):
j = raw.find(b"\r\n", i)
if j < 0:
break
n = int(raw[i:j].split(b";")[0], 16)
if n == 0:
break
out += raw[j + 2:j + 2 + n]
i = j + 2 + n + 2
return bytes(out)
if __name__ == "__main__":
raise SystemExit(main())