Version in base suite: 1.10.7+dfsg.1-1 Base version: psd-tools_1.10.7+dfsg.1-1 Target version: psd-tools_1.10.7+dfsg.1-1+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/psd-tools/psd-tools_1.10.7+dfsg.1-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/psd-tools/psd-tools_1.10.7+dfsg.1-1+deb13u1.dsc changelog | 7 patches/0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch | 596 ++++++++++ patches/series | 1 3 files changed, 604 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpgc9alail/psd-tools_1.10.7+dfsg.1-1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpgc9alail/psd-tools_1.10.7+dfsg.1-1+deb13u1.dsc: no acceptable signature found 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 04:35:59.000000000 +0000 +++ psd-tools-1.10.7+dfsg.1/debian/changelog 2026-06-21 17:06:58.000000000 +0000 @@ -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 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 00:00:00.000000000 +0000 +++ psd-tools-1.10.7+dfsg.1/debian/patches/0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch 2026-06-21 17:06:58.000000000 +0000 @@ -0,0 +1,596 @@ +From 507976f67adf6b4b3315f555098c964f0c00fb9e Mon Sep 17 00:00:00 2001 +From: Kota Yamaguchi +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 + +* 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 + +* 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 + +* 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 + +--------- + +Co-authored-by: Claude Sonnet 4.6 +--- + 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, 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, 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 ++# --------------------------------------------------------------------------- ++ ++ ++@pytest.mark.parametrize( ++ "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 ++ ++ ++@pytest.mark.parametrize( ++ "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) ++ ++ ++@pytest.mark.parametrize( ++ "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 00:00:00.000000000 +0000 +++ psd-tools-1.10.7+dfsg.1/debian/patches/series 2026-06-21 17:06:53.000000000 +0000 @@ -0,0 +1 @@ +0001-Fix-compression-security-issues-GHSA-24p2-j2jr-386w-.patch