From c2b67a4fd91d56a12847dee1079b023cc7a76e82 Mon Sep 17 00:00:00 2001 From: tongwentao Date: Tue, 20 Oct 2026 15:29:57 +0800 Subject: [PATCH 1/2] Copy read-only image memory before frombytes and ImageCms writes --- Tests/test_image_frombytes.py | 28 ++++++++++++++++++++++++++++ Tests/test_imagecms.py | 22 ++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 8 ++++++++ src/PIL/Image.py | 4 ++++ src/PIL/ImageCms.py | 2 ++ 5 files changed, 64 insertions(+) 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 140bcbe3115..bf0d02ac006 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. + Blurring I;16 images ^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index cefe6f1fc96..2ef9a17fec9 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -946,6 +946,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 From 05336667d333279af4266ef59eae20d1dde1a162 Mon Sep 17 00:00:00 2001 From: tongwentao Date: Tue, 20 Oct 2026 16:12:06 +0800 Subject: [PATCH 2/2] Fix release note heading underline --- docs/releasenotes/13.1.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index bf0d02ac006..cf2ca4bf4d7 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -50,7 +50,7 @@ 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