diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..661c7da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + +env: + BASE_VERSION: '6.0.0' + +jobs: + checks: + runs-on: ubuntu-latest + strategy: + max-parallel: 8 + matrix: + check: [bluecheck, doc8, docs, flake8, isortcheck, mypy, pylint, rstcheck] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install tox + run: pip install tox + - name: Run checks + run: tox -e ${{ matrix.check }} + + tests: + needs: checks + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install tox + run: pip install tox + - name: Run tests + run: tox -e py + + publish: + needs: tests + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + 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 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml deleted file mode 100644 index b596fc6..0000000 --- a/.github/workflows/integration.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: integration - -on: [push, pull_request] - -jobs: - - checks: - runs-on: ubuntu-latest - strategy: - max-parallel: 8 - matrix: - check: [bluecheck, doc8, docs, flake8, isortcheck, mypy, pylint, rstcheck] - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - 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 }} - - tests: - needs: checks - runs-on: ${{ matrix.os }} - strategy: - max-parallel: 8 - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.8, 3.9, '3.10', 3.11] - - steps: - - name: Set up Python ${{ matrix.python-version }} x64 - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - - uses: actions/checkout@v3 - - - name: Install tox - run: | - pip install --upgrade pip - pip install tox - - - name: Test with tox - run: tox -e py 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/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..38096e2 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', @@ -60,9 +64,9 @@ # Django not installed or not setup so ignore. pass -__title__ = 'diskcache' -__version__ = '5.6.3' -__build__ = 0x050603 +__title__ = 'mapped-diskcache' +__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_djangocache.py b/tests/test_djangocache.py index 734ba1b..6eca4d4 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -227,31 +227,39 @@ 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 +275,17 @@ 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 +873,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 +887,11 @@ def get_response(req): response.set_cookie('foo', 'bar') return response - update_middleware = UpdateCacheMiddleware(get_response) - response = update_middleware(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(get_response)(request) - 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. 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'