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

Reply via email to