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