Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions storages/chat-store/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ impl EventHandler for ChatStoreHandler {
EventKind::ServerAck,
EventKind::UndecryptableMessage,
EventKind::HistorySync,
EventKind::PushNameUpdate,
EventKind::ContactUpdate,
EventKind::PinUpdate,
EventKind::MuteUpdate,
Expand Down Expand Up @@ -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,
Expand Down
154 changes: 141 additions & 13 deletions tools/whatspec-codegen/src/emit/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,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 `<rust>_<VARIANT>`.
/// `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 {
Expand All @@ -67,7 +100,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 `<message>` envelope.\n\
///\n\
Expand All @@ -80,7 +113,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 `<message><meta>` node.\n\
///\n\
Expand All @@ -92,7 +125,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 `<enc>` node.\n\
///\n\
Expand All @@ -108,6 +141,57 @@ pub const WANTED: &[Wanted] = &[
renames: &[],
doc: "Bits of a receipt's `<meta mode>` bitmask.",
},
Wanted {
module: "WAWebBackendJobs.flow",
name: "DecryptFailType",
rust: "DecryptFailMode",
shape: Shape::Closed(WireDefault::Wire("show")),
renames: &[],
doc: "The `decrypt-fail` attribute of an `<enc>` 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.",
},
Wanted {
module: "WAWebStatusSetupController",
name: "MediaType",
rust: "CallLinkMedia",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep call-link media independent of status media

When the status composer's MediaType changes independently—for example, by gaining a status-only kind—the next regeneration will incorrectly add that value to CallLinkMedia, even though this type is used to build and parse call-link stanzas in wacore/src/stanza/group_call.rs. WAWebStatusSetupController is not the call-link protocol, and the commit's identical-variant-set match does not establish that it owns the same wire enum; keep this type handwritten until the actual call-link source is identified, or bind that source instead.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and this is the one binding in the sweep that was actually wrong. Reverted in b8e202aCallLinkMedia goes back to hand-written.

I checked the catalog before reverting: WAWebStatusSetupController::MediaType is the only entry whose variant set is exactly {audio, video}, and there is no call-link media enum anywhere in the 403, so there is nothing correct to bind instead. Meanwhile the type builds and parses <call_link> through build_call_link_create, build_call_link_join and parse_call_link_media. So the match was two unrelated two-valued media enums agreeing by coincidence, and a status-only kind added upstream would have landed in call-link stanzas on the next sync.

The underlying mistake is worth naming, because my own PR body advertised it as a feature: matching on variant sets is how a candidate gets found, not how it gets decided. The module has to own the wire format we parse. The other five hold up under that test — MemberAddMode from the group metadata schema, MemberShareGroupHistoryMode from the history-share module, PrivacyUserAction from the privacy job, GROUP_PARTICIPANT_TYPES from the group API, and DecryptFailType from the same WAWebBackendJobs.flow that already owns EncMediaType and the <enc> node — each is used for exactly what its module governs. Only this one crossed a domain boundary.

That rule is now in the emitter's module doc rather than only here, since a comment on a rejected candidate is not where the next sweep will look.


Generated by Claude Code

shape: Shape::Closed(WireDefault::Wire("audio")),
renames: &[],
doc: "The media a call link carries.",
},
];

pub fn generate(ir: &EnumsIr) -> Result<String> {
Expand All @@ -117,7 +201,7 @@ pub fn generate(ir: &EnumsIr) -> Result<String> {
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)?),
}
}
Expand Down Expand Up @@ -174,16 +258,15 @@ fn wire_enum(wanted: &Wanted, def: &EnumDef) -> Result<String> {
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 {
Expand All @@ -201,10 +284,26 @@ fn wire_enum(wanted: &Wanted, def: &EnumDef) -> Result<String> {
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",
);
Expand Down Expand Up @@ -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]
Expand Down
74 changes: 57 additions & 17 deletions wacore/src/iq/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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)]
Expand Down Expand Up @@ -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 <invite/>; 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();
Expand Down
9 changes: 1 addition & 8 deletions wacore/src/iq/privacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 1 addition & 12 deletions wacore/src/stanza/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,7 @@ pub struct GroupNotification {
pub actions: Vec<GroupNotificationAction>,
}

/// Admin tier from `<participant type="...">`. 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)]
Expand Down
Loading
Loading