From da93160ccad0d8311c3af9f549c842574e683330 Mon Sep 17 00:00:00 2001 From: a2811057970 Date: Fri, 3 Jul 2026 11:53:37 +0100 Subject: [PATCH 1/7] Restrict pickle deserialization to safe types (CVE-2025-69872) BREAKING CHANGE: Pickle deserialization now only permits safe built-in types (builtins, collections, datetime, decimal, fractions, uuid). Arbitrary objects can no longer be deserialized from cache, preventing code execution via crafted pickle payloads. Users caching custom types should migrate to JSONDisk or a custom Disk subclass. There is no opt-out mechanism by design. - Add SafeUnpickler with allowlist-based find_class override - Add UnpicklingError (inherits pickle.UnpicklingError) for downstream compatibility with libraries catching pickle.PickleError - Support pickle protocols 0-5 via __builtin__, copy_reg, and _codecs allowlist entries - Use frozenset values in SAFE_PICKLE_CLASSES to prevent runtime bypass - Bump version to 6.0.0 (breaking change per semver) This takes a different approach to PR #361 (HMAC envelope). The HMAC approach still allows arbitrary deserialization once the signature is verified, meaning an attacker with read+write access to the cache directory can read the auto-generated key file and forge valid payloads. The allowlist approach blocks dangerous types regardless of filesystem access. Fixes: CVE-2025-69872 Closes: #357, #360, #362 --- CHANGES.rst | 52 ++++++ diskcache/__init__.py | 8 +- diskcache/core.py | 149 ++++++++++++++++- tests/test_safe_pickle.py | 331 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 CHANGES.rst create mode 100644 tests/test_safe_pickle.py diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..934cd04 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,52 @@ +Changes +======= + +6.0.0 (NEXT) +------------- + +**Breaking Changes** + +* Pickle deserialization is now restricted to safe built-in types only. + This mitigates CVE-2025-69872, which allowed arbitrary code execution + when an attacker with write access to the cache directory injected a + crafted pickle payload. + + The following types are permitted during deserialization: + + - Python builtins: ``int``, ``float``, ``str``, ``bytes``, ``bytearray``, + ``list``, ``dict``, ``tuple``, ``set``, ``frozenset``, ``complex``, + ``range``, ``slice``, ``object``, ``bool``, ``None`` + - ``collections``: ``OrderedDict``, ``defaultdict``, ``deque`` + - ``datetime``: ``date``, ``datetime``, ``time``, ``timedelta``, + ``timezone`` + - ``decimal.Decimal`` + - ``fractions.Fraction`` + - ``uuid.UUID`` + + All other types will raise ``UnpicklingError`` on read. + +* There is no opt-out mechanism. Users who need to cache custom types have + two migration paths: + + 1. Use ``JSONDisk`` for JSON-serializable data:: + + cache = Cache('/tmp/my-cache', disk=JSONDisk) + + 2. Subclass ``Disk`` and override ``get()`` and ``fetch()`` with a custom + serialization strategy appropriate for your data. + +**New Features** + +* Added ``SafeUnpickler`` class for restricted pickle deserialization. +* Added ``UnpicklingError`` exception raised when a disallowed type is + encountered during deserialization. + +**Internal** + +* ``SAFE_PICKLE_CLASSES`` uses ``frozenset`` values to prevent runtime + modification. + +5.6.3 (2023-08-31) +------------------- + +* Previous release (see git history for details). diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 7757d66..a7e451d 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -14,8 +14,10 @@ Disk, EmptyDirWarning, JSONDisk, + SafeUnpickler, Timeout, UnknownFileWarning, + UnpicklingError, ) from .fanout import FanoutCache from .persistent import Deque, Index @@ -44,9 +46,11 @@ 'JSONDisk', 'Lock', 'RLock', + 'SafeUnpickler', 'Timeout', 'UNKNOWN', 'UnknownFileWarning', + 'UnpicklingError', 'barrier', 'memoize_stampede', 'throttle', @@ -61,8 +65,8 @@ pass __title__ = 'diskcache' -__version__ = '5.6.3' -__build__ = 0x050603 +__version__ = '6.0.0' +__build__ = 0x060000 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' diff --git a/diskcache/core.py b/diskcache/core.py index 7a3d23b..6138f88 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -100,6 +100,149 @@ def __repr__(self): } +class UnpicklingError(pickle.UnpicklingError): + """Error raised when unpickling encounters a disallowed type.""" + + +# Safe modules and classes that are allowed during deserialization. +# These are standard Python types that cannot execute arbitrary code +# during unpickling. This structure is immutable to prevent runtime +# modification as a security bypass. +SAFE_PICKLE_CLASSES = { + 'builtins': frozenset( + { + 'True', + 'False', + 'None', + 'bytes', + 'bytearray', + 'complex', + 'dict', + 'float', + 'frozenset', + 'int', + 'list', + 'object', + 'range', + 'set', + 'slice', + 'str', + 'tuple', + } + ), + # Python 2 module name used by pickle protocols 0 and 1. + '__builtin__': frozenset( + { + 'True', + 'False', + 'None', + 'bytes', + 'bytearray', + 'complex', + 'dict', + 'float', + 'frozenset', + 'int', + 'list', + 'long', + 'object', + 'range', + 'set', + 'slice', + 'str', + 'tuple', + 'unicode', + 'xrange', + } + ), + 'collections': frozenset( + { + 'OrderedDict', + 'defaultdict', + 'deque', + } + ), + # Used by pickle protocols 0 and 1 for object reconstruction. + 'copy_reg': frozenset( + { + '_reconstructor', + } + ), + 'copyreg': frozenset( + { + '_reconstructor', + } + ), + 'datetime': frozenset( + { + 'date', + 'datetime', + 'time', + 'timedelta', + 'timezone', + } + ), + 'decimal': frozenset( + { + 'Decimal', + } + ), + 'fractions': frozenset( + { + 'Fraction', + } + ), + 'uuid': frozenset( + { + 'UUID', + } + ), + '_codecs': frozenset( + { + 'encode', + } + ), +} + + +class SafeUnpickler(pickle.Unpickler): + """Restricted unpickler that only allows safe built-in types. + + This prevents arbitrary code execution via crafted pickle payloads. + Only types listed in SAFE_PICKLE_CLASSES are permitted. + + """ + + def find_class(self, module, name): + """Only allow safe classes to be unpickled. + + :param str module: module name + :param str name: class/function name + :raises UnpicklingError: if the class is not in the allowlist + + """ + allowed = SAFE_PICKLE_CLASSES.get(module, frozenset()) + if name in allowed: + return super().find_class(module, name) + raise UnpicklingError( + 'Unpickling of {}.{} is not allowed. ' + 'Only safe built-in types can be deserialized. ' + 'Use JSONDisk or a custom Disk subclass for other types.'.format( + module, name + ) + ) + + +def safe_pickle_load(file_obj): + """Load a pickle from a file object using the restricted unpickler. + + :param file_obj: file-like object to read from + :return: deserialized Python object + + """ + return SafeUnpickler(file_obj).load() + + class Disk: """Cache key and value serialization for SQLite database and files.""" @@ -174,7 +317,7 @@ def get(self, key, raw): if raw: return bytes(key) if type(key) is sqlite3.Binary else key else: - return pickle.load(io.BytesIO(key)) + return safe_pickle_load(io.BytesIO(key)) def store(self, value, read, key=UNKNOWN): """Convert `value` to fields size, mode, filename, and value for Cache @@ -279,9 +422,9 @@ def fetch(self, mode, filename, value, read): elif mode == MODE_PICKLE: if value is None: with open(op.join(self._directory, filename), 'rb') as reader: - return pickle.load(reader) + return safe_pickle_load(reader) else: - return pickle.load(io.BytesIO(value)) + return safe_pickle_load(io.BytesIO(value)) def filename(self, key=UNKNOWN, value=UNKNOWN): """Return filename and full-path tuple for file storage. diff --git a/tests/test_safe_pickle.py b/tests/test_safe_pickle.py new file mode 100644 index 0000000..49e4c36 --- /dev/null +++ b/tests/test_safe_pickle.py @@ -0,0 +1,331 @@ +"""Test diskcache safe pickle deserialization (CVE-2025-69872 fix).""" + +import inspect +import io +import os +import pickle +import shutil +import subprocess +import tempfile +from collections import OrderedDict, deque +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from fractions import Fraction +from uuid import UUID + +import pytest + +import diskcache as dc +from diskcache.core import MODE_PICKLE, UnpicklingError, safe_pickle_load + + +@pytest.fixture +def cache(): + with dc.Cache() as cache: + yield cache + shutil.rmtree(cache.directory, ignore_errors=True) + + +# --- SafeUnpickler Tests --- + + +class TestSafeUnpickler: + """Test the SafeUnpickler restricts deserialization correctly.""" + + def test_allows_basic_types(self): + """Safe types should deserialize without error.""" + safe_values = [ + 42, + 3.14, + 'hello', + b'bytes', + True, + False, + None, + [1, 2, 3], + {'key': 'value'}, + (1, 2, 3), + {1, 2, 3}, + frozenset([1, 2, 3]), + ] + for value in safe_values: + data = pickle.dumps(value) + result = safe_pickle_load(io.BytesIO(data)) + assert result == value + + def test_allows_collections(self): + """Standard collection types should deserialize.""" + values = [ + OrderedDict([('a', 1), ('b', 2)]), + deque([1, 2, 3]), + ] + for value in values: + data = pickle.dumps(value) + result = safe_pickle_load(io.BytesIO(data)) + assert result == value + + def test_allows_datetime(self): + """Datetime types should deserialize.""" + values = [ + datetime(2025, 1, 1, 12, 0, 0), + timedelta(days=1, hours=2), + timezone.utc, + ] + for value in values: + data = pickle.dumps(value) + result = safe_pickle_load(io.BytesIO(data)) + assert result == value + + def test_allows_decimal(self): + """Decimal should deserialize.""" + value = Decimal('3.14159') + data = pickle.dumps(value) + result = safe_pickle_load(io.BytesIO(data)) + assert result == value + + def test_allows_fraction(self): + """Fraction should deserialize.""" + value = Fraction(1, 3) + data = pickle.dumps(value) + result = safe_pickle_load(io.BytesIO(data)) + assert result == value + + def test_allows_uuid(self): + """UUID should deserialize.""" + value = UUID('12345678-1234-5678-1234-567812345678') + data = pickle.dumps(value) + result = safe_pickle_load(io.BytesIO(data)) + assert result == value + + def test_blocks_os_system(self): + """os.system should be blocked - classic RCE vector.""" + data = pickle.dumps(os.system) + with pytest.raises(UnpicklingError, match='not allowed'): + safe_pickle_load(io.BytesIO(data)) + + def test_blocks_eval(self): + """eval should be blocked.""" + data = pickle.dumps(eval) + with pytest.raises(UnpicklingError, match='not allowed'): + safe_pickle_load(io.BytesIO(data)) + + def test_blocks_exec(self): + """exec should be blocked.""" + data = pickle.dumps(exec) + with pytest.raises(UnpicklingError, match='not allowed'): + safe_pickle_load(io.BytesIO(data)) + + def test_blocks_subprocess(self): + """subprocess.Popen should be blocked.""" + data = pickle.dumps(subprocess.Popen) + with pytest.raises(UnpicklingError, match='not allowed'): + safe_pickle_load(io.BytesIO(data)) + + def test_blocks_pickle_reduce_exploit(self): + """Crafted __reduce__ payloads should be blocked.""" + + class Exploit: + def __reduce__(self): + return (os.system, ('echo pwned',)) + + data = pickle.dumps(Exploit()) + with pytest.raises(UnpicklingError, match='not allowed'): + safe_pickle_load(io.BytesIO(data)) + + def test_blocks_arbitrary_class(self): + """Custom classes should be blocked.""" + data = pickle.dumps(tempfile.NamedTemporaryFile) + with pytest.raises(UnpicklingError, match='not allowed'): + safe_pickle_load(io.BytesIO(data)) + + def test_error_message_includes_class_info(self): + """Error message should indicate what was blocked.""" + data = pickle.dumps(os.system) + with pytest.raises(UnpicklingError) as exc_info: + safe_pickle_load(io.BytesIO(data)) + assert 'JSONDisk' in str(exc_info.value) + + def test_nested_safe_types(self): + """Nested structures of safe types should work.""" + value = { + 'list': [1, 2.0, 'three'], + 'tuple': (4, 5, 6), + 'nested': {'a': [True, False, None]}, + 'ordered': OrderedDict([('x', datetime(2025, 1, 1))]), + } + data = pickle.dumps(value) + result = safe_pickle_load(io.BytesIO(data)) + assert result == value + + +# --- Cache Integration Tests --- + + +class TestCacheDefaultSafe: + """Test that Cache uses safe deserialization unconditionally.""" + + def test_safe_values_work(self, cache): + """Standard safe types should round-trip through cache.""" + test_data = { + 'int': 42, + 'float': 3.14, + 'str': 'hello world', + 'bytes': b'binary data', + 'list': [1, 2, 3], + 'dict': {'nested': True}, + 'tuple': (1, 'two', 3.0), + 'none': None, + 'bool': True, + } + for key, value in test_data.items(): + cache[key] = value + + for key, value in test_data.items(): + assert cache[key] == value + + def test_safe_complex_types(self, cache): + """Allowed complex types should work.""" + cache['decimal'] = Decimal('3.14') + cache['uuid'] = UUID('12345678-1234-5678-1234-567812345678') + cache['datetime'] = datetime(2025, 6, 15, 10, 30) + cache['ordered'] = OrderedDict([('a', 1), ('b', 2)]) + + assert cache['decimal'] == Decimal('3.14') + assert cache['uuid'] == UUID('12345678-1234-5678-1234-567812345678') + assert cache['datetime'] == datetime(2025, 6, 15, 10, 30) + assert cache['ordered'] == OrderedDict([('a', 1), ('b', 2)]) + + def test_blocks_malicious_payload(self, cache): + """Injecting a malicious pickle into cache should fail on read.""" + + class Exploit: + def __reduce__(self): + return (os.system, ('echo pwned',)) + + malicious_data = pickle.dumps(Exploit()) + + # Manually insert malicious data as if attacker had write access + sql = cache._sql + sql( + 'INSERT INTO Cache (key, raw, store_time, expire_time,' + ' access_time, access_count, tag, mode, filename, value)' + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ( + 'malicious', + True, + 0, + None, + 0, + 0, + None, + MODE_PICKLE, + None, + malicious_data, + ), + ) + + with pytest.raises(UnpicklingError): + cache['malicious'] + + +class TestNoEscapeHatch: + """Confirm there is no way to bypass safe deserialization.""" + + def test_no_allow_pickle_parameter(self): + """Disk.__init__ should not accept allow_pickle.""" + sig = inspect.signature(dc.Disk.__init__) + assert 'allow_pickle' not in sig.parameters + + def test_no_disk_allow_pickle_setting(self): + """DEFAULT_SETTINGS should not contain disk_allow_pickle.""" + assert 'disk_allow_pickle' not in dc.DEFAULT_SETTINGS + + def test_unknown_disk_setting_rejected(self): + """Passing disk_allow_pickle to Cache should raise TypeError.""" + with pytest.raises(TypeError): + dc.Cache(disk_allow_pickle=True) + + def test_safe_unpickling_always_active(self, cache): + """Even after explicit attempts, deserialization stays restricted.""" + # Try to bypass by setting attribute directly on disk + cache.disk.allow_pickle = True # This attribute doesn't exist/matter + + class Exploit: + def __reduce__(self): + return (os.system, ('echo pwned',)) + + malicious_data = pickle.dumps(Exploit()) + + sql = cache._sql + sql( + 'INSERT INTO Cache (key, raw, store_time, expire_time,' + ' access_time, access_count, tag, mode, filename, value)' + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ( + 'bypass_attempt', + True, + 0, + None, + 0, + 0, + None, + MODE_PICKLE, + None, + malicious_data, + ), + ) + + with pytest.raises(UnpicklingError): + cache['bypass_attempt'] + + +# --- FanoutCache Integration --- + + +class TestFanoutCacheSafe: + """Test FanoutCache uses safe deserialization.""" + + def test_default_safe(self): + """FanoutCache should use safe mode.""" + with dc.FanoutCache() as cache: + cache['key'] = [1, 2, 3] + assert cache['key'] == [1, 2, 3] + shutil.rmtree(cache.directory, ignore_errors=True) + + +# --- Deque and Index Integration --- + + +class TestPersistentSafe: + """Test Deque and Index use safe deserialization.""" + + def test_deque_safe(self): + """Deque should work with safe types.""" + deq = dc.Deque([1, 2, 3]) + assert list(deq) == [1, 2, 3] + shutil.rmtree(deq.directory, ignore_errors=True) + + def test_index_safe(self): + """Index should work with safe types.""" + index = dc.Index({'a': 1, 'b': 2}) + assert index['a'] == 1 + shutil.rmtree(index.directory, ignore_errors=True) + + +# --- Key Serialization Tests --- + + +class TestKeySerialization: + """Test that non-raw keys (tuple keys) are deserialized safely.""" + + def test_tuple_key_safe(self, cache): + """Tuple keys use pickle serialization and should work safely.""" + key = (1, 'two', 3.0) + cache[key] = 'value' + assert cache[key] == 'value' + + def test_complex_key_safe(self, cache): + """Complex safe keys should work.""" + key = (None, 0, 'abc') + cache[key] = 'value' + assert cache[key] == 'value' From 7efeb41595dc68c439340832f258378b6247d80e Mon Sep 17 00:00:00 2001 From: Ivan Sabelnikov Date: Mon, 6 Jul 2026 19:04:04 +0100 Subject: [PATCH 2/7] chore: rename to mapped-diskcache, add PyPI publish CI Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 46 +++++++++++++++++++++++++++++++++++ diskcache/__init__.py | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bd92a88 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: pip install pytest + + - name: Run tests + run: pytest tests/ -x -q + + publish: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Build package + run: | + pip install build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/diskcache/__init__.py b/diskcache/__init__.py index a7e451d..38096e2 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -64,7 +64,7 @@ # Django not installed or not setup so ignore. pass -__title__ = 'diskcache' +__title__ = 'mapped-diskcache' __version__ = '6.0.0' __build__ = 0x060000 __author__ = 'Grant Jenks' From 6bbef0a091be67f9055c0b2ccfcf267d92af824b Mon Sep 17 00:00:00 2001 From: Ivan Sabelnikov Date: Mon, 6 Jul 2026 19:30:28 +0100 Subject: [PATCH 3/7] fix(#868k5bbdr): Fix CI, rename to diskcache3, consolidate workflows Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration.yml | 64 ++++++++++++++++++------------- .github/workflows/publish.yml | 46 ---------------------- .github/workflows/release.yml | 30 --------------- .pylintrc | 6 ++- diskcache/__init__.py | 2 +- 5 files changed, 43 insertions(+), 105 deletions(-) delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b596fc6..84fd1e5 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,9 +1,9 @@ -name: integration +name: CI -on: [push, pull_request] +on: + push: jobs: - checks: runs-on: ubuntu-latest strategy: @@ -12,41 +12,53 @@ jobs: check: [bluecheck, doc8, docs, flake8, isortcheck, mypy, pylint, rstcheck] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Install dependencies - run: | - pip install --upgrade pip - pip install tox - - name: Run checks with tox - run: | - tox -e ${{ matrix.check }} + - name: Install tox + run: pip install tox + - name: Run checks + run: tox -e ${{ matrix.check }} tests: needs: checks - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: - max-parallel: 8 + max-parallel: 4 matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.8, 3.9, '3.10', 3.11] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - name: Set up Python ${{ matrix.python-version }} x64 - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - architecture: x64 + - name: Install tox + run: pip install tox + - name: Run tests + run: tox -e py - - uses: actions/checkout@v3 + publish: + needs: tests + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read - - name: Install tox + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Build package run: | - pip install --upgrade pip - pip install tox - - - name: Test with tox - run: tox -e py + pip install build + python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index bd92a88..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Publish to PyPI - -on: - push: - tags: - - 'v*' - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install dependencies - run: pip install pytest - - - name: Run tests - run: pytest tests/ -x -q - - publish: - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Build package - run: | - pip install build - python -m build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 33b3a8f..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: release - -on: - push: - tags: - - v* - -jobs: - - upload: - runs-on: ubuntu-latest - permissions: - id-token: write - - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install build - run: pip install build - - - name: Create build - run: python -m build - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.pylintrc b/.pylintrc index dc1490a..42623cf 100644 --- a/.pylintrc +++ b/.pylintrc @@ -101,7 +101,7 @@ source-roots= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. -suggestion-mode=yes +# suggestion-mode removed (pylint 4.x dropped this option) # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. @@ -433,7 +433,9 @@ disable=raw-checker-failed, no-member, no-else-return, no-else-raise, - inconsistent-return-statements + inconsistent-return-statements, + too-many-lines, + too-many-positional-arguments # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 38096e2..73cd056 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -64,7 +64,7 @@ # Django not installed or not setup so ignore. pass -__title__ = 'mapped-diskcache' +__title__ = 'diskcache3' __version__ = '6.0.0' __build__ = 0x060000 __author__ = 'Grant Jenks' From ab910145ad94f4431dd2c9dbce3df044d26846b2 Mon Sep 17 00:00:00 2001 From: Ivan Sabelnikov Date: Mon, 6 Jul 2026 19:35:36 +0100 Subject: [PATCH 4/7] fix(#868k5bbdr): Use action-vtl, consolidate to single ci.yml Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/{integration.yml => ci.yml} | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) rename .github/workflows/{integration.yml => ci.yml} (75%) diff --git a/.github/workflows/integration.yml b/.github/workflows/ci.yml similarity index 75% rename from .github/workflows/integration.yml rename to .github/workflows/ci.yml index 84fd1e5..cd17d80 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,9 @@ name: CI on: push: +env: + BASE_VERSION: '6.0.0' + jobs: checks: runs-on: ubuntu-latest @@ -43,21 +46,35 @@ jobs: publish: needs: tests - if: startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v4 + + - name: Establish Versioning, Tags, and Labels + id: vtl + uses: mapped/action-vtl@latest + with: + baseVersion: ${{ env.BASE_VERSION }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' + + - name: Update package version + run: | + sed -i "s/__version__ = '.*'/__version__ = '${{ steps.vtl.outputs.ver_semVerNoMeta }}'/" diskcache/__init__.py + - name: Build package run: | pip install build python -m build + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: From b8fe5eece53ff752390d63f12714a4207070b19f Mon Sep 17 00:00:00 2001 From: Ivan Sabelnikov Date: Mon, 6 Jul 2026 19:36:40 +0100 Subject: [PATCH 5/7] fix(#868k5bbdr): Use OIDC for PyPI publish, rename to mapped-diskcache Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 3 +-- diskcache/__init__.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd17d80..661c7da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + id-token: write steps: - uses: actions/checkout@v4 @@ -77,5 +78,3 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 73cd056..38096e2 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -64,7 +64,7 @@ # Django not installed or not setup so ignore. pass -__title__ = 'diskcache3' +__title__ = 'mapped-diskcache' __version__ = '6.0.0' __build__ = 0x060000 __author__ = 'Grant Jenks' From 5ed55bf7ac852073457cb71500511df5ec0e520d Mon Sep 17 00:00:00 2001 From: Ivan Sabelnikov Date: Mon, 6 Jul 2026 19:44:03 +0100 Subject: [PATCH 6/7] fix(#868k5bbdr): Update djangocache tests to reflect SafeUnpickler restrictions Co-Authored-By: Claude Sonnet 4.6 --- tests/test_djangocache.py | 65 +++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 734ba1b..4e33797 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -227,31 +227,37 @@ def test_close(self): cache.close() def test_data_types(self): - # Many different data types can be cached + # Safe built-in types can be cached stuff = { 'string': 'this is a string', 'int': 42, 'list': [1, 2, 3, 4], 'tuple': (1, 2, 3, 4), 'dict': {'A': 1, 'B': 2}, - 'function': f, - 'class': C, } cache.set('stuff', stuff) self.assertEqual(cache.get('stuff'), stuff) + def test_data_types_unsafe_rejected(self): + # Arbitrary functions and classes are blocked by SafeUnpickler (CVE-2025-69872) + from diskcache.core import UnpicklingError + cache.set('fn', f) + with self.assertRaises(UnpicklingError): + cache.get('fn') + cache.set('cls', C) + with self.assertRaises(UnpicklingError): + cache.get('cls') + def test_cache_read_for_model_instance(self): - # Don't want fields with callable as default to be called on cache read + # Django model instances use django.db.models.base.model_unpickle which + # is not in the SafeUnpickler allowlist (CVE-2025-69872). + from diskcache.core import UnpicklingError expensive_calculation.num_runs = 0 Poll.objects.all().delete() my_poll = Poll.objects.create(question='Well?') - self.assertEqual(Poll.objects.count(), 1) - pub_date = my_poll.pub_date cache.set('question', my_poll) - cached_poll = cache.get('question') - self.assertEqual(cached_poll.pub_date, pub_date) - # We only want the default expensive calculation run once - self.assertEqual(expensive_calculation.num_runs, 1) + with self.assertRaises(UnpicklingError): + cache.get('question') def test_cache_write_for_model_instance_with_deferred(self): # Don't want fields with callable as default to be called on cache write @@ -267,21 +273,16 @@ def test_cache_write_for_model_instance_with_deferred(self): self.assertEqual(expensive_calculation.num_runs, 1) def test_cache_read_for_model_instance_with_deferred(self): - # Don't want fields with callable as default to be called on cache read + # Deferred querysets reference user model classes not in the SafeUnpickler + # allowlist (CVE-2025-69872). + from diskcache.core import UnpicklingError expensive_calculation.num_runs = 0 Poll.objects.all().delete() Poll.objects.create(question='What?') - self.assertEqual(expensive_calculation.num_runs, 1) defer_qs = Poll.objects.all().defer('question') - self.assertEqual(defer_qs.count(), 1) cache.set('deferred_queryset', defer_qs) - self.assertEqual(expensive_calculation.num_runs, 1) - runs_before_cache_read = expensive_calculation.num_runs - cache.get('deferred_queryset') - # We only want the default expensive calculation run on creation and set - self.assertEqual( - expensive_calculation.num_runs, runs_before_cache_read - ) + with self.assertRaises(UnpicklingError): + cache.get('deferred_queryset') def test_expiration(self): # Cache values can be set to expire @@ -869,14 +870,12 @@ def test_custom_key_func(self): self.assertEqual(caches['custom_key2'].get('answer2'), 42) def test_cache_write_unpicklable_object(self): - fetch_middleware = FetchFromCacheMiddleware(empty_response) + # HttpResponse and SimpleCookie are not in the SafeUnpickler allowlist + # (CVE-2025-69872). Writing succeeds but reading raises UnpicklingError. + from diskcache.core import UnpicklingError request = self.factory.get('/cache/test') request._cache_update_cache = True - get_cache_data = FetchFromCacheMiddleware( - empty_response - ).process_request(request) - self.assertIsNone(get_cache_data) content = 'Testing cookie serialization.' @@ -885,19 +884,11 @@ def get_response(req): response.set_cookie('foo', 'bar') return response - update_middleware = UpdateCacheMiddleware(get_response) - response = update_middleware(request) + UpdateCacheMiddleware(get_response)(request) - get_cache_data = fetch_middleware.process_request(request) - self.assertIsNotNone(get_cache_data) - self.assertEqual(get_cache_data.content, content.encode()) - self.assertEqual(get_cache_data.cookies, response.cookies) - - UpdateCacheMiddleware(lambda req: get_cache_data)(request) - get_cache_data = fetch_middleware.process_request(request) - self.assertIsNotNone(get_cache_data) - self.assertEqual(get_cache_data.content, content.encode()) - self.assertEqual(get_cache_data.cookies, response.cookies) + fetch_middleware = FetchFromCacheMiddleware(empty_response) + with self.assertRaises(UnpicklingError): + fetch_middleware.process_request(request) def test_add_fail_on_pickleerror(self): # Shouldn't fail silently if trying to cache an unpicklable type. From 1ff09f5ba4a2d1a24b2684362903ea4dde8c44d2 Mon Sep 17 00:00:00 2001 From: Ivan Sabelnikov Date: Mon, 6 Jul 2026 19:47:42 +0100 Subject: [PATCH 7/7] fix(#868k5bbdr): Add blank lines after inline imports (blue formatter) Co-Authored-By: Claude Sonnet 4.6 --- tests/test_djangocache.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 4e33797..6eca4d4 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -241,6 +241,7 @@ def test_data_types(self): def test_data_types_unsafe_rejected(self): # Arbitrary functions and classes are blocked by SafeUnpickler (CVE-2025-69872) from diskcache.core import UnpicklingError + cache.set('fn', f) with self.assertRaises(UnpicklingError): cache.get('fn') @@ -252,6 +253,7 @@ def test_cache_read_for_model_instance(self): # Django model instances use django.db.models.base.model_unpickle which # is not in the SafeUnpickler allowlist (CVE-2025-69872). from diskcache.core import UnpicklingError + expensive_calculation.num_runs = 0 Poll.objects.all().delete() my_poll = Poll.objects.create(question='Well?') @@ -276,6 +278,7 @@ def test_cache_read_for_model_instance_with_deferred(self): # Deferred querysets reference user model classes not in the SafeUnpickler # allowlist (CVE-2025-69872). from diskcache.core import UnpicklingError + expensive_calculation.num_runs = 0 Poll.objects.all().delete() Poll.objects.create(question='What?')