Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 38 additions & 27 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ cache.set_with_ttl(&key, &user, ttl).await?; // plain MessagePack — a
let user: Option<User> = cache.interop_get(&key).await?; // strict read: exactly one document
```

Argument hashing is byte-identical across SDKs (canonical MessagePack + Blake2b-256), verified against the shared [protocol test vectors](https://github.com/cachekit-io/protocol/blob/main/test-vectors/interop-mode.json) in this repo's test suite. `interop_get` (also on `SecureCache`) rejects trailing bytes and Python-internal CK frames instead of silently misreading them. Encryption works unchanged — interop keys are identical across SDKs, so the AAD verifies cross-SDK.
Argument hashing is byte-identical across SDKs (canonical MessagePack + Blake2b-256), verified against the shared [protocol](https://github.com/cachekit-io/protocol) test vectors ([`interop-mode.json`](crates/cachekit/tests/vectors/interop-mode.json), vendored) in this repo's test suite. `interop_get` (also on `SecureCache`) rejects trailing bytes and Python-internal CK frames instead of silently misreading them. Every decode of backend-supplied bytes (`get` and `interop_get` alike) runs under an explicit nesting-depth bound (`serializer::MAX_DECODE_DEPTH` = 100, matching the TypeScript SDK) and a header-only structural walk that rejects headers declaring more than the input can back, verified against the protocol's shared [`decode-bounds.json`](crates/cachekit/tests/vectors/decode-bounds.json) vectors (vendored) — a forged nested-header entry is a bounded `Serialization` error, not a memory blow-up or a stack overflow. Encryption works unchanged — interop keys are identical across SDKs, so the AAD verifies cross-SDK.

> [!IMPORTANT]
> Use interop keys on a client **without** `.namespace()` — a client prefix would rewrite the storage key to `{prefix}:{interop_key}`, which no other SDK computes. `interop_get` fails closed with a config error rather than silently missing; interop keys already carry their own namespace segment.
Expand Down
4 changes: 2 additions & 2 deletions crates/cachekit/src/backend/memcached.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! different key. The ASCII `get` validates the echoed key, and a desynced
//! connection fails its next pool-checkout ping and is discarded. Every
//! operation additionally runs under a hard async time budget (see
//! [`MemcachedBackendBuilder::timeout`]) so a wedged server surfaces as a
//! [`crate::backend::memcached::MemcachedBackendBuilder::timeout`]) so a wedged server surfaces as a
//! `Timeout` error instead of hanging callers.
//!
//! ## TTL capability — the honest parity picture
Expand All @@ -24,7 +24,7 @@
//!
//! This backend mirrors that exactly: no [`TtlInspectable`] impl (the trait
//! requires the unreadable `ttl()`), and a bare inherent
//! [`refresh_ttl`](MemcachedBackend::refresh_ttl) wrapping `touch`. Rust
//! [`refresh_ttl`](crate::backend::memcached::MemcachedBackend::refresh_ttl) wrapping `touch`. Rust
//! *could* read TTLs via the meta protocol (`mg <key> t`, memcached >= 1.6),
//! but shipping a capability py cannot match would make TTL-driven behaviour
//! diverge between SDKs on the same cluster. Revisit only when cachekit-py
Expand Down
15 changes: 9 additions & 6 deletions crates/cachekit/src/interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
//! sorted by encoded bytes, and integral-float collapse. `rmp-serde` happens to
//! emit shortest forms but provides no sorting, no set semantics, and no number
//! canonicalization — hashing whatever serde produces would make key equality an
//! implementation accident. The closed [`InteropValue`] model plus an explicit
//! implementation accident. The closed [`crate::interop::InteropValue`] model plus an explicit
//! encoder is the only way to guarantee byte-identical hashes across SDKs.
//!
//! # Values
Expand All @@ -34,7 +34,7 @@
//! (via [`crate::serializer`]), so regular [`crate::CacheKit::set`] output is
//! interop-readable as-is. Reads are the sharp edge: interop readers MUST
//! consume exactly one MessagePack document and reject trailing bytes — see
//! [`deserialize`].
//! [`crate::interop::deserialize`].
//!
//! # Example
//!
Expand Down Expand Up @@ -344,6 +344,9 @@ pub fn serialize_value(value: &InteropValue) -> Result<Vec<u8>, CachekitError> {
/// Deserialize an interop-mode MessagePack document, consuming **exactly one**
/// document and rejecting trailing bytes (spec MUST).
///
/// Decode bounds: [`crate::serializer::MAX_DECODE_DEPTH`] and the header walk in
/// `crate::serializer::check_structure` (LAB-2503).
///
/// `rmp_serde::from_slice` silently ignores trailing bytes. That leniency is
/// dangerous here: a Python-SDK-internal CK frame begins `0x43` (`'C'`), which
/// is a *complete* one-byte MessagePack document (positive fixint 67) — a
Expand All @@ -365,13 +368,13 @@ pub fn deserialize<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CachekitError
));
}

// `Read for &[u8]` advances the slice, so `remaining` ends up holding
// whatever the decoder did not consume.
let mut remaining: &[u8] = bytes;
let mut de = rmp_serde::Deserializer::new(&mut remaining);
let mut de = crate::serializer::bounded_deserializer(bytes)?;
let value = T::deserialize(&mut de)
.map_err(|e| CachekitError::Serialization(format!("interop decode: {e}")))?;

// `Read for &[u8]` advances the slice, so the reader now holds exactly the
// bytes the decoder did not consume.
let remaining: &[u8] = de.into_inner();
if !remaining.is_empty() {
return Err(CachekitError::Serialization(format!(
"interop payload has {} trailing byte(s) after the MessagePack document — \
Expand Down
Loading