Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name>Tag`; parsers must dispatch on `<Name>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<T>`, 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.
Expand Down
351 changes: 351 additions & 0 deletions tools/whatspec-codegen/src/emit/enums.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
//! `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::{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. `bitPosition` entries are
/// shifted here so a caller never repeats the shift.
Masks { prefix: &'static str },
}

/// 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,
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 `<message>` 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 `<message><meta>` 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 `<enc>` 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 {
prefix: "RECEIPT_MODE_",
},
renames: &[],
doc: "",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];

pub fn generate(ir: &EnumsIr) -> Result<String> {
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 { prefix } => out.push_str(&masks(wanted, def, prefix)?),
}
}
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<String> {
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, prefix: &str) -> Result<String> {
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.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
);
};
let expr = if shifted {
format!("1 << {value}")
} else {
value.to_string()
};
out.push_str(&format!(
"/// `{}` of `{}`.\npub const {prefix}{}: u32 = {expr};\n",
variant.name, wanted.name, variant.name
));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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;

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 wanted = &WANTED[0];
let out = wire_enum(
wanted,
&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[1], &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[1]).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[1]).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[3], &entry, "RECEIPT_MODE_").expect("emit");
assert!(out.contains("pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 1 << 2;"));

entry.bit_position = None;
let plain = masks(&WANTED[3], &entry, "RECEIPT_MODE_").expect("emit");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
assert!(plain.contains("pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 2;"));
}
}
1 change: 1 addition & 0 deletions tools/whatspec-codegen/src/emit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

pub mod abprops;
pub mod appstate;
pub mod enums;
pub mod mex;
pub mod proto;
pub mod tokens;
Expand Down
Loading
Loading