cachekit 0.18.0__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {cachekit-0.18.0 → cachekit-0.19.0}/Cargo.lock +1 -1
  2. {cachekit-0.18.0 → cachekit-0.19.0}/PKG-INFO +14 -2
  3. {cachekit-0.18.0 → cachekit-0.19.0}/README.md +11 -1
  4. {cachekit-0.18.0 → cachekit-0.19.0}/pyproject.toml +44 -20
  5. {cachekit-0.18.0 → cachekit-0.19.0}/rust/Cargo.toml +1 -1
  6. {cachekit-0.18.0 → cachekit-0.19.0}/rust/README.md +11 -1
  7. {cachekit-0.18.0 → cachekit-0.19.0}/rust/TEST_EXPANSION_SUMMARY.md +2 -2
  8. {cachekit-0.18.0 → cachekit-0.19.0}/rust/src/lib.rs +28 -2
  9. cachekit-0.19.0/rust/src/msgpack_bounds.rs +87 -0
  10. {cachekit-0.18.0 → cachekit-0.19.0}/rust/src/python_bindings.rs +108 -38
  11. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/__init__.py +1 -1
  12. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/base.py +13 -7
  13. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/backend.py +34 -7
  14. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/error_handler.py +11 -6
  15. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/errors.py +11 -4
  16. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/file/backend.py +81 -22
  17. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/backend.py +4 -1
  18. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/error_handler.py +18 -8
  19. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/provider.py +10 -8
  20. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/backend.py +5 -5
  21. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/error_handler.py +12 -7
  22. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/provider.py +25 -11
  23. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/cache_handler.py +197 -103
  24. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/intent.py +5 -1
  25. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/orchestrator.py +26 -9
  26. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/session.py +16 -8
  27. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/wrapper.py +276 -105
  28. cachekit-0.19.0/src/cachekit/hash_utils.py +146 -0
  29. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/hiredis_compat.py +5 -3
  30. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/interop.py +2 -4
  31. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/l1_cache.py +15 -5
  32. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/logging.py +30 -129
  33. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/pool_monitor.py +5 -5
  34. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/object_cache.py +1 -1
  35. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/async_metrics.py +4 -2
  36. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/metrics_collection.py +47 -32
  37. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/profiles.py +0 -14
  38. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/__init__.py +3 -2
  39. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/auto_serializer.py +237 -122
  40. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/base.py +119 -0
  41. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/orjson_serializer.py +1 -1
  42. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/standard_serializer.py +15 -9
  43. cachekit-0.18.0/rust/supply-chain/audits.toml +0 -4
  44. cachekit-0.18.0/rust/supply-chain/config.toml +0 -1341
  45. cachekit-0.18.0/rust/supply-chain/imports.lock +0 -2
  46. cachekit-0.18.0/src/cachekit/hash_utils.py +0 -50
  47. {cachekit-0.18.0 → cachekit-0.19.0}/Cargo.toml +0 -0
  48. {cachekit-0.18.0 → cachekit-0.19.0}/LICENSE +0 -0
  49. {cachekit-0.18.0 → cachekit-0.19.0}/rust/Makefile +0 -0
  50. {cachekit-0.18.0 → cachekit-0.19.0}/rust/tsan_suppressions.txt +0 -0
  51. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/__init__.py +0 -0
  52. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/base_config.py +0 -0
  53. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
  54. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/client.py +0 -0
  55. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/config.py +0 -0
  56. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/session.py +0 -0
  57. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/file/__init__.py +0 -0
  58. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/file/config.py +0 -0
  59. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/__init__.py +0 -0
  60. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/config.py +0 -0
  61. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/__init__.py +0 -0
  62. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/client.py +0 -0
  63. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/config.py +0 -0
  64. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/__init__.py +0 -0
  65. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/decorator.py +0 -0
  66. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/nested.py +0 -0
  67. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/settings.py +0 -0
  68. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/singleton.py +0 -0
  69. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/validation.py +0 -0
  70. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/__init__.py +0 -0
  71. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/local_wrapper.py +0 -0
  72. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/main.py +0 -0
  73. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/stats_context.py +0 -0
  74. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/tenant_context.py +0 -0
  75. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/utils/__init__.py +0 -0
  76. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/di.py +0 -0
  77. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/health.py +0 -0
  78. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/imports.py +0 -0
  79. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/key_generator.py +0 -0
  80. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/__init__.py +0 -0
  81. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
  82. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/protocols.py +0 -0
  83. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/py.typed +0 -0
  84. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/__init__.py +0 -0
  85. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
  86. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/error_classification.py +0 -0
  87. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/load_control.py +0 -0
  88. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
  89. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/encryption_wrapper.py +0 -0
  90. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/interop_serializer.py +0 -0
  91. {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/wrapper.py +0 -0
@@ -271,7 +271,7 @@ dependencies = [
271
271
 
272
272
  [[package]]
273
273
  name = "cachekit-rs"
274
- version = "0.18.0"
274
+ version = "0.19.0"
275
275
  dependencies = [
276
276
  "cachekit-core",
277
277
  "criterion",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachekit
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -28,6 +28,8 @@ Requires-Dist: blake3>=1.0.5
28
28
  Requires-Dist: msgpack>=1.2.1
29
29
  Requires-Dist: xxhash>=3.5.0
30
30
  Requires-Dist: httpx[http2]>=0.28.1
31
+ Requires-Dist: anyio>=4.14.2
32
+ Requires-Dist: h2>=4.4.1
31
33
  Requires-Dist: numpy>=2.0.2 ; extra == 'data'
32
34
  Requires-Dist: pandas>=1.3.0 ; extra == 'data'
33
35
  Requires-Dist: pyarrow>=21.0.0 ; extra == 'data'
@@ -288,6 +290,7 @@ def test_cached_function():
288
290
  - Connection pooling with thread affinity (+28% throughput)
289
291
  - Distributed locking prevents cache stampedes
290
292
  - Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom)
293
+ - Untrusted-decode bounds: nesting depth and header-declared allocation are capped on every cache read (a forged entry is a bounded cache miss), verified against the protocol's shared [`decode-bounds.json`](https://github.com/cachekit-io/protocol/blob/2d56cce231e193141f09df9316f9afac17a1538e/test-vectors/decode-bounds.json) vectors
291
294
 
292
295
  > [!NOTE]
293
296
  > All reliability features are **enabled by default** with `@cache.production`. Use `@cache.minimal` to disable them for maximum throughput.
@@ -351,7 +354,7 @@ trial decryption — and old entries age out via TTL, no cache flush required. S
351
354
 
352
355
  cachekit employs comprehensive security tooling:
353
356
 
354
- - **Supply Chain Security**: cargo-deny for license compliance + RustSec scanning
357
+ - **Dependency Security**: cargo-deny for license compliance + cargo-audit for RustSec scanning
355
358
  - **Formal Verification**: Kani proves correctness of compression, checksums, encryption
356
359
  - **Runtime Analysis**: Miri + sanitizers for memory safety
357
360
  - **Fuzzing**: Coverage-guided testing with >80% code coverage
@@ -411,6 +414,15 @@ exposition setup.
411
414
  <details>
412
415
  <summary><strong>Thread Safety Details</strong></summary>
413
416
 
417
+ **Free-threaded CPython (3.14t):** the core suites run green on
418
+ free-threaded 3.14 with the GIL verified disabled (CI job
419
+ `test-freethreaded`), and the Rust extension declares free-threaded safety
420
+ (`gil_used = false`). Free-threaded wheels are **not yet published** and
421
+ free-threaded builds are not officially supported — blocked on upstream
422
+ wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). See
423
+ [measured performance results](docs/free-threading.md#measured-performance) and the
424
+ full concurrency audit: [docs/free-threading.md](docs/free-threading.md).
425
+
414
426
  **Per-Function Statistics:**
415
427
  - Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
416
428
  - Thread-safe via RLock (all methods safe for concurrent access)
@@ -235,6 +235,7 @@ def test_cached_function():
235
235
  - Connection pooling with thread affinity (+28% throughput)
236
236
  - Distributed locking prevents cache stampedes
237
237
  - Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom)
238
+ - Untrusted-decode bounds: nesting depth and header-declared allocation are capped on every cache read (a forged entry is a bounded cache miss), verified against the protocol's shared [`decode-bounds.json`](https://github.com/cachekit-io/protocol/blob/2d56cce231e193141f09df9316f9afac17a1538e/test-vectors/decode-bounds.json) vectors
238
239
 
239
240
  > [!NOTE]
240
241
  > All reliability features are **enabled by default** with `@cache.production`. Use `@cache.minimal` to disable them for maximum throughput.
@@ -298,7 +299,7 @@ trial decryption — and old entries age out via TTL, no cache flush required. S
298
299
 
299
300
  cachekit employs comprehensive security tooling:
300
301
 
301
- - **Supply Chain Security**: cargo-deny for license compliance + RustSec scanning
302
+ - **Dependency Security**: cargo-deny for license compliance + cargo-audit for RustSec scanning
302
303
  - **Formal Verification**: Kani proves correctness of compression, checksums, encryption
303
304
  - **Runtime Analysis**: Miri + sanitizers for memory safety
304
305
  - **Fuzzing**: Coverage-guided testing with >80% code coverage
@@ -358,6 +359,15 @@ exposition setup.
358
359
  <details>
359
360
  <summary><strong>Thread Safety Details</strong></summary>
360
361
 
362
+ **Free-threaded CPython (3.14t):** the core suites run green on
363
+ free-threaded 3.14 with the GIL verified disabled (CI job
364
+ `test-freethreaded`), and the Rust extension declares free-threaded safety
365
+ (`gil_used = false`). Free-threaded wheels are **not yet published** and
366
+ free-threaded builds are not officially supported — blocked on upstream
367
+ wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). See
368
+ [measured performance results](docs/free-threading.md#measured-performance) and the
369
+ full concurrency audit: [docs/free-threading.md](docs/free-threading.md).
370
+
361
371
  **Per-Function Statistics:**
362
372
  - Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
363
373
  - Thread-safe via RLock (all methods safe for concurrent access)
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "cachekit"
7
- version = "0.18.0"
7
+ version = "0.19.0"
8
8
  description = "Backend-agnostic caching for Python — intent-based decorators with circuit breaker, distributed locking, Prometheus metrics, and optional zero-knowledge AES-256-GCM encryption, on a Rust-powered core. Zero-config L1 in-memory; scales to Redis, Memcached, File, or CachekitIO."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -66,6 +66,22 @@ dependencies = [
66
66
  "xxhash>=3.5.0",
67
67
  # HTTP client for SaaS backend (cachekit.io)
68
68
  "httpx[http2]>=0.28.1",
69
+ # anyio is transitive via the mandatory httpx dependency above, so it ships
70
+ # to EVERY install, not just dev. Declared here rather than as a
71
+ # [tool.uv] constraint because that table is uv-local: it never reaches
72
+ # requires-dist, so `pip install cachekit` would ignore it. 4.14.2 fixes
73
+ # GHSA-82r6-8w77-94w6 / CVE-2026-63374 (IDNA-2003 hostname encoding lets a
74
+ # hijacked connection to an internationalised domain pass TLS certificate
75
+ # validation; CVSS 9.3), GHSA-5p39-cfhj-2xmp / CVE-2026-64847 (undrained
76
+ # process-pool stderr pipe deadlocks the worker) and GHSA-3w57-8xmc-8v26 /
77
+ # CVE-2026-63349 (extra_groups ignored, parent supplementary groups kept).
78
+ "anyio>=4.14.2",
79
+ # h2 arrives via the http2 extra on that same mandatory httpx dependency, so
80
+ # it ships to every install too, and was declared as a [tool.uv] constraint
81
+ # with the same no-op effect. 4.4.1 fixes GHSA-6hr6-w5qg-qmwg (duplicate Host
82
+ # headers forwarded across an HTTP/2 -> HTTP/1.1 downgrade — a request
83
+ # smuggling primitive).
84
+ "h2>=4.4.1",
69
85
  ]
70
86
 
71
87
  [project.optional-dependencies]
@@ -150,7 +166,6 @@ asyncio_default_fixture_loop_scope = "function"
150
166
  addopts = [
151
167
  "--strict-markers",
152
168
  "--verbose",
153
- "--basetemp=/tmp/pytest",
154
169
  "--doctest-modules", # Validate docstring examples
155
170
  "--doctest-continue-on-failure", # Report all doctest failures, not just first
156
171
  "--markdown-docs", # Validate markdown documentation examples
@@ -200,8 +215,12 @@ exclude_lines = [
200
215
  ]
201
216
 
202
217
  [dependency-groups]
203
- dev = [
204
- # Testing
218
+ # Core test toolchain — everything tests/unit + tests/critical need on ANY
219
+ # interpreter, including free-threaded CPython: the free-threaded CI lane
220
+ # installs ONLY this group (LAB-511). A dep may live here only if it ships
221
+ # free-threaded wheels or builds cleanly from source on 3.14t; deps that
222
+ # don't (orjson, numpy, pandas, pyarrow) stay in dev below.
223
+ test = [
205
224
  "fakeredis>=2.21.0",
206
225
  "pytest>=7.0.0",
207
226
  "pytest-asyncio>=0.21.0",
@@ -210,6 +229,19 @@ dev = [
210
229
  "pytest-markdown-docs>=0.6.0",
211
230
  "pytest-redis>=3.0.0",
212
231
  "pymemcache>=4.0.0",
232
+ # Utilities
233
+ "faker>=20.0.0",
234
+ "httpx>=0.28.1",
235
+ "hypothesis>=6.0.0",
236
+ "requests>=2.33.0; python_version >= '3.10'",
237
+ "psutil>=5.9.0",
238
+ "python-dotenv>=1.0.0",
239
+ "pyyaml>=6.0.3",
240
+ "pytest-xdist>=3.8.0",
241
+ "time-machine>=2.19.0",
242
+ ]
243
+ dev = [
244
+ { include-group = "test" },
213
245
  # Competitive comparison suite (tests/competitive/ benchmarks cachekit vs these)
214
246
  "cachetools>=5.3.0",
215
247
  "aiocache>=0.12.0",
@@ -218,14 +250,7 @@ dev = [
218
250
  "ruff>=0.6.0",
219
251
  # Utilities
220
252
  "bashlex>=0.18",
221
- "faker>=20.0.0",
222
- "httpx>=0.28.1",
223
- "hypothesis>=6.0.0",
224
253
  "pip-audit>=2.7.0",
225
- "requests>=2.33.0; python_version >= '3.10'",
226
- "psutil>=5.9.0",
227
- "python-dotenv>=1.0.0",
228
- "pyyaml>=6.0.3",
229
254
  # Data science support (for testing AutoSerializer with numpy/pandas)
230
255
  "numpy>=2.0.2",
231
256
  "pandas>=1.3.0",
@@ -233,15 +258,17 @@ dev = [
233
258
  # OrjsonSerializer support — now the [json] optional extra; kept here so the
234
259
  # orjson tests, doctests, and markdown-docs still resolve it in dev/CI.
235
260
  "orjson>=3.9.0",
236
- "pytest-xdist>=3.8.0",
237
- "time-machine>=2.19.0",
238
261
  ]
239
262
  # Linux CI only - Atheris requires libFuzzer (not available on macOS without building LLVM)
240
263
  fuzz = [
241
264
  "atheris>=2.3.0",
242
265
  ]
243
266
 
244
- # Override vulnerable transitive dependencies
267
+ # Override vulnerable DEV-ONLY transitive dependencies.
268
+ # This table is uv-local: it constrains resolution of this repo's lockfile and
269
+ # never reaches requires-dist, so a floor placed here does NOT protect anyone who
270
+ # runs `pip install cachekit`. A transitive that reaches users belongs in
271
+ # [project] dependencies instead — see the anyio/h2 entries above.
245
272
  [tool.uv]
246
273
  constraint-dependencies = [
247
274
  "urllib3>=2.7.0",
@@ -249,10 +276,7 @@ constraint-dependencies = [
249
276
  "werkzeug>=3.1.4",
250
277
  # pip is a dev-only transitive dep (pip-audit -> pip-api -> pip). 26.1.2 fixes
251
278
  # PYSEC-2026-196 (entry-point path traversal), GHSA-58qw-9mgm-455v (tar/zip
252
- # confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering).
253
- "pip>=26.1.2",
254
- # h2 is a transitive dep (httpx[http2] -> h2). 4.4.1 fixes
255
- # GHSA-6hr6-w5qg-qmwg (duplicate Host headers forwarded on HTTP/2 ->
256
- # HTTP/1.1 downgrade — request smuggling primitive).
257
- "h2>=4.4.1",
279
+ # confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering); 26.2 fixes
280
+ # PYSEC-2026-3721 (doubly-encoded index URLs install to arbitrary paths).
281
+ "pip>=26.2",
258
282
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "cachekit-rs"
3
- version = "0.18.0"
3
+ version = "0.19.0"
4
4
  edition = "2021"
5
5
  authors = ["cachekit Contributors"]
6
6
  description = "High-performance storage engine for caching with compression and encryption"
@@ -235,6 +235,7 @@ def test_cached_function():
235
235
  - Connection pooling with thread affinity (+28% throughput)
236
236
  - Distributed locking prevents cache stampedes
237
237
  - Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom)
238
+ - Untrusted-decode bounds: nesting depth and header-declared allocation are capped on every cache read (a forged entry is a bounded cache miss), verified against the protocol's shared [`decode-bounds.json`](https://github.com/cachekit-io/protocol/blob/2d56cce231e193141f09df9316f9afac17a1538e/test-vectors/decode-bounds.json) vectors
238
239
 
239
240
  > [!NOTE]
240
241
  > All reliability features are **enabled by default** with `@cache.production`. Use `@cache.minimal` to disable them for maximum throughput.
@@ -298,7 +299,7 @@ trial decryption — and old entries age out via TTL, no cache flush required. S
298
299
 
299
300
  cachekit employs comprehensive security tooling:
300
301
 
301
- - **Supply Chain Security**: cargo-deny for license compliance + RustSec scanning
302
+ - **Dependency Security**: cargo-deny for license compliance + cargo-audit for RustSec scanning
302
303
  - **Formal Verification**: Kani proves correctness of compression, checksums, encryption
303
304
  - **Runtime Analysis**: Miri + sanitizers for memory safety
304
305
  - **Fuzzing**: Coverage-guided testing with >80% code coverage
@@ -358,6 +359,15 @@ exposition setup.
358
359
  <details>
359
360
  <summary><strong>Thread Safety Details</strong></summary>
360
361
 
362
+ **Free-threaded CPython (3.14t):** the core suites run green on
363
+ free-threaded 3.14 with the GIL verified disabled (CI job
364
+ `test-freethreaded`), and the Rust extension declares free-threaded safety
365
+ (`gil_used = false`). Free-threaded wheels are **not yet published** and
366
+ free-threaded builds are not officially supported — blocked on upstream
367
+ wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). See
368
+ [measured performance results](docs/free-threading.md#measured-performance) and the
369
+ full concurrency audit: [docs/free-threading.md](docs/free-threading.md).
370
+
361
371
  **Per-Function Statistics:**
362
372
  - Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
363
373
  - Thread-safe via RLock (all methods safe for concurrent access)
@@ -55,7 +55,7 @@ Expanded Rust test suite from 900+ tests to **1100+ tests** by adding critical s
55
55
  - `test_subtle_multi_byte_patterns`: XOR, swap, increment, block corruption
56
56
 
57
57
  **Validation**:
58
- - Blake3 checksums detect all multi-byte corruption patterns
58
+ - xxHash3-64 checksums detect all tested multi-byte corruption patterns
59
59
  - Corruption at any offset (start/middle/end) detected
60
60
  - Subtle patterns (swap, increment, aligned blocks) caught
61
61
 
@@ -234,7 +234,7 @@ cargo test --tests --features compression,encryption
234
234
 
235
235
  1. **Nonce Generation**: Counter-based approach ([random_iv(8)][counter(4)]) is provably collision-free up to 2^32 operations per instance
236
236
  2. **Atomicity**: AtomicU64 with SeqCst ordering ensures thread safety across PyO3 boundary
237
- 3. **Corruption Detection**: Blake3 checksums detect all tested corruption patterns (single/multi-byte, any offset, subtle patterns)
237
+ 3. **Corruption Detection**: xxHash3-64 checksums detect all tested corruption patterns (single/multi-byte, any offset, subtle patterns)
238
238
  4. **Truncation Handling**: All truncation scenarios properly rejected with clear error messages
239
239
  5. **Large Payload Efficiency**: System handles 50MB+ payloads without OOM, with good compression ratios
240
240
  6. **Concurrent Safety**: No race conditions or corruption detected in stress tests with 100+ threads
@@ -1,11 +1,15 @@
1
1
  //! `PyO3` bindings for `cachekit-core`
2
2
  //!
3
3
  //! This crate provides thin Python wrappers around the cachekit-core library.
4
- //! All business logic lives in cachekit-core; this crate only handles Python FFI.
4
+ //! Business logic lives in cachekit-core, with one SDK-owned exception: the untrusted
5
+ //! msgpack decode bound in `msgpack_bounds` (LAB-2503), pending a core-shared walk.
5
6
 
6
7
  // Re-export core types for use in Python bindings
7
8
  pub use cachekit_core::{ByteStorage, OperationMetrics, StorageEnvelope};
8
9
 
10
+ /// Untrusted msgpack structural bound — pure Rust, not gated on `python`
11
+ pub mod msgpack_bounds;
12
+
9
13
  #[cfg(feature = "encryption")]
10
14
  pub use cachekit_core::{
11
15
  derive_domain_key,
@@ -21,17 +25,39 @@ pub mod python_bindings;
21
25
  use pyo3::prelude::*;
22
26
 
23
27
  /// Python module definition - exports raw byte storage and encryption
28
+ ///
29
+ /// `gil_used = false` (the PyO3 0.28+ default, made explicit): declares the
30
+ /// module thread-safe under free-threaded CPython so importing it does not
31
+ /// force the GIL back on. Verified by the LAB-511 audit: every `#[pyclass]`
32
+ /// exposes only `&self` methods, and shared state in cachekit-core is
33
+ /// `AtomicU64` (nonce counter) or `Mutex` (metrics) — no interior mutability
34
+ /// the GIL was papering over. PyO3 enforces `Send + Sync` on every pyclass at
35
+ /// compile time.
24
36
  #[cfg(feature = "python")]
25
- #[pymodule]
37
+ #[pymodule(gil_used = false)]
26
38
  fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
27
39
  // Add byte storage class
28
40
  m.add_class::<python_bindings::PyByteStorage>()?;
29
41
 
42
+ // ByteStorage.retrieve() envelope-verification-failure taxonomy (LAB-2736). Same
43
+ // __module__ patch as KeyringConfigurationError below: create_exception! sets it to the
44
+ // bare "_rust_serializer", which breaks pickling back to a parent process otherwise.
45
+ let envelope_integrity_error = m.py().get_type::<python_bindings::EnvelopeIntegrityError>();
46
+ envelope_integrity_error.setattr("__module__", "cachekit._rust_serializer")?;
47
+ m.add("EnvelopeIntegrityError", envelope_integrity_error)?;
48
+
30
49
  // Standalone integrity primitive — registered unconditionally (usable with
31
50
  // the checksum feature alone; must not vanish when encryption is off)
32
51
  m.add_function(wrap_pyfunction!(python_bindings::checksum_py, m)?)?;
33
52
  m.add_function(wrap_pyfunction!(python_bindings::verify_checksum_py, m)?)?;
34
53
 
54
+ // Untrusted-decode structural bound (LAB-2503) — zero-copy header walk that
55
+ // serializers/base.py::unpackb_bounded runs before every msgpack.unpackb
56
+ m.add_function(wrap_pyfunction!(
57
+ python_bindings::check_msgpack_structure_py,
58
+ m
59
+ )?)?;
60
+
35
61
  // Add encryption functionality if feature is enabled
36
62
  #[cfg(feature = "encryption")]
37
63
  {
@@ -0,0 +1,87 @@
1
+ //! Structural bound for untrusted MessagePack (LAB-2503; protocol spec/interop-mode.md →
2
+ //! Decode bounds). The one algorithm this crate owns rather than delegates to cachekit-core;
3
+ //! a core-shared walk usable from py/rs/wasm is the follow-up. Mirrors the opcode table of
4
+ //! cachekit-rs `check_structure` so the two SDKs reject the same documents.
5
+
6
+ /// Header-only walk over one `MessagePack` document: str/bin/ext payloads are skipped by
7
+ /// offset, never read, and nothing is allocated beyond one `u64` per open collection.
8
+ ///
9
+ /// Trailing bytes after the root element are left to the decoder (`ExtraData`).
10
+ ///
11
+ /// # Errors
12
+ ///
13
+ /// Names the violated bound, before any decoder pre-allocates a container, for:
14
+ /// - nesting deeper than `max_depth`;
15
+ /// - a header declaring more payload bytes than the input holds;
16
+ /// - more pending elements (across every open collection) than remaining bytes can back —
17
+ /// every element costs >= 1 byte, so a decoder's total container pre-allocation is then
18
+ /// bounded by the input length instead of by `depth × declared_len`;
19
+ /// - the reserved marker 0xc1 and input that ends mid-document.
20
+ pub fn check_msgpack_structure(bytes: &[u8], max_depth: usize) -> Result<(), String> {
21
+ fn be(bytes: &[u8], pos: usize, width: usize) -> Result<u64, String> {
22
+ let end = pos
23
+ .checked_add(width)
24
+ .filter(|e| *e <= bytes.len())
25
+ .ok_or_else(|| "ends inside a length prefix".to_owned())?;
26
+ Ok(bytes[pos..end]
27
+ .iter()
28
+ .fold(0u64, |acc, b| (acc << 8) | u64::from(*b)))
29
+ }
30
+
31
+ let mut pos = 0usize;
32
+ let mut pending: u64 = 1; // elements owed across all open collections (the root is one)
33
+ let mut open: Vec<u64> = Vec::new(); // elements still owed per open collection = depth
34
+ while pending > 0 {
35
+ while open.last() == Some(&0) {
36
+ open.pop();
37
+ }
38
+ let marker = *bytes
39
+ .get(pos)
40
+ .ok_or_else(|| "ends before the document is complete".to_owned())?;
41
+ pos += 1;
42
+ pending -= 1;
43
+ if let Some(innermost) = open.last_mut() {
44
+ *innermost -= 1;
45
+ }
46
+ // (length-prefix bytes, payload bytes after the prefix, child elements)
47
+ let (prefix, payload, children): (usize, u64, u64) = match marker {
48
+ 0x00..=0x7f | 0xc0 | 0xc2 | 0xc3 | 0xe0..=0xff => (0, 0, 0),
49
+ 0x80..=0x8f => (0, 0, 2 * u64::from(marker & 0x0f)),
50
+ 0x90..=0x9f => (0, 0, u64::from(marker & 0x0f)),
51
+ 0xa0..=0xbf => (0, u64::from(marker & 0x1f), 0),
52
+ 0xc1 => return Err("contains the reserved marker 0xc1".to_owned()),
53
+ 0xc4 | 0xd9 => (1, be(bytes, pos, 1)?, 0),
54
+ 0xc5 | 0xda => (2, be(bytes, pos, 2)?, 0),
55
+ 0xc6 | 0xdb => (4, be(bytes, pos, 4)?, 0),
56
+ 0xc7 => (1, be(bytes, pos, 1)? + 1, 0), // ext: length prefix, then type byte + data
57
+ 0xc8 => (2, be(bytes, pos, 2)? + 1, 0),
58
+ 0xc9 => (4, be(bytes, pos, 4)? + 1, 0),
59
+ 0xca..=0xd3 => (0, 1u64 << (marker & 0x03), 0), // f32/f64/u8..u64/i8..i64: 4,8,1,2,4,8,1,2,4,8
60
+ 0xd4..=0xd8 => (0, 1 + (1u64 << (marker - 0xd4)), 0), // fixext: type byte + 1/2/4/8/16
61
+ 0xdc => (2, 0, be(bytes, pos, 2)?),
62
+ 0xdd => (4, 0, be(bytes, pos, 4)?),
63
+ 0xde => (2, 0, 2 * be(bytes, pos, 2)?),
64
+ 0xdf => (4, 0, 2 * be(bytes, pos, 4)?),
65
+ };
66
+ pos += prefix;
67
+ let remaining = (bytes.len() - pos) as u64;
68
+ if payload > remaining {
69
+ return Err("declares more bytes than the input holds".to_owned());
70
+ }
71
+ // <= remaining, so this cannot fail; `try_from` rather than `as usize` satisfies
72
+ // clippy::cast_possible_truncation, line-for-line with cachekit-rs `check_structure`.
73
+ pos += usize::try_from(payload)
74
+ .map_err(|_| "declares more bytes than the input holds".to_owned())?;
75
+ if children > 0 {
76
+ if open.len() >= max_depth {
77
+ return Err(format!("nests deeper than {max_depth} levels"));
78
+ }
79
+ open.push(children);
80
+ }
81
+ pending += children;
82
+ if pending > remaining - payload {
83
+ return Err("declares more elements than the input can back".to_owned());
84
+ }
85
+ }
86
+ Ok(())
87
+ }
@@ -1,14 +1,54 @@
1
1
  //! Python bindings for cachekit-core
2
2
  //!
3
- //! This module provides thin PyO3 wrappers around cachekit-core functionality.
4
- //! All business logic is delegated to cachekit-core.
3
+ //! This module provides thin PyO3 wrappers around cachekit-core functionality, plus the
4
+ //! buffer-borrow helper they share. Business logic lives in cachekit-core, except the
5
+ //! SDK-owned msgpack decode bound in `crate::msgpack_bounds`.
5
6
 
7
+ use crate::msgpack_bounds::check_msgpack_structure;
8
+ use cachekit_core::byte_storage::ByteStorageError;
6
9
  use cachekit_core::ByteStorage;
7
10
  use pyo3::buffer::PyBuffer;
8
11
  use pyo3::exceptions::PyValueError;
9
12
  use pyo3::prelude::*;
10
13
  use pyo3::types::PyBytes;
11
14
 
15
+ pyo3::create_exception!(
16
+ _rust_serializer,
17
+ EnvelopeIntegrityError,
18
+ PyValueError,
19
+ "A ByteStorage envelope parsed but failed verification: checksum mismatch, decompression\n\
20
+ bomb/failure, or a decoded size mismatch against the envelope header.\n\
21
+ \n\
22
+ Distinguishes a verified-but-corrupt envelope (this exception) from bytes that were never\n\
23
+ a ByteStorage envelope at all (`DeserializationFailed`, e.g. written with integrity\n\
24
+ checking off) — the latter stays a plain `ValueError`. Whether a caller may fall through\n\
25
+ on that `ValueError` is the caller's contract (`AutoSerializer.deserialize` does so only\n\
26
+ for a metadata-less direct call); this exception must always fail closed.\n\
27
+ \n\
28
+ Subclasses ValueError so existing `pytest.raises(ValueError)` assertions on `retrieve()`\n\
29
+ failures stay valid. `AutoSerializer.deserialize` catches this specifically and re-raises\n\
30
+ it as `SerializationError` without falling through."
31
+ );
32
+
33
+ /// Map a cachekit-core `retrieve()` failure onto the Python exception taxonomy.
34
+ ///
35
+ /// `DeserializationFailed` means `envelope_bytes` never parsed as a `StorageEnvelope` — not
36
+ /// corruption, just "not an envelope" — so it stays a plain `ValueError`, the fall-through
37
+ /// signal `AutoSerializer.deserialize` depends on. Every other variant is mapped to
38
+ /// `EnvelopeIntegrityError` and must fail closed: the post-parse checks (checksum, decompressed
39
+ /// size, decompression itself, the compression-ratio bomb guard) are genuine corruption or
40
+ /// tampering, and `InputTooLarge` — raised on the raw `envelope_bytes` length before parsing is
41
+ /// even attempted — is deliberately bucketed the same way rather than treated as "not an
42
+ /// envelope": falling through would hand an oversized blob to the plain-msgpack decode path
43
+ /// instead of rejecting it outright, trading one size guard for a weaker one.
44
+ fn retrieve_error_to_py(err: ByteStorageError) -> PyErr {
45
+ let message = format!("Retrieval failed: {}", err);
46
+ match err {
47
+ ByteStorageError::DeserializationFailed(_) => PyValueError::new_err(message),
48
+ _ => EnvelopeIntegrityError::new_err(message),
49
+ }
50
+ }
51
+
12
52
  /// Python wrapper for ByteStorage
13
53
  #[pyclass(name = "ByteStorage")]
14
54
  pub struct PyByteStorage {
@@ -44,6 +84,67 @@ fn borrowable_offset(buf: &PyBuffer<u8>, base: &Bound<'_, PyBytes>) -> Option<us
44
84
  .then(|| ptr - start)
45
85
  }
46
86
 
87
+ /// A read-only view of a Python buffer-protocol object's bytes, borrowed without a copy
88
+ /// whenever that is provably sound and copied otherwise. Holds whatever keeps the memory
89
+ /// alive (the `bytes` object, or the owned copy) so `as_slice` needs no `unsafe`.
90
+ enum BytesView<'py> {
91
+ /// `(base, offset, len)`: a window onto an immutable `bytes` object kept alive by the
92
+ /// Bound — the whole object, or the read-only C-contiguous `memoryview` of it that
93
+ /// `SerializationWrapper.unwrap` produces, proven by `borrowable_offset`. Zero-copy.
94
+ Borrowed(Bound<'py, PyBytes>, usize, usize),
95
+ /// Mutable, non-`bytes`-backed, strided, or empty exporter: the only safe answer is a copy.
96
+ Owned(Vec<u8>),
97
+ }
98
+
99
+ impl BytesView<'_> {
100
+ fn as_slice(&self) -> &[u8] {
101
+ match self {
102
+ BytesView::Borrowed(base, off, len) => &base.as_bytes()[*off..*off + *len],
103
+ BytesView::Owned(v) => v,
104
+ }
105
+ }
106
+ }
107
+
108
+ /// Borrow `obj`'s bytes zero-copy when the BACKING STORAGE is provably immutable, else copy.
109
+ ///
110
+ /// `readonly()` describes the view, not the exporter (`memoryview(bytearray).toreadonly()`
111
+ /// passes it while another thread can still mutate the bytearray), and a PEP 688
112
+ /// `__buffer__` exporter can name a decoy `bytes` in `.obj` — so the gate is the containment
113
+ /// proof in `borrowable_offset`, whose payoff is that the borrow is an ORDINARY SLICE of that
114
+ /// `bytes`: bounds-checked by Rust, no `unsafe`, nothing for a stale comment to misstate.
115
+ fn bytes_view<'py>(py: Python<'py>, obj: &Bound<'py, PyAny>) -> PyResult<BytesView<'py>> {
116
+ if let Ok(b) = obj.cast::<PyBytes>() {
117
+ return Ok(BytesView::Borrowed(b.clone(), 0, b.len()?));
118
+ }
119
+ let buf = PyBuffer::<u8>::get(obj)?;
120
+ let base = obj
121
+ .getattr("obj")
122
+ .ok()
123
+ .and_then(|base| base.cast_into::<PyBytes>().ok());
124
+ if let Some(base) = base {
125
+ if let Some(off) = borrowable_offset(&buf, &base) {
126
+ return Ok(BytesView::Borrowed(base, off, buf.item_count()));
127
+ }
128
+ }
129
+ Ok(BytesView::Owned(buf.to_vec(py)?))
130
+ }
131
+
132
+ /// Reject a MessagePack document whose headers would make decoding it allocate out of
133
+ /// proportion to its size — see `check_msgpack_structure`. Zero-copy for `bytes` and for
134
+ /// read-only `memoryview`s of `bytes`; raises ValueError naming the violated bound.
135
+ #[pyfunction]
136
+ #[pyo3(name = "check_msgpack_structure")]
137
+ pub fn check_msgpack_structure_py(
138
+ py: Python<'_>,
139
+ data: &Bound<'_, PyAny>,
140
+ max_depth: usize,
141
+ ) -> PyResult<()> {
142
+ let view = bytes_view(py, data)?;
143
+ check_msgpack_structure(view.as_slice(), max_depth).map_err(|what| {
144
+ PyValueError::new_err(format!("Unpack failed: MessagePack document {what}"))
145
+ })
146
+ }
147
+
47
148
  #[pymethods]
48
149
  impl PyByteStorage {
49
150
  #[new]
@@ -86,44 +187,13 @@ impl PyByteStorage {
86
187
  py: Python,
87
188
  envelope_bytes: &Bound<'_, PyAny>,
88
189
  ) -> PyResult<(Vec<u8>, String)> {
89
- let owned: Vec<u8>;
90
- let buf: PyBuffer<u8>;
91
- let base_bytes: Option<Bound<'_, PyBytes>>;
92
- let data: &[u8] = if let Ok(b) = envelope_bytes.cast::<PyBytes>() {
93
- // `bytes` is immutable and kept alive by the Bound for the whole call:
94
- // a zero-copy borrow with no data-race exposure.
95
- b.as_bytes()
96
- } else {
97
- buf = PyBuffer::get(envelope_bytes)?;
98
- // Borrowing across the GIL release below is only sound when the BACKING
99
- // STORAGE is immutable — readonly() describes the view, not the exporter
100
- // (memoryview(bytearray).toreadonly() passes it while another thread can
101
- // still mutate the bytearray). Attribute trust is not enough either: a
102
- // PEP 688 __buffer__ exporter can name a decoy `bytes` in `.obj`. So the
103
- // gate is a containment proof (borrowable_offset), and its payoff is that
104
- // the borrow becomes expressible as an ORDINARY SLICE of that `bytes` —
105
- // bounds-checked by Rust, no `unsafe`, nothing for a stale comment to
106
- // misstate. Anything unproven falls back to a copy.
107
- base_bytes = envelope_bytes
108
- .getattr("obj")
109
- .ok()
110
- .and_then(|base| base.cast_into::<PyBytes>().ok());
111
- let borrowed = base_bytes.as_ref().and_then(|base| {
112
- borrowable_offset(&buf, base)
113
- .map(|off| &base.as_bytes()[off..off + buf.item_count()])
114
- });
115
- match borrowed {
116
- Some(slice) => slice,
117
- None => {
118
- // Mutable, non-bytes-backed, non-contiguous, or empty exporter.
119
- owned = buf.to_vec(py)?;
120
- &owned
121
- }
122
- }
123
- };
190
+ // Borrowing across the GIL release below is only sound when the backing storage
191
+ // is immutable — bytes_view proves that or copies (see its doc).
192
+ let view = bytes_view(py, envelope_bytes)?;
193
+ let data = view.as_slice();
124
194
  // Detach from the GIL for decompression + checksum (see store()).
125
195
  py.detach(|| self.inner.retrieve(data))
126
- .map_err(|e| PyValueError::new_err(format!("Retrieval failed: {}", e)))
196
+ .map_err(retrieve_error_to_py)
127
197
  }
128
198
 
129
199
  /// Get compression ratio for given data
@@ -68,7 +68,7 @@ Example Usage:
68
68
  ```
69
69
  """
70
70
 
71
- __version__ = "0.18.0"
71
+ __version__ = "0.19.0"
72
72
 
73
73
  from collections.abc import Callable
74
74
  from typing import Any, TypeVar
@@ -11,6 +11,7 @@ enable advanced features with graceful degradation.
11
11
  from __future__ import annotations
12
12
 
13
13
  from collections.abc import AsyncIterator, Callable
14
+ from contextlib import AbstractAsyncContextManager
14
15
  from typing import Any, BinaryIO, Optional, Protocol, runtime_checkable
15
16
 
16
17
  # Re-export BackendError for convenience (public API)
@@ -269,8 +270,11 @@ class LockableBackend(Protocol):
269
270
  features like cache stampede prevention and critical sections.
270
271
 
271
272
  Not all backends support this capability:
272
- - Supported: RedisBackend, CachekitIOBackend (SaaS ``POST /v1/cache/{key}/lock``)
273
- - Not supported: FileBackend, L1-only (in-memory)
273
+ - Supported: ``PerRequestRedisBackend`` what ``RedisBackendProvider`` and
274
+ therefore the env-resolved Redis path hand out — and ``CachekitIOBackend``
275
+ (SaaS ``POST /v1/cache/{key}/lock``).
276
+ - Not supported: ``RedisBackend`` constructed directly and passed as
277
+ ``backend=``, ``FileBackend``, L1-only (in-memory).
274
278
 
275
279
  Contract — bare cache key:
276
280
  ``acquire_lock`` receives the **bare cache key**, identical to what
@@ -293,12 +297,12 @@ class LockableBackend(Protocol):
293
297
  >>> # result = expensive_computation()
294
298
  """
295
299
 
296
- async def acquire_lock(
300
+ def acquire_lock(
297
301
  self,
298
302
  key: str,
299
303
  timeout: float,
300
304
  blocking_timeout: Optional[float] = None,
301
- ) -> AsyncIterator[bool]:
305
+ ) -> AbstractAsyncContextManager[bool]:
302
306
  """Acquire a distributed lock on key.
303
307
 
304
308
  Args:
@@ -309,9 +313,11 @@ class LockableBackend(Protocol):
309
313
  timeout: How long to hold the lock (seconds) before auto-release
310
314
  blocking_timeout: Max time to wait for lock acquisition (None = non-blocking)
311
315
 
312
- Yields:
313
- True if lock was acquired
314
- False if timeout occurred waiting for lock
316
+ Returns:
317
+ An async context manager yielding True if the lock was acquired,
318
+ False if the wait timed out. Implementations are ``async``
319
+ generators wrapped in ``@asynccontextmanager``, so this protocol
320
+ declares the *decorated* shape.
315
321
 
316
322
  Raises:
317
323
  BackendError: If backend operation fails