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