Repository navigation
Expand file tree
/
Copy pathcache_layers.py
More file actions
689 lines (594 loc) · 26 KB
/
Copy pathcache_layers.py
File metadata and controls
689 lines (594 loc) · 26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
"""Cache storage layers for TNFR.
This module provides the storage backend implementations for the cache system,
including in-memory, shelve-based, and Redis-based layers. It also handles
secure serialization with HMAC signing to prevent tampering.
"""
from __future__ import annotations
import io
import os
import pickle
import pickletools
import shelve
import threading
import warnings
from abc import ABC, abstractmethod
from collections.abc import Callable, MutableMapping
from typing import Any
from ..errors import TNFRSecurityError, TNFRSecurityWarning, TNFRValueError
from ..security.crypto import create_hmac_signer, create_hmac_validator
__all__ = (
"CacheLayer",
"MappingCacheLayer",
"ShelveCacheLayer",
"RedisCacheLayer",
"create_secure_shelve_layer",
"create_secure_redis_layer",
)
_SIGNATURE_PREFIX = b"TNFRSIG2"
_LEGACY_SIGNATURE_PREFIX = b"TNFRSIG1"
_SIGN_MODE_RAW = 0
_SIGN_MODE_PICKLE = 1
_SIGNATURE_HEADER_SIZE = len(_SIGNATURE_PREFIX) + 1 + 4
# Environment variable to control security warnings for pickle deserialization
_TNFR_ALLOW_UNSIGNED_PICKLE = "TNFR_ALLOW_UNSIGNED_PICKLE"
def create_secure_shelve_layer(
path: str,
secret: bytes | str | None = None,
*,
flag: str = "c",
protocol: int | None = None,
writeback: bool = False,
) -> ShelveCacheLayer:
"""Create a ShelveCacheLayer with HMAC signature validation enabled.
This is the recommended way to create persistent cache layers that handle
TNFR structures (EPI, NFR, NetworkX graphs). The outer shelf container is
decoded without executable globals; version-2 signatures authenticate both
payload bytes and their interpretation before inner pickle decoding.
Legacy version-1 signed cache entries must be rebuilt.
Parameters
----------
path : str
Path to the shelve database file.
secret : bytes, str, or None
Secret key for HMAC signing. If None, reads from TNFR_CACHE_SECRET
environment variable. In production, **always** set this via environment.
flag : str, default='c'
Shelve open flag ('r', 'w', 'c', 'n').
protocol : int, optional
Pickle protocol version. Defaults to pickle.HIGHEST_PROTOCOL.
writeback : bool, default=False
Enable shelve writeback mode.
Returns
-------
ShelveCacheLayer
A cache layer with signature validation enabled.
Raises
------
ValueError
If no secret is provided and TNFR_CACHE_SECRET is not set.
Examples
--------
>>> # In production, set environment variable:
>>> # export TNFR_CACHE_SECRET="your-secure-random-key"
>>>
>>> layer = create_secure_shelve_layer("coherence.db")
>>> # Or explicitly provide secret:
>>> layer = create_secure_shelve_layer("coherence.db", secret=b"my-secret")
"""
if secret is None:
secret = os.environ.get("TNFR_CACHE_SECRET")
if not secret:
raise TNFRValueError(
"Secret required for secure cache layer.",
context={"env_var": "TNFR_CACHE_SECRET"},
suggestion="set TNFR_CACHE_SECRET environment variable or pass secret parameter.",
)
signer = create_hmac_signer(secret)
validator = create_hmac_validator(secret)
return ShelveCacheLayer(
path,
flag=flag,
protocol=protocol,
writeback=writeback,
signer=signer,
validator=validator,
require_signature=True,
)
def create_secure_redis_layer(
client: Any | None = None,
secret: bytes | str | None = None,
*,
namespace: str = "tnfr:cache",
protocol: int | None = None,
) -> RedisCacheLayer:
"""Create a RedisCacheLayer with HMAC signature validation enabled.
This is the recommended way to create distributed cache layers for TNFR.
Signature validation protects against arbitrary code execution if Redis
is compromised or contains tampered data. Version-2 signatures cover the
raw/pickle mode as well as the payload; version-1 entries must be rebuilt.
Parameters
----------
client : redis.Redis, optional
Redis client instance. If None, creates default client.
secret : bytes, str, or None
Secret key for HMAC signing. If None, reads from TNFR_CACHE_SECRET
environment variable.
namespace : str, default='tnfr:cache'
Redis key namespace prefix.
protocol : int, optional
Pickle protocol version.
Returns
-------
RedisCacheLayer
A cache layer with signature validation enabled.
Raises
------
ValueError
If no secret is provided and TNFR_CACHE_SECRET is not set.
Examples
--------
>>> # set environment variable in production:
>>> # export TNFR_CACHE_SECRET="your-secure-random-key"
>>>
>>> layer = create_secure_redis_layer()
>>> # Or with explicit configuration:
>>> import redis
>>> client = redis.Redis(host='localhost', port=6379)
>>> layer = create_secure_redis_layer(client, secret=b"my-secret")
"""
if secret is None:
secret = os.environ.get("TNFR_CACHE_SECRET")
if not secret:
raise TNFRValueError(
"Secret required for secure cache layer.",
context={"env_var": "TNFR_CACHE_SECRET"},
suggestion="set TNFR_CACHE_SECRET environment variable or pass secret parameter.",
)
signer = create_hmac_signer(secret)
validator = create_hmac_validator(secret)
return RedisCacheLayer(
client=client,
namespace=namespace,
signer=signer,
validator=validator,
require_signature=True,
protocol=protocol,
)
def _prepare_payload_bytes(value: Any, *, protocol: int) -> tuple[int, bytes]:
"""Return payload encoding mode and the bytes that should be signed."""
if isinstance(value, (bytes, bytearray, memoryview)):
return _SIGN_MODE_RAW, bytes(value)
return _SIGN_MODE_PICKLE, pickle.dumps(value, protocol=protocol)
def _pack_signed_envelope(mode: int, payload: bytes, signature: bytes) -> bytes:
"""Pack payload and signature into a self-describing binary envelope."""
if not (0 <= mode <= 255): # pragma: no cover - defensive guard
raise TNFRValueError(
f"invalid payload mode: {mode}",
context={"mode": mode},
suggestion="Mode must be between 0 and 255.",
)
signature_length = len(signature)
if signature_length >= 2**32: # pragma: no cover - defensive guard
raise TNFRValueError(
"signature too large to encode",
context={"signature_length": signature_length},
suggestion="Signature must be smaller than 4GB.",
)
header = (
_SIGNATURE_PREFIX
+ bytes([mode])
+ signature_length.to_bytes(4, byteorder="big", signed=False)
)
return header + signature + payload
def _is_signed_envelope(blob: bytes) -> bool:
"""Return ``True`` when *blob* represents a signed cache entry."""
return blob.startswith((_SIGNATURE_PREFIX, _LEGACY_SIGNATURE_PREFIX))
def _signature_input(mode: int, payload: bytes) -> bytes:
"""Authenticate interpretation as well as payload bytes.
Version 1 signed only the payload, so a raw byte string could be relabelled
as a pickle. Those derived cache entries must be rebuilt, not trusted.
"""
return _SIGNATURE_PREFIX + bytes([mode]) + payload
class _EnvelopeUnpickler(pickle.Unpickler):
"""Defense in depth after primitive-only outer opcode validation."""
def find_class(self, module: str, name: str) -> Any:
raise TNFRSecurityError("executable outer shelve pickle rejected")
_OUTER_ENVELOPE_OPCODES = frozenset(
{
"PROTO",
"FRAME",
"STOP",
"SHORT_BINBYTES",
"BINBYTES",
"BINBYTES8",
"MEMOIZE",
"BINPUT",
"LONG_BINPUT",
"PUT",
"BINGET",
"LONG_BINGET",
"GET",
}
)
def _decode_outer_envelope(blob: bytes) -> bytes:
"""Validate a bytes-only outer representation before any unpickling.
EXT opcodes can bypass Unpickler.find_class through Python's global
extension cache. No extensions, globals, reducers or persistent IDs are
permitted in the outer representation, regardless of process state.
"""
for opcode, _, _ in pickletools.genops(blob):
if opcode.name not in _OUTER_ENVELOPE_OPCODES:
raise TNFRSecurityError(f"nonprimitive outer pickle opcode: {opcode.name}")
entry = _EnvelopeUnpickler(io.BytesIO(blob)).load()
if type(entry) is not bytes:
raise TNFRSecurityError("outer shelve envelope must be bytes")
return entry
def _unpack_signed_envelope(blob: bytes) -> tuple[int, bytes, bytes]:
"""Return the ``(mode, signature, payload)`` triple encoded in *blob*."""
if blob.startswith(_LEGACY_SIGNATURE_PREFIX):
raise TNFRSecurityError("legacy signed cache entries must be rebuilt")
if len(blob) < _SIGNATURE_HEADER_SIZE:
raise TNFRSecurityError("signed payload header truncated")
if not _is_signed_envelope(blob):
raise TNFRSecurityError("missing signed payload marker")
mode = blob[len(_SIGNATURE_PREFIX)]
sig_start = len(_SIGNATURE_PREFIX) + 1
sig_len = int.from_bytes(blob[sig_start : sig_start + 4], byteorder="big")
payload_start = sig_start + 4 + sig_len
if len(blob) < payload_start:
raise TNFRSecurityError("signed payload signature truncated")
signature = blob[sig_start + 4 : payload_start]
payload = blob[payload_start:]
return mode, signature, payload
def _decode_payload(mode: int, payload: bytes) -> Any:
"""Decode payload bytes depending on cache encoding *mode*."""
if mode == _SIGN_MODE_RAW:
return payload
if mode == _SIGN_MODE_PICKLE:
return pickle.loads(payload) # nosec B301 - validated via signature
raise TNFRSecurityError(f"unknown payload encoding mode: {mode}")
class CacheLayer(ABC):
"""Abstract interface implemented by storage backends orchestrated by :class:`CacheManager`."""
@abstractmethod
def load(self, name: str) -> Any:
"""Return the stored payload for ``name`` or raise :class:`KeyError`."""
@abstractmethod
def store(self, name: str, value: Any) -> None:
"""Persist ``value`` under ``name``."""
@abstractmethod
def delete(self, name: str) -> None:
"""Remove ``name`` from the backend if present."""
@abstractmethod
def clear(self) -> None:
"""Remove every entry maintained by the layer."""
def close(self) -> None: # pragma: no cover - optional hook
"""Release resources held by the backend."""
class MappingCacheLayer(CacheLayer):
"""In-memory cache layer backed by a mutable mapping."""
def __init__(self, storage: MutableMapping[str, Any] | None = None) -> None:
self._storage: MutableMapping[str, Any] = {} if storage is None else storage
self._lock = threading.RLock()
@property
def storage(self) -> MutableMapping[str, Any]:
"""Return the mapping used to store cache entries."""
return self._storage
def load(self, name: str) -> Any:
with self._lock:
if name not in self._storage:
raise KeyError(name)
return self._storage[name]
def store(self, name: str, value: Any) -> None:
with self._lock:
self._storage[name] = value
def delete(self, name: str) -> None:
with self._lock:
self._storage.pop(name, None)
def clear(self) -> None:
with self._lock:
self._storage.clear()
class ShelveCacheLayer(CacheLayer):
"""Persistent cache layer backed by :mod:`shelve`.
.. warning::
This layer uses :mod:`pickle` for serialization, which can deserialize
arbitrary Python objects and execute code during deserialization.
**Only use with trusted data** from controlled sources. Never load
shelf files from untrusted origins without cryptographic verification.
Pickle is required for TNFR's complex structures (NetworkX graphs, EPIs,
coherence states, numpy arrays). For untrusted inputs, enable
:term:`HMAC` or equivalent signing via ``signer``/``validator`` and
set ``require_signature=True`` to reject tampered payloads.
:param signer: Optional callable that receives payload bytes and returns a
signature (for example ``lambda payload: hmac.new(key, payload,
hashlib.sha256).digest()``).
:param validator: Optional callable that receives ``(payload_bytes,
signature)`` and returns ``True`` when the payload is trustworthy.
:param require_signature: When ``True`` the cache operates in hardened
mode, deleting entries whose signatures are missing or invalid and
raising :class:`SecurityError`.
"""
def __init__(
self,
path: str,
*,
flag: str = "c",
protocol: int | None = None,
writeback: bool = False,
signer: Callable[[bytes], bytes] | None = None,
validator: Callable[[bytes, bytes], bool] | None = None,
require_signature: bool = False,
) -> None:
# Validate cache file path to prevent path traversal
from ..security import PathTraversalError, validate_file_path
try:
validated_path = validate_file_path(
path,
allow_absolute=True,
allowed_extensions=None, # Shelve creates multiple files with various extensions
)
self._path = str(validated_path)
except (ValueError, PathTraversalError) as e:
raise TNFRValueError(
f"Invalid cache path {path!r}: {e}",
context={"path": path, "error": str(e)},
) from e
self._flag = flag
self._protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
# shelve module inherently uses pickle for serialization; security risks documented in class docstring
self._shelf = shelve.open(
self._path, flag=flag, protocol=self._protocol, writeback=writeback # type: ignore
) # nosec B301
self._lock = threading.RLock()
self._signer = signer
self._validator = validator
self._require_signature = require_signature
if require_signature and (signer is None or validator is None):
raise TNFRValueError(
"require_signature=True requires both signer and validator",
context={"signer": signer, "validator": validator},
)
# Issue security warning when using unsigned pickle deserialization
if not require_signature and os.environ.get(_TNFR_ALLOW_UNSIGNED_PICKLE) != "1":
warnings.warn(
f"ShelveCacheLayer at {path!r} uses pickle without signature validation. "
"This can execute arbitrary code during deserialization. "
"Use create_secure_shelve_layer() or set require_signature=True with signer/validator. "
f"To suppress this warning, set {_TNFR_ALLOW_UNSIGNED_PICKLE}=1 environment variable.",
TNFRSecurityWarning,
stacklevel=2,
)
def load(self, name: str) -> Any:
with self._lock:
if name not in self._shelf:
raise KeyError(name)
if self._require_signature or self._validator is not None:
# Shelf.__getitem__ invokes pickle before returning its value.
# Verify the outer container cannot execute before examining
# and authenticating the inner envelope. Unsigned, explicitly
# trusted shelves retain their original object policy below.
blob = self._shelf.dict[name.encode(self._shelf.keyencoding)]
try:
entry = _decode_outer_envelope(blob)
except (
pickle.PickleError,
EOFError,
ValueError,
TNFRSecurityError,
) as exc:
self.delete(name)
raise TNFRSecurityError("invalid outer shelve envelope") from exc
else:
entry = self._shelf[name]
return self._decode_entry(name, entry)
def store(self, name: str, value: Any) -> None:
if self._signer is None:
stored_value: Any = value
else:
mode, payload = _prepare_payload_bytes(value, protocol=self._protocol)
signature = self._signer(_signature_input(mode, payload))
stored_value = _pack_signed_envelope(mode, payload, signature)
with self._lock:
if self._signer is None:
self._shelf[name] = stored_value
else:
# A fixed bytes-only outer protocol avoids GLOBAL/REDUCE in
# protocols 0-2. The requested protocol still controls the
# authenticated inner value, preserving its public semantics.
self._shelf.dict[name.encode(self._shelf.keyencoding)] = pickle.dumps(
stored_value,
protocol=3,
)
self._shelf.cache.pop(name, None)
self._shelf.sync()
def delete(self, name: str) -> None:
with self._lock:
try:
del self._shelf[name]
except KeyError:
return
self._shelf.sync()
def clear(self) -> None:
with self._lock:
self._shelf.clear()
self._shelf.sync()
def close(self) -> None: # pragma: no cover - exercised indirectly
with self._lock:
self._shelf.close()
def _decode_entry(self, name: str, entry: Any) -> Any:
if isinstance(entry, (bytes, bytearray, memoryview)):
blob = bytes(entry)
if _is_signed_envelope(blob):
try:
mode, signature, payload = _unpack_signed_envelope(blob)
except TNFRSecurityError:
self.delete(name)
raise
validator = self._validator
if validator is None:
if self._require_signature:
self.delete(name)
raise TNFRSecurityError(
"signature validation requested but no validator configured"
)
else:
try:
valid = validator(_signature_input(mode, payload), signature)
except Exception as exc: # pragma: no cover - defensive
self.delete(name)
raise TNFRSecurityError(
"signature validator raised an exception"
) from exc
if not valid:
self.delete(name)
raise TNFRSecurityError(
f"signature validation failed for cache entry {name!r}"
)
try:
return _decode_payload(mode, payload)
except Exception as exc:
self.delete(name)
raise TNFRSecurityError("signed payload decode failure") from exc
if self._require_signature:
self.delete(name)
raise TNFRSecurityError(f"unsigned cache entry rejected: {name}")
return blob
if self._require_signature:
self.delete(name)
raise TNFRSecurityError(f"unsigned cache entry rejected: {name}")
return entry
class RedisCacheLayer(CacheLayer):
"""Distributed cache layer backed by a Redis client.
.. warning::
This layer uses :mod:`pickle` for serialization, which can deserialize
arbitrary Python objects and execute code during deserialization.
**Only cache trusted data** from controlled TNFR nodes. Ensure Redis
uses authentication (AUTH command or ACL for Redis 6.0+) and network
access controls. Never cache untrusted user input or external data.
If Redis is compromised or contains tampered data, pickle deserialization
executes arbitrary code. Use TLS for connections and enable signature
validation (``signer``/``validator`` with ``require_signature=True``)
in high-assurance deployments.
:param signer: Optional callable that produces a signature for payload bytes
before they are written to Redis.
:param validator: Optional callable that validates ``(payload_bytes,
signature)`` during loads.
:param require_signature: Enable hardened mode that deletes and rejects
cache entries whose signatures are missing or invalid, raising
:class:`SecurityError`.
"""
def __init__(
self,
client: Any | None = None,
*,
namespace: str = "tnfr:cache",
signer: Callable[[bytes], bytes] | None = None,
validator: Callable[[bytes, bytes], bool] | None = None,
require_signature: bool = False,
protocol: int | None = None,
) -> None:
if client is None:
try: # pragma: no cover - import guarded for optional dependency
import redis # type: ignore
except Exception as exc: # pragma: no cover - defensive import
raise RuntimeError(
"redis-py is required to initialise RedisCacheLayer"
) from exc
client = redis.Redis()
self._client = client
self._namespace = namespace.rstrip(":") or "tnfr:cache"
self._lock = threading.RLock()
self._signer = signer
self._validator = validator
self._require_signature = require_signature
self._protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
if require_signature and (signer is None or validator is None):
raise TNFRValueError(
"require_signature=True requires both signer and validator",
context={"signer": signer, "validator": validator},
)
# Issue security warning when using unsigned pickle deserialization
if not require_signature and os.environ.get(_TNFR_ALLOW_UNSIGNED_PICKLE) != "1":
warnings.warn(
f"RedisCacheLayer with namespace {namespace!r} uses pickle without signature validation. "
"This can execute arbitrary code if Redis is compromised. "
"Use create_secure_redis_layer() or set require_signature=True with signer/validator. "
f"To suppress this warning, set {_TNFR_ALLOW_UNSIGNED_PICKLE}=1 environment variable.",
TNFRSecurityWarning,
stacklevel=2,
)
def _format_key(self, name: str) -> str:
return f"{self._namespace}:{name}"
def load(self, name: str) -> Any:
key = self._format_key(name)
with self._lock:
value = self._client.get(key)
if value is None:
raise KeyError(name)
if isinstance(value, (bytes, bytearray, memoryview)):
blob = bytes(value)
if _is_signed_envelope(blob):
try:
mode, signature, payload = _unpack_signed_envelope(blob)
except TNFRSecurityError:
self.delete(name)
raise
validator = self._validator
if validator is None:
if self._require_signature:
self.delete(name)
raise TNFRSecurityError(
"signature validation requested but no validator configured"
)
else:
try:
valid = validator(_signature_input(mode, payload), signature)
except Exception as exc: # pragma: no cover - defensive
self.delete(name)
raise TNFRSecurityError(
"signature validator raised an exception"
) from exc
if not valid:
self.delete(name)
raise TNFRSecurityError(
f"signature validation failed for cache entry {name!r}"
)
try:
return _decode_payload(mode, payload)
except Exception as exc:
self.delete(name)
raise TNFRSecurityError("signed payload decode failure") from exc
if self._require_signature:
self.delete(name)
raise TNFRSecurityError(f"unsigned cache entry rejected: {name}")
# pickle from trusted Redis; documented security warning in class docstring
return pickle.loads(blob) # nosec B301
if self._require_signature:
self.delete(name)
raise TNFRSecurityError(f"unsigned nonbinary cache entry rejected: {name}")
return value
def store(self, name: str, value: Any) -> None:
key = self._format_key(name)
if self._signer is None:
payload: Any = value
if not isinstance(value, (bytes, bytearray, memoryview)):
payload = pickle.dumps(value, protocol=self._protocol)
else:
mode, payload_bytes = _prepare_payload_bytes(value, protocol=self._protocol)
signature = self._signer(_signature_input(mode, payload_bytes))
payload = _pack_signed_envelope(mode, payload_bytes, signature)
with self._lock:
self._client.set(key, payload)
def delete(self, name: str) -> None:
key = self._format_key(name)
with self._lock:
self._client.delete(key)
def clear(self) -> None:
pattern = f"{self._namespace}:*"
with self._lock:
if hasattr(self._client, "scan_iter"):
keys = list(self._client.scan_iter(match=pattern))
elif hasattr(self._client, "keys"):
keys = list(self._client.keys(pattern))
else: # pragma: no cover - extremely defensive
keys = []
if keys:
self._client.delete(*keys)