From c0a2b890f62306ca0371605f7ae82df4f50e40ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 04:22:22 +0200 Subject: [PATCH] cultcache-rs: the decode door names a missing or duplicated field and never quotes a value describe_decode_error keeps serde's own value-free texts for missing and duplicate fields (static names from the type), maps unknown-variant and invalid-type to kind descriptions, and leaves everything else opaque. Co-Authored-By: Claude Opus 5.5 --- packages/cultcache-rs/src/lib.rs | 27 ++++++++++++++++++- packages/cultcache-rs/tests/union_parity.rs | 30 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index d58f1242..ecb69145 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -732,6 +732,7 @@ fn opaque_decode_error(error: rmp_serde::decode::Error) -> anyhow::Error { anyhow!(describe_decode_error(&error)) } +/// The text of a decode failure. Field names in it come from the type, never from the bytes. fn describe_decode_error(error: &rmp_serde::decode::Error) -> String { use rmp_serde::decode::Error; match error { @@ -744,11 +745,35 @@ fn describe_decode_error(error: &rmp_serde::decode::Error) -> String { Error::Syntax(message) | Error::Uncategorized(message) if message.starts_with(RECORD_REFUSAL) => { return message.clone(); } - Error::Syntax(_) | Error::Uncategorized(_) => "a value does not fit the store format", + Error::Syntax(message) | Error::Uncategorized(message) => { + return describe_serde_message(message); + } } .to_string() } +/// serde's own texts, kept only where they cannot carry a received value: a missing or duplicated +/// field is named by the type's static field name (checked to look like one), an unknown tag and a +/// mistyped value are described by kind. Everything else, `unknown field` included, is opaque. +fn describe_serde_message(message: &str) -> String { + for lead in ["missing field `", "duplicate field `"] { + if let Some(name) = message.strip_prefix(lead).and_then(|rest| rest.strip_suffix('`')) { + let is_name = (1..=64).contains(&name.len()) + && name.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if is_name { + return message.to_string(); + } + } + } + if message.starts_with("unknown variant ") { + "a tag names no variant this type knows".to_string() + } else if message.starts_with("invalid type: ") { + "a value has a MessagePack type the store format does not allow".to_string() + } else { + "a value does not fit the store format".to_string() + } +} + /// The store header string, or `None` when the bytes do not open with an array whose /// first slot is a string (the legacy envelope array starts with a map). Only the /// leading bytes are read, so a store cut short after its header still names its format. diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index a566e627..0caa8ee9 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -293,6 +293,36 @@ fn a_union_that_is_not_a_key_and_slots_pair_is_refused_naming_the_enum() { } } +// A plain derive struct: the door names a missing or duplicated field (a static name from the type) +// and describes a mistyped value by kind, never quoting it. +#[derive(Debug, serde::Deserialize)] +#[allow(dead_code)] +struct Probe { + name: String, + flag: bool, +} + +#[test] +fn a_missing_or_duplicate_field_is_named_and_a_mistyped_value_is_not() { + let name = |value: &str| [&[0xa4][..], b"name", &[0xa0 | value.len() as u8][..], value.as_bytes()].concat(); + let flag_true = [&[0xa4][..], b"flag", &[0xc3][..]].concat(); + + let missing = [&[0x81][..], &name("a")].concat(); + let message = cultcache_rs::decode_payload::(&missing).unwrap_err().to_string(); + assert!(message.contains("missing field `flag`"), "{message}"); + + let duplicated = [&[0x83][..], &name("a"), &name("b"), &flag_true].concat(); + let message = cultcache_rs::decode_payload::(&duplicated).unwrap_err().to_string(); + assert!(message.contains("duplicate field `name`"), "{message}"); + + let canary = "CANARY-3d9e"; + let flag_text = [&[0xa4][..], b"flag", &[0xa0 | canary.len() as u8][..], canary.as_bytes()].concat(); + let mistyped = [&[0x82][..], &name("a"), &flag_text].concat(); + let message = cultcache_rs::decode_payload::(&mistyped).unwrap_err().to_string(); + assert!(!message.contains("CANARY"), "{message}"); + assert!(message.contains("MessagePack type"), "{message}"); +} + #[test] fn the_key_decides_the_arm() { // Tool and Part carry different slots under different keys in the two unions.