Skip to main content

cachekit/serializer/
mod.rs

1use rmp_serde::decode::ReadRefReader;
2use serde::{de::DeserializeOwned, Serialize};
3
4use crate::error::CachekitError;
5
6/// Maximum MessagePack nesting depth accepted from an untrusted payload.
7///
8/// Every cache read decodes bytes the backend controls, and `rmp-serde`
9/// recurses once per nesting level, so the failure mode of unbounded depth is
10/// a **stack overflow** — an uncatchable process abort. `rmp-serde`'s own
11/// default (1024) is not a safe bound: measured (rmp-serde 1.3.1, 2026-09) on
12/// a 2 MiB thread stack — the tokio worker default — a debug build overflows
13/// between 512 and 768 nested arrays, i.e. a ~700-byte forged entry; a release
14/// build fits 1024 with little margin, and frame size grows with the target
15/// type. 100 matches cachekit-ts (`DEFAULT_MAX_DEPTH`), leaves a wide margin on
16/// every profile and target (wasm32 included), and sits inside the protocol's
17/// required `32..=1024` window (`spec/interop-mode.md` → Decode bounds).
18///
19/// Enforced by `check_structure`, which defines how depth is counted.
20pub const MAX_DECODE_DEPTH: usize = 100;
21
22/// Header-only structural walk over one MessagePack document.
23///
24/// Proves that a complete document lies within `bytes`, that it nests no deeper
25/// than [`MAX_DECODE_DEPTH`], and that no header declares more elements or bytes
26/// than the remaining input can back — every element costs at least one input
27/// byte, so `pending ≤ remaining` at every step means Σ declared ≤ input.
28/// `pending` sums what every open header still owes, so nested headers that each
29/// fit the input after them but together overclaim are rejected too. All counts
30/// are `u64`: before each header is added `pending` is at most the remaining
31/// input, and one header adds less than 2³³ (a map32 declares 2 × (2³² − 1)
32/// children), so no sum can overflow. Depth counts every array and map header
33/// on the deepest path, empty ones included; scalars, str, bin and ext are
34/// leaves. Exactly `MAX_DECODE_DEPTH` levels are accepted, one more is rejected
35/// with a `decode bound:` error before `rmp-serde` recurses. Memory is fixed: a
36/// `[u64; MAX_DECODE_DEPTH]` stack of children still owed by each open
37/// collection, whatever the input. Fails closed on the reserved marker,
38/// truncation, excess depth, and length overflow, always with a message
39/// starting `decode bound:`. Returns the document's length in bytes: the walk
40/// covers exactly one document, so its end position is where the document ends.
41///
42/// Why depth is checked here and not only by `rmp-serde`: a guard that admits
43/// a document deeper than the bound leaves the rejection to the decoder,
44/// mid-recursion, after it has materialised the outer levels.
45///
46/// Why it is needed even though `rmp-serde` reads str/bin lazily: serde's
47/// sequence visitors (`Vec<T>`, and the `Content` buffer that
48/// `#[serde(untagged)]` targets decode through) reserve
49/// `min(declared_len × size_of::<Element>(), 1 MiB)` bytes per collection from
50/// `size_hint`, so without this walk a 500-byte payload of nested
51/// `array32(0xFFFFFFFF)` headers requests `MAX_DECODE_DEPTH` MiB before the
52/// first EOF error — an OOM kill on a Cloudflare Workers isolate. Trailing
53/// bytes are left to the caller: [`bounded_deserializer`] rejects them by
54/// comparing the returned length with the input's.
55pub(crate) fn check_structure(bytes: &[u8]) -> Result<usize, CachekitError> {
56    fn reject(what: &str) -> CachekitError {
57        CachekitError::Serialization(format!("decode bound: {what}"))
58    }
59    fn be(bytes: &[u8], pos: usize, width: usize) -> Result<u64, CachekitError> {
60        let end = pos.checked_add(width).filter(|e| *e <= bytes.len());
61        let field = &bytes[pos..end.ok_or_else(|| reject("input ends inside a length prefix"))?];
62        Ok(field.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b)))
63    }
64
65    // Children still owed by each open collection, innermost last. The top frame
66    // is non-zero between elements; an ancestor may reach 0 while its last child
67    // is still open, so the pop loop drains every finished frame, and an empty
68    // collection never stays pushed.
69    let mut open = [0u64; MAX_DECODE_DEPTH];
70    let mut depth = 0usize;
71    let mut pos = 0usize;
72    let mut pending: u64 = 1; // elements still owed by open headers (the root counts as one)
73    while pending > 0 {
74        let marker = *bytes
75            .get(pos)
76            .ok_or_else(|| reject("input ends before the document is complete"))?;
77        pos += 1;
78        pending -= 1;
79        if let Some(parent) = depth.checked_sub(1) {
80            open[parent] -= 1;
81        }
82        // (prefix bytes, payload bytes after the prefix, child elements)
83        let (prefix, payload, children): (usize, u64, u64) = match marker {
84            0x00..=0x7f | 0xc0 | 0xc2 | 0xc3 | 0xe0..=0xff => (0, 0, 0),
85            0x80..=0x8f => (0, 0, 2 * u64::from(marker & 0x0f)),
86            0x90..=0x9f => (0, 0, u64::from(marker & 0x0f)),
87            0xa0..=0xbf => (0, u64::from(marker & 0x1f), 0),
88            0xc1 => return Err(reject("reserved marker 0xc1")),
89            0xc4 | 0xd9 => (1, be(bytes, pos, 1)?, 0),
90            0xc5 | 0xda => (2, be(bytes, pos, 2)?, 0),
91            0xc6 | 0xdb => (4, be(bytes, pos, 4)?, 0),
92            0xc7 => (1, be(bytes, pos, 1)? + 1, 0), // ext: length prefix, then type byte + data
93            0xc8 => (2, be(bytes, pos, 2)? + 1, 0),
94            0xc9 => (4, be(bytes, pos, 4)? + 1, 0),
95            0xca..=0xd3 => (0, 1u64 << (marker & 0x03), 0), // f32/f64/u8..u64/i8..i64: 4,8,1,2,4,8,1,2,4,8
96            0xd4..=0xd8 => (0, 1 + (1u64 << (marker - 0xd4)), 0), // fixext: type byte + 1/2/4/8/16
97            0xdc => (2, 0, be(bytes, pos, 2)?),
98            0xdd => (4, 0, be(bytes, pos, 4)?),
99            0xde => (2, 0, 2 * be(bytes, pos, 2)?),
100            0xdf => (4, 0, 2 * be(bytes, pos, 4)?),
101        };
102        pos += prefix;
103        let remaining = (bytes.len() - pos) as u64;
104        if payload > remaining {
105            return Err(reject("header declares more bytes than the input holds"));
106        }
107        pos += usize::try_from(payload)
108            .map_err(|_| reject("header declares more bytes than the input holds"))?;
109        pending += children;
110        if pending > remaining - payload {
111            return Err(reject(
112                "header declares more elements than the input can back",
113            ));
114        }
115        if matches!(marker, 0x80..=0x9f | 0xdc..=0xdf) {
116            // Every collection header is a level, an empty one included.
117            if depth == MAX_DECODE_DEPTH {
118                return Err(reject(&format!(
119                    "nesting deeper than {MAX_DECODE_DEPTH} levels"
120                )));
121            }
122            open[depth] = children;
123            depth += 1;
124        }
125        while depth > 0 && open[depth - 1] == 0 {
126            depth -= 1;
127        }
128    }
129    Ok(pos)
130}
131
132/// A decoder reading a byte slice in place.
133pub(crate) type SliceDeserializer<'a> = rmp_serde::Deserializer<ReadRefReader<'a, [u8]>>;
134
135/// Build a depth- and allocation-bounded `rmp_serde::Deserializer` over
136/// `bytes`, which must hold exactly one MessagePack document.
137///
138/// Every decode of backend-supplied bytes (auto-mode [`deserialize`] and
139/// [`crate::interop::deserialize`]) MUST go through here so the bounds cannot
140/// drift between paths. Runs `check_structure` first (depth and allocation
141/// bound), then rejects any byte after the document it measured: no writer
142/// emits one, a lenient read would decode a foreign
143/// container by its first byte (a cachekit-py CK frame begins with `0x43`, the
144/// complete document 67), and after a decrypt the spec requires a parse
145/// mismatch to fail. It then sets `rmp-serde`'s own depth limit as a
146/// backstop just above [`MAX_DECODE_DEPTH`]: its default (1024) is deep enough
147/// to overflow a debug-build thread stack.
148///
149/// The decoder reads the input in place (`from_read_ref`), so str and bin
150/// payloads are handed to serde as borrowed slices. The default `ReadReader`
151/// copies each one through an internal buffer first, one extra allocation and
152/// copy per field on every cache read.
153///
154/// A successful decode consumes the whole document: rmp-serde rejects an array
155/// or map its target leaves unread. Ext is the exception: a target that reads
156/// only an ext's type byte leaves the body unread and is accepted. No value
157/// this SDK writes contains ext.
158pub(crate) fn bounded_deserializer(bytes: &[u8]) -> Result<SliceDeserializer<'_>, CachekitError> {
159    let doc_len = check_structure(bytes)?;
160    // The error carries only a count: on the secure path these bytes are
161    // decrypted plaintext (CWE-532).
162    let trailing = bytes.len() - doc_len;
163    if trailing > 0 {
164        return Err(CachekitError::Serialization(format!(
165            "payload has {trailing} trailing byte(s) after the MessagePack document — \
166             readers must consume exactly one document"
167        )));
168    }
169    let mut de = rmp_serde::Deserializer::from_read_ref(bytes);
170    // rmp-serde decrements its counter on entry and errors when it reaches 0, so
171    // `set_max_depth(n)` admits n - 1 levels, and it counts an ext value as a
172    // level where the walk (and the spec) count it as a leaf. Ext is always a
173    // leaf, so +2 admits every document the walk admits, including an ext at the
174    // bound, and the walk's error is the one callers see for anything deeper
175    // (both pinned by tests/decode_bounds_tests.rs).
176    de.set_max_depth(MAX_DECODE_DEPTH + 2);
177    Ok(de)
178}
179
180/// Serialize `value` to MessagePack bytes using named fields (map format).
181pub fn serialize<T: Serialize>(value: &T) -> Result<Vec<u8>, CachekitError> {
182    rmp_serde::to_vec_named(value).map_err(|e| CachekitError::Serialization(e.to_string()))
183}
184
185/// Deserialize `bytes` from MessagePack into `T` under the decode bounds: the
186/// `check_structure` walk (depth ≤ [`MAX_DECODE_DEPTH`], no overclaim) runs
187/// before anything is decoded, and `bytes` must hold exactly one document —
188/// trailing bytes are rejected, as in interop mode
189/// ([`crate::interop::deserialize`]).
190pub fn deserialize<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CachekitError> {
191    T::deserialize(&mut bounded_deserializer(bytes)?)
192        .map_err(|e| CachekitError::Serialization(e.to_string()))
193}