Skip to main content

cachekit/serializer/
mod.rs

1use cachekit_core::check_msgpack_structure;
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 `check_msgpack_structure`, 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/// `check_msgpack_structure` 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 reports no length, so the check walks `bytes` again without
60/// its last byte. A MessagePack document's own bytes fix where it ends, so it is
61/// still complete without that byte exactly when at least one byte follows it.
62/// Finally it sets `rmp-serde`'s own depth limit as a backstop just above
63/// [`MAX_DECODE_DEPTH`]: its default (1024) is deep enough to overflow a
64/// debug-build thread stack.
65///
66/// The decoder reads the input in place (`from_read_ref`), so str and bin
67/// payloads are handed to serde as borrowed slices. The default `ReadReader`
68/// copies each one through an internal buffer first, one extra allocation and
69/// copy per field on every cache read.
70///
71/// A successful decode consumes the whole document: rmp-serde rejects an array
72/// or map its target leaves unread. Ext is the exception: a target that reads
73/// only an ext's type byte leaves the body unread and is accepted. No value
74/// this SDK writes contains ext.
75pub(crate) fn bounded_deserializer(bytes: &[u8]) -> Result<SliceDeserializer<'_>, CachekitError> {
76    check_msgpack_structure(bytes, MAX_DECODE_DEPTH)
77        .map_err(|e| CachekitError::Serialization(format!("decode bound: {e}")))?;
78    let trailing = bytes
79        .split_last()
80        .is_some_and(|(_, head)| check_msgpack_structure(head, MAX_DECODE_DEPTH).is_ok());
81    // The error names no byte: on the secure path these bytes are decrypted
82    // plaintext (CWE-532).
83    if trailing {
84        return Err(CachekitError::Serialization(
85            "payload has trailing byte(s) after the MessagePack document — \
86             readers must consume exactly one document"
87                .to_owned(),
88        ));
89    }
90    let mut de = rmp_serde::Deserializer::from_read_ref(bytes);
91    // rmp-serde decrements its counter on entry and errors when it reaches 0, so
92    // `set_max_depth(n)` admits n - 1 levels, and it counts an ext value as a
93    // level where the walk (and the spec) count it as a leaf. Ext is always a
94    // leaf, so +2 admits every document the walk admits, including an ext at the
95    // bound, and the walk's error is the one callers see for anything deeper
96    // (both pinned by tests/decode_bounds_tests.rs).
97    de.set_max_depth(MAX_DECODE_DEPTH + 2);
98    Ok(de)
99}
100
101/// Serialize `value` to MessagePack bytes using named fields (map format).
102pub fn serialize<T: Serialize>(value: &T) -> Result<Vec<u8>, CachekitError> {
103    rmp_serde::to_vec_named(value).map_err(|e| CachekitError::Serialization(e.to_string()))
104}
105
106/// Deserialize `bytes` from MessagePack into `T` under the decode bounds:
107/// cachekit-core's structural walk (depth ≤ [`MAX_DECODE_DEPTH`], no
108/// overclaim) runs before anything is decoded, and `bytes` must hold exactly
109/// one document — trailing bytes are rejected, as in interop mode
110/// ([`crate::interop::deserialize`]).
111pub fn deserialize<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CachekitError> {
112    T::deserialize(&mut bounded_deserializer(bytes)?)
113        .map_err(|e| CachekitError::Serialization(e.to_string()))
114}