Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/http-compliance.toml
Original file line number Diff line number Diff line change
@@ -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"
64 changes: 64 additions & 0 deletions .github/workflows/http-compliance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: HTTP Compliance

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

permissions: {}

jobs:
http-compliance:
Comment thread
Kludex marked this conversation as resolved.
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
Comment thread
Kludex marked this conversation as resolved.
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
133 changes: 133 additions & 0 deletions tests/http_compliance_adapter.py
Original file line number Diff line number Diff line change
@@ -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())
Loading