pathapi 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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Loopback servers for the HTTP transports, scripted at the byte level: an
|
|
2
|
+
origin that records what it receives and answers from a script, a proxy, and a
|
|
3
|
+
test certificate authority. Nothing here leaves the loopback interface."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
import ipaddress
|
|
9
|
+
import pathlib
|
|
10
|
+
import select
|
|
11
|
+
import socket
|
|
12
|
+
import ssl
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import typing
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
CRLF = b"\r\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def wait_until(predicate, timeout: float = 15.0, interval: float = 0.02) -> bool:
|
|
23
|
+
"""Poll `predicate` until it is true or `timeout` seconds have passed;
|
|
24
|
+
returns its last value. A test asserts the result: what must be true
|
|
25
|
+
after the wait, not how long it took to get there."""
|
|
26
|
+
deadline = time.monotonic() + timeout
|
|
27
|
+
while time.monotonic() < deadline:
|
|
28
|
+
if predicate():
|
|
29
|
+
return True
|
|
30
|
+
time.sleep(interval)
|
|
31
|
+
return bool(predicate())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# How often a blocked accept() looks at the stop flag. Closing a listening
|
|
35
|
+
# socket from another thread does not wake an accept() on every platform
|
|
36
|
+
# (Linux leaves it blocked), so the accept loops never rely on it.
|
|
37
|
+
_POLL = 0.05
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _stop_listening(stop, thread, listener, sockets, lock):
|
|
41
|
+
"""Stop an accept loop and drop every connection it accepted."""
|
|
42
|
+
stop.set()
|
|
43
|
+
thread.join(timeout=10) # a safety net: the loop sees the flag within _POLL
|
|
44
|
+
try:
|
|
45
|
+
listener.close()
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
with lock:
|
|
49
|
+
accepted = list(sockets)
|
|
50
|
+
for sock in accepted:
|
|
51
|
+
try:
|
|
52
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
sock.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Request(typing.NamedTuple):
|
|
59
|
+
"""One request as the server received it."""
|
|
60
|
+
|
|
61
|
+
method: str
|
|
62
|
+
target: str
|
|
63
|
+
headers: "list[tuple[str, str]]"
|
|
64
|
+
body: bytes
|
|
65
|
+
connection: int # which accepted connection carried it, from 0
|
|
66
|
+
|
|
67
|
+
def header(self, name: str):
|
|
68
|
+
for key, value in self.headers:
|
|
69
|
+
if key.lower() == name.lower():
|
|
70
|
+
return value
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Reply(typing.NamedTuple):
|
|
75
|
+
"""Bytes to send, whether to close the connection after them, and bytes
|
|
76
|
+
to send `delay` seconds later on the same connection."""
|
|
77
|
+
|
|
78
|
+
raw: bytes
|
|
79
|
+
close: bool = False
|
|
80
|
+
later: bytes = b""
|
|
81
|
+
delay: float = 0.0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Drop:
|
|
85
|
+
"""Close the connection without answering."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Hang:
|
|
89
|
+
"""Never answer; the connection stays open until the server closes."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def response(status=200, headers=None, body=b"", reason="OK", length=True) -> bytes:
|
|
93
|
+
"""A complete HTTP/1.1 response; `Content-Length` is added unless
|
|
94
|
+
`length` is false or `headers` names one."""
|
|
95
|
+
head = [f"HTTP/1.1 {status} {reason}"]
|
|
96
|
+
names = {k.lower() for k in (headers or {})}
|
|
97
|
+
for key, value in (headers or {}).items():
|
|
98
|
+
head.append(f"{key}: {value}")
|
|
99
|
+
if length and "content-length" not in names and "transfer-encoding" not in names:
|
|
100
|
+
head.append(f"Content-Length: {len(body)}")
|
|
101
|
+
return "\r\n".join(head).encode("latin-1") + CRLF + CRLF + body
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_until(
|
|
105
|
+
sock, marker: bytes, limit=1 << 20, start: bytes = b""
|
|
106
|
+
) -> "tuple[bytes, bytes] | None":
|
|
107
|
+
data = start
|
|
108
|
+
while marker not in data:
|
|
109
|
+
chunk = sock.recv(65536)
|
|
110
|
+
if not chunk:
|
|
111
|
+
return None
|
|
112
|
+
data += chunk
|
|
113
|
+
if len(data) > limit:
|
|
114
|
+
return None
|
|
115
|
+
head, _, rest = data.partition(marker)
|
|
116
|
+
return head, rest
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_exactly(sock, rest: bytes, count: int) -> bytes:
|
|
120
|
+
while len(rest) < count:
|
|
121
|
+
chunk = sock.recv(65536)
|
|
122
|
+
if not chunk:
|
|
123
|
+
break
|
|
124
|
+
rest += chunk
|
|
125
|
+
return rest
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _read_chunked(sock, rest: bytes) -> "tuple[bytes, bytes]":
|
|
129
|
+
"""The body, and the bytes that followed it on the connection."""
|
|
130
|
+
body = b""
|
|
131
|
+
while True:
|
|
132
|
+
while CRLF not in rest:
|
|
133
|
+
chunk = sock.recv(65536)
|
|
134
|
+
if not chunk:
|
|
135
|
+
return body, b""
|
|
136
|
+
rest += chunk
|
|
137
|
+
line, _, rest = rest.partition(CRLF)
|
|
138
|
+
size = int(line.split(b";")[0], 16)
|
|
139
|
+
rest = _read_exactly(sock, rest, size + 2)
|
|
140
|
+
body += rest[:size]
|
|
141
|
+
rest = rest[size + 2 :]
|
|
142
|
+
if size == 0:
|
|
143
|
+
return body, rest
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Wire:
|
|
147
|
+
"""An origin on a loopback port. `script(request)` returns what to send:
|
|
148
|
+
`bytes`, a `Reply`, `Drop()` or `Hang()`; the default answers `200 ok`.
|
|
149
|
+
`requests` lists what was received, `connections` how many connections
|
|
150
|
+
were accepted, and `closed` the connections the client closed. With `tls`
|
|
151
|
+
(an `ssl.SSLContext`) every connection is wrapped by the server first."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, script=None, tls: "ssl.SSLContext | None" = None):
|
|
154
|
+
self.script = script or (lambda request: response(body=b"ok"))
|
|
155
|
+
self.tls = tls
|
|
156
|
+
self.requests: "list[Request]" = []
|
|
157
|
+
self._connections = 0
|
|
158
|
+
self._taking = threading.Lock()
|
|
159
|
+
self.closed: "list[int]" = []
|
|
160
|
+
self.handshakes_failed = 0
|
|
161
|
+
self._lock = threading.Lock()
|
|
162
|
+
self._sockets: "list[socket.socket]" = []
|
|
163
|
+
self._stop = threading.Event()
|
|
164
|
+
self._listener = socket.create_server(("127.0.0.1", 0))
|
|
165
|
+
self._listener.setblocking(False)
|
|
166
|
+
self.port = self._listener.getsockname()[1]
|
|
167
|
+
self.host = "127.0.0.1"
|
|
168
|
+
self._thread = threading.Thread(target=self._accept, daemon=True)
|
|
169
|
+
self._thread.start()
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def url(self) -> str:
|
|
173
|
+
return f"{'https' if self.tls else 'http'}://127.0.0.1:{self.port}"
|
|
174
|
+
|
|
175
|
+
def __enter__(self):
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def __exit__(self, *exc_info):
|
|
179
|
+
self.close()
|
|
180
|
+
|
|
181
|
+
def close(self):
|
|
182
|
+
_stop_listening(
|
|
183
|
+
self._stop, self._thread, self._listener, self._sockets, self._lock
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def connections(self) -> int:
|
|
188
|
+
"""How many connections the client has made. A connection waiting in
|
|
189
|
+
the kernel's queue is taken first, so a test asking "was one made?"
|
|
190
|
+
does not depend on the accept thread having run."""
|
|
191
|
+
self._take()
|
|
192
|
+
return self._connections
|
|
193
|
+
|
|
194
|
+
def _accept(self):
|
|
195
|
+
while not self._stop.is_set():
|
|
196
|
+
try:
|
|
197
|
+
select.select([self._listener], [], [], _POLL)
|
|
198
|
+
except (OSError, ValueError):
|
|
199
|
+
return
|
|
200
|
+
self._take()
|
|
201
|
+
|
|
202
|
+
def _take(self):
|
|
203
|
+
"""Accept every connection that is waiting, and start serving it."""
|
|
204
|
+
with self._taking:
|
|
205
|
+
while True:
|
|
206
|
+
try:
|
|
207
|
+
sock, _ = self._listener.accept()
|
|
208
|
+
except OSError: # nothing waiting, or the listener is closed
|
|
209
|
+
return
|
|
210
|
+
sock.setblocking(True)
|
|
211
|
+
with self._lock:
|
|
212
|
+
index = self._connections
|
|
213
|
+
self._connections += 1
|
|
214
|
+
self._sockets.append(sock)
|
|
215
|
+
threading.Thread(
|
|
216
|
+
target=self._serve, args=(sock, index), daemon=True
|
|
217
|
+
).start()
|
|
218
|
+
|
|
219
|
+
def _serve(self, sock, index):
|
|
220
|
+
try:
|
|
221
|
+
if self.tls is not None:
|
|
222
|
+
try:
|
|
223
|
+
sock = self.tls.wrap_socket(sock, server_side=True)
|
|
224
|
+
except (OSError, ssl.SSLError):
|
|
225
|
+
with self._lock:
|
|
226
|
+
self.handshakes_failed += 1
|
|
227
|
+
return
|
|
228
|
+
with self._lock:
|
|
229
|
+
self._sockets.append(sock)
|
|
230
|
+
carry = b"" # what the client sent beyond the request just read
|
|
231
|
+
while not self._stop.is_set():
|
|
232
|
+
parsed = _read_until(sock, CRLF + CRLF, start=carry)
|
|
233
|
+
if parsed is None:
|
|
234
|
+
return
|
|
235
|
+
head, rest = parsed
|
|
236
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
237
|
+
method, target, _version = lines[0].split(" ", 2)
|
|
238
|
+
headers = [tuple(line.split(":", 1)) for line in lines[1:]]
|
|
239
|
+
headers = [(k, v.strip()) for k, v in headers]
|
|
240
|
+
length = next(
|
|
241
|
+
(int(v) for k, v in headers if k.lower() == "content-length"), 0
|
|
242
|
+
)
|
|
243
|
+
chunked = any(
|
|
244
|
+
k.lower() == "transfer-encoding" and "chunked" in v.lower()
|
|
245
|
+
for k, v in headers
|
|
246
|
+
)
|
|
247
|
+
if chunked:
|
|
248
|
+
body, carry = _read_chunked(sock, rest)
|
|
249
|
+
else:
|
|
250
|
+
data = _read_exactly(sock, rest, length)
|
|
251
|
+
body, carry = data[:length], data[length:]
|
|
252
|
+
request = Request(method, target, headers, body, index)
|
|
253
|
+
with self._lock:
|
|
254
|
+
self.requests.append(request)
|
|
255
|
+
action = self.script(request)
|
|
256
|
+
if isinstance(action, Drop):
|
|
257
|
+
return
|
|
258
|
+
if isinstance(action, Hang):
|
|
259
|
+
self._stop.wait(30)
|
|
260
|
+
return
|
|
261
|
+
if isinstance(action, bytes):
|
|
262
|
+
action = Reply(action)
|
|
263
|
+
sock.sendall(action.raw)
|
|
264
|
+
if action.later:
|
|
265
|
+
time.sleep(action.delay)
|
|
266
|
+
sock.sendall(action.later)
|
|
267
|
+
if action.close:
|
|
268
|
+
return
|
|
269
|
+
except (OSError, ssl.SSLError, ValueError):
|
|
270
|
+
# A request line that does not parse, as after bytes the client
|
|
271
|
+
# should not have sent, ends the connection as a server would.
|
|
272
|
+
return
|
|
273
|
+
finally:
|
|
274
|
+
with self._lock:
|
|
275
|
+
self.closed.append(index)
|
|
276
|
+
try:
|
|
277
|
+
sock.close()
|
|
278
|
+
except OSError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Proxy:
|
|
283
|
+
"""An HTTP proxy on a loopback port. A request in absolute form is
|
|
284
|
+
answered by `script(request)` as an origin would; `CONNECT host:port` is
|
|
285
|
+
answered `200` and then relayed to `upstream` (a `(host, port)`), whatever
|
|
286
|
+
the host asked for, or refused with `refuse` (a status) when given.
|
|
287
|
+
`requests` lists what it received, `CONNECT`s included."""
|
|
288
|
+
|
|
289
|
+
def __init__(self, script=None, upstream=None, refuse=None):
|
|
290
|
+
self.script = script or (lambda request: response(body=b"via proxy"))
|
|
291
|
+
self.upstream = upstream
|
|
292
|
+
self.refuse = refuse
|
|
293
|
+
self.requests: "list[Request]" = []
|
|
294
|
+
self._lock = threading.Lock()
|
|
295
|
+
self._sockets: "list[socket.socket]" = []
|
|
296
|
+
self._stop = threading.Event()
|
|
297
|
+
self._listener = socket.create_server(("127.0.0.1", 0))
|
|
298
|
+
self._listener.settimeout(_POLL)
|
|
299
|
+
self.port = self._listener.getsockname()[1]
|
|
300
|
+
self.url = f"http://127.0.0.1:{self.port}"
|
|
301
|
+
self._thread = threading.Thread(target=self._accept, daemon=True)
|
|
302
|
+
self._thread.start()
|
|
303
|
+
|
|
304
|
+
def __enter__(self):
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def __exit__(self, *exc_info):
|
|
308
|
+
self.close()
|
|
309
|
+
|
|
310
|
+
def close(self):
|
|
311
|
+
_stop_listening(
|
|
312
|
+
self._stop, self._thread, self._listener, self._sockets, self._lock
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def _accept(self):
|
|
316
|
+
while not self._stop.is_set():
|
|
317
|
+
try:
|
|
318
|
+
sock, _ = self._listener.accept()
|
|
319
|
+
except socket.timeout:
|
|
320
|
+
continue
|
|
321
|
+
except OSError:
|
|
322
|
+
return
|
|
323
|
+
sock.settimeout(None)
|
|
324
|
+
with self._lock:
|
|
325
|
+
self._sockets.append(sock)
|
|
326
|
+
threading.Thread(target=self._serve, args=(sock,), daemon=True).start()
|
|
327
|
+
|
|
328
|
+
def _serve(self, sock):
|
|
329
|
+
try:
|
|
330
|
+
while not self._stop.is_set():
|
|
331
|
+
parsed = _read_until(sock, CRLF + CRLF)
|
|
332
|
+
if parsed is None:
|
|
333
|
+
return
|
|
334
|
+
head, rest = parsed
|
|
335
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
336
|
+
method, target, _version = lines[0].split(" ", 2)
|
|
337
|
+
headers = [
|
|
338
|
+
(k, v.strip()) for k, v in (l.split(":", 1) for l in lines[1:])
|
|
339
|
+
]
|
|
340
|
+
length = next(
|
|
341
|
+
(int(v) for k, v in headers if k.lower() == "content-length"), 0
|
|
342
|
+
)
|
|
343
|
+
body = _read_exactly(sock, rest, length)[:length]
|
|
344
|
+
request = Request(method, target, headers, body, 0)
|
|
345
|
+
with self._lock:
|
|
346
|
+
self.requests.append(request)
|
|
347
|
+
if method == "CONNECT":
|
|
348
|
+
if self.refuse:
|
|
349
|
+
sock.sendall(response(self.refuse, reason="Refused"))
|
|
350
|
+
return
|
|
351
|
+
self._tunnel(sock)
|
|
352
|
+
return
|
|
353
|
+
action = self.script(request)
|
|
354
|
+
if isinstance(action, Drop):
|
|
355
|
+
return
|
|
356
|
+
if isinstance(action, bytes):
|
|
357
|
+
action = Reply(action)
|
|
358
|
+
sock.sendall(action.raw)
|
|
359
|
+
if action.close:
|
|
360
|
+
return
|
|
361
|
+
except OSError:
|
|
362
|
+
return
|
|
363
|
+
finally:
|
|
364
|
+
try:
|
|
365
|
+
sock.close()
|
|
366
|
+
except OSError:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
def _tunnel(self, client):
|
|
370
|
+
upstream = socket.create_connection(self.upstream)
|
|
371
|
+
with self._lock:
|
|
372
|
+
self._sockets.append(upstream)
|
|
373
|
+
client.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
|
374
|
+
|
|
375
|
+
def pump(source, sink):
|
|
376
|
+
try:
|
|
377
|
+
while True:
|
|
378
|
+
data = source.recv(65536)
|
|
379
|
+
if not data:
|
|
380
|
+
break
|
|
381
|
+
sink.sendall(data)
|
|
382
|
+
except OSError:
|
|
383
|
+
pass
|
|
384
|
+
finally:
|
|
385
|
+
try:
|
|
386
|
+
sink.shutdown(socket.SHUT_WR)
|
|
387
|
+
except OSError:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
thread = threading.Thread(target=pump, args=(upstream, client), daemon=True)
|
|
391
|
+
thread.start()
|
|
392
|
+
pump(client, upstream)
|
|
393
|
+
# The client is gone, so nobody reads the upstream's side any more;
|
|
394
|
+
# shutting it wakes the other pump however the origin behaves.
|
|
395
|
+
try:
|
|
396
|
+
upstream.shutdown(socket.SHUT_RDWR)
|
|
397
|
+
except OSError:
|
|
398
|
+
pass
|
|
399
|
+
thread.join(timeout=10)
|
|
400
|
+
upstream.close()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def refusing_port() -> int:
|
|
404
|
+
"""A loopback port nothing listens on."""
|
|
405
|
+
with socket.socket() as sock:
|
|
406
|
+
sock.bind(("127.0.0.1", 0))
|
|
407
|
+
return sock.getsockname()[1]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class Authority(typing.NamedTuple):
|
|
411
|
+
"""A test certificate authority and a server certificate it signed."""
|
|
412
|
+
|
|
413
|
+
ca_file: str
|
|
414
|
+
cert_file: str
|
|
415
|
+
key_file: str
|
|
416
|
+
|
|
417
|
+
def server_context(self) -> ssl.SSLContext:
|
|
418
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
419
|
+
context.load_cert_chain(self.cert_file, self.key_file)
|
|
420
|
+
return context
|
|
421
|
+
|
|
422
|
+
def client_context(self) -> ssl.SSLContext:
|
|
423
|
+
return ssl.create_default_context(cafile=self.ca_file)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def make_authority(directory, *names: str) -> Authority:
|
|
427
|
+
"""A CA, and a server certificate for `names` (host names or IP
|
|
428
|
+
addresses) signed by it, written under `directory`."""
|
|
429
|
+
pytest.importorskip("cryptography")
|
|
430
|
+
from cryptography import x509
|
|
431
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
432
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
433
|
+
from cryptography.x509.oid import NameOID
|
|
434
|
+
|
|
435
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
436
|
+
|
|
437
|
+
def build(subject, issuer, key, signer, extensions):
|
|
438
|
+
builder = (
|
|
439
|
+
x509.CertificateBuilder()
|
|
440
|
+
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)]))
|
|
441
|
+
.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)]))
|
|
442
|
+
.public_key(key.public_key())
|
|
443
|
+
.serial_number(x509.random_serial_number())
|
|
444
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
445
|
+
.not_valid_after(now + datetime.timedelta(days=1))
|
|
446
|
+
)
|
|
447
|
+
for extension, critical in extensions:
|
|
448
|
+
builder = builder.add_extension(extension, critical=critical)
|
|
449
|
+
return builder.sign(signer, hashes.SHA256())
|
|
450
|
+
|
|
451
|
+
ca_key = ec.generate_private_key(ec.SECP256R1())
|
|
452
|
+
ca = build(
|
|
453
|
+
"test authority",
|
|
454
|
+
"test authority",
|
|
455
|
+
ca_key,
|
|
456
|
+
ca_key,
|
|
457
|
+
[
|
|
458
|
+
(x509.BasicConstraints(ca=True, path_length=None), True),
|
|
459
|
+
(
|
|
460
|
+
x509.KeyUsage(
|
|
461
|
+
digital_signature=False,
|
|
462
|
+
content_commitment=False,
|
|
463
|
+
key_encipherment=False,
|
|
464
|
+
data_encipherment=False,
|
|
465
|
+
key_agreement=False,
|
|
466
|
+
key_cert_sign=True,
|
|
467
|
+
crl_sign=True,
|
|
468
|
+
encipher_only=False,
|
|
469
|
+
decipher_only=False,
|
|
470
|
+
),
|
|
471
|
+
True,
|
|
472
|
+
),
|
|
473
|
+
(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), False),
|
|
474
|
+
],
|
|
475
|
+
)
|
|
476
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
477
|
+
alt = []
|
|
478
|
+
for name in names:
|
|
479
|
+
try:
|
|
480
|
+
alt.append(x509.IPAddress(ipaddress.ip_address(name)))
|
|
481
|
+
except ValueError:
|
|
482
|
+
alt.append(x509.DNSName(name))
|
|
483
|
+
leaf = build(
|
|
484
|
+
names[0],
|
|
485
|
+
"test authority",
|
|
486
|
+
key,
|
|
487
|
+
ca_key,
|
|
488
|
+
[
|
|
489
|
+
(x509.SubjectAlternativeName(alt), False),
|
|
490
|
+
(x509.BasicConstraints(ca=False, path_length=None), True),
|
|
491
|
+
(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False),
|
|
492
|
+
(
|
|
493
|
+
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
|
|
494
|
+
False,
|
|
495
|
+
),
|
|
496
|
+
],
|
|
497
|
+
)
|
|
498
|
+
directory = pathlib.Path(directory)
|
|
499
|
+
ca_file = directory / "ca.pem"
|
|
500
|
+
cert_file = directory / "server.pem"
|
|
501
|
+
key_file = directory / "server.key"
|
|
502
|
+
ca_file.write_bytes(ca.public_bytes(serialization.Encoding.PEM))
|
|
503
|
+
cert_file.write_bytes(leaf.public_bytes(serialization.Encoding.PEM))
|
|
504
|
+
key_file.write_bytes(
|
|
505
|
+
key.private_bytes(
|
|
506
|
+
serialization.Encoding.PEM,
|
|
507
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
508
|
+
serialization.NoEncryption(),
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
return Authority(str(ca_file), str(cert_file), str(key_file))
|