from __future__ import annotations import asyncio import hashlib import io import os import stat import struct import zipfile from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from pathlib import Path from typing import Any, cast import httpx import pytest from pypdf import PdfWriter from pypdf.generic import ( DecodedStreamObject, DictionaryObject, NameObject, ) from services.runtime.ingestion_files import ( ClamdScanner, FileIngestionError, FileIngestionService, FileInput, FilePolicy, PinnedNetworkBackend, _AsyncioNetworkStream, scan_text_source, validate_download_url, ) Resolver = Callable[[str], Awaitable[Sequence[str]]] class SequenceResolver: def __init__(self, *answers: Sequence[str]) -> None: self.answers = list(answers) self.hosts: list[str] = [] async def __call__(self, host: str) -> Sequence[str]: self.hosts.append(host) if len(self.answers) == 1: return self.answers[0] return self.answers.pop(0) class MappingResolver: def __init__(self, records: dict[str, Sequence[str]]) -> None: self.records = records self.hosts: list[str] = [] async def __call__(self, host: str) -> Sequence[str]: self.hosts.append(host) return self.records.get(host, ("93.184.216.34",)) class FakeScanner: def __init__(self, error: Exception | None = None) -> None: self.error = error self.scanned: list[tuple[Path, bytes, float, int]] = [] async def require_clean(self, path: Path, *, deadline: float) -> None: self.scanned.append((path, path.read_bytes(), deadline, stat.S_IMODE(path.stat().st_mode))) if self.error is not None: raise self.error class RoutingTransport(httpx.AsyncBaseTransport): def __init__(self, responses: dict[str, httpx.Response]) -> None: self.responses = responses self.urls: list[str] = [] async def handle_async_request(self, request: httpx.Request) -> httpx.Response: url = str(request.url) self.urls.append(url) response = self.responses[url] return httpx.Response( response.status_code, headers=response.headers, content=await response.aread(), request=request, ) async def aclose(self) -> None: return None def run(coro: Awaitable[Any]) -> Any: return asyncio.run(coro) def file_input( url: str = "https://files.example.test/document.txt", **overrides: str ) -> dict[str, str]: value = { "download_url": url, "file_id": "file-abc123", "mime_type": "text/plain", "file_name": "document.txt", } value.update(overrides) return value def text_response( body: bytes, *, content_type: str = "text/plain; charset=utf-8" ) -> httpx.Response: return httpx.Response(200, headers={"content-type": content_type}, content=body) def make_pdf(text: str, pages: int = 1) -> bytes: writer = PdfWriter() page = writer.add_blank_page(width=300, height=300) font = DictionaryObject( { NameObject("/Type"): NameObject("/Font"), NameObject("/Subtype"): NameObject("/Type1"), NameObject("/BaseFont"): NameObject("/Helvetica"), } ) page[NameObject("/Resources")] = DictionaryObject( { NameObject("/Font"): DictionaryObject({NameObject("/F1"): font}), } ) content = DecodedStreamObject() content.set_data(f"BT /F1 12 Tf 20 200 Td ({text}) Tj ET".encode("ascii")) page[NameObject("/Contents")] = content for _ in range(pages - 1): writer.add_blank_page(width=300, height=300) buffer = io.BytesIO() writer.write(buffer) return buffer.getvalue() def make_docx(text: str = "", *, document_xml: bytes | None = None) -> bytes: if document_xml is None: document_xml = ( "" "" f"{text}" ).encode() content_types = ( b"" b"" b"" b"" ) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr("[Content_Types].xml", content_types) archive.writestr("word/document.xml", document_xml) return buffer.getvalue() def make_transport(url: str, body: bytes, *, content_type: str = "text/plain") -> RoutingTransport: return RoutingTransport( {url: httpx.Response(200, headers={"content-type": content_type}, content=body)} ) def make_policy( tmp_path: Path, scanner: FakeScanner | None = None, **overrides: Any, ) -> FilePolicy: return FilePolicy(scanner=scanner or FakeScanner(), temp_directory=tmp_path, **overrides) def test_rejects_insecure_or_credentialed_download_urls() -> None: async def scenario() -> None: resolver = MappingResolver({}) for value in ( "http://files.example.test/a.txt", "https://user:password@files.example.test/a.txt", "https://files.example.test/a.txt#fragment", "https://files.example.test:8443/a.txt", "//files.example.test/a.txt", ): with pytest.raises(FileIngestionError): await validate_download_url(value, resolver=resolver) assert resolver.hosts == [] run(scenario()) def test_text_source_scanner_returns_bounded_digest_and_securely_removes_temporary_copy( tmp_path: Path, ) -> None: async def scenario() -> None: scanner = FakeScanner() policy = make_policy(tmp_path, scanner, max_text_chars_per_file=128) source = await scan_text_source("Distinctive repo text", "guide.md", policy=policy) assert source.text == "Distinctive repo text" assert source.content == b"Distinctive repo text" assert source.sha256 == hashlib.sha256(source.content).hexdigest() assert scanner.scanned[0][1] == source.content if os.name != "nt": assert scanner.scanned[0][3] == 0o600 assert not scanner.scanned[0][0].exists() run(scenario()) def test_text_source_scanner_fails_closed_for_missing_scanner_and_bounds(tmp_path: Path) -> None: async def scenario() -> None: with pytest.raises(FileIngestionError, match="Malware scanning is unavailable"): await scan_text_source( "safe", "guide.md", policy=FilePolicy(scanner=None, temp_directory=tmp_path) ) with pytest.raises(FileIngestionError): await scan_text_source( "too long", "guide.md", policy=make_policy(tmp_path, max_text_chars_per_file=4), ) with pytest.raises(FileIngestionError): await scan_text_source("bad\x00text", "guide.md", policy=make_policy(tmp_path)) run(scenario()) @pytest.mark.parametrize( "address", [ "127.0.0.1", "198.19.5.6", "169.254.169.254", "192.0.2.4", "224.0.0.1", "::1", "fe80::1", "fc00::1", "2001:db8::1", ], ) def test_rejects_non_public_dns_answers(address: str) -> None: async def scenario() -> None: with pytest.raises(FileIngestionError): await validate_download_url( "https://files.example.test/a.txt", resolver=MappingResolver({"files.example.test": (address,)}), ) run(scenario()) def test_pinned_network_backend_rejects_dns_rebinding_before_connect() -> None: async def scenario() -> None: resolver = SequenceResolver(("93.184.216.34",), ("198.19.0.7",)) await validate_download_url("https://files.example.test/a.txt", resolver=resolver) backend = PinnedNetworkBackend(resolver=resolver) with pytest.raises(FileIngestionError): await backend.connect_tcp("files.example.test", 443, timeout=0.1) assert resolver.hosts == ["files.example.test", "files.example.test"] run(scenario()) def test_tls_close_aborts_when_peer_never_completes_shutdown() -> None: async def scenario() -> None: class FakeTransport: def __init__(self) -> None: self.aborted = False def abort(self) -> None: self.aborted = True class StalledWriter: def __init__(self) -> None: self.transport = FakeTransport() self.closed = False self.wait_started = asyncio.Event() def close(self) -> None: self.closed = True async def wait_closed(self) -> None: self.wait_started.set() await asyncio.Future() writer = StalledWriter() stream = _AsyncioNetworkStream(None, writer) # type: ignore[arg-type] await asyncio.wait_for(stream.aclose(), timeout=2.0) assert writer.wait_started.is_set() assert writer.closed assert writer.transport.aborted run(scenario()) def test_tls_close_preserves_fast_peer_shutdown() -> None: async def scenario() -> None: class FakeTransport: def __init__(self) -> None: self.aborted = False def abort(self) -> None: self.aborted = True class FastWriter: def __init__(self) -> None: self.transport = FakeTransport() self.closed = False self.waited = False def close(self) -> None: self.closed = True async def wait_closed(self) -> None: self.waited = True writer = FastWriter() stream = _AsyncioNetworkStream(None, writer) # type: ignore[arg-type] await stream.aclose() assert writer.closed assert writer.waited assert not writer.transport.aborted run(scenario()) def test_tls_close_aborts_and_reraises_cancellation() -> None: async def scenario() -> None: class FakeTransport: def __init__(self) -> None: self.aborted = False def abort(self) -> None: self.aborted = True class StalledWriter: def __init__(self) -> None: self.transport = FakeTransport() self.wait_started = asyncio.Event() def close(self) -> None: return None async def wait_closed(self) -> None: self.wait_started.set() await asyncio.Future() writer = StalledWriter() stream = _AsyncioNetworkStream(None, writer) # type: ignore[arg-type] closing = asyncio.create_task(stream.aclose()) await writer.wait_started.wait() closing.cancel() with pytest.raises(asyncio.CancelledError): await closing assert writer.transport.aborted run(scenario()) def test_tls_close_aborts_when_wait_closed_fails() -> None: async def scenario() -> None: class FakeTransport: def __init__(self) -> None: self.aborted = False def abort(self) -> None: self.aborted = True class BrokenWriter: def __init__(self) -> None: self.transport = FakeTransport() def close(self) -> None: return None async def wait_closed(self) -> None: raise OSError("simulated close failure") writer = BrokenWriter() stream = _AsyncioNetworkStream(None, writer) # type: ignore[arg-type] await stream.aclose() assert writer.transport.aborted run(scenario()) def test_stalled_tls_close_keeps_downloaded_body_on_scanned_path(tmp_path: Path) -> None: async def scenario() -> None: payload = b"Complete body before the stalled TLS shutdown." scanner = FakeScanner() class FakeTransport: def __init__(self) -> None: self.aborted = False def abort(self) -> None: self.aborted = True class StalledWriter: def __init__(self) -> None: self.transport = FakeTransport() self.wait_started = asyncio.Event() def close(self) -> None: return None async def wait_closed(self) -> None: self.wait_started.set() await asyncio.Future() writer = StalledWriter() network_stream = _AsyncioNetworkStream(None, writer) # type: ignore[arg-type] class ClosingBody(httpx.AsyncByteStream): async def __aiter__(self) -> AsyncIterator[bytes]: yield payload async def aclose(self) -> None: await network_stream.aclose() class ResponseTransport(httpx.AsyncBaseTransport): async def handle_async_request(self, request: httpx.Request) -> httpx.Response: return httpx.Response( 200, headers={"content-type": "text/plain"}, stream=ClosingBody(), request=request, ) async def aclose(self) -> None: return None service = FileIngestionService( make_policy(tmp_path, scanner=scanner), resolver=MappingResolver({}), transport=ResponseTransport(), ) processed = await service.acquire([cast(FileInput, file_input())], actor=object()) assert len(processed) == 1 assert processed[0].content == payload assert processed[0].text == payload.decode() assert [entry[1] for entry in scanner.scanned] == [payload] assert writer.transport.aborted run(scenario()) def test_revalidates_each_redirect_before_following_it(tmp_path: Path) -> None: async def scenario() -> None: first = "https://files.example.test/a" private = "https://127.0.0.1/private" transport = RoutingTransport({first: httpx.Response(302, headers={"location": private})}) service = FileIngestionService( make_policy(tmp_path), resolver=MappingResolver({}), transport=transport, ) with pytest.raises(FileIngestionError): await service.acquire([file_input(first)], actor=object()) assert transport.urls == [first] run(scenario()) def test_rejects_redirects_beyond_the_configured_limit(tmp_path: Path) -> None: async def scenario() -> None: urls = [f"https://files{i}.example.test/a" for i in range(3)] transport = RoutingTransport( { urls[0]: httpx.Response(302, headers={"location": urls[1]}), urls[1]: httpx.Response(302, headers={"location": urls[2]}), urls[2]: text_response(b"never fetched"), } ) service = FileIngestionService( make_policy(tmp_path, max_redirects=1), resolver=MappingResolver({}), transport=transport, ) with pytest.raises(FileIngestionError): await service.acquire([file_input(urls[0])], actor=object()) assert transport.urls == urls[:2] run(scenario()) def test_streams_within_byte_limits_and_returns_scanned_original_bytes(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/document.txt" body = b"source bytes\n" scanner = FakeScanner() transport = make_transport(url, body) service = FileIngestionService( make_policy(tmp_path, scanner, max_file_bytes=100, max_total_bytes=100), resolver=MappingResolver({}), transport=transport, ) (source,) = await service.acquire([file_input(url)], actor=object()) assert source.content == body assert source.byte_size == len(body) assert source.sha256 == hashlib.sha256(body).hexdigest() assert source.text == "source bytes\n" scanned_path, scanned_content, deadline, mode = scanner.scanned[0] assert scanned_content == body assert deadline > 0 if os.name != "nt": assert mode == 0o600 assert scanned_path.parent.parent == tmp_path assert not scanned_path.exists() assert b"source bytes" not in repr(source).encode() run(scenario()) def test_cumulative_size_limit_stops_before_downloading_another_source(tmp_path: Path) -> None: async def scenario() -> None: first = "https://files.example.test/one.txt" second = "https://files.example.test/two.txt" transport = RoutingTransport( {first: text_response(b"1234"), second: text_response(b"5678")} ) service = FileIngestionService( make_policy(tmp_path, max_file_bytes=10, max_total_bytes=6), resolver=MappingResolver({}), transport=transport, ) files = [file_input(first), file_input(second, file_id="file-def456")] with pytest.raises(FileIngestionError): await service.acquire(files, actor=object()) assert transport.urls == [first, second] assert list(tmp_path.iterdir()) == [] run(scenario()) def test_rejects_oversize_stream_even_when_content_length_is_missing(tmp_path: Path) -> None: class Chunks(httpx.AsyncByteStream): async def __aiter__(self): yield b"12345" yield b"67890" async def scenario() -> None: url = "https://files.example.test/document.txt" class StreamingTransport(httpx.AsyncBaseTransport): async def handle_async_request(self, request: httpx.Request) -> httpx.Response: return httpx.Response( 200, headers={"content-type": "text/plain"}, stream=Chunks(), request=request, ) async def aclose(self) -> None: return None service = FileIngestionService( make_policy(tmp_path, max_file_bytes=8), resolver=MappingResolver({}), transport=StreamingTransport(), ) with pytest.raises(FileIngestionError): await service.acquire([file_input(url)], actor=object()) assert list(tmp_path.iterdir()) == [] run(scenario()) def test_scan_is_required_and_failure_removes_ephemeral_file(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/document.txt" scanner = FakeScanner(error=RuntimeError("scanner offline")) service = FileIngestionService( make_policy(tmp_path, scanner), resolver=MappingResolver({}), transport=make_transport(url, b"private source text"), ) with pytest.raises(FileIngestionError) as raised: await service.acquire([file_input(url)], actor=object()) assert "private source text" not in str(raised.value) assert "files.example.test" not in str(raised.value) assert list(tmp_path.iterdir()) == [] missing = FileIngestionService( FilePolicy(scanner=None, temp_directory=tmp_path), resolver=MappingResolver({}), transport=make_transport(url, b"source"), ) with pytest.raises(FileIngestionError): await missing.acquire([file_input(url)], actor=object()) run(scenario()) def test_rejects_claimed_mime_mismatch_and_arbitrary_archives(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/document.txt" valid_pdf = make_pdf("text") service = FileIngestionService( make_policy(tmp_path), resolver=MappingResolver({}), transport=make_transport(url, valid_pdf, content_type="application/pdf"), ) with pytest.raises(FileIngestionError) as raised: await service.acquire([file_input(url, mime_type="text/plain")], actor=object()) assert raised.value.code == "mime_mismatch" zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w") as archive: archive.writestr("payload.txt", "not a docx") archive_service = FileIngestionService( make_policy(tmp_path), resolver=MappingResolver({}), transport=make_transport(url, zip_buffer.getvalue(), content_type="application/zip"), ) with pytest.raises(FileIngestionError): await archive_service.acquire( [file_input(url, mime_type="application/zip")], actor=object() ) assert list(tmp_path.iterdir()) == [] run(scenario()) @pytest.mark.parametrize( "document_xml", [ b"", ( b"]>" b"" b"&xxe;" ), ], ) def test_rejects_malformed_and_entity_bearing_docx_xml( tmp_path: Path, document_xml: bytes, ) -> None: async def scenario() -> None: url = "https://files.example.test/document.docx" content = make_docx(document_xml=document_xml) mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" service = FileIngestionService( make_policy(tmp_path), resolver=MappingResolver({}), transport=make_transport(url, content, content_type=mime), ) with pytest.raises(FileIngestionError): await service.acquire( [file_input(url, mime_type=mime, file_name="document.docx")], actor=object(), ) assert list(tmp_path.iterdir()) == [] run(scenario()) def test_extracts_bounded_utf8_text_pdf_and_docx(tmp_path: Path) -> None: async def scenario() -> None: inputs = [ ( "https://files.example.test/a.txt", file_input("https://files.example.test/a.txt"), b"plain text", "text/plain", ), ( "https://files.example.test/b.pdf", file_input( "https://files.example.test/b.pdf", file_id="file-pdf", mime_type="application/pdf", file_name="b.pdf", ), make_pdf("pdf text"), "application/pdf", ), ( "https://files.example.test/c.docx", file_input( "https://files.example.test/c.docx", file_id="file-docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_name="c.docx", ), make_docx("docx text"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ), ] transport = RoutingTransport( {url: text_response(body, content_type=mime) for url, _, body, mime in inputs} ) service = FileIngestionService( make_policy(tmp_path), resolver=MappingResolver({}), transport=transport ) sources = await service.acquire([item for _, item, _, _ in inputs], actor=object()) assert [source.text.strip() for source in sources] == [ "plain text", "pdf text", "docx text", ] assert [source.content for source in sources] == [body for _, _, body, _ in inputs] assert [source.byte_size for source in sources] == [len(body) for _, _, body, _ in inputs] assert list(tmp_path.iterdir()) == [] run(scenario()) def test_image_only_pdf_without_extractable_text_is_rejected(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/image-only.pdf" scanner = FakeScanner() service = FileIngestionService( make_policy(tmp_path, scanner), resolver=MappingResolver({"files.example.test": ("93.184.216.34",)}), transport=make_transport(url, make_pdf(""), content_type="application/pdf"), ) with pytest.raises(FileIngestionError) as caught: await service.acquire( [file_input(url, mime_type="application/pdf", file_name="image-only.pdf")], actor=object(), ) assert caught.value.code == "extraction_failed" assert scanner.scanned assert not scanner.scanned[0][0].exists() run(scenario()) def test_enforces_pdf_page_and_extracted_text_limits(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/two.pdf" two_page_pdf = make_pdf("page", pages=2) service = FileIngestionService( make_policy(tmp_path, max_pdf_pages=1), resolver=MappingResolver({}), transport=make_transport(url, two_page_pdf, content_type="application/pdf"), ) with pytest.raises(FileIngestionError): await service.acquire( [file_input(url, mime_type="application/pdf", file_name="two.pdf")], actor=object(), ) assert list(tmp_path.iterdir()) == [] text_url = "https://files.example.test/text.txt" text_service = FileIngestionService( make_policy(tmp_path, max_text_chars_per_file=4), resolver=MappingResolver({}), transport=make_transport(text_url, b"12345"), ) with pytest.raises(FileIngestionError): await text_service.acquire([file_input(text_url)], actor=object()) run(scenario()) def test_docx_limits_reject_zip_bombs_and_relationship_overflow(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/document.docx" compressed = make_docx("A" * 50_000) service = FileIngestionService( make_policy(tmp_path, max_docx_uncompressed_bytes=1024), resolver=MappingResolver({}), transport=make_transport( url, compressed, content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ), ) with pytest.raises(FileIngestionError): await service.acquire( [ file_input( url, mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) ], actor=object(), ) assert list(tmp_path.iterdir()) == [] run(scenario()) def test_exact_file_input_shape_duplicate_ids_and_count_are_enforced(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/document.txt" transport = make_transport(url, b"body") service = FileIngestionService( make_policy(tmp_path, max_files=2), resolver=MappingResolver({}), transport=transport, ) with pytest.raises(FileIngestionError) as unknown_fields: await service.acquire([file_input(url, unexpected="field")], actor=object()) assert unknown_fields.value.code == "invalid_input" assert unknown_fields.value.input_reason == "attachment_unknown_fields" assert "unexpected" not in str(unknown_fields.value) opaque_prefix = "reference with spaces (sample caf" + chr(0x00E9) + " #1)/" opaque_file_id = opaque_prefix + "x" * (128 - len(opaque_prefix)) sources = await service.acquire([file_input(url, file_id=opaque_file_id)], actor=object()) assert sources[0].file_id == opaque_file_id with pytest.raises(FileIngestionError) as duplicate_ids: await service.acquire([file_input(url), file_input(url)], actor=object()) assert duplicate_ids.value.code == "invalid_input" assert duplicate_ids.value.input_reason == "duplicate_file_ids" with pytest.raises(FileIngestionError) as too_many: await service.acquire( [ file_input(url, file_id="file-first"), file_input(url, file_id="file-second"), file_input(url, file_id="file-third"), ], actor=object(), ) assert too_many.value.code == "source_limit" assert transport.urls == [url] run(scenario()) def test_file_id_validation_rejects_empty_overlong_and_non_printable_values( tmp_path: Path, ) -> None: async def scenario() -> None: url = "https://files.example.test/document.txt" transport = RoutingTransport({}) service = FileIngestionService( make_policy(tmp_path), resolver=MappingResolver({}), transport=transport, ) invalid_ids = ( ("", "attachment_file_id_empty"), (" ", "attachment_file_id_empty"), ("x" * 129, "attachment_file_id_too_long"), ("reference\x00with control", "attachment_file_id_non_printable"), ("reference\nwith control", "attachment_file_id_non_printable"), ("reference\x85with control", "attachment_file_id_non_printable"), ) for file_id, expected_reason in invalid_ids: with pytest.raises(FileIngestionError) as invalid: await service.acquire([file_input(url, file_id=file_id)], actor=object()) assert invalid.value.code == "invalid_input" assert invalid.value.input_reason == expected_reason assert str(invalid.value) == "File input is invalid." assert transport.urls == [] run(scenario()) def test_enforces_download_deadline_and_removes_temporary_files(tmp_path: Path) -> None: async def scenario() -> None: url = "https://files.example.test/document.txt" class SlowTransport(httpx.AsyncBaseTransport): async def handle_async_request(self, request: httpx.Request) -> httpx.Response: await asyncio.sleep(0.25) return httpx.Response( 200, headers={"content-type": "text/plain"}, content=b"late", request=request, ) async def aclose(self) -> None: return None service = FileIngestionService( make_policy(tmp_path, download_timeout_seconds=0.1), resolver=MappingResolver({}), transport=SlowTransport(), ) with pytest.raises(FileIngestionError): await service.acquire([file_input(url)], actor=object()) assert list(tmp_path.iterdir()) == [] run(scenario()) def test_clamd_scanner_accepts_only_exact_clean_response(tmp_path: Path) -> None: async def scenario() -> None: path = tmp_path / "source.txt" path.write_bytes(b"clamav payload") received = bytearray() async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: assert await reader.readexactly(10) == b"zINSTREAM\0" while True: length = struct.unpack("!I", await reader.readexactly(4))[0] if length == 0: break received.extend(await reader.readexactly(length)) writer.write(b"stream: OK\0") await writer.drain() writer.close() await writer.wait_closed() server = await asyncio.start_server(handle, "127.0.0.1", 0) try: port = server.sockets[0].getsockname()[1] await ClamdScanner("127.0.0.1", port=port).require_clean( path, deadline=asyncio.get_running_loop().time() + 1 ) finally: server.close() await server.wait_closed() assert bytes(received) == b"clamav payload" run(scenario()) @pytest.mark.parametrize( "answer", [b"stream: Eicar-Test-Signature FOUND\0", b"ERROR\0", b"stream: OK with extra data\0"] ) def test_clamd_scanner_fails_closed_on_hit_or_malformed_response( tmp_path: Path, answer: bytes ) -> None: async def scenario() -> None: path = tmp_path / "source.txt" path.write_bytes(b"payload") async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: await reader.readexactly(10) while True: length = struct.unpack("!I", await reader.readexactly(4))[0] if length == 0: break await reader.readexactly(length) writer.write(answer) await writer.drain() writer.close() await writer.wait_closed() server = await asyncio.start_server(handle, "127.0.0.1", 0) try: port = server.sockets[0].getsockname()[1] with pytest.raises(FileIngestionError): await ClamdScanner("127.0.0.1", port=port).require_clean( path, deadline=asyncio.get_running_loop().time() + 1 ) finally: server.close() await server.wait_closed() run(scenario()) def test_environment_policy_rejects_unbounded_and_invalid_limits( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("INGESTION_MAX_FILES", "1000000") with pytest.raises(ValueError): FilePolicy.from_environment(scanner=FakeScanner()) monkeypatch.setenv("INGESTION_MAX_FILES", "not-an-int") with pytest.raises(ValueError): FilePolicy.from_environment(scanner=FakeScanner())