|
8 | 8 |
|
9 | 9 | import os |
10 | 10 | import io |
| 11 | +import mmap |
11 | 12 | import ftplib |
12 | 13 | import socket |
13 | 14 | import array |
|
18 | 19 | import urllib.request |
19 | 20 | # The proxy bypass method imported below has logic specific to the |
20 | 21 | # corresponding system but is testable on all platforms. |
21 | | -from urllib.request import (Request, OpenerDirector, HTTPBasicAuthHandler, |
22 | | - HTTPPasswordMgrWithPriorAuth, _parse_proxy, |
23 | | - _proxy_bypass_winreg_override, |
24 | | - _proxy_bypass_macosx_sysconf, |
| 22 | +from urllib.request import (HTTPDefaultErrorHandler, HTTPErrorProcessor, |
| 23 | + Request, OpenerDirector, HTTPBasicAuthHandler, |
| 24 | + HTTPPasswordMgrWithPriorAuth, |
| 25 | + _parse_proxy, _proxy_bypass_winreg_override, |
| 26 | + _proxy_bypass_macosx_sysconf, _rewind_auth_retry_body, |
25 | 27 | AbstractDigestAuthHandler) |
26 | 28 | from urllib.parse import urlsplit |
27 | 29 | import urllib.error |
@@ -599,6 +601,113 @@ def http_open(self, req): |
599 | 601 | return MockResponse(self.code, name, MockFile(), "", req.get_full_url()) |
600 | 602 |
|
601 | 603 |
|
| 604 | +class _AuthRetrySocket: |
| 605 | + def __init__(self, response, sent_requests): |
| 606 | + self._response = io.BytesIO(response) |
| 607 | + self._sent_requests = sent_requests |
| 608 | + self._closed = False |
| 609 | + |
| 610 | + def sendall(self, data): |
| 611 | + self._sent_requests.append(data) |
| 612 | + |
| 613 | + def makefile(self, mode='r', buffering=None, *, encoding=None, errors=None): |
| 614 | + return self |
| 615 | + |
| 616 | + def read(self, amt=-1): |
| 617 | + if self._closed: |
| 618 | + return b"" |
| 619 | + return self._response.read(amt) |
| 620 | + |
| 621 | + def readline(self, length=-1): |
| 622 | + if self._closed: |
| 623 | + return b"" |
| 624 | + return self._response.readline(length) |
| 625 | + |
| 626 | + def flush(self): |
| 627 | + self._response.flush() |
| 628 | + |
| 629 | + def close(self): |
| 630 | + self._closed = True |
| 631 | + |
| 632 | +def _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, data): |
| 633 | + conn_cls.sent_requests.clear() |
| 634 | + conn_cls._request_count = 0 |
| 635 | + opener = OpenerDirector() |
| 636 | + opener.add_handler(auth_handler) |
| 637 | + opener.add_handler(http_handler_cls()) |
| 638 | + opener.add_handler(HTTPErrorProcessor()) |
| 639 | + opener.add_handler(HTTPDefaultErrorHandler()) |
| 640 | + opener.open(Request(url, data, method="POST")) |
| 641 | + return conn_cls.sent_requests |
| 642 | + |
| 643 | +def _decode_chunked_http_body(raw): |
| 644 | + body = raw |
| 645 | + result = b"" |
| 646 | + while body: |
| 647 | + size_line, sep, body = body.partition(b"\r\n") |
| 648 | + if not sep: |
| 649 | + break |
| 650 | + size = int(size_line.split(b";", 1)[0], 16) |
| 651 | + if size == 0: |
| 652 | + break |
| 653 | + result += body[:size] |
| 654 | + body = body[size+2:] |
| 655 | + return result |
| 656 | + |
| 657 | +def _group_sent_by_request(sent): |
| 658 | + groups = [] |
| 659 | + current = [] |
| 660 | + for chunk in sent: |
| 661 | + if chunk.startswith(b"POST ") and current: |
| 662 | + groups.append(current) |
| 663 | + current = [chunk] |
| 664 | + else: |
| 665 | + current.append(chunk) |
| 666 | + |
| 667 | + if current: |
| 668 | + groups.append(current) |
| 669 | + return groups |
| 670 | + |
| 671 | +def _body_from_request_chunks(chunks): |
| 672 | + return _body_from_sent_http_request(b"".join(chunks)) |
| 673 | + |
| 674 | +def _body_from_sent_http_request(raw): |
| 675 | + _, sep, body = raw.partition(b"\r\n\r\n") |
| 676 | + if not sep: |
| 677 | + return b"" |
| 678 | + header_block = raw[:raw.index(sep)].lower() |
| 679 | + if b"transfer-encoding: chunked" in header_block: |
| 680 | + return _decode_chunked_http_body(body) |
| 681 | + if b"content-length: 0" in header_block: |
| 682 | + return b"" |
| 683 | + return body.split(b"\r\n", 1)[0] |
| 684 | + |
| 685 | +def _make_auth_retry_http_connection(responses): |
| 686 | + class AuthRetryHTTPConnection(http.client.HTTPConnection): |
| 687 | + sent_requests = [] |
| 688 | + _request_count = 0 |
| 689 | + |
| 690 | + def connect(self): |
| 691 | + request_index = type(self)._request_count |
| 692 | + type(self)._request_count += 1 |
| 693 | + response = responses[request_index] |
| 694 | + self.sock = _AuthRetrySocket(response, type(self).sent_requests) |
| 695 | + |
| 696 | + return AuthRetryHTTPConnection |
| 697 | + |
| 698 | +def _make_auth_retry_http_handler(responses): |
| 699 | + conn_cls = _make_auth_retry_http_connection(responses) |
| 700 | + |
| 701 | + class AuthRetryHTTPHandler(urllib.request.HTTPHandler): |
| 702 | + def __init__(self): |
| 703 | + super().__init__() |
| 704 | + self.httpconn = conn_cls |
| 705 | + |
| 706 | + def http_open(self, req): |
| 707 | + return self.do_open(self.httpconn, req) |
| 708 | + |
| 709 | + return AuthRetryHTTPHandler, conn_cls |
| 710 | + |
602 | 711 |
|
603 | 712 | class MockPasswordManager: |
604 | 713 | def add_password(self, realm, uri, user, password): |
@@ -1921,6 +2030,202 @@ def test_basic_prior_auth_send_after_first_success(self): |
1921 | 2030 | # expect request to be sent with auth header |
1922 | 2031 | self.assertTrue(http_handler.has_auth_header) |
1923 | 2032 |
|
| 2033 | + def test_rewind_auth_retry_body_no_data(self): |
| 2034 | + req = Request("http://example.com/") |
| 2035 | + self.assertTrue(_rewind_auth_retry_body(req)) |
| 2036 | + |
| 2037 | + def test_rewind_auth_retry_body_non_file_like(self): |
| 2038 | + req = Request("http://example.com/", b"abc") |
| 2039 | + self.assertTrue(_rewind_auth_retry_body(req)) |
| 2040 | + |
| 2041 | + def test_rewind_auth_retry_body(self): |
| 2042 | + data = io.BytesIO(b"xxxabc") |
| 2043 | + data.seek(3) |
| 2044 | + req = Request("http://example.com/", data) |
| 2045 | + req._body_start_pos = 3 |
| 2046 | + data.read() |
| 2047 | + self.assertTrue(_rewind_auth_retry_body(req)) |
| 2048 | + self.assertEqual(data.tell(), 3) |
| 2049 | + self.assertEqual(data.read(), b"abc") |
| 2050 | + |
| 2051 | + def test_rewind_auth_retry_body_non_seekable(self): |
| 2052 | + class NonSeekableReader: |
| 2053 | + def read(self, n=-1): |
| 2054 | + return b"abc" |
| 2055 | + |
| 2056 | + req = Request("http://example.com", NonSeekableReader()) |
| 2057 | + self.assertFalse(_rewind_auth_retry_body(req)) |
| 2058 | + |
| 2059 | + def test_basic_auth_retry_file_body(self): |
| 2060 | + realm = "acme" |
| 2061 | + url = "http://example.com/" |
| 2062 | + body = b"basic auth retry file body" |
| 2063 | + auth_401 = ( |
| 2064 | + b"HTTP/1.1 401 Unauthorized\r\n" |
| 2065 | + b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n' |
| 2066 | + b"Connection: close\r\n\r\n" |
| 2067 | + ) |
| 2068 | + auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" |
| 2069 | + http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200]) |
| 2070 | + |
| 2071 | + password_manager = MockPasswordManager() |
| 2072 | + password_manager.add_password(realm, url, "user", "pass") |
| 2073 | + auth_handler = HTTPBasicAuthHandler(password_manager) |
| 2074 | + |
| 2075 | + sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, io.BytesIO(body)) |
| 2076 | + groups = _group_sent_by_request(sent) |
| 2077 | + self.assertEqual(len(groups), 2) |
| 2078 | + self.assertEqual(_body_from_request_chunks(groups[0]), body) |
| 2079 | + self.assertEqual(_body_from_request_chunks(groups[1]), body) |
| 2080 | + |
| 2081 | + def test_basic_auth_retry_file_body_from_nonzero_offset(self): |
| 2082 | + # The retry must resume from the position the body was in before |
| 2083 | + # the original request was sent, not from the start of the stream. |
| 2084 | + realm = "acme" |
| 2085 | + url = "http://example.com/" |
| 2086 | + prefix = b"SKIPME|" |
| 2087 | + body = b"basic auth retry file body" |
| 2088 | + auth_401 = ( |
| 2089 | + b"HTTP/1.1 401 Unauthorized\r\n" |
| 2090 | + b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n' |
| 2091 | + b"Connection: close\r\n\r\n" |
| 2092 | + ) |
| 2093 | + auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" |
| 2094 | + http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200]) |
| 2095 | + |
| 2096 | + password_manager = MockPasswordManager() |
| 2097 | + password_manager.add_password(realm, url, "user", "pass") |
| 2098 | + auth_handler = HTTPBasicAuthHandler(password_manager) |
| 2099 | + |
| 2100 | + data = io.BytesIO(prefix + body) |
| 2101 | + data.seek(len(prefix)) |
| 2102 | + sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, data) |
| 2103 | + groups = _group_sent_by_request(sent) |
| 2104 | + self.assertEqual(len(groups), 2) |
| 2105 | + self.assertEqual(_body_from_request_chunks(groups[0]), body) |
| 2106 | + self.assertEqual(_body_from_request_chunks(groups[1]), body) |
| 2107 | + |
| 2108 | + def test_basic_auth_retry_mmap_body(self): |
| 2109 | + realm = "acme" |
| 2110 | + url = "http://www.example.com/" |
| 2111 | + body = b"mmap auth retry file body" |
| 2112 | + auth_401 = ( |
| 2113 | + b"HTTP/1.1 401 Unauthorized\r\n" |
| 2114 | + b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n' |
| 2115 | + b"Connection: close\r\n\r\n" |
| 2116 | + ) |
| 2117 | + auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" |
| 2118 | + http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200]) |
| 2119 | + password_manager = MockPasswordManager() |
| 2120 | + password_manager.add_password(realm, url, "user", "pass") |
| 2121 | + auth_handler = HTTPBasicAuthHandler(password_manager) |
| 2122 | + |
| 2123 | + with tempfile.NamedTemporaryFile() as tmp: |
| 2124 | + tmp.write(body) |
| 2125 | + tmp.flush() |
| 2126 | + with mmap.mmap(tmp.fileno(), 0, access=mmap.ACCESS_READ) as mm: |
| 2127 | + sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, mm) |
| 2128 | + |
| 2129 | + groups = _group_sent_by_request(sent) |
| 2130 | + self.assertEqual(len(groups), 2) |
| 2131 | + self.assertEqual(_body_from_request_chunks(groups[0]), body) |
| 2132 | + self.assertEqual(_body_from_request_chunks(groups[1]), body) |
| 2133 | + |
| 2134 | + def test_digest_auth_retry_file_body(self): |
| 2135 | + realm = "acme" |
| 2136 | + url = "http://example.com/" |
| 2137 | + body = b"digest auth retry file body" |
| 2138 | + auth_401 = ( |
| 2139 | + b"HTTP/1.1 401 Unauthorized\r\n" |
| 2140 | + b'WWW-Authenticate: Digest realm="' + realm.encode() + b'", ' |
| 2141 | + b'qop="auth", nonce="abc", opaque="def"\r\n' |
| 2142 | + b"Connection: close\r\n\r\n" |
| 2143 | + ) |
| 2144 | + auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" |
| 2145 | + http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200]) |
| 2146 | + |
| 2147 | + password_manager = MockPasswordManager() |
| 2148 | + password_manager.add_password(realm, url, "user", "pass") |
| 2149 | + auth_handler = urllib.request.HTTPDigestAuthHandler(password_manager) |
| 2150 | + |
| 2151 | + sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, io.BytesIO(body)) |
| 2152 | + groups = _group_sent_by_request(sent) |
| 2153 | + self.assertEqual(len(groups), 2) |
| 2154 | + self.assertEqual(_body_from_request_chunks(groups[0]), body) |
| 2155 | + self.assertEqual(_body_from_request_chunks(groups[1]), body) |
| 2156 | + |
| 2157 | + def test_basic_auth_retry_explicit_content_length(self): |
| 2158 | + realm = "acme" |
| 2159 | + url = "http://example.com/" |
| 2160 | + body = b"x" * 30 |
| 2161 | + auth_401 = ( |
| 2162 | + b"HTTP/1.1 401 Unauthorized\r\n" |
| 2163 | + b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n' |
| 2164 | + b"Connection: close\r\n\r\n" |
| 2165 | + ) |
| 2166 | + auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" |
| 2167 | + http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200]) |
| 2168 | + |
| 2169 | + password_manager = MockPasswordManager() |
| 2170 | + password_manager.add_password(realm, url, "user", "pass") |
| 2171 | + auth_handler = HTTPBasicAuthHandler(password_manager) |
| 2172 | + |
| 2173 | + conn_cls.sent_requests.clear() |
| 2174 | + conn_cls._request_count = 0 |
| 2175 | + opener = OpenerDirector() |
| 2176 | + opener.add_handler(auth_handler) |
| 2177 | + opener.add_handler(http_handler_cls()) |
| 2178 | + opener.add_handler(HTTPErrorProcessor()) |
| 2179 | + opener.add_handler(HTTPDefaultErrorHandler()) |
| 2180 | + |
| 2181 | + req = Request(url, io.BytesIO(body), {"Content-Length": "30"}) |
| 2182 | + opener.open(req) |
| 2183 | + sent = conn_cls.sent_requests |
| 2184 | + |
| 2185 | + groups = _group_sent_by_request(sent) |
| 2186 | + self.assertEqual(len(groups), 2) |
| 2187 | + self.assertEqual(_body_from_request_chunks(groups[0]), body) |
| 2188 | + self.assertEqual(_body_from_request_chunks(groups[1]), body) |
| 2189 | + |
| 2190 | + def test_basic_auth_retry_non_seekable_body(self): |
| 2191 | + realm = "acme" |
| 2192 | + url = "http://example.com/" |
| 2193 | + auth_401 = ( |
| 2194 | + b"HTTP/1.1 401 Unauthorized\r\n" |
| 2195 | + b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n' |
| 2196 | + b"Connection: close\r\n\r\n" |
| 2197 | + ) |
| 2198 | + auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" |
| 2199 | + http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200]) |
| 2200 | + |
| 2201 | + password_manager = MockPasswordManager() |
| 2202 | + password_manager.add_password(realm, url, "user", "pass") |
| 2203 | + auth_handler = HTTPBasicAuthHandler(password_manager) |
| 2204 | + |
| 2205 | + class NonSeekableReader: |
| 2206 | + _data = b"pipe-data" |
| 2207 | + |
| 2208 | + def read(self, n=-1): |
| 2209 | + if self._data is None: |
| 2210 | + return b"" |
| 2211 | + chunk = self._data |
| 2212 | + self._data = None |
| 2213 | + return chunk |
| 2214 | + |
| 2215 | + opener = OpenerDirector() |
| 2216 | + opener.add_handler(auth_handler) |
| 2217 | + opener.add_handler(http_handler_cls()) |
| 2218 | + opener.add_handler(HTTPErrorProcessor()) |
| 2219 | + opener.add_handler(HTTPDefaultErrorHandler()) |
| 2220 | + |
| 2221 | + req = Request(url, NonSeekableReader(), method="POST") |
| 2222 | + with self.assertRaises(urllib.error.HTTPError) as cm: |
| 2223 | + opener.open(req) |
| 2224 | + self.addCleanup(cm.exception.close) |
| 2225 | + self.assertEqual(cm.exception.code, 401) |
| 2226 | + # The retry was abandoned rather than resending a partial body. |
| 2227 | + self.assertEqual(len(_group_sent_by_request(conn_cls.sent_requests)), 1) |
| 2228 | + |
1924 | 2229 | def test_http_closed(self): |
1925 | 2230 | """Test the connection is cleaned up when the response is closed""" |
1926 | 2231 | for (transfer, data) in ( |
|
0 commit comments