Inflate every receive-pack object under strict pack, size, and checksum bounds, resolve deltas against in-pack bases only, and scan the real decompressed payloads for runtime-token forms, private keys, SSH key material, and known provider token formats. Thin packs are rejected so no pushed content escapes the scan, and upstream Git exchanges now run under one absolute stream deadline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
238 lines
7.5 KiB
Python
238 lines
7.5 KiB
Python
"""Bounded I/O and concurrency contracts for the Hermes SCM broker."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import json
|
||
from email.message import Message
|
||
|
||
import pytest
|
||
|
||
from testing.tests.test_hermes_scm_broker_support import Response, _load
|
||
|
||
|
||
def test_agent_broker_client_sends_no_credential_or_authorization_header():
|
||
client = _load("scm_broker_client")
|
||
seen = []
|
||
|
||
def opener(request, timeout):
|
||
seen.append((request, timeout))
|
||
return Response(b"{}", content_type="application/json")
|
||
|
||
assert client.read("/api/v1/repos/atlas/cassandra", opener=opener) == b"{}"
|
||
request = seen[0][0]
|
||
assert request.full_url == client.BROKER_ORIGIN + "/v1/metadata"
|
||
assert request.get_header("Authorization") is None
|
||
assert json.loads(request.data) == {"path": "/api/v1/repos/atlas/cassandra"}
|
||
|
||
|
||
@pytest.mark.parametrize("raw", ["", "01", "9" * 4000, "134217729", "12"])
|
||
def test_broker_content_length_is_canonical_and_bounded(raw: str):
|
||
broker = _load("scm_broker")
|
||
headers = Message()
|
||
headers["Content-Length"] = raw
|
||
|
||
with pytest.raises(broker.PolicyError, match="request length"):
|
||
broker._content_length(headers, broker.MAX_GIT_REQUEST)
|
||
|
||
|
||
def test_large_git_exchange_rolls_to_disk_and_scans_chunk_boundaries():
|
||
broker = _load("scm_broker")
|
||
token = "runtime-sentinel"
|
||
safe = b"x" * (broker.SPOOL_MEMORY_LIMIT + 1)
|
||
inbound_timeouts = []
|
||
spool, length = broker._spool_bounded(
|
||
io.BytesIO(safe),
|
||
broker.MAX_GIT_REQUEST,
|
||
len(safe),
|
||
token=token,
|
||
context="Git request",
|
||
deadline=broker.time.monotonic() + 30,
|
||
set_timeout=inbound_timeouts.append,
|
||
)
|
||
try:
|
||
assert length == len(safe)
|
||
assert spool._rolled is True
|
||
assert spool.read(4) == b"xxxx"
|
||
assert inbound_timeouts
|
||
assert all(0 < value <= 30 for value in inbound_timeouts)
|
||
finally:
|
||
spool.close()
|
||
|
||
crossing = b"x" * (broker.STREAM_CHUNK - 5) + token.encode() + b"tail"
|
||
with pytest.raises(broker.PolicyError, match="credential material"):
|
||
broker._spool_bounded(
|
||
io.BytesIO(crossing),
|
||
broker.MAX_GIT_REQUEST,
|
||
len(crossing),
|
||
token=token,
|
||
context="Git request",
|
||
deadline=broker.time.monotonic() + 30,
|
||
)
|
||
|
||
|
||
def test_large_upstream_response_is_streamed_through_bounded_spool():
|
||
broker = _load("scm_broker")
|
||
body = b"z" * (broker.SPOOL_MEMORY_LIMIT + 1)
|
||
seen = []
|
||
|
||
def opener(request, **_kwargs):
|
||
seen.append(request)
|
||
assert request.get_header("Content-length") == "7"
|
||
assert request.data.read() == b"request"
|
||
return Response(body, content_type="application/x-git-upload-pack-result")
|
||
|
||
streamed = broker._upstream_git_request(
|
||
"/atlas/cassandra.git/git-upload-pack",
|
||
method="POST",
|
||
body=io.BytesIO(b"request"),
|
||
body_length=7,
|
||
content_type="application/x-git-upload-pack-request",
|
||
expected_type="application/x-git-upload-pack-result",
|
||
token="runtime-sentinel",
|
||
stream_result=True,
|
||
opener=opener,
|
||
)
|
||
spool, length = streamed
|
||
try:
|
||
assert length == len(body)
|
||
assert spool._rolled is True
|
||
assert spool.read(3) == b"zzz"
|
||
assert len(seen) == 1
|
||
finally:
|
||
spool.close()
|
||
|
||
|
||
def test_body_deadline_fails_closed_before_reading():
|
||
broker = _load("scm_broker")
|
||
with pytest.raises(broker.PolicyError, match="deadline"):
|
||
broker._spool_bounded(
|
||
io.BytesIO(b"body"),
|
||
broker.MAX_GIT_REQUEST,
|
||
4,
|
||
token="runtime-sentinel",
|
||
context="Git request",
|
||
deadline=broker.time.monotonic() - 1,
|
||
)
|
||
|
||
|
||
def test_upstream_stream_applies_absolute_socket_deadline():
|
||
broker = _load("scm_broker")
|
||
|
||
class DeadlineSocket:
|
||
def __init__(self):
|
||
self.values = []
|
||
|
||
def settimeout(self, value):
|
||
self.values.append(value)
|
||
|
||
response = Response(b"safe", content_type="application/x-git-upload-pack-result")
|
||
sock = DeadlineSocket()
|
||
response.fp = type("FP", (), {"raw": type("Raw", (), {"_sock": sock})()})()
|
||
spool, length = broker._spool_response(
|
||
response, broker.MAX_GIT_RESPONSE, "sentinel", broker.INBOUND_BODY_TIMEOUT
|
||
)
|
||
try:
|
||
assert length == 4
|
||
assert sock.values
|
||
assert all(0 < value <= broker.INBOUND_BODY_TIMEOUT for value in sock.values)
|
||
finally:
|
||
spool.close()
|
||
|
||
|
||
def test_broker_rejects_work_when_concurrency_slots_are_exhausted():
|
||
broker = _load("scm_broker")
|
||
|
||
class FakeSocket:
|
||
def __init__(self):
|
||
self.value = b""
|
||
|
||
def sendall(self, value):
|
||
self.value += value
|
||
|
||
def shutdown(self, _how):
|
||
return None
|
||
|
||
def close(self):
|
||
return None
|
||
|
||
server = broker.BoundedThreadingHTTPServer(("127.0.0.1", 0), broker.BrokerHandler)
|
||
try:
|
||
assert broker.BoundedThreadingHTTPServer.__module__ == "scm_broker_server"
|
||
assert server._slots.acquire(blocking=False)
|
||
assert server._slots.acquire(blocking=False)
|
||
assert server._slots.acquire(blocking=False)
|
||
request = FakeSocket()
|
||
server.process_request(request, ("127.0.0.1", 1))
|
||
assert b"503 Service Unavailable" in request.value
|
||
finally:
|
||
server._slots.release()
|
||
server._slots.release()
|
||
server._slots.release()
|
||
server.server_close()
|
||
|
||
|
||
def test_three_simultaneous_broker_calls_have_bounded_capacity():
|
||
server_module = _load("scm_broker_server")
|
||
semaphore = server_module.threading.BoundedSemaphore(
|
||
server_module.MAX_CONCURRENT_REQUESTS
|
||
)
|
||
assert server_module.MAX_CONCURRENT_REQUESTS >= 3
|
||
assert [semaphore.acquire(blocking=False) for _ in range(3)] == [True, True, True]
|
||
assert semaphore.acquire(blocking=False) is False
|
||
|
||
|
||
def test_header_deadline_is_absolute_against_slow_trickle(monkeypatch):
|
||
server_module = _load("scm_broker_server")
|
||
|
||
class Stream:
|
||
def __init__(self):
|
||
self.value = io.BytesIO(b"GET /healthz HTTP/1.1\r\n")
|
||
|
||
def read(self, length):
|
||
return self.value.read(length)
|
||
|
||
class Connection:
|
||
def __init__(self):
|
||
self.timeouts = []
|
||
|
||
def settimeout(self, value):
|
||
self.timeouts.append(value)
|
||
|
||
ticks = iter((0.0, 0.02, 0.04, 0.06, 0.08, 0.11))
|
||
monkeypatch.setattr(server_module.time, "monotonic", lambda: next(ticks))
|
||
connection = Connection()
|
||
reader = server_module._AbsoluteDeadlineReader(Stream(), connection)
|
||
reader.begin(0.1)
|
||
|
||
with pytest.raises(TimeoutError, match="absolute SCM header deadline"):
|
||
reader.readline(65537)
|
||
|
||
assert connection.timeouts == pytest.approx([0.08, 0.06, 0.04, 0.02])
|
||
|
||
|
||
def test_broker_bounds_header_count_size_and_duplicate_lengths():
|
||
broker = _load("scm_broker")
|
||
|
||
def validate(headers):
|
||
handler = object.__new__(broker.BrokerHandler)
|
||
handler.headers = headers
|
||
handler._validate_headers()
|
||
|
||
too_many = Message()
|
||
for index in range(broker.MAX_HEADERS + 1):
|
||
too_many[f"X-Test-{index}"] = "safe"
|
||
with pytest.raises(broker.PolicyError, match="too many headers"):
|
||
validate(too_many)
|
||
|
||
too_large = Message()
|
||
too_large["X-Test"] = "x" * broker.MAX_HEADER_BYTES
|
||
with pytest.raises(broker.PolicyError, match="safe limit"):
|
||
validate(too_large)
|
||
|
||
duplicate = Message()
|
||
duplicate["Content-Length"] = "1"
|
||
duplicate["Content-Length"] = "1"
|
||
with pytest.raises(broker.PolicyError, match="duplicate Content-Length"):
|
||
validate(duplicate)
|