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,134 @@
|
|
|
1
|
+
"""`Authorization` belongs to the origin a request was made for. A request
|
|
2
|
+
asked again after a redirect (`HTTPClient.redirect()`) leaves it off when the
|
|
3
|
+
target is another scheme, host or port and keeps it within the origin,
|
|
4
|
+
whichever transport sends."""
|
|
5
|
+
|
|
6
|
+
import errno
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from pathapi.http import HTTPClient, HTTPTransport
|
|
11
|
+
from transport_wire import Wire, make_authority, response
|
|
12
|
+
|
|
13
|
+
pytestmark = pytest.mark.usefixtures("http_transport_kind")
|
|
14
|
+
|
|
15
|
+
TOKEN = "Bearer SECRET-TOKEN"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sees_no_credential(wire):
|
|
19
|
+
return [r.header("Authorization") for r in wire.requests] == [None] * len(
|
|
20
|
+
wire.requests
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def first_answer(client, method, url, **kwargs):
|
|
25
|
+
"""The redirect that `url` answers, unfollowed."""
|
|
26
|
+
answer = client.request(method, url, allow_redirects=False, **kwargs)
|
|
27
|
+
assert answer.status in (301, 302)
|
|
28
|
+
answer.close()
|
|
29
|
+
return answer
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def origins():
|
|
34
|
+
"""`home` redirects every HEAD and GET to `away`; both answer a file of
|
|
35
|
+
five bytes."""
|
|
36
|
+
with Wire(
|
|
37
|
+
lambda r: response(200, {"Content-Type": "text/plain"}, b"hello")
|
|
38
|
+
) as away:
|
|
39
|
+
|
|
40
|
+
def home_script(request):
|
|
41
|
+
return response(302, {"Location": away.url + "/elsewhere"}, reason="Found")
|
|
42
|
+
|
|
43
|
+
with Wire(home_script) as home:
|
|
44
|
+
yield home, away
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize("method", ["GET", "HEAD"])
|
|
48
|
+
@pytest.mark.parametrize("how", ["header", "auth"])
|
|
49
|
+
def test_a_request_asked_again_at_another_origin_does_not_carry_authorization(
|
|
50
|
+
origins, method, how
|
|
51
|
+
):
|
|
52
|
+
home, away = origins
|
|
53
|
+
headers = {"X-Api-Key": "K"}
|
|
54
|
+
auth = None
|
|
55
|
+
if how == "header":
|
|
56
|
+
headers["Authorization"] = TOKEN
|
|
57
|
+
else:
|
|
58
|
+
auth = ("user", "s3cr3t")
|
|
59
|
+
with HTTPClient() as client:
|
|
60
|
+
url = home.url + "/f"
|
|
61
|
+
moved = first_answer(client, method, url, headers=headers, auth=auth)
|
|
62
|
+
again = client.redirect(method, url, moved, headers=headers, auth=auth)
|
|
63
|
+
assert again.status == 200
|
|
64
|
+
again.close()
|
|
65
|
+
# The credential reached the origin it was given for, and not the other.
|
|
66
|
+
assert home.requests[0].header("Authorization") is not None
|
|
67
|
+
assert away.requests and sees_no_credential(away)
|
|
68
|
+
# Only Authorization is left off.
|
|
69
|
+
assert away.requests[0].header("X-Api-Key") == "K"
|
|
70
|
+
assert away.requests[0].target == "/elsewhere"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_a_request_asked_again_within_the_origin_keeps_authorization():
|
|
74
|
+
def script(request):
|
|
75
|
+
if request.target == "/d":
|
|
76
|
+
return response(301, {"Location": "/d/"}, reason="Moved")
|
|
77
|
+
return response(200, {"Content-Type": "text/html"}, b"<html></html>")
|
|
78
|
+
|
|
79
|
+
with Wire(script) as wire:
|
|
80
|
+
with HTTPClient(headers={"Authorization": TOKEN}) as client:
|
|
81
|
+
url = wire.url + "/d"
|
|
82
|
+
moved = first_answer(client, "HEAD", url)
|
|
83
|
+
client.redirect("HEAD", url, moved).close()
|
|
84
|
+
assert [r.target for r in wire.requests] == ["/d", "/d/"]
|
|
85
|
+
assert [r.header("Authorization") for r in wire.requests] == [TOKEN, TOKEN]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_a_second_redirect_back_to_the_origin_does_not_bring_it_back():
|
|
89
|
+
with Wire() as wire:
|
|
90
|
+
with Wire(lambda r: response(302, {"Location": wire.url + "/home"})) as away:
|
|
91
|
+
|
|
92
|
+
def script(request):
|
|
93
|
+
if request.target == "/start":
|
|
94
|
+
return response(302, {"Location": away.url + "/away"})
|
|
95
|
+
return response(200, {}, b"ok")
|
|
96
|
+
|
|
97
|
+
wire.script = script
|
|
98
|
+
with HTTPClient(headers={"Authorization": TOKEN}) as client:
|
|
99
|
+
url = wire.url + "/start"
|
|
100
|
+
moved = first_answer(client, "GET", url)
|
|
101
|
+
client.redirect("GET", url, moved).read()
|
|
102
|
+
assert [r.header("Authorization") for r in wire.requests] == [TOKEN, None]
|
|
103
|
+
assert sees_no_credential(away)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_a_redirect_from_https_to_plain_http_does_not_carry_authorization(tmp_path):
|
|
107
|
+
authority = make_authority(tmp_path, "127.0.0.1", "localhost")
|
|
108
|
+
with Wire(lambda r: response(200, {}, b"plain")) as plain:
|
|
109
|
+
secure_script = lambda r: response( # noqa: E731
|
|
110
|
+
302, {"Location": plain.url + "/downgraded"}, reason="Found"
|
|
111
|
+
)
|
|
112
|
+
with Wire(secure_script, tls=authority.server_context()) as secure:
|
|
113
|
+
transport = HTTPTransport(ssl_context=authority.client_context())
|
|
114
|
+
client = HTTPClient(transport, headers={"Authorization": TOKEN})
|
|
115
|
+
try:
|
|
116
|
+
assert client.request("GET", secure.url + "/f").read() == b"plain"
|
|
117
|
+
finally:
|
|
118
|
+
transport.close()
|
|
119
|
+
assert secure.requests[0].header("Authorization") == TOKEN
|
|
120
|
+
assert plain.requests and sees_no_credential(plain)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_a_re_request_with_no_usable_location_is_a_refused_redirect():
|
|
124
|
+
with Wire(lambda r: response(302, {"Location": "ftp://127.0.0.1/x"})) as wire:
|
|
125
|
+
with HTTPClient() as client:
|
|
126
|
+
url = wire.url + "/f"
|
|
127
|
+
moved = first_answer(client, "GET", url)
|
|
128
|
+
with pytest.raises(OSError) as excinfo:
|
|
129
|
+
client.redirect("GET", url, moved)
|
|
130
|
+
assert excinfo.value.errno == errno.EIO
|
|
131
|
+
assert "HTTP 302" in str(excinfo.value) and "no usable Location" in str(
|
|
132
|
+
excinfo.value
|
|
133
|
+
)
|
|
134
|
+
assert len(wire.requests) == 1
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Redirects and credentials above a transport: the client follows a `GET`,
|
|
2
|
+
`HEAD` or `OPTIONS` redirect itself and takes `Authorization` off when the
|
|
3
|
+
scheme, host or port changes, whichever transport sends the requests."""
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from pathapi.http import HTTPClient
|
|
10
|
+
from transport_fakes import FakeTransport, reply
|
|
11
|
+
from transport_wire import Reply, Wire, response
|
|
12
|
+
|
|
13
|
+
AUTH = {"Authorization": "Bearer TOKEN-1"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def redirect(location, status=302):
|
|
17
|
+
return response(status, {"Location": location}, reason="Found")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sent_authorization(request):
|
|
21
|
+
return request.header("Authorization")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- across a real transport -------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_authorization_goes_with_a_redirect_that_stays_on_the_origin(http_transport):
|
|
28
|
+
def script(request):
|
|
29
|
+
return (
|
|
30
|
+
redirect("/final") if request.target == "/start" else response(body=b"ok")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
with Wire(script) as wire:
|
|
34
|
+
client = HTTPClient(http_transport, headers=AUTH)
|
|
35
|
+
reply_ = client.request("GET", wire.url + "/start")
|
|
36
|
+
assert reply_.read() == b"ok"
|
|
37
|
+
assert [sent_authorization(r) for r in wire.requests] == ["Bearer TOKEN-1"] * 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_authorization_is_dropped_by_a_redirect_to_another_port(http_transport):
|
|
41
|
+
with Wire() as elsewhere:
|
|
42
|
+
|
|
43
|
+
def script(request):
|
|
44
|
+
return redirect(elsewhere.url + "/landing")
|
|
45
|
+
|
|
46
|
+
with Wire(script) as wire:
|
|
47
|
+
client = HTTPClient(http_transport, headers={**AUTH, "X-Api-Key": "K"})
|
|
48
|
+
client.request("GET", wire.url + "/start").read()
|
|
49
|
+
assert sent_authorization(wire.requests[0]) == "Bearer TOKEN-1"
|
|
50
|
+
(landing,) = elsewhere.requests
|
|
51
|
+
assert landing.target == "/landing"
|
|
52
|
+
assert sent_authorization(landing) is None
|
|
53
|
+
# Only Authorization is dropped.
|
|
54
|
+
assert landing.header("X-Api-Key") == "K"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_basic_credentials_are_dropped_by_a_redirect_like_any_authorization(
|
|
58
|
+
http_transport,
|
|
59
|
+
):
|
|
60
|
+
with Wire() as elsewhere:
|
|
61
|
+
with Wire(lambda r: redirect(elsewhere.url + "/landing")) as wire:
|
|
62
|
+
client = HTTPClient(http_transport)
|
|
63
|
+
client.request("GET", wire.url + "/s", auth=("alice", "s3cr3t")).read()
|
|
64
|
+
assert sent_authorization(wire.requests[0]).startswith("Basic ")
|
|
65
|
+
assert sent_authorization(elsewhere.requests[0]) is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_authorization_does_not_come_back_when_a_redirect_returns_to_the_origin(
|
|
69
|
+
http_transport,
|
|
70
|
+
):
|
|
71
|
+
with Wire() as wire:
|
|
72
|
+
with Wire(lambda r: redirect(wire.url + "/back")) as away:
|
|
73
|
+
|
|
74
|
+
def script(request):
|
|
75
|
+
if request.target == "/start":
|
|
76
|
+
return redirect(away.url + "/away")
|
|
77
|
+
return response(body=b"home")
|
|
78
|
+
|
|
79
|
+
wire.script = script
|
|
80
|
+
client = HTTPClient(http_transport, headers=AUTH)
|
|
81
|
+
client.request("GET", wire.url + "/start").read()
|
|
82
|
+
assert sent_authorization(wire.requests[0]) == "Bearer TOKEN-1"
|
|
83
|
+
assert sent_authorization(wire.requests[1]) is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_303_becomes_a_get_and_a_307_keeps_the_method(http_transport):
|
|
87
|
+
def script(request):
|
|
88
|
+
if request.target == "/see":
|
|
89
|
+
return redirect("/there", 303)
|
|
90
|
+
if request.target == "/again":
|
|
91
|
+
return redirect("/there", 307)
|
|
92
|
+
return response(body=b"there")
|
|
93
|
+
|
|
94
|
+
with Wire(script) as wire:
|
|
95
|
+
client = HTTPClient(http_transport)
|
|
96
|
+
client.request("OPTIONS", wire.url + "/see").read()
|
|
97
|
+
client.request("OPTIONS", wire.url + "/again").read()
|
|
98
|
+
assert [(r.method, r.target) for r in wire.requests] == [
|
|
99
|
+
("OPTIONS", "/see"),
|
|
100
|
+
("GET", "/there"),
|
|
101
|
+
("OPTIONS", "/again"),
|
|
102
|
+
("OPTIONS", "/there"),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_a_redirect_loop_stops_after_thirty_redirects(http_transport):
|
|
107
|
+
with Wire(lambda r: redirect("/loop")) as wire:
|
|
108
|
+
client = HTTPClient(http_transport)
|
|
109
|
+
with pytest.raises(OSError) as excinfo:
|
|
110
|
+
client.request("GET", wire.url + "/loop")
|
|
111
|
+
assert len(wire.requests) == 31
|
|
112
|
+
assert "30 redirects" in str(excinfo.value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.parametrize(
|
|
116
|
+
"location",
|
|
117
|
+
[
|
|
118
|
+
"/a\x01b",
|
|
119
|
+
"ftp://127.0.0.1/x",
|
|
120
|
+
"file:///etc/hosts",
|
|
121
|
+
"javascript:alert(1)",
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
def test_a_location_that_is_not_an_http_url_is_a_refused_redirect(
|
|
125
|
+
http_transport, location
|
|
126
|
+
):
|
|
127
|
+
with Wire(lambda r: redirect(location)) as wire:
|
|
128
|
+
client = HTTPClient(http_transport)
|
|
129
|
+
with pytest.raises(OSError) as excinfo:
|
|
130
|
+
client.request("GET", wire.url + "/start")
|
|
131
|
+
assert excinfo.value.errno == errno.EIO
|
|
132
|
+
assert "HTTP 302" in str(excinfo.value) and "no usable Location" in str(
|
|
133
|
+
excinfo.value
|
|
134
|
+
)
|
|
135
|
+
assert len(wire.requests) == 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_userinfo_in_a_location_is_not_sent(http_transport):
|
|
139
|
+
with Wire() as elsewhere:
|
|
140
|
+
target = f"http://mallory:LEAKED@127.0.0.1:{elsewhere.port}/landing"
|
|
141
|
+
with Wire(lambda r: redirect(target)) as wire:
|
|
142
|
+
HTTPClient(http_transport).request("GET", wire.url + "/s").read()
|
|
143
|
+
assert elsewhere.requests[0].target == "/landing"
|
|
144
|
+
assert sent_authorization(elsewhere.requests[0]) is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_allow_redirects_false_returns_the_redirect(http_transport):
|
|
148
|
+
with Wire(lambda r: redirect("/there")) as wire:
|
|
149
|
+
reply_ = HTTPClient(http_transport).request(
|
|
150
|
+
"GET", wire.url + "/start", allow_redirects=False
|
|
151
|
+
)
|
|
152
|
+
assert (reply_.status, reply_.headers["Location"]) == (302, "/there")
|
|
153
|
+
reply_.close()
|
|
154
|
+
assert len(wire.requests) == 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- the scheme is part of the origin ----------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fake_redirecting(location):
|
|
161
|
+
def answer(method, url, **kwargs):
|
|
162
|
+
if url == "http://h:443/x":
|
|
163
|
+
return reply(302, headers={"Location": location}, url=url)
|
|
164
|
+
return reply(200, b"final", url=url)
|
|
165
|
+
|
|
166
|
+
return FakeTransport(handler=answer)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize(
|
|
170
|
+
"location, kept",
|
|
171
|
+
[
|
|
172
|
+
("https://h/x", False), # same host and port number, another scheme
|
|
173
|
+
("http://h:444/x", False),
|
|
174
|
+
("http://other/x", False),
|
|
175
|
+
("http://h:443/y", True),
|
|
176
|
+
("//h:443/y", True),
|
|
177
|
+
],
|
|
178
|
+
)
|
|
179
|
+
def test_authorization_follows_only_a_redirect_that_keeps_scheme_host_and_port(
|
|
180
|
+
location, kept
|
|
181
|
+
):
|
|
182
|
+
transport = fake_redirecting(location)
|
|
183
|
+
HTTPClient(transport, headers=AUTH).request("GET", "http://h:443/x").read()
|
|
184
|
+
assert len(transport.sent) == 2
|
|
185
|
+
assert ("Authorization" in transport.sent[1].headers) is kept
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_a_header_with_a_line_break_is_refused_before_any_transport_call():
|
|
189
|
+
transport = FakeTransport(default=reply(200))
|
|
190
|
+
client = HTTPClient(transport, headers={"X-A": "1\r\nX-B: 2"})
|
|
191
|
+
with pytest.raises(ValueError):
|
|
192
|
+
client.request("GET", "http://h/x")
|
|
193
|
+
with pytest.raises(ValueError):
|
|
194
|
+
HTTPClient(transport).request("GET", "http://h/x", headers={"X-A": "1\n2"})
|
|
195
|
+
with pytest.raises(ValueError):
|
|
196
|
+
HTTPClient(transport).request("GET", "http://h/x\r\nX-A: 1")
|
|
197
|
+
assert transport.sent == []
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- a Location the URL check refuses ----------------------------------------------
|
|
201
|
+
|
|
202
|
+
REFUSED_LOCATIONS = [
|
|
203
|
+
"http://127.0.0.1:99999/x", # a port out of range
|
|
204
|
+
"http://127.0.0.1:port/x", # a port that is not a number
|
|
205
|
+
"http://a\u3002\u3002b/x", # a host name with an empty label
|
|
206
|
+
"http://" + "\u00e9" * 70 + "/x", # a label too long to be a name
|
|
207
|
+
"http://[::1/x", # an unclosed address
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.parametrize("location", REFUSED_LOCATIONS)
|
|
212
|
+
def test_a_location_the_url_check_refuses_is_oserror_never_valueerror(
|
|
213
|
+
http_transport_kind, location
|
|
214
|
+
):
|
|
215
|
+
raw = (
|
|
216
|
+
"HTTP/1.1 302 Found\r\nLocation: " + location + "\r\nContent-Length: 0\r\n\r\n"
|
|
217
|
+
).encode("utf-8")
|
|
218
|
+
with Wire(lambda r: Reply(raw)) as wire:
|
|
219
|
+
with HTTPClient() as client:
|
|
220
|
+
try:
|
|
221
|
+
client.request("GET", wire.url + "/f").close()
|
|
222
|
+
except ValueError:
|
|
223
|
+
raise AssertionError("a ValueError escaped") from None
|
|
224
|
+
except OSError as error:
|
|
225
|
+
# The refused reply is named by its status; `requests` reads a
|
|
226
|
+
# Location it cannot parse before this library sees the reply.
|
|
227
|
+
assert error.errno == errno.EIO
|
|
228
|
+
assert "HTTP 302" in str(error) or "could not be read" in str(error)
|
|
229
|
+
else:
|
|
230
|
+
raise AssertionError("the redirect was followed")
|
|
231
|
+
assert len(wire.requests) == 1
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""How much of a reply the library reads. A reply read whole for its status or
|
|
2
|
+
for a small document is held to `MAX_REPLY_BYTES`, counted in DECODED bytes,
|
|
3
|
+
whichever transport sent it; one nobody looks at is read up to that limit and
|
|
4
|
+
dropped; and a connection whose reply was longer is not kept."""
|
|
5
|
+
|
|
6
|
+
import errno
|
|
7
|
+
import gzip
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import pathapi.http as http
|
|
12
|
+
from pathapi.http import (
|
|
13
|
+
HTTPClient,
|
|
14
|
+
drop_body,
|
|
15
|
+
json_body,
|
|
16
|
+
read_limited,
|
|
17
|
+
)
|
|
18
|
+
from transport_wire import Wire, response, wait_until
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.usefixtures("http_transport_kind")
|
|
21
|
+
|
|
22
|
+
CAP = 64 * 1024
|
|
23
|
+
INFLATED = bytes(1024 * 1024) # 16 times the cap, a kilobyte on the wire
|
|
24
|
+
BOMB = gzip.compress(INFLATED)
|
|
25
|
+
CODED = {"Content-Encoding": "gzip"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def small_cap(monkeypatch):
|
|
30
|
+
monkeypatch.setattr(http, "MAX_REPLY_BYTES", CAP)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def long_reply(status=200, extra=None):
|
|
34
|
+
"""A reply whose body is three times the limit on the wire as well."""
|
|
35
|
+
return response(status, extra or {}, b"x" * (3 * CAP))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bomb(status=200, extra=None):
|
|
39
|
+
return response(status, {**CODED, **(extra or {})}, BOMB)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_the_setting_is_read_where_it_takes_effect():
|
|
43
|
+
from pathapi.http import _settings
|
|
44
|
+
|
|
45
|
+
assert _settings.MAX_REPLY_BYTES == CAP == http.MAX_REPLY_BYTES
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_default_limit_is_eight_mebibytes(monkeypatch):
|
|
49
|
+
monkeypatch.undo()
|
|
50
|
+
assert http.MAX_REPLY_BYTES == 8 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- replies nobody looks at ---------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_a_dropped_reply_over_the_limit_does_not_keep_its_connection():
|
|
57
|
+
with Wire(lambda r: long_reply(201)) as wire:
|
|
58
|
+
with HTTPClient() as client:
|
|
59
|
+
drop_body(
|
|
60
|
+
client.request("PUT", wire.url + "/file", data=b"data", stream=True)
|
|
61
|
+
)
|
|
62
|
+
assert wait_until(lambda: wire.closed == [0])
|
|
63
|
+
drop_body(
|
|
64
|
+
client.request("PUT", wire.url + "/file", data=b"again", stream=True)
|
|
65
|
+
)
|
|
66
|
+
assert wire.connections == 2
|
|
67
|
+
assert [r.body for r in wire.requests] == [b"data", b"again"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_a_dropped_small_reply_is_read_so_the_connection_serves_the_next_one():
|
|
71
|
+
with Wire(lambda r: response(201, {}, b"x" * 1000)) as wire:
|
|
72
|
+
with HTTPClient() as client:
|
|
73
|
+
drop_body(
|
|
74
|
+
client.request("PUT", wire.url + "/file", data=b"one", stream=True)
|
|
75
|
+
)
|
|
76
|
+
drop_body(
|
|
77
|
+
client.request("PUT", wire.url + "/file", data=b"two", stream=True)
|
|
78
|
+
)
|
|
79
|
+
assert wire.connections == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_a_coded_reply_is_dropped_without_inflating_it_whole():
|
|
83
|
+
with Wire(lambda r: bomb(201)) as wire:
|
|
84
|
+
with HTTPClient() as client:
|
|
85
|
+
for body in (b"data", b"again"):
|
|
86
|
+
reply = client.request(
|
|
87
|
+
"PUT", wire.url + "/file", data=body, stream=True
|
|
88
|
+
)
|
|
89
|
+
drop_body(reply)
|
|
90
|
+
assert [r.body for r in wire.requests] == [b"data", b"again"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_the_limit_of_a_dropped_reply_can_be_given_in_the_call():
|
|
94
|
+
with Wire(lambda r: long_reply(200)) as wire:
|
|
95
|
+
with HTTPClient() as client:
|
|
96
|
+
for _ in range(2):
|
|
97
|
+
reply = client.request("GET", wire.url + "/file", stream=True)
|
|
98
|
+
drop_body(reply, 10 * CAP)
|
|
99
|
+
assert wire.connections == 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- replies read for what they say ----------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_a_reply_over_the_limit_is_efbig_counted_in_decoded_bytes():
|
|
106
|
+
with Wire(lambda r: bomb(207)) as wire:
|
|
107
|
+
with HTTPClient() as client:
|
|
108
|
+
reply = client.request("GET", wire.url + "/dir/", stream=True)
|
|
109
|
+
with pytest.raises(OSError) as excinfo:
|
|
110
|
+
read_limited(reply, "the directory")
|
|
111
|
+
# A kilobyte on the wire, so a check of the stated length would pass.
|
|
112
|
+
assert len(BOMB) < CAP
|
|
113
|
+
assert excinfo.value.errno == errno.EFBIG
|
|
114
|
+
assert "the directory" in str(excinfo.value)
|
|
115
|
+
assert str(CAP) in str(excinfo.value)
|
|
116
|
+
assert "MAX_REPLY_BYTES" in str(excinfo.value)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_a_plain_reply_that_is_too_long_is_efbig_as_well_and_not_waited_for():
|
|
120
|
+
with Wire(lambda r: long_reply(207)) as wire:
|
|
121
|
+
with HTTPClient() as client:
|
|
122
|
+
reply = client.request("GET", wire.url + "/dir/", stream=True)
|
|
123
|
+
with pytest.raises(OSError) as excinfo:
|
|
124
|
+
read_limited(reply, "the directory")
|
|
125
|
+
# Past the limit, the rest of the body is not waited for.
|
|
126
|
+
assert wait_until(lambda: wire.closed == [0])
|
|
127
|
+
assert excinfo.value.errno == errno.EFBIG
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_a_reply_within_the_limit_is_read_and_the_connection_is_kept():
|
|
131
|
+
with Wire(lambda r: response(207, {}, b"<multistatus/>")) as wire:
|
|
132
|
+
with HTTPClient() as client:
|
|
133
|
+
for _ in range(2):
|
|
134
|
+
reply = client.request("GET", wire.url + "/dir/", stream=True)
|
|
135
|
+
assert read_limited(reply, "the directory") == b"<multistatus/>"
|
|
136
|
+
assert wire.connections == 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_the_limit_is_read_each_time_so_raising_it_lets_a_larger_reply_through(
|
|
140
|
+
monkeypatch,
|
|
141
|
+
):
|
|
142
|
+
with Wire(lambda r: response(207, {}, b" " * (2 * CAP))) as wire:
|
|
143
|
+
with HTTPClient() as client:
|
|
144
|
+
with pytest.raises(OSError):
|
|
145
|
+
read_limited(client.request("GET", wire.url + "/x", stream=True), "x")
|
|
146
|
+
monkeypatch.setattr(http, "MAX_REPLY_BYTES", 4 * CAP)
|
|
147
|
+
reply = client.request("GET", wire.url + "/x", stream=True)
|
|
148
|
+
assert len(read_limited(reply, "x")) == 2 * CAP
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_the_limit_of_a_read_can_be_given_in_the_call():
|
|
152
|
+
with Wire(lambda r: response(200, {}, b"x" * (2 * CAP))) as wire:
|
|
153
|
+
with HTTPClient() as client:
|
|
154
|
+
reply = client.request("GET", wire.url + "/x", stream=True)
|
|
155
|
+
assert len(read_limited(reply, "x", 4 * CAP)) == 2 * CAP
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_a_json_reply_over_the_limit_is_efbig():
|
|
159
|
+
with Wire(lambda r: bomb(200, {"Content-Type": "application/json"})) as wire:
|
|
160
|
+
with HTTPClient() as client:
|
|
161
|
+
reply = client.request("GET", wire.url + "/api", stream=True)
|
|
162
|
+
with pytest.raises(OSError) as excinfo:
|
|
163
|
+
json_body(reply, "the API")
|
|
164
|
+
assert excinfo.value.errno == errno.EFBIG
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_a_json_reply_within_the_limit_is_the_document():
|
|
168
|
+
with Wire(lambda r: response(200, {}, b'{"name": "n", "size": [1, 2]}')) as wire:
|
|
169
|
+
with HTTPClient() as client:
|
|
170
|
+
reply = client.request("GET", wire.url + "/api", stream=True)
|
|
171
|
+
assert json_body(reply, "the API") == {"name": "n", "size": [1, 2]}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_a_reply_that_is_not_json_is_a_value_error():
|
|
175
|
+
with Wire(lambda r: response(200, {}, b"<html>")) as wire:
|
|
176
|
+
with HTTPClient() as client:
|
|
177
|
+
reply = client.request("GET", wire.url + "/api", stream=True)
|
|
178
|
+
with pytest.raises(ValueError):
|
|
179
|
+
json_body(reply, "the API")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- the client's own reads --------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_the_client_reads_a_reply_whole_only_up_to_the_limit(http_transport):
|
|
186
|
+
with Wire(lambda r: bomb(200)) as wire:
|
|
187
|
+
client = HTTPClient(http_transport)
|
|
188
|
+
with pytest.raises(OSError) as excinfo:
|
|
189
|
+
client.request("GET", wire.url + "/x")
|
|
190
|
+
assert excinfo.value.errno == errno.EFBIG
|
|
191
|
+
assert wire.url in str(excinfo.value)
|
|
192
|
+
# A caller that reads the body itself is not limited.
|
|
193
|
+
with client.request("GET", wire.url + "/x", stream=True) as streamed:
|
|
194
|
+
assert len(streamed.read()) == len(INFLATED)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_a_reply_within_the_limit_is_returned_read_and_closed(http_transport):
|
|
198
|
+
with Wire(lambda r: response(200, {}, b"abc")) as wire:
|
|
199
|
+
reply = HTTPClient(http_transport).request("GET", wire.url + "/x")
|
|
200
|
+
assert reply.read() == b"abc"
|
|
201
|
+
assert reply.read() == b""
|