diff --git a/AGENTS.md b/AGENTS.md index 437f40966..104d5b890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Things that look correct and are not: - **Locks.** `session_locks` serializes Signal encrypt/decrypt per protocol address; `chat_lanes` (`ChatLane::enqueue_lock` in `src/client.rs`) serializes *incoming* processing per chat. Outgoing sends are deliberately not per-chat locked — WA Web doesn't lock them either. - **Wire-tagged enums.** Every protocol enum derives `WireEnum`, and its `#[wire = ...]` attribute is the single source of truth for the wire value. Do not also derive `serde::Serialize`/`Deserialize` or add `#[serde(rename_all)]` — the derive owns both. In tagged mode it generates a sibling `Tag`; parsers must dispatch on `Tag::try_from(node.tag.as_ref())` rather than string literals, so renaming a tag stays a one-attribute change. Modes and attributes: `agent_docs/protocol_architecture.md`. - **Event payloads are a frozen API.** Sealed with `#[non_exhaustive]` + `#[derive(bon::Builder)]` and constructed via `Type::builder()…build()`; a maybe-absent field is `Option`, never an empty-string or zero sentinel. The full stability policy is the `Event` doc comment in `wacore/src/types/events.rs`. -- **Generated files are generated, not edited.** `wacore/src/iq/abprops.rs`, `wacore/src/iq/mex_operations.rs`, `wacore/appstate/src/schemas.rs`, `wacore/binary/src/tokens.json`, `waproto/src/whatsapp.proto` and `wacore/src/version/generated.rs` all come out of `cargo run -p whatspec-codegen`, together, from one pinned whatspec commit. An action or flag the protocol carries but the bundle no longer builds goes in a hand-written sibling (`wacore/appstate/src/schemas_unlisted.rs`, `props::stale`), never in the generated file. +- **Generated files are generated, not edited.** `wacore/src/iq/abprops.rs`, `wacore/src/iq/mex_operations.rs`, `wacore/appstate/src/schemas.rs`, `wacore/src/types/wire_enums.rs`, `wacore/binary/src/tokens.json`, `waproto/src/whatsapp.proto` and `wacore/src/version/generated.rs` all come out of `cargo run -p whatspec-codegen`, together, from one pinned whatspec commit. An action or flag the protocol carries but the bundle no longer builds goes in a hand-written sibling (`wacore/appstate/src/schemas_unlisted.rs`, `props::stale`), never in the generated file. `wire_enums.rs` binds only the catalog entries listed in the emitter's `WANTED`, because 88 of the 403 have a synthetic name and names repeat across modules; the variants themselves always come from the bundle. - **`whatsapp.proto` is not the whole persisted schema.** It comes from whatspec and is regenerated wholesale, so fields we persist but upstream does not declare live in `LOCAL_FIELDS` in `waproto/build.rs`, spliced into the descriptor at build time, and whole retained messages in `LOCAL_BLOCKS` in the codegen's proto emitter. Never hand-edit the `.proto` or `.desc` to add one — the next sync would drop it. - **Blocking work** — `ureq`, heavy CPU — belongs in `tokio::task::spawn_blocking`; it shares a runtime with the read loop. - **let-chains**, never nested `if let`. Clippy's `collapsible_if` is denied in CI. diff --git a/tools/whatspec-codegen/src/emit/enums.rs b/tools/whatspec-codegen/src/emit/enums.rs new file mode 100644 index 000000000..b80ab97c1 --- /dev/null +++ b/tools/whatspec-codegen/src/emit/enums.rs @@ -0,0 +1,410 @@ +//! `wacore/src/types/wire_enums.rs`: the protocol enums this repository binds +//! to Rust types, with their variants taken from the whatspec enum catalog. +//! +//! The catalog carries 403 entries and this emits a handful, which is +//! deliberate. 88 of them have a `syntheticName`, a placeholder whatspec builds +//! 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 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. + +use std::collections::BTreeSet; + +use anyhow::{Context, Result, bail, ensure}; + +use crate::ir::{EnumDef, EnumValueKind, EnumsIr, Scalar}; +use crate::naming::pascal_case; + +const HEADER: &str = "\ +//! Protocol enums generated from the whatspec enum catalog. +//! +//! Regenerate with `cargo run -p whatspec-codegen`; never edit by hand. To bind +//! another catalog entry, add it to `WANTED` in the codegen's enum emitter -- +//! the variants come from the bundle, not from this file. + +#![allow(clippy::all)] + +"; + +/// How a catalog entry is bound to Rust. +#[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, + /// The same, plus a `#[wire_fallback] Unknown(String)` arm keeping the wire + /// bytes of a value this build does not model. + Open, + /// Integer variants emitted as `pub const` masks named `_`. + /// `bitPosition` entries are shifted here so a caller never repeats the + /// shift. + Masks, +} + +/// One catalog entry this repository binds, keyed the way the catalog is: +/// module first, because the name alone is not unique. +pub struct Wanted { + pub module: &'static str, + pub name: &'static str, + /// The Rust type name, or for [`Shape::Masks`] the prefix its constants + /// share. + pub rust: &'static str, + pub shape: Shape, + /// Variant spellings the mechanical `pascal_case` gets wrong, as + /// `(wire value, Rust identifier)`. `medianotify` is one word on the wire + /// and two in English, and nothing in the bundle says so. + pub renames: &'static [(&'static str, &'static str)], + pub doc: &'static str, +} + +pub const WANTED: &[Wanted] = &[ + Wanted { + module: "WAWebHandleMsgCommon", + name: "STANZA_MSG_TYPES", + rust: "StanzaMessageType", + shape: Shape::Open, + renames: &[("medianotify", "MediaNotify")], + doc: "The `type` attribute of an incoming `` envelope.\n\ + ///\n\ + /// The official parser rejects a stanza whose `type` is absent or\n\ + /// outside this set (`unknownValue: \"reject\"`). This client keeps\n\ + /// the stanza instead, so the fallback arm holds the exact wire\n\ + /// bytes of a value it does not model.", + }, + Wanted { + module: "WAWebHandleMsgCommon", + name: "POLL_TYPES", + rust: "PollType", + shape: Shape::Closed, + renames: &[], + doc: "The `polltype` attribute of an incoming `` node.\n\ + ///\n\ + /// Closed on purpose: the attribute is `attrEnumOrNullIfUnknown`\n\ + /// upstream (`unknownValue: \"null\"`), so a value outside this set\n\ + /// is dropped rather than preserved.", + }, + Wanted { + module: "WAWebBackendJobs.flow", + name: "EncMediaType", + rust: "EncMediaType", + shape: Shape::Open, + renames: &[("livelocation", "LiveLocation")], + doc: "The `mediatype` attribute of an `` node.\n\ + ///\n\ + /// A hint about the payload the ciphertext carries, available\n\ + /// before the decryption that would reveal it. It is the sender's\n\ + /// claim and nothing checks it against the decrypted message.", + }, + Wanted { + module: "WAWebSendReceiptJobCommon", + name: "ReceiptModeBitPosition", + rust: "RECEIPT_MODE", + shape: Shape::Masks, + renames: &[], + doc: "Bits of a receipt's `` bitmask.", + }, +]; + +pub fn generate(ir: &EnumsIr) -> Result { + let mut out = super::header("protocol enums", &ir.wa_version); + out.push_str(HEADER); + + for wanted in WANTED { + let def = lookup(ir, wanted)?; + match wanted.shape { + Shape::Closed | Shape::Open => out.push_str(&wire_enum(wanted, def)?), + Shape::Masks => out.push_str(&masks(wanted, def)?), + } + } + Ok(out) +} + +/// The one entry matching `(module, name)`. Ambiguity is fatal rather than +/// first-wins: the catalog does repeat names, and silently binding the wrong +/// module's enum is the failure this key exists to prevent. +fn lookup<'a>(ir: &'a EnumsIr, wanted: &Wanted) -> Result<&'a EnumDef> { + let matches: Vec<&EnumDef> = ir + .enums + .iter() + .filter(|e| e.name == wanted.name && e.module == wanted.module) + .collect(); + match matches.as_slice() { + [one] => { + ensure!( + one.synthetic_name != Some(true), + "{}::{} has a synthetic name, which cannot be a stable Rust type identity", + wanted.module, + wanted.name + ); + Ok(one) + } + [] => bail!( + "the enum catalog has no {}::{}; it was renamed or dropped upstream", + wanted.module, + wanted.name + ), + many => bail!( + "the enum catalog has {} entries for {}::{}", + many.len(), + wanted.module, + wanted.name + ), + } +} + +fn variant_ident(wanted: &Wanted, wire: &str) -> String { + for (value, rust) in wanted.renames { + if *value == wire { + return (*rust).to_string(); + } + } + pascal_case(wire) +} + +fn wire_enum(wanted: &Wanted, def: &EnumDef) -> Result { + ensure!( + def.value_kind == EnumValueKind::String, + "{}::{} carries integers, so it cannot be a unit-string WireEnum", + wanted.module, + wanted.name + ); + + let copy = if wanted.shape == Shape::Closed { + ", Copy" + } else { + "" + }; + 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 mut used = BTreeSet::new(); + for variant in &def.variants { + let Scalar::Str(wire) = &variant.value else { + bail!( + "{}::{} variant {} does not carry a string", + wanted.module, + wanted.name, + variant.name + ); + }; + let ident = variant_ident(wanted, wire); + ensure!( + used.insert(ident.clone()), + "{}::{} maps two wire values onto the Rust variant {ident}", + wanted.module, + wanted.name + ); + out.push_str(&format!(" #[wire = {}]\n {ident},\n", rust_str(wire))); + } + + if wanted.shape == Shape::Open { + out.push_str( + " /// A value this build does not model, kept verbatim.\n #[wire_fallback]\n Unknown(String),\n", + ); + } + out.push_str("}\n\n"); + Ok(out) +} + +fn masks(wanted: &Wanted, def: &EnumDef) -> Result { + ensure!( + def.value_kind == EnumValueKind::Int, + "{}::{} carries strings, so it cannot be emitted as masks", + wanted.module, + wanted.name + ); + let shifted = def.bit_position == Some(true); + + let mut out = format!( + "// {} `{}` in `{}`. {}\n", + wanted.doc, + wanted.name, + def.module, + if shifted { + "The catalog stores bit positions; these are already shifted." + } else { + "The catalog stores the values themselves." + } + ); + for variant in &def.variants { + let Scalar::Int(value) = &variant.value else { + bail!( + "{}::{} variant {} does not carry an integer", + wanted.module, + wanted.name, + variant.name + ); + }; + // Checked here rather than left to the generated file: `1 << 32` trips + // rustc's overflow lint inside `wire_enums.rs`, which sends whoever hits + // it to the artifact instead of to the catalog entry that caused it. + let expr = if shifted { + ensure!( + (0..u32::BITS as i64).contains(value), + "{}::{} variant {} is bit position {value}, which does not fit a u32 mask", + wanted.module, + wanted.name, + variant.name + ); + format!("1 << {value}") + } else { + let value = u32::try_from(*value).with_context(|| { + format!( + "{}::{} variant {} carries {value}, which is not a u32 mask", + wanted.module, wanted.name, variant.name + ) + })?; + value.to_string() + }; + out.push_str(&format!( + "/// `{}` of `{}`.\npub const {}_{}: u32 = {expr};\n", + variant.name, wanted.name, wanted.rust, variant.name + )); + } + out.push('\n'); + Ok(out) +} + +/// A Rust string literal for a wire value. Wire values are ASCII identifiers in +/// practice, so this only has to survive a quote or a backslash appearing. +fn rust_str(s: &str) -> String { + format!("{s:?}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::EnumVariant; + + /// Entries are looked up by the identity they already carry. Indexing + /// `WANTED` by position would silently re-aim a test at another entry the + /// moment the table is reordered or grown at the top. + fn wanted(name: &str) -> &'static Wanted { + WANTED + .iter() + .find(|w| w.name == name) + .expect("WANTED holds the entry this test names") + } + + fn def(name: &str, module: &str, values: &[&str]) -> EnumDef { + EnumDef { + name: name.to_string(), + module: module.to_string(), + value_kind: EnumValueKind::String, + variants: values + .iter() + .map(|v| EnumVariant { + name: (*v).to_string(), + value: Scalar::Str((*v).to_string()), + }) + .collect(), + synthetic_name: None, + bit_position: None, + } + } + + #[test] + fn an_open_enum_gets_a_fallback_and_a_closed_one_does_not() { + let open = wanted("STANZA_MSG_TYPES"); + let out = wire_enum( + open, + &def("STANZA_MSG_TYPES", "m", &["text", "medianotify"]), + ) + .expect("emit"); + assert!(out.contains("#[wire = \"medianotify\"]\n MediaNotify,")); + assert!(out.contains("#[wire_fallback]")); + assert!(!out.contains(", Copy,")); + + let closed = + wire_enum(wanted("POLL_TYPES"), &def("POLL_TYPES", "m", &["vote"])).expect("emit"); + assert!(!closed.contains("#[wire_fallback]")); + assert!(closed.contains(", Copy,")); + } + + /// The catalog repeats names across modules, so binding by name alone would + /// pick whichever entry came first. + #[test] + fn lookup_is_keyed_by_module_and_rejects_a_missing_entry() { + let ir = EnumsIr { + wa_version: "2.0.0".to_string(), + enums: vec![def("POLL_TYPES", "SomeOtherModule", &["vote"])], + }; + let err = lookup(&ir, wanted("POLL_TYPES")).expect_err("the module does not match"); + assert!( + err.to_string() + .contains("has no WAWebHandleMsgCommon::POLL_TYPES") + ); + } + + #[test] + fn a_synthetic_name_is_refused() { + let mut entry = def("POLL_TYPES", "WAWebHandleMsgCommon", &["vote"]); + entry.synthetic_name = Some(true); + let ir = EnumsIr { + wa_version: "2.0.0".to_string(), + enums: vec![entry], + }; + let err = + lookup(&ir, wanted("POLL_TYPES")).expect_err("synthetic names are not identities"); + assert!(err.to_string().contains("synthetic name")); + } + + /// The whole point of reading `bitPosition`: position 2 has to reach Rust + /// as 4, so no caller repeats the shift. + #[test] + fn bit_positions_are_shifted_and_plain_values_are_not() { + let mut entry = EnumDef { + name: "ReceiptModeBitPosition".to_string(), + module: "m".to_string(), + value_kind: EnumValueKind::Int, + variants: vec![EnumVariant { + name: "HID_FAILED_DECRYPT".to_string(), + value: Scalar::Int(2), + }], + synthetic_name: None, + bit_position: Some(true), + }; + let out = masks(wanted("ReceiptModeBitPosition"), &entry).expect("emit"); + assert!(out.contains("pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 1 << 2;")); + + entry.bit_position = None; + let plain = masks(wanted("ReceiptModeBitPosition"), &entry).expect("emit"); + assert!(plain.contains("pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 2;")); + } + + /// A value the generated file could not compile has to stop the generator, + /// where the offending catalog entry is still in hand. `1 << 32` would + /// otherwise surface as an overflow lint inside `wire_enums.rs`. + #[test] + fn an_out_of_range_mask_stops_the_generator() { + let mut entry = EnumDef { + name: "ReceiptModeBitPosition".to_string(), + module: "m".to_string(), + value_kind: EnumValueKind::Int, + variants: vec![EnumVariant { + name: "TOO_WIDE".to_string(), + value: Scalar::Int(32), + }], + synthetic_name: None, + bit_position: Some(true), + }; + let err = masks(wanted("ReceiptModeBitPosition"), &entry).expect_err("32 is not a u32 bit"); + assert!(err.to_string().contains("does not fit a u32 mask"), "{err}"); + + entry.variants[0].value = Scalar::Int(-1); + let err = masks(wanted("ReceiptModeBitPosition"), &entry).expect_err("negative position"); + assert!(err.to_string().contains("does not fit a u32 mask"), "{err}"); + + entry.bit_position = None; + entry.variants[0].value = Scalar::Int(i64::from(u32::MAX) + 1); + let err = masks(wanted("ReceiptModeBitPosition"), &entry).expect_err("beyond u32"); + assert!(err.to_string().contains("is not a u32 mask"), "{err}"); + } +} diff --git a/tools/whatspec-codegen/src/emit/mod.rs b/tools/whatspec-codegen/src/emit/mod.rs index 9a0f3ba5d..b375d31e1 100644 --- a/tools/whatspec-codegen/src/emit/mod.rs +++ b/tools/whatspec-codegen/src/emit/mod.rs @@ -5,6 +5,7 @@ pub mod abprops; pub mod appstate; +pub mod enums; pub mod mex; pub mod proto; pub mod tokens; diff --git a/tools/whatspec-codegen/src/ir.rs b/tools/whatspec-codegen/src/ir.rs index 753b0e0f2..0b7d47715 100644 --- a/tools/whatspec-codegen/src/ir.rs +++ b/tools/whatspec-codegen/src/ir.rs @@ -11,7 +11,7 @@ use serde::Deserialize; /// The IR contract version this tool was written against. A whatspec bundle /// stamping a different major reshapes the documents below, so refuse it rather /// than deserialize a shape we no longer understand. -pub const SUPPORTED_SCHEMA_MAJOR: &str = "2"; +pub const SUPPORTED_SCHEMA_MAJOR: &str = "4"; /// Fields every domain document carries, used to prove the domains were all /// extracted from one WhatsApp build. @@ -71,6 +71,50 @@ pub struct AbPropsIr { pub configs: Vec, } +/// Whether an enum's variants carry strings or integers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EnumValueKind { + String, + Int, +} + +/// One variant: the upstream member name and the value it carries on the wire. +#[derive(Debug, Clone, Deserialize)] +pub struct EnumVariant { + pub name: String, + pub value: Scalar, +} + +/// One entry of the enum catalog. A name is only unique together with its +/// module -- `ACK` and `ENUM_LID_PN` each appear in several -- so both halves +/// are part of its identity. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnumDef { + pub name: String, + pub module: String, + pub value_kind: EnumValueKind, + pub variants: Vec, + /// `true` when whatspec invented the name by concatenating the variant + /// values, because the bundle no longer carries the upstream one. Such a + /// name changes whenever a variant is added, so it cannot be a Rust type's + /// identity. + #[serde(default)] + pub synthetic_name: Option, + /// `true` when the integer values are bit *positions* rather than the + /// values themselves, so a consumer has to shift before masking. + #[serde(default)] + pub bit_position: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnumsIr { + pub wa_version: String, + pub enums: Vec, +} + /// One element of an action's mutation index; `type` discriminates the shape. #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type")] diff --git a/tools/whatspec-codegen/src/main.rs b/tools/whatspec-codegen/src/main.rs index 0f6e069ed..5a4facca8 100644 --- a/tools/whatspec-codegen/src/main.rs +++ b/tools/whatspec-codegen/src/main.rs @@ -250,6 +250,8 @@ fn build(ir: &Ir, wa_version: &str) -> Result> { serde_json::from_str(&ir.text("abprops/index.json")?).context("parsing the abprops IR")?; let appstate: ir::AppstateIr = serde_json::from_str(&ir.text("appstate/index.json")?) .context("parsing the appstate IR")?; + let enums: ir::EnumsIr = + serde_json::from_str(&ir.text("enums/index.json")?).context("parsing the enums IR")?; let mex: ir::MexIr = serde_json::from_str(&ir.text("mex/index.json")?).context("parsing the mex IR")?; let tokens: ir::TokensIr = @@ -266,6 +268,11 @@ fn build(ir: &Ir, wa_version: &str) -> Result> { content: emit::abprops::generate(&abprops)?, rust: true, }, + Artifact { + path: "wacore/src/types/wire_enums.rs", + content: emit::enums::generate(&enums)?, + rust: true, + }, Artifact { path: "wacore/src/iq/mex_operations.rs", content: emit::mex::generate(&mex), @@ -530,6 +537,21 @@ mod tests { format!(r#"{{"schemaVersion":"{schema}","waVersion":"{wa}"}}"#) } + /// A schema version this tool accepts, derived from the gate rather than + /// spelled out, so raising the supported major does not silently turn every + /// fixture below into a test of the rejection path. + fn supported_schema() -> String { + format!("{}.0.0", ir::SUPPORTED_SCHEMA_MAJOR) + } + + /// The next major up, which the gate must refuse. + fn unsupported_schema() -> String { + let major: u32 = ir::SUPPORTED_SCHEMA_MAJOR + .parse() + .expect("the supported major is numeric"); + format!("{}.0.0", major + 1) + } + /// The proto carries its stamp as a comment rather than a JSON envelope. fn proto_stub(wa: &str) -> String { format!("syntax = \"proto2\";\n\n/// WhatsApp Version: {wa}\n") @@ -552,7 +574,7 @@ mod tests { #[test] fn stamped_version_agrees_across_domains() { assert_eq!( - stamped_version(&ir_from(&ir_files("2.0.0", "2.3000.7"))).expect("stamp"), + stamped_version(&ir_from(&ir_files(&supported_schema(), "2.3000.7"))).expect("stamp"), "2.3000.7" ); } @@ -561,12 +583,12 @@ mod tests { fn a_domain_from_another_build_stops_the_run() { // The exact drift this tool replaces: abprops and mex vendored from two // different WhatsApp releases. - let mut files = ir_files("2.0.0", "2.3000.7"); + let mut files = ir_files(&supported_schema(), "2.3000.7"); let mex = files .iter_mut() .find(|(f, _)| *f == "mex/index.json") .expect("mex"); - mex.1 = envelope("2.0.0", "2.3000.8"); + mex.1 = envelope(&supported_schema(), "2.3000.8"); let err = stamped_version(&ir_from(&files)).expect_err("mixed builds"); assert!(err.to_string().contains("mex/index.json"), "{err}"); } @@ -576,7 +598,7 @@ mod tests { // The proto has no JSON envelope, so the loop above cannot see it. It is // the one domain whose drift would otherwise reach whatsapp.proto with // every other registry agreeing. - let mut files = ir_files("2.0.0", "2.3000.7"); + let mut files = ir_files(&supported_schema(), "2.3000.7"); let proto = files .iter_mut() .find(|(f, _)| *f == PROTO_IR) @@ -588,7 +610,7 @@ mod tests { #[test] fn a_proto_with_no_version_header_stops_the_run() { - let mut files = ir_files("2.0.0", "2.3000.7"); + let mut files = ir_files(&supported_schema(), "2.3000.7"); let proto = files .iter_mut() .find(|(f, _)| *f == PROTO_IR) @@ -600,7 +622,8 @@ mod tests { #[test] fn an_unsupported_ir_schema_major_stops_the_run() { - let err = stamped_version(&ir_from(&ir_files("3.0.0", "2.3000.7"))).expect_err("schema 3"); + let err = stamped_version(&ir_from(&ir_files(&unsupported_schema(), "2.3000.7"))) + .expect_err("a newer major is not readable"); assert!(err.to_string().contains("not supported"), "{err}"); } } diff --git a/tools/whatspec-codegen/src/source.rs b/tools/whatspec-codegen/src/source.rs index 37b699205..999babe7a 100644 --- a/tools/whatspec-codegen/src/source.rs +++ b/tools/whatspec-codegen/src/source.rs @@ -20,6 +20,7 @@ pub const IR_FILES: &[&str] = &[ "manifest.json", "abprops/index.json", "appstate/index.json", + "enums/index.json", "mex/index.json", "tokens/index.json", "proto/WAProto.proto", diff --git a/tools/whatspec-codegen/tests/committed_artifacts.rs b/tools/whatspec-codegen/tests/committed_artifacts.rs index 047148b84..d070aa7b8 100644 --- a/tools/whatspec-codegen/tests/committed_artifacts.rs +++ b/tools/whatspec-codegen/tests/committed_artifacts.rs @@ -48,6 +48,7 @@ fn every_generated_rust_file_stamps_the_locked_version() { "wacore/src/iq/abprops.rs", "wacore/src/iq/mex_operations.rs", "wacore/appstate/src/schemas.rs", + "wacore/src/types/wire_enums.rs", ] { let text = read(rel); let got = stamped_in_header(&text) diff --git a/tools/whatspec-codegen/whatspec.lock.json b/tools/whatspec-codegen/whatspec.lock.json index aa035b35d..b5bd0f0aa 100644 --- a/tools/whatspec-codegen/whatspec.lock.json +++ b/tools/whatspec-codegen/whatspec.lock.json @@ -1,13 +1,14 @@ { "repo": "https://github.com/oxidezap/whatspec", - "rev": "622d256be5c796750641ac5216b978d261c18fb2", + "rev": "631f76b6379e04e9badd7bf8be8836ff11a8a707", "waVersion": "2.3000.1044659339", "files": { - "abprops/index.json": "sha256:8e935cd88d28fa8e2c87dee73405a4611266f7bdd4c0f6f62c2764656fc29c0a", - "appstate/index.json": "sha256:3ab0c56c54d0ba6c0e2e5b319737b799258f881a158deada1dcf8c6233d84eb8", - "manifest.json": "sha256:cf8db5f2fb9b8e5b7ab2cc3e5d5fb74a7a0a21f76efe946f7add27a8f6d461a1", - "mex/index.json": "sha256:e381c86874d16ec39d817fd85592b6b5e4423a68831219d5b790341993fd0b1f", + "abprops/index.json": "sha256:21f6f4a28fef514bf2f4e9006839c946d9a6b9e2df376c7170bd2e8520ae9abd", + "appstate/index.json": "sha256:655e50ecd5f3e592a9c5e5a32a98cfd40d9aa2e6a4b142f3f9203750116d6092", + "enums/index.json": "sha256:e0f9d1d71ab254f14a7ac2725db50e6f820a408900feb422bca481292e46c104", + "manifest.json": "sha256:a944729a9d7741c7a6b27ee952853f787d9ba6380c324eb9cdb8d9c76eef8601", + "mex/index.json": "sha256:7d14b1daf2169e9647adb3f18d31482bb7e0271344774ed7ae0e937392d8dbee", "proto/WAProto.proto": "sha256:021d53059e7b35d8553c97c09567da8d6aba7278c7f2510242eab686bff3647a", - "tokens/index.json": "sha256:6d45399515cdc6ec55528aa56e36ecf566f06290039e3ed37a5561a272cb89f9" + "tokens/index.json": "sha256:676ab4a648f64397e2b571293dee683bb27efc3a74dc58ef7b52390782ce960f" } } diff --git a/wacore/src/protocol/retry.rs b/wacore/src/protocol/retry.rs index a09de491a..dd9a970ca 100644 --- a/wacore/src/protocol/retry.rs +++ b/wacore/src/protocol/retry.rs @@ -163,14 +163,14 @@ pub fn should_drop_unknown_device_retry(keys_present: bool, device_known: bool) /// The `HID_FAILED_DECRYPT` bit of a receipt's `` bitmask. /// -/// WA Web's receipt mode is a set of bit *positions*, not values; this is -/// position 2 already shifted. It says the failure that prompted the receipt -/// came from an ``, so the sender knows the receiver -/// showed the user nothing for it. +/// It says the failure that prompted the receipt came from an +/// ``, so the sender knows the receiver showed the +/// user nothing for it. The catalog stores bit *positions* rather than values, +/// and the generated constant is already shifted, so nothing here repeats it. /// -/// The other two positions WA Web defines (`ORPHAN`, `NO_CHECKMARK_UX`) name -/// states this client does not model, so it never sets them. -pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 1 << 2; +/// The other two positions the catalog defines (`ORPHAN`, `NO_CHECKMARK_UX`) +/// name states this client does not model, so it never sets them. +pub use crate::types::wire_enums::RECEIPT_MODE_HID_FAILED_DECRYPT; /// The `` child of a receipt, or `None` when no bit is set. /// diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index efff0a43a..db895d5b6 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -47,131 +47,11 @@ pub enum PushPriority { HighForce, } -/// The `type` attribute of an incoming `` envelope. -/// -/// The server declares which class of payload the stanza carries before any -/// `` is decrypted. WhatsApp Web treats the attribute as required and -/// fails the parse when it is absent or carries a value outside its list; this -/// client keeps the stanza instead, so absence surfaces as `None` on -/// [`MessageInfo::type`](MessageInfo) and an unrecognized value as -/// [`Unknown`](Self::Unknown) holding the exact wire bytes. -/// -/// Says nothing about the decrypted content: it is the envelope's own claim, -/// which nothing verifies against the `Message` that comes out of the -/// ciphertext. -#[derive(Debug, Clone, PartialEq, Eq, WireEnum)] -pub enum StanzaMessageType { - #[wire = "text"] - Text, - #[wire = "media"] - Media, - #[wire = "medianotify"] - MediaNotify, - #[wire = "pay"] - Pay, - #[wire = "poll"] - Poll, - #[wire = "reaction"] - Reaction, - #[wire = "event"] - Event, - /// A value this build does not model, kept verbatim. - #[wire_fallback] - Unknown(String), -} - -/// The `polltype` attribute of an incoming `` node. -/// -/// Read only when the envelope declares [`StanzaMessageType::Poll`], mirroring -/// the official parser, which scopes the attribute to poll envelopes. Closed -/// on purpose: the attribute is `attrEnumOrNullIfUnknown` upstream, so a value -/// outside this list parses as `None` rather than being preserved. -#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] -pub enum PollType { - #[wire = "creation"] - Creation, - #[wire = "quiz_creation"] - QuizCreation, - #[wire = "vote"] - Vote, - #[wire = "result_snapshot"] - ResultSnapshot, - #[wire = "edit"] - Edit, -} - -/// The `mediatype` attribute of an `` node. -/// -/// A hint about the payload the ciphertext carries, available before the -/// decryption that would reveal it. It is the sender's claim and nothing -/// checks it against the decrypted `Message`, so it is useful for routing and -/// telemetry and not for deciding what a message is. -#[derive(Debug, Clone, PartialEq, Eq, WireEnum)] -pub enum EncMediaType { - #[wire = "image"] - Image, - #[wire = "video"] - Video, - #[wire = "ptv"] - Ptv, - #[wire = "audio"] - Audio, - #[wire = "ptt"] - Ptt, - #[wire = "location"] - Location, - #[wire = "vcard"] - Vcard, - #[wire = "document"] - Document, - #[wire = "url"] - Url, - #[wire = "call"] - Call, - #[wire = "gif"] - Gif, - #[wire = "future"] - Future, - #[wire = "contact_array"] - ContactArray, - #[wire = "livelocation"] - LiveLocation, - #[wire = "profile_pic"] - ProfilePic, - #[wire = "sticker"] - Sticker, - #[wire = "sticker_pack"] - StickerPack, - #[wire = "hsm"] - Hsm, - #[wire = "product_image"] - ProductImage, - #[wire = "template"] - Template, - #[wire = "md_app_state"] - MdAppState, - #[wire = "md_history_sync"] - MdHistorySync, - #[wire = "list"] - List, - #[wire = "list_response"] - ListResponse, - #[wire = "button"] - Button, - #[wire = "button_response"] - ButtonResponse, - #[wire = "order"] - Order, - #[wire = "product"] - Product, - #[wire = "native_flow_response"] - NativeFlowResponse, - #[wire = "group_history"] - GroupHistory, - /// A value this build does not model, kept verbatim. - #[wire_fallback] - Unknown(String), -} +// The wire vocabulary these three enums carry is generated from the whatspec +// enum catalog, so a variant added upstream arrives on the next sync instead of +// being noticed by hand. Re-exported here because this is where the types that +// use them live, and moving the path would break consumers for no gain. +pub use crate::types::wire_enums::{EncMediaType, PollType, StanzaMessageType}; /// Whether an envelope's declared type agrees with the server's request to /// hide decryption failures for it. diff --git a/wacore/src/types/mod.rs b/wacore/src/types/mod.rs index 40eb3bf54..53077654e 100644 --- a/wacore/src/types/mod.rs +++ b/wacore/src/types/mod.rs @@ -7,6 +7,7 @@ pub mod message; pub mod presence; pub mod spam_report; pub mod user; +pub mod wire_enums; pub use lid_pn::{LearningSource, LidPnEntry}; pub use spam_report::{SpamFlow, SpamReportRequest, SpamReportResult, build_spam_list_node}; diff --git a/wacore/src/types/wire_enums.rs b/wacore/src/types/wire_enums.rs new file mode 100644 index 000000000..98ac6be9c --- /dev/null +++ b/wacore/src/types/wire_enums.rs @@ -0,0 +1,141 @@ +//! Auto-generated protocol enums (WhatsApp 2.3000.1044659339). DO NOT EDIT. +//! +//! Protocol enums generated from the whatspec enum catalog. +//! +//! Regenerate with `cargo run -p whatspec-codegen`; never edit by hand. To bind +//! another catalog entry, add it to `WANTED` in the codegen's enum emitter -- +//! the variants come from the bundle, not from this file. + +#![allow(clippy::all)] + +/// The `type` attribute of an incoming `` envelope. +/// +/// The official parser rejects a stanza whose `type` is absent or +/// outside this set (`unknownValue: "reject"`). This client keeps +/// the stanza instead, so the fallback arm holds the exact wire +/// bytes of a value it does not model. +/// +/// Generated from `STANZA_MSG_TYPES` in `WAWebHandleMsgCommon`. +#[derive(Debug, Clone, PartialEq, Eq, crate::WireEnum)] +pub enum StanzaMessageType { + #[wire = "text"] + Text, + #[wire = "media"] + Media, + #[wire = "medianotify"] + MediaNotify, + #[wire = "pay"] + Pay, + #[wire = "poll"] + Poll, + #[wire = "reaction"] + Reaction, + #[wire = "event"] + Event, + /// A value this build does not model, kept verbatim. + #[wire_fallback] + Unknown(String), +} + +/// The `polltype` attribute of an incoming `` node. +/// +/// Closed on purpose: the attribute is `attrEnumOrNullIfUnknown` +/// upstream (`unknownValue: "null"`), so a value outside this set +/// is dropped rather than preserved. +/// +/// Generated from `POLL_TYPES` in `WAWebHandleMsgCommon`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] +pub enum PollType { + #[wire = "creation"] + Creation, + #[wire = "quiz_creation"] + QuizCreation, + #[wire = "vote"] + Vote, + #[wire = "result_snapshot"] + ResultSnapshot, + #[wire = "edit"] + Edit, +} + +/// The `mediatype` attribute of an `` node. +/// +/// A hint about the payload the ciphertext carries, available +/// before the decryption that would reveal it. It is the sender's +/// claim and nothing checks it against the decrypted message. +/// +/// Generated from `EncMediaType` in `WAWebBackendJobs.flow`. +#[derive(Debug, Clone, PartialEq, Eq, crate::WireEnum)] +pub enum EncMediaType { + #[wire = "image"] + Image, + #[wire = "video"] + Video, + #[wire = "ptv"] + Ptv, + #[wire = "audio"] + Audio, + #[wire = "ptt"] + Ptt, + #[wire = "location"] + Location, + #[wire = "vcard"] + Vcard, + #[wire = "document"] + Document, + #[wire = "url"] + Url, + #[wire = "call"] + Call, + #[wire = "gif"] + Gif, + #[wire = "future"] + Future, + #[wire = "contact_array"] + ContactArray, + #[wire = "livelocation"] + LiveLocation, + #[wire = "profile_pic"] + ProfilePic, + #[wire = "sticker"] + Sticker, + #[wire = "sticker_pack"] + StickerPack, + #[wire = "hsm"] + Hsm, + #[wire = "product_image"] + ProductImage, + #[wire = "template"] + Template, + #[wire = "md_app_state"] + MdAppState, + #[wire = "md_history_sync"] + MdHistorySync, + #[wire = "list"] + List, + #[wire = "list_response"] + ListResponse, + #[wire = "button"] + Button, + #[wire = "button_response"] + ButtonResponse, + #[wire = "order"] + Order, + #[wire = "product"] + Product, + #[wire = "native_flow_response"] + NativeFlowResponse, + #[wire = "group_history"] + GroupHistory, + /// A value this build does not model, kept verbatim. + #[wire_fallback] + Unknown(String), +} + +// Bits of a receipt's `` bitmask. `ReceiptModeBitPosition` in `WAWebSendReceiptJobCommon`. The catalog stores bit positions; these are already shifted. +/// `ORPHAN` of `ReceiptModeBitPosition`. +pub const RECEIPT_MODE_ORPHAN: u32 = 1 << 0; +/// `NO_CHECKMARK_UX` of `ReceiptModeBitPosition`. +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;