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 @@
|
|
|
1
|
+
"""Protocol implementations and protocol registries."""
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrcorn_core.errors import ProtocolError
|
|
4
|
+
|
|
5
|
+
# Shared HPACK/QPACK Huffman tables from RFC 7541 Appendix B.
|
|
6
|
+
HUFFMAN_CODES: tuple[int, ...] = (
|
|
7
|
+
8184, 8388568, 268435426, 268435427, 268435428, 268435429, 268435430, 268435431,
|
|
8
|
+
268435432, 16777194, 1073741820, 268435433, 268435434, 1073741821, 268435435, 268435436,
|
|
9
|
+
268435437, 268435438, 268435439, 268435440, 268435441, 268435442, 1073741822, 268435443,
|
|
10
|
+
268435444, 268435445, 268435446, 268435447, 268435448, 268435449, 268435450, 268435451,
|
|
11
|
+
20, 1016, 1017, 4090, 8185, 21, 248, 2042,
|
|
12
|
+
1018, 1019, 249, 2043, 250, 22, 23, 24,
|
|
13
|
+
0, 1, 2, 25, 26, 27, 28, 29,
|
|
14
|
+
30, 31, 92, 251, 32764, 32, 4091, 1020,
|
|
15
|
+
8186, 33, 93, 94, 95, 96, 97, 98,
|
|
16
|
+
99, 100, 101, 102, 103, 104, 105, 106,
|
|
17
|
+
107, 108, 109, 110, 111, 112, 113, 114,
|
|
18
|
+
252, 115, 253, 8187, 524272, 8188, 16380, 34,
|
|
19
|
+
32765, 3, 35, 4, 36, 5, 37, 38,
|
|
20
|
+
39, 6, 116, 117, 40, 41, 42, 7,
|
|
21
|
+
43, 118, 44, 8, 9, 45, 119, 120,
|
|
22
|
+
121, 122, 123, 32766, 2044, 16381, 8189, 268435452,
|
|
23
|
+
1048550, 4194258, 1048551, 1048552, 4194259, 4194260, 4194261, 8388569,
|
|
24
|
+
4194262, 8388570, 8388571, 8388572, 8388573, 8388574, 16777195, 8388575,
|
|
25
|
+
16777196, 16777197, 4194263, 8388576, 16777198, 8388577, 8388578, 8388579,
|
|
26
|
+
8388580, 2097116, 4194264, 8388581, 4194265, 8388582, 8388583, 16777199,
|
|
27
|
+
4194266, 2097117, 1048553, 4194267, 4194268, 8388584, 8388585, 2097118,
|
|
28
|
+
8388586, 4194269, 4194270, 16777200, 2097119, 4194271, 8388587, 8388588,
|
|
29
|
+
2097120, 2097121, 4194272, 2097122, 8388589, 4194273, 8388590, 8388591,
|
|
30
|
+
1048554, 4194274, 4194275, 4194276, 8388592, 4194277, 4194278, 8388593,
|
|
31
|
+
67108832, 67108833, 1048555, 524273, 4194279, 8388594, 4194280, 33554412,
|
|
32
|
+
67108834, 67108835, 67108836, 134217694, 134217695, 67108837, 16777201, 33554413,
|
|
33
|
+
524274, 2097123, 67108838, 134217696, 134217697, 67108839, 134217698, 16777202,
|
|
34
|
+
2097124, 2097125, 67108840, 67108841, 268435453, 134217699, 134217700, 134217701,
|
|
35
|
+
1048556, 16777203, 1048557, 2097126, 4194281, 2097127, 2097128, 8388595,
|
|
36
|
+
4194282, 4194283, 33554414, 33554415, 16777204, 16777205, 67108842, 8388596,
|
|
37
|
+
67108843, 134217702, 67108844, 67108845, 134217703, 134217704, 134217705, 134217706,
|
|
38
|
+
134217707, 268435454, 134217708, 134217709, 134217710, 134217711, 134217712, 67108846,
|
|
39
|
+
1073741823,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
HUFFMAN_CODE_LENGTHS: tuple[int, ...] = (
|
|
43
|
+
13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28,
|
|
44
|
+
28, 28, 28, 28, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28,
|
|
45
|
+
6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6,
|
|
46
|
+
5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10,
|
|
47
|
+
13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
|
|
48
|
+
7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6,
|
|
49
|
+
15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5,
|
|
50
|
+
6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28,
|
|
51
|
+
20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, 23, 23, 24, 23,
|
|
52
|
+
24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24,
|
|
53
|
+
22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23,
|
|
54
|
+
21, 21, 22, 21, 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23,
|
|
55
|
+
26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, 26, 24, 25,
|
|
56
|
+
19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27,
|
|
57
|
+
20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23,
|
|
58
|
+
26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26,
|
|
59
|
+
30,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
EOS_SYMBOL = 256
|
|
63
|
+
|
|
64
|
+
def encode_prefixed_integer(value: int, prefix_bits: int, prefix_mask: int = 0) -> bytes:
|
|
65
|
+
if value < 0:
|
|
66
|
+
raise ValueError("header-compression integers must be non-negative")
|
|
67
|
+
max_prefix = (1 << prefix_bits) - 1
|
|
68
|
+
if value < max_prefix:
|
|
69
|
+
return bytes([prefix_mask | value])
|
|
70
|
+
out = bytearray([prefix_mask | max_prefix])
|
|
71
|
+
value -= max_prefix
|
|
72
|
+
while value >= 128:
|
|
73
|
+
out.append((value & 0x7F) | 0x80)
|
|
74
|
+
value >>= 7
|
|
75
|
+
out.append(value)
|
|
76
|
+
return bytes(out)
|
|
77
|
+
|
|
78
|
+
def decode_prefixed_integer(
|
|
79
|
+
data: bytes,
|
|
80
|
+
offset: int,
|
|
81
|
+
prefix_bits: int,
|
|
82
|
+
*,
|
|
83
|
+
max_octets: int | None = None,
|
|
84
|
+
max_value: int | None = None,
|
|
85
|
+
) -> tuple[int, int]:
|
|
86
|
+
if offset >= len(data):
|
|
87
|
+
raise ProtocolError("header-compression integer underflow")
|
|
88
|
+
max_prefix = (1 << prefix_bits) - 1
|
|
89
|
+
value = data[offset] & max_prefix
|
|
90
|
+
offset += 1
|
|
91
|
+
if value < max_prefix:
|
|
92
|
+
if max_value is not None and value > max_value:
|
|
93
|
+
raise ProtocolError("header-compression integer exceeds configured maximum")
|
|
94
|
+
return value, offset
|
|
95
|
+
shift = 0
|
|
96
|
+
octets = 0
|
|
97
|
+
while True:
|
|
98
|
+
if offset >= len(data):
|
|
99
|
+
raise ProtocolError("header-compression integer continuation underflow")
|
|
100
|
+
byte = data[offset]
|
|
101
|
+
offset += 1
|
|
102
|
+
octets += 1
|
|
103
|
+
if max_octets is not None and octets > max_octets:
|
|
104
|
+
raise ProtocolError("header-compression integer exceeds configured maximum")
|
|
105
|
+
value += (byte & 0x7F) << shift
|
|
106
|
+
if max_value is not None and value > max_value:
|
|
107
|
+
raise ProtocolError("header-compression integer exceeds configured maximum")
|
|
108
|
+
if not (byte & 0x80):
|
|
109
|
+
return value, offset
|
|
110
|
+
shift += 7
|
|
111
|
+
|
|
112
|
+
def huffman_encode(data: bytes) -> bytes:
|
|
113
|
+
if not data:
|
|
114
|
+
return b""
|
|
115
|
+
final_num = 0
|
|
116
|
+
final_len = 0
|
|
117
|
+
for byte in data:
|
|
118
|
+
code_len = HUFFMAN_CODE_LENGTHS[byte]
|
|
119
|
+
code = HUFFMAN_CODES[byte] & ((1 << code_len) - 1)
|
|
120
|
+
final_num = (final_num << code_len) | code
|
|
121
|
+
final_len += code_len
|
|
122
|
+
pad = (8 - (final_len % 8)) % 8
|
|
123
|
+
final_num = (final_num << pad) | ((1 << pad) - 1)
|
|
124
|
+
total_bytes = (final_len + pad) // 8
|
|
125
|
+
return final_num.to_bytes(total_bytes, "big")
|
|
126
|
+
|
|
127
|
+
class _TrieNode:
|
|
128
|
+
__slots__ = ("zero", "one", "symbol")
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
self.zero: _TrieNode | None = None
|
|
131
|
+
self.one: _TrieNode | None = None
|
|
132
|
+
self.symbol: int | None = None
|
|
133
|
+
|
|
134
|
+
def _build_huffman_tree() -> _TrieNode:
|
|
135
|
+
root = _TrieNode()
|
|
136
|
+
for symbol, (code, length) in enumerate(zip(HUFFMAN_CODES, HUFFMAN_CODE_LENGTHS)):
|
|
137
|
+
node = root
|
|
138
|
+
for shift in range(length - 1, -1, -1):
|
|
139
|
+
bit = (code >> shift) & 1
|
|
140
|
+
if bit:
|
|
141
|
+
if node.one is None:
|
|
142
|
+
node.one = _TrieNode()
|
|
143
|
+
node = node.one
|
|
144
|
+
else:
|
|
145
|
+
if node.zero is None:
|
|
146
|
+
node.zero = _TrieNode()
|
|
147
|
+
node = node.zero
|
|
148
|
+
if node.symbol is not None:
|
|
149
|
+
raise RuntimeError("duplicate Huffman code")
|
|
150
|
+
node.symbol = symbol
|
|
151
|
+
return root
|
|
152
|
+
|
|
153
|
+
_HUFFMAN_ROOT = _build_huffman_tree()
|
|
154
|
+
|
|
155
|
+
def huffman_decode(data: bytes, *, max_output_length: int | None = None) -> bytes:
|
|
156
|
+
if not data:
|
|
157
|
+
return b""
|
|
158
|
+
node = _HUFFMAN_ROOT
|
|
159
|
+
decoded = bytearray()
|
|
160
|
+
trailing_value = 0
|
|
161
|
+
trailing_bits = 0
|
|
162
|
+
for byte in data:
|
|
163
|
+
for shift in range(7, -1, -1):
|
|
164
|
+
bit = (byte >> shift) & 1
|
|
165
|
+
trailing_value = (trailing_value << 1) | bit
|
|
166
|
+
trailing_bits += 1
|
|
167
|
+
next_node = node.one if bit else node.zero
|
|
168
|
+
if next_node is None:
|
|
169
|
+
raise ProtocolError("invalid Huffman string")
|
|
170
|
+
node = next_node
|
|
171
|
+
if node.symbol is None:
|
|
172
|
+
continue
|
|
173
|
+
if node.symbol == EOS_SYMBOL:
|
|
174
|
+
raise ProtocolError("EOS symbol is not permitted in header strings")
|
|
175
|
+
decoded.append(node.symbol)
|
|
176
|
+
if max_output_length is not None and len(decoded) > max_output_length:
|
|
177
|
+
raise ProtocolError("header-compression string exceeds configured maximum")
|
|
178
|
+
node = _HUFFMAN_ROOT
|
|
179
|
+
trailing_value = 0
|
|
180
|
+
trailing_bits = 0
|
|
181
|
+
if node is not _HUFFMAN_ROOT:
|
|
182
|
+
if trailing_bits > 7 or trailing_value != (1 << trailing_bits) - 1:
|
|
183
|
+
raise ProtocolError("incomplete Huffman string")
|
|
184
|
+
return bytes(decoded)
|
|
185
|
+
|
|
186
|
+
def encode_prefixed_string(data: bytes, prefix_bits: int, prefix_mask: int = 0, *, huffman: bool = True) -> bytes:
|
|
187
|
+
payload = data
|
|
188
|
+
huffman_flag = 0
|
|
189
|
+
if huffman and data:
|
|
190
|
+
encoded = huffman_encode(data)
|
|
191
|
+
if len(encoded) < len(data):
|
|
192
|
+
payload = encoded
|
|
193
|
+
huffman_flag = 1 << (prefix_bits - 1)
|
|
194
|
+
return encode_prefixed_integer(len(payload), prefix_bits - 1, prefix_mask | huffman_flag) + payload
|
|
195
|
+
|
|
196
|
+
def decode_prefixed_string(
|
|
197
|
+
data: bytes,
|
|
198
|
+
offset: int,
|
|
199
|
+
prefix_bits: int,
|
|
200
|
+
*,
|
|
201
|
+
max_length: int | None = None,
|
|
202
|
+
max_decoded_length: int | None = None,
|
|
203
|
+
max_integer_octets: int | None = None,
|
|
204
|
+
) -> tuple[bytes, int]:
|
|
205
|
+
if offset >= len(data):
|
|
206
|
+
raise ProtocolError("header-compression string underflow")
|
|
207
|
+
huffman = bool(data[offset] & (1 << (prefix_bits - 1)))
|
|
208
|
+
length, offset = decode_prefixed_integer(data, offset, prefix_bits - 1, max_octets=max_integer_octets, max_value=max_length)
|
|
209
|
+
if max_length is not None and length > max_length:
|
|
210
|
+
raise ProtocolError("header-compression string exceeds configured maximum")
|
|
211
|
+
end = offset + length
|
|
212
|
+
if end > len(data):
|
|
213
|
+
raise ProtocolError("header-compression string overflow")
|
|
214
|
+
payload = data[offset:end]
|
|
215
|
+
if huffman:
|
|
216
|
+
payload = huffman_decode(payload, max_output_length=max_decoded_length)
|
|
217
|
+
elif max_decoded_length is not None and len(payload) > max_decoded_length:
|
|
218
|
+
raise ProtocolError("header-compression string exceeds configured maximum")
|
|
219
|
+
return payload, end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ipaddress
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_connect_authority(authority: str) -> tuple[str, int]:
|
|
9
|
+
if authority.startswith('['):
|
|
10
|
+
end = authority.find(']')
|
|
11
|
+
if end == -1 or end + 2 > len(authority) or authority[end + 1] != ':':
|
|
12
|
+
raise ValueError('invalid CONNECT authority-form target')
|
|
13
|
+
host = authority[1:end]
|
|
14
|
+
port_text = authority[end + 2:]
|
|
15
|
+
else:
|
|
16
|
+
if authority.count(':') != 1:
|
|
17
|
+
raise ValueError('invalid CONNECT authority-form target')
|
|
18
|
+
host, port_text = authority.rsplit(':', 1)
|
|
19
|
+
port = int(port_text)
|
|
20
|
+
if not host or port <= 0 or port > 65535:
|
|
21
|
+
raise ValueError('invalid CONNECT authority-form target')
|
|
22
|
+
return host, port
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def half_close_tcp_writer(writer: asyncio.StreamWriter) -> None:
|
|
26
|
+
if writer.is_closing():
|
|
27
|
+
return
|
|
28
|
+
if writer.can_write_eof():
|
|
29
|
+
with suppress(Exception):
|
|
30
|
+
writer.write_eof()
|
|
31
|
+
await writer.drain()
|
|
32
|
+
return
|
|
33
|
+
writer.close()
|
|
34
|
+
with suppress(Exception):
|
|
35
|
+
await writer.wait_closed()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def close_tcp_writer(writer: asyncio.StreamWriter) -> None:
|
|
39
|
+
if writer.is_closing():
|
|
40
|
+
return
|
|
41
|
+
writer.close()
|
|
42
|
+
with suppress(Exception):
|
|
43
|
+
await writer.wait_closed()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _split_allow_entry(entry: str) -> tuple[str, str | None]:
|
|
48
|
+
entry = entry.strip()
|
|
49
|
+
if not entry:
|
|
50
|
+
raise ValueError('empty CONNECT allowlist entry')
|
|
51
|
+
if entry.startswith('['):
|
|
52
|
+
if ']:' in entry:
|
|
53
|
+
host, port = entry.rsplit(':', 1)
|
|
54
|
+
return host[1:-1], port
|
|
55
|
+
return entry[1:-1], None
|
|
56
|
+
if '/' in entry:
|
|
57
|
+
if entry.count(':') == 1 and entry.rsplit(':', 1)[1].isdigit():
|
|
58
|
+
network, port = entry.rsplit(':', 1)
|
|
59
|
+
return network, port
|
|
60
|
+
return entry, None
|
|
61
|
+
if entry.count(':') == 1 and entry.rsplit(':', 1)[1].isdigit():
|
|
62
|
+
host, port = entry.rsplit(':', 1)
|
|
63
|
+
return host, port
|
|
64
|
+
return entry, None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_connect_allow_entry(entry: str) -> str:
|
|
68
|
+
host_or_network, port_text = _split_allow_entry(entry)
|
|
69
|
+
if port_text is not None:
|
|
70
|
+
port = int(port_text)
|
|
71
|
+
if port <= 0 or port > 65535:
|
|
72
|
+
raise ValueError('invalid CONNECT allowlist port')
|
|
73
|
+
if '/' in host_or_network:
|
|
74
|
+
ipaddress.ip_network(host_or_network, strict=False)
|
|
75
|
+
elif not host_or_network:
|
|
76
|
+
raise ValueError('empty CONNECT allowlist host')
|
|
77
|
+
return entry
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_connect_allowed(host: str, port: int, allowlist: list[str] | tuple[str, ...]) -> bool:
|
|
81
|
+
if not allowlist:
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
address = ipaddress.ip_address(host)
|
|
85
|
+
except ValueError:
|
|
86
|
+
address = None
|
|
87
|
+
normalized_host = host.lower()
|
|
88
|
+
for raw in allowlist:
|
|
89
|
+
try:
|
|
90
|
+
host_or_network, port_text = _split_allow_entry(raw)
|
|
91
|
+
except ValueError:
|
|
92
|
+
continue
|
|
93
|
+
if port_text is not None and int(port_text) != port:
|
|
94
|
+
continue
|
|
95
|
+
if '/' in host_or_network:
|
|
96
|
+
if address is None:
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
network = ipaddress.ip_network(host_or_network, strict=False)
|
|
100
|
+
except ValueError:
|
|
101
|
+
continue
|
|
102
|
+
if address in network:
|
|
103
|
+
return True
|
|
104
|
+
continue
|
|
105
|
+
if normalized_host == host_or_network.lower():
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import zlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
try: # pragma: no cover - optional dependency surface
|
|
8
|
+
import brotli # type: ignore[import-not-found]
|
|
9
|
+
except Exception: # pragma: no cover - optional dependency surface
|
|
10
|
+
brotli = None # type: ignore[assignment]
|
|
11
|
+
|
|
12
|
+
from tigrcorn_protocols.http1.serializer import response_allows_body
|
|
13
|
+
from tigrcorn_core.utils.headers import append_if_missing, get_header
|
|
14
|
+
|
|
15
|
+
_SUPPORTED_ENCODINGS = ('br', 'gzip', 'deflate')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _available_supported_encodings(supported: tuple[str, ...]) -> tuple[str, ...]:
|
|
19
|
+
available: list[str] = []
|
|
20
|
+
for coding in supported:
|
|
21
|
+
if coding == 'br' and brotli is None:
|
|
22
|
+
continue
|
|
23
|
+
if coding not in available:
|
|
24
|
+
available.append(coding)
|
|
25
|
+
return tuple(available)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ContentCodingSelection:
|
|
30
|
+
coding: str | None
|
|
31
|
+
identity_acceptable: bool = True
|
|
32
|
+
explicit_identity_forbidden: bool = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def not_acceptable(self) -> bool:
|
|
36
|
+
return self.coding is None and not self.identity_acceptable
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_qvalue(raw: str) -> float:
|
|
41
|
+
try:
|
|
42
|
+
value = float(raw)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return 0.0
|
|
45
|
+
if value < 0.0:
|
|
46
|
+
return 0.0
|
|
47
|
+
if value > 1.0:
|
|
48
|
+
return 1.0
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def select_content_coding(
|
|
54
|
+
request_headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...],
|
|
55
|
+
*,
|
|
56
|
+
supported: tuple[str, ...] = _SUPPORTED_ENCODINGS,
|
|
57
|
+
) -> ContentCodingSelection:
|
|
58
|
+
supported = _available_supported_encodings(supported)
|
|
59
|
+
header_value = get_header(request_headers, b'accept-encoding')
|
|
60
|
+
if header_value is None:
|
|
61
|
+
return ContentCodingSelection(coding=None, identity_acceptable=True)
|
|
62
|
+
|
|
63
|
+
identity_q = 1.0
|
|
64
|
+
wildcard_q: float | None = None
|
|
65
|
+
coding_q: dict[str, float] = {}
|
|
66
|
+
order: dict[str, int] = {}
|
|
67
|
+
for index, part in enumerate(header_value.decode('ascii', 'ignore').split(',')):
|
|
68
|
+
token = part.strip()
|
|
69
|
+
if not token:
|
|
70
|
+
continue
|
|
71
|
+
name, *params = [piece.strip() for piece in token.split(';')]
|
|
72
|
+
lower = name.lower()
|
|
73
|
+
q = 1.0
|
|
74
|
+
for param in params:
|
|
75
|
+
if '=' not in param:
|
|
76
|
+
continue
|
|
77
|
+
key, value = param.split('=', 1)
|
|
78
|
+
if key.strip().lower() == 'q':
|
|
79
|
+
q = _parse_qvalue(value.strip())
|
|
80
|
+
if lower == 'identity':
|
|
81
|
+
identity_q = q
|
|
82
|
+
elif lower == '*':
|
|
83
|
+
wildcard_q = q
|
|
84
|
+
else:
|
|
85
|
+
coding_q[lower] = q
|
|
86
|
+
order.setdefault(lower, index)
|
|
87
|
+
|
|
88
|
+
chosen: tuple[float, int, str] | None = None
|
|
89
|
+
for index, encoding in enumerate(supported):
|
|
90
|
+
q = coding_q.get(encoding)
|
|
91
|
+
if q is None:
|
|
92
|
+
q = wildcard_q if wildcard_q is not None else 0.0
|
|
93
|
+
if q <= 0.0:
|
|
94
|
+
continue
|
|
95
|
+
rank = (-q, order.get(encoding, 1000 + index), encoding)
|
|
96
|
+
if chosen is None or rank < chosen:
|
|
97
|
+
chosen = rank
|
|
98
|
+
if chosen is not None:
|
|
99
|
+
return ContentCodingSelection(coding=chosen[2], identity_acceptable=identity_q > 0.0, explicit_identity_forbidden=identity_q <= 0.0)
|
|
100
|
+
return ContentCodingSelection(coding=None, identity_acceptable=identity_q > 0.0, explicit_identity_forbidden=identity_q <= 0.0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def encode_content(coding: str, payload: bytes) -> bytes:
|
|
105
|
+
if coding == 'gzip':
|
|
106
|
+
return gzip.compress(payload)
|
|
107
|
+
if coding == 'deflate':
|
|
108
|
+
return zlib.compress(payload)
|
|
109
|
+
if coding == 'br':
|
|
110
|
+
if brotli is None:
|
|
111
|
+
raise RuntimeError('brotli support is not available; install tigrcorn[compression]')
|
|
112
|
+
return brotli.compress(payload)
|
|
113
|
+
raise ValueError(f'unsupported content coding: {coding}')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _replace_content_length(headers: list[tuple[bytes, bytes]], payload_length: int) -> list[tuple[bytes, bytes]]:
|
|
118
|
+
filtered = [(name.lower(), value) for name, value in headers if name.lower() not in {b'content-length'}]
|
|
119
|
+
filtered.append((b'content-length', str(payload_length).encode('ascii')))
|
|
120
|
+
return filtered
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def apply_http_content_coding(
|
|
125
|
+
*,
|
|
126
|
+
request_headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...],
|
|
127
|
+
response_headers: list[tuple[bytes, bytes]],
|
|
128
|
+
body: bytes,
|
|
129
|
+
status: int,
|
|
130
|
+
policy: str = 'allowlist',
|
|
131
|
+
supported: tuple[str, ...] = _SUPPORTED_ENCODINGS,
|
|
132
|
+
) -> tuple[int, list[tuple[bytes, bytes]], bytes, ContentCodingSelection]:
|
|
133
|
+
normalized_headers = [(bytes(name).lower(), bytes(value)) for name, value in response_headers]
|
|
134
|
+
supported = _available_supported_encodings(tuple(str(item).lower() for item in supported))
|
|
135
|
+
header_value = get_header(request_headers, b'accept-encoding')
|
|
136
|
+
if policy == 'identity-only':
|
|
137
|
+
identity_forbidden = False
|
|
138
|
+
if header_value is not None:
|
|
139
|
+
lowered = header_value.decode('ascii', 'ignore').lower()
|
|
140
|
+
identity_forbidden = 'identity;q=0' in lowered and '*;q=0' in lowered
|
|
141
|
+
selection = ContentCodingSelection(coding=None, identity_acceptable=not identity_forbidden, explicit_identity_forbidden=identity_forbidden)
|
|
142
|
+
else:
|
|
143
|
+
selection = select_content_coding(request_headers, supported=supported)
|
|
144
|
+
|
|
145
|
+
if not response_allows_body(status):
|
|
146
|
+
return status, normalized_headers, body, selection
|
|
147
|
+
if get_header(normalized_headers, b'content-encoding') is not None:
|
|
148
|
+
return status, normalized_headers, body, selection
|
|
149
|
+
if not body:
|
|
150
|
+
return status, normalized_headers, body, selection
|
|
151
|
+
|
|
152
|
+
if selection.not_acceptable:
|
|
153
|
+
headers = _replace_content_length([(b'content-type', b'text/plain; charset=utf-8')], len(b'not acceptable'))
|
|
154
|
+
append_if_missing(headers, b'vary', b'accept-encoding')
|
|
155
|
+
return 406, headers, b'not acceptable', selection
|
|
156
|
+
|
|
157
|
+
if policy == 'strict' and header_value is not None and selection.coding is None:
|
|
158
|
+
headers = _replace_content_length([(b'content-type', b'text/plain; charset=utf-8')], len(b'not acceptable'))
|
|
159
|
+
append_if_missing(headers, b'vary', b'accept-encoding')
|
|
160
|
+
return 406, headers, b'not acceptable', selection
|
|
161
|
+
|
|
162
|
+
if selection.coding is None:
|
|
163
|
+
return status, normalized_headers, body, selection
|
|
164
|
+
|
|
165
|
+
encoded = encode_content(selection.coding, body)
|
|
166
|
+
headers = [(name.lower(), value) for name, value in normalized_headers if name.lower() not in {b'content-length', b'content-encoding'}]
|
|
167
|
+
headers.append((b'content-encoding', selection.coding.encode('ascii')))
|
|
168
|
+
append_if_missing(headers, b'vary', b'accept-encoding')
|
|
169
|
+
headers = _replace_content_length(headers, len(encoded))
|
|
170
|
+
return status, headers, encoded, selection
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__ = [
|
|
175
|
+
'ContentCodingSelection',
|
|
176
|
+
'apply_http_content_coding',
|
|
177
|
+
'encode_content',
|
|
178
|
+
'select_content_coding',
|
|
179
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrcorn_asgi.events.custom import stream_receive, stream_send
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def adapt_scope(scope: dict) -> dict:
|
|
7
|
+
adapted = dict(scope)
|
|
8
|
+
adapted.setdefault('extensions', {})
|
|
9
|
+
adapted['extensions'].setdefault('tigrcorn.custom', {})
|
|
10
|
+
return adapted
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def adapt_inbound(payload: bytes, *, more_data: bool = False) -> dict:
|
|
14
|
+
return stream_receive(payload, more_data=more_data)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def adapt_outbound(payload: bytes, *, more_data: bool = False) -> dict:
|
|
18
|
+
return stream_send(payload, more_data=more_data)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Callable, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class CustomProtocolRegistry:
|
|
9
|
+
handlers: dict[str, Callable[..., Any]] = field(default_factory=dict)
|
|
10
|
+
|
|
11
|
+
def register(self, name: str, handler: Callable[..., Any]) -> None:
|
|
12
|
+
self.handlers[name] = handler
|
|
13
|
+
|
|
14
|
+
def get(self, name: str):
|
|
15
|
+
return self.handlers[name]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Flow-control helpers."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class BackpressureState:
|
|
8
|
+
paused: bool = False
|
|
9
|
+
high_water: int = 64 * 1024
|
|
10
|
+
low_water: int = 16 * 1024
|
|
11
|
+
|
|
12
|
+
def update(self, buffered: int) -> bool:
|
|
13
|
+
if buffered >= self.high_water:
|
|
14
|
+
self.paused = True
|
|
15
|
+
elif buffered <= self.low_water:
|
|
16
|
+
self.paused = False
|
|
17
|
+
return self.paused
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class BufferLimits:
|
|
8
|
+
read_limit: int = 64 * 1024
|
|
9
|
+
write_limit: int = 64 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class ByteBuffer:
|
|
14
|
+
limit: int = 64 * 1024
|
|
15
|
+
data: bytearray = field(default_factory=bytearray)
|
|
16
|
+
|
|
17
|
+
def append(self, payload: bytes) -> None:
|
|
18
|
+
if len(self.data) + len(payload) > self.limit:
|
|
19
|
+
raise BufferError('buffer limit exceeded')
|
|
20
|
+
self.data.extend(payload)
|
|
21
|
+
|
|
22
|
+
def take(self, n: int = -1) -> bytes:
|
|
23
|
+
if n < 0 or n >= len(self.data):
|
|
24
|
+
payload = bytes(self.data)
|
|
25
|
+
self.data.clear()
|
|
26
|
+
return payload
|
|
27
|
+
payload = bytes(self.data[:n])
|
|
28
|
+
del self.data[:n]
|
|
29
|
+
return payload
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class CreditWindow:
|
|
8
|
+
remaining: int
|
|
9
|
+
|
|
10
|
+
def consume(self, n: int) -> None:
|
|
11
|
+
if n < 0:
|
|
12
|
+
raise ValueError('credit consumption must be non-negative')
|
|
13
|
+
self.remaining = max(0, self.remaining - n)
|
|
14
|
+
|
|
15
|
+
def refill(self, n: int) -> None:
|
|
16
|
+
if n < 0:
|
|
17
|
+
raise ValueError('credit refill must be non-negative')
|
|
18
|
+
self.remaining += n
|
|
19
|
+
|
|
20
|
+
def available(self, n: int = 1) -> bool:
|
|
21
|
+
return self.remaining >= n
|