tigrcorn-protocols 0.3.16.dev5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tigrcorn_protocols/__init__.py +1 -0
- tigrcorn_protocols/_compression.py +219 -0
- tigrcorn_protocols/connect.py +107 -0
- tigrcorn_protocols/content_coding.py +179 -0
- tigrcorn_protocols/custom/__init__.py +3 -0
- tigrcorn_protocols/custom/adapters.py +18 -0
- tigrcorn_protocols/custom/registry.py +15 -0
- tigrcorn_protocols/flow/__init__.py +1 -0
- tigrcorn_protocols/flow/backpressure.py +17 -0
- tigrcorn_protocols/flow/buffers.py +29 -0
- tigrcorn_protocols/flow/credits.py +21 -0
- tigrcorn_protocols/flow/keepalive.py +85 -0
- tigrcorn_protocols/flow/timeouts.py +17 -0
- tigrcorn_protocols/flow/watermarks.py +16 -0
- tigrcorn_protocols/http1/__init__.py +16 -0
- tigrcorn_protocols/http1/keepalive.py +21 -0
- tigrcorn_protocols/http1/parser.py +481 -0
- tigrcorn_protocols/http1/serializer.py +198 -0
- tigrcorn_protocols/http1/state.py +9 -0
- tigrcorn_protocols/http2/__init__.py +16 -0
- tigrcorn_protocols/http2/codec.py +266 -0
- tigrcorn_protocols/http2/flow.py +35 -0
- tigrcorn_protocols/http2/handler.py +1303 -0
- tigrcorn_protocols/http2/hpack.py +393 -0
- tigrcorn_protocols/http2/state.py +226 -0
- tigrcorn_protocols/http2/streams.py +76 -0
- tigrcorn_protocols/http2/websocket.py +360 -0
- tigrcorn_protocols/http3/__init__.py +82 -0
- tigrcorn_protocols/http3/codec.py +148 -0
- tigrcorn_protocols/http3/handler/__init__.py +3 -0
- tigrcorn_protocols/http3/handler/core.py +1823 -0
- tigrcorn_protocols/http3/handler/webtransport.py +184 -0
- tigrcorn_protocols/http3/handler.py +3 -0
- tigrcorn_protocols/http3/qpack.py +843 -0
- tigrcorn_protocols/http3/state.py +129 -0
- tigrcorn_protocols/http3/streams.py +657 -0
- tigrcorn_protocols/http3/websocket.py +360 -0
- tigrcorn_protocols/lifespan/__init__.py +3 -0
- tigrcorn_protocols/lifespan/driver.py +83 -0
- tigrcorn_protocols/py.typed +1 -0
- tigrcorn_protocols/rawframed/__init__.py +5 -0
- tigrcorn_protocols/rawframed/codec.py +18 -0
- tigrcorn_protocols/rawframed/frames.py +28 -0
- tigrcorn_protocols/rawframed/handler.py +72 -0
- tigrcorn_protocols/rawframed/state.py +9 -0
- tigrcorn_protocols/registry.py +22 -0
- tigrcorn_protocols/scheduler/__init__.py +17 -0
- tigrcorn_protocols/scheduler/cancellation.py +40 -0
- tigrcorn_protocols/scheduler/dispatch.py +27 -0
- tigrcorn_protocols/scheduler/fairness.py +21 -0
- tigrcorn_protocols/scheduler/policy.py +12 -0
- tigrcorn_protocols/scheduler/priorities.py +8 -0
- tigrcorn_protocols/scheduler/quotas.py +19 -0
- tigrcorn_protocols/scheduler/runtime.py +156 -0
- tigrcorn_protocols/scheduler/tasks.py +31 -0
- tigrcorn_protocols/sessions/__init__.py +1 -0
- tigrcorn_protocols/sessions/base.py +16 -0
- tigrcorn_protocols/sessions/connection.py +12 -0
- tigrcorn_protocols/sessions/limits.py +12 -0
- tigrcorn_protocols/sessions/manager.py +31 -0
- tigrcorn_protocols/sessions/metadata.py +10 -0
- tigrcorn_protocols/sessions/quic.py +14 -0
- tigrcorn_protocols/streams/__init__.py +1 -0
- tigrcorn_protocols/streams/base.py +13 -0
- tigrcorn_protocols/streams/ids.py +5 -0
- tigrcorn_protocols/streams/multiplex.py +6 -0
- tigrcorn_protocols/streams/registry.py +22 -0
- tigrcorn_protocols/streams/singleplex.py +6 -0
- tigrcorn_protocols/websocket/__init__.py +1 -0
- tigrcorn_protocols/websocket/codec.py +31 -0
- tigrcorn_protocols/websocket/extensions.py +324 -0
- tigrcorn_protocols/websocket/frames.py +174 -0
- tigrcorn_protocols/websocket/handler.py +462 -0
- tigrcorn_protocols/websocket/handshake.py +66 -0
- tigrcorn_protocols/websocket/state.py +10 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/METADATA +240 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/RECORD +80 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/WHEEL +5 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from time import monotonic
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class KeepAlivePolicy:
|
|
9
|
+
idle_timeout: float = 30.0
|
|
10
|
+
ping_interval: float | None = None
|
|
11
|
+
ping_timeout: float | None = None
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def effective_ping_interval(self) -> float | None:
|
|
15
|
+
if self.ping_interval is not None:
|
|
16
|
+
return self.ping_interval
|
|
17
|
+
return self.ping_timeout
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def effective_ping_timeout(self) -> float | None:
|
|
21
|
+
interval = self.effective_ping_interval
|
|
22
|
+
if self.ping_timeout is not None:
|
|
23
|
+
return self.ping_timeout
|
|
24
|
+
return interval
|
|
25
|
+
|
|
26
|
+
def expired(self, last_activity: float, now: float | None = None) -> bool:
|
|
27
|
+
now = monotonic() if now is None else now
|
|
28
|
+
return now - last_activity >= self.idle_timeout
|
|
29
|
+
|
|
30
|
+
def should_ping(self, last_activity: float, now: float | None = None) -> bool:
|
|
31
|
+
interval = self.effective_ping_interval
|
|
32
|
+
if interval is None:
|
|
33
|
+
return False
|
|
34
|
+
now = monotonic() if now is None else now
|
|
35
|
+
return now - last_activity >= interval
|
|
36
|
+
|
|
37
|
+
def ping_timed_out(self, ping_sent_at: float, now: float | None = None) -> bool:
|
|
38
|
+
timeout = self.effective_ping_timeout
|
|
39
|
+
if timeout is None:
|
|
40
|
+
return False
|
|
41
|
+
now = monotonic() if now is None else now
|
|
42
|
+
return now - ping_sent_at >= timeout
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def enabled(self) -> bool:
|
|
46
|
+
return self.effective_ping_interval is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class KeepAliveRuntime:
|
|
51
|
+
policy: KeepAlivePolicy
|
|
52
|
+
last_activity: float = field(default_factory=monotonic)
|
|
53
|
+
pending_ping_payload: bytes | None = None
|
|
54
|
+
pending_ping_sent_at: float | None = None
|
|
55
|
+
sequence: int = 0
|
|
56
|
+
|
|
57
|
+
def record_activity(self, now: float | None = None) -> None:
|
|
58
|
+
self.last_activity = monotonic() if now is None else now
|
|
59
|
+
|
|
60
|
+
def next_ping_payload(self, now: float | None = None) -> bytes | None:
|
|
61
|
+
if self.pending_ping_payload is not None:
|
|
62
|
+
return None
|
|
63
|
+
if not self.policy.should_ping(self.last_activity, now=now):
|
|
64
|
+
return None
|
|
65
|
+
self.sequence += 1
|
|
66
|
+
payload = self.sequence.to_bytes(8, 'big')
|
|
67
|
+
self.pending_ping_payload = payload
|
|
68
|
+
self.pending_ping_sent_at = monotonic() if now is None else now
|
|
69
|
+
return payload
|
|
70
|
+
|
|
71
|
+
def acknowledge_pong(self, payload: bytes, now: float | None = None) -> bool:
|
|
72
|
+
if self.pending_ping_payload is None:
|
|
73
|
+
self.record_activity(now=now)
|
|
74
|
+
return False
|
|
75
|
+
if payload and payload != self.pending_ping_payload:
|
|
76
|
+
return False
|
|
77
|
+
self.pending_ping_payload = None
|
|
78
|
+
self.pending_ping_sent_at = None
|
|
79
|
+
self.record_activity(now=now)
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def ping_timed_out(self, now: float | None = None) -> bool:
|
|
83
|
+
if self.pending_ping_sent_at is None:
|
|
84
|
+
return False
|
|
85
|
+
return self.policy.ping_timed_out(self.pending_ping_sent_at, now=now)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class TimeoutPolicy:
|
|
10
|
+
read_timeout: float = 30.0
|
|
11
|
+
write_timeout: float = 30.0
|
|
12
|
+
|
|
13
|
+
async def wait_read(self, awaitable):
|
|
14
|
+
return await asyncio.wait_for(awaitable, timeout=self.read_timeout)
|
|
15
|
+
|
|
16
|
+
async def wait_write(self, awaitable):
|
|
17
|
+
return await asyncio.wait_for(awaitable, timeout=self.write_timeout)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class Watermarks:
|
|
8
|
+
low: int = 16 * 1024
|
|
9
|
+
high: int = 64 * 1024
|
|
10
|
+
|
|
11
|
+
def classify(self, value: int) -> str:
|
|
12
|
+
if value >= self.high:
|
|
13
|
+
return 'high'
|
|
14
|
+
if value <= self.low:
|
|
15
|
+
return 'low'
|
|
16
|
+
return 'mid'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .parser import ParsedRequest, read_http11_request
|
|
2
|
+
from .serializer import (
|
|
3
|
+
finalize_chunked_body,
|
|
4
|
+
serialize_http11_response_chunk,
|
|
5
|
+
serialize_http11_response_head,
|
|
6
|
+
serialize_http11_response_whole,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ParsedRequest",
|
|
11
|
+
"read_http11_request",
|
|
12
|
+
"serialize_http11_response_head",
|
|
13
|
+
"serialize_http11_response_whole",
|
|
14
|
+
"serialize_http11_response_chunk",
|
|
15
|
+
"finalize_chunked_body",
|
|
16
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrcorn_core.utils.headers import get_header, header_contains_token
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def keep_alive_for_request(http_version: str, headers: list[tuple[bytes, bytes]]) -> bool:
|
|
7
|
+
if http_version == "1.0":
|
|
8
|
+
return header_contains_token(headers, b"connection", b"keep-alive")
|
|
9
|
+
if header_contains_token(headers, b"connection", b"close"):
|
|
10
|
+
return False
|
|
11
|
+
return True
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def expect_continue(headers: list[tuple[bytes, bytes]]) -> bool:
|
|
15
|
+
value = get_header(headers, b"expect")
|
|
16
|
+
return bool(value and value.lower() == b"100-continue")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def apply_keep_alive_policy(request_keep_alive: bool, *, enabled: bool) -> bool:
|
|
21
|
+
return request_keep_alive and enabled
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from tigrcorn_core.errors import ProtocolError, UnsupportedFeature
|
|
9
|
+
from tigrcorn_protocols.http1.keepalive import expect_continue, keep_alive_for_request
|
|
10
|
+
from tigrcorn_core.types import StreamReaderLike
|
|
11
|
+
from tigrcorn_core.utils.headers import get_headers, header_contains_token
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
RequestTargetForm = Literal['origin', 'absolute', 'authority', 'asterisk']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_TCHAR = frozenset(b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_token(value: bytes) -> bool:
|
|
21
|
+
return bool(value) and all(byte in _TCHAR for byte in value)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_header_name(name: bytes) -> None:
|
|
25
|
+
if not _is_token(name):
|
|
26
|
+
raise ProtocolError('invalid header field name')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_header_value(value: bytes) -> None:
|
|
30
|
+
for byte in value:
|
|
31
|
+
if byte in {0x00, 0x0A, 0x0D} or (byte < 0x20 and byte != 0x09):
|
|
32
|
+
raise ProtocolError('invalid header field value')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ParsedRequest:
|
|
37
|
+
method: str
|
|
38
|
+
target: str
|
|
39
|
+
path: str
|
|
40
|
+
raw_path: bytes
|
|
41
|
+
query_string: bytes
|
|
42
|
+
http_version: str
|
|
43
|
+
headers: list[tuple[bytes, bytes]]
|
|
44
|
+
body: bytes
|
|
45
|
+
keep_alive: bool
|
|
46
|
+
expect_continue: bool
|
|
47
|
+
websocket_upgrade: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class ParsedRequestHead:
|
|
52
|
+
method: str
|
|
53
|
+
target: str
|
|
54
|
+
path: str
|
|
55
|
+
raw_path: bytes
|
|
56
|
+
query_string: bytes
|
|
57
|
+
http_version: str
|
|
58
|
+
headers: list[tuple[bytes, bytes]]
|
|
59
|
+
keep_alive: bool
|
|
60
|
+
expect_continue: bool
|
|
61
|
+
websocket_upgrade: bool
|
|
62
|
+
body_kind: Literal['none', 'content-length', 'chunked']
|
|
63
|
+
content_length: int | None
|
|
64
|
+
target_form: RequestTargetForm
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _read_line(reader: StreamReaderLike) -> bytes:
|
|
68
|
+
try:
|
|
69
|
+
return await reader.readuntil(b"\r\n")
|
|
70
|
+
except asyncio.IncompleteReadError as exc:
|
|
71
|
+
raise ProtocolError('unexpected EOF while reading HTTP/1.1 body') from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _readexactly(reader: StreamReaderLike, amount: int) -> bytes:
|
|
75
|
+
try:
|
|
76
|
+
return await reader.readexactly(amount)
|
|
77
|
+
except asyncio.IncompleteReadError as exc:
|
|
78
|
+
raise ProtocolError('unexpected EOF while reading HTTP/1.1 body') from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _read_request_head_until_terminator(
|
|
82
|
+
reader: StreamReaderLike,
|
|
83
|
+
*,
|
|
84
|
+
limit: int,
|
|
85
|
+
buffer_size: int,
|
|
86
|
+
) -> bytes:
|
|
87
|
+
limited_readuntil = getattr(reader, 'readuntil_limited', None)
|
|
88
|
+
if callable(limited_readuntil):
|
|
89
|
+
try:
|
|
90
|
+
return await limited_readuntil(b"\r\n\r\n", limit=limit, read_chunk_size=buffer_size)
|
|
91
|
+
except TypeError:
|
|
92
|
+
return await limited_readuntil(b"\r\n\r\n", limit=limit)
|
|
93
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
94
|
+
if len(head) > limit:
|
|
95
|
+
raise asyncio.LimitOverrunError('request head exceeds configured HTTP/1.1 request-head limit', consumed=len(head))
|
|
96
|
+
return head
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _consume_chunked_trailers(reader: StreamReaderLike) -> None:
|
|
100
|
+
while True:
|
|
101
|
+
trailer = await _read_line(reader)
|
|
102
|
+
if trailer == b"\r\n":
|
|
103
|
+
return
|
|
104
|
+
if trailer[:1] in {b' ', b'\t'}:
|
|
105
|
+
raise ProtocolError('obsolete line folding is not supported')
|
|
106
|
+
if b':' not in trailer[:-2]:
|
|
107
|
+
raise ProtocolError('malformed chunk trailer line')
|
|
108
|
+
name, value = trailer[:-2].split(b':', 1)
|
|
109
|
+
_validate_header_name(name.strip().lower())
|
|
110
|
+
_validate_header_value(value.strip())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _read_chunked_body(reader: StreamReaderLike, *, max_body_size: int) -> bytes:
|
|
114
|
+
parts: list[bytes] = []
|
|
115
|
+
total = 0
|
|
116
|
+
while True:
|
|
117
|
+
line = await _read_line(reader)
|
|
118
|
+
size_token = line[:-2].split(b';', 1)[0].strip()
|
|
119
|
+
try:
|
|
120
|
+
size = int(size_token, 16)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
raise ProtocolError('invalid chunk size') from exc
|
|
123
|
+
if size < 0:
|
|
124
|
+
raise ProtocolError('invalid chunk size')
|
|
125
|
+
if size == 0:
|
|
126
|
+
await _consume_chunked_trailers(reader)
|
|
127
|
+
return b''.join(parts)
|
|
128
|
+
chunk = await _readexactly(reader, size)
|
|
129
|
+
terminator = await _readexactly(reader, 2)
|
|
130
|
+
if terminator != b"\r\n":
|
|
131
|
+
raise ProtocolError('invalid chunk terminator')
|
|
132
|
+
total += size
|
|
133
|
+
if total > max_body_size:
|
|
134
|
+
raise ProtocolError('request body exceeds configured max_body_size')
|
|
135
|
+
parts.append(chunk)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_request_target(method: str, target: str) -> tuple[str, bytes, bytes, RequestTargetForm]:
|
|
140
|
+
method_upper = method.upper()
|
|
141
|
+
if target == '*':
|
|
142
|
+
if method_upper != 'OPTIONS':
|
|
143
|
+
raise ProtocolError('asterisk-form request-target is only valid for OPTIONS')
|
|
144
|
+
return '*', b'*', b'', 'asterisk'
|
|
145
|
+
|
|
146
|
+
if method_upper == 'CONNECT':
|
|
147
|
+
if '://' in target or '/' in target or '?' in target or '#' in target or not target:
|
|
148
|
+
raise ProtocolError('invalid authority-form request-target')
|
|
149
|
+
return target, target.encode('ascii'), b'', 'authority'
|
|
150
|
+
|
|
151
|
+
if target.startswith('http://') or target.startswith('https://'):
|
|
152
|
+
split = urlsplit(target)
|
|
153
|
+
if not split.scheme or not split.netloc:
|
|
154
|
+
raise ProtocolError('invalid absolute-form request-target')
|
|
155
|
+
path = split.path or '/'
|
|
156
|
+
return path, path.encode('utf-8'), split.query.encode('ascii'), 'absolute'
|
|
157
|
+
|
|
158
|
+
if not target.startswith('/'):
|
|
159
|
+
raise ProtocolError('invalid origin-form request-target')
|
|
160
|
+
split = urlsplit(target)
|
|
161
|
+
path = split.path or '/'
|
|
162
|
+
return path, path.encode('utf-8'), split.query.encode('ascii'), 'origin'
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_transfer_encoding(headers: list[tuple[bytes, bytes]]) -> Literal['none', 'chunked']:
|
|
167
|
+
codings: list[bytes] = []
|
|
168
|
+
for key, value in headers:
|
|
169
|
+
if key != b'transfer-encoding':
|
|
170
|
+
continue
|
|
171
|
+
for part in value.split(b','):
|
|
172
|
+
token = part.strip().lower()
|
|
173
|
+
if token:
|
|
174
|
+
codings.append(token)
|
|
175
|
+
if not codings:
|
|
176
|
+
return 'none'
|
|
177
|
+
if codings.count(b'chunked') > 1:
|
|
178
|
+
raise ProtocolError('chunked transfer-encoding must not be repeated')
|
|
179
|
+
if b'chunked' in codings and codings[-1] != b'chunked':
|
|
180
|
+
raise ProtocolError('chunked transfer-encoding must be final')
|
|
181
|
+
unsupported = [coding for coding in codings if coding not in {b'chunked', b'identity'}]
|
|
182
|
+
if unsupported:
|
|
183
|
+
raise UnsupportedFeature('unsupported transfer-encoding')
|
|
184
|
+
if codings and codings[-1] == b'chunked' and any(coding not in {b'chunked', b'identity'} for coding in codings[:-1]):
|
|
185
|
+
raise UnsupportedFeature('unsupported transfer-encoding')
|
|
186
|
+
if any(coding != b'identity' for coding in codings[:-1]):
|
|
187
|
+
raise UnsupportedFeature('unsupported transfer-encoding')
|
|
188
|
+
return 'chunked' if codings[-1] == b'chunked' else 'none'
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_request_head_bytes(head: bytes) -> ParsedRequestHead | None:
|
|
193
|
+
if not head:
|
|
194
|
+
return None
|
|
195
|
+
lines = head.split(b"\r\n")
|
|
196
|
+
if not lines or not lines[0]:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
request_line = lines[0]
|
|
200
|
+
parts = request_line.split(b' ', 2)
|
|
201
|
+
if len(parts) != 3:
|
|
202
|
+
raise ProtocolError('invalid HTTP request line')
|
|
203
|
+
|
|
204
|
+
method_b, target_b, version_b = parts
|
|
205
|
+
if not version_b.startswith(b'HTTP/'):
|
|
206
|
+
raise ProtocolError('invalid HTTP version token')
|
|
207
|
+
|
|
208
|
+
if not _is_token(method_b):
|
|
209
|
+
raise ProtocolError('invalid HTTP method token')
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
method = method_b.decode('ascii', 'strict')
|
|
213
|
+
target = target_b.decode('ascii', 'strict')
|
|
214
|
+
http_version = version_b.removeprefix(b'HTTP/').decode('ascii', 'strict')
|
|
215
|
+
except UnicodeDecodeError as exc:
|
|
216
|
+
raise ProtocolError('request line is not valid ASCII') from exc
|
|
217
|
+
|
|
218
|
+
if http_version not in {'1.0', '1.1'}:
|
|
219
|
+
raise ProtocolError('unsupported HTTP version')
|
|
220
|
+
|
|
221
|
+
path, raw_path, query_string, target_form = _parse_request_target(method, target)
|
|
222
|
+
|
|
223
|
+
headers: list[tuple[bytes, bytes]] = []
|
|
224
|
+
content_length: int | None = None
|
|
225
|
+
host_values: list[bytes] = []
|
|
226
|
+
for raw_line in lines[1:]:
|
|
227
|
+
if raw_line == b'':
|
|
228
|
+
continue
|
|
229
|
+
if raw_line[:1] in {b' ', b'\t'}:
|
|
230
|
+
raise ProtocolError('obsolete line folding is not supported')
|
|
231
|
+
try:
|
|
232
|
+
key, value = raw_line.split(b':', 1)
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
raise ProtocolError('malformed header line') from exc
|
|
235
|
+
key = key.strip().lower()
|
|
236
|
+
value = value.strip()
|
|
237
|
+
_validate_header_name(key)
|
|
238
|
+
_validate_header_value(value)
|
|
239
|
+
headers.append((key, value))
|
|
240
|
+
if key == b'content-length':
|
|
241
|
+
try:
|
|
242
|
+
new_len = int(value.decode('ascii'))
|
|
243
|
+
except ValueError as exc:
|
|
244
|
+
raise ProtocolError('invalid Content-Length header') from exc
|
|
245
|
+
if new_len < 0:
|
|
246
|
+
raise ProtocolError('invalid Content-Length header')
|
|
247
|
+
if content_length is None:
|
|
248
|
+
content_length = new_len
|
|
249
|
+
elif content_length != new_len:
|
|
250
|
+
raise ProtocolError('conflicting Content-Length headers')
|
|
251
|
+
elif key == b'host':
|
|
252
|
+
host_values.append(value)
|
|
253
|
+
|
|
254
|
+
if http_version == '1.1':
|
|
255
|
+
if len(host_values) != 1 or not host_values[0]:
|
|
256
|
+
raise ProtocolError('HTTP/1.1 requests must include exactly one Host header')
|
|
257
|
+
|
|
258
|
+
transfer_encoding = _parse_transfer_encoding(headers)
|
|
259
|
+
if transfer_encoding == 'chunked' and content_length is not None:
|
|
260
|
+
raise ProtocolError('request cannot specify both Content-Length and chunked transfer-encoding')
|
|
261
|
+
|
|
262
|
+
body_kind: Literal['none', 'content-length', 'chunked']
|
|
263
|
+
if transfer_encoding == 'chunked':
|
|
264
|
+
body_kind = 'chunked'
|
|
265
|
+
elif content_length:
|
|
266
|
+
body_kind = 'content-length'
|
|
267
|
+
else:
|
|
268
|
+
body_kind = 'none'
|
|
269
|
+
|
|
270
|
+
return ParsedRequestHead(
|
|
271
|
+
method=method,
|
|
272
|
+
target=target,
|
|
273
|
+
path=path,
|
|
274
|
+
raw_path=raw_path,
|
|
275
|
+
query_string=query_string,
|
|
276
|
+
http_version=http_version,
|
|
277
|
+
headers=headers,
|
|
278
|
+
keep_alive=keep_alive_for_request(http_version, headers),
|
|
279
|
+
expect_continue=expect_continue(headers) and body_kind != 'none',
|
|
280
|
+
websocket_upgrade=(
|
|
281
|
+
method.upper() == 'GET'
|
|
282
|
+
and header_contains_token(headers, b'connection', b'upgrade')
|
|
283
|
+
and header_contains_token(headers, b'upgrade', b'websocket')
|
|
284
|
+
),
|
|
285
|
+
body_kind=body_kind,
|
|
286
|
+
content_length=content_length,
|
|
287
|
+
target_form=target_form,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def read_http11_request_head(
|
|
292
|
+
reader: StreamReaderLike,
|
|
293
|
+
*,
|
|
294
|
+
max_body_size: int = 16 * 1024 * 1024,
|
|
295
|
+
max_header_size: int = 64 * 1024,
|
|
296
|
+
max_incomplete_event_size: int | None = None,
|
|
297
|
+
buffer_size: int = 64 * 1024,
|
|
298
|
+
) -> ParsedRequestHead | None:
|
|
299
|
+
request_head_limit = max_header_size if max_incomplete_event_size is None else min(max_header_size, max_incomplete_event_size)
|
|
300
|
+
try:
|
|
301
|
+
head = await _read_request_head_until_terminator(
|
|
302
|
+
reader,
|
|
303
|
+
limit=request_head_limit,
|
|
304
|
+
buffer_size=buffer_size,
|
|
305
|
+
)
|
|
306
|
+
except asyncio.IncompleteReadError as exc:
|
|
307
|
+
if exc.partial == b'':
|
|
308
|
+
return None
|
|
309
|
+
raise ProtocolError('unexpected EOF while reading request head') from exc
|
|
310
|
+
except asyncio.LimitOverrunError as exc:
|
|
311
|
+
raise ProtocolError('request head exceeds configured HTTP/1.1 request-head limit') from exc
|
|
312
|
+
|
|
313
|
+
if not head:
|
|
314
|
+
return None
|
|
315
|
+
if len(head) > request_head_limit:
|
|
316
|
+
raise ProtocolError('request head exceeds configured HTTP/1.1 request-head limit')
|
|
317
|
+
if len(head) > max_header_size:
|
|
318
|
+
raise ProtocolError('request head exceeds configured max_header_size')
|
|
319
|
+
|
|
320
|
+
parsed = _parse_request_head_bytes(head)
|
|
321
|
+
if parsed is None:
|
|
322
|
+
return None
|
|
323
|
+
if parsed.content_length is not None and parsed.content_length > max_body_size:
|
|
324
|
+
raise ProtocolError('request body exceeds configured max_body_size')
|
|
325
|
+
return parsed
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
HTTP11_REQUEST_HEAD_ERROR_MATRIX: tuple[dict[str, object], ...] = (
|
|
329
|
+
{
|
|
330
|
+
'case': 'request_line_shape',
|
|
331
|
+
'rfc': 'RFC 9112 request line',
|
|
332
|
+
'trigger': 'request line must contain exactly method, target, and version tokens',
|
|
333
|
+
'expected_exception': 'ProtocolError',
|
|
334
|
+
'message_fragment': 'invalid HTTP request line',
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
'case': 'http_version_token',
|
|
338
|
+
'rfc': 'RFC 9112 version token',
|
|
339
|
+
'trigger': 'version token must begin with HTTP/ and resolve to 1.0 or 1.1',
|
|
340
|
+
'expected_exception': 'ProtocolError',
|
|
341
|
+
'message_fragment': 'invalid HTTP version token',
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
'case': 'unsupported_http_version',
|
|
345
|
+
'rfc': 'RFC 9112 version negotiation',
|
|
346
|
+
'trigger': 'request line advertises an unsupported HTTP version',
|
|
347
|
+
'expected_exception': 'ProtocolError',
|
|
348
|
+
'message_fragment': 'unsupported HTTP version',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
'case': 'method_token',
|
|
352
|
+
'rfc': 'RFC 9110 method token syntax',
|
|
353
|
+
'trigger': 'method token contains invalid bytes',
|
|
354
|
+
'expected_exception': 'ProtocolError',
|
|
355
|
+
'message_fragment': 'invalid HTTP method token',
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
'case': 'target_form_authority',
|
|
359
|
+
'rfc': 'RFC 9112 CONNECT authority-form',
|
|
360
|
+
'trigger': 'CONNECT target is not valid authority-form',
|
|
361
|
+
'expected_exception': 'ProtocolError',
|
|
362
|
+
'message_fragment': 'invalid authority-form request-target',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
'case': 'target_form_absolute',
|
|
366
|
+
'rfc': 'RFC 9112 absolute-form',
|
|
367
|
+
'trigger': 'absolute-form target is syntactically malformed',
|
|
368
|
+
'expected_exception': 'ProtocolError',
|
|
369
|
+
'message_fragment': 'invalid absolute-form request-target',
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
'case': 'target_form_origin',
|
|
373
|
+
'rfc': 'RFC 9112 origin-form',
|
|
374
|
+
'trigger': 'origin-form target does not start with /',
|
|
375
|
+
'expected_exception': 'ProtocolError',
|
|
376
|
+
'message_fragment': 'invalid origin-form request-target',
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
'case': 'target_form_asterisk',
|
|
380
|
+
'rfc': 'RFC 9112 asterisk-form',
|
|
381
|
+
'trigger': 'asterisk-form is used with a method other than OPTIONS',
|
|
382
|
+
'expected_exception': 'ProtocolError',
|
|
383
|
+
'message_fragment': 'asterisk-form request-target is only valid for OPTIONS',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
'case': 'header_line_folding',
|
|
387
|
+
'rfc': 'RFC 9110 field line syntax',
|
|
388
|
+
'trigger': 'obs-fold / line folding appears in field section',
|
|
389
|
+
'expected_exception': 'ProtocolError',
|
|
390
|
+
'message_fragment': 'obsolete line folding is not supported',
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
'case': 'header_name_and_value',
|
|
394
|
+
'rfc': 'RFC 9110 field syntax',
|
|
395
|
+
'trigger': 'header field name or value contains forbidden octets',
|
|
396
|
+
'expected_exception': 'ProtocolError',
|
|
397
|
+
'message_fragment': 'invalid header field',
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
'case': 'content_length_conflict',
|
|
401
|
+
'rfc': 'RFC 9112 message body length',
|
|
402
|
+
'trigger': 'multiple Content-Length values disagree or are negative',
|
|
403
|
+
'expected_exception': 'ProtocolError',
|
|
404
|
+
'message_fragment': 'Content-Length',
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
'case': 'host_header_requirements',
|
|
408
|
+
'rfc': 'RFC 9112 Host requirements',
|
|
409
|
+
'trigger': 'HTTP/1.1 request does not include exactly one non-empty Host header',
|
|
410
|
+
'expected_exception': 'ProtocolError',
|
|
411
|
+
'message_fragment': 'must include exactly one Host header',
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
'case': 'transfer_encoding_chain',
|
|
415
|
+
'rfc': 'RFC 9112 transfer-coding',
|
|
416
|
+
'trigger': 'chunked is repeated, not final, or appears with an unsupported chain',
|
|
417
|
+
'expected_exception': 'ProtocolError|UnsupportedFeature',
|
|
418
|
+
'message_fragment': 'transfer-encoding',
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
'case': 'content_length_and_chunked_conflict',
|
|
422
|
+
'rfc': 'RFC 9112 message body length',
|
|
423
|
+
'trigger': 'Content-Length appears with chunked transfer-encoding',
|
|
424
|
+
'expected_exception': 'ProtocolError',
|
|
425
|
+
'message_fragment': 'both Content-Length and chunked transfer-encoding',
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
'case': 'chunked_body_syntax',
|
|
429
|
+
'rfc': 'RFC 9112 chunked coding',
|
|
430
|
+
'trigger': 'chunk size, terminator, or trailers are malformed',
|
|
431
|
+
'expected_exception': 'ProtocolError',
|
|
432
|
+
'message_fragment': 'chunk',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
'case': 'size_limits',
|
|
436
|
+
'rfc': 'RFC 9112 implementation limits',
|
|
437
|
+
'trigger': 'request head or body exceeds configured limits',
|
|
438
|
+
'expected_exception': 'ProtocolError',
|
|
439
|
+
'message_fragment': 'configured max_',
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def http11_request_head_error_matrix() -> tuple[dict[str, object], ...]:
|
|
445
|
+
return tuple(dict(entry) for entry in HTTP11_REQUEST_HEAD_ERROR_MATRIX)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
async def read_http11_request(
|
|
449
|
+
reader: StreamReaderLike,
|
|
450
|
+
*,
|
|
451
|
+
max_body_size: int = 16 * 1024 * 1024,
|
|
452
|
+
max_header_size: int = 64 * 1024,
|
|
453
|
+
) -> ParsedRequest | None:
|
|
454
|
+
parsed = await read_http11_request_head(
|
|
455
|
+
reader,
|
|
456
|
+
max_body_size=max_body_size,
|
|
457
|
+
max_header_size=max_header_size,
|
|
458
|
+
)
|
|
459
|
+
if parsed is None:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
body = b''
|
|
463
|
+
if parsed.body_kind == 'chunked':
|
|
464
|
+
body = await _read_chunked_body(reader, max_body_size=max_body_size)
|
|
465
|
+
elif parsed.body_kind == 'content-length':
|
|
466
|
+
assert parsed.content_length is not None
|
|
467
|
+
body = await _readexactly(reader, parsed.content_length)
|
|
468
|
+
|
|
469
|
+
return ParsedRequest(
|
|
470
|
+
method=parsed.method,
|
|
471
|
+
target=parsed.target,
|
|
472
|
+
path=parsed.path,
|
|
473
|
+
raw_path=parsed.raw_path,
|
|
474
|
+
query_string=parsed.query_string,
|
|
475
|
+
http_version=parsed.http_version,
|
|
476
|
+
headers=parsed.headers,
|
|
477
|
+
body=body,
|
|
478
|
+
keep_alive=parsed.keep_alive,
|
|
479
|
+
expect_continue=parsed.expect_continue,
|
|
480
|
+
websocket_upgrade=parsed.websocket_upgrade,
|
|
481
|
+
)
|