diff --git a/glance/api/v2/image_data.py b/glance/api/v2/image_data.py index 936cd0b8..03acdac9 100644 --- a/glance/api/v2/image_data.py +++ b/glance/api/v2/image_data.py @@ -18,6 +18,7 @@ from oslo_config import cfg from oslo_log import log as logging from oslo_utils import encodeutils from oslo_utils import excutils +import six import webob.exc import glance.api.policy @@ -282,17 +283,20 @@ class ResponseSerializer(wsgi.JSONResponseSerializer): def download(self, response, image): offset, chunk_size = 0, None - range_val = response.request.get_content_range() + # NOTE(dharinic): In case of a malformed content range, + # glance/common/wsgi.py will raise HTTPRequestRangeNotSatisfiable + # (setting status_code to 416) + range_val = response.request.get_content_range(image.size) if range_val: # NOTE(flaper87): if not present, both, start # and stop, will be None. - if range_val.start is not None: + if range_val.start is not None and range_val.stop is not None: offset = range_val.start - - if range_val.stop is not None: chunk_size = range_val.stop - offset + response.status_int = 206 + response.headers['Content-Type'] = 'application/octet-stream' try: @@ -301,6 +305,11 @@ class ResponseSerializer(wsgi.JSONResponseSerializer): # an iterator very strange response.app_iter = iter(image.get_data(offset=offset, chunk_size=chunk_size)) + # NOTE(dharinic): In case of a full image download, when + # chunk_size was none, reset it to image.size to set the + # response header's Content-Length. + if not chunk_size: + chunk_size = image.size except glance_store.NotFound as e: raise webob.exc.HTTPNoContent(explanation=e.msg) except glance_store.RemoteServiceUnavailable as e: @@ -318,7 +327,7 @@ class ResponseSerializer(wsgi.JSONResponseSerializer): response.headers['Content-MD5'] = image.checksum # NOTE(markwash): "response.app_iter = ..." also erroneously resets the # content-length - response.headers['Content-Length'] = str(image.size) + response.headers['Content-Length'] = six.text_type(chunk_size) def upload(self, response, result): response.status_int = 204 diff --git a/glance/common/wsgi.py b/glance/common/wsgi.py index a1198e1f..f9faf56a 100644 --- a/glance/common/wsgi.py +++ b/glance/common/wsgi.py @@ -972,14 +972,17 @@ class Request(webob.Request): langs = i18n.get_available_languages('glance') return self.accept_language.best_match(langs) - def get_content_range(self): + def get_content_range(self, image_size): """Return the `Range` in a request.""" range_str = self.headers.get('Content-Range') if range_str is not None: range_ = webob.byterange.ContentRange.parse(range_str) - if range_ is None: + # NOTE(dharinic): Ensure that a range like 1-4/* for an image + # size of 3 is invalidated. + if range_ is None or (range_.length is None and + range_.stop > image_size): msg = _('Malformed Content-Range header: %s') % range_str - raise webob.exc.HTTPBadRequest(explanation=msg) + raise webob.exc.HTTPRequestRangeNotSatisfiable(explanation=msg) return range_ diff --git a/glance/tests/functional/v2/test_images.py b/glance/tests/functional/v2/test_images.py index dee4b8fa..f726d976 100644 --- a/glance/tests/functional/v2/test_images.py +++ b/glance/tests/functional/v2/test_images.py @@ -771,10 +771,18 @@ class TestImages(functional.FunctionalTest): headers = self._headers({'Content-Range': content_range}) path = self._url('/v2/images/%s/file' % image_id) response = requests.get(path, headers=headers) + self.assertEqual(206, response.status_code) result_body += response.text self.assertEqual(result_body, image_data) + # test for failure on unsatisfiable request range. + content_range = 'bytes 3-16/15' + headers = self._headers({'Content-Range': content_range}) + path = self._url('/v2/images/%s/file' % image_id) + response = requests.get(path, headers=headers) + self.assertEqual(416, response.status_code) + self.stop_servers() def test_download_policy_when_cache_is_not_enabled(self): diff --git a/glance/tests/unit/common/test_wsgi.py b/glance/tests/unit/common/test_wsgi.py index e152f2d9..03a6b8bc 100644 --- a/glance/tests/unit/common/test_wsgi.py +++ b/glance/tests/unit/common/test_wsgi.py @@ -67,7 +67,7 @@ class RequestTest(test_utils.BaseTestCase): def test_content_range(self): request = wsgi.Request.blank('/tests/123') request.headers["Content-Range"] = 'bytes 10-99/*' - range_ = request.get_content_range() + range_ = request.get_content_range(120) self.assertEqual(10, range_.start) self.assertEqual(100, range_.stop) # non-inclusive self.assertIsNone(range_.length) @@ -75,8 +75,8 @@ class RequestTest(test_utils.BaseTestCase): def test_content_range_invalid(self): request = wsgi.Request.blank('/tests/123') request.headers["Content-Range"] = 'bytes=0-99' - self.assertRaises(webob.exc.HTTPBadRequest, - request.get_content_range) + self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable, + request.get_content_range, 120) def test_content_type_missing(self): request = wsgi.Request.blank('/tests/123') diff --git a/glance/tests/unit/v2/test_image_data_resource.py b/glance/tests/unit/v2/test_image_data_resource.py index ef514725..f638851c 100644 --- a/glance/tests/unit/v2/test_image_data_resource.py +++ b/glance/tests/unit/v2/test_image_data_resource.py @@ -548,6 +548,54 @@ class TestImageDataSerializer(test_utils.BaseTestCase): self.assertEqual('application/octet-stream', response.headers['Content-Type']) + def _test_partial_download_successful(self, d_range): + request = wsgi.Request.blank('/') + request.environ = {} + request.headers['Content-Range'] = d_range + response = webob.Response() + response.request = request + image = FakeImage(size=3, data=[b'Z', b'Z', b'Z']) + self.serializer.download(response, image) + self.assertEqual(206, response.status_code) + self.assertEqual('2', response.headers['Content-Length']) + + def test_partial_download_successful_with_range(self): + self._test_partial_download_successful('bytes 1-2/3') + self._test_partial_download_successful('bytes 1-2/*') + + def _test_partial_download_failures(self, d_range): + request = wsgi.Request.blank('/') + request.environ = {} + request.headers['Content-Range'] = d_range + response = webob.Response() + response.request = request + image = FakeImage(size=3, data=[b'Z', b'Z', b'Z']) + self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable, + self.serializer.download, + response, image) + return + + def test_partial_download_failure_with_range(self): + self._test_partial_download_failures('bytes 1-4/3') + self._test_partial_download_failures('bytes 1-4/*') + self._test_partial_download_failures('bytes 4-1/3') + self._test_partial_download_failures('bytes 4-1/*') + + def test_download_failure_with_valid_range(self): + with mock.patch.object(glance.api.policy.ImageProxy, + 'get_data') as mock_get_data: + mock_get_data.side_effect = glance_store.NotFound(image="image") + request = wsgi.Request.blank('/') + request.environ = {} + request.headers['Content-Range'] = 'bytes %s-%s/3' % (1, 2) + response = webob.Response() + response.request = request + image = FakeImage(size=3, data=[b'Z', b'Z', b'Z']) + image.get_data = mock_get_data + self.assertRaises(webob.exc.HTTPNoContent, + self.serializer.download, + response, image) + def test_download_with_checksum(self): request = wsgi.Request.blank('/') request.environ = {}