syshttp 0.1.0__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.
- syshttp/AGENTS.md +817 -0
- syshttp/README.md +177 -0
- syshttp/__init__.py +71 -0
- syshttp/_fileio.py +78 -0
- syshttp/_parse.py +30 -0
- syshttp/auth.py +17 -0
- syshttp/autopatch.py +382 -0
- syshttp/client/__init__.py +70 -0
- syshttp/client/_body.py +156 -0
- syshttp/client/_client.py +413 -0
- syshttp/client/_context.py +107 -0
- syshttp/client/_deadline.py +94 -0
- syshttp/client/_default.py +72 -0
- syshttp/client/_fetch.py +98 -0
- syshttp/client/_follow.py +243 -0
- syshttp/client/_headers.py +238 -0
- syshttp/client/_pool.py +170 -0
- syshttp/client/_prepare.py +77 -0
- syshttp/client/_response.py +301 -0
- syshttp/client/_route.py +75 -0
- syshttp/client/_settings.py +12 -0
- syshttp/client/_state.py +322 -0
- syshttp/client/_status.py +59 -0
- syshttp/client/_tlstunnel.py +114 -0
- syshttp/client/_transport.py +550 -0
- syshttp/cookies.py +391 -0
- syshttp/env.py +144 -0
- syshttp/exceptions.py +29 -0
- syshttp/hooks.py +314 -0
- syshttp/jar.py +133 -0
- syshttp/proxy.py +341 -0
- syshttp/py.typed +0 -0
- syshttp/retry.py +34 -0
- syshttp/tls.py +60 -0
- syshttp-0.1.0.dist-info/METADATA +219 -0
- syshttp-0.1.0.dist-info/RECORD +39 -0
- syshttp-0.1.0.dist-info/WHEEL +4 -0
- syshttp-0.1.0.dist-info/entry_points.txt +6 -0
- syshttp-0.1.0.dist-info/licenses/LICENSE +21 -0
syshttp/client/_state.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""What a client remembers between requests: the cookies of every host it talked
|
|
2
|
+
to, kept in memory and, with a jar, on disk by host; and the tokens that become
|
|
3
|
+
`Authorization` headers.
|
|
4
|
+
|
|
5
|
+
Both are chosen by the host of the URL being requested -- the origin -- never
|
|
6
|
+
by a proxy's or a gateway's: they sit above the transport."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses as _dataclasses
|
|
11
|
+
import ipaddress as _ipaddress
|
|
12
|
+
import re as _re
|
|
13
|
+
import threading as _threading
|
|
14
|
+
import time as _time
|
|
15
|
+
import typing as _ty
|
|
16
|
+
import urllib.parse as _urlparse
|
|
17
|
+
|
|
18
|
+
from ..cookies import (
|
|
19
|
+
_SECURE_HOSTS,
|
|
20
|
+
CookieSpec,
|
|
21
|
+
_path_matches,
|
|
22
|
+
cookie_applies,
|
|
23
|
+
domain_covers,
|
|
24
|
+
host_aliases,
|
|
25
|
+
set_cookie_specs,
|
|
26
|
+
)
|
|
27
|
+
from ..env import HTTPEnv
|
|
28
|
+
from ..exceptions import ConfigurationError
|
|
29
|
+
from ..jar import CookieJar, FileCookieJar, FileTokenJar, TokenJar
|
|
30
|
+
from ._headers import has_header
|
|
31
|
+
|
|
32
|
+
COOKIE_HOSTS = "SYSHTTP_COOKIE_HOSTS"
|
|
33
|
+
"""The setting declaring hosts that share cookies: hosts split by `,`, groups
|
|
34
|
+
by `;`."""
|
|
35
|
+
|
|
36
|
+
BY_HOST = "__by_host__"
|
|
37
|
+
"""The jar key that means: the origin's host, its alias group's first host."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cookie_aliases(cookie_hosts: "_ty.Iterable[_ty.Iterable[str]] | None", env: HTTPEnv) -> "dict[str, str]":
|
|
41
|
+
"""`cookie_hosts`, else `SYSHTTP_COOKIE_HOSTS`, as `cookies.host_aliases`
|
|
42
|
+
maps them."""
|
|
43
|
+
if cookie_hosts is not None:
|
|
44
|
+
return host_aliases(cookie_hosts)
|
|
45
|
+
found = env.find(COOKIE_HOSTS)
|
|
46
|
+
if not found:
|
|
47
|
+
return {}
|
|
48
|
+
variable, raw = found
|
|
49
|
+
groups = [[h for h in group.split(",") if h.strip()] for group in raw.split(";")]
|
|
50
|
+
try:
|
|
51
|
+
return host_aliases(groups)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise ConfigurationError("%s: %s" % (variable, exc)) from None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_ip(host: str) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
_ipaddress.ip_address(host)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _with_host(url: str, host: str) -> str:
|
|
65
|
+
"""`url` with its host replaced by `host`, port kept."""
|
|
66
|
+
parts = _urlparse.urlsplit(url)
|
|
67
|
+
netloc = "[%s]" % host if ":" in host else host
|
|
68
|
+
try:
|
|
69
|
+
port = parts.port
|
|
70
|
+
except ValueError:
|
|
71
|
+
port = None
|
|
72
|
+
if port is not None:
|
|
73
|
+
netloc += ":%d" % port
|
|
74
|
+
return _urlparse.urlunsplit(parts._replace(netloc=netloc))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Cookies:
|
|
78
|
+
"""The cookies of one client.
|
|
79
|
+
|
|
80
|
+
Rows are `CookieSpec`s, set from `Set-Cookie` as RFC 6265 reads it
|
|
81
|
+
(`set_cookie_specs`) and sent as `cookie_applies` decides. With a `jar`, the
|
|
82
|
+
rows of a host are loaded from it the first time that host is asked for,
|
|
83
|
+
and written back only when they differ from what was loaded or written
|
|
84
|
+
last. `key` is `BY_HOST` (one entry per origin host) or one name for every
|
|
85
|
+
host. `aliases` (`host_aliases`) makes groups of hosts one."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, jar: "CookieJar | None", key: str, aliases: "_ty.Mapping[str, str]"):
|
|
88
|
+
self._jar = jar
|
|
89
|
+
self._key = key
|
|
90
|
+
self._aliases = dict(aliases)
|
|
91
|
+
self._group_of: "dict[str, list[str]]" = {}
|
|
92
|
+
for alias, first in self._aliases.items():
|
|
93
|
+
self._group_of.setdefault(first, []).append(alias)
|
|
94
|
+
self._rows: "dict[tuple[str, str, str], CookieSpec]" = {}
|
|
95
|
+
self._loaded: "set[str]" = set()
|
|
96
|
+
self._synced: "dict[str, frozenset[CookieSpec]]" = {}
|
|
97
|
+
self._lock = _threading.RLock()
|
|
98
|
+
|
|
99
|
+
# -- which hosts and keys a URL means
|
|
100
|
+
|
|
101
|
+
def _host(self, url: str) -> str:
|
|
102
|
+
"""The host cookies of `url` are kept under: its alias group's first."""
|
|
103
|
+
host = _urlparse.urlsplit(url).hostname or ""
|
|
104
|
+
return self._aliases.get(host.lower(), host)
|
|
105
|
+
|
|
106
|
+
def _keys(self, url: str) -> "list[str]":
|
|
107
|
+
if self._key != BY_HOST:
|
|
108
|
+
return [self._key]
|
|
109
|
+
host = self._host(url)
|
|
110
|
+
return [host] if host else []
|
|
111
|
+
|
|
112
|
+
def _urls(self, url: str) -> "list[str]":
|
|
113
|
+
"""`url`, and the same URL for every other host of its alias group."""
|
|
114
|
+
host = (_urlparse.urlsplit(url).hostname or "").lower()
|
|
115
|
+
first = self._aliases.get(host)
|
|
116
|
+
if first is None:
|
|
117
|
+
return [url]
|
|
118
|
+
return [url] + [_with_host(url, member) for member in self._group_of[first] if member != host]
|
|
119
|
+
|
|
120
|
+
# -- the rows
|
|
121
|
+
|
|
122
|
+
def _load(self, rows: "_ty.Iterable[CookieSpec]", hosts: "_ty.Sequence[str] | None" = None) -> None:
|
|
123
|
+
"""Keeps `rows`; with `hosts`, only the ones whose domain covers one of
|
|
124
|
+
them, so a file that holds a row for another domain (planted, or
|
|
125
|
+
copied) never sends it from this host."""
|
|
126
|
+
for row in rows:
|
|
127
|
+
bare = row.domain.lstrip(".").lower()
|
|
128
|
+
if not bare:
|
|
129
|
+
continue
|
|
130
|
+
# An IP address matches only itself, whatever the flag says.
|
|
131
|
+
subdomains = bool((row.subdomains or row.domain.startswith(".")) and not _is_ip(bare))
|
|
132
|
+
spec = _dataclasses.replace(
|
|
133
|
+
row, domain=bare, subdomains=subdomains, path=row.path or "/", expires=row.expires or None
|
|
134
|
+
)
|
|
135
|
+
if hosts is not None and not any(self._covers(spec, host) for host in hosts):
|
|
136
|
+
continue
|
|
137
|
+
self._rows[(bare, spec.path, spec.name)] = spec
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _covers(row: CookieSpec, host: str) -> bool:
|
|
141
|
+
# A row's domain is bare; its include-subdomains flag is the dot.
|
|
142
|
+
return domain_covers("." + row.domain if row.subdomains else row.domain, host)
|
|
143
|
+
|
|
144
|
+
def _rows_for(self, key: str, host: str) -> "list[CookieSpec]":
|
|
145
|
+
"""The rows written under `key`: a by-host key keeps only the rows
|
|
146
|
+
whose domain covers its host (or group), so a client that talked to two
|
|
147
|
+
hosts never copies one's cookies into the other's file; a named key keeps
|
|
148
|
+
everything."""
|
|
149
|
+
rows = list(self._rows.values())
|
|
150
|
+
if key != host:
|
|
151
|
+
return rows
|
|
152
|
+
hosts = self._group_of.get(key, [key])
|
|
153
|
+
return [row for row in rows if any(self._covers(row, h) for h in hosts)]
|
|
154
|
+
|
|
155
|
+
# -- what a hop does
|
|
156
|
+
|
|
157
|
+
def before(self, url: str) -> None:
|
|
158
|
+
"""Loads the jar's rows for the keys of `url` that were not yet loaded."""
|
|
159
|
+
if self._jar is None:
|
|
160
|
+
return
|
|
161
|
+
with self._lock:
|
|
162
|
+
for key in self._keys(url):
|
|
163
|
+
if key in self._loaded:
|
|
164
|
+
continue
|
|
165
|
+
host = self._host(url)
|
|
166
|
+
try:
|
|
167
|
+
# A by-host file holds its host's (or group's) cookies only.
|
|
168
|
+
covered = self._group_of.get(key, [key]) if self._key == BY_HOST else None
|
|
169
|
+
self._load(self._jar.get(key, []), covered)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass # an unreadable jar is an empty one
|
|
172
|
+
self._synced[key] = frozenset(self._rows_for(key, host))
|
|
173
|
+
self._loaded.add(key)
|
|
174
|
+
|
|
175
|
+
def header(self, url: str, now: "float | None" = None) -> "str | None":
|
|
176
|
+
"""The `Cookie` header for a request to `url`, or `None`. Longer paths
|
|
177
|
+
come first (RFC 6265 5.4)."""
|
|
178
|
+
moment = _time.time() if now is None else now
|
|
179
|
+
urls = self._urls(url)
|
|
180
|
+
with self._lock:
|
|
181
|
+
chosen = [row for row in self._rows.values() if any(cookie_applies(row, u, moment) for u in urls)]
|
|
182
|
+
chosen.sort(key=lambda row: -len(row.path))
|
|
183
|
+
return "; ".join("%s=%s" % (row.name, row.value) for row in chosen) or None
|
|
184
|
+
|
|
185
|
+
def store(self, url: str, values: "_ty.Iterable[str]") -> None:
|
|
186
|
+
"""Keeps the cookies of `Set-Cookie` `values` a response from `url` sent,
|
|
187
|
+
deletes the ones it expires, and writes the jar when rows changed."""
|
|
188
|
+
parts = _urlparse.urlsplit(url)
|
|
189
|
+
found = set_cookie_specs(values, parts.hostname or "", request_path=parts.path or "/", aliases=self._aliases)
|
|
190
|
+
secure = parts.scheme == "https" or (parts.hostname or "").lower() in _SECURE_HOSTS
|
|
191
|
+
with self._lock:
|
|
192
|
+
for spec, gone in found:
|
|
193
|
+
if not secure and (spec.secure or self._shadows_secure(spec)):
|
|
194
|
+
continue # a plain-http response cannot set, replace or delete a Secure cookie
|
|
195
|
+
key = (spec.domain, spec.path, spec.name)
|
|
196
|
+
if gone:
|
|
197
|
+
self._rows.pop(key, None)
|
|
198
|
+
else:
|
|
199
|
+
self._rows[key] = spec
|
|
200
|
+
self._write(url)
|
|
201
|
+
|
|
202
|
+
def _shadows_secure(self, spec: CookieSpec) -> bool:
|
|
203
|
+
"""Whether a stored Secure cookie has `spec`'s name, a domain that
|
|
204
|
+
matches its domain or the other way round, and a path `spec`'s path
|
|
205
|
+
matches (RFC 6265bis 5.7, leave Secure cookies alone)."""
|
|
206
|
+
return any(
|
|
207
|
+
row.secure
|
|
208
|
+
and row.name == spec.name
|
|
209
|
+
and (
|
|
210
|
+
row.domain == spec.domain
|
|
211
|
+
or row.domain.endswith("." + spec.domain)
|
|
212
|
+
or spec.domain.endswith("." + row.domain)
|
|
213
|
+
)
|
|
214
|
+
and _path_matches(row.path, spec.path)
|
|
215
|
+
for row in self._rows.values()
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _write(self, url: str) -> None:
|
|
219
|
+
if self._jar is None:
|
|
220
|
+
return
|
|
221
|
+
host = self._host(url)
|
|
222
|
+
for key in self._keys(url):
|
|
223
|
+
rows = self._rows_for(key, host)
|
|
224
|
+
state = frozenset(rows)
|
|
225
|
+
if self._synced.get(key) == state:
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
self._jar[key] = rows
|
|
229
|
+
except Exception:
|
|
230
|
+
continue
|
|
231
|
+
self._synced[key] = state
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Tokens:
|
|
235
|
+
"""The tokens of one client: `Authorization` values chosen by the origin's
|
|
236
|
+
host, or one key for every host. A secret: never logged or put in a
|
|
237
|
+
message."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, jar: TokenJar, key: str):
|
|
240
|
+
self._jar = jar
|
|
241
|
+
self._key = key
|
|
242
|
+
|
|
243
|
+
def authorization(self, url: str) -> "str | None":
|
|
244
|
+
"""The `Authorization` value for `url`'s host, or `None`. A token that
|
|
245
|
+
already names its scheme (`Basic ...`, `token ...`) is sent verbatim; a
|
|
246
|
+
bare one is a bearer token. One with a CR, LF or NUL is a
|
|
247
|
+
`ConfigurationError` naming the key, never the value."""
|
|
248
|
+
key = self._key if self._key != BY_HOST else (_urlparse.urlsplit(url).hostname or "")
|
|
249
|
+
if not key:
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
token = self._jar.get(key)
|
|
253
|
+
except Exception:
|
|
254
|
+
return None
|
|
255
|
+
if not token:
|
|
256
|
+
return None
|
|
257
|
+
token = str(token).strip()
|
|
258
|
+
if any(ch in token for ch in "\r\n\0"):
|
|
259
|
+
raise ConfigurationError("the token for %r contains a carriage return, line feed or NUL character" % key)
|
|
260
|
+
return token if _re.match(r"^[A-Za-z][\w-]*\s+\S", token) else "Bearer " + token
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def make_cookies(cookies: "bool | str | CookieJar | None", env: HTTPEnv, aliases: "_ty.Mapping[str, str]") -> Cookies:
|
|
264
|
+
"""The `cookies=` argument of a client as its `Cookies`: `True` is a file
|
|
265
|
+
jar under the state directory keyed by host, a string one key for every
|
|
266
|
+
host, a jar object is used as given, `False` or `None` keeps cookies in
|
|
267
|
+
memory only."""
|
|
268
|
+
if cookies is True:
|
|
269
|
+
return Cookies(FileCookieJar(env=env), BY_HOST, aliases)
|
|
270
|
+
if isinstance(cookies, str):
|
|
271
|
+
return Cookies(FileCookieJar(env=env), cookies, aliases)
|
|
272
|
+
if cookies is None or cookies is False:
|
|
273
|
+
return Cookies(None, BY_HOST, aliases)
|
|
274
|
+
return Cookies(cookies, BY_HOST, aliases) # `is not False`, not truthiness: an empty jar is a jar
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def make_tokens(tokens: "bool | str | TokenJar | None", env: HTTPEnv) -> "Tokens | None":
|
|
278
|
+
"""The `tokens=` argument of a client as its `Tokens`, read as `cookies=`
|
|
279
|
+
is; `None` for no tokens."""
|
|
280
|
+
if tokens is True:
|
|
281
|
+
return Tokens(FileTokenJar(env=env), BY_HOST)
|
|
282
|
+
if isinstance(tokens, str):
|
|
283
|
+
return Tokens(FileTokenJar(env=env), tokens)
|
|
284
|
+
if tokens is None or tokens is False:
|
|
285
|
+
return None
|
|
286
|
+
return Tokens(tokens, BY_HOST)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class StateTransport:
|
|
290
|
+
"""A transport as the client sends through it: the cookies for each URL go
|
|
291
|
+
with the request and the ones the response sets are kept. The transport it
|
|
292
|
+
wraps stays the caller's."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, transport: _ty.Any, cookies: Cookies):
|
|
295
|
+
self._transport = transport
|
|
296
|
+
self._cookies = cookies
|
|
297
|
+
|
|
298
|
+
def send(
|
|
299
|
+
self,
|
|
300
|
+
method: str,
|
|
301
|
+
url: str,
|
|
302
|
+
*,
|
|
303
|
+
headers: "_ty.Mapping[str, str]",
|
|
304
|
+
body: _ty.Any = None,
|
|
305
|
+
timeout: _ty.Any,
|
|
306
|
+
stream: bool = False,
|
|
307
|
+
) -> _ty.Any:
|
|
308
|
+
sent = headers
|
|
309
|
+
self._cookies.before(url)
|
|
310
|
+
if not has_header(headers, "Cookie"):
|
|
311
|
+
cookie = self._cookies.header(url)
|
|
312
|
+
if cookie is not None:
|
|
313
|
+
sent = dict(headers)
|
|
314
|
+
sent["Cookie"] = cookie
|
|
315
|
+
response = self._transport.send(method, url, headers=sent, body=body, timeout=timeout, stream=stream)
|
|
316
|
+
values = response.headers.get_all("Set-Cookie")
|
|
317
|
+
if values:
|
|
318
|
+
self._cookies.store(url, values)
|
|
319
|
+
return response
|
|
320
|
+
|
|
321
|
+
def close(self) -> None:
|
|
322
|
+
"""Nothing of its own: the transport it wraps is closed by whoever made it."""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The status of a reply, and how much of its body the library reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
|
|
7
|
+
from . import _settings
|
|
8
|
+
from ._response import CHUNK, HTTPResponse, too_large
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HTTPStatusError(Exception):
|
|
12
|
+
"""A reply with an error status (4xx or 5xx). `response` is the reply."""
|
|
13
|
+
|
|
14
|
+
response: HTTPResponse
|
|
15
|
+
|
|
16
|
+
def __init__(self, response: HTTPResponse):
|
|
17
|
+
super().__init__(response.status)
|
|
18
|
+
self.response = response
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def raise_for_status(resp: HTTPResponse) -> None:
|
|
22
|
+
"""Raises `HTTPStatusError` for a reply with status 400 or above."""
|
|
23
|
+
if resp.status >= 400:
|
|
24
|
+
raise HTTPStatusError(resp)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_limited(resp: HTTPResponse, what: object, limit: "int | None" = None) -> bytes:
|
|
28
|
+
"""The decoded body of `resp`, which must be at most `limit` bytes (default
|
|
29
|
+
`MAX_REPLY_BYTES`): `OSError(EFBIG)` naming `what` otherwise, with the
|
|
30
|
+
connection closed."""
|
|
31
|
+
limit = _settings.MAX_REPLY_BYTES if limit is None else limit
|
|
32
|
+
resp._buffered(limit, too_large(what, limit))
|
|
33
|
+
return resp.read()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def drop_body(resp: HTTPResponse, limit: "int | None" = None) -> None:
|
|
37
|
+
"""Gives up the body of a reply nobody looks at: it is read, up to `limit`
|
|
38
|
+
bytes (default `MAX_REPLY_BYTES`), so the connection can serve the next
|
|
39
|
+
request, and `resp` is closed (which closes the connection when the body
|
|
40
|
+
was longer). A failure while reading it is not the caller's concern: the
|
|
41
|
+
status was already used."""
|
|
42
|
+
remaining = _settings.MAX_REPLY_BYTES if limit is None else limit
|
|
43
|
+
try:
|
|
44
|
+
while remaining > 0:
|
|
45
|
+
chunk = resp.read(min(CHUNK, remaining))
|
|
46
|
+
if not chunk:
|
|
47
|
+
break
|
|
48
|
+
remaining -= len(chunk)
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
pass
|
|
51
|
+
finally:
|
|
52
|
+
resp.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def json_body(resp: HTTPResponse, what: object, limit: "int | None" = None):
|
|
56
|
+
"""The JSON document in the body of `resp` (at most `limit` bytes, default
|
|
57
|
+
`MAX_REPLY_BYTES`, else `OSError(EFBIG)` naming `what`); `ValueError` when
|
|
58
|
+
it is not one."""
|
|
59
|
+
return _json.loads(read_limited(resp, what, limit))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""TLS inside TLS: an https origin reached through an https proxy.
|
|
2
|
+
|
|
3
|
+
`ssl` wraps a plain socket only, so the inner session runs on memory buffers
|
|
4
|
+
and this object moves its bytes over the already encrypted connection to the
|
|
5
|
+
proxy."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io as _io
|
|
10
|
+
import socket as _socket
|
|
11
|
+
import ssl as _ssl
|
|
12
|
+
import typing as _ty
|
|
13
|
+
|
|
14
|
+
_BLOCK = 16384
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TunneledTLS:
|
|
18
|
+
"""The socket `http.client` needs (`sendall`, `makefile("rb")`, `settimeout`,
|
|
19
|
+
`shutdown`, `close`, `fileno`, `pending`) for a TLS session whose bytes
|
|
20
|
+
travel over `outer`, a connected `ssl.SSLSocket` to the proxy that has
|
|
21
|
+
already carried the `CONNECT`. Closing it closes `outer`.
|
|
22
|
+
|
|
23
|
+
The handshake runs on construction, with `context.wrap_bio()`, so a
|
|
24
|
+
context that verifies the chain (the system's included) does so here."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, outer: "_ssl.SSLSocket", context: "_ssl.SSLContext", server_hostname: str):
|
|
27
|
+
self._outer = outer
|
|
28
|
+
self._incoming = _ssl.MemoryBIO()
|
|
29
|
+
self._outgoing = _ssl.MemoryBIO()
|
|
30
|
+
self._inner = context.wrap_bio(self._incoming, self._outgoing, server_hostname=server_hostname)
|
|
31
|
+
self._io_refs = 0
|
|
32
|
+
self._closed = False
|
|
33
|
+
self._run(self._inner.do_handshake)
|
|
34
|
+
|
|
35
|
+
def _run(self, call: "_ty.Callable[..., _ty.Any]", *args: _ty.Any) -> _ty.Any:
|
|
36
|
+
"""`call(*args)` on the inner session, moving bytes between its buffers
|
|
37
|
+
and `outer` until it neither wants to read nor to write."""
|
|
38
|
+
while True:
|
|
39
|
+
wants_read = wants_write = False
|
|
40
|
+
result = None
|
|
41
|
+
try:
|
|
42
|
+
result = call(*args)
|
|
43
|
+
except _ssl.SSLWantReadError:
|
|
44
|
+
wants_read = True
|
|
45
|
+
except _ssl.SSLWantWriteError:
|
|
46
|
+
wants_write = True
|
|
47
|
+
produced = self._outgoing.read()
|
|
48
|
+
if produced:
|
|
49
|
+
self._outer.sendall(produced)
|
|
50
|
+
if not (wants_read or wants_write):
|
|
51
|
+
return result
|
|
52
|
+
if wants_read:
|
|
53
|
+
received = self._outer.recv(_BLOCK)
|
|
54
|
+
if received:
|
|
55
|
+
self._incoming.write(received)
|
|
56
|
+
else:
|
|
57
|
+
self._incoming.write_eof()
|
|
58
|
+
|
|
59
|
+
def recv_into(self, buffer, nbytes: "int | None" = None) -> int:
|
|
60
|
+
if nbytes is None:
|
|
61
|
+
nbytes = len(buffer)
|
|
62
|
+
try:
|
|
63
|
+
return self._run(self._inner.read, nbytes, buffer)
|
|
64
|
+
except (_ssl.SSLZeroReturnError, _ssl.SSLEOFError):
|
|
65
|
+
return 0 # the peer ended the session, with or without close_notify
|
|
66
|
+
|
|
67
|
+
def recv(self, size: int = _BLOCK, flags: int = 0) -> bytes:
|
|
68
|
+
buffer = bytearray(size)
|
|
69
|
+
return bytes(buffer[: self.recv_into(buffer)])
|
|
70
|
+
|
|
71
|
+
def sendall(self, data, flags: int = 0) -> None:
|
|
72
|
+
view = memoryview(data).cast("B")
|
|
73
|
+
while view:
|
|
74
|
+
sent = self._run(self._inner.write, view[:_BLOCK])
|
|
75
|
+
view = view[sent:]
|
|
76
|
+
|
|
77
|
+
def send(self, data, flags: int = 0) -> int:
|
|
78
|
+
return self._run(self._inner.write, memoryview(data).cast("B")[:_BLOCK])
|
|
79
|
+
|
|
80
|
+
def makefile(self, mode: str = "rb", buffering: "int | None" = None, **kwargs: _ty.Any):
|
|
81
|
+
"""A buffered binary reader over `recv_into`, as `socket.makefile`
|
|
82
|
+
gives. Nothing else is read from a tunnelled session."""
|
|
83
|
+
if mode != "rb":
|
|
84
|
+
raise ValueError("a tunnelled TLS session is read as bytes: makefile('rb')")
|
|
85
|
+
raw = _socket.SocketIO(self, "r") # type: ignore[arg-type]
|
|
86
|
+
self._io_refs += 1
|
|
87
|
+
size = _io.DEFAULT_BUFFER_SIZE if buffering is None or buffering < 0 else buffering
|
|
88
|
+
return raw if size == 0 else _io.BufferedReader(raw, size)
|
|
89
|
+
|
|
90
|
+
def _decref_socketios(self) -> None:
|
|
91
|
+
if self._io_refs > 0:
|
|
92
|
+
self._io_refs -= 1
|
|
93
|
+
if self._closed:
|
|
94
|
+
self._outer.close()
|
|
95
|
+
|
|
96
|
+
def pending(self) -> int:
|
|
97
|
+
return self._inner.pending() + (self._outer.pending() or 0)
|
|
98
|
+
|
|
99
|
+
def fileno(self) -> int:
|
|
100
|
+
return self._outer.fileno()
|
|
101
|
+
|
|
102
|
+
def settimeout(self, value: "float | None") -> None:
|
|
103
|
+
self._outer.settimeout(value)
|
|
104
|
+
|
|
105
|
+
def gettimeout(self) -> "float | None":
|
|
106
|
+
return self._outer.gettimeout()
|
|
107
|
+
|
|
108
|
+
def shutdown(self, how: int) -> None:
|
|
109
|
+
self._outer.shutdown(how)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
self._closed = True
|
|
113
|
+
if self._io_refs <= 0:
|
|
114
|
+
self._outer.close()
|