--- Begin Message ---
Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected], [email protected]
Control: affects -1 + src:psd-tools
User: [email protected]
Usertags: pu
* CVE-2026-27809: Compression module vulnerabilities (Closes: #1129098)
diffstat for psd-tools-1.10.7+dfsg.1 psd-tools-1.10.7+dfsg.1
changelog | 7
patches/0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch | 596
++++++++++
patches/series | 1
3 files changed, 604 insertions(+)
diff -Nru psd-tools-1.10.7+dfsg.1/debian/changelog
psd-tools-1.10.7+dfsg.1/debian/changelog
--- psd-tools-1.10.7+dfsg.1/debian/changelog 2025-04-14 07:35:59.000000000
+0300
+++ psd-tools-1.10.7+dfsg.1/debian/changelog 2026-06-21 20:06:58.000000000
+0300
@@ -1,3 +1,10 @@
+psd-tools (1.10.7+dfsg.1-1+deb13u1) trixie; urgency=medium
+
+ * Non-maintainer upload.
+ * CVE-2026-27809: Compression module vulnerabilities (Closes: #1129098)
+
+ -- Adrian Bunk <[email protected]> Sun, 21 Jun 2026 20:06:58 +0300
+
psd-tools (1.10.7+dfsg.1-1) unstable; urgency=low
* New upstream release.
diff -Nru
psd-tools-1.10.7+dfsg.1/debian/patches/0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch
psd-tools-1.10.7+dfsg.1/debian/patches/0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch
---
psd-tools-1.10.7+dfsg.1/debian/patches/0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch
1970-01-01 02:00:00.000000000 +0200
+++
psd-tools-1.10.7+dfsg.1/debian/patches/0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch
2026-06-21 20:06:58.000000000 +0300
@@ -0,0 +1,596 @@
+From 507976f67adf6b4b3315f555098c964f0c00fb9e Mon Sep 17 00:00:00 2001
+From: Kota Yamaguchi <[email protected]>
+Date: Tue, 24 Feb 2026 10:47:06 +0900
+Subject: Fix compression security issues (GHSA-24p2-j2jr-386w) (#549)
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+* Fix crash on invalid RLE compression in decompress()
+
+When a PSD file contains malformed RLE-compressed image data (e.g. a
+literal run that extends past the expected row size), decode_rle() raises
+ValueError which propagated all the way to the user, crashing
+psd.composite() and psd-tools export.
+
+decompress() already had a fallback that replaces failed channels with
+black pixels when result is None, but it never triggered because the
+ValueError from decode_rle() was not caught. Wrap the decode_rle() call
+in a try/except so the existing fallback handles the error gracefully.
+
+Fixes #544
+
+Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
+
+* Make RLE decoder tolerant of non-standard PackBits encoding
+
+Investigation of issue #544 revealed four patterns in the wild that
+Photoshop decodes gracefully but the previous strict decoder rejected:
+
+1. Overflow runs (421 rows): a repeat or copy run whose output would
+ exceed row_size. Decoder now clips to the remaining output space.
+
+2. Truncated copy runs (238 rows): input stream shorter than the
+ declared run length. Decoder copies available bytes and zero-pads.
+
+3. Lone repeat header at end of row (4 rows): Cython decoder crashed
+ with IndexError (uncaught by the ValueError safety net). Fixed by
+ guarding data[i] access for both repeat and copy run headers.
+
+4. Short output rows (8 rows): all input consumed before row_size bytes
+ decoded. Pre-allocated zero-filled buffer handles this for free.
+
+Both rle.py and _rle.pyx are updated with matching logic; the Cython
+extension is rebuilt. The safety-net except in decompress() now also
+catches IndexError and emits a structured logger.warning instead of the
+previous silent swallow.
+
+Tests: renamed test_malicious → test_tolerant_decode with correct
+expected values; added five new low-level decode() tolerance tests;
+updated test_decompress_invalid_rle_fallback_to_black to assert the
+correct clipped output now that the decoder succeeds instead of falling
+back to black.
+
+Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
+
+* Fix compression security issues (GHSA-24p2-j2jr-386w)
+
+Address five remaining findings from the security review of the
+compression module:
+
+1. ZIP bomb protection: replace bare zlib.decompress() with a new
+ _safe_zlib_decompress() helper that uses zlib.decompressobj with
+ a hard max_length cap. Decompressed output exceeding the expected
+ channel size now falls back to a black channel (consistent with the
+ existing tolerant RLE behaviour) instead of exhausting memory.
+
+2. Dimension bounds validation: decompress() now validates width,
+ height, and depth against Adobe-spec limits before any allocation
+ (width/height in [1, 300000], depth in {1, 8, 16, 32}).
+
+3. assert -> raise: the 'assert len(result) == length' guard is
+ replaced with an explicit 'if … raise ValueError' so it cannot be
+ silently disabled with python -O.
+
+4. PSDDecompressionWarning: a new UserWarning subclass is emitted
+ (alongside the existing logger.warning) whenever any codec falls
+ back to a black channel. Re-exported from psd_tools.__init__ so
+ callers can filter or escalate it via warnings.filterwarnings.
+
+5. Cython type fix (_rle.pyx): all loop indices in decode() changed
+ from 'cdef int' to 'cdef Py_ssize_t', eliminating the signed-int /
+ Py_ssize_t mismatch that caused undefined behaviour for row sizes
+ > INT_MAX. encode() zero-length branch now returns the explicit
+ empty std::string instead of relying on implicit memoryview coercion.
+
+Dev: add cython and setuptools to the dev dependency group so the
+Cython extension can be rebuilt locally.
+
+Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
+
+* style: apply ruff formatting to compression/__init__.py
+
+Wrap long _warn_decompress_failure call for ZIP_WITH_PREDICTION to
+satisfy the line-length rule that failed CI.
+
+Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
+
+---------
+
+Co-authored-by: Claude Sonnet 4.6 <[email protected]>
+---
+ src/psd_tools/__init__.py | 3 +-
+ src/psd_tools/compression/__init__.py | 101 ++++++++++++++--
+ src/psd_tools/compression/_rle.pyx | 48 ++++----
+ src/psd_tools/compression/rle.py | 38 +++---
+ .../psd_tools/compression/test_compression.py | 113 ++++++++++++++++++
+ tests/psd_tools/compression/test_rle.py | 34 +++---
+ 6 files changed, 273 insertions(+), 64 deletions(-)
+
+diff --git a/src/psd_tools/__init__.py b/src/psd_tools/__init__.py
+index ccfdcc5..a556666 100644
+--- a/src/psd_tools/__init__.py
++++ b/src/psd_tools/__init__.py
+@@ -1,4 +1,5 @@
+ from psd_tools.api.psd_image import PSDImage
++from psd_tools.compression import PSDDecompressionWarning
+ from psd_tools.version import __version__
+
+-__all__ = ["PSDImage", "__version__"]
++__all__ = ["PSDImage", "PSDDecompressionWarning", "__version__"]
+diff --git a/src/psd_tools/compression/__init__.py
b/src/psd_tools/compression/__init__.py
+index 8a4bf50..c4415a2 100644
+--- a/src/psd_tools/compression/__init__.py
++++ b/src/psd_tools/compression/__init__.py
+@@ -8,6 +8,7 @@ from typing import Iterator
+ import array
+ import io
+ import logging
++import warnings
+ import zlib
+
+ from PIL import Image
+@@ -28,6 +29,60 @@ except ImportError:
+ logger = logging.getLogger(__name__)
+
+
++class PSDDecompressionWarning(UserWarning):
++ """Issued when channel data cannot be fully decompressed.
++
++ The affected channel is replaced with black pixels. Catch or filter this
++ warning to detect silently degraded images::
++
++ import warnings
++ from psd_tools.compression import PSDDecompressionWarning
++
++ with warnings.catch_warnings():
++ warnings.simplefilter("error", PSDDecompressionWarning)
++ psd = PSDImage.open("file.psd")
++ """
++
++
++_VALID_DEPTHS: frozenset[int] = frozenset((1, 8, 16, 32))
++_MAX_DIMENSION: int = 300_000 # PSD/PSB hard limit per the Adobe spec
++
++
++def _warn_decompress_failure(
++ codec: str,
++ exc: Exception,
++ width: int,
++ height: int,
++ depth: int,
++ version: int,
++) -> None:
++ """Log and emit a PSDDecompressionWarning for a failed channel decode."""
++ msg = (
++ "%s decode failed (%s: %s); channel replaced with black. "
++ "width=%d height=%d depth=%d version=%d"
++ % (codec, type(exc).__name__, exc, width, height, depth, version)
++ )
++ logger.warning(msg)
++ warnings.warn(msg, PSDDecompressionWarning, stacklevel=3)
++
++
++def _safe_zlib_decompress(data: bytes, max_length: int) -> bytes:
++ """Decompress *data* with a hard upper bound on output size.
++
++ Unlike :func:`zlib.decompress`, this function raises :exc:`ValueError`
++ if the decompressed output would exceed *max_length* bytes, preventing
++ memory exhaustion from crafted ZIP-bomb payloads.
++ """
++ d = zlib.decompressobj()
++ out = d.decompress(data, max_length + 1)
++ if d.unconsumed_tail:
++ raise ValueError(
++ "Decompressed size exceeds expected maximum of %d bytes" %
max_length
++ )
++ out += d.flush()
++ return out
++
++
+ def compress(
+ data: bytes,
+ compression: Compression,
+@@ -72,24 +127,46 @@ def decompress(
+ :param data: compressed data bytes.
+ :param compression: compression type,
+ see :py:class:`~psd_tools.constants.Compression`.
+- :param width: width.
+- :param height: height.
+- :param depth: bit depth of the pixel.
++ :param width: width in pixels; must be in [1, 300000].
++ :param height: height in pixels; must be in [1, 300000].
++ :param depth: bit depth of the pixel; must be one of 1, 8, 16, 32.
+ :param version: psd file version.
+ :return: decompressed data bytes.
++ :raises ValueError: if *width*, *height*, or *depth* are out of range.
+ """
++ if width < 1 or width > _MAX_DIMENSION:
++ raise ValueError("width %d out of range [1, %d]" % (width,
_MAX_DIMENSION))
++ if height < 1 or height > _MAX_DIMENSION:
++ raise ValueError("height %d out of range [1, %d]" % (height,
_MAX_DIMENSION))
++ if depth not in _VALID_DEPTHS:
++ raise ValueError("depth %d not in %s" % (depth,
sorted(_VALID_DEPTHS)))
++
+ length = width * height * max(1, depth // 8)
+
+ result = None
+ if compression == Compression.RAW:
+ result = data[:length]
+ elif compression == Compression.RLE:
+- result = decode_rle(data, width, height, depth, version)
++ try:
++ result = decode_rle(data, width, height, depth, version)
++ except (ValueError, IndexError) as e:
++ _warn_decompress_failure("RLE", e, width, height, depth, version)
++ result = None
+ elif compression == Compression.ZIP:
+- result = zlib.decompress(data)
++ try:
++ result = _safe_zlib_decompress(data, length)
++ except (ValueError, zlib.error) as e:
++ _warn_decompress_failure("ZIP", e, width, height, depth, version)
++ result = None
+ else:
+- decompressed = zlib.decompress(data)
+- result = decode_prediction(decompressed, width, height, depth)
++ try:
++ decompressed = _safe_zlib_decompress(data, length)
++ result = decode_prediction(decompressed, width, height, depth)
++ except (ValueError, zlib.error) as e:
++ _warn_decompress_failure(
++ "ZIP_WITH_PREDICTION", e, width, height, depth, version
++ )
++ result = None
+
+ if depth >= 8:
+ if result is None:
+@@ -97,8 +174,14 @@ def decompress(
+ result = Image.new(mode, (width, height), color=0).tobytes()
+ logger.warning("Failed channel has been replaced by black")
+ else:
+- assert len(result) == length, "len=%d, expected=%d" %
(len(result), length)
+-
++ if len(result) != length:
++ raise ValueError(
++ "Decompressed length mismatch: got %d, expected %d"
++ % (len(result), length)
++ )
++
++ if result is None:
++ raise RuntimeError("decompress() produced no result for depth=%d" %
depth)
+ return result
+
+
+diff --git a/src/psd_tools/compression/_rle.pyx
b/src/psd_tools/compression/_rle.pyx
+index 6d2207b..18786dc 100644
+--- a/src/psd_tools/compression/_rle.pyx
++++ b/src/psd_tools/compression/_rle.pyx
+@@ -8,39 +8,45 @@ def decode(const unsigned char[:] data, Py_ssize_t size) ->
string:
+ """decode(data, size) -> bytes
+
+ Apple PackBits RLE decoder.
++
++ Tolerant implementation: runs that would exceed *size* are clipped at the
++ row boundary, runs whose input is truncated copy what is available, and
any
++ remaining bytes are zero-padded (std::string::resize zero-initialises).
++ The function always returns exactly *size* bytes without raising.
+ """
+
+- cdef int i = 0
+- cdef int j = 0
+- cdef int length = data.shape[0]
++ cdef Py_ssize_t i = 0
++ cdef Py_ssize_t j = 0
++ cdef Py_ssize_t length = data.shape[0]
++ cdef Py_ssize_t actual, available
+ cdef unsigned char bit
+ cdef string result
+
++ result.resize(size) # zero-initialised by std::string::resize
++
+ if length == 1:
+- if data[0] != 128:
+- raise ValueError('Invalid RLE compression')
++ # Single byte: either a no-op (128) or a stray header — return zeros
+ return result
+
+- result.resize(size)
+-
+- while i < length:
++ while i < length and j < size:
+ i, bit = i+1, data[i]
+ if bit > 128:
+ bit = 256 - bit
+- if j+1+bit > size:
+- raise ValueError('Invalid RLE compression')
+- fill_n(result.begin()+j, 1+bit, <char>data[i])
+- j += 1+bit
++ if i >= length: # lone repeat header at end of stream — stop
++ break
++ actual = min(1+bit, size-j) # clip at remaining output space
++ fill_n(result.begin()+j, actual, <char>data[i])
++ j += actual
+ i += 1
+ elif bit < 128:
+- if i+1+bit > length or (j+1+bit > size):
+- raise ValueError('Invalid RLE compression')
+- copy_n(&data[i], 1+bit, result.begin()+j)
+- j += 1+bit
+- i += 1+bit
+-
+- if size and (j != size):
+- raise ValueError('Expected %d bytes but decoded %d bytes' % (size, j))
++ if i >= length: # copy header is the last byte; nothing to copy
++ break
++ available = min(length-i, 1+bit)
++ actual = min(available, size-j) # clip to input and output
++ copy_n(&data[i], actual, result.begin()+j)
++ j += actual
++ i += available # advance by declared amount or to end
++ # bit == 128: no-op
+
+ return result
+
+@@ -58,7 +64,7 @@ def encode(const unsigned char[:] data) -> string:
+ cdef string result
+
+ if length == 0:
+- return data
++ return result
+ if length == 1:
+ result.push_back(0)
+ result.push_back(data[0])
+diff --git a/src/psd_tools/compression/rle.py
b/src/psd_tools/compression/rle.py
+index 19f07e1..e7822b8 100644
+--- a/src/psd_tools/compression/rle.py
++++ b/src/psd_tools/compression/rle.py
+@@ -4,36 +4,36 @@ def decode(data: bytes, size: int) -> bytes:
+ """decode(data, size) -> bytes
+
+ Apple PackBits RLE decoder.
++
++ Tolerant implementation: runs that would exceed *size* are clipped at the
++ row boundary, runs whose input is truncated copy what is available, and
any
++ remaining bytes are zero-padded. The function always returns exactly
*size*
++ bytes without raising.
+ """
+
+ i, j = 0, 0
+ length = len(data)
+ data = bytearray(data)
+- result = bytearray()
++ result = bytearray(size) # pre-allocated and zero-filled
+
+- if length == 1:
+- if data[0] != 128:
+- raise ValueError("Invalid RLE compression")
+- return result
+-
+- while i < length:
++ while i < length and j < size:
+ i, bit = i + 1, data[i]
+ if bit > 128:
+ bit = 256 - bit
+- if j + 1 + bit > size:
+- raise ValueError("Invalid RLE compression")
+- result.extend((data[i : i + 1]) * (1 + bit))
+- j += 1 + bit
++ if i >= length: # lone repeat header at end of stream — stop
++ break
++ actual = min(1 + bit, size - j) # clip at remaining output space
++ result[j : j + actual] = bytes([data[i]]) * actual
++ j += actual
+ i += 1
+ elif bit < 128:
+- if i + 1 + bit > length or (j + 1 + bit > size):
+- raise ValueError("Invalid RLE compression")
+- result.extend(data[i : i + 1 + bit])
+- j += 1 + bit
+- i += 1 + bit
+-
+- if size and (len(result) != size):
+- raise ValueError("Expected %d bytes but decoded %d bytes" % (size, j))
++ copy_count = 1 + bit
++ available = length - i
++ actual = min(copy_count, available, size - j) # clip to input
and output
++ result[j : j + actual] = data[i : i + actual]
++ j += actual
++ i += min(copy_count, available) # advance by declared amount or
to end
++ # bit == 128: no-op
+
+ return bytes(result)
+
+diff --git a/tests/psd_tools/compression/test_compression.py
b/tests/psd_tools/compression/test_compression.py
+index 2b71591..7189b46 100644
+--- a/tests/psd_tools/compression/test_compression.py
++++ b/tests/psd_tools/compression/test_compression.py
+@@ -1,16 +1,20 @@
+ from __future__ import print_function, unicode_literals
+
+ import logging
++import warnings
++import zlib
+
+ import pytest
+
+ from psd_tools.compression import (
++ PSDDecompressionWarning,
+ compress,
+ decode_prediction,
+ decode_rle,
+ decompress,
+ encode_prediction,
+ encode_rle,
++ rle_impl,
+ )
+ from psd_tools.constants import Compression
+
+@@ -77,6 +81,54 @@ def test_compress_decompress(data, kind, width, height,
depth, version):
+ assert output == data, "output=%r, expected=%r" % (output, data)
+
+
++def test_decompress_rle_overflow_clips() -> None:
++ # Header: 1 row of 4 compressed bytes (\x00\x04).
++ # Row data: literal run header 0x02 = copy 3 bytes, but row_size is 2.
++ # Tolerant decoder clips to 2 bytes → correct decoded output, no fallback.
++ rle_data = b"\x00\x04\x02\x00\x00\x00"
++ width, height, depth = 2, 1, 8
++ result = decompress(rle_data, Compression.RLE, width, height, depth)
++ assert result == b"\x00\x00"
++
++
++# --- Low-level decode() tolerance tests (exercise both Python and Cython
impls) ---
++
++
++def test_decode_rle_repeat_overflow() -> None:
++ # 0x82 = repeat-run header: 256 - 0x82 = 126, so repeat 127× next byte.
++ # row_size=3 → decoder should clip to 3 repetitions of 0xAA.
++ data = bytes([0x82, 0xAA])
++ assert rle_impl.decode(data, 3) == b"\xaa\xaa\xaa"
++
++
++def test_decode_rle_copy_overflow() -> None:
++ # 0x02 = copy-run header: copy 3 bytes, but row_size=2.
++ # Decoder should clip to 2 bytes.
++ data = bytes([0x02, 0x01, 0x02, 0x03])
++ assert rle_impl.decode(data, 2) == b"\x01\x02"
++
++
++def test_decode_rle_copy_truncated_input() -> None:
++ # 0x04 = copy-run header: copy 5 bytes, but only 3 bytes follow in the
stream.
++ # Decoder should copy 3 available bytes and zero-pad the remaining 2.
++ data = bytes([0x04, 0x01, 0x02, 0x03])
++ assert rle_impl.decode(data, 5) == b"\x01\x02\x03\x00\x00"
++
++
++def test_decode_rle_lone_repeat_header() -> None:
++ # 0x82 = repeat-run header with no following pixel byte (stream ends).
++ # Should not raise (previously caused IndexError in Cython); returns
zeros.
++ data = bytes([0x82])
++ assert rle_impl.decode(data, 4) == b"\x00\x00\x00\x00"
++
++
++def test_decode_rle_short_output() -> None:
++ # Stream is valid but encodes fewer bytes than row_size (zero-padded
remainder).
++ # 0x00 = copy 1 byte (0xFF); row_size=4 → b"\xff\x00\x00\x00"
++ data = bytes([0x00, 0xFF])
++ assert rle_impl.decode(data, 4) == b"\xff\x00\x00\x00"
++
++
+ # This will fail due to irreversible zlib compression.
+ @pytest.mark.xfail
+ @pytest.mark.parametrize(
+@@ -97,3 +149,64 @@ def test_compress_decompress_fail(data, width, height,
depth):
+ decoded = decompress(data, Compression.ZIP_WITH_PREDICTION, width,
height, depth)
+ encoded = compress(decoded, Compression.ZIP_WITH_PREDICTION, width,
height, depth)
+ assert data == encoded
++
++
++# ---------------------------------------------------------------------------
++# Security fix tests
++# ---------------------------------------------------------------------------
++
++
[email protected](
++ "kind",
++ [Compression.ZIP, Compression.ZIP_WITH_PREDICTION],
++)
++def test_decompress_zip_bomb_falls_back_to_black(kind: Compression) -> None:
++ """A zlib payload that expands beyond the declared channel size must not
++ exhaust memory; it should fall back to a black channel."""
++ oversized = zlib.compress(b"\x00" * 10_000)
++ result = decompress(oversized, kind, width=2, height=2, depth=8)
++ assert result == b"\x00" * 4 # black fallback for 2×2 8-bit
++
++
[email protected](
++ "kind",
++ [Compression.ZIP, Compression.ZIP_WITH_PREDICTION],
++)
++def test_decompress_zip_bomb_emits_psd_warning(kind: Compression) -> None:
++ """ZIP bomb fallback must emit PSDDecompressionWarning."""
++ oversized = zlib.compress(b"\x00" * 10_000)
++ with warnings.catch_warnings(record=True) as caught:
++ warnings.simplefilter("always")
++ decompress(oversized, kind, width=2, height=2, depth=8)
++ assert any(issubclass(w.category, PSDDecompressionWarning) for w in
caught)
++
++
++def test_decompress_rle_failure_emits_psd_warning() -> None:
++ """An undecodable RLE channel must emit PSDDecompressionWarning."""
++ # version=1 row byte-count table needs height*2 bytes (unsigned short
each).
++ # Providing only 1 byte forces array.frombytes to raise ValueError (not a
++ # multiple of 2), which is the path that triggers the warning.
++ bad_rle = b"\x00"
++ with warnings.catch_warnings(record=True) as caught:
++ warnings.simplefilter("always")
++ decompress(bad_rle, Compression.RLE, width=4, height=1, depth=8,
version=1)
++ assert any(issubclass(w.category, PSDDecompressionWarning) for w in
caught)
++
++
[email protected](
++ "bad_kwarg",
++ [
++ {"width": 0},
++ {"width": 300_001},
++ {"height": 0},
++ {"height": 300_001},
++ {"depth": 7},
++ {"depth": 0},
++ ],
++)
++def test_decompress_invalid_dimensions_raises(bad_kwarg: dict) -> None:
++ """Out-of-spec dimensions must raise ValueError immediately."""
++ params: dict = dict(width=4, height=4, depth=8, version=1)
++ params.update(bad_kwarg)
++ with pytest.raises(ValueError):
++ decompress(b"\x00" * 16, Compression.RAW, **params)
+diff --git a/tests/psd_tools/compression/test_rle.py
b/tests/psd_tools/compression/test_rle.py
+index f7234de..a0b3378 100644
+--- a/tests/psd_tools/compression/test_rle.py
++++ b/tests/psd_tools/compression/test_rle.py
+@@ -26,20 +26,26 @@ def test_identical():
+ assert decoded_c == EDGE_CASE_1
+
+ @pytest.mark.parametrize(
+- ("mod, data, size"),
++ ("mod, data, size, expected"),
+ [
+- # b'\x01\x01\x01\x01'
+- (rle, b"\xfd\x01", 3),
+- (rle, b"\xfd\x01", 5),
+- (_rle, b"\xfd\x01", 3),
+- (_rle, b"\xfd\x01", 5),
+- # b'\x01\x02\x03'
+- (rle, b"\x02\x01\x02\x03", 2),
+- (rle, b"\x02\x01\x02\x03", 4),
+- (_rle, b"\x02\x01\x02\x03", 2),
+- (_rle, b"\x02\x01\x02\x03", 4),
++ # 0xfd = repeat-run: 256-253=3, so repeat 4× byte 0x01.
++ # size=3 → overflow clipped: b'\x01\x01\x01'
++ (rle, b"\xfd\x01", 3, b"\x01\x01\x01"),
++ (_rle, b"\xfd\x01", 3, b"\x01\x01\x01"),
++ # size=5 → 4 real bytes + 1 zero-padded: b'\x01\x01\x01\x01\x00'
++ (rle, b"\xfd\x01", 5, b"\x01\x01\x01\x01\x00"),
++ (_rle, b"\xfd\x01", 5, b"\x01\x01\x01\x01\x00"),
++ # 0x02 = copy-run: copy 3 bytes (0x01 0x02 0x03).
++ # size=2 → overflow clipped: b'\x01\x02'
++ (rle, b"\x02\x01\x02\x03", 2, b"\x01\x02"),
++ (_rle, b"\x02\x01\x02\x03", 2, b"\x01\x02"),
++ # size=4 → 3 real bytes + 1 zero-padded: b'\x01\x02\x03\x00'
++ (rle, b"\x02\x01\x02\x03", 4, b"\x01\x02\x03\x00"),
++ (_rle, b"\x02\x01\x02\x03", 4, b"\x01\x02\x03\x00"),
+ ],
+ )
+-def test_malicious(mod, data, size):
+- with pytest.raises(ValueError):
+- mod.decode(data, size)
++def test_tolerant_decode(mod, data, size, expected) -> None:
++ # The decoder must never raise; it clips overflow runs and zero-pads
short output.
++ result = mod.decode(data, size)
++ assert result == expected
++ assert len(result) == size
+--
+2.47.3
+
diff -Nru psd-tools-1.10.7+dfsg.1/debian/patches/series
psd-tools-1.10.7+dfsg.1/debian/patches/series
--- psd-tools-1.10.7+dfsg.1/debian/patches/series 1970-01-01
02:00:00.000000000 +0200
+++ psd-tools-1.10.7+dfsg.1/debian/patches/series 2026-06-21
20:06:53.000000000 +0300
@@ -0,0 +1 @@
+0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch
--- End Message ---