Skip to main content

cachekit/
interop.rs

1//! Interop mode (interop/v1): language-neutral cache keys and plain-MessagePack values.
2//!
3//! Implements the cross-SDK key and value formats from
4//! `protocol/spec/interop-mode.md`, byte-verified against
5//! `protocol/test-vectors/interop-mode.json` (see `tests/interop_vector_tests.rs`).
6//!
7//! # Key format
8//!
9//! ```text
10//! {namespace}:{operation}:{args_hash}
11//! ```
12//!
13//! `namespace` and `operation` are user-supplied, validated against
14//! `^[a-z0-9][a-z0-9._-]{0,63}$` (full-string). `namespace` must also not be
15//! exactly `ns` or `nsapi`: the CachekitIO server parses a key starting `ns:`
16//! or `nsapi:` as namespace-prefixed. `args_hash` is the Blake2b-256
17//! digest (lowercase hex) of the canonical MessagePack encoding of the flat
18//! argument array. Unlike auto mode, there is no `func:` segment — the
19//! operation identity is explicit, so every SDK computes the same key for the
20//! same logical call.
21//!
22//! # Why a hand-rolled encoder
23//!
24//! The spec makes the encoding *normative*: shortest-form integer/str/bin/array/map
25//! headers, float64-only floats, map keys sorted by Unicode code point, sets
26//! sorted by encoded bytes, and integral-float collapse. `rmp-serde` happens to
27//! emit shortest forms but provides no sorting, no set semantics, and no number
28//! canonicalization — hashing whatever serde produces would make key equality an
29//! implementation accident. The closed [`crate::interop::InteropValue`] model plus an explicit
30//! encoder is the only way to guarantee byte-identical hashes across SDKs.
31//!
32//! # Values
33//!
34//! Interop values are plain MessagePack documents — no ByteStorage envelope, no
35//! LZ4, no checksum. `cachekit-rs` already writes plain MessagePack natively
36//! (via [`crate::serializer`]), so regular [`crate::CacheKit::set`] output is
37//! interop-readable as-is. Reads are the sharp edge: interop readers MUST
38//! consume exactly one MessagePack document and reject trailing bytes — see
39//! [`crate::interop::deserialize`].
40//!
41//! # Example
42//!
43//! ```
44//! use cachekit::interop::{interop_key, InteropValue};
45//!
46//! let key = interop_key("users", "get_user", &[InteropValue::from(42i64)]).unwrap();
47//! assert_eq!(
48//!     key,
49//!     "users:get_user:61598716255080080f6456eb065c2e51badfaa4320b0efe97469c29cffee8875"
50//! );
51//! ```
52
53use std::collections::BTreeMap;
54
55use blake2::{digest::consts::U32, Blake2b, Digest};
56use serde::de::DeserializeOwned;
57
58use crate::error::CachekitError;
59
60type Blake2b256 = Blake2b<U32>;
61
62/// Inclusive lower bound of the interop integer range: -2^63.
63const INT_MIN: i128 = i64::MIN as i128;
64/// Inclusive upper bound of the interop integer range: 2^64 - 1.
65const INT_MAX: i128 = u64::MAX as i128;
66
67/// -2^63 as float64 (exact; the collapse range lower bound, inclusive).
68const F64_COLLAPSE_MIN: f64 = -9_223_372_036_854_775_808.0;
69/// 2^64 as float64 (exact; the collapse range upper bound, EXCLUSIVE).
70///
71/// Spec: do NOT write 2^64-1 here — that literal rounds up to 2^64 as float64,
72/// so the comparison must be strict-less-than against 2^64 itself.
73const F64_COLLAPSE_MAX: f64 = 18_446_744_073_709_551_616.0;
74
75// ── Data model ───────────────────────────────────────────────────────────────
76
77/// A value in the closed interop/v1 argument data model.
78///
79/// Anything outside this model is unrepresentable by construction; values that
80/// are representable but out of spec range (non-finite floats, integers outside
81/// `[-2^63, 2^64-1]`) are rejected with an error at encode time — never
82/// silently coerced.
83#[derive(Debug, Clone, PartialEq)]
84pub enum InteropValue {
85    /// msgpack nil.
86    Null,
87    /// msgpack bool.
88    Bool(bool),
89    /// Integer. Must be within `[-2^63, 2^64-1]` (checked at encode time);
90    /// `i128` storage lets a single variant span the full signed+unsigned range.
91    Int(i128),
92    /// Float. Must be finite (NaN / ±Inf rejected at encode time). In the
93    /// argument profile, integral floats within `[-2^63, 2^64)` collapse to the
94    /// integer encoding, so `2.0` and `2` hash identically (spec: number
95    /// canonicalization).
96    Float(f64),
97    /// UTF-8 string, encoded as the msgpack str family. No Unicode
98    /// normalization is applied. Rust strings are well-formed Unicode scalar
99    /// sequences by construction, which the spec requires.
100    Str(String),
101    /// Byte string, encoded as the msgpack bin family (never str).
102    Bytes(Vec<u8>),
103    /// Ordered sequence; elements normalized recursively.
104    Array(Vec<InteropValue>),
105    /// String-keyed map. `BTreeMap`'s byte-wise key order **is** the spec's
106    /// Unicode-code-point order (UTF-8 byte order ≡ code point order), applied
107    /// at every nesting level.
108    Map(BTreeMap<String, InteropValue>),
109    /// Set: elements are normalized, encoded, sorted by their encoded bytes
110    /// (unsigned lexicographic), and deduplicated post-normalization.
111    Set(Vec<InteropValue>),
112    /// Timezone-aware instant as microseconds since the Unix epoch.
113    ///
114    /// Sub-microsecond precision must be floored toward negative infinity
115    /// before construction. Encoding performs the spec's single IEEE 754
116    /// float64 division by 10^6, so hashes are bit-deterministic across SDKs.
117    /// Naive (offset-less) datetimes are unrepresentable in this API.
118    DateTime {
119        /// Microseconds since `1970-01-01T00:00:00Z` (negative = pre-epoch).
120        unix_micros: i64,
121    },
122    /// UUID, encoded as its lowercase hyphenated string form.
123    Uuid(uuid::Uuid),
124}
125
126impl InteropValue {
127    /// Build a byte string (msgpack bin).
128    #[must_use]
129    pub fn bytes(bytes: impl Into<Vec<u8>>) -> Self {
130        Self::Bytes(bytes.into())
131    }
132
133    /// Build a timezone-aware datetime from microseconds since the Unix epoch.
134    #[must_use]
135    pub fn datetime_from_unix_micros(unix_micros: i64) -> Self {
136        Self::DateTime { unix_micros }
137    }
138}
139
140impl From<bool> for InteropValue {
141    fn from(v: bool) -> Self {
142        Self::Bool(v)
143    }
144}
145
146impl From<i32> for InteropValue {
147    fn from(v: i32) -> Self {
148        Self::Int(i128::from(v))
149    }
150}
151
152impl From<i64> for InteropValue {
153    fn from(v: i64) -> Self {
154        Self::Int(i128::from(v))
155    }
156}
157
158impl From<u32> for InteropValue {
159    fn from(v: u32) -> Self {
160        Self::Int(i128::from(v))
161    }
162}
163
164impl From<u64> for InteropValue {
165    fn from(v: u64) -> Self {
166        Self::Int(i128::from(v))
167    }
168}
169
170impl From<i128> for InteropValue {
171    /// Range is checked at encode time, not construction time.
172    fn from(v: i128) -> Self {
173        Self::Int(v)
174    }
175}
176
177impl From<f64> for InteropValue {
178    /// Finiteness is checked at encode time, not construction time.
179    fn from(v: f64) -> Self {
180        Self::Float(v)
181    }
182}
183
184impl From<&str> for InteropValue {
185    fn from(v: &str) -> Self {
186        Self::Str(v.to_owned())
187    }
188}
189
190impl From<String> for InteropValue {
191    fn from(v: String) -> Self {
192        Self::Str(v)
193    }
194}
195
196// Deliberately NO `From<Vec<u8>>`: serde_json maps `Vec<u8>` to an array of
197// numbers, so an implicit conversion to msgpack bin here would silently flip
198// bin/array semantics for anyone carrying serde_json muscle memory — and a
199// silently different encoding is a silently different cache key. Use the
200// explicit `InteropValue::bytes()` constructor.
201
202impl From<Vec<InteropValue>> for InteropValue {
203    fn from(v: Vec<InteropValue>) -> Self {
204        Self::Array(v)
205    }
206}
207
208impl From<BTreeMap<String, InteropValue>> for InteropValue {
209    fn from(v: BTreeMap<String, InteropValue>) -> Self {
210        Self::Map(v)
211    }
212}
213
214impl From<uuid::Uuid> for InteropValue {
215    fn from(v: uuid::Uuid) -> Self {
216        Self::Uuid(v)
217    }
218}
219
220impl TryFrom<std::time::SystemTime> for InteropValue {
221    type Error = CachekitError;
222
223    /// Convert an instant to interop datetime micros, flooring sub-microsecond
224    /// precision toward negative infinity (spec rule — truncation toward zero
225    /// would differ for pre-epoch instants).
226    fn try_from(t: std::time::SystemTime) -> Result<Self, CachekitError> {
227        let out_of_range =
228            || CachekitError::Serialization("datetime out of interop range".to_owned());
229        match t.duration_since(std::time::UNIX_EPOCH) {
230            Ok(after) => {
231                let micros = i64::try_from(after.as_micros()).map_err(|_| out_of_range())?;
232                Ok(Self::DateTime {
233                    unix_micros: micros,
234                })
235            }
236            Err(before) => {
237                // Pre-epoch: flooring toward -inf rounds the magnitude UP.
238                let micros_up = before.duration().as_nanos().div_ceil(1000);
239                let micros = i64::try_from(micros_up).map_err(|_| out_of_range())?;
240                Ok(Self::DateTime {
241                    unix_micros: -micros,
242                })
243            }
244        }
245    }
246}
247
248// ── Segment validation ───────────────────────────────────────────────────────
249
250/// Validate a key segment against `^[a-z0-9][a-z0-9._-]{0,63}$` as a
251/// full-string match, and reject the reserved namespaces `ns` and `nsapi`.
252///
253/// Byte-wise iteration over the whole string makes this a full match by
254/// construction — a trailing `\n` (which Python's `re.match` + `$` would
255/// accept) fails here, as the `reject_trailing_newline` vector requires.
256///
257/// The reservation is exact-match and applies only when `kind` is
258/// `"namespace"`: the CachekitIO server parses a key starting `ns:` or
259/// `nsapi:` as namespace-prefixed, so such an interop key would be rejected or
260/// misrouted there. `ns` and `nsapi` stay valid operations.
261///
262/// NOTE: `cachekit-macros` carries a compile-time mirror of this grammar and
263/// the reservation (`segment_is_valid` / `parse_segment`) — proc-macro crates
264/// cannot depend on this crate. If you change either here, change it there in
265/// the same diff.
266fn validate_segment(kind: &str, segment: &str) -> Result<(), CachekitError> {
267    let bytes = segment.as_bytes();
268    let valid = matches!(bytes.first(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit())
269        && bytes.len() <= 64
270        && bytes[1..].iter().all(|b| {
271            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
272        });
273    if !valid {
274        return Err(CachekitError::InvalidKey(format!(
275            "interop {kind} {segment:?} must match ^[a-z0-9][a-z0-9._-]{{0,63}}$ \
276             (lowercase ASCII letters, digits, '.', '_', '-'; 1-64 chars)"
277        )));
278    }
279    if kind == "namespace" && matches!(segment, "ns" | "nsapi") {
280        return Err(CachekitError::InvalidKey(format!(
281            "interop namespace {segment:?} is reserved: the CachekitIO server parses a key \
282             starting \"{segment}:\" as namespace-prefixed"
283        )));
284    }
285    Ok(())
286}
287
288// ── Public API ───────────────────────────────────────────────────────────────
289
290/// Generate an interop/v1 cache key: `{namespace}:{operation}:{args_hash}`.
291///
292/// `args_hash` is Blake2b-256 (32-byte digest, unkeyed, lowercase hex) over
293/// [`canonical_args`] of the flat argument array. The maximum possible key
294/// length is 194 characters, so the auto-mode truncation rule never applies.
295///
296/// # Errors
297///
298/// - [`CachekitError::InvalidKey`] if `namespace` or `operation` fails the
299///   segment grammar (rejected, never normalized), or `namespace` is the
300///   reserved `ns` or `nsapi`.
301/// - [`CachekitError::Serialization`] if any argument is outside the interop
302///   data model's ranges (non-finite float, integer outside `[-2^63, 2^64-1]`).
303pub fn interop_key(
304    namespace: &str,
305    operation: &str,
306    args: &[InteropValue],
307) -> Result<String, CachekitError> {
308    validate_segment("namespace", namespace)?;
309    validate_segment("operation", operation)?;
310
311    let encoded = canonical_args(args)?;
312    let mut hasher = Blake2b256::new();
313    hasher.update(&encoded);
314    let args_hash = hex::encode(hasher.finalize());
315
316    Ok(format!("{namespace}:{operation}:{args_hash}"))
317}
318
319/// Encode the flat argument array to canonical MessagePack (argument profile:
320/// number canonicalization applies, so integral floats collapse to ints).
321///
322/// This is the exact byte string hashed by [`interop_key`]; exposed so the
323/// canonical bytes themselves can be verified against the protocol vectors.
324///
325/// # Errors
326///
327/// [`CachekitError::Serialization`] for out-of-model values (see [`interop_key`]).
328pub fn canonical_args(args: &[InteropValue]) -> Result<Vec<u8>, CachekitError> {
329    let mut buf = Vec::new();
330    encode_array_header(&mut buf, args.len())?;
331    for arg in args {
332        encode(&mut buf, arg, Profile::Args)?;
333    }
334    Ok(buf)
335}
336
337/// Encode a single value to canonical MessagePack (value profile: floats are
338/// **not** collapsed — a float value `2.0` stays float64 so it round-trips as
339/// a float).
340///
341/// Interop values are plain MessagePack, so any serde-serialized document is
342/// also a valid interop value; use this function when byte-canonical output
343/// (sorted map keys, shortest forms) is required, e.g. to match the published
344/// value vectors.
345///
346/// # Errors
347///
348/// [`CachekitError::Serialization`] for out-of-model values, and for
349/// [`InteropValue::DateTime`] — temporal *values* use the wire-format sentinel
350/// map (`{"__datetime__": true, "value": "<ISO-8601>"}`), not the
351/// argument-hashing Unix-timestamp encoding; build the sentinel map explicitly.
352pub fn serialize_value(value: &InteropValue) -> Result<Vec<u8>, CachekitError> {
353    let mut buf = Vec::new();
354    encode(&mut buf, value, Profile::Value)?;
355    Ok(buf)
356}
357
358/// Deserialize an interop-mode MessagePack document, consuming **exactly one**
359/// document and rejecting trailing bytes (spec MUST).
360///
361/// Decode bounds: the header walk in `crate::serializer::check_structure` runs
362/// first and rejects, with a `decode bound:` error, any document nested deeper
363/// than [`crate::serializer::MAX_DECODE_DEPTH`] or declaring more than the input
364/// can back.
365///
366/// `rmp_serde::from_slice` silently ignores trailing bytes. That leniency is
367/// dangerous here: a Python-SDK-internal CK frame begins `0x43` (`'C'`), which
368/// is a *complete* one-byte MessagePack document (positive fixint 67) — a
369/// lenient reader would silently decode an entire CK frame as the integer 67.
370///
371/// A payload with the `0x43 0x4B` (`"CK"`) prefix gets a specific diagnostic
372/// naming the Python auto-mode frame instead of a generic trailing-bytes error.
373///
374/// # Errors
375///
376/// [`CachekitError::Serialization`] if the payload is a CK frame, is not valid
377/// MessagePack, or has trailing bytes after the first document.
378pub fn deserialize<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CachekitError> {
379    if bytes.starts_with(b"CK") {
380        return Err(CachekitError::Serialization(
381            "payload starts with 0x43 0x4B (\"CK\"): this is a Python-SDK-internal auto-mode \
382             entry (CK frame), not an interop-mode value — it cannot be read cross-SDK"
383                .to_owned(),
384        ));
385    }
386
387    let mut de = crate::serializer::bounded_deserializer(bytes)?;
388    let value = T::deserialize(&mut de)
389        .map_err(|e| CachekitError::Serialization(format!("interop decode: {e}")))?;
390
391    // `Read for &[u8]` advances the slice, so the reader now holds exactly the
392    // bytes the decoder did not consume.
393    let remaining: &[u8] = de.into_inner();
394    if !remaining.is_empty() {
395        return Err(CachekitError::Serialization(format!(
396            "interop payload has {} trailing byte(s) after the MessagePack document — \
397             interop readers must consume exactly one document",
398            remaining.len()
399        )));
400    }
401
402    Ok(value)
403}
404
405// ── Canonical encoder ────────────────────────────────────────────────────────
406
407/// Which canonicalization profile to encode with. The profiles differ in
408/// exactly one rule: the argument profile collapses integral floats to ints
409/// (keys need hash equality); the value profile does not (values need
410/// round-trip fidelity).
411#[derive(Clone, Copy)]
412enum Profile {
413    Args,
414    Value,
415}
416
417fn encode(buf: &mut Vec<u8>, value: &InteropValue, profile: Profile) -> Result<(), CachekitError> {
418    match value {
419        InteropValue::Null => buf.push(0xc0),
420        InteropValue::Bool(false) => buf.push(0xc2),
421        InteropValue::Bool(true) => buf.push(0xc3),
422        InteropValue::Int(i) => encode_int(buf, *i)?,
423        InteropValue::Float(f) => encode_float(buf, *f, profile)?,
424        InteropValue::Str(s) => encode_str(buf, s)?,
425        InteropValue::Bytes(b) => encode_bin(buf, b)?,
426        InteropValue::Array(items) => {
427            encode_array_header(buf, items.len())?;
428            for item in items {
429                encode(buf, item, profile)?;
430            }
431        }
432        InteropValue::Map(map) => {
433            // BTreeMap iterates in ascending byte order of the UTF-8 keys,
434            // which is exactly the spec's Unicode-code-point sort.
435            encode_map_header(buf, map.len())?;
436            for (key, val) in map {
437                encode_str(buf, key)?;
438                encode(buf, val, profile)?;
439            }
440        }
441        InteropValue::Set(items) => {
442            let mut encoded: Vec<Vec<u8>> = items
443                .iter()
444                .map(|item| {
445                    let mut b = Vec::new();
446                    encode(&mut b, item, profile)?;
447                    Ok(b)
448                })
449                .collect::<Result<_, CachekitError>>()?;
450            // Vec<u8> Ord is unsigned lexicographic — the spec's total order.
451            // Sorting first makes dedup() remove ALL post-normalization
452            // duplicates ({2, 2.0} collapses to the same bytes -> one element).
453            encoded.sort();
454            encoded.dedup();
455            encode_array_header(buf, encoded.len())?;
456            for bytes in &encoded {
457                buf.extend_from_slice(bytes);
458            }
459        }
460        InteropValue::DateTime { unix_micros } => {
461            // The Unix-float encoding is an ARGUMENT rule (keys need byte-equal
462            // hashes). Temporal VALUES use the wire-format sentinel-map
463            // convention instead (round-trip fidelity) — a bare float64 value
464            // would be off-spec bytes no other SDK revives as a datetime.
465            if matches!(profile, Profile::Value) {
466                return Err(CachekitError::Serialization(
467                    "datetime interop VALUES use the wire-format sentinel map \
468                     {\"__datetime__\": true, \"value\": \"<ISO-8601>\"} — build that map \
469                     explicitly; the Unix-timestamp encoding is argument-hashing only"
470                        .to_owned(),
471                ));
472            }
473            // Spec: floor to integer micros, then ONE IEEE 754 float64
474            // division by 10^6 — bit-deterministic in every language.
475            #[allow(clippy::cast_precision_loss)] // the i64->f64 conversion IS the spec rule
476            let ts = (*unix_micros as f64) / 1_000_000.0;
477            encode_float(buf, ts, profile)?;
478        }
479        InteropValue::Uuid(u) => {
480            // `Hyphenated` formats lowercase; 36 chars -> str8 header.
481            encode_str(buf, &u.hyphenated().to_string())?;
482        }
483    }
484    Ok(())
485}
486
487fn encode_int(buf: &mut Vec<u8>, i: i128) -> Result<(), CachekitError> {
488    if !(INT_MIN..=INT_MAX).contains(&i) {
489        return Err(CachekitError::Serialization(format!(
490            "integer {i} is outside the interop range [-2^63, 2^64-1]"
491        )));
492    }
493    // Range-checked above: non-negative fits u64, negative fits i64.
494    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
495    if i >= 0 {
496        encode_uint(buf, i as u64);
497    } else {
498        encode_negative_int(buf, i as i64);
499    }
500    Ok(())
501}
502
503/// Shortest-form unsigned encoding (spec: non-negative integers always use the
504/// positive fixint / uint family).
505#[allow(clippy::cast_possible_truncation)] // every arm is range-guarded by its branch
506fn encode_uint(buf: &mut Vec<u8>, n: u64) {
507    if n <= 0x7f {
508        buf.push(n as u8); // positive fixint
509    } else if n <= 0xff {
510        buf.push(0xcc);
511        buf.push(n as u8);
512    } else if n <= 0xffff {
513        buf.push(0xcd);
514        buf.extend_from_slice(&(n as u16).to_be_bytes());
515    } else if n <= 0xffff_ffff {
516        buf.push(0xce);
517        buf.extend_from_slice(&(n as u32).to_be_bytes());
518    } else {
519        buf.push(0xcf);
520        buf.extend_from_slice(&n.to_be_bytes());
521    }
522}
523
524/// Shortest-form signed encoding for strictly negative integers.
525#[allow(clippy::cast_possible_truncation)] // every arm is range-guarded by its branch
526fn encode_negative_int(buf: &mut Vec<u8>, n: i64) {
527    debug_assert!(n < 0);
528    if n >= -32 {
529        buf.push((n as i8).to_be_bytes()[0]); // negative fixint (0xe0..0xff)
530    } else if n >= i64::from(i8::MIN) {
531        buf.push(0xd0);
532        buf.push((n as i8).to_be_bytes()[0]);
533    } else if n >= i64::from(i16::MIN) {
534        buf.push(0xd1);
535        buf.extend_from_slice(&(n as i16).to_be_bytes());
536    } else if n >= i64::from(i32::MIN) {
537        buf.push(0xd2);
538        buf.extend_from_slice(&(n as i32).to_be_bytes());
539    } else {
540        buf.push(0xd3);
541        buf.extend_from_slice(&n.to_be_bytes());
542    }
543}
544
545fn encode_float(buf: &mut Vec<u8>, f: f64, profile: Profile) -> Result<(), CachekitError> {
546    if !f.is_finite() {
547        return Err(CachekitError::Serialization(format!(
548            "{f} is not allowed in interop values (NaN and infinities are rejected, \
549             never silently encoded)"
550        )));
551    }
552    if matches!(profile, Profile::Args)
553        && f.trunc() == f
554        && (F64_COLLAPSE_MIN..F64_COLLAPSE_MAX).contains(&f)
555    {
556        // Integral and in range: collapse to the int encoding. The casts are
557        // exact — f is integral, non-negative values are < 2^64, negative
558        // values are >= -2^63. -0.0 compares >= 0.0 and casts to 0.
559        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
560        if f >= 0.0 {
561            encode_uint(buf, f as u64);
562        } else {
563            encode_negative_int(buf, f as i64);
564        }
565    } else {
566        // float64 (0xcb) only — float32 is forbidden by the spec.
567        buf.push(0xcb);
568        buf.extend_from_slice(&f.to_be_bytes());
569    }
570    Ok(())
571}
572
573fn encode_str(buf: &mut Vec<u8>, s: &str) -> Result<(), CachekitError> {
574    let len = checked_len(s.len())?;
575    #[allow(clippy::cast_possible_truncation)] // arms are range-guarded
576    if len <= 31 {
577        buf.push(0xa0 | (len as u8)); // fixstr
578    } else if len <= 0xff {
579        buf.push(0xd9);
580        buf.push(len as u8);
581    } else if len <= 0xffff {
582        buf.push(0xda);
583        buf.extend_from_slice(&(len as u16).to_be_bytes());
584    } else {
585        buf.push(0xdb);
586        buf.extend_from_slice(&len.to_be_bytes());
587    }
588    buf.extend_from_slice(s.as_bytes());
589    Ok(())
590}
591
592fn encode_bin(buf: &mut Vec<u8>, b: &[u8]) -> Result<(), CachekitError> {
593    let len = checked_len(b.len())?;
594    #[allow(clippy::cast_possible_truncation)] // arms are range-guarded
595    if len <= 0xff {
596        buf.push(0xc4);
597        buf.push(len as u8);
598    } else if len <= 0xffff {
599        buf.push(0xc5);
600        buf.extend_from_slice(&(len as u16).to_be_bytes());
601    } else {
602        buf.push(0xc6);
603        buf.extend_from_slice(&len.to_be_bytes());
604    }
605    buf.extend_from_slice(b);
606    Ok(())
607}
608
609fn encode_array_header(buf: &mut Vec<u8>, len: usize) -> Result<(), CachekitError> {
610    let len = checked_len(len)?;
611    #[allow(clippy::cast_possible_truncation)] // arms are range-guarded
612    if len <= 15 {
613        buf.push(0x90 | (len as u8)); // fixarray
614    } else if len <= 0xffff {
615        buf.push(0xdc);
616        buf.extend_from_slice(&(len as u16).to_be_bytes());
617    } else {
618        buf.push(0xdd);
619        buf.extend_from_slice(&len.to_be_bytes());
620    }
621    Ok(())
622}
623
624fn encode_map_header(buf: &mut Vec<u8>, len: usize) -> Result<(), CachekitError> {
625    let len = checked_len(len)?;
626    #[allow(clippy::cast_possible_truncation)] // arms are range-guarded
627    if len <= 15 {
628        buf.push(0x80 | (len as u8)); // fixmap
629    } else if len <= 0xffff {
630        buf.push(0xde);
631        buf.extend_from_slice(&(len as u16).to_be_bytes());
632    } else {
633        buf.push(0xdf);
634        buf.extend_from_slice(&len.to_be_bytes());
635    }
636    Ok(())
637}
638
639/// MessagePack length fields are at most u32.
640fn checked_len(len: usize) -> Result<u32, CachekitError> {
641    u32::try_from(len).map_err(|_| {
642        CachekitError::Serialization(format!("length {len} exceeds the MessagePack u32 maximum"))
643    })
644}
645
646// ── Tests ────────────────────────────────────────────────────────────────────
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    // Byte-level vector conformance lives in tests/interop_vector_tests.rs;
653    // these unit tests pin the read guard and API-level behaviors that the
654    // shared vectors cannot express.
655
656    #[test]
657    fn deserialize_single_document() {
658        let value: i64 = deserialize(&[0x2a]).unwrap();
659        assert_eq!(value, 42);
660    }
661
662    #[test]
663    fn deserialize_rejects_trailing_bytes() {
664        // fixint 42 followed by one stray byte — from_slice would accept this.
665        let err = deserialize::<i64>(&[0x2a, 0x00]).unwrap_err();
666        assert!(
667            err.to_string().contains("trailing byte"),
668            "expected trailing-bytes error, got: {err}"
669        );
670    }
671
672    #[test]
673    fn deserialize_rejects_ck_frame_with_diagnostic() {
674        // A CK frame prefix: 0x43 alone is a complete msgpack document
675        // (fixint 67), so a lenient reader decodes the frame as the int 67.
676        let ck_frame = b"CK\x03\x00\x00\x00\x02{}payload";
677        let err = deserialize::<i64>(ck_frame).unwrap_err();
678        let msg = err.to_string();
679        assert!(
680            msg.contains("Python-SDK-internal auto-mode entry"),
681            "expected the CK-frame diagnostic, got: {msg}"
682        );
683    }
684
685    #[test]
686    fn deserialize_matches_lenient_reader_on_clean_input() {
687        let bytes = rmp_serde::to_vec(&("hello", 7u8)).unwrap();
688        let strict: (String, u8) = deserialize(&bytes).unwrap();
689        let lenient: (String, u8) = rmp_serde::from_slice(&bytes).unwrap();
690        assert_eq!(strict, lenient);
691    }
692
693    #[test]
694    fn segment_rejects_trailing_newline() {
695        assert!(interop_key("users\n", "get_user", &[]).is_err());
696    }
697
698    #[test]
699    fn segment_rejects_empty_and_too_long() {
700        assert!(interop_key("", "op", &[]).is_err());
701        assert!(interop_key(&"a".repeat(65), "op", &[]).is_err());
702        assert!(interop_key(&"a".repeat(64), "op", &[]).is_ok());
703    }
704
705    #[test]
706    fn segment_rejects_leading_punctuation() {
707        assert!(interop_key(".users", "op", &[]).is_err());
708        assert!(interop_key("-users", "op", &[]).is_err());
709        assert!(interop_key("users.v2_x-y", "op", &[]).is_ok());
710    }
711
712    #[test]
713    fn namespace_rejects_reserved_ns_and_nsapi() {
714        for reserved in ["ns", "nsapi"] {
715            let err = interop_key(reserved, "get_user", &[]).unwrap_err();
716            assert!(
717                matches!(err, CachekitError::InvalidKey(_)),
718                "{reserved:?} must be InvalidKey, got: {err}"
719            );
720        }
721    }
722
723    #[test]
724    fn reservation_is_exact_match_and_namespace_only() {
725        // Reserved names stay valid operations...
726        assert!(interop_key("users", "ns", &[]).is_ok());
727        assert!(interop_key("users", "nsapi", &[]).is_ok());
728        // ...and near-miss namespaces stay valid.
729        for ok in ["nsx", "nsapi2", "nsfw", "n", "ns-api"] {
730            assert!(
731                interop_key(ok, "op", &[]).is_ok(),
732                "{ok:?} should be accepted"
733            );
734        }
735    }
736
737    #[test]
738    fn systemtime_pre_epoch_floors_toward_negative_infinity() {
739        use std::time::{Duration, SystemTime, UNIX_EPOCH};
740        fn micros(t: SystemTime) -> Option<i64> {
741            match InteropValue::try_from(t) {
742                Ok(InteropValue::DateTime { unix_micros }) => Some(unix_micros),
743                _ => None,
744            }
745        }
746        // 1.5 us before the epoch: floor(-1.5us) = -2us, not -1us.
747        assert_eq!(micros(UNIX_EPOCH - Duration::from_nanos(1500)), Some(-2));
748        // Exactly on a microsecond boundary: no rounding.
749        assert_eq!(micros(UNIX_EPOCH - Duration::from_micros(3)), Some(-3));
750    }
751
752    #[test]
753    fn int_range_enforced_at_encode_time() {
754        // 2^64 and -2^63-1 are constructible (i128) but must fail to encode.
755        assert!(canonical_args(&[InteropValue::Int(INT_MAX + 1)]).is_err());
756        assert!(canonical_args(&[InteropValue::Int(INT_MIN - 1)]).is_err());
757        assert!(canonical_args(&[InteropValue::Int(INT_MAX)]).is_ok());
758        assert!(canonical_args(&[InteropValue::Int(INT_MIN)]).is_ok());
759    }
760
761    #[test]
762    fn value_profile_rejects_datetime() {
763        // Temporal VALUES must use the sentinel-map convention; the
764        // Unix-timestamp encoding is argument-hashing only.
765        let dt = InteropValue::datetime_from_unix_micros(1_704_067_200_000_000);
766        let err = serialize_value(&dt).unwrap_err();
767        assert!(err.to_string().contains("__datetime__"), "got: {err}");
768        // ...while the args profile accepts it (datetime_whole_second vector).
769        assert!(canonical_args(&[dt]).is_ok());
770    }
771
772    #[test]
773    fn value_profile_preserves_floats() {
774        // 2.0 stays float64 in the value profile...
775        let bytes = serialize_value(&InteropValue::from(2.0f64)).unwrap();
776        assert_eq!(bytes, hex::decode("cb4000000000000000").unwrap());
777        // ...but collapses to int 2 in the args profile.
778        let bytes = canonical_args(&[InteropValue::from(2.0f64)]).unwrap();
779        assert_eq!(bytes, hex::decode("9102").unwrap());
780    }
781}