Skip to main content

cachekit/serializer/
mod.rs

1use cachekit_core::msgpack_document_len;
2use rmp_serde::decode::ReadRefReader;
3use serde::{de::DeserializeOwned, Serialize};
4
5use crate::error::CachekitError;
6
7/// Maximum MessagePack nesting depth accepted from an untrusted payload.
8///
9/// Every cache read decodes bytes the backend controls, and `rmp-serde`
10/// recurses once per nesting level, so the failure mode of unbounded depth is
11/// a **stack overflow** — an uncatchable process abort. `rmp-serde`'s own
12/// default (1024) is not a safe bound: measured (rmp-serde 1.3.1, 2026-09) on
13/// a 2 MiB thread stack — the tokio worker default — a debug build overflows
14/// between 512 and 768 nested arrays, i.e. a ~700-byte forged entry; a release
15/// build fits 1024 with little margin, and frame size grows with the target
16/// type. 100 matches cachekit-ts (`DEFAULT_MAX_DEPTH`), leaves a wide margin on
17/// every profile and target (wasm32 included), and sits inside the protocol's
18/// required `32..=1024` window (`spec/interop-mode.md` → Decode bounds).
19///
20/// Enforced by cachekit-core's `msgpack_document_len`, which runs before
21/// every decode in [`deserialize`] and [`crate::interop::deserialize`]. Depth
22/// counts every array and map header on the deepest path, empty ones included;
23/// scalars, str, bin and ext are leaves.
24/// Exactly `MAX_DECODE_DEPTH` levels are accepted, and one more is rejected
25/// with a `decode bound:` error before `rmp-serde` recurses.
26pub const MAX_DECODE_DEPTH: usize = 100;
27
28/// A decoder reading a byte slice in place.
29pub(crate) type SliceDeserializer<'a> = rmp_serde::Deserializer<ReadRefReader<'a, [u8]>>;
30
31/// Build a depth- and allocation-bounded `rmp_serde::Deserializer` over
32/// `bytes`, which must hold exactly one MessagePack document.
33///
34/// Every decode of backend-supplied bytes (auto-mode [`deserialize`] and
35/// [`crate::interop::deserialize`]) MUST go through here so the bounds cannot
36/// drift between paths. It runs cachekit-core's header-only
37/// `msgpack_document_len` walk first. The walk proves that a complete
38/// document lies within `bytes`, that it nests no deeper than
39/// [`MAX_DECODE_DEPTH`], and that no header declares more elements or bytes
40/// than the remaining input can back. Its rejection becomes a
41/// `CachekitError::Serialization` starting `decode bound:`.
42///
43/// Why depth is checked by the walk and not only by `rmp-serde`: a guard that
44/// admits a document deeper than the bound leaves the rejection to the decoder,
45/// mid-recursion, after it has materialised the outer levels.
46///
47/// Why the walk is needed even though `rmp-serde` reads str/bin lazily: serde's
48/// sequence visitors (`Vec<T>`, and the `Content` buffer that
49/// `#[serde(untagged)]` targets decode through) reserve
50/// `min(declared_len × size_of::<Element>(), 1 MiB)` bytes per collection from
51/// `size_hint`, so without the walk a 500-byte payload of nested
52/// `array32(0xFFFFFFFF)` headers requests `MAX_DECODE_DEPTH` MiB before the
53/// first EOF error — an OOM kill on a Cloudflare Workers isolate.
54///
55/// It then rejects any byte after the document: no writer emits one, a lenient
56/// read would decode a foreign container by its first byte (a cachekit-py CK
57/// frame begins with `0x43`, the complete document 67), and after a decrypt the
58/// spec requires a parse mismatch to fail. The walk leaves trailing bytes to
59/// its caller and returns where the document ends, so the same walk settles
60/// this: any length short of `bytes.len()` means bytes follow the document.
61/// Finally it sets `rmp-serde`'s own depth limit as a backstop just above
62/// [`MAX_DECODE_DEPTH`]: its default (1024) is deep enough to overflow a
63/// debug-build thread stack.
64///
65/// The decoder reads the input in place (`from_read_ref`), so str and bin
66/// payloads are handed to serde as borrowed slices. The default `ReadReader`
67/// copies each one through an internal buffer first, one extra allocation and
68/// copy per field on every cache read.
69///
70/// A successful decode consumes the whole document: rmp-serde rejects an array
71/// or map its target leaves unread. Ext is the exception: a target that reads
72/// only an ext's type byte leaves the body unread and is accepted. No value
73/// this SDK writes contains ext.
74pub(crate) fn bounded_deserializer(bytes: &[u8]) -> Result<SliceDeserializer<'_>, CachekitError> {
75    let len = msgpack_document_len(bytes, MAX_DECODE_DEPTH)
76        .map_err(|e| CachekitError::Serialization(format!("decode bound: {e}")))?;
77    // The error names no byte: on the secure path these bytes are decrypted
78    // plaintext (CWE-532). Their count is not.
79    if len < bytes.len() {
80        return Err(CachekitError::Serialization(format!(
81            "payload has {} trailing byte(s) after the MessagePack document — \
82             readers must consume exactly one document",
83            bytes.len() - len
84        )));
85    }
86    let mut de = rmp_serde::Deserializer::from_read_ref(bytes);
87    // rmp-serde decrements its counter on entry and errors when it reaches 0, so
88    // `set_max_depth(n)` admits n - 1 levels, and it counts an ext value as a
89    // level where the walk (and the spec) count it as a leaf. Ext is always a
90    // leaf, so +2 admits every document the walk admits, including an ext at the
91    // bound, and the walk's error is the one callers see for anything deeper
92    // (both pinned by tests/decode_bounds_tests.rs).
93    de.set_max_depth(MAX_DECODE_DEPTH + 2);
94    Ok(de)
95}
96
97/// Serialize `value` to MessagePack bytes using named fields (map format).
98pub fn serialize<T: Serialize>(value: &T) -> Result<Vec<u8>, CachekitError> {
99    rmp_serde::to_vec_named(value).map_err(|e| CachekitError::Serialization(e.to_string()))
100}
101
102/// Deserialize `bytes` from MessagePack into `T` under the decode bounds:
103/// cachekit-core's structural walk (depth ≤ [`MAX_DECODE_DEPTH`], no
104/// overclaim) runs before anything is decoded, and `bytes` must hold exactly
105/// one document — trailing bytes are rejected, as in interop mode
106/// ([`crate::interop::deserialize`]).
107pub fn deserialize<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CachekitError> {
108    T::deserialize(&mut bounded_deserializer(bytes)?)
109        .map_err(|e| CachekitError::Serialization(e.to_string()))
110}