From c586640e817af2ce9e99e5509fac5ce41b6573fb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 04:37:47 +0200 Subject: [PATCH] cultcache-rs-derive: arm keys are Int32 and given once, an arm is a slots record, refusals classified like the reference Key attributes (variant, field, entry type/schema) are refused when repeated. A CultUnion key parses as the reference's Int32, negatives included; a key outside it is a compile error. Decode classifies the key as the reference does: non-integer, outside Int32, undeclared (named), and a pair of the wrong length is refused before its key is read; no value is echoed beyond the undeclared key the reference names. The SlotRecord trait, implemented by the shared slot generator, bounds an arm payload, so a String or Option arm is a compile error. compile_fail fixtures use keys other than 0; the derive crate's unit tests assert each refusal's message. Unverified: committed before the first build; the verification job runs next. Co-Authored-By: Claude Opus 5.5 --- packages/cultcache-rs/README.md | 2 +- .../cultcache-rs-derive/src/lib.rs | 266 +++++++++++++++++- packages/cultcache-rs/src/lib.rs | 95 +++++-- packages/cultcache-rs/tests/union_parity.rs | 126 +++++++++ 4 files changed, 460 insertions(+), 29 deletions(-) diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index c0aa02e0..7191eeed 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -189,7 +189,7 @@ without pretending Rust has runtime reflection hiding under the floorboards. A union that crosses runtimes is written `[armKey, [armSlots...]]`, the shape the C# reference writes for `[Union(key, type)]`. Derive `CultUnion` on an enum: every variant carries `#[cultcache(key = N)]` and is a unit (`[N, []]`) or a newtype over a `#[derive(CultSlots)]` struct, which is a `DatabaseEntry`'s slot array without the -entry consts. A key the enum does not declare is a decode error naming the enum and the declared keys, and never +entry consts (a `String` or an `Option` is not an arm, and is a compile error). An arm key is the reference's Int32, negatives included, and is given once. A key the enum does not declare is a decode error naming the enum and the declared keys, and never quoting the payload. A null union is `Option`. Plain serde enums are untouched and keep their bytes. ```rust diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index ec0abedc..01e46f52 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -23,9 +23,11 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { { let parse_result = attribute.parse_nested_meta(|meta| { if meta.path.is_ident("type") { + once(&entry_type, &meta, "type")?; entry_type = Some(meta.value()?.parse()?); Ok(()) } else if meta.path.is_ident("schema") { + once(&schema_name, &meta, "schema")?; schema_name = Some(meta.value()?.parse()?); Ok(()) } else { @@ -131,6 +133,7 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result proc_macro2::Token }); quote! { + impl ::cultcache_rs::SlotRecord for #ident {} + impl ::serde::Serialize for #ident { fn serialize(&self, serializer: S) -> ::std::result::Result where @@ -310,6 +315,31 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token } } +/// An attribute names each thing once; a second `key = N` would silently win otherwise. +fn once(seen: &Option, meta: &syn::meta::ParseNestedMeta, name: &str) -> syn::Result<()> { + if seen.is_some() { + return Err(meta.error(format!("repeated `{name}`: #[cultcache({name} = ...)] is given once"))); + } + Ok(()) +} + +/// An arm key is the reference's Int32, negatives included: `key = 5` or `key = -1`. +fn arm_key(meta: &syn::meta::ParseNestedMeta) -> syn::Result { + let expr: syn::Expr = meta.value()?.parse()?; + let (negative, literal) = match expr { + syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(literal), .. }) => (false, literal), + syn::Expr::Unary(syn::ExprUnary { op: syn::UnOp::Neg(_), expr, .. }) => match *expr { + syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(literal), .. }) => (true, literal), + other => return Err(syn::Error::new_spanned(other, "a CultUnion arm key is an integer literal")), + }, + other => return Err(syn::Error::new_spanned(other, "a CultUnion arm key is an integer literal")), + }; + let magnitude: i128 = literal.base10_parse()?; + let value = if negative { -magnitude } else { magnitude }; + i32::try_from(value) + .map_err(|_| syn::Error::new_spanned(&literal, format!("CultUnion arm key {value} is outside Int32"))) +} + struct CultCacheSlot { key: usize, ident: syn::Ident, @@ -334,13 +364,13 @@ fn union_codec(input: DeriveInput) -> syn::Result { } struct Arm { - key: u64, + key: i32, ident: syn::Ident, payload: Option, } let mut arms: Vec = Vec::new(); for variant in data.variants { - let mut key: Option = None; + let mut key: Option = None; for attribute in variant .attrs .iter() @@ -348,8 +378,8 @@ fn union_codec(input: DeriveInput) -> syn::Result { { attribute.parse_nested_meta(|meta| { if meta.path.is_ident("key") { - let value: LitInt = meta.value()?.parse()?; - key = Some(value.base10_parse()?); + once(&key, &meta, "key")?; + key = Some(arm_key(&meta)?); Ok(()) } else { Err(meta.error("unsupported variant cultcache attribute")) @@ -388,13 +418,16 @@ fn union_codec(input: DeriveInput) -> syn::Result { } let enum_name = ident.to_string(); - let declared = arms - .iter() - .map(|arm| arm.key.to_string()) - .collect::>() - .join(", "); + let mut declared_keys: Vec = arms.iter().map(|arm| arm.key).collect(); + declared_keys.sort_unstable(); + let declared = declared_keys.iter().map(i32::to_string).collect::>().join(", "); let unknown_message = format!("unknown arm key {{}} for union {enum_name}; declared keys: {declared}"); let arity_message = format!("union {enum_name} is not a [key, slots] pair"); + let non_integer_message = format!("union {enum_name} has a non-integer arm key; declared keys: {declared}"); + let out_of_range_message = format!("union {enum_name} has an arm key outside Int32; declared keys: {declared}"); + let payload_checks = arms.iter().filter_map(|arm| arm.payload.as_ref()).map(|ty| { + quote! { assert_slot_record::<#ty>(); } + }); let serialize_arms = arms.iter().map(|arm| { let variant = &arm.ident; @@ -441,6 +474,14 @@ fn union_codec(input: DeriveInput) -> syn::Result { }); Ok(quote! { + // An arm is a slots array: a newtype over anything else is refused here, by the + // trait the CultSlots and DatabaseEntry derives implement. + const _: fn() = || { + #[allow(dead_code)] + fn assert_slot_record() {} + #(#payload_checks)* + }; + impl ::serde::Serialize for #ident { fn serialize(&self, serializer: S) -> ::std::result::Result where @@ -460,6 +501,92 @@ fn union_codec(input: DeriveInput) -> syn::Result { where D: ::serde::Deserializer<'de>, { + // The reference classifies the key before the arm: an integer in Int32, an + // integer outside it, or not an integer. No input value is kept or echoed. + enum ArmKey { + Int(i64), + OutOfRange, + NonInteger, + } + struct ArmKeyProbe(ArmKey); + struct ArmKeyVisitor; + + impl<'de> ::serde::de::Visitor<'de> for ArmKeyVisitor { + type Value = ArmKeyProbe; + + fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { + formatter.write_str("an integer arm key") + } + + fn visit_i64(self, value: i64) -> ::std::result::Result { + Ok(ArmKeyProbe(ArmKey::Int(value))) + } + + fn visit_u64(self, value: u64) -> ::std::result::Result { + Ok(ArmKeyProbe(match i64::try_from(value) { + Ok(value) => ArmKey::Int(value), + Err(_) => ArmKey::OutOfRange, + })) + } + + fn visit_bool(self, _: bool) -> ::std::result::Result { + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_f64(self, _: f64) -> ::std::result::Result { + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_str(self, _: &str) -> ::std::result::Result { + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_bytes(self, _: &[u8]) -> ::std::result::Result { + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_unit(self) -> ::std::result::Result { + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_none(self) -> ::std::result::Result { + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_some(self, deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + <::serde::de::IgnoredAny as ::serde::Deserialize>::deserialize(deserializer)?; + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_seq(self, mut seq: A) -> ::std::result::Result + where + A: ::serde::de::SeqAccess<'de>, + { + while seq.next_element::<::serde::de::IgnoredAny>()?.is_some() {} + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + + fn visit_map(self, mut map: A) -> ::std::result::Result + where + A: ::serde::de::MapAccess<'de>, + { + while map.next_entry::<::serde::de::IgnoredAny, ::serde::de::IgnoredAny>()?.is_some() {} + Ok(ArmKeyProbe(ArmKey::NonInteger)) + } + } + + impl<'de> ::serde::Deserialize<'de> for ArmKeyProbe { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + deserializer.deserialize_any(ArmKeyVisitor) + } + } + struct CultUnionVisitor; impl<'de> ::serde::de::Visitor<'de> for CultUnionVisitor { @@ -473,10 +600,28 @@ fn union_codec(input: DeriveInput) -> syn::Result { where A: ::serde::de::SeqAccess<'de>, { - let key = seq - .next_element::() + if seq.size_hint().is_some_and(|len| len != 2) { + return ::std::result::Result::Err(::serde::de::Error::custom(#arity_message)); + } + let key = match seq + .next_element::() .map_err(|_| ::serde::de::Error::custom(#arity_message))? - .ok_or_else(|| ::serde::de::Error::custom(#arity_message))?; + .ok_or_else(|| ::serde::de::Error::custom(#arity_message))? + .0 + { + ArmKey::Int(wide) => match i32::try_from(wide) { + Ok(key) => key, + Err(_) => { + return ::std::result::Result::Err(::serde::de::Error::custom(#out_of_range_message)); + } + }, + ArmKey::OutOfRange => { + return ::std::result::Result::Err(::serde::de::Error::custom(#out_of_range_message)); + } + ArmKey::NonInteger => { + return ::std::result::Result::Err(::serde::de::Error::custom(#non_integer_message)); + } + }; let value = match key { #(#deserialize_arms)* other => { @@ -497,3 +642,100 @@ fn union_codec(input: DeriveInput) -> syn::Result { } }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn union_error(source: &str) -> String { + let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); + union_codec(input).expect_err("the fixture is refused").to_string() + } + + fn slots_error(source: &str) -> String { + let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); + let Err(error) = named_slots(input.data, &input.ident, "CultSlots") else { + panic!("the fixture is refused"); + }; + error.to_string() + } + + fn union_ok(source: &str) { + let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); + union_codec(input).expect("the fixture is accepted"); + } + + // Keys other than 0 throughout, so a default of 0 cannot stand in for a missing key. + #[test] + fn a_variant_without_a_key_is_refused_for_that_reason() { + let message = union_error("enum E { #[cultcache(key = 4)] A(Arm), B }"); + assert!(message.contains("require #[cultcache(key = N)]"), "{message}"); + let message = union_error("enum E { B, #[cultcache(key = 4)] A(Arm) }"); + assert!(message.contains("require #[cultcache(key = N)]"), "{message}"); + } + + #[test] + fn two_variants_with_one_key_are_refused_for_that_reason() { + let message = union_error("enum E { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 4)] B }"); + assert!(message.contains("duplicate CultUnion key 4"), "{message}"); + let message = union_error("enum E { #[cultcache(key = 0x4)] A(Arm), #[cultcache(key = 4)] B }"); + assert!(message.contains("duplicate CultUnion key 4"), "{message}"); + } + + #[test] + fn a_repeated_key_attribute_is_refused_on_a_variant_and_on_a_field() { + for source in [ + "enum E { #[cultcache(key = 7)] #[cultcache(key = 0)] A(Arm) }", + "enum E { #[cultcache(key = 7, key = 0)] A(Arm) }", + ] { + let message = union_error(source); + assert!(message.contains("repeated `key`"), "{source}: {message}"); + } + for source in [ + "struct S { #[cultcache(key = 7)] #[cultcache(key = 0)] a: String }", + "struct S { #[cultcache(key = 7, key = 0)] a: String }", + ] { + let message = slots_error(source); + assert!(message.contains("repeated `key`"), "{source}: {message}"); + } + } + + #[test] + fn a_duplicate_slot_is_refused_for_that_reason() { + let message = slots_error("struct S { #[cultcache(key = 3)] a: String, #[cultcache(key = 3)] b: String }"); + assert!(message.contains("duplicate CultSlots slot 3"), "{message}"); + } + + #[test] + fn a_field_without_a_key_is_refused_for_that_reason() { + let message = slots_error("struct S { #[cultcache(key = 3)] a: String, b: String }"); + assert!(message.contains("fields require #[cultcache(key = N)]"), "{message}"); + } + + #[test] + fn struct_multi_field_and_generic_variants_are_refused_for_their_reasons() { + let message = union_error("enum E { #[cultcache(key = 4)] A { id: String } }"); + assert!(message.contains("use a newtype over a #[derive(CultSlots)] struct"), "{message}"); + let message = union_error("enum E { #[cultcache(key = 4)] A(Arm, Arm) }"); + assert!(message.contains("use a newtype over a #[derive(CultSlots)] struct"), "{message}"); + let message = union_error("enum E { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 5)] B(T) }"); + assert!(message.contains("does not support generics"), "{message}"); + } + + // The arm key domain is the reference's Int32: negatives declare, Int32::MAX declares, + // one past it is a compile error. + #[test] + fn the_arm_key_domain_is_int32() { + union_ok("enum E { #[cultcache(key = -1)] A(Arm), #[cultcache(key = 2147483647)] B, #[cultcache(key = -2147483648)] C }"); + for source in [ + "enum E { #[cultcache(key = 2147483648)] A(Arm) }", + "enum E { #[cultcache(key = -2147483649)] A(Arm) }", + "enum E { #[cultcache(key = 4294967296)] A(Arm) }", + ] { + let message = union_error(source); + assert!(message.contains("outside Int32"), "{source}: {message}"); + } + let message = union_error("enum E { #[cultcache(key = \"1\")] A(Arm) }"); + assert!(message.contains("integer literal"), "{message}"); + } +} diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 5c84c478..0423c224 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1,22 +1,32 @@ //! The derives refuse a malformed union at compile time. Each block below is a valid -//! union with one property changed. +//! union with one property changed; keys are never 0, so a refusal cannot be a default +//! key colliding with a declared 0. The derive crate's unit tests assert the message of +//! every refusal the derive itself makes; a `compile_fail` block shows only that it fails. //! //! ``` //! use cultcache_rs::{CultSlots, CultUnion}; //! #[derive(CultSlots)] -//! struct Arm { #[cultcache(key = 0)] id: String } +//! struct Arm { #[cultcache(key = 3)] id: String } //! #[derive(CultUnion)] -//! enum Ok_ { #[cultcache(key = 0)] A(Arm), #[cultcache(key = 1)] B } +//! enum Fine { #[cultcache(key = -1)] A(Arm), #[cultcache(key = 2147483647)] B, #[cultcache(key = 4)] C } //! ``` //! -//! A variant without a key: +//! A variant without a key, first and last: //! //! ```compile_fail //! use cultcache_rs::{CultSlots, CultUnion}; //! #[derive(CultSlots)] -//! struct Arm { #[cultcache(key = 0)] id: String } +//! struct Arm { #[cultcache(key = 3)] id: String } //! #[derive(CultUnion)] -//! enum Bad { #[cultcache(key = 0)] A(Arm), B } +//! enum Bad { #[cultcache(key = 4)] A(Arm), B } +//! ``` +//! +//! ```compile_fail +//! use cultcache_rs::{CultSlots, CultUnion}; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 3)] id: String } +//! #[derive(CultUnion)] +//! enum Bad { B, #[cultcache(key = 4)] A(Arm) } //! ``` //! //! Two variants with one key: @@ -24,9 +34,25 @@ //! ```compile_fail //! use cultcache_rs::{CultSlots, CultUnion}; //! #[derive(CultSlots)] -//! struct Arm { #[cultcache(key = 0)] id: String } +//! struct Arm { #[cultcache(key = 3)] id: String } //! #[derive(CultUnion)] -//! enum Bad { #[cultcache(key = 0)] A(Arm), #[cultcache(key = 0)] B } +//! enum Bad { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 4)] B } +//! ``` +//! +//! A key attribute given twice: +//! +//! ```compile_fail +//! use cultcache_rs::CultUnion; +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 4)] #[cultcache(key = 5)] A } +//! ``` +//! +//! A key outside Int32: +//! +//! ```compile_fail +//! use cultcache_rs::CultUnion; +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 2147483648)] A } //! ``` //! //! A struct variant: @@ -34,7 +60,7 @@ //! ```compile_fail //! use cultcache_rs::CultUnion; //! #[derive(CultUnion)] -//! enum Bad { #[cultcache(key = 0)] A { id: String }, #[cultcache(key = 1)] B } +//! enum Bad { #[cultcache(key = 4)] A { id: String }, #[cultcache(key = 5)] B } //! ``` //! //! A multi-field tuple variant: @@ -42,9 +68,9 @@ //! ```compile_fail //! use cultcache_rs::{CultSlots, CultUnion}; //! #[derive(CultSlots)] -//! struct Arm { #[cultcache(key = 0)] id: String } +//! struct Arm { #[cultcache(key = 3)] id: String } //! #[derive(CultUnion)] -//! enum Bad { #[cultcache(key = 0)] A(Arm, Arm), #[cultcache(key = 1)] B } +//! enum Bad { #[cultcache(key = 4)] A(Arm, Arm), #[cultcache(key = 5)] B } //! ``` //! //! A generic enum: @@ -52,23 +78,51 @@ //! ```compile_fail //! use cultcache_rs::{CultSlots, CultUnion}; //! #[derive(CultSlots)] -//! struct Arm { #[cultcache(key = 0)] id: String } +//! struct Arm { #[cultcache(key = 3)] id: String } //! #[derive(CultUnion)] -//! enum Bad { #[cultcache(key = 0)] A(Arm), #[cultcache(key = 1)] B(std::marker::PhantomData) } +//! enum Bad { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 5)] B(std::marker::PhantomData) } //! ``` //! -//! A CultSlots field without a key, and a generic CultSlots struct: +//! An arm that is not a slots record (rustc's `SlotRecord` bound, not the derive, refuses it): +//! +//! ```compile_fail +//! use cultcache_rs::CultUnion; +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 4)] A(String) } +//! ``` +//! +//! ```compile_fail +//! use cultcache_rs::{CultSlots, CultUnion}; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 3)] id: String } +//! #[derive(CultUnion)] +//! enum Bad { #[cultcache(key = 4)] A(Option) } +//! ``` +//! +//! A CultSlots field without a key, a repeated slot, a repeated key attribute, and a generic struct: //! //! ```compile_fail //! use cultcache_rs::CultSlots; //! #[derive(CultSlots)] -//! struct Arm { #[cultcache(key = 0)] id: String, name: String } +//! struct Arm { #[cultcache(key = 3)] id: String, name: String } //! ``` //! //! ```compile_fail //! use cultcache_rs::CultSlots; //! #[derive(CultSlots)] -//! struct Arm { #[cultcache(key = 0)] id: T } +//! struct Arm { #[cultcache(key = 3)] id: String, #[cultcache(key = 3)] name: String } +//! ``` +//! +//! ```compile_fail +//! use cultcache_rs::CultSlots; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 3)] #[cultcache(key = 4)] id: String } +//! ``` +//! +//! ```compile_fail +//! use cultcache_rs::CultSlots; +//! #[derive(CultSlots)] +//! struct Arm { #[cultcache(key = 3)] id: T } //! ``` use anyhow::Context; @@ -102,6 +156,15 @@ pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static { const SCHEMA_NAME: &'static str = "DatabaseEntry"; } +/// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry` +/// derives implement, and what a `CultUnion` arm must carry. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a slots record", + label = "a CultUnion arm is a newtype over a #[derive(CultSlots)] struct", + note = "a String, an Option or any other type is not written as an array of slots" +)] +pub trait SlotRecord {} + pub trait CultCacheRegistry { fn register_entries(&self, cache: &mut CultCache) -> Result<()>; } diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index 08507806..d121c599 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -293,6 +293,132 @@ fn union_store_reads() { assert_eq!(cache.get::("surface").unwrap(), Some(constructed())); } +// The arm key domain is the reference's Int32. Expected bytes are what MessagePack-CSharp writes +// for the same keys (WriteInt32: fixneg, uint32, int32); the refusal classes are CultUnionGuard's. +mod arm_keys { + use cultcache_rs::CultSlots; + use cultcache_rs::CultUnion; + + #[derive(Clone, Debug, PartialEq, CultSlots)] + struct Count { + #[cultcache(key = 0)] + name: String, + #[cultcache(key = 1)] + count: i32, + } + + #[derive(Clone, Debug, PartialEq, CultUnion)] + enum Keyed { + #[cultcache(key = -1)] + Negative(Count), + #[cultcache(key = 2147483647)] + Max(Count), + #[cultcache(key = -2147483648)] + Min, + #[cultcache(key = 7)] + Plain(Count), + } + + fn count() -> Count { + Count { name: "n".into(), count: 1 } + } + + fn message(bytes: &[u8]) -> String { + rmp_serde::from_slice::(bytes).unwrap_err().to_string() + } + + #[test] + fn negative_and_int32_max_keys_are_declared_and_round_trip() { + let cases: [(Keyed, &[u8]); 4] = [ + (Keyed::Negative(count()), &[0x92, 0xff, 0x92, 0xa1, b'n', 0x01]), + (Keyed::Max(count()), &[0x92, 0xce, 0x7f, 0xff, 0xff, 0xff, 0x92, 0xa1, b'n', 0x01]), + (Keyed::Min, &[0x92, 0xd2, 0x80, 0x00, 0x00, 0x00, 0x90]), + (Keyed::Plain(count()), &[0x92, 0x07, 0x92, 0xa1, b'n', 0x01]), + ]; + for (value, bytes) in cases { + assert_eq!(rmp_serde::to_vec(&value).unwrap(), bytes, "{value:?}"); + assert_eq!(rmp_serde::from_slice::(bytes).unwrap(), value); + } + } + + // Every integer width of a negative key reads as the same arm. + #[test] + fn a_negative_key_reads_in_every_integer_width() { + for key in [&[0xff][..], &[0xd0, 0xff], &[0xd1, 0xff, 0xff], &[0xd2, 0xff, 0xff, 0xff, 0xff], &[0xd3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]] { + let mut bytes = vec![0x92]; + bytes.extend_from_slice(key); + bytes.extend_from_slice(&[0x92, 0xa1, b'n', 0x01]); + assert_eq!(rmp_serde::from_slice::(&bytes).unwrap(), Keyed::Negative(count()), "{key:02x?}"); + } + } + + fn pair(key: &[u8]) -> Vec { + let mut bytes = vec![0x92]; + bytes.extend_from_slice(key); + bytes.extend_from_slice(&[0x92, 0xa1, b'C', 0xa1, b'A']); + bytes + } + + // A negative key the union does not declare is an unknown arm naming the key, as in the reference. + #[test] + fn a_negative_undeclared_key_is_an_unknown_arm_naming_the_key() { + for (key, shown) in [(&[0xe0][..], "-32"), (&[0xd0, 0xdf], "-33"), (&[0xd1, 0x80, 0x00], "-32768"), (&[0xd2, 0x80, 0x00, 0x00, 0x01], "-2147483647")] { + let text = message(&pair(key)); + assert!(text.contains(&format!("unknown arm key {shown};")), "{text}"); + assert!(text.contains("declared keys: -2147483648, -1, 7, 2147483647"), "{text}"); + assert!(!text.contains("CA"), "{text}"); + } + } + + // Outside Int32 is its own class, naming neither the value nor the payload. + #[test] + fn keys_outside_int32_are_their_own_class_and_never_echoed() { + let wide: [&[u8]; 6] = [ + &[0xce, 0x80, 0x00, 0x00, 0x00], + &[0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00], + &[0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], + &[0xd3, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff], + &[0xd3, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + &[0xcf, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], + ]; + for key in wide { + let text = message(&pair(key)); + assert!(text.contains("an arm key outside Int32"), "{key:02x?}: {text}"); + assert!(text.contains("Keyed") && !text.contains("CA"), "{text}"); + for value in ["4294967296", "9223372036854775807", "9223372036854775808", "18446744073709551615"] { + assert!(!text.contains(value), "{text}"); + } + } + } + + #[test] + fn a_key_that_is_not_an_integer_is_its_own_class_and_never_echoed() { + let keys: [&[u8]; 7] = [ + &[0xc0], + &[0xc3], + &[0xcb, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + &[0xa6, b'C', b'A', b'N', b'A', b'R', b'Y'], + &[0xc4, 0x02, b'C', b'A'], + &[0x91, 0xa6, b'C', b'A', b'N', b'A', b'R', b'Y'], + &[0x81, 0xa6, b'C', b'A', b'N', b'A', b'R', b'Y', 0x01], + ]; + for key in keys { + let text = message(&pair(key)); + assert!(text.contains("a non-integer arm key") && text.contains("Keyed"), "{key:02x?}: {text}"); + assert!(!text.contains("CANARY") && !text.contains("CA"), "{text}"); + } + } + + // Anything that is not a two-element array is the arity class, whatever its first element is. + #[test] + fn a_pair_of_the_wrong_length_is_refused_before_its_key_is_read() { + for bytes in [&[0x91, 0xa1, b'x'][..], &[0x93, 0x07, 0x90, 0x00], &[0x90]] { + let text = message(bytes); + assert!(text.contains("is not a [key, slots] pair"), "{bytes:02x?}: {text}"); + } + } +} + mod serde_enum_bytes { use cultcache_rs::DatabaseEntry;