Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 298
ngclient: Prevent automatic decoding of gzip files#2048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
c6857e072fd5e9eeba7c7a4b390e7a5862ef4195c7520112b6952b7313dafdd382cf48cc08e49906b2a7File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -7,16 +7,19 @@ | ||
| """ | ||
| import io | ||
| import json | ||
| import logging | ||
| import math | ||
| import os | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
| from functools import partialmethod | ||
| from typing import Any, ClassVar, Iterator | ||
| from unittest.mock import Mock, patch | ||
| import requests | ||
| from urllib3.exceptions import ReadTimeoutError | ||
| from tests import utils | ||
| from tuf.api import exceptions | ||
| @@ -94,6 +97,40 @@ def test_fetch_in_chunks(self) -> None: | ||
| # Check that we calculate chunks as expected | ||
| self.assertEqual(chunks_count, expected_chunks_count) | ||
| # Fetch data with Content-Encoding gzip (or deflate) | ||
| def test_fetch_content_encoding(self) -> None: | ||
| """ | ||
| Regression test for issue #2047 | ||
| By default, requests/urllib3 will automatically try to decode data | ||
| that is served with a "Content-Encoding: gzip" header (or "deflate"). | ||
| As the length of the decoded data typically would be different from | ||
| the expected number of bytes specified in the TUF targets metadata, | ||
| this would give rise to a DownloadLengthMismatchError later on. Thus, | ||
| we need to ensure that a file served with the "Content-Encoding" | ||
| header is *not* decoded by the RequestsFetcher. | ||
| """ | ||
| # Serve dummy file with "Content-Encoding: gzip" header | ||
| content_encoding_header = json.dumps({"Content-Encoding": "gzip"}) | ||
| headers = {utils.DESIRED_RESPONSE_HEADERS: content_encoding_header} | ||
| get_with_headers = partialmethod(requests.Session.get, headers=headers) | ||
| target = "tuf.ngclient._internal.requests_fetcher.requests.Session.get" | ||
| # The test file content does not represent a real gzip file, | ||
| # so we can expect an error to be raised if requests/urllib3 tries to | ||
| # decode the file (urllib3 uses zlib for this). | ||
Comment on lines
+119
to
+120
Collaborator There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have you tried running this script before replacing
| ||
| try: | ||
| with tempfile.TemporaryFile() as temp_file: | ||
| with patch(target, get_with_headers): | ||
| for chunk in self.fetcher.fetch(self.url): | ||
| temp_file.write(chunk) | ||
| temp_file.seek(0) | ||
| fetched_data = temp_file.read() | ||
| except requests.exceptions.ContentDecodingError as e: | ||
| self.fail(f"fetch() raised unexpected decoding error: {e}") | ||
| # If all is well, decoding has *not* been attempted, and the fetched | ||
| # data matches the original file contents | ||
| self.assertEqual(self.file_contents, fetched_data) | ||
| # Incorrect URL parsing | ||
| def test_url_parsing(self) -> None: | ||
| with self.assertRaises(exceptions.DownloadError): | ||
| @@ -109,18 +146,18 @@ def test_http_error(self) -> None: | ||
| # Response read timeout error | ||
| @patch.object(requests.Session, "get") | ||
| def test_response_read_timeout(self, mock_session_get: Any) -> None: | ||
| mock_response = Mock() | ||
| mock_response = Mock(raw=Mock()) | ||
| attr = { | ||
| "iter_content.side_effect": requests.exceptions.ConnectionError( | ||
| "Simulated timeout" | ||
| "read.side_effect": ReadTimeoutError( | ||
| None, None, "Simulated timeout" | ||
| ) | ||
| } | ||
| mock_response.configure_mock(**attr) | ||
| mock_response.raw.configure_mock(**attr) | ||
| mock_session_get.return_value = mock_response | ||
| with self.assertRaises(exceptions.SlowRetrievalError): | ||
| next(self.fetcher.fetch(self.url)) | ||
| mock_response.iter_content.assert_called_once() | ||
| mock_response.raw.read.assert_called_once() | ||
| # Read/connect session timeout error | ||
| @patch.object( | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -11,6 +11,7 @@ | ||
| # Imports | ||
| import requests | ||
| from urllib3.exceptions import ReadTimeoutError | ||
| import tuf | ||
| from tuf.api import exceptions | ||
| @@ -19,6 +20,7 @@ | ||
| # Globals | ||
| logger = logging.getLogger(__name__) | ||
Collaborator There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure we need that. Author There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I got a PEP8 warning there: "expecting 2 blank lines" | ||
| # Classes | ||
| class RequestsFetcher(FetcherInterface): | ||
| """An implementation of ``FetcherInterface`` based on the requests library. | ||
| @@ -96,12 +98,16 @@ def _chunks(self, response: "requests.Response") -> Iterator[bytes]: | ||
| download.""" | ||
| try: | ||
| for data in response.iter_content(self.chunk_size): | ||
| while True: | ||
Author There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternatively, we could perhaps replace this yieldfromresponse.raw.stream(amt=self.chunk_size, decode_content=False)See Author There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. | ||
| # Requests already calls `urlopen(..., decode_content=False)`, | ||
| # but we explicitly disable decode_content here, to be safe. | ||
| data = response.raw.read( | ||
| amt=self.chunk_size, decode_content=False | ||
| ) | ||
| if not data: | ||
| break | ||
| yield data | ||
| except ( | ||
| requests.exceptions.ConnectionError, | ||
| requests.exceptions.Timeout, | ||
| ) as e: | ||
| except ReadTimeoutError as e: | ||
| raise exceptions.SlowRetrievalError from e | ||
| finally: | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I am new to
functoolsandpartialmethodwhat is the bonus of creating apartialmethodobject instead of just callingresponse.Session.getdirectly in line 123 with the headers you want?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling
response.Session.getdirectly with theheadersargument would certainly simplify things, but, in this case, I wanted to testRequestsFetcher.fetch. This implies thatresponse.Session.getis called indirectly, viz. through RequestsFetcher._fetch.So, instead of calling
response.Session.getdirectly, I patch it to make sure it is called with the desiredheadersargument. Basically, I usefunctools.partialmethodto supply a default value forheaders. Now, whenRequestsFetcher._fetchcallsget, theheadersarg is automatically set.I am aware
patchalso accepts additional kwargs, but I believe these are only applied ifpatchcreates aMockobject.If there is a simpler way to do this, I am all for it. :)