From af3b54f481e1ae50c72b22614acf053886ff62cb Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 7 Nov 2026 01:51:19 +0000 Subject: [PATCH 1/7] bench: add l1_hit_row/enc, an encrypted L1 hit on a 20-field struct --- README.md | 4 ++-- crates/cachekit/examples/bench_hot_path.rs | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 69e8659..996bd2a 100644 --- a/README.md +++ b/README.md @@ -928,8 +928,8 @@ targets turn on. `l1_hit_row` reads an L1 hit whose value is a 20-field struct, stored as a MessagePack map of 20 entries, so it prices decoding a many-field value: a bounds walk, where one runs, covers 41 headers where a `String` value has one. -It runs `plain` only, since it prices a plaintext decode: the encrypted modes -decode decrypted plaintext, which is walked on every read. +It runs `plain` and `enc`, where the value decoded is the decrypted +plaintext. `ns` and `nsenc` run `l1_hit`, `l2_hit` and `set` as `plain` and `enc` on a client with a `.namespace()`, which builds `{namespace}:{key}` on every op. `delete/parked256` and `delete/parked1024` run the plain 64 B `delete` while diff --git a/crates/cachekit/examples/bench_hot_path.rs b/crates/cachekit/examples/bench_hot_path.rs index c9e6dbb..71c470c 100644 --- a/crates/cachekit/examples/bench_hot_path.rs +++ b/crates/cachekit/examples/bench_hot_path.rs @@ -333,9 +333,8 @@ fn is_case(op: &str, mode: &str, size: usize) -> bool { "oneshot" => matches!(mode, "enc" | "prev"), "macro_hit" => cfg!(feature = "macros") && !matches!(mode, "prev" | "ns" | "nsenc"), "l1_hit" | "l2_hit" => true, - // A plaintext decode: the encrypted modes decode decrypted plaintext, - // which is walked on every read. - "l1_hit_row" => mode == "plain", + // A many-field decode, of the stored bytes or of decrypted plaintext. + "l1_hit_row" => matches!(mode, "plain" | "enc"), "set" => mode != "prev", _ => !matches!(mode, "prev" | "ns" | "nsenc"), } From 3b1390657d271cb22f6a8ca52feacbf77d01bf89 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 7 Nov 2026 02:00:34 +0000 Subject: [PATCH 2/7] perf(l1): an encrypted L1 hit walks its decrypted plaintext once --- crates/cachekit/src/client.rs | 181 ++++++++++++++++--- crates/cachekit/src/interop.rs | 11 ++ crates/cachekit/src/l1/mod.rs | 20 ++ crates/cachekit/src/serializer/mod.rs | 116 ++++++++++-- crates/cachekit/tests/common/mod.rs | 47 +++++ crates/cachekit/tests/decode_bounds_tests.rs | 114 +++++++++++- 6 files changed, 442 insertions(+), 47 deletions(-) diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index dedc847..6208288 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -440,7 +440,8 @@ impl CacheKit { /// read that is before the L1 backfill, which runs before any decode, so /// L1 only holds a mark the bytes earned, and the read's own decode does /// not walk again. An encrypting client's bytes are ciphertext: they are - /// not MessagePack, and the decrypted plaintext is walked on every read. + /// not MessagePack, so they are admitted unwalked, and the first L1 hit + /// whose decrypted plaintext passes the walk marks them (`SecureCache::open`). #[cfg_attr(not(feature = "encryption"), allow(clippy::unused_self))] fn admit(&self, bytes: Bytes) -> Payload { #[cfg(feature = "encryption")] @@ -527,6 +528,14 @@ impl CacheKit { } } + /// See [`crate::l1::L1Cache::adopt`]. + #[cfg(all(feature = "l1", feature = "encryption"))] + fn l1_adopt(&self, full_key: &str, marked: Payload) { + if let Some(ref l1) = self.l1 { + l1.adopt(full_key, marked); + } + } + #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))] async fn lock_l1_mutation(&self, full_key: &str) -> Option { if self.l1.is_some() { @@ -1532,8 +1541,12 @@ impl SecureCache<'_> { let Some((ciphertext, by)) = read_l1_first!(self.client, &full_key) else { return Ok(None); }; - let plaintext = self.decrypt_or_evict(ciphertext.bytes(), &full_key)?; - let value = redact_decode(serializer::deserialize(&plaintext))?; + let value = self.open( + &ciphertext, + by, + &full_key, + serializer::deserialize_decrypted, + )?; self.client.l1_stop(sample, by); Ok(Some(value)) } @@ -1563,8 +1576,12 @@ impl SecureCache<'_> { let Some((ciphertext, by)) = read_l1_first!(self.client, &full_key) else { return Ok(None); }; - let plaintext = self.decrypt_or_evict(ciphertext.bytes(), &full_key)?; - let value = redact_decode(crate::interop::deserialize(&plaintext))?; + let value = self.open( + &ciphertext, + by, + &full_key, + crate::interop::deserialize_decrypted, + )?; self.client.l1_stop(sample, by); Ok(Some(value)) } @@ -1586,14 +1603,11 @@ impl SecureCache<'_> { let sample = self.client.l1_sample(); self.client.reject_namespaced_interop()?; let full_key = self.client.resolve_key(key)?; - let decode = |ct: &[u8]| -> Result { - redact_decode(crate::interop::deserialize( - &self.decrypt_or_evict(ct, &full_key)?, - )) - }; + let decode = + |ct: &Payload, by| self.open(ct, by, &full_key, crate::interop::deserialize_decrypted); let (read, by) = match self.client.get_bytes_swr(&full_key).await? { - SwrRead::Fresh((ct, by)) => (SwrRead::Fresh(decode(ct.bytes())?), by), - SwrRead::Stale((ct, by), token) => (SwrRead::Stale(decode(ct.bytes())?, token), by), + SwrRead::Fresh((ct, by)) => (SwrRead::Fresh(decode(&ct, by)?), by), + SwrRead::Stale((ct, by), token) => (SwrRead::Stale(decode(&ct, by)?, token), by), SwrRead::Miss => return Ok(SwrRead::Miss), }; self.client.l1_stop(sample, by); @@ -1610,9 +1624,7 @@ impl SecureCache<'_> { let full_key = self.client.resolve_key(key)?; match self.client.read_bytes(&full_key, None).await? { Some((ct, by)) => Ok(Some(( - redact_decode(crate::interop::deserialize( - &self.decrypt_or_evict(ct.bytes(), &full_key)?, - ))?, + self.open(&ct, by, &full_key, crate::interop::deserialize_decrypted)?, by, ))), None => Ok(None), @@ -1629,18 +1641,40 @@ impl SecureCache<'_> { ) -> Result, CachekitError> { self.client.reject_namespaced_interop()?; let full_key = self.client.resolve_key(key)?; - let decode = |ct: &[u8]| -> Result { - redact_decode(crate::interop::deserialize( - &self.decrypt_or_evict(ct, &full_key)?, - )) - }; + let decode = + |ct: &Payload, by| self.open(ct, by, &full_key, crate::interop::deserialize_decrypted); Ok(match self.client.read_bytes_swr(&full_key, lead).await? { - SwrRead::Fresh((ct, by)) => SwrRead::Fresh((decode(ct.bytes())?, by)), - SwrRead::Stale((ct, by), token) => SwrRead::Stale((decode(ct.bytes())?, by), token), + SwrRead::Fresh((ct, by)) => SwrRead::Fresh((decode(&ct, by)?, by)), + SwrRead::Stale((ct, by), token) => SwrRead::Stale((decode(&ct, by)?, by), token), SwrRead::Miss => SwrRead::Miss, }) } + /// Decrypt `ciphertext`, read for `full_key` from the layer `by`, and + /// decode its plaintext with `decode`, which walks the plaintext unless + /// the ciphertext's mark says it already passed. An L1 hit whose + /// plaintext passes the walk here marks its L1 entry, if L1 still holds + /// that ciphertext ([`crate::l1::L1Cache::adopt`]), so later hits on it + /// decode without the walk. An L2 hit marks nothing: L1 then pays one + /// write per key read twice, not one per key read. + fn open( + &self, + ciphertext: &Payload, + by: ReadOutcome, + full_key: &str, + decode: DecodeDecrypted, + ) -> Result { + let decrypted = ciphertext.decrypt(|ct| self.decrypt_or_evict(ct, full_key))?; + let (value, marked) = decode(&decrypted).map_err(redact::)?; + #[cfg(feature = "l1")] + if let (Some(marked), ReadOutcome::L1Hit | ReadOutcome::L1Stale) = (marked, by) { + self.client.l1_adopt(full_key, marked); + } + #[cfg(not(feature = "l1"))] + let _ = (marked, by); + Ok(value) + } + /// Decrypt ciphertext read for `full_key`, the key this client passed to /// its backend (`.namespace()` prefix included, from /// [`CacheKit::resolve_key`]), which the AAD binds; on failure, drop the @@ -1680,16 +1714,22 @@ impl SecureCache<'_> { /// …`), and that value is the plaintext encryption exists to protect /// (CWE-532). #[cfg(feature = "encryption")] -fn redact_decode(decoded: Result) -> Result { - decoded.map_err(|e| match e { +fn redact(e: CachekitError) -> CachekitError { + match e { CachekitError::Serialization(_) => CachekitError::Serialization(format!( "decrypted entry does not decode as {}", std::any::type_name::() )), other => other, - }) + } } +/// A decoder of decrypted plaintext, auto-mode or interop, returning the +/// ciphertext marked when it walked the plaintext and it passed. +#[cfg(feature = "encryption")] +type DecodeDecrypted = + fn(&serializer::Decrypted<'_>) -> Result<(T, Option), CachekitError>; + // ── CacheKitBuilder ─────────────────────────────────────────────────────────── /// Fluent builder for [`CacheKit`]. @@ -2607,3 +2647,94 @@ mod walked_mark_tests { assert!(walked(&client, "good")); } } + +/// An encrypting client's L1 entry is marked once an L1 hit's decrypted +/// plaintext passes the walk, and only while L1 still holds the ciphertext +/// that hit read. That hostile plaintext stays unmarked is pinned on every +/// read path by `tests/decode_bounds_tests.rs`. +#[cfg(all(test, feature = "l1", feature = "encryption", not(feature = "unsync")))] +#[allow(clippy::unwrap_used, clippy::expect_used)] // test-only: a failed setup step should panic loudly +mod plaintext_mark_tests { + use super::{CacheKit, SharedBackend}; + use crate::backend::MemoryBackend; + + fn secure_client(backend: &MemoryBackend) -> CacheKit { + let shared: SharedBackend = std::sync::Arc::new(backend.clone()); + CacheKit::builder() + .backend(shared) + .l1_capacity(16) + .encryption_from_bytes(&[0x42; 32], "marks") + .unwrap() + .build() + .unwrap() + } + + fn is_marked(client: &CacheKit, key: &str) -> bool { + let entry = client.l1_get(key).expect("an L1 entry"); + assert!( + !entry.is_walked(), + "ciphertext never carries the plain mark" + ); + entry.is_plaintext_walked() + } + + #[tokio::test] + async fn an_own_write_is_marked_by_its_first_l1_hit() { + let client = secure_client(&MemoryBackend::default()); + client.set("k", &vec![1u32, 2, 3]).await.unwrap(); + assert!(!is_marked(&client, "k"), "a write is not trusted"); + for _ in 0..2 { + assert_eq!( + client.get::>("k").await.unwrap(), + Some(vec![1, 2, 3]) + ); + assert!(is_marked(&client, "k")); + } + } + + #[tokio::test] + async fn an_l2_hit_marks_nothing_and_the_next_l1_hit_does() { + let backend = MemoryBackend::default(); + secure_client(&backend).set("k", &"v").await.unwrap(); + let client = secure_client(&backend); + assert_eq!( + client.get::("k").await.unwrap().as_deref(), + Some("v") + ); + assert_eq!(client.stats().l2_hits, 1); + assert!(!is_marked(&client, "k")); + assert_eq!( + client.get::("k").await.unwrap().as_deref(), + Some("v") + ); + assert_eq!(client.stats().l1_hits, 1); + assert!(is_marked(&client, "k")); + } + + /// A mark lands only on the entry its read returned: a write or a delete + /// between the read and the mark leaves L1 as that write or delete left it. + #[tokio::test] + async fn a_mark_never_lands_on_a_rewritten_or_deleted_entry() { + let client = secure_client(&MemoryBackend::default()); + let l1 = client.l1.as_ref().unwrap(); + client.set("k", &1u32).await.unwrap(); + let read = client.l1_get("k").unwrap(); + let encryption = client.encryption.as_deref().unwrap(); + let decrypted = read.decrypt(|ct| encryption.decrypt(ct, "k")).unwrap(); + let marked = decrypted + .deserializer() + .unwrap() + .1 + .expect("the walk passed"); + + client.set("k", &2u32).await.unwrap(); + l1.adopt("k", marked.clone()); + assert!(!is_marked(&client, "k"), "the rewrite stays unmarked"); + assert_eq!(client.get::("k").await.unwrap(), Some(2)); + + client.delete("k").await.unwrap(); + l1.adopt("k", marked); + assert!(client.l1_get("k").is_none(), "a mark adds no entry"); + assert_eq!(l1.entry_count(), 0); + } +} diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index c774c25..1126c5b 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -485,6 +485,17 @@ pub(crate) fn deserialize_payload( decode(payload.deserializer()?) } +/// [`deserialize`] for decrypted plaintext, with the ciphertext marked when +/// this decode walked it (see `crate::serializer::Decrypted::deserializer`). +#[cfg(feature = "encryption")] +pub(crate) fn deserialize_decrypted( + decrypted: &crate::serializer::Decrypted<'_>, +) -> Result<(T, Option), CachekitError> { + reject_ck_frame(decrypted.plaintext())?; + let (de, marked) = decrypted.deserializer()?; + Ok((decode(de)?, marked)) +} + fn reject_ck_frame(bytes: &[u8]) -> Result<(), CachekitError> { if bytes.starts_with(b"CK") { return Err(CachekitError::Serialization( diff --git a/crates/cachekit/src/l1/mod.rs b/crates/cachekit/src/l1/mod.rs index 15eeab3..484fc1e 100644 --- a/crates/cachekit/src/l1/mod.rs +++ b/crates/cachekit/src/l1/mod.rs @@ -230,6 +230,26 @@ impl L1Cache { ); } + /// Put `marked`, a read's payload with the mark its decode just earned, + /// in place of `key`'s entry's payload, if that entry still holds the + /// same buffer. A rewrite holds another buffer and keeps its own mark; + /// after a delete or eviction there is no entry and none is added. The + /// entry's TTL, freshness window and eviction state are unchanged. + #[cfg(feature = "encryption")] + pub(crate) fn adopt(&self, key: &str, marked: Payload) { + // Under the shard's write lock, so no write can land between the + // check and the swap. A zero timeout never waits on a placeholder; + // L1 inserts none, so only another `adopt` of an absent key holds + // one. On an absent key `entry` returns a placeholder guard, and + // dropping it uninserted removes the placeholder and nothing else. + let _ = self.store.entry(key, Some(Duration::ZERO), |_, entry| { + if entry.data.same_buffer(&marked) { + entry.data = marked; + } + quick_cache::sync::EntryAction::Retain(()) + }); + } + /// Remove an entry by key. pub fn delete(&self, key: &str) { self.store.remove(key); diff --git a/crates/cachekit/src/serializer/mod.rs b/crates/cachekit/src/serializer/mod.rs index 3e77eb3..49e1e04 100644 --- a/crates/cachekit/src/serializer/mod.rs +++ b/crates/cachekit/src/serializer/mod.rs @@ -119,32 +119,54 @@ fn depth_limited(bytes: &[u8]) -> SliceDeserializer<'_> { /// The walk guards a decode against bytes the backend controls, and a value /// this process wrote needs it too: rmp-serde's own depth limit does not count /// enum nesting, so a deep recursive enum would overflow the stack without it. -/// Bytes need the walk once, not on every read. [`Self::walk`] is the only way -/// to set the mark, and sets it only when the bytes pass, so a marked payload -/// decodes without walking again. L1 keeps the mark for the entry's lifetime. +/// Bytes need the walk once, not on every read. [`Self::walk`] and +/// [`Decrypted::deserializer`] are the only ways to set the mark, and set it +/// only when the walk passes, so a marked payload decodes without walking +/// again. L1 keeps the mark for the entry's lifetime. #[derive(Clone, Debug)] pub(crate) struct Payload { - // Private, with `walked`: nothing outside `walk` can pair a mark with - // other bytes. + // Private, with `walked`: nothing outside this module can pair a mark + // with other bytes. bytes: Bytes, - walked: bool, + walked: Walked, +} + +/// What a [`Payload`]'s mark says passed [`check_bounds`]. Two kinds, so a +/// mark one kind of decode earned is never honoured by the other. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Walked { + /// Nothing yet: every decode walks. + No, + /// The stored bytes, on a client without encryption. + Bytes, + /// The plaintext the stored bytes, AES-GCM ciphertext, decrypt to. The + /// client's keyring is fixed when it is built and the AAD binds the key + /// the entry is stored under, so the same ciphertext read under the same + /// key fails authentication or yields the same plaintext: the walk's + /// verdict on it cannot change. Every read still decrypts. + #[cfg(feature = "encryption")] + Plaintext, } impl Payload { - /// Bytes every decode walks: ciphertext, whose decrypted plaintext is - /// walked instead, and bytes nothing has checked. + /// Bytes every decode walks: ciphertext not yet decrypted and walked, and + /// bytes nothing has checked. #[cfg_attr(not(any(feature = "l1", feature = "encryption")), allow(dead_code))] pub(crate) fn unwalked(bytes: Bytes) -> Self { Self { bytes, - walked: false, + walked: Walked::No, } } /// Walk `bytes` now and mark them only if they pass. A failure is not /// reported here; a decode walks the unmarked bytes again and reports it. pub(crate) fn walk(bytes: Bytes) -> Self { - let walked = check_bounds(&bytes).is_ok(); + let walked = if check_bounds(&bytes).is_ok() { + Walked::Bytes + } else { + Walked::No + }; Self { bytes, walked } } @@ -153,21 +175,91 @@ impl Payload { &self.bytes } + /// Whether `self` and `other` share one buffer. The address comparison is + /// sound while both are alive: each holds a reference, so the buffer + /// cannot be freed and its address reused. + #[cfg(all(feature = "l1", feature = "encryption"))] + pub(crate) fn same_buffer(&self, other: &Self) -> bool { + self.bytes.as_ptr() == other.bytes.as_ptr() && self.bytes.len() == other.bytes.len() + } + /// Whether the bytes passed the walk. #[cfg(all(test, feature = "l1", not(feature = "unsync")))] pub(crate) fn is_walked(&self) -> bool { - self.walked + self.walked == Walked::Bytes + } + + /// Whether the plaintext these bytes decrypt to passed the walk. + #[cfg(all(test, feature = "l1", feature = "encryption", not(feature = "unsync")))] + pub(crate) fn is_plaintext_walked(&self) -> bool { + self.walked == Walked::Plaintext } /// [`bounded_deserializer`] over the bytes, without the walk when they /// already passed it. pub(crate) fn deserializer(&self) -> Result, CachekitError> { - if self.walked { + if self.walked == Walked::Bytes { Ok(depth_limited(&self.bytes)) } else { bounded_deserializer(&self.bytes) } } + + /// Decrypt the bytes with `decrypt`, which must decrypt for the key this + /// payload was read under, keeping the plaintext with its ciphertext. + #[cfg(feature = "encryption")] + pub(crate) fn decrypt( + &self, + decrypt: impl FnOnce(&[u8]) -> Result, CachekitError>, + ) -> Result, CachekitError> { + Ok(Decrypted { + plaintext: decrypt(&self.bytes)?, + ciphertext: self, + }) + } +} + +/// The plaintext a ciphertext [`Payload`] decrypted to. +#[cfg(feature = "encryption")] +pub(crate) struct Decrypted<'a> { + plaintext: Vec, + ciphertext: &'a Payload, +} + +#[cfg(feature = "encryption")] +impl Decrypted<'_> { + /// The decrypted bytes. + pub(crate) fn plaintext(&self) -> &[u8] { + &self.plaintext + } + + /// [`bounded_deserializer`] over the plaintext, without the walk when the + /// ciphertext's mark says it already passed. When this call walked the + /// plaintext and it passed, it also returns the ciphertext marked so, for + /// the caller to put back in L1 (`L1Cache::adopt`). + pub(crate) fn deserializer( + &self, + ) -> Result<(SliceDeserializer<'_>, Option), CachekitError> { + if self.ciphertext.walked == Walked::Plaintext { + return Ok((depth_limited(&self.plaintext), None)); + } + let de = bounded_deserializer(&self.plaintext)?; + let marked = Payload { + bytes: self.ciphertext.bytes.clone(), + walked: Walked::Plaintext, + }; + Ok((de, Some(marked))) + } +} + +/// [`deserialize`] for decrypted plaintext, with the ciphertext marked when +/// this decode walked it (see [`Decrypted::deserializer`]). +#[cfg(feature = "encryption")] +pub(crate) fn deserialize_decrypted( + decrypted: &Decrypted<'_>, +) -> Result<(T, Option), CachekitError> { + let (de, marked) = decrypted.deserializer()?; + Ok((decode(de)?, marked)) } /// [`deserialize`] for a payload a read returned: a marked payload skips the diff --git a/crates/cachekit/tests/common/mod.rs b/crates/cachekit/tests/common/mod.rs index 0623de9..613b704 100644 --- a/crates/cachekit/tests/common/mod.rs +++ b/crates/cachekit/tests/common/mod.rs @@ -265,6 +265,53 @@ pub fn read_every_path_twice_through_l1(bytes: &[u8]) -> Ve }) } +/// One path of an encrypting client read three times: from the backend, from +/// L1, and from L1 again. +#[cfg(all(feature = "l1", feature = "encryption"))] +pub type ReadThrice = (&'static str, [Result; 3]); + +/// [`read_every_path_twice_through_l1`] on an encrypting client, whose +/// backend entry is `plaintext` sealed under the client's own key for the +/// entry's key, so it passes authentication and only the decode can refuse +/// it. Each path reads it from the backend, then, with the backend copy +/// removed, twice from L1: the first L1 hit walks the plaintext, and the +/// second skips the walk only if the first marked the entry. A miss panics, +/// so L1 served both of those reads. +#[cfg(all(feature = "l1", feature = "encryption"))] +pub fn read_every_path_sealed_through_l1( + plaintext: &[u8], +) -> Vec> { + const KEY: [u8; 32] = [0x42; 32]; + const TENANT: &str = "sealed-reads"; + let runtime = runtime(); + let (backend, handle) = MockBackend::new_with_handle(); + let client = CacheKit::builder() + .backend(backend) + .l1_capacity(64) + .encryption_from_bytes(&KEY, TENANT) + .expect("key and tenant") + .build() + .expect("client builds"); + let layer = cachekit::EncryptionLayer::new(&KEY, TENANT).expect("key and tenant"); + let found = |r: Result, CachekitError>| { + r.map(|v| v.expect("the sealed entry must be found, not missed")) + }; + runtime.block_on(async { + let mut reads = Vec::new(); + for path in L1_READS { + let key = format!("sealed:{path}"); + let sealed = layer.encrypt(plaintext, &key).expect("seal"); + handle.store.lock().await.insert(key.clone(), sealed); + let backend = found(read_by::(&client, path, &key).await); + handle.store.lock().await.remove(&key); + let l1 = found(read_by::(&client, path, &key).await); + let l1_again = found(read_by::(&client, path, &key).await); + reads.push((path, [backend, l1, l1_again])); + } + reads + }) +} + /// An encrypting client, L1 off so every read reaches the backend, holding /// `bytes` at the backend key `stored_key`. #[cfg(feature = "encryption")] diff --git a/crates/cachekit/tests/decode_bounds_tests.rs b/crates/cachekit/tests/decode_bounds_tests.rs index 5f6776c..1c1de41 100644 --- a/crates/cachekit/tests/decode_bounds_tests.rs +++ b/crates/cachekit/tests/decode_bounds_tests.rs @@ -190,23 +190,14 @@ fn every_accept_vector_decodes_from_the_backend_and_from_l1() { #[cfg(feature = "l1")] #[test] fn an_own_write_of_a_deep_recursive_enum_fails_from_l1() { - #[derive(serde::Serialize, Deserialize)] - enum Node { - Leaf(i64), - List(Vec), - } on_default_stack(|| { let client = cachekit::CacheKit::builder() .backend(common::MockBackend::shared()) .l1_capacity(16) .build() .expect("client builds"); - let mut value = Node::Leaf(0); - for _ in 0..60 { - value = Node::List(vec![value]); - } common::runtime().block_on(async { - client.set("enum", &value).await.expect("set"); + client.set("enum", &deep_enum()).await.expect("set"); let read = client .get::("enum") .await @@ -218,6 +209,109 @@ fn an_own_write_of_a_deep_recursive_enum_fails_from_l1() { }); } +/// `List` nested 60 deep: 120 MessagePack levels, which the walk rejects and +/// rmp-serde alone would decode (see the test above). +#[cfg(feature = "l1")] +#[derive(serde::Serialize, Deserialize)] +enum Node { + Leaf(i64), + List(Vec), +} + +#[cfg(feature = "l1")] +fn deep_enum() -> Node { + let mut value = Node::Leaf(0); + for _ in 0..60 { + value = Node::List(vec![value]); + } + value +} + +/// An encrypting client's first L1 hit whose decrypted plaintext passes the +/// walk marks the entry, and later hits skip the walk. Hostile plaintext +/// under a valid tag must never be marked: the read from the backend fails, +/// and so does every read L1 serves after it. +/// +/// The secure path replaces the decoder's message, so a reject vector shows +/// only that each read failed. The recursive enum is the case a wrong mark +/// would let through: without the walk it decodes. +#[cfg(all(feature = "l1", feature = "encryption"))] +#[test] +fn sealed_hostile_plaintext_fails_from_the_backend_and_every_time_from_l1() { + on_default_stack(|| { + let mut cases: Vec<(String, Vec)> = vectors()["reject_vectors"] + .as_array() + .unwrap() + .iter() + .map(|v| { + let name = v["name"].as_str().unwrap().to_owned(); + (name, unhex(v["input_hex"].as_str().unwrap())) + }) + .collect(); + cases.push(( + "deep recursive enum".to_owned(), + serializer::serialize(&deep_enum()).unwrap(), + )); + for (name, plaintext) in cases { + let reads = common::read_every_path_sealed_through_l1::(&plaintext); + for (path, reads) in reads { + for (layer, read) in ["backend", "L1", "L1 again"].into_iter().zip(reads) { + assert!( + matches!(read, Err(CachekitError::Serialization(_))), + "{name}: {path} ({layer}) must refuse sealed hostile plaintext" + ); + } + } + } + }); +} + +/// A sealed accept vector decodes the same from the backend, from the L1 hit +/// that walks and marks it, and from the marked hit after it. +#[cfg(all(feature = "l1", feature = "encryption"))] +#[test] +fn every_sealed_accept_vector_decodes_from_the_backend_and_from_l1() { + on_default_stack(|| { + for vector in vectors()["accept_vectors"].as_array().unwrap() { + let name = vector["name"].as_str().unwrap(); + let bytes = unhex(vector["input_hex"].as_str().unwrap()); + for (path, reads) in common::read_every_path_sealed_through_l1::(&bytes) { + let [backend, l1, l1_again] = + reads.map(|r| r.unwrap_or_else(|e| panic!("{name}: {path}: {e}"))); + assert_eq!(backend, l1, "{name}: {path}"); + assert_eq!(l1, l1_again, "{name}: {path}"); + } + } + }); +} + +/// The encrypting twin of `an_own_write_of_a_deep_recursive_enum_fails_from_l1`: +/// an own write is not trusted either, so every L1 hit on it walks and fails. +#[cfg(all(feature = "l1", feature = "encryption"))] +#[test] +fn an_own_encrypted_write_of_a_deep_recursive_enum_fails_from_l1_every_time() { + on_default_stack(|| { + let client = cachekit::CacheKit::builder() + .backend(common::MockBackend::shared()) + .l1_capacity(16) + .encryption_from_bytes(&[0x42; 32], "own-write") + .expect("key and tenant") + .build() + .expect("client builds"); + common::runtime().block_on(async { + client.set("enum", &deep_enum()).await.expect("set"); + for hit in 1..=2 { + let read = client.get::("enum").await; + assert_eq!(client.stats().l1_hits, hit, "L1 served it"); + assert!( + matches!(read, Err(CachekitError::Serialization(_))), + "L1 hit {hit} must refuse the deep enum" + ); + } + }); + }); +} + #[test] fn every_accept_vector_decodes_on_every_read_path() { on_default_stack(|| { From 5a4047c85c2eeffe7969ec9dc88c82b1b8ff8f1a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 7 Nov 2026 02:30:53 +0000 Subject: [PATCH 3/7] perf(l1): decode decrypted plaintext through a generic decoder and mint the mark from the walk --- crates/cachekit/src/client.rs | 32 +++++++++++----------- crates/cachekit/src/interop.rs | 10 +++---- crates/cachekit/src/serializer/mod.rs | 39 ++++++++++++++++----------- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index 6208288..de110ef 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -1657,21 +1657,26 @@ impl SecureCache<'_> { /// that ciphertext ([`crate::l1::L1Cache::adopt`]), so later hits on it /// decode without the walk. An L2 hit marks nothing: L1 then pays one /// write per key read twice, not one per key read. + /// + /// `decode` is a type parameter, not a function pointer, so each read + /// inlines its decoder as it did before the mark. fn open( &self, ciphertext: &Payload, by: ReadOutcome, full_key: &str, - decode: DecodeDecrypted, + decode: impl FnOnce(&serializer::Decrypted<'_>) -> Result, ) -> Result { let decrypted = ciphertext.decrypt(|ct| self.decrypt_or_evict(ct, full_key))?; - let (value, marked) = decode(&decrypted).map_err(redact::)?; + let value = decode(&decrypted).map_err(redact::)?; #[cfg(feature = "l1")] - if let (Some(marked), ReadOutcome::L1Hit | ReadOutcome::L1Stale) = (marked, by) { - self.client.l1_adopt(full_key, marked); + if matches!(by, ReadOutcome::L1Hit | ReadOutcome::L1Stale) { + if let Some(marked) = decrypted.marked() { + self.client.l1_adopt(full_key, marked); + } } #[cfg(not(feature = "l1"))] - let _ = (marked, by); + let _ = by; Ok(value) } @@ -1724,12 +1729,6 @@ fn redact(e: CachekitError) -> CachekitError { } } -/// A decoder of decrypted plaintext, auto-mode or interop, returning the -/// ciphertext marked when it walked the plaintext and it passed. -#[cfg(feature = "encryption")] -type DecodeDecrypted = - fn(&serializer::Decrypted<'_>) -> Result<(T, Option), CachekitError>; - // ── CacheKitBuilder ─────────────────────────────────────────────────────────── /// Fluent builder for [`CacheKit`]. @@ -2721,11 +2720,12 @@ mod plaintext_mark_tests { let read = client.l1_get("k").unwrap(); let encryption = client.encryption.as_deref().unwrap(); let decrypted = read.decrypt(|ct| encryption.decrypt(ct, "k")).unwrap(); - let marked = decrypted - .deserializer() - .unwrap() - .1 - .expect("the walk passed"); + assert!( + decrypted.marked().is_none(), + "nothing is marked before the walk" + ); + decrypted.deserializer().unwrap(); + let marked = decrypted.marked().expect("the walk passed"); client.set("k", &2u32).await.unwrap(); l1.adopt("k", marked.clone()); diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index 1126c5b..e1f57a3 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -485,15 +485,15 @@ pub(crate) fn deserialize_payload( decode(payload.deserializer()?) } -/// [`deserialize`] for decrypted plaintext, with the ciphertext marked when -/// this decode walked it (see `crate::serializer::Decrypted::deserializer`). +/// [`deserialize`] for decrypted plaintext: skips the walk when the +/// ciphertext's mark says it already passed (see +/// `crate::serializer::Decrypted::deserializer`). #[cfg(feature = "encryption")] pub(crate) fn deserialize_decrypted( decrypted: &crate::serializer::Decrypted<'_>, -) -> Result<(T, Option), CachekitError> { +) -> Result { reject_ck_frame(decrypted.plaintext())?; - let (de, marked) = decrypted.deserializer()?; - Ok((decode(de)?, marked)) + decode(decrypted.deserializer()?) } fn reject_ck_frame(bytes: &[u8]) -> Result<(), CachekitError> { diff --git a/crates/cachekit/src/serializer/mod.rs b/crates/cachekit/src/serializer/mod.rs index 49e1e04..64ea63a 100644 --- a/crates/cachekit/src/serializer/mod.rs +++ b/crates/cachekit/src/serializer/mod.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "encryption")] +use std::cell::Cell; + use bytes::Bytes; use cachekit_core::msgpack_document_len; use rmp_serde::decode::ReadRefReader; @@ -215,6 +218,7 @@ impl Payload { Ok(Decrypted { plaintext: decrypt(&self.bytes)?, ciphertext: self, + passed: Cell::new(false), }) } } @@ -224,6 +228,8 @@ impl Payload { pub(crate) struct Decrypted<'a> { plaintext: Vec, ciphertext: &'a Payload, + /// [`Self::deserializer`] walked the plaintext and it passed. + passed: Cell, } #[cfg(feature = "encryption")] @@ -234,32 +240,35 @@ impl Decrypted<'_> { } /// [`bounded_deserializer`] over the plaintext, without the walk when the - /// ciphertext's mark says it already passed. When this call walked the - /// plaintext and it passed, it also returns the ciphertext marked so, for - /// the caller to put back in L1 (`L1Cache::adopt`). - pub(crate) fn deserializer( - &self, - ) -> Result<(SliceDeserializer<'_>, Option), CachekitError> { + /// ciphertext's mark says it already passed. + pub(crate) fn deserializer(&self) -> Result, CachekitError> { if self.ciphertext.walked == Walked::Plaintext { - return Ok((depth_limited(&self.plaintext), None)); + return Ok(depth_limited(&self.plaintext)); } let de = bounded_deserializer(&self.plaintext)?; - let marked = Payload { + self.passed.set(true); + Ok(de) + } + + /// The ciphertext marked as decrypting to plaintext that passed the walk, + /// when [`Self::deserializer`] walked it here and it passed: for the + /// caller to put back in L1 (`L1Cache::adopt`). `None` otherwise. + #[cfg(feature = "l1")] + pub(crate) fn marked(&self) -> Option { + self.passed.get().then(|| Payload { bytes: self.ciphertext.bytes.clone(), walked: Walked::Plaintext, - }; - Ok((de, Some(marked))) + }) } } -/// [`deserialize`] for decrypted plaintext, with the ciphertext marked when -/// this decode walked it (see [`Decrypted::deserializer`]). +/// [`deserialize`] for decrypted plaintext: skips the walk when the +/// ciphertext's mark says it already passed (see [`Decrypted::deserializer`]). #[cfg(feature = "encryption")] pub(crate) fn deserialize_decrypted( decrypted: &Decrypted<'_>, -) -> Result<(T, Option), CachekitError> { - let (de, marked) = decrypted.deserializer()?; - Ok((decode(de)?, marked)) +) -> Result { + decode(decrypted.deserializer()?) } /// [`deserialize`] for a payload a read returned: a marked payload skips the From 66d8ba19e6a6baf8598a5d34889a899250a5f3f2 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 7 Nov 2026 02:40:31 +0000 Subject: [PATCH 4/7] docs: say where an encrypting client walks decrypted plaintext --- README.md | 2 +- crates/cachekit/src/client.rs | 8 ++++++++ crates/cachekit/src/serializer/mod.rs | 10 +++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 996bd2a..a7e8594 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,7 @@ let user: Option = cache.interop_get(&key).await?; // strict read: exactly `ns` and `nsapi` are reserved as namespaces — the CacheKit Cloud server parses a key starting `ns:` or `nsapi:` as namespace-prefixed — so `interop_key` rejects them with `InvalidKey` and `#[cachekit(namespace = ...)]` with a compile error; operations are unaffected. Neither segment may contain `..` (`a..b` is rejected the same two ways; a lone `.`, as in `app.v1`, is fine), because the server rejects `..` anywhere in a key. On a `#[cachekit]` function, a parameter whose name starts with `__ck` or `__CK` is a compile error too: that prefix is reserved for the macro's generated locals, which would otherwise shadow the argument. -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. `get` and `interop_get` (also on `SecureCache`) both read exactly one MessagePack document and reject trailing bytes, so neither silently misreads a Python-internal CK frame as the integer 67; `interop_get` names the CK frame in its error. On a client without encryption, `get`, `interop_get` and `interop_get_swr` refuse every CK frame in the protocol's shared [`python-frame.json`](crates/cachekit/tests/vectors/python-frame.json) vectors (vendored); an encrypted client refuses them at decrypt. A bare ByteStorage envelope (another SDK's auto-mode container) is one well-formed MessagePack document, so no structural check can refuse it: on a client without encryption, a read of one fails only when its shape does not fit the requested type, and rmp-serde decodes by position, so a type whose four elements line up with the envelope's (bytes, 8 integers, an integer, a string) does read one. An encrypting client fails at decrypt, whatever the type. Every decode of backend-supplied bytes (`get` and `interop_get` alike) first passes a header-only structural walk that rejects, before anything is decoded, a document nested deeper than `serializer::MAX_DECODE_DEPTH` (100 levels, matching the TypeScript SDK; every collection header counts, empty ones included) or declaring more elements or bytes 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. A client without encryption walks the bytes when a backend read or its own write admits them, and a later L1 hit on bytes that passed decodes without walking again. Encryption works unchanged — interop keys are identical across SDKs, so the AAD verifies cross-SDK. On a client with encryption configured, `set_with_ttl` stores the AES-GCM ciphertext of that MessagePack and `interop_get` decrypts it. +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. `get` and `interop_get` (also on `SecureCache`) both read exactly one MessagePack document and reject trailing bytes, so neither silently misreads a Python-internal CK frame as the integer 67; `interop_get` names the CK frame in its error. On a client without encryption, `get`, `interop_get` and `interop_get_swr` refuse every CK frame in the protocol's shared [`python-frame.json`](crates/cachekit/tests/vectors/python-frame.json) vectors (vendored); an encrypted client refuses them at decrypt. A bare ByteStorage envelope (another SDK's auto-mode container) is one well-formed MessagePack document, so no structural check can refuse it: on a client without encryption, a read of one fails only when its shape does not fit the requested type, and rmp-serde decodes by position, so a type whose four elements line up with the envelope's (bytes, 8 integers, an integer, a string) does read one. An encrypting client fails at decrypt, whatever the type. Every decode of backend-supplied bytes (`get` and `interop_get` alike) first passes a header-only structural walk that rejects, before anything is decoded, a document nested deeper than `serializer::MAX_DECODE_DEPTH` (100 levels, matching the TypeScript SDK; every collection header counts, empty ones included) or declaring more elements or bytes 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. A client without encryption walks the bytes when a backend read or its own write admits them, and a later L1 hit on bytes that passed decodes without walking again. An encrypting client's L1 holds ciphertext: every read decrypts it, and the first L1 hit whose decrypted plaintext passes the walk marks the entry, so later hits on that ciphertext decode without walking again. A rewrite, delete or eviction drops the mark with the entry, and a hostile plaintext under a valid tag is never marked. Encryption works unchanged — interop keys are identical across SDKs, so the AAD verifies cross-SDK. On a client with encryption configured, `set_with_ttl` stores the AES-GCM ciphertext of that MessagePack and `interop_get` decrypts it. > [!WARNING] > Every service that binds one `(namespace, operation)` must agree on encryption: on in all of them or off in all of them, with the same master key and `tenant_id` (decrypt-only previous master keys may differ). If an unencrypted service shares the operation with an encrypted one, values end up **stored unencrypted** at the shared key: the unencrypted service writes in the clear whenever it fills the entry, and a `#[cachekit]` function on a client without encryption also replaces ciphertext it cannot decode. See [Shared entries are a contract](https://docs.cachekit.io/concepts/using-interop-mode/#shared-entries-are-a-contract). diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index de110ef..c5d10e6 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -2668,6 +2668,14 @@ mod plaintext_mark_tests { .unwrap() } + #[test] + fn the_public_l1_set_stays_unwalked() { + let l1 = crate::l1::L1Cache::new(16); + l1.set("k", &[0x01], std::time::Duration::from_secs(60)); + let entry = l1.get_shared("k").unwrap(); + assert!(!entry.is_walked() && !entry.is_plaintext_walked()); + } + fn is_marked(client: &CacheKit, key: &str) -> bool { let entry = client.l1_get(key).expect("an L1 entry"); assert!( diff --git a/crates/cachekit/src/serializer/mod.rs b/crates/cachekit/src/serializer/mod.rs index 64ea63a..ef0054c 100644 --- a/crates/cachekit/src/serializer/mod.rs +++ b/crates/cachekit/src/serializer/mod.rs @@ -25,7 +25,9 @@ use crate::error::CachekitError; /// runs before every decode in [`deserialize`] and /// [`crate::interop::deserialize`]. A client without encryption also runs it /// once when a backend read or its own write admits bytes, and a decode of -/// bytes that passed then skips it (see `Payload`). Depth +/// bytes that passed then skips it (see `Payload`). An encrypting client +/// runs it on decrypted plaintext until an L1 hit's plaintext passes, and +/// later hits on that L1 ciphertext skip it (see `Decrypted`). Depth /// counts every array and map header on the deepest path, empty ones included; /// scalars, str, bin and ext are leaves. /// Exactly `MAX_DECODE_DEPTH` levels are accepted, and one more is rejected @@ -40,8 +42,10 @@ pub(crate) type SliceDeserializer<'a> = rmp_serde::Deserializer Date: Sat, 7 Nov 2026 02:50:38 +0000 Subject: [PATCH 5/7] fix(l1): mark a passed walk whatever the decode, and correct the mark docs --- crates/cachekit/src/client.rs | 22 +++- crates/cachekit/src/l1/mod.rs | 3 +- crates/cachekit/src/serializer/mod.rs | 15 ++- crates/cachekit/tests/common/mod.rs | 117 ++++++++----------- crates/cachekit/tests/decode_bounds_tests.rs | 4 +- 5 files changed, 81 insertions(+), 80 deletions(-) diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index c5d10e6..11125a0 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -1668,7 +1668,9 @@ impl SecureCache<'_> { decode: impl FnOnce(&serializer::Decrypted<'_>) -> Result, ) -> Result { let decrypted = ciphertext.decrypt(|ct| self.decrypt_or_evict(ct, full_key))?; - let value = decode(&decrypted).map_err(redact::)?; + // Marked before the result is checked: a plaintext that passed the + // walk and does not decode as `T` still passed the walk. + let decoded = decode(&decrypted); #[cfg(feature = "l1")] if matches!(by, ReadOutcome::L1Hit | ReadOutcome::L1Stale) { if let Some(marked) = decrypted.marked() { @@ -1677,7 +1679,7 @@ impl SecureCache<'_> { } #[cfg(not(feature = "l1"))] let _ = by; - Ok(value) + decoded.map_err(redact::) } /// Decrypt ciphertext read for `full_key`, the key this client passed to @@ -2699,6 +2701,22 @@ mod plaintext_mark_tests { } } + /// The mark records the walk, not the decode: a plaintext that passes the + /// walk and is read as the wrong type is marked, and the error stays. + #[tokio::test] + async fn a_walk_that_passes_marks_even_when_the_type_does_not_fit() { + let client = secure_client(&MemoryBackend::default()); + client.set("k", &7u32).await.unwrap(); + for _ in 0..2 { + assert!(matches!( + client.get::("k").await, + Err(crate::CachekitError::Serialization(_)) + )); + assert!(is_marked(&client, "k")); + } + assert_eq!(client.get::("k").await.unwrap(), Some(7)); + } + #[tokio::test] async fn an_l2_hit_marks_nothing_and_the_next_l1_hit_does() { let backend = MemoryBackend::default(); diff --git a/crates/cachekit/src/l1/mod.rs b/crates/cachekit/src/l1/mod.rs index 484fc1e..5692c14 100644 --- a/crates/cachekit/src/l1/mod.rs +++ b/crates/cachekit/src/l1/mod.rs @@ -234,7 +234,8 @@ impl L1Cache { /// in place of `key`'s entry's payload, if that entry still holds the /// same buffer. A rewrite holds another buffer and keeps its own mark; /// after a delete or eviction there is no entry and none is added. The - /// entry's TTL, freshness window and eviction state are unchanged. + /// entry's TTL and freshness window are unchanged; the swap counts as one + /// more access for eviction, as a read does. #[cfg(feature = "encryption")] pub(crate) fn adopt(&self, key: &str, marked: Payload) { // Under the shard's write lock, so no write can land between the diff --git a/crates/cachekit/src/serializer/mod.rs b/crates/cachekit/src/serializer/mod.rs index ef0054c..874b75b 100644 --- a/crates/cachekit/src/serializer/mod.rs +++ b/crates/cachekit/src/serializer/mod.rs @@ -44,8 +44,9 @@ pub(crate) type SliceDeserializer<'a> = rmp_serde::Deserializer SliceDeserializer<'_> { de } -/// Stored value bytes, and whether they already passed [`check_bounds`]. +/// Stored value bytes, and what about them already passed [`check_bounds`]. /// /// The walk guards a decode against bytes the backend controls, and a value /// this process wrote needs it too: rmp-serde's own depth limit does not count /// enum nesting, so a deep recursive enum would overflow the stack without it. /// Bytes need the walk once, not on every read. [`Self::walk`] and -/// [`Decrypted::deserializer`] are the only ways to set the mark, and set it -/// only when the walk passes, so a marked payload decodes without walking -/// again. L1 keeps the mark for the entry's lifetime. +/// [`Decrypted::marked`] are the only ways to mint a mark, and mint it only +/// when the walk passed. [`Self::deserializer`] honours only a +/// [`Walked::Bytes`] mark and [`Decrypted::deserializer`] only a +/// [`Walked::Plaintext`] one; either skips the walk on a mark it honours. +/// L1 keeps the mark for the entry's lifetime. #[derive(Clone, Debug)] pub(crate) struct Payload { // Private, with `walked`: nothing outside this module can pair a mark diff --git a/crates/cachekit/tests/common/mod.rs b/crates/cachekit/tests/common/mod.rs index 613b704..d0dcc4a 100644 --- a/crates/cachekit/tests/common/mod.rs +++ b/crates/cachekit/tests/common/mod.rs @@ -93,13 +93,6 @@ impl Backend for MockBackend { /// One untrusted read: the path's name and what it decoded. pub type Read = (&'static str, Result); -/// One path read twice: from the backend, then from L1. -pub type ReadTwice = ( - &'static str, - Result, - Result, -); - /// Both untrusted decode entry points: auto-mode `get` and interop `interop_get`. pub fn decode_both(bytes: &[u8]) -> [Read; 2] { [ @@ -189,7 +182,7 @@ pub fn read_every_path(bytes: &[u8]) -> Vec> { reads } -/// The client reads that decode an L1 entry without decrypting it. +/// The client read paths an L1 hit can serve. #[cfg(feature = "l1")] pub const L1_READS: [&str; 5] = [ "CacheKit::get", @@ -230,86 +223,72 @@ async fn read_by( } } -/// Every [`L1_READS`] path of a client with L1 on, each reading a backend -/// entry holding exactly `bytes` twice under a key of its own. The first read -/// reaches the backend, which backfills L1 before anything decodes. The -/// backend entry is then removed, so the second read is served by L1 or it is -/// a miss, and a miss panics here. Returns `(path, first, second)`. +/// One [`L1_READS`] path read `N` times: from the backend, then from L1. #[cfg(feature = "l1")] -pub fn read_every_path_twice_through_l1(bytes: &[u8]) -> Vec> { - let runtime = runtime(); +pub type ReadsThroughL1 = (&'static str, [Result; N]); + +/// Every [`L1_READS`] path of a client with L1 on, each under a key of its +/// own whose backend entry is `stored(key)`. The first read reaches the +/// backend, which backfills L1 before anything decodes. The backend entry is +/// then removed, so the other `N - 1` reads are served by L1 or are misses, +/// and a miss panics here. +#[cfg(feature = "l1")] +fn read_every_path_through_l1( + builder: cachekit::CacheKitBuilder, + stored: impl Fn(&str) -> Vec, +) -> Vec> { let (backend, handle) = MockBackend::new_with_handle(); - let client = CacheKit::builder() + let client = builder .backend(backend) .l1_capacity(64) .build() .expect("client builds"); - let found = |r: Result, CachekitError>| { - r.map(|v| v.expect("the forged entry must be found, not missed")) - }; - runtime.block_on(async { + runtime().block_on(async { let mut reads = Vec::new(); for path in L1_READS { - let key = format!("forged:{path}"); - handle - .store - .lock() - .await - .insert(key.clone(), bytes.to_vec()); - let first = found(read_by::(&client, path, &key).await); - handle.store.lock().await.remove(&key); - let second = found(read_by::(&client, path, &key).await); - reads.push((path, first, second)); + let key = format!("stored:{path}"); + handle.store.lock().await.insert(key.clone(), stored(&key)); + let mut results = Vec::with_capacity(N); + for _ in 0..N { + let read = read_by::(&client, path, &key).await; + results.push(read.map(|v| v.expect("the stored entry must be found, not missed"))); + handle.store.lock().await.remove(&key); + } + let Ok(results) = results.try_into() else { + unreachable!("N reads were pushed"); + }; + reads.push((path, results)); } reads }) } -/// One path of an encrypting client read three times: from the backend, from -/// L1, and from L1 again. -#[cfg(all(feature = "l1", feature = "encryption"))] -pub type ReadThrice = (&'static str, [Result; 3]); - -/// [`read_every_path_twice_through_l1`] on an encrypting client, whose -/// backend entry is `plaintext` sealed under the client's own key for the -/// entry's key, so it passes authentication and only the decode can refuse -/// it. Each path reads it from the backend, then, with the backend copy -/// removed, twice from L1: the first L1 hit walks the plaintext, and the -/// second skips the walk only if the first marked the entry. A miss panics, -/// so L1 served both of those reads. +/// [`read_every_path_through_l1`] reading a backend entry holding exactly +/// `bytes` twice: from the backend, then from L1. +#[cfg(feature = "l1")] +pub fn read_every_path_twice_through_l1( + bytes: &[u8], +) -> Vec> { + read_every_path_through_l1(CacheKit::builder(), |_| bytes.to_vec()) +} + +/// [`read_every_path_through_l1`] on an encrypting client, whose backend +/// entry is `plaintext` sealed under the client's own key for the entry's +/// key, so it passes authentication and only the decode can refuse it. Each +/// path reads it from the backend, then twice from L1: the first L1 hit +/// walks the plaintext, and the second skips the walk only if the first +/// marked the entry. #[cfg(all(feature = "l1", feature = "encryption"))] pub fn read_every_path_sealed_through_l1( plaintext: &[u8], -) -> Vec> { +) -> Vec> { const KEY: [u8; 32] = [0x42; 32]; const TENANT: &str = "sealed-reads"; - let runtime = runtime(); - let (backend, handle) = MockBackend::new_with_handle(); - let client = CacheKit::builder() - .backend(backend) - .l1_capacity(64) - .encryption_from_bytes(&KEY, TENANT) - .expect("key and tenant") - .build() - .expect("client builds"); let layer = cachekit::EncryptionLayer::new(&KEY, TENANT).expect("key and tenant"); - let found = |r: Result, CachekitError>| { - r.map(|v| v.expect("the sealed entry must be found, not missed")) - }; - runtime.block_on(async { - let mut reads = Vec::new(); - for path in L1_READS { - let key = format!("sealed:{path}"); - let sealed = layer.encrypt(plaintext, &key).expect("seal"); - handle.store.lock().await.insert(key.clone(), sealed); - let backend = found(read_by::(&client, path, &key).await); - handle.store.lock().await.remove(&key); - let l1 = found(read_by::(&client, path, &key).await); - let l1_again = found(read_by::(&client, path, &key).await); - reads.push((path, [backend, l1, l1_again])); - } - reads - }) + let builder = CacheKit::builder() + .encryption_from_bytes(&KEY, TENANT) + .expect("key and tenant"); + read_every_path_through_l1(builder, |key| layer.encrypt(plaintext, key).expect("seal")) } /// An encrypting client, L1 off so every read reaches the backend, holding diff --git a/crates/cachekit/tests/decode_bounds_tests.rs b/crates/cachekit/tests/decode_bounds_tests.rs index 1c1de41..fe1724f 100644 --- a/crates/cachekit/tests/decode_bounds_tests.rs +++ b/crates/cachekit/tests/decode_bounds_tests.rs @@ -156,7 +156,7 @@ fn a_hostile_entry_fails_from_the_backend_and_again_from_l1() { for vector in vectors()["reject_vectors"].as_array().unwrap() { let name = vector["name"].as_str().unwrap(); let bytes = unhex(vector["input_hex"].as_str().unwrap()); - for (path, first, second) in read_every_path_twice_through_l1::(&bytes) { + for (path, [first, second]) in read_every_path_twice_through_l1::(&bytes) { assert_guard_rejected(name, &format!("{path} (backend)"), first); assert_guard_rejected(name, &format!("{path} (L1)"), second); } @@ -173,7 +173,7 @@ fn every_accept_vector_decodes_from_the_backend_and_from_l1() { for vector in vectors()["accept_vectors"].as_array().unwrap() { let name = vector["name"].as_str().unwrap(); let bytes = unhex(vector["input_hex"].as_str().unwrap()); - for (path, first, second) in read_every_path_twice_through_l1::(&bytes) { + for (path, [first, second]) in read_every_path_twice_through_l1::(&bytes) { let first = first.unwrap_or_else(|e| panic!("{name}: {path} (backend): {e}")); let second = second.unwrap_or_else(|e| panic!("{name}: {path} (L1): {e}")); assert_eq!(first, second, "{name}: {path}"); From 3bce8315da5ba112407d43b5d787b8c4ce41bdf3 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 7 Nov 2026 04:11:28 +0000 Subject: [PATCH 6/7] perf(client): branch a secure SWR read after its one await `interop_read_swr` awaited `SecureCache::__interop_read_swr` for an encrypting client, so its state machine stored the whole secure read future beside the plain read's locals. The `#[cachekit]` read's poll function is generic and instantiated in the caller's crate, so any change to the secure read reshaped the plain read's code: the plaintext mark added 34 instructions per `macro_hit/plain` and `macro_hit/rel` op, all of them moves of the resolved key in that poll function. Both clients read the same bytes and differ only in the decode. The read now awaits `read_bytes_swr` once and branches afterwards, and the secure side becomes `SecureCache::decode_swr`, a synchronous decode, so the future holds no part of the secure read. Instruction counts against main (callgrind, 122 cases): every case passes. `macro_hit/plain` and `macro_hit/rel` read 37 Ir/op faster (-0.67% at 40 B), and `macro_hit/enc` 105 Ir/op faster (-1.08%). The same change on main alone moves `macro_hit/plain` from 5508 to 5474, and the mark then moves it to 5471: the plain read no longer depends on the secure read's code. --- crates/cachekit/src/client.rs | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index 11125a0..e086775 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -1299,13 +1299,19 @@ impl CacheKit { key: &str, lead: LeadSlot<'_>, ) -> Result, CachekitError> { + self.reject_namespaced_interop()?; + let full_key = self.resolve_key(key)?; + let read = self.read_bytes_swr(&full_key, lead).await?; + // An encrypting client reads the same bytes and decodes them + // differently, so it branches after this one await, synchronously. + // Awaiting a secure read here instead stored that read's future in + // this one, and any change to the secure read changed the code of + // every plain read. #[cfg(feature = "encryption")] if let Some(secure) = self.encrypting() { - return secure.__interop_read_swr(key, lead).await; + return secure.decode_swr(read, &full_key); } - self.reject_namespaced_interop()?; - let full_key = self.resolve_key(key)?; - Ok(match self.read_bytes_swr(&full_key, lead).await? { + Ok(match read { SwrRead::Fresh((payload, by)) => { SwrRead::Fresh((crate::interop::deserialize_payload(&payload)?, by)) } @@ -1631,19 +1637,17 @@ impl SecureCache<'_> { } } - /// The secure twin of [`CacheKit::__interop_read_swr`] and - /// [`CacheKit::__interop_read_swr_leading`], which hand an encrypting - /// client's reads here. - async fn __interop_read_swr( + /// The secure decode of [`CacheKit::__interop_read_swr`] and + /// [`CacheKit::__interop_read_swr_leading`]: `read` is what an encrypting + /// client's read returned for `full_key`. + fn decode_swr( &self, - key: &str, - lead: LeadSlot<'_>, + read: SwrRead<(Payload, ReadOutcome)>, + full_key: &str, ) -> Result, CachekitError> { - self.client.reject_namespaced_interop()?; - let full_key = self.client.resolve_key(key)?; let decode = - |ct: &Payload, by| self.open(ct, by, &full_key, crate::interop::deserialize_decrypted); - Ok(match self.client.read_bytes_swr(&full_key, lead).await? { + |ct: &Payload, by| self.open(ct, by, full_key, crate::interop::deserialize_decrypted); + Ok(match read { SwrRead::Fresh((ct, by)) => SwrRead::Fresh((decode(&ct, by)?, by)), SwrRead::Stale((ct, by), token) => SwrRead::Stale((decode(&ct, by)?, by), token), SwrRead::Miss => SwrRead::Miss, From 48ada55026f787de338b97e5a30d716d7ef9b69d Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 7 Nov 2026 04:22:15 +0000 Subject: [PATCH 7/7] refactor(client): name the secure SWR read open_swr and state the branch rule decode_swr also decrypts, evicts on a failed tag and adopts the walk mark into L1, all through SecureCache::open, so name it for open. The comment in interop_read_swr now states the rule, not its history: the secure decode still runs in the shared poll function, and only an awaited secure future would sit in the plain read's state. --- crates/cachekit/src/client.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index e086775..75053a2 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -1302,14 +1302,13 @@ impl CacheKit { self.reject_namespaced_interop()?; let full_key = self.resolve_key(key)?; let read = self.read_bytes_swr(&full_key, lead).await?; - // An encrypting client reads the same bytes and decodes them - // differently, so it branches after this one await, synchronously. - // Awaiting a secure read here instead stored that read's future in - // this one, and any change to the secure read changed the code of - // every plain read. + // Plain and encrypting clients read the same bytes and differ only in + // the decode, so branch after the await, synchronously: an awaited + // secure read's future would sit in this future's state, which every + // plain read shares. #[cfg(feature = "encryption")] if let Some(secure) = self.encrypting() { - return secure.decode_swr(read, &full_key); + return secure.open_swr(read, &full_key); } Ok(match read { SwrRead::Fresh((payload, by)) => { @@ -1637,10 +1636,10 @@ impl SecureCache<'_> { } } - /// The secure decode of [`CacheKit::__interop_read_swr`] and - /// [`CacheKit::__interop_read_swr_leading`]: `read` is what an encrypting - /// client's read returned for `full_key`. - fn decode_swr( + /// [`Self::open`] over `read`, what an encrypting client's + /// [`CacheKit::__interop_read_swr`] or + /// [`CacheKit::__interop_read_swr_leading`] read for `full_key`. + fn open_swr( &self, read: SwrRead<(Payload, ReadOutcome)>, full_key: &str,