diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 7f70770d1..80480a18d 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -545,7 +545,7 @@ fn is_option_type(ty: &syn::Type) -> bool { // variant; optional #[wire_alias = "..."] adds parser-side aliases; // #[wire(skip)] on a field excludes it from JSON; #[wire_fallback] with // { tag: String } catches unknown tags. -// Emits: wire_tag(), impl Serialize (SerializeMap), and a sibling +// Emits: wire_tag(), impl Serialize (SerializeStruct), and a sibling // Tag unit enum (unit-string WireEnum) for parser dispatch. // Adding `content = "data"` selects Serde's adjacent representation, // supports tuple payloads, and emits both Serialize and Deserialize. @@ -1598,15 +1598,43 @@ fn expand_wire_enum_tagged( } } } else { + let struct_name_lit = name.to_string(); + // Struct-field path, not map keys: serializers that intern struct keys + // cannot intern a name that arrives as a value. let serialize_arms: Vec<_> = infos .iter() .map(|info| { let id = &info.ident; + let emit_arm = |pattern: proc_macro2::TokenStream, + len_prelude: proc_macro2::TokenStream, + entries: Vec| { + quote! { + #pattern => { + #len_prelude + let mut __state = ::serde::Serializer::serialize_struct( + serializer, #struct_name_lit, __len + )?; + ::serde::ser::SerializeStruct::serialize_field( + &mut __state, #discriminator_lit, self.wire_tag() + )?; + #(#entries)* + ::serde::ser::SerializeStruct::end(__state) + } + } + }; if info.is_fallback { - quote! { #name::#id { tag: _ } => {} } + emit_arm( + quote! { #name::#id { tag: _ } }, + quote! { let __len = 1usize; }, + Vec::new(), + ) } else { match &info.fields { - Fields::Unit => quote! { #name::#id => {} }, + Fields::Unit => emit_arm( + quote! { #name::#id }, + quote! { let __len = 1usize; }, + Vec::new(), + ), Fields::Named(named) => { let bindings: Vec = named .named @@ -1620,38 +1648,67 @@ fn expand_wire_enum_tagged( } }) .collect(); - let entries: Vec = named + let serialized: Vec<&syn::Field> = named .named .iter() .filter(|field| !field_has_wire_skip(&field.attrs)) + .collect(); + let optional: Vec<&syn::Field> = serialized + .iter() + .copied() + .filter(|field| is_option_type(&field.ty)) + .collect(); + // Exact, not an upper bound: length-prefixed formats + // encode this count. + let constant_count = serialized.len() - optional.len() + 1; + let len_prelude = if optional.is_empty() { + quote! { let __len = #constant_count; } + } else { + let increments = optional.iter().map(|field| { + let id = field.ident.as_ref().unwrap(); + quote! { + if ::core::option::Option::is_some(#id) { + __len += 1; + } + } + }); + quote! { + let mut __len = #constant_count; + #(#increments)* + } + }; + let entries: Vec = serialized + .iter() .map(|field| { let id = field.ident.as_ref().unwrap(); let key = id.to_string(); if is_option_type(&field.ty) { quote! { if let ::core::option::Option::Some(__v) = #id { - ::serde::ser::SerializeMap::serialize_entry( - &mut map, #key, __v + ::serde::ser::SerializeStruct::serialize_field( + &mut __state, #key, __v )?; } } } else { quote! { - ::serde::ser::SerializeMap::serialize_entry( - &mut map, #key, #id + ::serde::ser::SerializeStruct::serialize_field( + &mut __state, #key, #id )?; } } }) .collect(); - quote! { - #name::#id { #(#bindings),* } => { - #(#entries)* - } - } + emit_arm( + quote! { #name::#id { #(#bindings),* } }, + len_prelude, + entries, + ) } + // Scoped to the offending variant so it cannot shadow + // the arms that follow it. Fields::Unnamed(_) => quote! { - compile_error!("tagged WireEnum tuple variants require #[wire(content = \"...\")]"); + #name::#id(..) => compile_error!("tagged WireEnum tuple variants require #[wire(content = \"...\")]") }, } } @@ -1664,15 +1721,9 @@ fn expand_wire_enum_tagged( &self, serializer: S, ) -> ::core::result::Result { - use ::serde::ser::SerializeMap; - let mut map = serializer.serialize_map(None)?; - ::serde::ser::SerializeMap::serialize_entry( - &mut map, #discriminator_lit, self.wire_tag() - )?; match self { #(#serialize_arms,)* } - ::serde::ser::SerializeMap::end(map) } } } diff --git a/wacore/tests/wire_enum_serde_test.rs b/wacore/tests/wire_enum_serde_test.rs index 142600c1f..6d62d3995 100644 --- a/wacore/tests/wire_enum_serde_test.rs +++ b/wacore/tests/wire_enum_serde_test.rs @@ -8,17 +8,25 @@ //! Cases cover unit-string mode (with and without `#[wire_fallback]`), //! int mode (see `TempBanReason` / `ConnectFailureReason` serialization as //! i32), and the sanity check on `EditAttribute` whose wire strings diverge -//! from variant names. Tagged mode is covered end-to-end inside -//! `stanza::groups::tests`. +//! from variant names. Tagged mode is pinned here field-by-field, and covered +//! end-to-end inside `stanza::groups::tests`. +use serde_json::json; +use wacore::iq::usync::{ + UsyncAddressingMode, UsyncContactResult, UsyncDevicesResult, UsyncFeature, UsyncOutcome, + UsyncProtocol, UsyncProtocolResult, +}; use wacore::stanza::business::BusinessNotificationType; use wacore::stanza::devices::DeviceNotificationType; +use wacore::stanza::groups::{GroupNotificationAction, MembershipRequestMethod}; use wacore::types::events::{ BusinessUpdateType, ConnectFailureReason, DecryptFailMode, DeviceListUpdateType, TempBanReason, UnavailableType, }; use wacore::types::lid_pn::LearningSource; use wacore::types::message::{AddressingMode, EditAttribute, MessageCategory}; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::Jid; fn assert_roundtrip(values: &[T]) where @@ -31,6 +39,174 @@ where } } +/// `serde_json` discards the field count handed to `serialize_struct`, so a +/// wrong count survives every JSON assertion in this file and only surfaces in +/// a length-prefixed format. This serializer checks the count instead of the +/// bytes. +mod field_count { + use serde::ser::{Impossible, Serialize, SerializeStruct, Serializer}; + use std::fmt; + + #[derive(Debug, PartialEq, Eq)] + pub struct Error(pub String); + + impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + + impl std::error::Error for Error {} + + impl serde::ser::Error for Error { + fn custom(message: T) -> Self { + Self(message.to_string()) + } + } + + pub struct Counter { + declared: usize, + written: usize, + } + + impl SerializeStruct for Counter { + type Ok = (); + type Error = Error; + + fn serialize_field( + &mut self, + _key: &'static str, + _value: &T, + ) -> Result<(), Error> { + self.written += 1; + Ok(()) + } + + fn end(self) -> Result<(), Error> { + if self.declared == self.written { + Ok(()) + } else { + Err(Error(format!( + "declared {} fields, wrote {}", + self.declared, self.written + ))) + } + } + } + + pub struct CheckFieldCount; + + macro_rules! reject { + ($($method:ident($($arg:ty),*);)*) => { + $(fn $method(self $(, _: $arg)*) -> Result<(), Error> { + Err(Error(concat!(stringify!($method), " is not a tagged struct").into())) + })* + }; + } + + impl Serializer for CheckFieldCount { + type Ok = (); + type Error = Error; + type SerializeSeq = Impossible<(), Error>; + type SerializeTuple = Impossible<(), Error>; + type SerializeTupleStruct = Impossible<(), Error>; + type SerializeTupleVariant = Impossible<(), Error>; + type SerializeMap = Impossible<(), Error>; + type SerializeStruct = Counter; + type SerializeStructVariant = Impossible<(), Error>; + + fn serialize_struct(self, _name: &'static str, len: usize) -> Result { + Ok(Counter { + declared: len, + written: 0, + }) + } + + reject! { + serialize_bool(bool); + serialize_i8(i8); + serialize_i16(i16); + serialize_i32(i32); + serialize_i64(i64); + serialize_u8(u8); + serialize_u16(u16); + serialize_u32(u32); + serialize_u64(u64); + serialize_f32(f32); + serialize_f64(f64); + serialize_char(char); + serialize_str(&str); + serialize_bytes(&[u8]); + serialize_none(); + serialize_unit(); + serialize_unit_struct(&'static str); + serialize_unit_variant(&'static str, u32, &'static str); + } + + fn serialize_some(self, _value: &T) -> Result<(), Error> { + Err(Error("some is not a tagged struct".into())) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + _value: &T, + ) -> Result<(), Error> { + Err(Error("newtype struct is not a tagged struct".into())) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _index: u32, + _variant: &'static str, + _value: &T, + ) -> Result<(), Error> { + Err(Error("newtype variant is not a tagged struct".into())) + } + + fn serialize_seq(self, _len: Option) -> Result { + Err(Error("seq is not a tagged struct".into())) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Err(Error("tuple is not a tagged struct".into())) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(Error("tuple struct is not a tagged struct".into())) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error("tuple variant is not a tagged struct".into())) + } + + fn serialize_map(self, _len: Option) -> Result { + Err(Error("map is not a tagged struct".into())) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error("struct variant is not a tagged struct".into())) + } + } +} + #[test] fn device_notification_type_uses_wire_strings() { for (value, expected) in [ @@ -183,6 +359,266 @@ fn temp_ban_reason_serializes_as_int_and_roundtrips() { } } +/// Tagged variants serialize through the struct path, so the constant field +/// names stay constant. These pin the exact JSON so a serializer swap or a +/// derive refactor cannot silently reshape the payload. +#[test] +fn group_notification_action_serializes_every_field_present() { + assert_eq!( + serde_json::to_value(GroupNotificationAction::Subject { + subject: "Team".into(), + subject_owner: Some("555000111@s.whatsapp.net".parse::().unwrap()), + subject_time: Some(1_704_067_200), + }) + .unwrap(), + json!({ + "type": "subject", + "subject": "Team", + "subject_owner": { + "user": "555000111", + "server": "s.whatsapp.net", + "agent": 0, + "device": 0, + "integrator": 0, + }, + "subject_time": 1_704_067_200, + }) + ); + + assert_eq!( + serde_json::to_value(GroupNotificationAction::Ephemeral { + expiration: 86_400, + trigger: Some(2), + }) + .unwrap(), + json!({ "type": "ephemeral", "expiration": 86_400, "trigger": 2 }) + ); + + assert_eq!( + serde_json::to_value(GroupNotificationAction::CreatedMembershipRequests { + request_method: MembershipRequestMethod::NonAdminAdd, + parent_group_jid: Some("555000222@g.us".parse::().unwrap()), + requests: vec![], + }) + .unwrap(), + json!({ + "type": "created_membership_requests", + "request_method": "non_admin_add", + "parent_group_jid": { + "user": "555000222", + "server": "g.us", + "agent": 0, + "device": 0, + "integrator": 0, + }, + "requests": [], + }) + ); +} + +#[test] +fn group_notification_action_omits_none_fields() { + assert_eq!( + serde_json::to_value(GroupNotificationAction::Subject { + subject: "Team".into(), + subject_owner: None, + subject_time: None, + }) + .unwrap(), + json!({ "type": "subject", "subject": "Team" }) + ); + + // Every field optional: only the discriminator survives. + assert_eq!( + serde_json::to_value(GroupNotificationAction::Locked { threshold: None }).unwrap(), + json!({ "type": "locked" }) + ); + assert_eq!( + serde_json::to_value(GroupNotificationAction::Locked { + threshold: Some("admin".into()), + }) + .unwrap(), + json!({ "type": "locked", "threshold": "admin" }) + ); + + assert_eq!( + serde_json::to_value(GroupNotificationAction::Ephemeral { + expiration: 0, + trigger: None, + }) + .unwrap(), + json!({ "type": "ephemeral", "expiration": 0 }) + ); +} + +#[test] +fn group_notification_action_skips_unit_and_skipped_fields() { + assert_eq!( + serde_json::to_value(GroupNotificationAction::Unlocked).unwrap(), + json!({ "type": "unlocked" }) + ); + + let raw = NodeBuilder::new("link").attr("link_type", "sub").build(); + assert_eq!( + serde_json::to_value(GroupNotificationAction::Link { + link_type: "sub".into(), + raw: raw.clone(), + }) + .unwrap(), + json!({ "type": "link", "link_type": "sub" }) + ); + assert_eq!( + serde_json::to_value(GroupNotificationAction::Unlink { + unlink_type: "sub".into(), + unlink_reason: None, + raw, + }) + .unwrap(), + json!({ "type": "unlink", "unlink_type": "sub" }) + ); + + // Fallback: the captured tag IS the discriminator, never an extra field. + assert_eq!( + serde_json::to_value(GroupNotificationAction::Unknown { + tag: "future_tag".into(), + }) + .unwrap(), + json!({ "type": "future_tag" }) + ); +} + +#[test] +fn group_notification_action_declares_exact_field_count() { + use serde::Serialize; + + let raw = NodeBuilder::new("link").attr("link_type", "sub").build(); + let samples = vec![ + // Mixed constant + optional, both present and absent. + GroupNotificationAction::Subject { + subject: "Team".into(), + subject_owner: Some("555000111@s.whatsapp.net".parse::().unwrap()), + subject_time: Some(1_704_067_200), + }, + GroupNotificationAction::Subject { + subject: "Team".into(), + subject_owner: None, + subject_time: Some(1_704_067_200), + }, + GroupNotificationAction::Subject { + subject: "Team".into(), + subject_owner: None, + subject_time: None, + }, + // Only-optional, only-skipped, unit and fallback variants. + GroupNotificationAction::Locked { + threshold: Some("admin".into()), + }, + GroupNotificationAction::Locked { threshold: None }, + GroupNotificationAction::Create { raw: raw.clone() }, + GroupNotificationAction::Unlink { + unlink_type: "sub".into(), + unlink_reason: None, + raw, + }, + GroupNotificationAction::Unlocked, + GroupNotificationAction::Unknown { + tag: "future_tag".into(), + }, + ]; + + for action in &samples { + action + .serialize(field_count::CheckFieldCount) + .unwrap_or_else(|error| panic!("{action:?}: {error}")); + } +} + +#[test] +fn field_count_checker_rejects_a_wrong_count() { + use serde::Serialize; + use serde::ser::SerializeStruct; + + // Guards the guard: without this, a checker that accepted anything would + // make the test above pass silently. + struct OverCounted; + + impl Serialize for OverCounted { + fn serialize(&self, serializer: S) -> Result { + let mut state = serializer.serialize_struct("OverCounted", 2)?; + state.serialize_field("only", &1u8)?; + state.end() + } + } + + let error = OverCounted + .serialize(field_count::CheckFieldCount) + .expect_err("a 2-field header with 1 field written must be rejected"); + assert_eq!(error.0, "declared 2 fields, wrote 1"); +} + +#[test] +fn usync_protocol_serializes_adjacently_and_roundtrips() { + let contact = UsyncProtocol::Contact { + addressing_mode: UsyncAddressingMode::Lid, + }; + assert_eq!( + serde_json::to_value(&contact).unwrap(), + json!({ "type": "contact", "data": { "addressing_mode": "lid" } }) + ); + + assert_eq!( + serde_json::to_value(UsyncProtocol::Status).unwrap(), + json!({ "type": "status" }) + ); + + let features = UsyncProtocol::Features(vec![UsyncFeature::Document]); + assert_eq!( + serde_json::to_value(&features).unwrap(), + json!({ "type": "feature", "data": ["document"] }) + ); + + assert_roundtrip(&[contact, UsyncProtocol::Status, features]); +} + +#[test] +fn usync_protocol_result_serializes_adjacently_and_roundtrips() { + // `#[non_exhaustive]` payloads are built from the wire form they parse from. + let full: UsyncContactResult = + serde_json::from_value(json!({ "contact_type": "in", "username": "ada", "content": "1" })) + .unwrap(); + let full = UsyncProtocolResult::Contact(UsyncOutcome::Value(full)); + assert_eq!( + serde_json::to_value(&full).unwrap(), + json!({ + "type": "contact", + "data": { + "type": "value", + "data": { "contact_type": "in", "username": "ada", "content": "1" }, + }, + }) + ); + + let sparse: UsyncContactResult = + serde_json::from_value(json!({ "contact_type": "in" })).unwrap(); + let sparse = UsyncProtocolResult::Contact(UsyncOutcome::Value(sparse)); + assert_eq!( + serde_json::to_value(&sparse).unwrap(), + json!({ + "type": "contact", + "data": { "type": "value", "data": { "contact_type": "in" } }, + }) + ); + + let empty: UsyncDevicesResult = serde_json::from_value(json!({})).unwrap(); + let empty = UsyncProtocolResult::Devices(UsyncOutcome::Value(empty)); + assert_eq!( + serde_json::to_value(&empty).unwrap(), + json!({ "type": "devices", "data": { "type": "value", "data": {} } }) + ); + + assert_roundtrip(&[full, sparse, empty]); +} + #[test] fn connect_failure_reason_serializes_as_int_and_roundtrips() { for (value, expected) in [