diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e1d7e311..78e215f78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,6 +101,7 @@ jobs: - tests/mock_vws/test_update_target.py::TestImage::test_image_valid - tests/mock_vws/test_update_target.py::TestImage::test_bad_image_format_or_color_space - tests/mock_vws/test_update_target.py::TestImage::test_corrupted + - tests/mock_vws/test_update_target.py::TestImage::test_truncated - tests/mock_vws/test_update_target.py::TestImage::test_image_too_large - tests/mock_vws/test_update_target.py::TestImage::test_not_base64_encoded_processable - tests/mock_vws/test_update_target.py::TestImage::test_not_base64_encoded_not_processable diff --git a/newsfragments/3498.change b/newsfragments/3498.change new file mode 100644 index 000000000..e158d81cc --- /dev/null +++ b/newsfragments/3498.change @@ -0,0 +1 @@ +Return a ``BadImage`` response, rather than failing to respond, when an image given to the Target API is truncated before the end of its image data. diff --git a/src/mock_vws/_services_validators/image_validators.py b/src/mock_vws/_services_validators/image_validators.py index fadf0fea0..1d693732c 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -43,7 +43,13 @@ def validate_image_integrity(*, request_body: bytes) -> None: with open_image(fp=image_file) as pil_image: try: pil_image.verify() - except SyntaxError as exc: + except (OSError, SyntaxError) as exc: + # ``verify`` raises ``SyntaxError`` for a damaged header and + # ``OSError`` for damaged image data, such as a PNG which is + # truncated before its ``IEND`` chunk. + # ``open_image`` runs outside this ``try``, so anything which + # cannot be opened at all is already rejected by + # ``validate_image_is_image``. _LOGGER.warning(msg="The image is not a valid image file.") raise BadImageError from exc diff --git a/tests/mock_vws/test_add_target.py b/tests/mock_vws/test_add_target.py index 76f1d5efe..a595eec0a 100644 --- a/tests/mock_vws/test_add_target.py +++ b/tests/mock_vws/test_add_target.py @@ -29,6 +29,7 @@ from tests.mock_vws.utils import ( make_decompression_bomb_image_file, make_single_color_image_file, + make_truncated_png_file, ) from tests.mock_vws.utils.assertions import ( assert_vws_failure, @@ -505,6 +506,30 @@ def test_corrupted( result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod + def test_truncated(vws_client: VWS) -> None: + """ + An error is returned when the given image is truncated before + the + end of its image data. + """ + image_file = make_truncated_png_file() + + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.BAD_IMAGE, + ) + @staticmethod def test_decompression_bomb(vws_client: VWS) -> None: """ diff --git a/tests/mock_vws/test_update_target.py b/tests/mock_vws/test_update_target.py index 3109c4f9b..4574af336 100644 --- a/tests/mock_vws/test_update_target.py +++ b/tests/mock_vws/test_update_target.py @@ -25,6 +25,7 @@ from vws_test_fixtures.images import VWS_MAX_IMAGE_FILE_SIZE from mock_vws._constants import ResultCodes +from tests.mock_vws.utils import make_truncated_png_file from tests.mock_vws.utils.assertions import ( assert_vws_failure, assert_vws_response, @@ -666,6 +667,31 @@ def test_corrupted( result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod + def test_truncated( + *, + vws_client: VWS, + target_id: str, + ) -> None: + """ + An error is returned when the given image is truncated before + the + end of its image data. + """ + image_file = make_truncated_png_file() + + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=image_file, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.BAD_IMAGE, + ) + @staticmethod def test_image_too_large( *, diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 69ffb607d..3f405ca85 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -203,6 +203,27 @@ def make_single_color_image_file(*, width: int, height: int) -> io.BytesIO: return image_buffer +@beartype +def make_truncated_png_file() -> io.BytesIO: + """Return a PNG file whose image data is cut off before the end. + + The file keeps a valid signature and header, so Pillow opens it, but the + ``IEND`` chunk which marks the end of the image is missing, so decoding + the image data fails. + + Returns: + A PNG file which is truncated before its ``IEND`` chunk. + """ + image = Image.frombytes(mode="RGB", size=(1, 1), data=b"\x01\x02\x03") + image_buffer = io.BytesIO() + image.save(fp=image_buffer, format="PNG", optimize=True) + image_bytes = image_buffer.getvalue() + # Remove the ``IEND`` chunk: its four length bytes, its four type bytes + # and its four checksum bytes. + truncated = image_bytes[: image_bytes.rindex(b"IEND") - 4] + return io.BytesIO(initial_bytes=truncated) + + @beartype def make_decompression_bomb_image_file() -> io.BytesIO: """Return a PNG file which is tiny on disk but huge when decoded.