diff --git a/Tests/test_image_frombytes.py b/Tests/test_image_frombytes.py index 06b4c5b3118..f5edec2dfd0 100644 --- a/Tests/test_image_frombytes.py +++ b/Tests/test_image_frombytes.py @@ -17,3 +17,31 @@ def test_sanity(data_type: str) -> None: im2 = Image.frombytes(im1.mode, im1.size, data) assert_image_equal(im1, im2) + + +@pytest.mark.parametrize("mode", ("L", "RGBA")) +def test_readonly_buffer(mode: str) -> None: + size = (2, 2) + length = 4 * Image.getmodebands(mode) + data = bytes(bytearray(length)) + im = Image.frombuffer(mode, size, data, "raw", mode, 0, 1) + assert im.readonly + + replacement = b"\x01" * length + im.frombytes(replacement) + + assert im.tobytes() == replacement + assert data == bytes(length) + assert not im.readonly + + +def test_mutable_core_with_readonly_image() -> None: + # Plugins may call frombytes() from load() before clearing the image's flag. + im = Image.new("L", (2, 2)) + im.readonly = 1 + core = im.im + + im.frombytes(b"\x01" * 4) + + assert im.im is core + assert im.tobytes() == b"\x01" * 4 diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index b287004e010..4b498128aef 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -101,6 +101,28 @@ def test_sanity() -> None: hopper().point(t) +@pytest.mark.parametrize("in_place", (False, True)) +def test_readonly_output(in_place: bool) -> None: + original = bytes([10, 20, 30, 255] * 4) + data = bytes(bytearray(original)) + im = Image.frombuffer("RGBA", (2, 2), data, "raw", "RGBA", 0, 1) + assert im.readonly + profile = ImageCms.createProfile("sRGB") + transform = ImageCms.buildTransform(profile, profile, "RGBA", "RGBA") + + if in_place: + expected = im.copy() + result = transform.apply_in_place(im) + else: + expected = Image.new("RGBA", im.size, (90, 80, 70, 255)) + result = transform.apply(expected, im) + + assert result is im + assert_image_equal(im, expected) + assert data == original + assert not im.readonly + + def test_flags() -> None: assert ImageCms.Flags.NONE.value == 0 assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 5edc0d28693..93f3d00b038 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -49,6 +49,14 @@ TODO Other changes ============= +Copy read-only images before writing pixel data +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.frombytes` and :py:meth:`~PIL.ImageCms.ImageCmsTransform.apply` +now copy read-only image memory before writing to it. This also applies to in-place +ImageCms transforms, preventing writes into shared buffers or read-only memory-mapped +files. + Resizing I;16B and I;16N images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 40e43501081..750ab9b361c 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -947,6 +947,10 @@ def frombytes( if self.width == 0 or self.height == 0: return + # Plugins may call frombytes() during load(), before clearing self.readonly. + if self.im.readonly: + self._copy() + decoder_args: Any = args if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): # may pass tuple instead of argument list diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 3dc30a7c96f..712ac9aea17 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -317,6 +317,8 @@ def point(self, im: Image.Image) -> Image.Image: def apply(self, im: Image.Image, imOut: Image.Image | None = None) -> Image.Image: if imOut is None: imOut = Image.new(self.output_mode, im.size) + else: + imOut._ensure_mutable() self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut