diff --git a/.github/http-compliance.toml b/.github/http-compliance.toml new file mode 100644 index 0000000..4461cbc --- /dev/null +++ b/.github/http-compliance.toml @@ -0,0 +1,10 @@ +schema = 1 +command = ["uv", "run", "python", "tests/http_compliance_adapter.py"] +suites = ["multipart"] +profiles = ["multipart.form-data.receiver-core"] +exclude_tags = ["preamble", "transport-padding"] +timeout_ms = 5000 +max_output_bytes = 1048576 +fail_on_unsupported = true +report_json = "reports/http-compliance.json" +report_junit = "reports/http-compliance.xml" diff --git a/.github/workflows/http-compliance.yml b/.github/workflows/http-compliance.yml new file mode 100644 index 0000000..5ab77a6 --- /dev/null +++ b/.github/workflows/http-compliance.yml @@ -0,0 +1,64 @@ +name: HTTP Compliance + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: {} + +jobs: + http-compliance: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.14" + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Install Rust + run: rustup toolchain install 1.85.0 --profile minimal + + - name: Cache HTTP Compliance + id: cache-http-compliance + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + .http-compliance + .http-compliance-target + key: http-compliance-${{ runner.os }}-${{ runner.arch }}-rust-1.85.0-a6217077 + restore-keys: http-compliance-${{ runner.os }}-${{ runner.arch }}-rust-1.85.0- + + - name: Install HTTP Compliance + if: steps.cache-http-compliance.outputs.cache-hit != 'true' + env: + CARGO_TARGET_DIR: .http-compliance-target + run: >- + cargo +1.85.0 install + --locked + --git https://github.com/Kludex/http-compliance.git + --rev a6217077c938b59ae7a46495349831387d2f60f0 + --root .http-compliance + http-compliance + + - name: Run HTTP Compliance + uses: Kludex/http-compliance@a6217077c938b59ae7a46495349831387d2f60f0 + with: + config: .github/http-compliance.toml + runner-path: .http-compliance/bin/http-compliance + upload-results: false diff --git a/tests/http_compliance_adapter.py b/tests/http_compliance_adapter.py new file mode 100644 index 0000000..d4f4dfa --- /dev/null +++ b/tests/http_compliance_adapter.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""http-compliance adapter for python-multipart's public parser API.""" + +from __future__ import annotations + +import base64 +import json +import sys +from typing import Any + +import python_multipart +from python_multipart.exceptions import MultipartParseError + +PROTOCOL = 1 +CAPABILITIES = ["multipart.form-data"] + + +def b64(value: bytes) -> str: + return base64.b64encode(value).decode("ascii") + + +def send(message: dict[str, Any]) -> None: + print(json.dumps(message, separators=(",", ":")), flush=True) + + +def parse_multipart(message: dict[str, Any]) -> dict[str, Any]: + boundary = base64.b64decode(message["context"]["boundary"], validate=True) + chunks = [base64.b64decode(chunk, validate=True) for chunk in message["chunks"]] + events: list[dict[str, Any]] = [] + header_name = bytearray() + header_value = bytearray() + part_started = False + headers_finished = False + complete = False + + def on_part_begin() -> None: + nonlocal part_started, headers_finished + part_started = True + headers_finished = False + events.append({"type": "part_begin"}) + + def on_header_field(data: bytes, start: int, end: int) -> None: + header_name.extend(data[start:end]) + + def on_header_value(data: bytes, start: int, end: int) -> None: + header_value.extend(data[start:end]) + + def on_header_end() -> None: + events.append({"type": "part_header", "name": b64(bytes(header_name)), "value": b64(bytes(header_value))}) + header_name.clear() + header_value.clear() + + def on_headers_finished() -> None: + nonlocal headers_finished + headers_finished = True + + def on_part_data(data: bytes, start: int, end: int) -> None: + if start != end: + events.append({"type": "part_data", "data": b64(data[start:end])}) + + def on_part_end() -> None: + events.append({"type": "part_end"}) + + def on_end() -> None: + nonlocal complete + complete = True + events.append({"type": "complete"}) + + parser = python_multipart.MultipartParser( + boundary, + callbacks={ + "on_part_begin": on_part_begin, + "on_header_field": on_header_field, + "on_header_value": on_header_value, + "on_header_end": on_header_end, + "on_headers_finished": on_headers_finished, + "on_part_data": on_part_data, + "on_part_end": on_part_end, + "on_end": on_end, + }, + ) + try: + for chunk in chunks: + parser.write(chunk) + parser.finalize() + except MultipartParseError as error: + stage = "boundary" if not part_started else ("body" if headers_finished else "part-headers") + return { + "op": "result", + "protocol": PROTOCOL, + "id": message["id"], + "status": "rejected", + "stage": stage, + "message": str(error), + } + + if not complete: + return { + "op": "result", + "protocol": PROTOCOL, + "id": message["id"], + "status": "incomplete", + "stage": "body" if headers_finished else ("part-headers" if part_started else "boundary"), + "message": "end of input before closing multipart delimiter", + } + return {"op": "result", "protocol": PROTOCOL, "id": message["id"], "status": "accepted", "events": events} + + +def main() -> int: + for line in sys.stdin: + message = json.loads(line) + if message.get("op") == "hello": + send( + { + "op": "ready", + "protocol": PROTOCOL, + "name": "python-multipart", + "version": python_multipart.__version__, + "capabilities": CAPABILITIES, + } + ) + elif message.get("op") == "case": + send(parse_multipart(message)) + elif message.get("op") == "shutdown": + return 0 + else: + print(f"unexpected operation: {message.get('op')!r}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())