From 8ca5be5a3bc6df5e479a1f85c239aa3f48614894 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 20 Aug 2026 09:40:38 +0200 Subject: [PATCH 1/3] Run HTTP compliance checks in CI --- .github/http-compliance.toml | 10 +++++++++ .github/workflows/main.yml | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .github/http-compliance.toml diff --git a/.github/http-compliance.toml b/.github/http-compliance.toml new file mode 100644 index 0000000..bfe3f5d --- /dev/null +++ b/.github/http-compliance.toml @@ -0,0 +1,10 @@ +schema = 1 +command = ["uv", "run", "python", ".http-compliance/tools/adapters/python_multipart_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/main.yml b/.github/workflows/main.yml index 51283ee..ed88e12 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,10 +41,47 @@ jobs: - name: Run rename test run: scripts/rename + 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: Checkout HTTP Compliance + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Kludex/http-compliance + ref: a6217077c938b59ae7a46495349831387d2f60f0 + path: .http-compliance + persist-credentials: false + + - name: Build HTTP Compliance + run: cargo build --locked --release --manifest-path .http-compliance/Cargo.toml + + - name: Run HTTP Compliance + uses: Kludex/http-compliance@a6217077c938b59ae7a46495349831387d2f60f0 + with: + config: .github/http-compliance.toml + runner-path: .http-compliance/target/release/http-compliance + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() - needs: [test] + needs: [test, http-compliance] runs-on: ubuntu-latest permissions: From 6ad020a06dc707cd6d06b36edbbcff6ee88136ba Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 20 Aug 2026 09:51:36 +0200 Subject: [PATCH 2/3] Move HTTP compliance checks to a dedicated workflow --- .github/http-compliance.toml | 2 +- .github/workflows/http-compliance.yml | 49 ++++++++++ .github/workflows/main.yml | 39 +------- tests/http_compliance_adapter.py | 133 ++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/http-compliance.yml create mode 100644 tests/http_compliance_adapter.py diff --git a/.github/http-compliance.toml b/.github/http-compliance.toml index bfe3f5d..4461cbc 100644 --- a/.github/http-compliance.toml +++ b/.github/http-compliance.toml @@ -1,5 +1,5 @@ schema = 1 -command = ["uv", "run", "python", ".http-compliance/tools/adapters/python_multipart_adapter.py"] +command = ["uv", "run", "python", "tests/http_compliance_adapter.py"] suites = ["multipart"] profiles = ["multipart.form-data.receiver-core"] exclude_tags = ["preamble", "transport-padding"] diff --git a/.github/workflows/http-compliance.yml b/.github/workflows/http-compliance.yml new file mode 100644 index 0000000..5f8a162 --- /dev/null +++ b/.github/workflows/http-compliance.yml @@ -0,0 +1,49 @@ +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: Install HTTP Compliance + run: >- + cargo +1.85.0 install + --locked + --git https://github.com/Kludex/http-compliance.git + --rev a6217077c938b59ae7a46495349831387d2f60f0 + --root "${RUNNER_TEMP}/http-compliance" + http-compliance + + - name: Run HTTP Compliance + uses: Kludex/http-compliance@a6217077c938b59ae7a46495349831387d2f60f0 + with: + config: .github/http-compliance.toml + runner-path: ${{ runner.temp }}/http-compliance/bin/http-compliance + upload-results: false diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ed88e12..51283ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,47 +41,10 @@ jobs: - name: Run rename test run: scripts/rename - 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: Checkout HTTP Compliance - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: Kludex/http-compliance - ref: a6217077c938b59ae7a46495349831387d2f60f0 - path: .http-compliance - persist-credentials: false - - - name: Build HTTP Compliance - run: cargo build --locked --release --manifest-path .http-compliance/Cargo.toml - - - name: Run HTTP Compliance - uses: Kludex/http-compliance@a6217077c938b59ae7a46495349831387d2f60f0 - with: - config: .github/http-compliance.toml - runner-path: .http-compliance/target/release/http-compliance - # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() - needs: [test, http-compliance] + needs: [test] runs-on: ubuntu-latest permissions: diff --git a/tests/http_compliance_adapter.py b/tests/http_compliance_adapter.py new file mode 100644 index 0000000..beca4ee --- /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 "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()) From 6a6f0a6f413e96ba033e3585a0b4b002a3a0f4f9 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 20 Aug 2026 10:02:34 +0200 Subject: [PATCH 3/3] Cache and gate HTTP compliance checks --- .github/workflows/http-compliance.yml | 19 +++++++++++++++++-- tests/http_compliance_adapter.py | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/http-compliance.yml b/.github/workflows/http-compliance.yml index 5f8a162..5ab77a6 100644 --- a/.github/workflows/http-compliance.yml +++ b/.github/workflows/http-compliance.yml @@ -32,18 +32,33 @@ jobs: - 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 "${RUNNER_TEMP}/http-compliance" + --root .http-compliance http-compliance - name: Run HTTP Compliance uses: Kludex/http-compliance@a6217077c938b59ae7a46495349831387d2f60f0 with: config: .github/http-compliance.toml - runner-path: ${{ runner.temp }}/http-compliance/bin/http-compliance + runner-path: .http-compliance/bin/http-compliance upload-results: false diff --git a/tests/http_compliance_adapter.py b/tests/http_compliance_adapter.py index beca4ee..d4f4dfa 100644 --- a/tests/http_compliance_adapter.py +++ b/tests/http_compliance_adapter.py @@ -100,7 +100,7 @@ def on_end() -> None: "protocol": PROTOCOL, "id": message["id"], "status": "incomplete", - "stage": "body" if headers_finished else "boundary", + "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}