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,393 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from tigrcorn_core.errors import ProtocolError
|
|
7
|
+
from tigrcorn_protocols._compression import (
|
|
8
|
+
decode_prefixed_integer,
|
|
9
|
+
decode_prefixed_string,
|
|
10
|
+
encode_prefixed_integer,
|
|
11
|
+
encode_prefixed_string,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_STATIC_TABLE: list[tuple[bytes, bytes]] = [
|
|
15
|
+
(b":authority", b""),
|
|
16
|
+
(b":method", b"GET"),
|
|
17
|
+
(b":method", b"POST"),
|
|
18
|
+
(b":path", b"/"),
|
|
19
|
+
(b":path", b"/index.html"),
|
|
20
|
+
(b":scheme", b"http"),
|
|
21
|
+
(b":scheme", b"https"),
|
|
22
|
+
(b":status", b"200"),
|
|
23
|
+
(b":status", b"204"),
|
|
24
|
+
(b":status", b"206"),
|
|
25
|
+
(b":status", b"304"),
|
|
26
|
+
(b":status", b"400"),
|
|
27
|
+
(b":status", b"404"),
|
|
28
|
+
(b":status", b"500"),
|
|
29
|
+
(b"accept-charset", b""),
|
|
30
|
+
(b"accept-encoding", b"gzip, deflate"),
|
|
31
|
+
(b"accept-language", b""),
|
|
32
|
+
(b"accept-ranges", b""),
|
|
33
|
+
(b"accept", b""),
|
|
34
|
+
(b"access-control-allow-origin", b""),
|
|
35
|
+
(b"age", b""),
|
|
36
|
+
(b"allow", b""),
|
|
37
|
+
(b"authorization", b""),
|
|
38
|
+
(b"cache-control", b""),
|
|
39
|
+
(b"content-disposition", b""),
|
|
40
|
+
(b"content-encoding", b""),
|
|
41
|
+
(b"content-language", b""),
|
|
42
|
+
(b"content-length", b""),
|
|
43
|
+
(b"content-location", b""),
|
|
44
|
+
(b"content-range", b""),
|
|
45
|
+
(b"content-type", b""),
|
|
46
|
+
(b"cookie", b""),
|
|
47
|
+
(b"date", b""),
|
|
48
|
+
(b"etag", b""),
|
|
49
|
+
(b"expect", b""),
|
|
50
|
+
(b"expires", b""),
|
|
51
|
+
(b"from", b""),
|
|
52
|
+
(b"host", b""),
|
|
53
|
+
(b"if-match", b""),
|
|
54
|
+
(b"if-modified-since", b""),
|
|
55
|
+
(b"if-none-match", b""),
|
|
56
|
+
(b"if-range", b""),
|
|
57
|
+
(b"if-unmodified-since", b""),
|
|
58
|
+
(b"last-modified", b""),
|
|
59
|
+
(b"link", b""),
|
|
60
|
+
(b"location", b""),
|
|
61
|
+
(b"max-forwards", b""),
|
|
62
|
+
(b"proxy-authenticate", b""),
|
|
63
|
+
(b"proxy-authorization", b""),
|
|
64
|
+
(b"range", b""),
|
|
65
|
+
(b"referer", b""),
|
|
66
|
+
(b"refresh", b""),
|
|
67
|
+
(b"retry-after", b""),
|
|
68
|
+
(b"server", b""),
|
|
69
|
+
(b"set-cookie", b""),
|
|
70
|
+
(b"strict-transport-security", b""),
|
|
71
|
+
(b"transfer-encoding", b""),
|
|
72
|
+
(b"user-agent", b""),
|
|
73
|
+
(b"vary", b""),
|
|
74
|
+
(b"via", b""),
|
|
75
|
+
(b"www-authenticate", b""),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
STATIC_TABLE: list[tuple[bytes, bytes] | None] = [None, *_STATIC_TABLE]
|
|
79
|
+
STATIC_TABLE_LENGTH = len(STATIC_TABLE) - 1
|
|
80
|
+
STATIC_INDEX = {entry: idx for idx, entry in enumerate(STATIC_TABLE) if idx and entry is not None}
|
|
81
|
+
STATIC_NAME_INDEX: dict[bytes, int] = {}
|
|
82
|
+
for idx, entry in enumerate(STATIC_TABLE):
|
|
83
|
+
if not idx or entry is None:
|
|
84
|
+
continue
|
|
85
|
+
name, _value = entry
|
|
86
|
+
if name not in STATIC_NAME_INDEX:
|
|
87
|
+
STATIC_NAME_INDEX[name] = idx
|
|
88
|
+
|
|
89
|
+
SENSITIVE_HEADERS = {
|
|
90
|
+
b"authorization",
|
|
91
|
+
b"cookie",
|
|
92
|
+
b"proxy-authorization",
|
|
93
|
+
b"set-cookie",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Public helpers retained for existing callers.
|
|
98
|
+
def encode_integer(value: int, prefix_bits: int, prefix_mask: int = 0) -> bytes:
|
|
99
|
+
return encode_prefixed_integer(value, prefix_bits, prefix_mask)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def decode_integer(
|
|
104
|
+
data: bytes,
|
|
105
|
+
offset: int,
|
|
106
|
+
prefix_bits: int,
|
|
107
|
+
*,
|
|
108
|
+
max_octets: int | None = None,
|
|
109
|
+
max_value: int | None = None,
|
|
110
|
+
) -> tuple[int, int]:
|
|
111
|
+
return decode_prefixed_integer(data, offset, prefix_bits, max_octets=max_octets, max_value=max_value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def encode_string(data: bytes, *, huffman: bool = True) -> bytes:
|
|
116
|
+
return encode_prefixed_string(data, 8, 0x00, huffman=huffman)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def decode_string(
|
|
121
|
+
data: bytes,
|
|
122
|
+
offset: int,
|
|
123
|
+
*,
|
|
124
|
+
max_length: int | None = None,
|
|
125
|
+
max_decoded_length: int | None = None,
|
|
126
|
+
max_integer_octets: int | None = None,
|
|
127
|
+
) -> tuple[bytes, int]:
|
|
128
|
+
return decode_prefixed_string(
|
|
129
|
+
data,
|
|
130
|
+
offset,
|
|
131
|
+
8,
|
|
132
|
+
max_length=max_length,
|
|
133
|
+
max_decoded_length=max_decoded_length,
|
|
134
|
+
max_integer_octets=max_integer_octets,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(slots=True)
|
|
139
|
+
class DynamicTableEntry:
|
|
140
|
+
name: bytes
|
|
141
|
+
value: bytes
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def size(self) -> int:
|
|
145
|
+
return len(self.name) + len(self.value) + 32
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(slots=True)
|
|
149
|
+
class HPACKDynamicTable:
|
|
150
|
+
max_size: int = 4096
|
|
151
|
+
entries: list[DynamicTableEntry] = field(default_factory=list)
|
|
152
|
+
size: int = 0
|
|
153
|
+
|
|
154
|
+
def update_max_size(self, max_size: int) -> None:
|
|
155
|
+
if max_size < 0:
|
|
156
|
+
raise ProtocolError("HPACK dynamic table size must be non-negative")
|
|
157
|
+
self.max_size = max_size
|
|
158
|
+
self._evict_to_limit(0)
|
|
159
|
+
|
|
160
|
+
def _evict_to_limit(self, incoming_size: int) -> None:
|
|
161
|
+
while self.size + incoming_size > self.max_size and self.entries:
|
|
162
|
+
evicted = self.entries.pop()
|
|
163
|
+
self.size -= evicted.size
|
|
164
|
+
|
|
165
|
+
def insert(self, name: bytes, value: bytes) -> None:
|
|
166
|
+
entry = DynamicTableEntry(name=name, value=value)
|
|
167
|
+
if entry.size > self.max_size:
|
|
168
|
+
self.entries.clear()
|
|
169
|
+
self.size = 0
|
|
170
|
+
return
|
|
171
|
+
self._evict_to_limit(entry.size)
|
|
172
|
+
self.entries.insert(0, entry)
|
|
173
|
+
self.size += entry.size
|
|
174
|
+
|
|
175
|
+
def lookup(self, index: int) -> tuple[bytes, bytes]:
|
|
176
|
+
if index <= 0:
|
|
177
|
+
raise ProtocolError(f"invalid HPACK index: {index}")
|
|
178
|
+
if index <= STATIC_TABLE_LENGTH:
|
|
179
|
+
entry = STATIC_TABLE[index]
|
|
180
|
+
if entry is None:
|
|
181
|
+
raise ProtocolError(f"unknown HPACK static index: {index}")
|
|
182
|
+
return entry
|
|
183
|
+
dynamic_index = index - STATIC_TABLE_LENGTH - 1
|
|
184
|
+
if dynamic_index < 0 or dynamic_index >= len(self.entries):
|
|
185
|
+
raise ProtocolError(f"unknown HPACK dynamic index: {index}")
|
|
186
|
+
entry = self.entries[dynamic_index]
|
|
187
|
+
return entry.name, entry.value
|
|
188
|
+
|
|
189
|
+
def lookup_exact(self, name: bytes, value: bytes) -> int | None:
|
|
190
|
+
exact_static = STATIC_INDEX.get((name, value))
|
|
191
|
+
if exact_static is not None:
|
|
192
|
+
return exact_static
|
|
193
|
+
for offset, entry in enumerate(self.entries, start=STATIC_TABLE_LENGTH + 1):
|
|
194
|
+
if entry.name == name and entry.value == value:
|
|
195
|
+
return offset
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def lookup_name(self, name: bytes) -> int:
|
|
199
|
+
for offset, entry in enumerate(self.entries, start=STATIC_TABLE_LENGTH + 1):
|
|
200
|
+
if entry.name == name:
|
|
201
|
+
return offset
|
|
202
|
+
return STATIC_NAME_INDEX.get(name, 0)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class HPACKEncoder:
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
max_table_size: int = 4096,
|
|
210
|
+
use_huffman: bool = True,
|
|
211
|
+
sensitive_headers: set[bytes] | None = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
self.dynamic_table = HPACKDynamicTable(max_size=max_table_size)
|
|
214
|
+
self.use_huffman = use_huffman
|
|
215
|
+
self.sensitive_headers = set(SENSITIVE_HEADERS if sensitive_headers is None else sensitive_headers)
|
|
216
|
+
self._pending_table_size_updates: list[int] = []
|
|
217
|
+
|
|
218
|
+
def set_max_table_size(self, value: int) -> None:
|
|
219
|
+
self.dynamic_table.update_max_size(value)
|
|
220
|
+
self._pending_table_size_updates.append(value)
|
|
221
|
+
|
|
222
|
+
def _encode_indexed(self, index: int) -> bytes:
|
|
223
|
+
return encode_integer(index, 7, 0x80)
|
|
224
|
+
|
|
225
|
+
def _encode_literal(self, name: bytes, value: bytes, *, prefix_mask: int, prefix_bits: int, index: bool) -> bytes:
|
|
226
|
+
name_index = self.dynamic_table.lookup_name(name)
|
|
227
|
+
raw = bytearray(encode_integer(name_index, prefix_bits, prefix_mask))
|
|
228
|
+
if name_index == 0:
|
|
229
|
+
raw.extend(encode_string(name, huffman=self.use_huffman))
|
|
230
|
+
raw.extend(encode_string(value, huffman=self.use_huffman))
|
|
231
|
+
if index:
|
|
232
|
+
self.dynamic_table.insert(name, value)
|
|
233
|
+
return bytes(raw)
|
|
234
|
+
|
|
235
|
+
def _should_index(self, name: bytes, value: bytes) -> bool:
|
|
236
|
+
if name in self.sensitive_headers:
|
|
237
|
+
return False
|
|
238
|
+
return self.dynamic_table.max_size > 0 and len(name) + len(value) + 32 <= self.dynamic_table.max_size
|
|
239
|
+
|
|
240
|
+
def encode_header(self, name: bytes, value: bytes) -> bytes:
|
|
241
|
+
exact = self.dynamic_table.lookup_exact(name, value)
|
|
242
|
+
if exact is not None:
|
|
243
|
+
return self._encode_indexed(exact)
|
|
244
|
+
if self._should_index(name, value):
|
|
245
|
+
return self._encode_literal(name, value, prefix_mask=0x40, prefix_bits=6, index=True)
|
|
246
|
+
prefix_mask = 0x10 if name in self.sensitive_headers else 0x00
|
|
247
|
+
return self._encode_literal(name, value, prefix_mask=prefix_mask, prefix_bits=4, index=False)
|
|
248
|
+
|
|
249
|
+
def encode_header_block(self, headers: Iterable[tuple[bytes, bytes]]) -> bytes:
|
|
250
|
+
raw = bytearray()
|
|
251
|
+
for value in self._pending_table_size_updates:
|
|
252
|
+
raw.extend(encode_integer(value, 5, 0x20))
|
|
253
|
+
self._pending_table_size_updates.clear()
|
|
254
|
+
for name, value in headers:
|
|
255
|
+
raw.extend(self.encode_header(name, value))
|
|
256
|
+
return bytes(raw)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class HPACKDecoder:
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
*,
|
|
263
|
+
max_table_size: int = 4096,
|
|
264
|
+
max_header_list_size: int | None = 65536,
|
|
265
|
+
max_header_block_size: int = 65536,
|
|
266
|
+
max_header_count: int = 256,
|
|
267
|
+
max_string_length: int = 65536,
|
|
268
|
+
max_integer_octets: int = 8,
|
|
269
|
+
) -> None:
|
|
270
|
+
self.dynamic_table = HPACKDynamicTable(max_size=max_table_size)
|
|
271
|
+
self.max_allowed_table_size = max_table_size
|
|
272
|
+
self.max_header_list_size = max_header_list_size
|
|
273
|
+
self.max_header_block_size = max_header_block_size
|
|
274
|
+
self.max_header_count = max_header_count
|
|
275
|
+
self.max_string_length = max_string_length
|
|
276
|
+
self.max_integer_octets = max_integer_octets
|
|
277
|
+
|
|
278
|
+
def set_max_allowed_table_size(self, value: int) -> None:
|
|
279
|
+
if value < 0:
|
|
280
|
+
raise ProtocolError("HPACK table size limit must be non-negative")
|
|
281
|
+
self.max_allowed_table_size = value
|
|
282
|
+
if self.dynamic_table.max_size > value:
|
|
283
|
+
self.dynamic_table.update_max_size(value)
|
|
284
|
+
|
|
285
|
+
def set_max_header_list_size(self, value: int | None) -> None:
|
|
286
|
+
if value is not None and value < 0:
|
|
287
|
+
raise ProtocolError("HPACK header list size limit must be non-negative")
|
|
288
|
+
self.max_header_list_size = value
|
|
289
|
+
|
|
290
|
+
def _resolve_name(self, name_index: int, data: bytes, offset: int) -> tuple[bytes, int]:
|
|
291
|
+
if name_index == 0:
|
|
292
|
+
return decode_string(
|
|
293
|
+
data,
|
|
294
|
+
offset,
|
|
295
|
+
max_length=self.max_string_length,
|
|
296
|
+
max_decoded_length=self.max_string_length,
|
|
297
|
+
max_integer_octets=self.max_integer_octets,
|
|
298
|
+
)
|
|
299
|
+
name, _value = self.dynamic_table.lookup(name_index)
|
|
300
|
+
return name, offset
|
|
301
|
+
|
|
302
|
+
def _append_header(
|
|
303
|
+
self,
|
|
304
|
+
headers: list[tuple[bytes, bytes]],
|
|
305
|
+
header: tuple[bytes, bytes],
|
|
306
|
+
running_size: int,
|
|
307
|
+
) -> int:
|
|
308
|
+
if len(headers) >= self.max_header_count:
|
|
309
|
+
raise ProtocolError("HPACK header count exceeds configured maximum")
|
|
310
|
+
new_size = running_size + len(header[0]) + len(header[1]) + 32
|
|
311
|
+
if self.max_header_list_size is not None and new_size > self.max_header_list_size:
|
|
312
|
+
raise ProtocolError("HPACK header list exceeds configured maximum")
|
|
313
|
+
headers.append(header)
|
|
314
|
+
return new_size
|
|
315
|
+
|
|
316
|
+
def decode_header_block(self, block: bytes) -> list[tuple[bytes, bytes]]:
|
|
317
|
+
if len(block) > self.max_header_block_size:
|
|
318
|
+
raise ProtocolError("HPACK header block exceeds configured maximum")
|
|
319
|
+
headers: list[tuple[bytes, bytes]] = []
|
|
320
|
+
offset = 0
|
|
321
|
+
header_size = 0
|
|
322
|
+
saw_header_representation = False
|
|
323
|
+
while offset < len(block):
|
|
324
|
+
first = block[offset]
|
|
325
|
+
if first & 0x80:
|
|
326
|
+
saw_header_representation = True
|
|
327
|
+
index, offset = decode_integer(block, offset, 7, max_octets=self.max_integer_octets)
|
|
328
|
+
header = self.dynamic_table.lookup(index)
|
|
329
|
+
header_size = self._append_header(headers, header, header_size)
|
|
330
|
+
continue
|
|
331
|
+
if first & 0x40:
|
|
332
|
+
saw_header_representation = True
|
|
333
|
+
name_index, offset = decode_integer(block, offset, 6, max_octets=self.max_integer_octets)
|
|
334
|
+
name, offset = self._resolve_name(name_index, block, offset)
|
|
335
|
+
value, offset = decode_string(
|
|
336
|
+
block,
|
|
337
|
+
offset,
|
|
338
|
+
max_length=self.max_string_length,
|
|
339
|
+
max_decoded_length=self.max_string_length,
|
|
340
|
+
max_integer_octets=self.max_integer_octets,
|
|
341
|
+
)
|
|
342
|
+
header_size = self._append_header(headers, (name, value), header_size)
|
|
343
|
+
self.dynamic_table.insert(name, value)
|
|
344
|
+
continue
|
|
345
|
+
if first & 0x20:
|
|
346
|
+
if saw_header_representation:
|
|
347
|
+
raise ProtocolError("HPACK dynamic table size update must appear at the start of a header block")
|
|
348
|
+
size, offset = decode_integer(
|
|
349
|
+
block,
|
|
350
|
+
offset,
|
|
351
|
+
5,
|
|
352
|
+
max_octets=self.max_integer_octets,
|
|
353
|
+
max_value=self.max_allowed_table_size,
|
|
354
|
+
)
|
|
355
|
+
if size > self.max_allowed_table_size:
|
|
356
|
+
raise ProtocolError("HPACK dynamic table size update exceeds allowed maximum")
|
|
357
|
+
self.dynamic_table.update_max_size(size)
|
|
358
|
+
continue
|
|
359
|
+
saw_header_representation = True
|
|
360
|
+
name_index, offset = decode_integer(block, offset, 4, max_octets=self.max_integer_octets)
|
|
361
|
+
name, offset = self._resolve_name(name_index, block, offset)
|
|
362
|
+
value, offset = decode_string(
|
|
363
|
+
block,
|
|
364
|
+
offset,
|
|
365
|
+
max_length=self.max_string_length,
|
|
366
|
+
max_decoded_length=self.max_string_length,
|
|
367
|
+
max_integer_octets=self.max_integer_octets,
|
|
368
|
+
)
|
|
369
|
+
header_size = self._append_header(headers, (name, value), header_size)
|
|
370
|
+
return headers
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# Stateless wrappers used by standalone tests and utilities.
|
|
374
|
+
def encode_header(name: bytes, value: bytes) -> bytes:
|
|
375
|
+
return HPACKEncoder(max_table_size=0).encode_header(name, value)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def encode_header_block(headers: Iterable[tuple[bytes, bytes]]) -> bytes:
|
|
380
|
+
return HPACKEncoder().encode_header_block(headers)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def decode_header_block(
|
|
385
|
+
block: bytes,
|
|
386
|
+
*,
|
|
387
|
+
max_header_list_size: int | None = 65536,
|
|
388
|
+
max_header_block_size: int = 65536,
|
|
389
|
+
) -> list[tuple[bytes, bytes]]:
|
|
390
|
+
return HPACKDecoder(
|
|
391
|
+
max_header_list_size=max_header_list_size,
|
|
392
|
+
max_header_block_size=max_header_block_size,
|
|
393
|
+
).decode_header_block(block)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from tigrcorn_core.errors import ProtocolError
|
|
7
|
+
from tigrcorn_protocols.http2.codec import DEFAULT_SETTINGS, SETTING_ENABLE_PUSH, SETTING_INITIAL_WINDOW_SIZE, SETTING_MAX_CONCURRENT_STREAMS, SETTING_MAX_FRAME_SIZE, SETTING_MAX_HEADER_LIST_SIZE
|
|
8
|
+
|
|
9
|
+
MAX_FLOW_WINDOW = 0x7FFFFFFF
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class H2StreamLifecycle(str, Enum):
|
|
13
|
+
IDLE = "idle"
|
|
14
|
+
RESERVED_LOCAL = "reserved-local"
|
|
15
|
+
RESERVED_REMOTE = "reserved-remote"
|
|
16
|
+
OPEN = "open"
|
|
17
|
+
HALF_CLOSED_LOCAL = "half-closed-local"
|
|
18
|
+
HALF_CLOSED_REMOTE = "half-closed-remote"
|
|
19
|
+
CLOSED = "closed"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class FlowWindow:
|
|
24
|
+
available: int
|
|
25
|
+
|
|
26
|
+
def consume(self, amount: int) -> None:
|
|
27
|
+
if amount < 0:
|
|
28
|
+
raise ValueError("amount must be non-negative")
|
|
29
|
+
self.available -= amount
|
|
30
|
+
|
|
31
|
+
def increase(self, amount: int) -> None:
|
|
32
|
+
if amount < 0:
|
|
33
|
+
raise ValueError("amount must be non-negative")
|
|
34
|
+
if self.available > MAX_FLOW_WINDOW - amount:
|
|
35
|
+
raise ProtocolError("HTTP/2 flow-control window overflow")
|
|
36
|
+
self.available += amount
|
|
37
|
+
|
|
38
|
+
def adjust(self, delta: int) -> None:
|
|
39
|
+
if delta == 0:
|
|
40
|
+
return
|
|
41
|
+
if delta > 0 and self.available > MAX_FLOW_WINDOW - delta:
|
|
42
|
+
raise ProtocolError("HTTP/2 flow-control window overflow")
|
|
43
|
+
self.available += delta
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class H2StreamState:
|
|
48
|
+
stream_id: int
|
|
49
|
+
headers: list[tuple[bytes, bytes]] = field(default_factory=list)
|
|
50
|
+
trailers: list[tuple[bytes, bytes]] = field(default_factory=list)
|
|
51
|
+
body_parts: list[bytes] = field(default_factory=list)
|
|
52
|
+
header_fragments: list[bytes] = field(default_factory=list)
|
|
53
|
+
headers_complete: bool = False
|
|
54
|
+
trailers_complete: bool = False
|
|
55
|
+
awaiting_continuation: bool = False
|
|
56
|
+
end_stream_received: bool = False
|
|
57
|
+
dispatched: bool = False
|
|
58
|
+
closed: bool = False
|
|
59
|
+
websocket_session: object | None = None
|
|
60
|
+
connect_tunnel: object | None = None
|
|
61
|
+
send_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
|
|
62
|
+
receive_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
|
|
63
|
+
receive_window_target: int = DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]
|
|
64
|
+
receive_consumed_since_update: int = 0
|
|
65
|
+
buffered_body_size: int = 0
|
|
66
|
+
header_block_bytes: int = 0
|
|
67
|
+
current_header_block_is_trailers: bool = False
|
|
68
|
+
opened: bool = False
|
|
69
|
+
local_closed: bool = False
|
|
70
|
+
reserved_local: bool = False
|
|
71
|
+
reserved_remote: bool = False
|
|
72
|
+
reset_received: bool = False
|
|
73
|
+
reset_sent: bool = False
|
|
74
|
+
lifecycle: H2StreamLifecycle = H2StreamLifecycle.IDLE
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def body(self) -> bytes:
|
|
78
|
+
return b"".join(self.body_parts)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def remote_closed(self) -> bool:
|
|
82
|
+
return self.end_stream_received
|
|
83
|
+
|
|
84
|
+
def _sync_lifecycle(self) -> None:
|
|
85
|
+
if self.reset_received or self.reset_sent or (self.local_closed and self.end_stream_received):
|
|
86
|
+
self.lifecycle = H2StreamLifecycle.CLOSED
|
|
87
|
+
elif self.reserved_local:
|
|
88
|
+
self.lifecycle = H2StreamLifecycle.RESERVED_LOCAL
|
|
89
|
+
elif self.reserved_remote:
|
|
90
|
+
self.lifecycle = H2StreamLifecycle.RESERVED_REMOTE
|
|
91
|
+
elif not self.opened:
|
|
92
|
+
self.lifecycle = H2StreamLifecycle.IDLE
|
|
93
|
+
elif self.local_closed:
|
|
94
|
+
self.lifecycle = H2StreamLifecycle.HALF_CLOSED_LOCAL
|
|
95
|
+
elif self.end_stream_received:
|
|
96
|
+
self.lifecycle = H2StreamLifecycle.HALF_CLOSED_REMOTE
|
|
97
|
+
else:
|
|
98
|
+
self.lifecycle = H2StreamLifecycle.OPEN
|
|
99
|
+
self.closed = self.lifecycle is H2StreamLifecycle.CLOSED
|
|
100
|
+
|
|
101
|
+
def open_remote(self, *, end_stream: bool = False) -> None:
|
|
102
|
+
if self.opened and self.lifecycle is not H2StreamLifecycle.IDLE:
|
|
103
|
+
raise ProtocolError("HTTP/2 stream is already open")
|
|
104
|
+
self.opened = True
|
|
105
|
+
self.end_stream_received = end_stream
|
|
106
|
+
self._sync_lifecycle()
|
|
107
|
+
|
|
108
|
+
def reserve_local(self) -> None:
|
|
109
|
+
self.reserved_local = True
|
|
110
|
+
self.opened = False
|
|
111
|
+
self.local_closed = False
|
|
112
|
+
self.end_stream_received = False
|
|
113
|
+
self._sync_lifecycle()
|
|
114
|
+
|
|
115
|
+
def open_local_reserved(self, *, end_stream: bool = False) -> None:
|
|
116
|
+
if not self.reserved_local:
|
|
117
|
+
raise ProtocolError("HTTP/2 local stream is not reserved")
|
|
118
|
+
self.reserved_local = False
|
|
119
|
+
self.opened = True
|
|
120
|
+
self.end_stream_received = True
|
|
121
|
+
self.local_closed = end_stream
|
|
122
|
+
self._sync_lifecycle()
|
|
123
|
+
|
|
124
|
+
def receive_end_stream(self) -> None:
|
|
125
|
+
self.end_stream_received = True
|
|
126
|
+
self._sync_lifecycle()
|
|
127
|
+
|
|
128
|
+
def send_end_stream(self) -> None:
|
|
129
|
+
self.local_closed = True
|
|
130
|
+
self._sync_lifecycle()
|
|
131
|
+
|
|
132
|
+
def mark_reset_received(self) -> None:
|
|
133
|
+
self.reset_received = True
|
|
134
|
+
self.local_closed = True
|
|
135
|
+
self.end_stream_received = True
|
|
136
|
+
self._sync_lifecycle()
|
|
137
|
+
|
|
138
|
+
def mark_reset_sent(self) -> None:
|
|
139
|
+
self.reset_sent = True
|
|
140
|
+
self.local_closed = True
|
|
141
|
+
self.end_stream_received = True
|
|
142
|
+
self._sync_lifecycle()
|
|
143
|
+
|
|
144
|
+
def append_body(self, payload: bytes) -> None:
|
|
145
|
+
if payload:
|
|
146
|
+
self.body_parts.append(payload)
|
|
147
|
+
self.buffered_body_size += len(payload)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(slots=True)
|
|
151
|
+
class H2ConnectionState:
|
|
152
|
+
local_settings: dict[int, int] = field(default_factory=lambda: dict(DEFAULT_SETTINGS))
|
|
153
|
+
remote_settings: dict[int, int] = field(default_factory=lambda: {**DEFAULT_SETTINGS, SETTING_ENABLE_PUSH: 1})
|
|
154
|
+
connection_send_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
|
|
155
|
+
connection_receive_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
|
|
156
|
+
connection_receive_window_target: int = DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]
|
|
157
|
+
connection_receive_consumed_since_update: int = 0
|
|
158
|
+
preface_seen: bool = False
|
|
159
|
+
remote_settings_seen: bool = False
|
|
160
|
+
shutdown: bool = False
|
|
161
|
+
last_stream_id: int = 0
|
|
162
|
+
highest_remote_stream_id: int = 0
|
|
163
|
+
peer_goaway_received: bool = False
|
|
164
|
+
peer_last_stream_id: int | None = None
|
|
165
|
+
local_goaway_sent: bool = False
|
|
166
|
+
local_goaway_last_stream_id: int | None = None
|
|
167
|
+
next_local_stream_id: int = 2
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def max_frame_size(self) -> int:
|
|
171
|
+
return self.remote_settings.get(SETTING_MAX_FRAME_SIZE, DEFAULT_SETTINGS[SETTING_MAX_FRAME_SIZE])
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def initial_window_size(self) -> int:
|
|
175
|
+
return self.remote_settings.get(SETTING_INITIAL_WINDOW_SIZE, DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE])
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def local_initial_window_size(self) -> int:
|
|
179
|
+
return self.local_settings.get(SETTING_INITIAL_WINDOW_SIZE, DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE])
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def max_concurrent_streams(self) -> int:
|
|
183
|
+
return self.local_settings.get(SETTING_MAX_CONCURRENT_STREAMS, DEFAULT_SETTINGS[SETTING_MAX_CONCURRENT_STREAMS])
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def max_header_list_size(self) -> int:
|
|
187
|
+
return self.local_settings.get(SETTING_MAX_HEADER_LIST_SIZE, DEFAULT_SETTINGS[SETTING_MAX_HEADER_LIST_SIZE])
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def client_allows_push(self) -> bool:
|
|
191
|
+
return self.remote_settings.get(SETTING_ENABLE_PUSH, 1) != 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
H2_STREAM_TRANSITION_TABLE: tuple[dict[str, object], ...] = (
|
|
195
|
+
{'from': 'idle', 'event': 'remote headers', 'to': 'open', 'notes': 'peer opens the stream without END_STREAM'},
|
|
196
|
+
{'from': 'idle', 'event': 'remote headers + END_STREAM', 'to': 'half-closed-remote', 'notes': 'request headers end the peer send side immediately'},
|
|
197
|
+
{'from': 'idle', 'event': 'reserve_local', 'to': 'reserved-local', 'notes': 'local PUSH_PROMISE reservation state'},
|
|
198
|
+
{'from': 'reserved-local', 'event': 'local headers', 'to': 'half-closed-remote', 'notes': 'reserved local stream becomes locally open and remotely closed'},
|
|
199
|
+
{'from': 'reserved-local', 'event': 'local headers + END_STREAM', 'to': 'closed', 'notes': 'reserved local stream can close immediately when local side ends'},
|
|
200
|
+
{'from': 'open', 'event': 'receive_end_stream', 'to': 'half-closed-remote', 'notes': 'peer closed its send side'},
|
|
201
|
+
{'from': 'open', 'event': 'send_end_stream', 'to': 'half-closed-local', 'notes': 'local side closed while peer may still send'},
|
|
202
|
+
{'from': 'half-closed-remote', 'event': 'send_end_stream', 'to': 'closed', 'notes': 'stream fully closed after local END_STREAM'},
|
|
203
|
+
{'from': 'half-closed-local', 'event': 'receive_end_stream', 'to': 'closed', 'notes': 'stream fully closed after peer END_STREAM'},
|
|
204
|
+
{'from': 'open|half-closed-local|half-closed-remote|reserved-local|reserved-remote', 'event': 'reset sent/received', 'to': 'closed', 'notes': 'RST_STREAM transitions to closed regardless of prior active lifecycle'},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
H2_CONNECTION_RULE_TABLE: tuple[dict[str, object], ...] = (
|
|
208
|
+
{'rule': 'first-frame-after-preface-is-settings', 'source': 'handler', 'notes': 'peer frame sequence starts with SETTINGS'},
|
|
209
|
+
{'rule': 'continuation-sequences-are-exclusive', 'source': 'handler', 'notes': 'no interleaved frames are permitted while CONTINUATION is pending'},
|
|
210
|
+
{'rule': 'priority-self-dependency-forbidden', 'source': 'handler', 'notes': 'PRIORITY cannot depend on its own stream'},
|
|
211
|
+
{'rule': 'client-push-promise-forbidden', 'source': 'handler', 'notes': 'server rejects PUSH_PROMISE received from the client'},
|
|
212
|
+
{'rule': 'max-concurrent-streams-enforced', 'source': 'handler/state', 'notes': 'new streams are rejected beyond advertised local limits'},
|
|
213
|
+
{'rule': 'goaway-last-stream-id-monotonic', 'source': 'handler', 'notes': 'peer GOAWAY last_stream_id must not increase'},
|
|
214
|
+
{'rule': 'new-stream-after-goaway-forbidden', 'source': 'handler', 'notes': 'new remotely initiated streams are rejected after peer GOAWAY'},
|
|
215
|
+
{'rule': 'flow-control-window-overflow-forbidden', 'source': 'state', 'notes': 'flow-control windows cannot overflow 2^31-1 or go negative under DATA'},
|
|
216
|
+
{'rule': 'window-update-on-closed-stream-ignored', 'source': 'handler', 'notes': 'closed-stream WINDOW_UPDATE does not reopen or mutate stream state'},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def h2_stream_transition_table() -> tuple[dict[str, object], ...]:
|
|
221
|
+
return tuple(dict(entry) for entry in H2_STREAM_TRANSITION_TABLE)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def h2_connection_rule_table() -> tuple[dict[str, object], ...]:
|
|
226
|
+
return tuple(dict(entry) for entry in H2_CONNECTION_RULE_TABLE)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from tigrcorn_core.errors import ProtocolError
|
|
6
|
+
from tigrcorn_protocols.http2.state import FlowWindow, H2StreamState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class H2StreamRegistry:
|
|
11
|
+
streams: dict[int, H2StreamState] = field(default_factory=dict)
|
|
12
|
+
closed_stream_ids: set[int] = field(default_factory=set)
|
|
13
|
+
|
|
14
|
+
def get(self, stream_id: int) -> H2StreamState:
|
|
15
|
+
return self.streams.setdefault(stream_id, H2StreamState(stream_id=stream_id))
|
|
16
|
+
|
|
17
|
+
def find(self, stream_id: int) -> H2StreamState | None:
|
|
18
|
+
return self.streams.get(stream_id)
|
|
19
|
+
|
|
20
|
+
def activate_remote(self, stream_id: int, *, send_window: int, receive_window: int) -> H2StreamState:
|
|
21
|
+
if stream_id in self.closed_stream_ids:
|
|
22
|
+
raise ProtocolError("HTTP/2 closed stream cannot be reopened")
|
|
23
|
+
state = self.streams.get(stream_id)
|
|
24
|
+
if state is None:
|
|
25
|
+
state = H2StreamState(
|
|
26
|
+
stream_id=stream_id,
|
|
27
|
+
send_window=FlowWindow(send_window),
|
|
28
|
+
receive_window=FlowWindow(receive_window),
|
|
29
|
+
receive_window_target=receive_window,
|
|
30
|
+
)
|
|
31
|
+
self.streams[stream_id] = state
|
|
32
|
+
else:
|
|
33
|
+
state.send_window = FlowWindow(send_window)
|
|
34
|
+
state.receive_window = FlowWindow(receive_window)
|
|
35
|
+
state.receive_window_target = receive_window
|
|
36
|
+
return state
|
|
37
|
+
|
|
38
|
+
def reserve_local(self, stream_id: int, *, send_window: int, receive_window: int) -> H2StreamState:
|
|
39
|
+
if stream_id in self.closed_stream_ids:
|
|
40
|
+
raise ProtocolError("HTTP/2 closed stream cannot be reopened")
|
|
41
|
+
if stream_id in self.streams:
|
|
42
|
+
raise ProtocolError("HTTP/2 local stream is already active")
|
|
43
|
+
state = H2StreamState(
|
|
44
|
+
stream_id=stream_id,
|
|
45
|
+
send_window=FlowWindow(send_window),
|
|
46
|
+
receive_window=FlowWindow(receive_window),
|
|
47
|
+
receive_window_target=receive_window,
|
|
48
|
+
)
|
|
49
|
+
state.reserve_local()
|
|
50
|
+
self.streams[stream_id] = state
|
|
51
|
+
return state
|
|
52
|
+
|
|
53
|
+
def close(self, stream_id: int) -> None:
|
|
54
|
+
state = self.streams.get(stream_id)
|
|
55
|
+
if state is not None:
|
|
56
|
+
state.local_closed = True
|
|
57
|
+
state.end_stream_received = True
|
|
58
|
+
state._sync_lifecycle()
|
|
59
|
+
self.streams.pop(stream_id, None)
|
|
60
|
+
self.closed_stream_ids.add(stream_id)
|
|
61
|
+
|
|
62
|
+
def apply_window_delta(self, delta: int) -> None:
|
|
63
|
+
for state in self.streams.values():
|
|
64
|
+
state.send_window.adjust(delta)
|
|
65
|
+
|
|
66
|
+
def active_ids(self) -> list[int]:
|
|
67
|
+
return sorted(self.streams)
|
|
68
|
+
|
|
69
|
+
def active_remote_stream_count(self) -> int:
|
|
70
|
+
return sum(1 for stream_id, state in self.streams.items() if stream_id % 2 == 1 and not state.closed)
|
|
71
|
+
|
|
72
|
+
def active_local_stream_count(self) -> int:
|
|
73
|
+
return sum(1 for stream_id, state in self.streams.items() if stream_id % 2 == 0 and not state.closed)
|
|
74
|
+
|
|
75
|
+
def is_closed(self, stream_id: int) -> bool:
|
|
76
|
+
return stream_id in self.closed_stream_ids
|