diff --git a/storages/chat-store/src/store.rs b/storages/chat-store/src/store.rs index cbc333a0d..e7f1f43be 100644 --- a/storages/chat-store/src/store.rs +++ b/storages/chat-store/src/store.rs @@ -127,7 +127,6 @@ impl EventHandler for ChatStoreHandler { EventKind::ServerAck, EventKind::UndecryptableMessage, EventKind::HistorySync, - EventKind::PushNameUpdate, EventKind::ContactUpdate, EventKind::PinUpdate, EventKind::MuteUpdate, @@ -978,16 +977,6 @@ fn apply_event( Ok(()) } Event::HistorySync(lazy) => apply_history_sync(conn, device_id, lazy, cs), - Event::PushNameUpdate(update) => { - upsert_contact_push_name( - conn, - device_id, - &update.jid.to_string(), - &update.new_push_name, - )?; - cs.contacts = true; - Ok(()) - } Event::ContactUpdate(update) => { upsert_contact_names( conn, diff --git a/tools/whatspec-codegen/src/emit/enums.rs b/tools/whatspec-codegen/src/emit/enums.rs index b80ab97c1..6f7ee6641 100644 --- a/tools/whatspec-codegen/src/emit/enums.rs +++ b/tools/whatspec-codegen/src/emit/enums.rs @@ -6,8 +6,16 @@ //! by concatenating the variant values, so adding one variant upstream renames //! the entry; names also repeat across modules (`ACK`, `ENUM_LID_PN`, //! `EventType`), and 17 are proto-nested names `waproto` already generates from -//! the `.proto`. None of those can carry a stable Rust type identity. So the -//! split is: [`WANTED`] binds a name, a shape and any variant spellings, and +//! the `.proto`. None of those can carry a stable Rust type identity. +//! +//! What makes an entry bindable is that its module owns the wire format we +//! parse, not that its variants match ours. Two enums can agree on every value +//! and still be unrelated -- the catalog's only `audio`/`video` pair belongs to +//! the status composer, and binding it to the call-link media type would let a +//! kind added for composing a status arrive in ``. A variant set is +//! how a candidate is found; the module is what decides. +//! +//! So the split is: [`WANTED`] binds a name, a shape and any variant spellings, and //! the IR owns everything that can drift -- which variants exist, what they //! carry, and whether integers are bit positions. A variant added upstream //! lands here on the next sync, and `--check` fails if the tree disagrees. @@ -30,22 +38,55 @@ const HEADER: &str = "\ "; +/// Which variant carries `#[wire_default]`. +/// +/// Stated per enum rather than inferred, because `WireEnum` emits `Default` +/// whether or not we ask for one and falls back to the variant declared first. +/// Variant order here is the catalog's, so inferring would hand upstream the +/// power to change a default by reordering: `DecryptFailType` is listed +/// `hide, show` and would have flipped this client's `Show` to `Hide` with no +/// compile error and no failing test. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WireDefault { + /// The protocol's default for this attribute, named by its wire value. + /// Checked against the catalog, so a value that is renamed or dropped + /// upstream fails generation rather than moving the default. + Wire(&'static str), + /// The protocol defines no default. `Default` still exists, since the + /// derive emits it unconditionally, and resolves to whichever variant the + /// catalog happens to list first; no meaning may be read into that value. + Unspecified, +} + /// How a catalog entry is bound to Rust. +/// +/// The default rides on the shape rather than sitting beside it so that the +/// combinations that do not exist cannot be written down: an enum always +/// answers the question, and a mask set is never asked it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Shape { /// A `WireEnum` over the variant values, closed: a wire value outside the /// set is not representable. Mirrors an `attrEnumOrNullIfUnknown` field, /// where the official parser nulls what it does not recognize. - Closed, + Closed(WireDefault), /// The same, plus a `#[wire_fallback] Unknown(String)` arm keeping the wire /// bytes of a value this build does not model. - Open, + Open(WireDefault), /// Integer variants emitted as `pub const` masks named `_`. /// `bitPosition` entries are shifted here so a caller never repeats the - /// shift. + /// shift. Constants have no `Default` to pin. Masks, } +impl Shape { + fn default_value(self) -> Option<&'static str> { + match self { + Self::Closed(WireDefault::Wire(v)) | Self::Open(WireDefault::Wire(v)) => Some(v), + _ => None, + } + } +} + /// One catalog entry this repository binds, keyed the way the catalog is: /// module first, because the name alone is not unique. pub struct Wanted { @@ -67,7 +108,7 @@ pub const WANTED: &[Wanted] = &[ module: "WAWebHandleMsgCommon", name: "STANZA_MSG_TYPES", rust: "StanzaMessageType", - shape: Shape::Open, + shape: Shape::Open(WireDefault::Unspecified), renames: &[("medianotify", "MediaNotify")], doc: "The `type` attribute of an incoming `` envelope.\n\ ///\n\ @@ -80,7 +121,7 @@ pub const WANTED: &[Wanted] = &[ module: "WAWebHandleMsgCommon", name: "POLL_TYPES", rust: "PollType", - shape: Shape::Closed, + shape: Shape::Closed(WireDefault::Unspecified), renames: &[], doc: "The `polltype` attribute of an incoming `` node.\n\ ///\n\ @@ -92,7 +133,7 @@ pub const WANTED: &[Wanted] = &[ module: "WAWebBackendJobs.flow", name: "EncMediaType", rust: "EncMediaType", - shape: Shape::Open, + shape: Shape::Open(WireDefault::Unspecified), renames: &[("livelocation", "LiveLocation")], doc: "The `mediatype` attribute of an `` node.\n\ ///\n\ @@ -108,6 +149,49 @@ pub const WANTED: &[Wanted] = &[ renames: &[], doc: "Bits of a receipt's `` bitmask.", }, + Wanted { + module: "WAWebBackendJobs.flow", + name: "DecryptFailType", + rust: "DecryptFailMode", + shape: Shape::Closed(WireDefault::Wire("show")), + renames: &[], + doc: "The `decrypt-fail` attribute of an `` node.\n\ + ///\n\ + /// `Hide` is the server asking that a failure to decrypt this\n\ + /// stanza not be surfaced to the user.", + }, + Wanted { + module: "WAWebSchemaGroupMetadata", + name: "MemberAddMode", + rust: "MemberAddMode", + shape: Shape::Closed(WireDefault::Wire("admin_add")), + renames: &[], + doc: "Who may add participants to a group.", + }, + Wanted { + module: "WAWebGroupHistoryShareMode", + name: "MemberShareGroupHistoryMode", + rust: "MemberShareHistoryMode", + shape: Shape::Closed(WireDefault::Wire("admin_share")), + renames: &[], + doc: "Who may share a group's history with a new participant.", + }, + Wanted { + module: "WAWebSetPrivacyJob", + name: "PrivacyUserAction", + rust: "DisallowedListAction", + shape: Shape::Closed(WireDefault::Wire("add")), + renames: &[], + doc: "Whether a privacy disallowed-list entry is being added or removed.", + }, + Wanted { + module: "WAWebGroupApiConst", + name: "GROUP_PARTICIPANT_TYPES", + rust: "GroupParticipantType", + shape: Shape::Closed(WireDefault::Wire("participant")), + renames: &[("superadmin", "SuperAdmin")], + doc: "A participant's role in a group.", + }, ]; pub fn generate(ir: &EnumsIr) -> Result { @@ -117,7 +201,7 @@ pub fn generate(ir: &EnumsIr) -> Result { for wanted in WANTED { let def = lookup(ir, wanted)?; match wanted.shape { - Shape::Closed | Shape::Open => out.push_str(&wire_enum(wanted, def)?), + Shape::Closed(_) | Shape::Open(_) => out.push_str(&wire_enum(wanted, def)?), Shape::Masks => out.push_str(&masks(wanted, def)?), } } @@ -174,16 +258,15 @@ fn wire_enum(wanted: &Wanted, def: &EnumDef) -> Result { wanted.name ); - let copy = if wanted.shape == Shape::Closed { - ", Copy" - } else { - "" - }; + let open = matches!(wanted.shape, Shape::Open(_)); + let copy = if open { "" } else { ", Copy" }; let mut out = format!( "/// {}\n///\n/// Generated from `{}` in `{}`.\n#[derive(Debug, Clone{copy}, PartialEq, Eq, crate::WireEnum)]\npub enum {} {{\n", wanted.doc, wanted.name, def.module, wanted.rust ); + let declared_default = wanted.shape.default_value(); + let mut marked = 0usize; let mut used = BTreeSet::new(); for variant in &def.variants { let Scalar::Str(wire) = &variant.value else { @@ -201,10 +284,26 @@ fn wire_enum(wanted: &Wanted, def: &EnumDef) -> Result { wanted.module, wanted.name ); + if declared_default == Some(wire.as_str()) { + out.push_str(" #[wire_default]\n"); + marked += 1; + } out.push_str(&format!(" #[wire = {}]\n {ident},\n", rust_str(wire))); } - if wanted.shape == Shape::Open { + // Checked against what was emitted rather than against the catalog, so the + // assertion covers the write as well as the declaration: a default the + // catalog dropped and a default the loop failed to place both land here. + let expected = usize::from(declared_default.is_some()); + ensure!( + marked == expected, + "{}::{} expected {expected} variant(s) to carry #[wire_default] and {marked} did; \ + the declared default {declared_default:?} is not a wire value this catalog entry carries", + wanted.module, + wanted.name + ); + + if open { out.push_str( " /// A value this build does not model, kept verbatim.\n #[wire_fallback]\n Unknown(String),\n", ); @@ -328,6 +427,35 @@ mod tests { assert!(closed.contains(", Copy,")); } + /// The failure this guards is silent by nature: the derive always produces + /// a `Default`, so a declared default the catalog stopped carrying would + /// otherwise leave the first variant standing in for it. + #[test] + fn a_declared_default_the_catalog_dropped_stops_the_generator() { + let entry = wanted("DecryptFailType"); + assert_eq!(entry.shape.default_value(), Some("show")); + + let ok = wire_enum(entry, &def("DecryptFailType", "m", &["hide", "show"])).expect("emit"); + // Placed on the declared value, not on whichever the catalog lists first. + assert!(ok.contains("#[wire_default]\n #[wire = \"show\"]")); + + let err = wire_enum(entry, &def("DecryptFailType", "m", &["hide", "suppress"])) + .expect_err("the declared default is gone"); + assert!(err.to_string().contains("#[wire_default]"), "{err}"); + } + + /// The mirror case: an enum that declares no default must not pick one up + /// from a variant that happens to share the spelling of another's. + #[test] + fn an_unspecified_default_marks_nothing() { + let out = wire_enum( + wanted("POLL_TYPES"), + &def("POLL_TYPES", "m", &["vote", "show", "add"]), + ) + .expect("emit"); + assert!(!out.contains("#[wire_default]")); + } + /// The catalog repeats names across modules, so binding by name alone would /// pick whichever entry came first. #[test] diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index 43b67ef9c..25913241e 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -52,14 +52,7 @@ pub enum MemberLinkMode { AllMemberLink, } -/// Member add mode for who can add participants. -#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] -pub enum MemberAddMode { - #[wire = "admin_add"] - AdminAdd, - #[wire = "all_member_add"] - AllMemberAdd, -} +pub use crate::types::wire_enums::MemberAddMode; /// Membership approval mode for join requests. #[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] @@ -71,15 +64,7 @@ pub enum MembershipApprovalMode { On, } -/// Who can share message history with new members. -#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] -pub enum MemberShareHistoryMode { - #[wire_default] - #[wire = "admin_share"] - AdminShare, - #[wire = "all_member_share"] - AllMemberShare, -} +pub use crate::types::wire_enums::MemberShareHistoryMode; /// Review state for an appeal on a suspended group. #[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] @@ -3690,6 +3675,61 @@ mod tests { use super::*; use crate::request::InfoQueryType; + /// Whether a `w:g2` request is addressed to the group server or to one + /// group's own JID, checked against what the whatspec IR resolves for each + /// upstream builder. + /// + /// The IR only started answering this in schema 4: before it, every `w:g2` + /// stanza reported the namespace's base target of `s.whatsapp.net`, so a + /// request sent to the wrong one of the two looked exactly like a request + /// sent to the right one. It now splits them (26 `group_jid`, 6 `g.us`), + /// and a mistake here is otherwise silent -- the server ignores the IQ and + /// the caller waits out its timeout. + /// + /// This is a regression lock, not a derivation: the expectations below were + /// read off the IR by hand, since the IR is not available at test time. + #[test] + fn group_requests_are_addressed_the_way_the_ir_resolves_them() { + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + let server = Jid::new("", Server::Group); + + // `g.us`: the group server answers these, not any one group. + assert_eq!(LeaveGroupIq::new(&group).build_iq().to, server); + assert_eq!(GroupParticipatingIq::new().build_iq().to, server); + assert_eq!( + BatchGetGroupInfoIq::new(std::slice::from_ref(&group)) + .build_iq() + .to, + server + ); + assert_eq!(GetGroupInviteInfoIq::new("ABC123").build_iq().to, server); + + // `group_jid`: addressed to the one group they act on. + assert_eq!( + SetGroupSubjectIq::new(&group, GroupSubject::new("x").unwrap()) + .build_iq() + .to, + group + ); + assert_eq!( + GetGroupInviteLinkIq::new(&group, false).build_iq().to, + group + ); + assert_eq!( + GetGroupInviteLinkIq::new(&group, true).build_iq().to, + group, + "the reset overload the IR resolves to group_jid is the one with an \ + empty ; the g.us overload carries a code and is not this" + ); + assert_eq!( + ReportGroupMessagesIq::new(&group, &["M1".to_string()]) + .build_iq() + .to, + group + ); + assert_eq!(GetReportedGroupMessagesIq::new(&group).build_iq().to, group); + } + #[test] fn report_messages_iq_matches_the_group_report_shape() { let jid: Jid = "120363000000000001@g.us".parse().unwrap(); diff --git a/wacore/src/iq/privacy.rs b/wacore/src/iq/privacy.rs index 2f69cc411..77c5d7ab4 100644 --- a/wacore/src/iq/privacy.rs +++ b/wacore/src/iq/privacy.rs @@ -220,14 +220,7 @@ impl IqSpec for PrivacySettingsSpec { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] -pub enum DisallowedListAction { - #[wire_default] - #[wire = "add"] - Add, - #[wire = "remove"] - Remove, -} +pub use crate::types::wire_enums::DisallowedListAction; #[derive(Debug, Clone)] pub struct DisallowedListUserEntry { diff --git a/wacore/src/stanza/groups.rs b/wacore/src/stanza/groups.rs index c7b607ab6..ac140387d 100644 --- a/wacore/src/stanza/groups.rs +++ b/wacore/src/stanza/groups.rs @@ -61,18 +61,7 @@ pub struct GroupNotification { pub actions: Vec, } -/// Admin tier from ``. Mirrors -/// `GROUP_PARTICIPANT_TYPES` in `WAWebGroupApiConst`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] -pub enum GroupParticipantType { - #[wire_default] - #[wire = "participant"] - Participant, - #[wire = "admin"] - Admin, - #[wire = "superadmin"] - SuperAdmin, -} +pub use crate::types::wire_enums::GroupParticipantType; /// Delivery state for history shared with a newly joined participant. #[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 7f481f306..27117467a 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -241,7 +241,11 @@ pub enum EventKind { IncomingCall, MissedCall, CallEndedElsewhere, - PushNameUpdate, + /// Retired: the payload promised an old-name/new-name comparison this + /// client has no contact store to make, and nothing ever dispatched it. + /// The slot stays because the discriminant is an `EventInterest` bit index + /// a consumer persists, so removing it would re-point every mask past it. + RetiredPushNameUpdate, SelfPushNameUpdated, PinUpdate, MuteUpdate, @@ -630,6 +634,16 @@ impl CoreEventBus { } } +/// Payload of the retired [`Event::RetiredPushNameUpdate`], kept only so that +/// variant can keep its position in an index-based `Serialize` format. +/// +/// Deliberately empty: the fields it used to carry named a comparison this +/// repository cannot make, and leaving them would keep promising it. Nothing +/// constructs this and nothing dispatches the variant it fills. +#[derive(Debug, Clone, Serialize, bon::Builder)] +#[non_exhaustive] +pub struct RetiredPushNameUpdate {} + #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct SelfPushNameUpdated { @@ -921,7 +935,17 @@ pub enum Event { /// Rejected call-log outcomes (``). CallEndedElsewhere(CallEndedElsewhere), - PushNameUpdate(PushNameUpdate), + /// Retired: nothing dispatches this, and nothing can. The payload promised + /// an old-name/new-name comparison, and this repository holds no contact + /// store to source the previous name from. Read the current name from + /// [`crate::types::message::MessageInfo::push_name`] instead. + /// + /// The variant stays because its *position* is load-bearing, for the same + /// reason new variants are appended rather than inserted: an index-based + /// `Serialize` format keys variants by position, so dropping one renumbers + /// every variant after it and changes how already-stored events decode. + RetiredPushNameUpdate(RetiredPushNameUpdate), + SelfPushNameUpdated(SelfPushNameUpdated), PinUpdate(PinUpdate), MuteUpdate(MuteUpdate), @@ -1119,7 +1143,7 @@ impl Event { Event::IncomingCall(_) => EventKind::IncomingCall, Event::MissedCall(_) => EventKind::MissedCall, Event::CallEndedElsewhere(_) => EventKind::CallEndedElsewhere, - Event::PushNameUpdate(_) => EventKind::PushNameUpdate, + Event::RetiredPushNameUpdate(_) => EventKind::RetiredPushNameUpdate, Event::SelfPushNameUpdated(_) => EventKind::SelfPushNameUpdated, Event::AppStateSyncFailed(_) => EventKind::AppStateSyncFailed, Event::PinUpdate(_) => EventKind::PinUpdate, @@ -1668,13 +1692,7 @@ pub struct DirtyState { pub timestamp: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] -pub enum DecryptFailMode { - #[wire = "show"] - Show, - #[wire = "hide"] - Hide, -} +pub use crate::types::wire_enums::DecryptFailMode; #[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] pub enum UnavailableType { @@ -2207,16 +2225,6 @@ pub struct ContactUpdate { pub from_full_sync: bool, } -#[derive(Debug, Clone, Serialize, bon::Builder)] -#[non_exhaustive] -pub struct PushNameUpdate { - /// The contact who changed their push name. - pub jid: Jid, - pub message: Box, - pub old_push_name: String, - pub new_push_name: String, -} - #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PinUpdate { diff --git a/wacore/src/types/group_call.rs b/wacore/src/types/group_call.rs index 13070abe3..b0698af47 100644 --- a/wacore/src/types/group_call.rs +++ b/wacore/src/types/group_call.rs @@ -15,6 +15,12 @@ pub const GROUP_CALL_MAX_PARTICIPANTS: usize = 32; pub const GROUP_CALL_MAX_REMOTE_PARTICIPANTS: usize = GROUP_CALL_MAX_PARTICIPANTS - 1; /// Audio/video mode of a reusable call link. +/// +/// Hand-written rather than generated. The enum catalog holds exactly one +/// `audio`/`video` pair, `MediaType` in the status composer's module, and two +/// two-valued media enums agreeing on their values is not evidence that one +/// owns the other's wire format. Binding it would let a kind added for status +/// composition arrive here, in the type that builds and parses ``. #[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] pub enum CallLinkMedia { #[wire = "audio"] diff --git a/wacore/src/types/user.rs b/wacore/src/types/user.rs index f614600cc..efb9ac85e 100644 --- a/wacore/src/types/user.rs +++ b/wacore/src/types/user.rs @@ -1,4 +1,3 @@ -use chrono::{DateTime, Utc}; use waproto::whatsapp as wa; #[derive(Debug, Clone)] @@ -6,11 +5,3 @@ pub struct VerifiedName { pub certificate: Box, pub details: Box, } - -#[derive(Debug, Clone, Default)] -pub struct LocalChatSettings { - pub found: bool, - pub muted_until: Option>, - pub pinned: bool, - pub archived: bool, -} diff --git a/wacore/src/types/wire_enums.rs b/wacore/src/types/wire_enums.rs index 98ac6be9c..0e1b9e042 100644 --- a/wacore/src/types/wire_enums.rs +++ b/wacore/src/types/wire_enums.rs @@ -139,3 +139,68 @@ pub const RECEIPT_MODE_ORPHAN: u32 = 1 << 0; pub const RECEIPT_MODE_NO_CHECKMARK_UX: u32 = 1 << 1; /// `HID_FAILED_DECRYPT` of `ReceiptModeBitPosition`. pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 1 << 2; + +/// The `decrypt-fail` attribute of an `` node. +/// +/// `Hide` is the server asking that a failure to decrypt this +/// stanza not be surfaced to the user. +/// +/// Generated from `DecryptFailType` in `WAWebBackendJobs.flow`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] +pub enum DecryptFailMode { + #[wire = "hide"] + Hide, + #[wire_default] + #[wire = "show"] + Show, +} + +/// Who may add participants to a group. +/// +/// Generated from `MemberAddMode` in `WAWebSchemaGroupMetadata`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] +pub enum MemberAddMode { + #[wire_default] + #[wire = "admin_add"] + AdminAdd, + #[wire = "all_member_add"] + AllMemberAdd, +} + +/// Who may share a group's history with a new participant. +/// +/// Generated from `MemberShareGroupHistoryMode` in `WAWebGroupHistoryShareMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] +pub enum MemberShareHistoryMode { + #[wire_default] + #[wire = "admin_share"] + AdminShare, + #[wire = "all_member_share"] + AllMemberShare, +} + +/// Whether a privacy disallowed-list entry is being added or removed. +/// +/// Generated from `PrivacyUserAction` in `WAWebSetPrivacyJob`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] +pub enum DisallowedListAction { + #[wire_default] + #[wire = "add"] + Add, + #[wire = "remove"] + Remove, +} + +/// A participant's role in a group. +/// +/// Generated from `GROUP_PARTICIPANT_TYPES` in `WAWebGroupApiConst`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] +pub enum GroupParticipantType { + #[wire = "superadmin"] + SuperAdmin, + #[wire = "admin"] + Admin, + #[wire_default] + #[wire = "participant"] + Participant, +}